expressjs -> nextjs

This commit is contained in:
Ludwig Lehnert
2026-03-27 19:50:53 +01:00
parent bcaec8636d
commit 8b937eee72
34 changed files with 3769 additions and 3078 deletions

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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');
}