feat: improve upload admin workflow

Add users.is_admin SQLite migration for promoted admins.

Support curl PUT request uploads, save uploads into admin file-manager paths, default 25 GiB uploads, tabbed UI, and local dev preview.
This commit is contained in:
Ludwig Lehnert
2026-06-18 17:09:07 +00:00
parent 61f10fd994
commit a2b6054440
19 changed files with 1148 additions and 396 deletions

View File

@@ -110,7 +110,7 @@ export default async function UploadRequestPage({ params, searchParams }) {
</section>
{state === 'open' ? (
<section className="panel panel-spotlight">
<section className="panel">
<h2>Datei hochladen</h2>
<form
className="form-grid"
@@ -132,6 +132,11 @@ export default async function UploadRequestPage({ params, searchParams }) {
Datei senden
</button>
</form>
<details className="help-box">
<summary>Upload per curl</summary>
<pre><code>{`curl -T ./datei.pdf -H 'X-File-Name: datei.pdf' 'https://HOST/_request/${requestEntry.id}/upload'`}</code></pre>
</details>
</section>
) : null}

View File

@@ -14,7 +14,7 @@ import {
import { get, logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js';
import { safeBaseName } from '@/src/lib/files.js';
import { sendUploadRequestCompletedMail } from '@/src/lib/mailer.js';
import { streamMultipartToPath } from '@/src/lib/multipart.js';
import { streamBodyToPath, streamMultipartToPath } from '@/src/lib/multipart.js';
import { getRequestMeta } from '@/src/lib/security.js';
export const runtime = 'nodejs';
@@ -50,6 +50,113 @@ function redirectToRequest(requestId, params = {}) {
});
}
function textResponse(message, status = 200) {
return new NextResponse(`${message}\n`, {
status,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
function isMultipartRequest(request) {
return String(request.headers.get('content-type') || '')
.toLowerCase()
.includes('multipart/form-data');
}
function uploadErrorResponse(requestId, rawUpload, message, status = 400) {
if (rawUpload) {
return textResponse(message, status);
}
return redirectToRequest(requestId, { error: message });
}
function uploadSuccessResponse(requestId, rawUpload, message, downloadUrl = '') {
if (rawUpload) {
const body = downloadUrl ? `${message}\nDownload: ${downloadUrl}` : message;
return textResponse(body, 201);
}
return redirectToRequest(requestId, { success: message });
}
function contentDispositionFilename(value) {
const raw = String(value || '');
const encodedMatch = raw.match(/filename\*=UTF-8''([^;]+)/i);
if (encodedMatch) {
try {
return decodeURIComponent(encodedMatch[1].trim());
} catch {
return encodedMatch[1].trim();
}
}
const quotedMatch = raw.match(/filename="([^"]+)"/i);
if (quotedMatch) {
return quotedMatch[1];
}
const plainMatch = raw.match(/filename=([^;]+)/i);
return plainMatch ? plainMatch[1].trim() : '';
}
function rawUploadFilename(request, requestId) {
const url = new URL(request.url);
const candidate =
url.searchParams.get('filename') ||
url.searchParams.get('name') ||
request.headers.get('x-file-name') ||
request.headers.get('x-filename') ||
contentDispositionFilename(request.headers.get('content-disposition')) ||
`${requestId}.upload`;
return safeBaseName(candidate, 'upload');
}
function rawFulfilledBy(request) {
const url = new URL(request.url);
return String(url.searchParams.get('by') || request.headers.get('x-fulfilled-by') || '')
.trim()
.slice(0, 200);
}
async function parseUpload(request, requestId, storedPath) {
if (isMultipartRequest(request)) {
const parsed = await streamMultipartToPath(request, {
fileFieldName: 'file',
targetPath: storedPath,
maxFileBytes: maxUploadBytes,
});
return {
rawUpload: false,
fulfilledBy: String(parsed.fields.fulfilledBy || '').trim().slice(0, 200),
originalName: safeBaseName(parsed.file.filename, 'upload'),
sizeBytes: parsed.file.sizeBytes,
};
}
const contentLength = Number.parseInt(String(request.headers.get('content-length') || ''), 10);
if (maxUploadBytes > 0 && Number.isFinite(contentLength) && contentLength > maxUploadBytes) {
throw new Error('file-too-large');
}
const parsed = await streamBodyToPath(request, {
targetPath: storedPath,
maxBytes: maxUploadBytes,
});
return {
rawUpload: true,
fulfilledBy: rawFulfilledBy(request),
originalName: rawUploadFilename(request, requestId),
sizeBytes: parsed.sizeBytes,
};
}
function firstForwardedPart(value) {
if (!value) {
return '';
@@ -113,11 +220,12 @@ function createRandomId() {
return toBase32(crypto.randomBytes(5));
}
export async function POST(request, { params }) {
async function handleUpload(request, { params }) {
await runCleanupIfNeeded();
const resolvedParams = await params;
const requestId = normalizeRequestId(resolvedParams.id);
const rawUpload = !isMultipartRequest(request);
if (!isValidRequestId(requestId)) {
return new NextResponse('Ungültige Anfrage-ID', { status: 400 });
@@ -131,16 +239,20 @@ export async function POST(request, { params }) {
);
if (!uploadRequest) {
return redirectToRequest(requestId, { error: 'Anfrage nicht gefunden.' });
return uploadErrorResponse(requestId, rawUpload, 'Anfrage nicht gefunden.', 404);
}
if (Number(uploadRequest.completed_at || 0) > 0) {
if (rawUpload) {
return textResponse('Diese Anfrage wurde bereits erfüllt.', 409);
}
return redirectToRequest(requestId, { success: 'Diese Anfrage wurde bereits erfüllt.' });
}
const now = Date.now();
if (Number(uploadRequest.expires_at || 0) <= now) {
return redirectToRequest(requestId, { error: 'Diese Anfrage ist bereits abgelaufen.' });
return uploadErrorResponse(requestId, rawUpload, 'Diese Anfrage ist bereits abgelaufen.', 410);
}
let storedName = '';
@@ -158,35 +270,31 @@ export async function POST(request, { params }) {
}
if (!storedName || !storedPath) {
return redirectToRequest(requestId, { error: 'Upload-ID konnte nicht erzeugt werden.' });
return uploadErrorResponse(requestId, rawUpload, 'Upload-ID konnte nicht erzeugt werden.', 500);
}
let parsed;
try {
parsed = await streamMultipartToPath(request, {
fileFieldName: 'file',
targetPath: storedPath,
maxFileBytes: maxUploadBytes,
});
parsed = await parseUpload(request, requestId, storedPath);
} catch (error) {
if (error && error.message === 'file-too-large') {
return redirectToRequest(requestId, {
error: `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`,
});
return uploadErrorResponse(
requestId,
rawUpload,
`Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`,
413
);
}
if (error && error.message === 'missing-file') {
return redirectToRequest(requestId, { error: 'Keine Datei hochgeladen.' });
return uploadErrorResponse(requestId, rawUpload, 'Keine Datei hochgeladen.', 400);
}
return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' });
return uploadErrorResponse(requestId, rawUpload, 'Upload fehlgeschlagen.', 500);
}
const fulfilledBy = String(parsed.fields.fulfilledBy || '').trim().slice(0, 200);
const originalName = safeBaseName(parsed.file.filename, 'upload');
const uploadExpiry = Math.min(now + uploadTtlSeconds * 1000, now + maxRetentionSeconds * 1000);
let uploadId = null;
const sizeBytes = parsed.file.sizeBytes;
const { fulfilledBy, originalName, sizeBytes } = parsed;
try {
const insertResult = await run(
`INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at)
@@ -196,7 +304,7 @@ export async function POST(request, { params }) {
uploadId = insertResult.lastID;
} catch {
await fs.promises.rm(storedPath, { force: true }).catch(() => undefined);
return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' });
return uploadErrorResponse(requestId, rawUpload, 'Upload fehlgeschlagen.', 500);
}
const updateResult = await run(
@@ -215,6 +323,10 @@ export async function POST(request, { params }) {
if (!updateResult || updateResult.changes < 1) {
await run('DELETE FROM uploads WHERE id = ?', [uploadId]).catch(() => undefined);
await fs.promises.rm(storedPath, { force: true }).catch(() => undefined);
if (rawUpload) {
return textResponse('Diese Anfrage wurde bereits erfüllt.', 409);
}
return redirectToRequest(requestId, { success: 'Diese Anfrage wurde bereits erfüllt.' });
}
@@ -239,7 +351,12 @@ export async function POST(request, { params }) {
if (mailResult.ok) {
await run('UPDATE upload_requests SET notification_sent_at = ? WHERE id = ?', [Date.now(), requestId]);
return redirectToRequest(requestId, { success: 'Datei erfolgreich hochgeladen. Vielen Dank!' });
return uploadSuccessResponse(
requestId,
rawUpload,
'Datei erfolgreich hochgeladen. Vielen Dank!',
downloadUrl
);
}
await logEvent(
@@ -249,7 +366,18 @@ export async function POST(request, { params }) {
await getRequestMeta()
);
return redirectToRequest(requestId, {
success: 'Datei hochgeladen. Hinweis: E-Mail-Benachrichtigung konnte nicht gesendet werden.',
});
return uploadSuccessResponse(
requestId,
rawUpload,
'Datei hochgeladen. Hinweis: E-Mail-Benachrichtigung konnte nicht gesendet werden.',
downloadUrl
);
}
export async function POST(request, context) {
return handleUpload(request, context);
}
export async function PUT(request, context) {
return handleUpload(request, context);
}

View File

@@ -1,22 +1,19 @@
:root {
--font-body: 'Manrope', sans-serif;
--font-heading: 'Space Grotesk', sans-serif;
--bg-main: #eef4f7;
--bg-accent: #d9ece4;
--bg-main: #f4f6f8;
--surface: #ffffff;
--surface-soft: #f7fafc;
--surface-glass: rgb(255 255 255 / 0.72);
--text-main: #10243a;
--text-muted: #566b81;
--line: #d6e0ea;
--surface-soft: #f8fafc;
--text-main: #172033;
--text-muted: #64748b;
--line: #d9e1ea;
--primary: #0f766e;
--primary-hover: #0e635c;
--primary-soft: #d8f3ea;
--primary-hover: #0b5f59;
--primary-soft: #e2f4ef;
--danger: #b42318;
--danger-soft: #fee4e2;
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 16px 36px -24px rgb(16 36 58 / 0.45);
--radius: 12px;
--radius-sm: 8px;
}
* {
@@ -30,13 +27,10 @@ body {
}
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;
background: var(--bg-main);
color: var(--text-main);
font-family: var(--font-body);
}
a {
@@ -45,27 +39,35 @@ a {
h1,
h2,
h3 {
margin: 0;
font-family: var(--font-heading);
letter-spacing: -0.02em;
}
h3,
p {
margin: 0;
}
h1,
h2,
h3 {
font-family: var(--font-heading);
letter-spacing: -0.02em;
}
.page-shell {
width: min(1200px, 100% - 2.5rem);
width: min(1120px, 100% - 2rem);
margin: 0 auto;
padding: 2.2rem 0 3.5rem;
padding: 1.6rem 0 2.5rem;
display: grid;
gap: 1.6rem;
animation: page-enter 240ms ease-out both;
gap: 1rem;
}
.page-shell.narrow {
width: min(560px, 100% - 2rem);
width: min(560px, 100% - 1.5rem);
}
.page-header,
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.page-header {
@@ -73,57 +75,20 @@ p {
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.9rem;
background: linear-gradient(170deg, var(--surface-glass), rgb(255 255 255 / 0.5));
border: 1px solid rgb(214 224 234 / 0.9);
border-radius: var(--radius);
padding: 0.95rem 1.05rem;
backdrop-filter: blur(6px);
gap: 0.8rem;
padding: 1rem;
}
.header-main {
.header-main,
.panel,
.form-grid,
.stack-actions {
display: grid;
gap: 0.3rem;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
gap: 0.75rem;
}
.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.2rem;
display: grid;
gap: 0.95rem;
}
.panel.panel-soft {
background: linear-gradient(180deg, #f5fafc, #ffffff);
}
.panel.panel-spotlight {
position: relative;
overflow: hidden;
}
.panel.panel-spotlight::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 90% 0%, rgb(15 118 110 / 0.12), transparent 45%),
radial-gradient(circle at 10% 100%, rgb(15 118 110 / 0.08), transparent 40%);
}
.panel.panel-spotlight > * {
position: relative;
padding: 1rem;
}
.panel.centered {
@@ -135,10 +100,69 @@ p {
color: var(--text-muted);
}
.toolbar,
.row-actions,
.inline-form,
.tabs,
.summary-line,
.breadcrumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.toolbar,
.row-actions,
.inline-form {
gap: 0.45rem;
}
.tabs {
gap: 0.4rem;
padding: 0.25rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.tab {
border-radius: var(--radius-sm);
color: var(--text-muted);
font-weight: 700;
padding: 0.48rem 0.75rem;
text-decoration: none;
}
.tab:hover,
.tab.active {
background: var(--primary-soft);
color: #0c4f4a;
}
.summary-line {
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.summary-line span,
.chip,
.badge {
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
}
.summary-line span {
padding: 0.28rem 0.6rem;
}
.status {
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 0.65rem 0.8rem;
border: 1px solid transparent;
font-size: 0.93rem;
}
@@ -154,16 +178,11 @@ p {
color: var(--danger);
}
.form-grid {
display: grid;
gap: 0.8rem;
}
.field {
display: grid;
gap: 0.35rem;
font-size: 0.92rem;
font-weight: 600;
font-weight: 700;
}
.input {
@@ -174,13 +193,15 @@ p {
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:focus-visible,
.btn:focus-visible,
.tab:focus-visible,
.chip:focus-visible,
.action-menu > summary:focus-visible {
outline: 3px solid rgb(15 118 110 / 0.22);
outline-offset: 2px;
}
.input.small {
@@ -190,33 +211,27 @@ p {
.btn {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: var(--primary);
color: #fff;
text-decoration: none;
font: inherit;
font-weight: 600;
cursor: pointer;
font: inherit;
font-weight: 700;
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;
text-decoration: none;
}
.btn:hover {
background: var(--primary-hover);
}
.btn:active {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
opacity: 0.65;
}
.btn.secondary {
@@ -226,14 +241,13 @@ p {
}
.btn.secondary:hover {
background: #f4f8fb;
border-color: #c3d4e2;
background: var(--surface-soft);
}
.btn.danger {
background: var(--danger-soft);
color: var(--danger);
border-color: #f7b0a8;
color: var(--danger);
}
.btn.danger:hover {
@@ -241,16 +255,11 @@ p {
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
border-radius: 999px;
background: #fff;
border: 1px solid var(--line);
gap: 0.35rem;
color: var(--text-main);
text-decoration: none;
font-size: 0.82rem;
padding: 0.3rem 0.58rem;
text-decoration: none;
}
.chip.primary {
@@ -260,14 +269,11 @@ p {
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid transparent;
padding: 0.18rem 0.5rem;
border-color: transparent;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.02em;
padding: 0.18rem 0.5rem;
}
.badge.primary {
@@ -290,49 +296,21 @@ p {
.metric-grid {
display: grid;
gap: 0.8rem;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.metric {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
padding: 0.7rem;
display: grid;
gap: 0.2rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.7rem;
}
.metric strong {
font-family: var(--font-heading);
font-size: 1.25rem;
letter-spacing: -0.02em;
}
.dashboard-top-grid {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.9fr);
}
.info-stack {
display: grid;
gap: 0.65rem;
}
.info-card {
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: #fff;
padding: 0.7rem 0.8rem;
display: grid;
gap: 0.2rem;
}
.info-card strong {
font-family: var(--font-heading);
letter-spacing: -0.02em;
font-size: 1.02rem;
}
.request-meta-wrap {
@@ -355,16 +333,11 @@ p {
font-weight: 700;
}
.request-meta-table tbody tr:first-child th,
.request-meta-table tbody tr:first-child td {
border-top: none;
}
.upload-progress {
display: grid;
gap: 0.45rem;
border-radius: var(--radius-sm);
border: 1px solid #bde1d9;
border-radius: var(--radius-sm);
background: #ecf8f4;
padding: 0.58rem 0.66rem;
}
@@ -378,73 +351,54 @@ p {
}
.upload-progress-track {
width: 100%;
height: 8px;
border-radius: 999px;
overflow: hidden;
border-radius: 999px;
background: #d7ebe4;
}
.upload-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #0f766e, #189181);
height: 100%;
background: var(--primary);
transition: width 140ms linear;
}
.table-wrap {
width: 100%;
overflow-x: auto;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 680px;
border-collapse: collapse;
}
th,
td {
border-top: 1px solid var(--line);
padding: 0.62rem 0.66rem;
text-align: left;
vertical-align: top;
padding: 0.62rem 0.66rem;
border-top: 1px solid var(--line);
font-size: 0.9rem;
}
thead th {
thead th,
tbody tr:first-child th,
tbody tr:first-child td {
border-top: none;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.74rem;
}
thead th {
background: var(--surface-soft);
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;
font-size: 0.74rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.action-form-row {
@@ -456,15 +410,51 @@ tbody tr:hover {
min-width: 7rem;
}
.input.path-input {
min-width: 12rem;
}
.inline-form.stacked {
display: grid;
}
.breadcrumbs {
.check-row {
display: flex;
gap: 0.35rem;
align-items: center;
flex-wrap: wrap;
gap: 0.45rem;
color: var(--text-muted);
font-size: 0.92rem;
font-weight: 700;
}
.action-menu > summary {
display: inline-flex;
cursor: pointer;
list-style: none;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
font-weight: 700;
padding: 0.48rem 0.75rem;
}
.action-menu > summary::-webkit-details-marker {
display: none;
}
.action-menu[open] > summary {
background: var(--surface-soft);
}
.action-menu-panel {
display: grid;
gap: 0.55rem;
margin-top: 0.6rem;
min-width: 13rem;
}
.breadcrumbs {
gap: 0.35rem;
font-size: 0.88rem;
}
@@ -477,40 +467,46 @@ tbody tr:hover {
text-decoration: underline;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 0.8rem;
.help-box {
border-top: 1px solid var(--line);
padding-top: 0.75rem;
}
@keyframes page-enter {
from {
opacity: 0;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
.help-box summary {
cursor: pointer;
font-weight: 700;
}
pre {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #0f172a;
color: #e2e8f0;
font-size: 0.82rem;
padding: 0.75rem;
}
.mono,
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.mono {
font-size: 0.8rem;
}
@media (max-width: 900px) {
.page-shell {
width: min(100% - 1.4rem, 1200px);
padding-top: 1.3rem;
}
.page-header {
padding: 0.8rem;
width: min(100% - 1rem, 1120px);
padding-top: 1rem;
}
.page-header,
.panel {
padding: 0.85rem;
}
.dashboard-top-grid {
grid-template-columns: 1fr;
}
table {
min-width: 620px;
}

View File

@@ -63,7 +63,7 @@ export function UploadProgressForm({ csrfToken }) {
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const redirectPath = xhr.response?.redirect || '/manage/dashboard?success=Upload%20abgeschlossen.';
const redirectPath = xhr.response?.redirect || '/manage/dashboard?tab=upload&success=Upload%20abgeschlossen.';
window.location.assign(redirectPath);
return;
}

View File

@@ -2,8 +2,8 @@ import {
adminDeleteUploadAction,
adminExtendUploadAction,
adminLogoutAction,
adminSaveUploadToPathAction,
} from '@/src/lib/actions.js';
import { adminHash } from '@/src/lib/config.js';
import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js';
import { sharedLinkName } from '@/src/lib/files.js';
import {
@@ -23,20 +23,6 @@ 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();
@@ -50,6 +36,7 @@ export default async function AdminDashboardPage({ searchParams }) {
lastCleanup,
recentLogs,
allUploads,
uploadRequests,
] = await Promise.all([
get('SELECT COUNT(*) AS count FROM uploads'),
get('SELECT COALESCE(SUM(size_bytes), 0) AS total FROM uploads'),
@@ -68,11 +55,19 @@ export default async function AdminDashboardPage({ searchParams }) {
all(
'SELECT id, owner, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads ORDER BY uploaded_at DESC LIMIT 500'
),
all(
`SELECT id, owner, note, created_at, expires_at, completed_at, upload_id, uploaded_original_name, uploaded_size_bytes
FROM upload_requests
ORDER BY created_at DESC
LIMIT 500`
),
]);
const resolvedSearchParams = await searchParams;
const error = readSearchParam(resolvedSearchParams, 'error');
const success = readSearchParam(resolvedSearchParams, 'success');
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
const activeTab = ['stats', 'uploads', 'requests', 'logs'].includes(requestedTab) ? requestedTab : 'stats';
return (
<main className="page-shell">
@@ -100,6 +95,22 @@ export default async function AdminDashboardPage({ searchParams }) {
<StatusMessage error={error} success={success} />
<nav className="tabs" aria-label="Admin-Bereiche">
<a className={`tab ${activeTab === 'stats' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=stats">
Statistik
</a>
<a className={`tab ${activeTab === 'uploads' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=uploads">
Uploads
</a>
<a className={`tab ${activeTab === 'requests' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=requests">
Anfragen
</a>
<a className={`tab ${activeTab === 'logs' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=logs">
Logs
</a>
</nav>
{activeTab === 'stats' ? (
<section className="panel">
<h2>Statistiken</h2>
<div className="metric-grid">
@@ -133,7 +144,9 @@ export default async function AdminDashboardPage({ searchParams }) {
</article>
</div>
</section>
) : null}
{activeTab === 'uploads' ? (
<section className="panel">
<h2>Aktuelle Uploads</h2>
{allUploads.length === 0 ? (
@@ -169,7 +182,9 @@ export default async function AdminDashboardPage({ searchParams }) {
<div className="muted">Noch {formatCountdown(item.expires_at)}</div>
</td>
<td>
<div className="stack-actions">
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<div className="row-actions">
<a className="btn secondary" href={sharePath}>
Download
@@ -177,6 +192,21 @@ export default async function AdminDashboardPage({ searchParams }) {
<CopyLinkButton path={sharePath} label={item.original_name} />
</div>
<form className="inline-form stacked" action={adminSaveUploadToPathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="uploadId" value={item.id} />
<input type="hidden" name="redirectTab" value="uploads" />
<input
className="input small path-input"
name="targetPath"
placeholder="Ziel, z.B. inbox/"
required
/>
<button className="btn secondary" type="submit">
Im Dateimanager speichern
</button>
</form>
<form className="inline-form action-form-row">
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="uploadId" value={item.id} />
@@ -188,7 +218,8 @@ export default async function AdminDashboardPage({ searchParams }) {
Löschen
</button>
</form>
</div>
</div>
</details>
</td>
</tr>
);
@@ -198,7 +229,95 @@ export default async function AdminDashboardPage({ searchParams }) {
</div>
)}
</section>
) : null}
{activeTab === 'requests' ? (
<section className="panel">
<h2>Upload-Anfragen</h2>
{uploadRequests.length === 0 ? (
<p className="muted">Keine Upload-Anfragen vorhanden.</p>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Anfrage</th>
<th>Nutzer</th>
<th>Status</th>
<th>Erstellt</th>
<th>Ablauf</th>
<th>Datei</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{uploadRequests.map((entry) => {
const isCompleted = Number(entry.completed_at || 0) > 0;
const isExpired = !isCompleted && Number(entry.expires_at || 0) <= Date.now();
const requestPath = `/_request/${encodeURIComponent(entry.id)}`;
return (
<tr key={entry.id}>
<td>
<strong>{entry.id}</strong>
{entry.note ? <div className="muted">{entry.note}</div> : null}
</td>
<td>{entry.owner}</td>
<td>
<span className={`badge ${isCompleted ? 'success' : isExpired ? 'danger' : 'primary'}`}>
{isCompleted ? 'Abgeschlossen' : isExpired ? 'Abgelaufen' : 'Offen'}
</span>
</td>
<td>{formatTimestamp(entry.created_at)}</td>
<td>{formatTimestamp(entry.expires_at)}</td>
<td>
{entry.uploaded_original_name ? (
<>
<strong>{entry.uploaded_original_name}</strong>
<div className="muted">{formatBytes(entry.uploaded_size_bytes || 0)}</div>
</>
) : (
<span className="muted">-</span>
)}
</td>
<td>
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<a className="btn secondary" href={requestPath}>
Öffnen
</a>
{entry.upload_id ? (
<form className="inline-form stacked" action={adminSaveUploadToPathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="uploadId" value={entry.upload_id} />
<input type="hidden" name="redirectTab" value="requests" />
<input
className="input small path-input"
name="targetPath"
placeholder="Ziel, z.B. inbox/"
required
/>
<button className="btn secondary" type="submit">
Im Dateimanager speichern
</button>
</form>
) : null}
</div>
</details>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
) : null}
{activeTab === 'logs' ? (
<section className="panel">
<h2>Letzte Ereignisse</h2>
{recentLogs.length === 0 ? (
@@ -247,6 +366,7 @@ export default async function AdminDashboardPage({ searchParams }) {
</div>
)}
</section>
) : null}
</main>
);
}

View File

@@ -35,6 +35,12 @@ function buildBreadcrumbs(relativePath) {
return breadcrumbs;
}
function adminFilesTabHref(relativePath, tab) {
const baseHref = adminFilesHref(relativePath);
const separator = baseHref.includes('?') ? '&' : '?';
return `${baseHref}${separator}tab=${encodeURIComponent(tab)}`;
}
export default async function AdminFilesPage({ searchParams }) {
await runCleanupIfNeeded();
await requireAdminUser();
@@ -45,6 +51,8 @@ export default async function AdminFilesPage({ searchParams }) {
const queryError = readSearchParam(resolvedSearchParams, 'error');
const success = readSearchParam(resolvedSearchParams, 'success');
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
const activeTab = ['content', 'folder', 'upload'].includes(requestedTab) ? requestedTab : 'content';
const absolutePath = resolveAdminPath(relativePath);
if (!absolutePath) {
@@ -164,8 +172,20 @@ export default async function AdminFilesPage({ searchParams }) {
</div>
</section>
<section className="panel" style={{ display: 'grid', gap: '0.8rem', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
<div>
<nav className="tabs" aria-label="Dateimanager-Bereiche">
<a className={`tab ${activeTab === 'content' ? 'active' : ''}`} href={adminFilesTabHref(relativePath, 'content')}>
Inhalt
</a>
<a className={`tab ${activeTab === 'folder' ? 'active' : ''}`} href={adminFilesTabHref(relativePath, 'folder')}>
Neuer Ordner
</a>
<a className={`tab ${activeTab === 'upload' ? 'active' : ''}`} href={adminFilesTabHref(relativePath, 'upload')}>
Upload
</a>
</nav>
{activeTab === 'folder' ? (
<section className="panel">
<h2>Ordner erstellen</h2>
<form className="form-grid" action={adminMkdirAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
@@ -180,9 +200,11 @@ export default async function AdminFilesPage({ searchParams }) {
Erstellen
</button>
</form>
</div>
</section>
) : null}
<div>
{activeTab === 'upload' ? (
<section className="panel">
<h2>Datei hochladen</h2>
<form className="form-grid" action={adminUploadToPathAction} encType="multipart/form-data">
<input type="hidden" name="csrfToken" value={csrfToken} />
@@ -197,9 +219,10 @@ export default async function AdminFilesPage({ searchParams }) {
Hochladen
</button>
</form>
</div>
</section>
</section>
) : null}
{activeTab === 'content' ? (
<section className="panel">
<h2>Inhalt</h2>
{details.length === 0 ? (
@@ -233,44 +256,47 @@ export default async function AdminFilesPage({ searchParams }) {
<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>
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<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={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 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>
<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>
</details>
</td>
</tr>
))}
@@ -279,6 +305,7 @@ export default async function AdminFilesPage({ searchParams }) {
</div>
)}
</section>
) : null}
</main>
);
}

View File

@@ -3,6 +3,7 @@ import {
adminDeleteUserAction,
adminLogoutAction,
adminResetUserAction,
adminSetUserAdminAction,
} from '@/src/lib/actions.js';
import { all, runCleanupIfNeeded } from '@/src/lib/db.js';
import { formatTimestamp, readSearchParam } from '@/src/lib/format.js';
@@ -14,14 +15,16 @@ export const dynamic = 'force-dynamic';
export default async function AdminUsersPage({ searchParams }) {
await runCleanupIfNeeded();
await requireAdminUser();
const actor = await requireAdminUser();
const csrfToken = await ensureCsrfToken();
const users = await all('SELECT username, created_at FROM users ORDER BY username ASC');
const users = await all('SELECT username, created_at, is_admin FROM users ORDER BY username ASC');
const resolvedSearchParams = await searchParams;
const error = readSearchParam(resolvedSearchParams, 'error');
const success = readSearchParam(resolvedSearchParams, 'success');
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
const activeTab = requestedTab === 'create' ? 'create' : 'users';
return (
<main className="page-shell">
@@ -46,6 +49,16 @@ export default async function AdminUsersPage({ searchParams }) {
<StatusMessage error={error} success={success} />
<nav className="tabs" aria-label="Benutzer-Bereiche">
<a className={`tab ${activeTab === 'users' ? 'active' : ''}`} href="/manage/admin/users?tab=users">
Benutzer
</a>
<a className={`tab ${activeTab === 'create' ? 'active' : ''}`} href="/manage/admin/users?tab=create">
Neu
</a>
</nav>
{activeTab === 'create' ? (
<section className="panel">
<h2>Neuen Benutzer anlegen</h2>
<form className="form-grid" action={adminCreateUserAction}>
@@ -61,12 +74,19 @@ export default async function AdminUsersPage({ searchParams }) {
<input className="input" name="password" type="password" autoComplete="new-password" required />
</label>
<label className="check-row">
<input type="checkbox" name="isAdmin" value="1" />
Adminrechte vergeben
</label>
<button className="btn" type="submit">
Benutzer erstellen
</button>
</form>
</section>
) : null}
{activeTab === 'users' ? (
<section className="panel">
<h2>Bestehende Benutzer</h2>
{users.length === 0 ? (
@@ -77,6 +97,7 @@ export default async function AdminUsersPage({ searchParams }) {
<thead>
<tr>
<th>Benutzername</th>
<th>Rolle</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
@@ -85,9 +106,27 @@ export default async function AdminUsersPage({ searchParams }) {
{users.map((user) => (
<tr key={user.username}>
<td>{user.username}</td>
<td>
<span className={`badge ${user.is_admin ? 'primary' : ''}`}>
{user.is_admin ? 'Admin' : 'Benutzer'}
</span>
</td>
<td>{formatTimestamp(user.created_at)}</td>
<td>
<div className="stack-actions">
<form className="inline-form" action={adminSetUserAdminAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="username" value={user.username} />
<input type="hidden" name="isAdmin" value={user.is_admin ? '0' : '1'} />
<button
className="btn secondary"
type="submit"
disabled={actor.username === user.username && Boolean(user.is_admin)}
>
{user.is_admin ? 'Adminrechte entziehen' : 'Zum Admin machen'}
</button>
</form>
<form className="inline-form" action={adminResetUserAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="username" value={user.username} />
@@ -106,7 +145,7 @@ export default async function AdminUsersPage({ searchParams }) {
<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">
<button className="btn danger" type="submit" disabled={actor.username === user.username}>
Benutzer löschen
</button>
</form>
@@ -119,6 +158,7 @@ export default async function AdminUsersPage({ searchParams }) {
</div>
)}
</section>
) : null}
</main>
);
}

View File

@@ -75,7 +75,7 @@ function errorResponse(request, message, status = 400) {
return new NextResponse(null, {
status: 303,
headers: {
location: dashboardHref({ error: message }),
location: dashboardHref({ tab: 'upload', error: message }),
},
});
}
@@ -84,7 +84,7 @@ function errorResponse(request, message, status = 400) {
}
function successResponse(request, message) {
const redirectPath = dashboardHref({ success: message });
const redirectPath = dashboardHref({ tab: 'upload', success: message });
if (expectsHtml(request)) {
return new NextResponse(null, {
status: 303,

View File

@@ -38,6 +38,8 @@ export default async function DashboardPage({ searchParams }) {
const success = readSearchParam(resolvedSearchParams, 'success');
const totalBytes = uploads.reduce((total, item) => total + (Number(item.size_bytes) || 0), 0);
const nowTs = Date.now();
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
const activeTab = ['upload', 'files', 'requests'].includes(requestedTab) ? requestedTab : 'upload';
return (
<main className="page-shell">
@@ -65,27 +67,31 @@ export default async function DashboardPage({ searchParams }) {
<StatusMessage error={error} success={success} />
<div className="dashboard-top-grid">
<section className="panel panel-spotlight">
<nav className="tabs" aria-label="Dashboard-Bereiche">
<a className={`tab ${activeTab === 'upload' ? 'active' : ''}`} href="/manage/dashboard?tab=upload">
Upload
</a>
<a className={`tab ${activeTab === 'files' ? 'active' : ''}`} href="/manage/dashboard?tab=files">
Dateien
</a>
<a className={`tab ${activeTab === 'requests' ? 'active' : ''}`} href="/manage/dashboard?tab=requests">
Anfragen
</a>
</nav>
<div className="summary-line">
<span>{uploads.length} aktive Uploads</span>
<span>{totalBytes > 0 ? formatBytes(totalBytes) : '0 B'} genutzt</span>
</div>
{activeTab === 'upload' ? (
<section className="panel">
<h2>Neue Datei hochladen</h2>
<UploadProgressForm csrfToken={csrfToken} />
</section>
) : null}
<aside className="panel panel-soft">
<h2>Schnellüberblick</h2>
<div className="info-stack">
<div className="info-card">
<strong>{uploads.length}</strong>
<span className="muted">aktive Uploads</span>
</div>
<div className="info-card">
<strong>{totalBytes > 0 ? formatBytes(totalBytes) : '0 B'}</strong>
<span className="muted">genutzter Speicher</span>
</div>
</div>
</aside>
</div>
{activeTab === 'requests' ? (
<section className="panel">
<h2>Upload-Anfragen</h2>
<p className="muted">Benachrichtigung erfolgt an deinen Benutzernamen (E-Mail-Adresse).</p>
@@ -162,7 +168,9 @@ export default async function DashboardPage({ searchParams }) {
</div>
)}
</section>
) : null}
{activeTab === 'files' ? (
<section className="panel">
<h2>Aktuelle Uploads</h2>
{uploads.length === 0 ? (
@@ -225,6 +233,7 @@ export default async function DashboardPage({ searchParams }) {
</div>
)}
</section>
) : null}
</main>
);
}