expressjs -> nextjs
This commit is contained in:
80
nextjs/app/%5Fshare/[filename]/route.js
Normal file
80
nextjs/app/%5Fshare/[filename]/route.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { shareDir } from '@/src/lib/config.js';
|
||||
import { get, logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import { getRequestMeta } from '@/src/lib/security.js';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function safeFilename(value) {
|
||||
const fileName = String(value || '');
|
||||
if (!fileName) {
|
||||
return '';
|
||||
}
|
||||
if (fileName.includes('/') || fileName.includes('\\') || fileName.includes('..')) {
|
||||
return '';
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
function contentDisposition(filename) {
|
||||
const fallback = String(filename || 'download')
|
||||
.replace(/[\r\n]/g, ' ')
|
||||
.replace(/[\\"]/g, '_')
|
||||
.replace(/[^ -~]/g, '_');
|
||||
const encoded = encodeURIComponent(filename || 'download');
|
||||
return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
await runCleanupIfNeeded();
|
||||
|
||||
const resolvedParams = await params;
|
||||
const fileName = safeFilename(resolvedParams.filename);
|
||||
if (!fileName) {
|
||||
return new NextResponse('Ungültiger Dateiname', { status: 400 });
|
||||
}
|
||||
|
||||
const row = await get('SELECT id, original_name, stored_path FROM uploads WHERE stored_name = ?', [fileName]);
|
||||
|
||||
let filePath;
|
||||
let downloadName;
|
||||
|
||||
if (row) {
|
||||
filePath = row.stored_path;
|
||||
downloadName = row.original_name || fileName;
|
||||
|
||||
const requestMeta = await getRequestMeta();
|
||||
run('UPDATE uploads SET downloads = downloads + 1 WHERE id = ?', [row.id]).catch(() => undefined);
|
||||
logEvent('download', null, { name: fileName, original: downloadName }, requestMeta).catch(() => undefined);
|
||||
} else {
|
||||
filePath = path.join(shareDir, fileName);
|
||||
downloadName = fileName;
|
||||
}
|
||||
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = await fs.promises.stat(filePath);
|
||||
} catch {
|
||||
return new NextResponse('Datei nicht gefunden', { status: 404 });
|
||||
}
|
||||
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
const webStream = Readable.toWeb(fileStream);
|
||||
|
||||
return new NextResponse(webStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': String(fileStat.size),
|
||||
'Content-Disposition': contentDisposition(downloadName),
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
353
nextjs/app/globals.css
Normal file
353
nextjs/app/globals.css
Normal file
@@ -0,0 +1,353 @@
|
||||
:root {
|
||||
--font-body: 'Manrope', sans-serif;
|
||||
--font-heading: 'Space Grotesk', sans-serif;
|
||||
--bg-main: #eef4f7;
|
||||
--bg-accent: #d9ece4;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f7fafc;
|
||||
--text-main: #10243a;
|
||||
--text-muted: #566b81;
|
||||
--line: #d6e0ea;
|
||||
--primary: #0f766e;
|
||||
--primary-hover: #0e635c;
|
||||
--primary-soft: #d8f3ea;
|
||||
--danger: #b42318;
|
||||
--danger-soft: #fee4e2;
|
||||
--radius: 16px;
|
||||
--radius-sm: 10px;
|
||||
--shadow: 0 16px 36px -24px rgb(16 36 58 / 0.45);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-main);
|
||||
background:
|
||||
radial-gradient(circle at 15% 20%, rgb(255 255 255 / 0.95) 0%, rgb(255 255 255 / 0.8) 35%, transparent 65%),
|
||||
radial-gradient(circle at 85% 0%, rgb(217 236 228 / 0.75) 0%, transparent 45%),
|
||||
linear-gradient(140deg, var(--bg-main), #f6f9fc 60%, #e8f2f7);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: min(1200px, 100% - 2.5rem);
|
||||
margin: 0 auto;
|
||||
padding: 2.2rem 0 3.5rem;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
animation: page-enter 240ms ease-out both;
|
||||
}
|
||||
|
||||
.page-shell.narrow {
|
||||
width: min(560px, 100% - 2rem);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: linear-gradient(180deg, var(--surface), var(--surface-soft));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.panel.centered {
|
||||
text-align: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status {
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #ddf5ea;
|
||||
border-color: #b7e8d3;
|
||||
color: #10513f;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: var(--danger-soft);
|
||||
border-color: #f8b4af;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--text-main);
|
||||
font: inherit;
|
||||
padding: 0.52rem 0.62rem;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgb(15 118 110 / 0.16);
|
||||
}
|
||||
|
||||
.input.small {
|
||||
padding: 0.38rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0.48rem 0.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 140ms ease, transform 120ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: #fff;
|
||||
border-color: var(--line);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn.secondary:hover {
|
||||
background: #f4f8fb;
|
||||
border-color: #c3d4e2;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-color: #f7b0a8;
|
||||
}
|
||||
|
||||
.btn.danger:hover {
|
||||
background: #fccfc9;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text-main);
|
||||
text-decoration: none;
|
||||
padding: 0.3rem 0.58rem;
|
||||
}
|
||||
|
||||
.chip.primary {
|
||||
background: var(--primary-soft);
|
||||
border-color: #9ddac6;
|
||||
color: #0c4f4a;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
}
|
||||
|
||||
.metric {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
padding: 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 680px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
padding: 0.62rem 0.66rem;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
thead th {
|
||||
border-top: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
background: #f8fbfd;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f7fbfc;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stack-actions {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inline-form.stacked {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.breadcrumbs a {
|
||||
color: #0f5f84;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumbs a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@keyframes page-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(7px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-shell {
|
||||
width: min(100% - 1.4rem, 1200px);
|
||||
padding-top: 1.3rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 620px;
|
||||
}
|
||||
}
|
||||
28
nextjs/app/layout.js
Normal file
28
nextjs/app/layout.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Manrope, Space_Grotesk } from 'next/font/google';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
const bodyFont = Manrope({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const headingFont = Space_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-heading',
|
||||
weight: ['500', '700'],
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dateiverwaltung',
|
||||
description: 'Dateiuploads und Admin-Verwaltung mit Next.js',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="de" className={`${bodyFont.variable} ${headingFont.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
47
nextjs/app/manage/_components/copy-link-button.js
Normal file
47
nextjs/app/manage/_components/copy-link-button.js
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
function wait(milliseconds) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, milliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
export function CopyLinkButton({ path, label }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function onCopy() {
|
||||
const url = `${window.location.origin}${path}`;
|
||||
|
||||
try {
|
||||
const html = `<a href="${url}">${label || 'Download'}</a>`;
|
||||
const clipboardItem = new ClipboardItem({
|
||||
'text/html': new Blob([html], { type: 'text/html' }),
|
||||
'text/plain': new Blob([url], { type: 'text/plain' }),
|
||||
});
|
||||
await navigator.clipboard.write([clipboardItem]);
|
||||
} catch {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch {
|
||||
const helper = document.createElement('textarea');
|
||||
helper.value = url;
|
||||
document.body.appendChild(helper);
|
||||
helper.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(helper);
|
||||
}
|
||||
}
|
||||
|
||||
setCopied(true);
|
||||
await wait(1800);
|
||||
setCopied(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="btn secondary" onClick={onCopy}>
|
||||
{copied ? 'Kopiert' : 'Link kopieren'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
11
nextjs/app/manage/_components/status-message.js
Normal file
11
nextjs/app/manage/_components/status-message.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export function StatusMessage({ error, success }) {
|
||||
if (!error && !success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="status error">{error}</div>;
|
||||
}
|
||||
|
||||
return <div className="status success">{success}</div>;
|
||||
}
|
||||
255
nextjs/app/manage/admin/dashboard/page.js
Normal file
255
nextjs/app/manage/admin/dashboard/page.js
Normal file
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
adminDeleteUploadAction,
|
||||
adminExtendUploadAction,
|
||||
adminLogoutAction,
|
||||
} from '@/src/lib/actions.js';
|
||||
import { adminHash } from '@/src/lib/config.js';
|
||||
import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import {
|
||||
formatBytes,
|
||||
formatCountdown,
|
||||
formatTimestamp,
|
||||
parseLogDetail,
|
||||
readSearchParam,
|
||||
} from '@/src/lib/format.js';
|
||||
import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js';
|
||||
|
||||
import { CopyLinkButton } from '../../_components/copy-link-button.js';
|
||||
import { StatusMessage } from '../../_components/status-message.js';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminDashboardPage({ searchParams }) {
|
||||
await runCleanupIfNeeded();
|
||||
|
||||
if (!adminHash) {
|
||||
return (
|
||||
<main className="page-shell narrow">
|
||||
<section className="panel centered">
|
||||
<h1>Adminzugang nicht konfiguriert</h1>
|
||||
<p className="muted">Setze MANAGEMENT_ADMIN_HASH in der Umgebungskonfiguration.</p>
|
||||
<a className="btn secondary" href="/manage/login">
|
||||
Zurück
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
const csrfToken = await ensureCsrfToken();
|
||||
|
||||
const [
|
||||
activeCount,
|
||||
activeBytes,
|
||||
distinctOwners,
|
||||
totalUploads,
|
||||
totalDownloads,
|
||||
totalDeletes,
|
||||
lastCleanup,
|
||||
recentLogs,
|
||||
allUploads,
|
||||
] = await Promise.all([
|
||||
get('SELECT COUNT(*) AS count FROM uploads'),
|
||||
get('SELECT COALESCE(SUM(size_bytes), 0) AS total FROM uploads'),
|
||||
get('SELECT COUNT(DISTINCT owner) AS count FROM uploads'),
|
||||
get('SELECT COUNT(*) AS count FROM admin_logs WHERE event = ?', ['upload']),
|
||||
get('SELECT COALESCE(SUM(downloads), 0) AS count FROM uploads'),
|
||||
get('SELECT COUNT(*) AS count FROM admin_logs WHERE event IN (?, ?, ?)', [
|
||||
'delete',
|
||||
'cleanup',
|
||||
'admin_delete',
|
||||
]),
|
||||
get('SELECT MAX(created_at) AS ts FROM admin_logs WHERE event = ?', ['cleanup']),
|
||||
all(
|
||||
'SELECT event, owner, detail, created_at, ip, user_agent FROM admin_logs ORDER BY created_at DESC LIMIT 250'
|
||||
),
|
||||
all(
|
||||
'SELECT id, owner, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads ORDER BY uploaded_at DESC LIMIT 500'
|
||||
),
|
||||
]);
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||
|
||||
return (
|
||||
<main className="page-shell">
|
||||
<header className="page-header">
|
||||
<div className="header-main">
|
||||
<h1>Adminübersicht</h1>
|
||||
<p className="muted">Metriken, Ereignisse und direkte Eingriffe in Uploads.</p>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<a className="chip primary" href="/manage/admin/files">
|
||||
Dateimanager
|
||||
</a>
|
||||
<a className="chip primary" href="/manage/admin/users">
|
||||
Benutzer verwalten
|
||||
</a>
|
||||
<form className="inline-form" action={adminLogoutAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<button className="btn secondary" type="submit">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<StatusMessage error={error} success={success} />
|
||||
|
||||
<section className="panel">
|
||||
<h2>Statistiken</h2>
|
||||
<div className="metric-grid">
|
||||
<article className="metric">
|
||||
<span className="muted">Aktive Uploads</span>
|
||||
<strong>{activeCount?.count || 0}</strong>
|
||||
</article>
|
||||
<article className="metric">
|
||||
<span className="muted">Aktive Dateigröße</span>
|
||||
<strong>{formatBytes(activeBytes?.total || 0)}</strong>
|
||||
</article>
|
||||
<article className="metric">
|
||||
<span className="muted">Aktive Nutzer</span>
|
||||
<strong>{distinctOwners?.count || 0}</strong>
|
||||
</article>
|
||||
<article className="metric">
|
||||
<span className="muted">Uploads gesamt</span>
|
||||
<strong>{totalUploads?.count || 0}</strong>
|
||||
</article>
|
||||
<article className="metric">
|
||||
<span className="muted">Downloads gesamt</span>
|
||||
<strong>{totalDownloads?.count || 0}</strong>
|
||||
</article>
|
||||
<article className="metric">
|
||||
<span className="muted">Löschungen gesamt</span>
|
||||
<strong>{totalDeletes?.count || 0}</strong>
|
||||
</article>
|
||||
<article className="metric">
|
||||
<span className="muted">Letztes Cleanup</span>
|
||||
<strong>{lastCleanup?.ts ? formatTimestamp(lastCleanup.ts) : '-'}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h2>Aktuelle Uploads</h2>
|
||||
{allUploads.length === 0 ? (
|
||||
<p className="muted">Noch keine Uploads vorhanden.</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nutzer</th>
|
||||
<th>Datei</th>
|
||||
<th>Größe</th>
|
||||
<th>Hochgeladen</th>
|
||||
<th>Ablauf</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allUploads.map((item) => {
|
||||
const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`;
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>{item.owner}</td>
|
||||
<td>
|
||||
<strong>{item.original_name}</strong>
|
||||
<div className="muted mono">{item.stored_name}</div>
|
||||
</td>
|
||||
<td>{formatBytes(item.size_bytes)}</td>
|
||||
<td>{formatTimestamp(item.uploaded_at)}</td>
|
||||
<td>
|
||||
<div>{formatTimestamp(item.expires_at)}</div>
|
||||
<div className="muted">Noch {formatCountdown(item.expires_at)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="stack-actions">
|
||||
<div className="row-actions">
|
||||
<a className="btn secondary" href={sharePath}>
|
||||
Download
|
||||
</a>
|
||||
<CopyLinkButton path={sharePath} label={item.original_name} />
|
||||
</div>
|
||||
|
||||
<form className="inline-form" action={adminExtendUploadAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="uploadId" value={item.id} />
|
||||
<input className="input small" name="extendHours" placeholder="Stunden" />
|
||||
<button className="btn" type="submit">
|
||||
Verlängern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="inline-form" action={adminDeleteUploadAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="uploadId" value={item.id} />
|
||||
<button className="btn danger" type="submit">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h2>Letzte Ereignisse</h2>
|
||||
{recentLogs.length === 0 ? (
|
||||
<p className="muted">Keine Logs vorhanden.</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<th>Event</th>
|
||||
<th>Nutzer</th>
|
||||
<th>IP</th>
|
||||
<th>Details</th>
|
||||
<th>User-Agent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentLogs.map((entry, index) => {
|
||||
const details = parseLogDetail(entry.detail);
|
||||
return (
|
||||
<tr key={`${entry.created_at}-${entry.event}-${index}`}>
|
||||
<td>{formatTimestamp(entry.created_at)}</td>
|
||||
<td>{entry.event}</td>
|
||||
<td>{entry.owner || '-'}</td>
|
||||
<td className="mono">{entry.ip || '-'}</td>
|
||||
<td>
|
||||
{details.length === 0 ? (
|
||||
<span className="muted">-</span>
|
||||
) : (
|
||||
<div className="stack-actions">
|
||||
{details.map((detail) => (
|
||||
<div key={`${detail.key}-${detail.value}`}>
|
||||
<strong>{detail.key}:</strong> <span>{detail.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">{entry.user_agent || '-'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
284
nextjs/app/manage/admin/files/page.js
Normal file
284
nextjs/app/manage/admin/files/page.js
Normal file
@@ -0,0 +1,284 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
adminCopyPathAction,
|
||||
adminDeletePathAction,
|
||||
adminLogoutAction,
|
||||
adminMkdirAction,
|
||||
adminMovePathAction,
|
||||
adminRenamePathAction,
|
||||
adminUploadToPathAction,
|
||||
} from '@/src/lib/actions.js';
|
||||
import { runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import { adminFilesHref, resolveAdminPath, sanitizeRelativePath } from '@/src/lib/files.js';
|
||||
import { formatBytes, formatTimestamp, readSearchParam } from '@/src/lib/format.js';
|
||||
import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js';
|
||||
|
||||
import { StatusMessage } from '../../_components/status-message.js';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function buildBreadcrumbs(relativePath) {
|
||||
const segments = relativePath.split('/').filter(Boolean);
|
||||
const breadcrumbs = [];
|
||||
let currentPath = '';
|
||||
|
||||
for (const segment of segments) {
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
breadcrumbs.push({
|
||||
label: segment,
|
||||
href: adminFilesHref(currentPath),
|
||||
});
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
export default async function AdminFilesPage({ searchParams }) {
|
||||
await runCleanupIfNeeded();
|
||||
await requireAdminUser();
|
||||
|
||||
const csrfToken = await ensureCsrfToken();
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const relativePath = sanitizeRelativePath(readSearchParam(resolvedSearchParams, 'path'));
|
||||
|
||||
const queryError = readSearchParam(resolvedSearchParams, 'error');
|
||||
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||
|
||||
const absolutePath = resolveAdminPath(relativePath);
|
||||
if (!absolutePath) {
|
||||
return (
|
||||
<main className="page-shell">
|
||||
<header className="page-header">
|
||||
<div className="header-main">
|
||||
<h1>Admin-Dateimanager</h1>
|
||||
<p className="muted">Dateien im DATA_DIR verwalten (ausgenommen _share).</p>
|
||||
</div>
|
||||
<a className="chip" href="/manage/admin/dashboard">
|
||||
Zur Adminübersicht
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<StatusMessage error={queryError || 'Ungültiger Pfad.'} success={success} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.promises.readdir(absolutePath, { withFileTypes: true });
|
||||
} catch {
|
||||
return (
|
||||
<main className="page-shell">
|
||||
<header className="page-header">
|
||||
<div className="header-main">
|
||||
<h1>Admin-Dateimanager</h1>
|
||||
<p className="muted">Dateien im DATA_DIR verwalten (ausgenommen _share).</p>
|
||||
</div>
|
||||
<a className="chip" href="/manage/admin/dashboard">
|
||||
Zur Adminübersicht
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<StatusMessage error="Ordner konnte nicht gelesen werden." success={success} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleEntries = entries.filter((entry) => entry.name !== '_share');
|
||||
const details = await Promise.all(
|
||||
visibleEntries.map(async (entry) => {
|
||||
const childPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
const absoluteChildPath = path.join(absolutePath, entry.name);
|
||||
|
||||
let stat = null;
|
||||
try {
|
||||
stat = await fs.promises.stat(absoluteChildPath);
|
||||
} catch {
|
||||
}
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
childPath,
|
||||
isDir: entry.isDirectory(),
|
||||
size: stat && stat.isFile() ? stat.size : null,
|
||||
modifiedAt: stat ? stat.mtimeMs : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
details.sort((left, right) => {
|
||||
if (left.isDir !== right.isDir) {
|
||||
return left.isDir ? -1 : 1;
|
||||
}
|
||||
return left.name.localeCompare(right.name, 'de');
|
||||
});
|
||||
|
||||
const parentPathRaw = relativePath ? path.dirname(relativePath) : '';
|
||||
const parentPath = parentPathRaw === '.' ? '' : sanitizeRelativePath(parentPathRaw);
|
||||
const breadcrumbs = buildBreadcrumbs(relativePath);
|
||||
|
||||
return (
|
||||
<main className="page-shell">
|
||||
<header className="page-header">
|
||||
<div className="header-main">
|
||||
<h1>Admin-Dateimanager</h1>
|
||||
<p className="muted">Dateien im DATA_DIR verwalten (ausgenommen _share).</p>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<a className="chip" href="/manage/admin/dashboard">
|
||||
Zur Adminübersicht
|
||||
</a>
|
||||
<form className="inline-form" action={adminLogoutAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<button className="btn secondary" type="submit">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<StatusMessage error={queryError} success={success} />
|
||||
|
||||
<section className="panel">
|
||||
<div className="toolbar">
|
||||
<span className="chip">Pfad: /{relativePath}</span>
|
||||
{relativePath ? (
|
||||
<a className="chip primary" href={adminFilesHref(parentPath)}>
|
||||
Eine Ebene zurück
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="breadcrumbs">
|
||||
<span className="muted">Position:</span>
|
||||
<a href={adminFilesHref('')}>root</a>
|
||||
{breadcrumbs.map((item) => (
|
||||
<span key={item.href}>
|
||||
{' / '}
|
||||
<a href={item.href}>{item.label}</a>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ display: 'grid', gap: '0.8rem', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
|
||||
<div>
|
||||
<h2>Ordner erstellen</h2>
|
||||
<form className="form-grid" action={adminMkdirAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={relativePath} />
|
||||
|
||||
<label className="field">
|
||||
Ordnername
|
||||
<input className="input" name="name" placeholder="z.B. Projekte" required />
|
||||
</label>
|
||||
|
||||
<button className="btn" type="submit">
|
||||
Erstellen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Datei hochladen</h2>
|
||||
<form className="form-grid" action={adminUploadToPathAction} encType="multipart/form-data">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={relativePath} />
|
||||
|
||||
<label className="field">
|
||||
Datei
|
||||
<input className="input" name="file" type="file" required />
|
||||
</label>
|
||||
|
||||
<button className="btn" type="submit">
|
||||
Hochladen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h2>Inhalt</h2>
|
||||
{details.length === 0 ? (
|
||||
<p className="muted">Keine Einträge in diesem Ordner.</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Typ</th>
|
||||
<th>Größe</th>
|
||||
<th>Geändert</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{details.map((item) => (
|
||||
<tr key={item.childPath}>
|
||||
<td>
|
||||
{item.isDir ? (
|
||||
<a href={adminFilesHref(item.childPath)}>
|
||||
<strong>{item.name}</strong>
|
||||
</a>
|
||||
) : (
|
||||
<span>{item.name}</span>
|
||||
)}
|
||||
<div className="muted mono">{item.childPath}</div>
|
||||
</td>
|
||||
<td>{item.isDir ? 'Ordner' : 'Datei'}</td>
|
||||
<td>{item.size != null ? formatBytes(item.size) : '-'}</td>
|
||||
<td>{item.modifiedAt ? formatTimestamp(item.modifiedAt) : '-'}</td>
|
||||
<td>
|
||||
<div className="stack-actions">
|
||||
<form className="inline-form stacked" action={adminRenamePathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<input className="input small" name="newName" placeholder="Neuer Name" required />
|
||||
<button className="btn secondary" type="submit">
|
||||
Umbenennen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="inline-form stacked" action={adminMovePathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<input type="hidden" name="currentPath" value={relativePath} />
|
||||
<input className="input small" name="targetPath" placeholder="Zielpfad" required />
|
||||
<button className="btn secondary" type="submit">
|
||||
Verschieben
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="inline-form stacked" action={adminCopyPathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<input type="hidden" name="currentPath" value={relativePath} />
|
||||
<input className="input small" name="targetPath" placeholder="Zielpfad" required />
|
||||
<button className="btn secondary" type="submit">
|
||||
Kopieren
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="inline-form" action={adminDeletePathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<button className="btn danger" type="submit">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
66
nextjs/app/manage/admin/page.js
Normal file
66
nextjs/app/manage/admin/page.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { adminLoginAction } from '@/src/lib/actions.js';
|
||||
import { adminHash } from '@/src/lib/config.js';
|
||||
import { runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import { readSearchParam } from '@/src/lib/format.js';
|
||||
import { ensureCsrfToken, getAuthenticatedUser } from '@/src/lib/security.js';
|
||||
|
||||
import { StatusMessage } from '../_components/status-message.js';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminLoginPage({ searchParams }) {
|
||||
await runCleanupIfNeeded();
|
||||
|
||||
if (!adminHash) {
|
||||
return (
|
||||
<main className="page-shell narrow">
|
||||
<section className="panel centered">
|
||||
<h1>Adminzugang nicht konfiguriert</h1>
|
||||
<p className="muted">Setze MANAGEMENT_ADMIN_HASH in der Umgebungskonfiguration.</p>
|
||||
<a className="btn secondary" href="/manage/login">
|
||||
Zurück zur Anmeldung
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const user = await getAuthenticatedUser();
|
||||
if (user?.admin) {
|
||||
redirect('/manage/admin/dashboard');
|
||||
}
|
||||
|
||||
const csrfToken = await ensureCsrfToken();
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||
|
||||
return (
|
||||
<main className="page-shell narrow">
|
||||
<header className="page-header">
|
||||
<div className="header-main">
|
||||
<h1>Adminbereich</h1>
|
||||
<p className="muted">Melde dich als Administrator an.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="panel">
|
||||
<StatusMessage error={error} success={success} />
|
||||
<form className="form-grid" action={adminLoginAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
|
||||
<label className="field">
|
||||
Admin-Passwort
|
||||
<input className="input" name="password" type="password" autoComplete="current-password" required />
|
||||
</label>
|
||||
|
||||
<button className="btn" type="submit">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
124
nextjs/app/manage/admin/users/page.js
Normal file
124
nextjs/app/manage/admin/users/page.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
adminCreateUserAction,
|
||||
adminDeleteUserAction,
|
||||
adminLogoutAction,
|
||||
adminResetUserAction,
|
||||
} from '@/src/lib/actions.js';
|
||||
import { all, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import { formatTimestamp, readSearchParam } from '@/src/lib/format.js';
|
||||
import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js';
|
||||
|
||||
import { StatusMessage } from '../../_components/status-message.js';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminUsersPage({ searchParams }) {
|
||||
await runCleanupIfNeeded();
|
||||
await requireAdminUser();
|
||||
|
||||
const csrfToken = await ensureCsrfToken();
|
||||
const users = await all('SELECT username, created_at FROM users ORDER BY username ASC');
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||
|
||||
return (
|
||||
<main className="page-shell">
|
||||
<header className="page-header">
|
||||
<div className="header-main">
|
||||
<h1>Benutzerverwaltung</h1>
|
||||
<p className="muted">Konten erstellen, Passwort setzen oder Benutzer entfernen.</p>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<a className="chip" href="/manage/admin/dashboard">
|
||||
Zur Adminübersicht
|
||||
</a>
|
||||
<form className="inline-form" action={adminLogoutAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<button className="btn secondary" type="submit">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<StatusMessage error={error} success={success} />
|
||||
|
||||
<section className="panel">
|
||||
<h2>Neuen Benutzer anlegen</h2>
|
||||
<form className="form-grid" action={adminCreateUserAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
|
||||
<label className="field">
|
||||
Benutzername
|
||||
<input className="input" name="username" autoComplete="username" required />
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
Passwort
|
||||
<input className="input" name="password" type="password" autoComplete="new-password" required />
|
||||
</label>
|
||||
|
||||
<button className="btn" type="submit">
|
||||
Benutzer erstellen
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h2>Bestehende Benutzer</h2>
|
||||
{users.length === 0 ? (
|
||||
<p className="muted">Noch keine Benutzer vorhanden.</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzername</th>
|
||||
<th>Erstellt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.username}>
|
||||
<td>{user.username}</td>
|
||||
<td>{formatTimestamp(user.created_at)}</td>
|
||||
<td>
|
||||
<div className="stack-actions">
|
||||
<form className="inline-form" action={adminResetUserAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<input
|
||||
className="input small"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Neues Passwort"
|
||||
required
|
||||
/>
|
||||
<button className="btn" type="submit">
|
||||
Passwort setzen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="inline-form" action={adminDeleteUserAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<button className="btn danger" type="submit">
|
||||
Benutzer löschen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
149
nextjs/app/manage/dashboard/page.js
Normal file
149
nextjs/app/manage/dashboard/page.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
deleteOwnUploadAction,
|
||||
extendOwnUploadAction,
|
||||
uploadFileAction,
|
||||
userLogoutAction,
|
||||
} from '@/src/lib/actions.js';
|
||||
import { all, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import { formatBytes, formatCountdown, formatTimestamp, readSearchParam } from '@/src/lib/format.js';
|
||||
import { ensureCsrfToken, requireAuthenticatedUser } from '@/src/lib/security.js';
|
||||
|
||||
import { CopyLinkButton } from '../_components/copy-link-button.js';
|
||||
import { StatusMessage } from '../_components/status-message.js';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function DashboardPage({ searchParams }) {
|
||||
await runCleanupIfNeeded();
|
||||
|
||||
const user = await requireAuthenticatedUser();
|
||||
const csrfToken = await ensureCsrfToken();
|
||||
const uploads = await all(
|
||||
'SELECT id, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads WHERE owner = ? ORDER BY uploaded_at DESC',
|
||||
[user.username]
|
||||
);
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||
|
||||
return (
|
||||
<main className="page-shell">
|
||||
<header className="page-header">
|
||||
<div className="header-main">
|
||||
<h1>Dateiverwaltung</h1>
|
||||
<p className="muted">Angemeldet als {user.username}</p>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
{user.admin ? (
|
||||
<a className="chip primary" href="/manage/admin/dashboard">
|
||||
Adminbereich
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
<form className="inline-form" action={userLogoutAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<button className="btn secondary" type="submit">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="panel">
|
||||
<h2>Neue Datei hochladen</h2>
|
||||
<StatusMessage error={error} success={success} />
|
||||
|
||||
<form className="form-grid" action={uploadFileAction} encType="multipart/form-data">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
|
||||
<label className="field">
|
||||
Datei
|
||||
<input className="input" name="file" type="file" required />
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
Aufbewahrung in Stunden
|
||||
<input className="input" name="retentionHours" placeholder="168" />
|
||||
</label>
|
||||
|
||||
<button className="btn" type="submit">
|
||||
Hochladen
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h2>Aktuelle Uploads</h2>
|
||||
{uploads.length === 0 ? (
|
||||
<p className="muted">Noch keine Uploads.</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datei</th>
|
||||
<th>Größe</th>
|
||||
<th>Hochgeladen</th>
|
||||
<th>Ablauf</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{uploads.map((item) => {
|
||||
const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`;
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<strong>{item.original_name}</strong>
|
||||
<div className="muted mono">{item.stored_name}</div>
|
||||
</td>
|
||||
<td>{formatBytes(item.size_bytes)}</td>
|
||||
<td>{formatTimestamp(item.uploaded_at)}</td>
|
||||
<td>
|
||||
<div>{formatTimestamp(item.expires_at)}</div>
|
||||
<div className="muted">Noch {formatCountdown(item.expires_at)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="stack-actions">
|
||||
<div className="row-actions">
|
||||
<a className="btn secondary" href={sharePath}>
|
||||
Download
|
||||
</a>
|
||||
<CopyLinkButton path={sharePath} label={item.original_name} />
|
||||
</div>
|
||||
|
||||
<form className="inline-form" action={extendOwnUploadAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="uploadId" value={item.id} />
|
||||
<input
|
||||
className="input small"
|
||||
name="extendHours"
|
||||
placeholder="Stunden"
|
||||
/>
|
||||
<button className="btn" type="submit">
|
||||
Verlängern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="inline-form" action={deleteOwnUploadAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="uploadId" value={item.id} />
|
||||
<button className="btn danger" type="submit">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
65
nextjs/app/manage/login/page.js
Normal file
65
nextjs/app/manage/login/page.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { userLoginAction } from '@/src/lib/actions.js';
|
||||
import { runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import { readSearchParam } from '@/src/lib/format.js';
|
||||
import { ensureCsrfToken, getAuthenticatedUser } from '@/src/lib/security.js';
|
||||
|
||||
import { StatusMessage } from '../_components/status-message.js';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function LoginPage({ searchParams }) {
|
||||
await runCleanupIfNeeded();
|
||||
|
||||
const user = await getAuthenticatedUser();
|
||||
if (user) {
|
||||
redirect('/manage/dashboard');
|
||||
}
|
||||
|
||||
const csrfToken = await ensureCsrfToken();
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||
|
||||
return (
|
||||
<main className="page-shell narrow">
|
||||
<header className="page-header">
|
||||
<div className="header-main">
|
||||
<h1>Dateiverwaltung</h1>
|
||||
<p className="muted">Melde dich an, um Uploads zu verwalten.</p>
|
||||
</div>
|
||||
<a className="chip" href="/manage/admin">
|
||||
Admin-Anmeldung
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<section className="panel">
|
||||
<StatusMessage error={error} success={success} />
|
||||
<form className="form-grid" action={userLoginAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
|
||||
<label className="field">
|
||||
Benutzername
|
||||
<input className="input" name="username" autoComplete="username" required />
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
Passwort
|
||||
<input
|
||||
className="input"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button className="btn" type="submit">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
21
nextjs/app/manage/page.js
Normal file
21
nextjs/app/manage/page.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import { getAuthenticatedUser } from '@/src/lib/security.js';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ManageIndexPage() {
|
||||
await runCleanupIfNeeded();
|
||||
|
||||
const user = await getAuthenticatedUser();
|
||||
if (!user) {
|
||||
redirect('/manage/login');
|
||||
}
|
||||
|
||||
if (user.admin) {
|
||||
redirect('/manage/admin/dashboard');
|
||||
}
|
||||
|
||||
redirect('/manage/dashboard');
|
||||
}
|
||||
13
nextjs/app/not-found.js
Normal file
13
nextjs/app/not-found.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<main className="page-shell narrow">
|
||||
<section className="panel centered">
|
||||
<h1>Seite nicht gefunden</h1>
|
||||
<p className="muted">Die angeforderte Seite existiert nicht oder wurde verschoben.</p>
|
||||
<a className="btn" href="/manage/login">
|
||||
Zurück zur Anmeldung
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
5
nextjs/app/page.js
Normal file
5
nextjs/app/page.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function HomePage() {
|
||||
redirect('/manage');
|
||||
}
|
||||
Reference in New Issue
Block a user