From a2b6054440e41e80fa39dbe05ab5744064a00b53 Mon Sep 17 00:00:00 2001 From: Ludwig Lehnert Date: Thu, 18 Jun 2026 17:09:07 +0000 Subject: [PATCH] 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. --- .env.example | 2 +- .gitignore | 1 + README.md | 18 +- nextjs/app/%5Frequest/[id]/page.js | 7 +- nextjs/app/%5Frequest/[id]/upload/route.js | 176 +++++++- nextjs/app/globals.css | 426 +++++++++--------- .../_components/upload-progress-form.js | 2 +- nextjs/app/manage/admin/dashboard/page.js | 154 ++++++- nextjs/app/manage/admin/files/page.js | 109 +++-- nextjs/app/manage/admin/users/page.js | 46 +- nextjs/app/manage/api/upload/route.js | 4 +- nextjs/app/manage/dashboard/page.js | 43 +- nextjs/next.config.mjs | 2 +- nextjs/src/lib/actions.js | 263 ++++++++--- nextjs/src/lib/config.js | 2 +- nextjs/src/lib/db.js | 4 +- nextjs/src/lib/multipart.js | 43 +- nextjs/src/lib/security.js | 16 +- scripts/dev | 226 ++++++++++ 19 files changed, 1148 insertions(+), 396 deletions(-) create mode 100755 scripts/dev diff --git a/.env.example b/.env.example index 8b8b3ec..efde9a8 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ LETSENCRYPT_EMAIL=user@example.com PUBLIC_BASE_URL= DATA_DIR=/storagebox UPLOAD_TTL_SECONDS=604800 -UPLOAD_MAX_BYTES=0 +UPLOAD_MAX_BYTES=26843545600 MANAGEMENT_ADMIN_HASH= COOKIE_SECURE=true SMTP_HOST= diff --git a/.gitignore b/.gitignore index fb18d95..c797890 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +.dev/ traefik/ node_modules/ nextjs/data/ diff --git a/README.md b/README.md index 761405c..4dce6a4 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,31 @@ File server infrastructure hosted on [files.lehnert.cloud](https://files.lehnert - Datei-Downloads: `/_share/` - Upload-Anfragen: `/_request/` +Upload-Anfragen akzeptieren Browser-Multipart und rohe PUT-Uploads auf `/_request//upload`. +Beispiel mit `curl`: + +```bash +curl -T ./datei.pdf -H 'X-File-Name: datei.pdf' 'https://files.example.tld/_request//upload' +``` + ## Lokale Initialisierung +Dev-Vorschau ohne lokale Node/npm-Installation: + +```bash +./scripts/dev +``` + +Admin-Benutzer: `john.doe` / `123456`. Adminbereich: `/manage/admin`, Passwort `123456`. +Dev-Container laufen mit `--rm`; `Ctrl-C`/`SIGTERM` entfernen den laufenden Container per `rm -f`. + ```bash ./initialize.sh ``` Danach: -1. `.env` anpassen (`SERVICE_FQDN`, `LETSENCRYPT_EMAIL`, `DATA_DIR`, `UPLOAD_TTL_SECONDS`, `MANAGEMENT_ADMIN_HASH`, optional `UPLOAD_MAX_BYTES` und `COOKIE_SECURE`) +1. `.env` anpassen (`SERVICE_FQDN`, `LETSENCRYPT_EMAIL`, `DATA_DIR`, `UPLOAD_TTL_SECONDS`, `MANAGEMENT_ADMIN_HASH`, optional `UPLOAD_MAX_BYTES` Standard: 25 GiB, und `COOKIE_SECURE`) 2. Optional `PUBLIC_BASE_URL` setzen, falls absolute Links in E-Mails einen festen Host verwenden sollen 3. Für Upload-Anfragen mit E-Mail-Benachrichtigung SMTP setzen (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_MAIL`, `SMTP_NAME`; Absender: `SMTP_NAME `) 4. Stack starten: `docker compose up --build` diff --git a/nextjs/app/%5Frequest/[id]/page.js b/nextjs/app/%5Frequest/[id]/page.js index 5ecb18c..1290ce2 100644 --- a/nextjs/app/%5Frequest/[id]/page.js +++ b/nextjs/app/%5Frequest/[id]/page.js @@ -110,7 +110,7 @@ export default async function UploadRequestPage({ params, searchParams }) { {state === 'open' ? ( -
+

Datei hochladen

+ +
+ Upload per curl +
{`curl -T ./datei.pdf -H 'X-File-Name: datei.pdf' 'https://HOST/_request/${requestEntry.id}/upload'`}
+
) : null} diff --git a/nextjs/app/%5Frequest/[id]/upload/route.js b/nextjs/app/%5Frequest/[id]/upload/route.js index df953d1..4714050 100644 --- a/nextjs/app/%5Frequest/[id]/upload/route.js +++ b/nextjs/app/%5Frequest/[id]/upload/route.js @@ -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); } diff --git a/nextjs/app/globals.css b/nextjs/app/globals.css index ceb968d..2d21249 100644 --- a/nextjs/app/globals.css +++ b/nextjs/app/globals.css @@ -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; } diff --git a/nextjs/app/manage/_components/upload-progress-form.js b/nextjs/app/manage/_components/upload-progress-form.js index 707b3af..6f7d278 100644 --- a/nextjs/app/manage/_components/upload-progress-form.js +++ b/nextjs/app/manage/_components/upload-progress-form.js @@ -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; } diff --git a/nextjs/app/manage/admin/dashboard/page.js b/nextjs/app/manage/admin/dashboard/page.js index a76fdf6..23601d6 100644 --- a/nextjs/app/manage/admin/dashboard/page.js +++ b/nextjs/app/manage/admin/dashboard/page.js @@ -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 ( -
-
-

Adminzugang nicht konfiguriert

-

Setze MANAGEMENT_ADMIN_HASH in der Umgebungskonfiguration.

- - Zurück - -
-
- ); - } - 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 (
@@ -100,6 +95,22 @@ export default async function AdminDashboardPage({ searchParams }) { + + + {activeTab === 'stats' ? (

Statistiken

@@ -133,7 +144,9 @@ export default async function AdminDashboardPage({ searchParams }) {
+ ) : null} + {activeTab === 'uploads' ? (

Aktuelle Uploads

{allUploads.length === 0 ? ( @@ -169,7 +182,9 @@ export default async function AdminDashboardPage({ searchParams }) {
Noch {formatCountdown(item.expires_at)}
- + ); @@ -198,7 +229,95 @@ export default async function AdminDashboardPage({ searchParams }) { )}
+ ) : null} + {activeTab === 'requests' ? ( +
+

Upload-Anfragen

+ {uploadRequests.length === 0 ? ( +

Keine Upload-Anfragen vorhanden.

+ ) : ( +
+ + + + + + + + + + + + + + {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 ( + + + + + + + + + + ); + })} + +
AnfrageNutzerStatusErstelltAblaufDateiAktionen
+ {entry.id} + {entry.note ?
{entry.note}
: null} +
{entry.owner} + + {isCompleted ? 'Abgeschlossen' : isExpired ? 'Abgelaufen' : 'Offen'} + + {formatTimestamp(entry.created_at)}{formatTimestamp(entry.expires_at)} + {entry.uploaded_original_name ? ( + <> + {entry.uploaded_original_name} +
{formatBytes(entry.uploaded_size_bytes || 0)}
+ + ) : ( + - + )} +
+
+ Aktionen +
+ + Öffnen + + + {entry.upload_id ? ( +
+ + + + + +
+ ) : null} +
+
+
+
+ )} +
+ ) : null} + + {activeTab === 'logs' ? (

Letzte Ereignisse

{recentLogs.length === 0 ? ( @@ -247,6 +366,7 @@ export default async function AdminDashboardPage({ searchParams }) { )}
+ ) : null}
); } diff --git a/nextjs/app/manage/admin/files/page.js b/nextjs/app/manage/admin/files/page.js index 3c2f6f8..0ac5eda 100644 --- a/nextjs/app/manage/admin/files/page.js +++ b/nextjs/app/manage/admin/files/page.js @@ -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 }) {
-
-
+ + + {activeTab === 'folder' ? ( +

Ordner erstellen

@@ -180,9 +200,11 @@ export default async function AdminFilesPage({ searchParams }) { Erstellen
-
+
+ ) : null} -
+ {activeTab === 'upload' ? ( +

Datei hochladen

@@ -197,9 +219,10 @@ export default async function AdminFilesPage({ searchParams }) { Hochladen
-
- + + ) : null} + {activeTab === 'content' ? (

Inhalt

{details.length === 0 ? ( @@ -233,44 +256,47 @@ export default async function AdminFilesPage({ searchParams }) { {item.size != null ? formatBytes(item.size) : '-'} {item.modifiedAt ? formatTimestamp(item.modifiedAt) : '-'} -
-
- - - - -
+
+ Aktionen +
+
+ + + + +
-
- - - - - -
+
+ + + + + +
-
- - - - - -
+
+ + + + + +
-
- - - -
-
+
+ + + +
+
+ ))} @@ -279,6 +305,7 @@ export default async function AdminFilesPage({ searchParams }) { )}
+ ) : null} ); } diff --git a/nextjs/app/manage/admin/users/page.js b/nextjs/app/manage/admin/users/page.js index 5908c58..3bb72ac 100644 --- a/nextjs/app/manage/admin/users/page.js +++ b/nextjs/app/manage/admin/users/page.js @@ -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 (
@@ -46,6 +49,16 @@ export default async function AdminUsersPage({ searchParams }) { + + + {activeTab === 'create' ? (

Neuen Benutzer anlegen

@@ -61,12 +74,19 @@ export default async function AdminUsersPage({ searchParams }) { + +
+ ) : null} + {activeTab === 'users' ? (

Bestehende Benutzer

{users.length === 0 ? ( @@ -77,6 +97,7 @@ export default async function AdminUsersPage({ searchParams }) { Benutzername + Rolle Erstellt Aktionen @@ -85,9 +106,27 @@ export default async function AdminUsersPage({ searchParams }) { {users.map((user) => ( {user.username} + + + {user.is_admin ? 'Admin' : 'Benutzer'} + + {formatTimestamp(user.created_at)}
+
+ + + + +
+
@@ -106,7 +145,7 @@ export default async function AdminUsersPage({ searchParams }) { -
@@ -119,6 +158,7 @@ export default async function AdminUsersPage({ searchParams }) {
)}
+ ) : null}
); } diff --git a/nextjs/app/manage/api/upload/route.js b/nextjs/app/manage/api/upload/route.js index c5fb8a6..9960f90 100644 --- a/nextjs/app/manage/api/upload/route.js +++ b/nextjs/app/manage/api/upload/route.js @@ -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, diff --git a/nextjs/app/manage/dashboard/page.js b/nextjs/app/manage/dashboard/page.js index 989a0cc..777ff6c 100644 --- a/nextjs/app/manage/dashboard/page.js +++ b/nextjs/app/manage/dashboard/page.js @@ -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 (
@@ -65,27 +67,31 @@ export default async function DashboardPage({ searchParams }) { -
-
+ + +
+ {uploads.length} aktive Uploads + {totalBytes > 0 ? formatBytes(totalBytes) : '0 B'} genutzt +
+ + {activeTab === 'upload' ? ( +

Neue Datei hochladen

+ ) : null} - -
- + {activeTab === 'requests' ? (

Upload-Anfragen

Benachrichtigung erfolgt an deinen Benutzernamen (E-Mail-Adresse).

@@ -162,7 +168,9 @@ export default async function DashboardPage({ searchParams }) { )}
+ ) : null} + {activeTab === 'files' ? (

Aktuelle Uploads

{uploads.length === 0 ? ( @@ -225,6 +233,7 @@ export default async function DashboardPage({ searchParams }) { )}
+ ) : null}
); } diff --git a/nextjs/next.config.mjs b/nextjs/next.config.mjs index 5363f71..6b1a8fd 100644 --- a/nextjs/next.config.mjs +++ b/nextjs/next.config.mjs @@ -2,7 +2,7 @@ const uploadMaxBytes = Number.parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10); const actionBodySizeLimit = Number.isFinite(uploadMaxBytes) && uploadMaxBytes > 0 ? `${uploadMaxBytes}` - : '1gb'; + : '25gb'; const managementPath = '/manage'; const nextConfig = { diff --git a/nextjs/src/lib/actions.js b/nextjs/src/lib/actions.js index c3aee76..ddef10b 100644 --- a/nextjs/src/lib/actions.js +++ b/nextjs/src/lib/actions.js @@ -37,7 +37,8 @@ import { } from './security.js'; function buildPathWithQuery(pathname, params = {}) { - const query = new URLSearchParams(); + const [pathOnly, existingQuery = ''] = String(pathname).split('?'); + const query = new URLSearchParams(existingQuery); for (const [key, value] of Object.entries(params)) { if (value) { @@ -46,7 +47,7 @@ function buildPathWithQuery(pathname, params = {}) { } const serialized = query.toString(); - return serialized ? `${pathname}?${serialized}` : pathname; + return serialized ? `${pathOnly}?${serialized}` : pathOnly; } function redirectWithError(pathname, message) { @@ -57,6 +58,10 @@ function redirectWithSuccess(pathname, message) { redirect(buildPathWithQuery(pathname, { success: message })); } +function adminFilesTabHref(relativePath, tab) { + return buildPathWithQuery(adminFilesHref(relativePath), { tab }); +} + function getUploadId(formData, fieldName = 'uploadId') { const parsed = Number.parseInt(String(formData.get(fieldName) || ''), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; @@ -156,6 +161,33 @@ async function copyPath(sourcePath, targetPath) { }); } +function savedUploadFileName(originalName, storedName) { + const fallback = safeBaseName(storedName, 'upload'); + const candidate = safeBaseName(originalName, fallback).replace(/[\\/]/g, '_'); + return isValidNodeName(candidate) ? candidate : fallback; +} + +async function resolveSavedUploadTarget(targetRelativePath, fileName) { + const targetPath = resolveAdminPath(targetRelativePath); + if (!targetPath) { + return null; + } + + try { + const targetStat = await fs.promises.stat(targetPath); + if (targetStat.isDirectory()) { + return path.join(targetPath, fileName); + } + } catch { + } + + if (!isValidNodeName(path.basename(targetPath))) { + return null; + } + + return targetPath; +} + export async function userLoginAction(formData) { await runCleanupIfNeeded(); @@ -175,13 +207,13 @@ export async function userLoginAction(formData) { const username = String(formData.get('username') || '').trim(); const password = String(formData.get('password') || ''); - const row = await get('SELECT password_hash FROM users WHERE username = ?', [username]); + const row = await get('SELECT password_hash, is_admin FROM users WHERE username = ?', [username]); if (!row || !bcrypt.compareSync(password, row.password_hash)) { redirectWithError(`${managementBasePath}/login`, 'Anmeldung fehlgeschlagen.'); } - await setAuthCookie({ sub: username }); + await setAuthCookie({ sub: username, admin: Boolean(row.is_admin), auth: 'user' }); await clearLoginRateLimit('user'); await logEvent('login', username, { ok: true }, await getRequestMeta()); redirect(`${managementBasePath}/dashboard`); @@ -191,7 +223,7 @@ export async function userLogoutAction(formData) { try { await verifyCsrf(formData); } catch { - redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.'); + redirectWithError(`${managementBasePath}/dashboard?tab=upload`, 'CSRF-Prüfung fehlgeschlagen.'); } await clearAuthCookie(); @@ -222,7 +254,7 @@ export async function adminLoginAction(formData) { redirectWithError(`${managementBasePath}/admin`, 'Anmeldung fehlgeschlagen.'); } - await setAuthCookie({ sub: 'admin', admin: true }); + await setAuthCookie({ sub: 'admin', admin: true, auth: 'admin' }); await clearLoginRateLimit('admin'); await logEvent('admin_login', 'admin', { ok: true }, await getRequestMeta()); redirect(`${managementBasePath}/admin/dashboard`); @@ -232,7 +264,7 @@ export async function adminLogoutAction(formData) { try { await verifyCsrf(formData); } catch { - redirectWithError(`${managementBasePath}/admin/dashboard`, 'CSRF-Prüfung fehlgeschlagen.'); + redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'CSRF-Prüfung fehlgeschlagen.'); } await clearAuthCookie(); @@ -245,18 +277,18 @@ export async function uploadFileAction(formData) { try { await verifyCsrf(formData); } catch { - redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.'); + redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'CSRF-Prüfung fehlgeschlagen.'); } const user = await requireAuthenticatedUser(); const uploadedFile = uploadedFileFromForm(formData, 'file'); if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) { - redirectWithError(`${managementBasePath}/dashboard`, 'Keine Datei hochgeladen.'); + redirectWithError(`${managementBasePath}/dashboard?tab=upload`, 'Keine Datei hochgeladen.'); } if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) { redirectWithError( - `${managementBasePath}/dashboard`, + `${managementBasePath}/dashboard?tab=upload`, `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).` ); } @@ -285,7 +317,7 @@ export async function uploadFileAction(formData) { ] ); } catch { - redirectWithError(`${managementBasePath}/dashboard`, 'Upload fehlgeschlagen.'); + redirectWithError(`${managementBasePath}/dashboard?tab=upload`, 'Upload fehlgeschlagen.'); } await logEvent( @@ -295,7 +327,7 @@ export async function uploadFileAction(formData) { await getRequestMeta() ); - redirectWithSuccess(`${managementBasePath}/dashboard`, 'Upload abgeschlossen.'); + redirectWithSuccess(`${managementBasePath}/dashboard?tab=upload`, 'Upload abgeschlossen.'); } export async function deleteOwnUploadAction(formData) { @@ -308,7 +340,7 @@ export async function deleteOwnUploadAction(formData) { const user = await requireAuthenticatedUser(); const uploadId = getUploadId(formData); if (!uploadId) { - redirectWithError(`${managementBasePath}/dashboard`, 'Ungültige Upload-ID.'); + redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'Ungültige Upload-ID.'); } const uploadEntry = await get( @@ -317,7 +349,7 @@ export async function deleteOwnUploadAction(formData) { ); if (!uploadEntry) { - redirectWithError(`${managementBasePath}/dashboard`, 'Upload nicht gefunden.'); + redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'Upload nicht gefunden.'); } try { @@ -327,20 +359,20 @@ export async function deleteOwnUploadAction(formData) { await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]); await logEvent('delete', user.username, { id: uploadEntry.id }, await getRequestMeta()); - redirectWithSuccess(`${managementBasePath}/dashboard`, 'Upload gelöscht.'); + redirectWithSuccess(`${managementBasePath}/dashboard?tab=files`, 'Upload gelöscht.'); } export async function extendOwnUploadAction(formData) { try { await verifyCsrf(formData); } catch { - redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.'); + redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'CSRF-Prüfung fehlgeschlagen.'); } const user = await requireAuthenticatedUser(); const uploadId = getUploadId(formData); if (!uploadId) { - redirectWithError(`${managementBasePath}/dashboard`, 'Ungültige Upload-ID.'); + redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'Ungültige Upload-ID.'); } const uploadEntry = await get( @@ -349,7 +381,7 @@ export async function extendOwnUploadAction(formData) { ); if (!uploadEntry) { - redirectWithError(`${managementBasePath}/dashboard`, 'Upload nicht gefunden.'); + redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'Upload nicht gefunden.'); } const extensionSeconds = parseHours(formData.get('extendHours'), uploadTtlSeconds); @@ -366,7 +398,7 @@ export async function extendOwnUploadAction(formData) { await getRequestMeta() ); - redirectWithSuccess(`${managementBasePath}/dashboard`, 'Aufbewahrung aktualisiert.'); + redirectWithSuccess(`${managementBasePath}/dashboard?tab=files`, 'Aufbewahrung aktualisiert.'); } export async function createUploadRequestAction(formData) { @@ -375,7 +407,7 @@ export async function createUploadRequestAction(formData) { try { await verifyCsrf(formData); } catch { - redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.'); + redirectWithError(`${managementBasePath}/dashboard?tab=requests`, 'CSRF-Prüfung fehlgeschlagen.'); } const user = await requireAuthenticatedUser(); @@ -395,7 +427,7 @@ export async function createUploadRequestAction(formData) { } if (!requestId) { - redirectWithError(`${managementBasePath}/dashboard`, 'Anfrage-ID konnte nicht erzeugt werden.'); + redirectWithError(`${managementBasePath}/dashboard?tab=requests`, 'Anfrage-ID konnte nicht erzeugt werden.'); } try { @@ -404,7 +436,7 @@ export async function createUploadRequestAction(formData) { [requestId, user.username, note || null, now, expiresAt] ); } catch { - redirectWithError(`${managementBasePath}/dashboard`, 'Anfrage konnte nicht gespeichert werden.'); + redirectWithError(`${managementBasePath}/dashboard?tab=requests`, 'Anfrage konnte nicht gespeichert werden.'); } await logEvent( @@ -414,44 +446,77 @@ export async function createUploadRequestAction(formData) { await getRequestMeta() ); - redirectWithSuccess(`${managementBasePath}/dashboard`, 'Upload-Anfrage erstellt.'); + redirectWithSuccess(`${managementBasePath}/dashboard?tab=requests`, 'Upload-Anfrage erstellt.'); } export async function adminCreateUserAction(formData) { try { await verifyCsrf(formData); } catch { - redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.'); + redirectWithError(`${managementBasePath}/admin/users?tab=create`, 'CSRF-Prüfung fehlgeschlagen.'); } await requireAdminUser(); const username = String(formData.get('username') || '').trim(); const password = String(formData.get('password') || ''); + const isAdmin = String(formData.get('isAdmin') || '') === '1'; if (!username || username.length > 200 || !password) { - redirectWithError(`${managementBasePath}/admin/users`, 'Ungültige Eingabe.'); + redirectWithError(`${managementBasePath}/admin/users?tab=create`, 'Ungültige Eingabe.'); } const passwordHash = bcrypt.hashSync(password, 12); try { - await run('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)', [ + await run('INSERT INTO users (username, password_hash, created_at, is_admin) VALUES (?, ?, ?, ?)', [ username, passwordHash, Date.now(), + isAdmin ? 1 : 0, ]); } catch { - redirectWithError(`${managementBasePath}/admin/users`, 'Benutzername existiert bereits.'); + redirectWithError(`${managementBasePath}/admin/users?tab=create`, 'Benutzername existiert bereits.'); } - await logEvent('admin_user_create', 'admin', { username }, await getRequestMeta()); - redirectWithSuccess(`${managementBasePath}/admin/users`, 'Benutzer erstellt.'); + await logEvent('admin_user_create', 'admin', { username, is_admin: isAdmin }, await getRequestMeta()); + redirectWithSuccess(`${managementBasePath}/admin/users?tab=users`, 'Benutzer erstellt.'); +} + +export async function adminSetUserAdminAction(formData) { + try { + await verifyCsrf(formData); + } catch { + redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'CSRF-Prüfung fehlgeschlagen.'); + } + + const actor = await requireAdminUser(); + + const username = String(formData.get('username') || '').trim(); + const isAdmin = String(formData.get('isAdmin') || '') === '1'; + if (!username) { + redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Ungültige Eingabe.'); + } + + if (actor.username === username && !isAdmin) { + redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Eigene Adminrechte können nicht entzogen werden.'); + } + + const result = await run('UPDATE users SET is_admin = ? WHERE username = ?', [isAdmin ? 1 : 0, username]); + if (!result || result.changes < 1) { + redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Benutzer nicht gefunden.'); + } + + await logEvent('admin_user_role', 'admin', { username, is_admin: isAdmin }, await getRequestMeta()); + redirectWithSuccess( + `${managementBasePath}/admin/users?tab=users`, + isAdmin ? 'Adminrechte vergeben.' : 'Adminrechte entzogen.' + ); } export async function adminResetUserAction(formData) { try { await verifyCsrf(formData); } catch { - redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.'); + redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'CSRF-Prüfung fehlgeschlagen.'); } await requireAdminUser(); @@ -459,32 +524,36 @@ export async function adminResetUserAction(formData) { const username = String(formData.get('username') || '').trim(); const password = String(formData.get('password') || ''); if (!username || !password) { - redirectWithError(`${managementBasePath}/admin/users`, 'Ungültige Eingabe.'); + redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Ungültige Eingabe.'); } const passwordHash = bcrypt.hashSync(password, 12); await run('UPDATE users SET password_hash = ? WHERE username = ?', [passwordHash, username]); await logEvent('admin_user_reset', 'admin', { username }, await getRequestMeta()); - redirectWithSuccess(`${managementBasePath}/admin/users`, 'Passwort aktualisiert.'); + redirectWithSuccess(`${managementBasePath}/admin/users?tab=users`, 'Passwort aktualisiert.'); } export async function adminDeleteUserAction(formData) { try { await verifyCsrf(formData); } catch { - redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.'); + redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'CSRF-Prüfung fehlgeschlagen.'); } - await requireAdminUser(); + const actor = await requireAdminUser(); const username = String(formData.get('username') || '').trim(); if (!username) { - redirectWithError(`${managementBasePath}/admin/users`, 'Ungültige Eingabe.'); + redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Ungültige Eingabe.'); + } + + if (actor.username === username) { + redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Eigener Benutzer kann nicht gelöscht werden.'); } await run('DELETE FROM users WHERE username = ?', [username]); await logEvent('admin_user_delete', 'admin', { username }, await getRequestMeta()); - redirectWithSuccess(`${managementBasePath}/admin/users`, 'Benutzer gelöscht.'); + redirectWithSuccess(`${managementBasePath}/admin/users?tab=users`, 'Benutzer gelöscht.'); } export async function adminDeleteUploadAction(formData) { @@ -498,12 +567,12 @@ export async function adminDeleteUploadAction(formData) { const uploadId = getUploadId(formData); if (!uploadId) { - redirectWithError(`${managementBasePath}/admin/dashboard`, 'Ungültige Upload-ID.'); + redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Ungültige Upload-ID.'); } const uploadEntry = await get('SELECT id, stored_path FROM uploads WHERE id = ?', [uploadId]); if (!uploadEntry) { - redirectWithError(`${managementBasePath}/admin/dashboard`, 'Upload nicht gefunden.'); + redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Upload nicht gefunden.'); } try { @@ -513,26 +582,26 @@ export async function adminDeleteUploadAction(formData) { await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]); await logEvent('delete', 'admin', { id: uploadEntry.id }, await getRequestMeta()); - redirectWithSuccess(`${managementBasePath}/admin/dashboard`, 'Upload gelöscht.'); + redirectWithSuccess(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Upload gelöscht.'); } export async function adminExtendUploadAction(formData) { try { await verifyCsrf(formData); } catch { - redirectWithError(`${managementBasePath}/admin/dashboard`, 'CSRF-Prüfung fehlgeschlagen.'); + redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'CSRF-Prüfung fehlgeschlagen.'); } await requireAdminUser(); const uploadId = getUploadId(formData); if (!uploadId) { - redirectWithError(`${managementBasePath}/admin/dashboard`, 'Ungültige Upload-ID.'); + redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Ungültige Upload-ID.'); } const uploadEntry = await get('SELECT id, expires_at FROM uploads WHERE id = ?', [uploadId]); if (!uploadEntry) { - redirectWithError(`${managementBasePath}/admin/dashboard`, 'Upload nicht gefunden.'); + redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Upload nicht gefunden.'); } const extensionSeconds = parseHours(formData.get('extendHours'), uploadTtlSeconds); @@ -549,7 +618,61 @@ export async function adminExtendUploadAction(formData) { await getRequestMeta() ); - redirectWithSuccess(`${managementBasePath}/admin/dashboard`, 'Aufbewahrung aktualisiert.'); + redirectWithSuccess(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Aufbewahrung aktualisiert.'); +} + +export async function adminSaveUploadToPathAction(formData) { + const requestedTab = String(formData.get('redirectTab') || 'uploads'); + const redirectTab = ['uploads', 'requests'].includes(requestedTab) ? requestedTab : 'uploads'; + const dashboardPath = `${managementBasePath}/admin/dashboard?tab=${redirectTab}`; + + try { + await verifyCsrf(formData); + } catch { + redirectWithError(dashboardPath, 'CSRF-Prüfung fehlgeschlagen.'); + } + + await requireAdminUser(); + + const uploadId = getUploadId(formData); + const rawTargetPath = String(formData.get('targetPath') || '').trim(); + const targetRelativePath = sanitizeRelativePath(rawTargetPath); + if (!uploadId || !rawTargetPath || !targetRelativePath) { + redirectWithError(dashboardPath, 'Upload und Zielpfad sind erforderlich.'); + } + + const uploadEntry = await get( + 'SELECT id, original_name, stored_name, stored_path FROM uploads WHERE id = ?', + [uploadId] + ); + if (!uploadEntry) { + redirectWithError(dashboardPath, 'Upload nicht gefunden.'); + } + + const fileName = savedUploadFileName(uploadEntry.original_name, uploadEntry.stored_name); + const targetPath = await resolveSavedUploadTarget(targetRelativePath, fileName); + if (!targetPath) { + redirectWithError(dashboardPath, 'Ungültiger Zielpfad.'); + } + + try { + await fs.promises.cp(uploadEntry.stored_path, targetPath, { force: false, errorOnExist: true }); + } catch { + redirectWithError( + dashboardPath, + 'Upload konnte nicht gespeichert werden. Ziel existiert evtl. bereits.' + ); + } + + const relativeTarget = sanitizeRelativePath(path.relative(resolveAdminPath(''), targetPath)); + await logEvent( + 'admin_upload_save', + 'admin', + { upload_id: uploadEntry.id, target: relativeTarget }, + await getRequestMeta() + ); + + redirectWithSuccess(dashboardPath, 'Upload im Dateimanager gespeichert.'); } export async function adminMkdirAction(formData) { @@ -565,19 +688,19 @@ export async function adminMkdirAction(formData) { const folderName = String(formData.get('name') || '').trim(); if (!isValidNodeName(folderName)) { - redirectWithError(adminFilesHref(relativePath), 'Ungültiger Ordnername.'); + redirectWithError(adminFilesTabHref(relativePath, 'folder'), 'Ungültiger Ordnername.'); } const basePath = resolveAdminPath(relativePath); if (!basePath) { - redirectWithError(adminFilesHref(relativePath), 'Ungültiger Pfad.'); + redirectWithError(adminFilesTabHref(relativePath, 'folder'), 'Ungültiger Pfad.'); } const targetPath = path.join(basePath, folderName); try { await fs.promises.mkdir(targetPath, { recursive: true }); } catch { - redirectWithError(adminFilesHref(relativePath), 'Ordner konnte nicht erstellt werden.'); + redirectWithError(adminFilesTabHref(relativePath, 'folder'), 'Ordner konnte nicht erstellt werden.'); } await logEvent( @@ -587,7 +710,7 @@ export async function adminMkdirAction(formData) { await getRequestMeta() ); - redirectWithSuccess(adminFilesHref(relativePath), 'Ordner erstellt.'); + redirectWithSuccess(adminFilesTabHref(relativePath, 'content'), 'Ordner erstellt.'); } export async function adminUploadToPathAction(formData) { @@ -602,30 +725,30 @@ export async function adminUploadToPathAction(formData) { const relativePath = sanitizeRelativePath(formData.get('path')); const basePath = resolveAdminPath(relativePath); if (!basePath) { - redirectWithError(adminFilesHref(relativePath), 'Ungültiger Pfad.'); + redirectWithError(adminFilesTabHref(relativePath, 'upload'), 'Ungültiger Pfad.'); } const uploadedFile = uploadedFileFromForm(formData, 'file'); if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) { - redirectWithError(adminFilesHref(relativePath), 'Keine Datei hochgeladen.'); + redirectWithError(adminFilesTabHref(relativePath, 'upload'), 'Keine Datei hochgeladen.'); } if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) { redirectWithError( - adminFilesHref(relativePath), + adminFilesTabHref(relativePath, 'upload'), `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).` ); } const fileName = safeBaseName(uploadedFile.name, 'upload'); if (!isValidNodeName(fileName)) { - redirectWithError(adminFilesHref(relativePath), 'Ungültiger Dateiname.'); + redirectWithError(adminFilesTabHref(relativePath, 'upload'), 'Ungültiger Dateiname.'); } try { await writeUploadedFile(uploadedFile, path.join(basePath, fileName)); } catch { - redirectWithError(adminFilesHref(relativePath), 'Datei konnte nicht hochgeladen werden.'); + redirectWithError(adminFilesTabHref(relativePath, 'upload'), 'Datei konnte nicht hochgeladen werden.'); } await logEvent( @@ -635,7 +758,7 @@ export async function adminUploadToPathAction(formData) { await getRequestMeta() ); - redirectWithSuccess(adminFilesHref(relativePath), 'Datei hochgeladen.'); + redirectWithSuccess(adminFilesTabHref(relativePath, 'content'), 'Datei hochgeladen.'); } export async function adminRenamePathAction(formData) { @@ -655,7 +778,7 @@ export async function adminRenamePathAction(formData) { } if (!isValidNodeName(newName)) { - redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Ungültiger Name.'); + redirectWithError(adminFilesTabHref(parentRelativePath(relativePath), 'content'), 'Ungültiger Name.'); } const sourcePath = resolveAdminPath(relativePath); @@ -667,7 +790,10 @@ export async function adminRenamePathAction(formData) { try { await fs.promises.rename(sourcePath, targetPath); } catch { - redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Pfad konnte nicht umbenannt werden.'); + redirectWithError( + adminFilesTabHref(parentRelativePath(relativePath), 'content'), + 'Pfad konnte nicht umbenannt werden.' + ); } await logEvent( @@ -680,7 +806,7 @@ export async function adminRenamePathAction(formData) { await getRequestMeta() ); - redirectWithSuccess(adminFilesHref(parentRelativePath(relativePath)), 'Pfad umbenannt.'); + redirectWithSuccess(adminFilesTabHref(parentRelativePath(relativePath), 'content'), 'Pfad umbenannt.'); } export async function adminDeletePathAction(formData) { @@ -705,11 +831,14 @@ export async function adminDeletePathAction(formData) { try { await fs.promises.rm(sourcePath, { recursive: true, force: true }); } catch { - redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Pfad konnte nicht gelöscht werden.'); + redirectWithError( + adminFilesTabHref(parentRelativePath(relativePath), 'content'), + 'Pfad konnte nicht gelöscht werden.' + ); } await logEvent('admin_delete', 'admin', { path: relativePath }, await getRequestMeta()); - redirectWithSuccess(adminFilesHref(parentRelativePath(relativePath)), 'Pfad gelöscht.'); + redirectWithSuccess(adminFilesTabHref(parentRelativePath(relativePath), 'content'), 'Pfad gelöscht.'); } export async function adminMovePathAction(formData) { @@ -726,13 +855,13 @@ export async function adminMovePathAction(formData) { const targetRelativePath = sanitizeRelativePath(formData.get('targetPath')); if (!sourceRelativePath || !targetRelativePath) { - redirectWithError(adminFilesHref(currentPath), 'Ungültige Eingabe.'); + redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Ungültige Eingabe.'); } const sourcePath = resolveAdminPath(sourceRelativePath); const targetBasePath = resolveAdminPath(targetRelativePath); if (!sourcePath || !targetBasePath) { - redirectWithError(adminFilesHref(currentPath), 'Ungültiger Pfad.'); + redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Ungültiger Pfad.'); } const targetPath = await resolveMoveCopyTarget(sourcePath, targetBasePath); @@ -745,10 +874,10 @@ export async function adminMovePathAction(formData) { await copyPath(sourcePath, targetPath); await fs.promises.rm(sourcePath, { recursive: true, force: true }); } catch { - redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht verschoben werden.'); + redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Pfad konnte nicht verschoben werden.'); } } else { - redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht verschoben werden.'); + redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Pfad konnte nicht verschoben werden.'); } } @@ -759,7 +888,7 @@ export async function adminMovePathAction(formData) { await getRequestMeta() ); - redirectWithSuccess(adminFilesHref(currentPath), 'Pfad verschoben.'); + redirectWithSuccess(adminFilesTabHref(currentPath, 'content'), 'Pfad verschoben.'); } export async function adminCopyPathAction(formData) { @@ -776,13 +905,13 @@ export async function adminCopyPathAction(formData) { const targetRelativePath = sanitizeRelativePath(formData.get('targetPath')); if (!sourceRelativePath || !targetRelativePath) { - redirectWithError(adminFilesHref(currentPath), 'Ungültige Eingabe.'); + redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Ungültige Eingabe.'); } const sourcePath = resolveAdminPath(sourceRelativePath); const targetBasePath = resolveAdminPath(targetRelativePath); if (!sourcePath || !targetBasePath) { - redirectWithError(adminFilesHref(currentPath), 'Ungültiger Pfad.'); + redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Ungültiger Pfad.'); } const targetPath = await resolveMoveCopyTarget(sourcePath, targetBasePath); @@ -790,7 +919,7 @@ export async function adminCopyPathAction(formData) { try { await copyPath(sourcePath, targetPath); } catch { - redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht kopiert werden.'); + redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Pfad konnte nicht kopiert werden.'); } await logEvent( @@ -800,5 +929,5 @@ export async function adminCopyPathAction(formData) { await getRequestMeta() ); - redirectWithSuccess(adminFilesHref(currentPath), 'Pfad kopiert.'); + redirectWithSuccess(adminFilesTabHref(currentPath, 'content'), 'Pfad kopiert.'); } diff --git a/nextjs/src/lib/config.js b/nextjs/src/lib/config.js index 7f0ac99..5cec306 100644 --- a/nextjs/src/lib/config.js +++ b/nextjs/src/lib/config.js @@ -29,7 +29,7 @@ export const serviceFqdn = String(process.env.SERVICE_FQDN || '').trim(); export const adminHash = process.env.MANAGEMENT_ADMIN_HASH || ''; export const uploadTtlSeconds = parseInteger(process.env.UPLOAD_TTL_SECONDS || '604800', 604800); export const maxRetentionSeconds = 90 * 24 * 60 * 60; -export const maxUploadBytes = parseInteger(process.env.UPLOAD_MAX_BYTES || '0', 0); +export const maxUploadBytes = parseInteger(process.env.UPLOAD_MAX_BYTES || '26843545600', 26843545600); export const cookieSecure = process.env.COOKIE_SECURE === 'true'; export const publicBaseUrl = normalizeBaseUrl(process.env.PUBLIC_BASE_URL || serviceFqdn); export const smtpHost = String(process.env.SMTP_HOST || '').trim(); diff --git a/nextjs/src/lib/db.js b/nextjs/src/lib/db.js index 2357007..0ba1195 100644 --- a/nextjs/src/lib/db.js +++ b/nextjs/src/lib/db.js @@ -51,7 +51,8 @@ function initializeSchema(database) { database.run(`CREATE TABLE IF NOT EXISTS users ( username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, - created_at INTEGER NOT NULL + created_at INTEGER NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0 )`); database.run(`CREATE TABLE IF NOT EXISTS upload_requests ( id TEXT PRIMARY KEY, @@ -74,6 +75,7 @@ function initializeSchema(database) { database.run('ALTER TABLE uploads ADD COLUMN downloads INTEGER DEFAULT 0', () => undefined); database.run('ALTER TABLE admin_logs ADD COLUMN ip TEXT', () => undefined); database.run('ALTER TABLE admin_logs ADD COLUMN user_agent TEXT', () => undefined); + database.run('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0', () => undefined); database.run('ALTER TABLE upload_requests ADD COLUMN upload_id INTEGER', () => undefined); database.run('ALTER TABLE upload_requests ADD COLUMN uploaded_original_name TEXT', () => undefined); database.run('ALTER TABLE upload_requests ADD COLUMN uploaded_stored_name TEXT', () => undefined); diff --git a/nextjs/src/lib/multipart.js b/nextjs/src/lib/multipart.js index 7e3fd4e..c29e5af 100644 --- a/nextjs/src/lib/multipart.js +++ b/nextjs/src/lib/multipart.js @@ -1,5 +1,6 @@ import fs from 'node:fs'; -import { Readable } from 'node:stream'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; import Busboy from 'busboy'; @@ -108,3 +109,43 @@ export async function streamMultipartToPath(request, options) { Readable.fromWeb(request.body).pipe(parser); }); } + +export async function streamBodyToPath(request, options) { + const { targetPath, maxBytes = 0 } = options; + + if (!request.body) { + throw new Error('missing-body'); + } + + let sizeBytes = 0; + const counter = new Transform({ + transform(chunk, encoding, callback) { + sizeBytes += chunk.length; + + if (maxBytes > 0 && sizeBytes > maxBytes) { + callback(new Error('file-too-large')); + return; + } + + callback(null, chunk); + }, + }); + + try { + await pipeline( + Readable.fromWeb(request.body), + counter, + fs.createWriteStream(targetPath, { flags: 'wx' }) + ); + } catch (error) { + await fs.promises.rm(targetPath, { force: true }).catch(() => undefined); + throw error; + } + + if (sizeBytes <= 0) { + await fs.promises.rm(targetPath, { force: true }).catch(() => undefined); + throw new Error('missing-file'); + } + + return { sizeBytes }; +} diff --git a/nextjs/src/lib/security.js b/nextjs/src/lib/security.js index ac2d42f..7ebd830 100644 --- a/nextjs/src/lib/security.js +++ b/nextjs/src/lib/security.js @@ -5,6 +5,7 @@ import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { cookieSecure } from './config.js'; +import { get } from './db.js'; const state = globalThis.__filesLehnertSecurityState || (globalThis.__filesLehnertSecurityState = {}); @@ -131,9 +132,20 @@ export async function getAuthenticatedUser() { if (!payload || typeof payload !== 'object' || !payload.sub) { return null; } + const username = String(payload.sub); + const authType = String(payload.auth || (username === 'admin' && payload.admin ? 'admin' : 'user')); + if (authType === 'admin' && payload.admin) { + return { username, admin: true }; + } + + const row = await get('SELECT is_admin FROM users WHERE username = ?', [username]); + if (!row) { + return null; + } + return { - username: String(payload.sub), - admin: Boolean(payload.admin), + username, + admin: Boolean(row.is_admin), }; } catch { return null; diff --git a/scripts/dev b/scripts/dev new file mode 100755 index 0000000..3bab61a --- /dev/null +++ b/scripts/dev @@ -0,0 +1,226 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_path=${BASH_SOURCE[0]} +if [[ "$script_path" == */* ]]; then + script_dir=${script_path%/*} +else + script_dir=. +fi + +cd "$script_dir/.." +root_dir=$PWD + +if [[ -n "${CONTAINER_ENGINE:-}" ]]; then + engine=$CONTAINER_ENGINE +elif command -v podman >/dev/null 2>&1; then + engine=podman +elif command -v docker >/dev/null 2>&1; then + engine=docker +else + printf 'Need podman or docker in PATH.\n' >&2 + exit 1 +fi + +if ! command -v "$engine" >/dev/null 2>&1; then + printf 'Container engine not found: %s\n' "$engine" >&2 + exit 1 +fi + +image=${FILES_DEV_IMAGE:-files-dev-nextjs:latest} +container=${FILES_DEV_CONTAINER:-files-dev-nextjs} +host_port=${FILES_DEV_PORT:-3000} +dev_dir=${FILES_DEV_DIR:-$root_dir/.dev} +app_data_dir=$dev_dir/app-data +storage_dir=$dev_dir/storage +password_hash='$2a$12$8d4a4zH/tb5TYiP4wQpuFO4GheZarYDWVDGl2S90R4xdMd83JOPMm' + +volume_arg() { + if [[ "$engine" == podman ]]; then + printf '%s:%s:Z' "$1" "$2" + else + printf '%s:%s' "$1" "$2" + fi +} + +mkdir -p "$app_data_dir" "$storage_dir/_share" + +printf 'Using %s\n' "$engine" +printf 'Building %s\n' "$image" +"$engine" build -t "$image" "$root_dir/nextjs" + +printf 'Seeding dev user john.doe / 123456\n' +"$engine" run --rm -i \ + -e DB_PATH=/app/data/uploads.sqlite \ + -e DEV_PASSWORD_HASH="$password_hash" \ + -v "$(volume_arg "$app_data_dir" /app/data)" \ + -v "$(volume_arg "$storage_dir" /data)" \ + "$image" node - <<'NODE' +const fs = require('node:fs'); +const path = require('node:path'); +const sqlite3 = require('sqlite3'); + +const dbPath = process.env.DB_PATH || '/app/data/uploads.sqlite'; +const passwordHash = process.env.DEV_PASSWORD_HASH; + +fs.mkdirSync(path.dirname(dbPath), { recursive: true }); +fs.mkdirSync('/data/_share', { recursive: true }); + +const db = new sqlite3.Database(dbPath); + +function run(sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, function onRun(error) { + if (error) { + reject(error); + return; + } + resolve(this); + }); + }); +} + +function close() { + return new Promise((resolve, reject) => { + db.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +(async () => { + await run(`CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0 + )`); + await run('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0').catch(() => undefined); + await run( + `INSERT INTO users (username, password_hash, created_at, is_admin) + VALUES (?, ?, ?, 1) + ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash, is_admin = 1`, + ['john.doe', passwordHash, Date.now()] + ); + await close(); +})().catch(async (error) => { + try { + await close(); + } catch { + } + console.error(error); + process.exit(1); +}); +NODE + +if "$engine" container inspect "$container" >/dev/null 2>&1; then + printf 'Removing old %s\n' "$container" + "$engine" rm -f "$container" >/dev/null +fi + +logs_pid= +watchdog_pid= + +stop_watchdog() { + if [[ -n "$watchdog_pid" ]]; then + kill "$watchdog_pid" >/dev/null 2>&1 || true + wait "$watchdog_pid" >/dev/null 2>&1 || true + watchdog_pid= + fi +} + +cleanup() { + status=$? + trap - EXIT INT TERM HUP QUIT + + stop_watchdog + + if [[ -n "$logs_pid" ]]; then + kill "$logs_pid" >/dev/null 2>&1 || true + wait "$logs_pid" >/dev/null 2>&1 || true + fi + + if "$engine" container inspect "$container" >/dev/null 2>&1; then + printf '\nKilling %s\n' "$container" >&2 + "$engine" rm -f "$container" >/dev/null 2>&1 || true + fi + + exit "$status" +} + +start_watchdog() { + local parent_pid=$$ + ( + trap '' INT TERM HUP QUIT + while kill -0 "$parent_pid" >/dev/null 2>&1; do + sleep 0.2 + done + "$engine" rm -f "$container" >/dev/null 2>&1 || true + ) & + watchdog_pid=$! +} + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP +trap 'exit 131' QUIT + +printf '\nPreview: http://localhost:%s\n' "$host_port" +printf 'User login: john.doe / 123456\n' +printf 'Admin area: /manage/admin, password 123456\n' +printf 'Data dir: %s\n' "$dev_dir" +printf 'Stop: Ctrl-C\n\n' + +"$engine" run --rm -d \ + --name "$container" \ + --stop-timeout 1 \ + -p "127.0.0.1:$host_port:3000" \ + -e DATA_DIR=/data \ + -e DB_PATH=/app/data/uploads.sqlite \ + -e SERVICE_FQDN=localhost \ + -e PUBLIC_BASE_URL="http://localhost:$host_port" \ + -e UPLOAD_TTL_SECONDS=604800 \ + -e UPLOAD_MAX_BYTES=26843545600 \ + -e MANAGEMENT_ADMIN_HASH="$password_hash" \ + -e COOKIE_SECURE=false \ + -e SMTP_HOST= \ + -e SMTP_PORT=587 \ + -e SMTP_USER= \ + -e SMTP_PASS= \ + -e SMTP_MAIL= \ + -e SMTP_NAME= \ + -e PORT=3000 \ + -v "$(volume_arg "$app_data_dir" /app/data)" \ + -v "$(volume_arg "$storage_dir" /data)" \ + "$image" >/dev/null + +start_watchdog + +"$engine" logs -f "$container" & +logs_pid=$! + +set +e +container_status=$("$engine" wait "$container") +wait_status=$? +set -e + +kill "$logs_pid" >/dev/null 2>&1 || true +wait "$logs_pid" >/dev/null 2>&1 || true +logs_pid= +stop_watchdog + +trap - EXIT INT TERM HUP QUIT + +if [[ $wait_status -ne 0 ]]; then + exit "$wait_status" +fi + +if [[ "$container_status" =~ ^[0-9]+$ ]]; then + exit "$container_status" +fi