From 0ed32f7623edd6b81e178a8b976a68cfc14adf6a Mon Sep 17 00:00:00 2001 From: Ludwig Lehnert Date: Thu, 18 Jun 2026 19:17:51 +0000 Subject: [PATCH] feat: add scoped upload tokens --- README.md | 8 + docker-compose.yml | 4 +- nextjs/app/%5Frequest/[id]/page.js | 3 +- nextjs/app/%5Ftoken-upload/[...path]/route.js | 123 +++++++++++++++ nextjs/app/globals.css | 27 ++++ .../app/manage/_components/action-dialog.js | 36 +++++ nextjs/app/manage/_components/admin-tabs.js | 22 +++ .../manage/_components/curl-upload-example.js | 17 ++ nextjs/app/manage/admin/dashboard/page.js | 52 ++----- nextjs/app/manage/admin/files/page.js | 33 ++-- nextjs/app/manage/admin/tokens/page.js | 146 ++++++++++++++++++ nextjs/app/manage/admin/users/page.js | 19 +-- nextjs/app/manage/dashboard/page.js | 28 ++-- nextjs/src/lib/actions.js | 103 ++++++++++++ nextjs/src/lib/db.js | 13 ++ nextjs/src/lib/files.js | 37 +++++ nextjs/src/lib/multipart.js | 16 +- 17 files changed, 592 insertions(+), 95 deletions(-) create mode 100644 nextjs/app/%5Ftoken-upload/[...path]/route.js create mode 100644 nextjs/app/manage/_components/action-dialog.js create mode 100644 nextjs/app/manage/_components/admin-tabs.js create mode 100644 nextjs/app/manage/_components/curl-upload-example.js create mode 100644 nextjs/app/manage/admin/tokens/page.js diff --git a/README.md b/README.md index 4dce6a4..654d874 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ Beispiel mit `curl`: curl -T ./datei.pdf -H 'X-File-Name: datei.pdf' 'https://files.example.tld/_request//upload' ``` +Admins können scoped Upload-Tokens unter `/manage/admin/tokens` erstellen. Diese Tokens erlauben +`curl -T`-Uploads in einen festen Dateimanager-Ordner. Pfade unterhalb des Tokens dürfen +Unterordner enthalten; `..` und `.php`-Zieldateien werden abgelehnt. + +```bash +curl -T ./file.txt -H 'Authorization: Bearer ' 'https://files.example.tld/_token-upload/subdir/file.txt' +``` + ## Lokale Initialisierung Dev-Vorschau ohne lokale Node/npm-Installation: diff --git a/docker-compose.yml b/docker-compose.yml index 6a212c1..7929d33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,7 +89,7 @@ services: labels: - "traefik.enable=true" - - "traefik.http.routers.nextjs.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`) || PathPrefix(`/_request`))" + - "traefik.http.routers.nextjs.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`) || PathPrefix(`/_request`) || PathPrefix(`/_token-upload`))" - "traefik.http.routers.nextjs.entrypoints=websecure" - "traefik.http.routers.nextjs.tls=true" - "traefik.http.routers.nextjs.tls.certresolver=letsencrypt" @@ -97,7 +97,7 @@ services: - "traefik.http.services.nextjs-svc.loadbalancer.server.port=3000" - "traefik.http.routers.nextjs.priority=10" # Optional HTTP redirect - - "traefik.http.routers.nextjs-http.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`) || PathPrefix(`/_request`))" + - "traefik.http.routers.nextjs-http.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`) || PathPrefix(`/_request`) || PathPrefix(`/_token-upload`))" - "traefik.http.routers.nextjs-http.entrypoints=web" - "traefik.http.routers.nextjs-http.middlewares=nextjs-https-redirect" - "traefik.http.middlewares.nextjs-https-redirect.redirectscheme.scheme=https" diff --git a/nextjs/app/%5Frequest/[id]/page.js b/nextjs/app/%5Frequest/[id]/page.js index 1290ce2..db5e3fa 100644 --- a/nextjs/app/%5Frequest/[id]/page.js +++ b/nextjs/app/%5Frequest/[id]/page.js @@ -2,6 +2,7 @@ import { get, runCleanupIfNeeded } from '@/src/lib/db.js'; import { formatTimestamp, readSearchParam } from '@/src/lib/format.js'; import { StatusMessage } from '@/app/manage/_components/status-message.js'; +import { CurlUploadExample } from '@/app/manage/_components/curl-upload-example.js'; export const dynamic = 'force-dynamic'; @@ -135,7 +136,7 @@ export default async function UploadRequestPage({ params, searchParams }) {
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/%5Ftoken-upload/[...path]/route.js b/nextjs/app/%5Ftoken-upload/[...path]/route.js new file mode 100644 index 0000000..b59c5d3 --- /dev/null +++ b/nextjs/app/%5Ftoken-upload/[...path]/route.js @@ -0,0 +1,123 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { NextResponse } from 'next/server'; + +import { maxUploadBytes } from '@/src/lib/config.js'; +import { get, logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js'; +import { resolveScopedAdminUploadPath } from '@/src/lib/files.js'; +import { streamBodyToPath } from '@/src/lib/multipart.js'; +import { getRequestMeta } from '@/src/lib/security.js'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function textResponse(message, status = 200) { + return new NextResponse(`${message}\n`, { + status, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }); +} + +function bearerToken(request) { + const authorization = String(request.headers.get('authorization') || ''); + const match = authorization.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : ''; +} + +function hashUploadToken(token) { + return crypto.createHash('sha256').update(String(token || '')).digest('hex'); +} + +function requestedUploadPath(value) { + const parts = Array.isArray(value) ? value : []; + return parts.join('/'); +} + +function hasBlockedExtension(uploadPath) { + return path.extname(String(uploadPath || '')).toLowerCase() === '.php'; +} + +export async function PUT(request, { params }) { + await runCleanupIfNeeded(); + + const token = bearerToken(request); + if (!token) { + return textResponse('Missing bearer token.', 401); + } + + const tokenHash = hashUploadToken(token); + const tokenEntry = await get( + `SELECT id, created_by, scope_path, expires_at, disabled_at + FROM upload_tokens + WHERE token_hash = ?`, + [tokenHash] + ); + + if (!tokenEntry || Number(tokenEntry.disabled_at || 0) > 0) { + return textResponse('Invalid bearer token.', 401); + } + + const now = Date.now(); + if (Number(tokenEntry.expires_at || 0) > 0 && Number(tokenEntry.expires_at) <= now) { + return textResponse('Expired bearer token.', 401); + } + + const resolvedParams = await params; + const uploadPath = requestedUploadPath(resolvedParams.path); + if (hasBlockedExtension(uploadPath)) { + return textResponse('PHP uploads are not allowed.', 415); + } + + const scopedTarget = resolveScopedAdminUploadPath( + tokenEntry.scope_path, + uploadPath + ); + if (!scopedTarget) { + return textResponse('Invalid upload path.', 400); + } + + const contentLength = Number.parseInt(String(request.headers.get('content-length') || ''), 10); + if (maxUploadBytes > 0 && Number.isFinite(contentLength) && contentLength > maxUploadBytes) { + return textResponse(`File exceeds upload limit (${maxUploadBytes} bytes).`, 413); + } + + try { + await fs.promises.mkdir(path.dirname(scopedTarget.targetPath), { recursive: true }); + try { + await fs.promises.access(scopedTarget.targetPath, fs.constants.F_OK); + return textResponse('Target file already exists.', 409); + } catch { + } + + const parsed = await streamBodyToPath(request, { + targetPath: scopedTarget.targetPath, + maxBytes: maxUploadBytes, + }); + + await run('UPDATE upload_tokens SET last_used_at = ? WHERE id = ?', [Date.now(), tokenEntry.id]); + await logEvent( + 'token_upload', + tokenEntry.created_by || null, + { token_id: tokenEntry.id, path: scopedTarget.relativePath, size: parsed.sizeBytes }, + await getRequestMeta() + ); + + return textResponse(`Uploaded ${scopedTarget.relativePath}`, 201); + } catch (error) { + if (error && error.message === 'file-too-large') { + return textResponse(`File exceeds upload limit (${maxUploadBytes} bytes).`, 413); + } + if (error && error.message === 'missing-file') { + return textResponse('Missing file body.', 400); + } + if (error && error.code === 'EEXIST') { + return textResponse('Target file already exists.', 409); + } + return textResponse('Upload failed.', 500); + } +} diff --git a/nextjs/app/globals.css b/nextjs/app/globals.css index 2d21249..ce5ce86 100644 --- a/nextjs/app/globals.css +++ b/nextjs/app/globals.css @@ -453,6 +453,33 @@ thead th { min-width: 13rem; } +.action-dialog { + width: min(520px, calc(100% - 2rem)); + border: 1px solid var(--line); + border-radius: var(--radius); + color: var(--text-main); + padding: 0; +} + +.action-dialog::backdrop { + background: rgb(15 23 42 / 0.42); +} + +.dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + border-bottom: 1px solid var(--line); + padding: 0.9rem 1rem; +} + +.action-dialog-body { + display: grid; + gap: 0.75rem; + padding: 1rem; +} + .breadcrumbs { gap: 0.35rem; font-size: 0.88rem; diff --git a/nextjs/app/manage/_components/action-dialog.js b/nextjs/app/manage/_components/action-dialog.js new file mode 100644 index 0000000..3770d44 --- /dev/null +++ b/nextjs/app/manage/_components/action-dialog.js @@ -0,0 +1,36 @@ +'use client'; + +import { useRef } from 'react'; + +export function ActionDialog({ label = 'Aktionen', title = 'Aktionen', children }) { + const dialogRef = useRef(null); + + function openDialog() { + dialogRef.current?.showModal(); + } + + function closeOnBackdrop(event) { + if (event.target === event.currentTarget) { + event.currentTarget.close(); + } + } + + return ( + <> + + +
+

{title}

+
+ +
+
+
{children}
+
+ + ); +} diff --git a/nextjs/app/manage/_components/admin-tabs.js b/nextjs/app/manage/_components/admin-tabs.js new file mode 100644 index 0000000..0c5405a --- /dev/null +++ b/nextjs/app/manage/_components/admin-tabs.js @@ -0,0 +1,22 @@ +const adminTabs = [ + { key: 'dashboard', href: '/manage/dashboard', label: 'Meine Dateien' }, + { key: 'stats', href: '/manage/admin/dashboard?tab=stats', label: 'Admin' }, + { key: 'uploads', href: '/manage/admin/dashboard?tab=uploads', label: 'Alle Uploads' }, + { key: 'requests', href: '/manage/admin/dashboard?tab=requests', label: 'Anfragen' }, + { key: 'files', href: '/manage/admin/files', label: 'Dateimanager' }, + { key: 'users', href: '/manage/admin/users', label: 'Benutzer' }, + { key: 'tokens', href: '/manage/admin/tokens', label: 'Upload-Tokens' }, + { key: 'logs', href: '/manage/admin/dashboard?tab=logs', label: 'Logs' }, +]; + +export function AdminTabs({ active }) { + return ( + + ); +} diff --git a/nextjs/app/manage/_components/curl-upload-example.js b/nextjs/app/manage/_components/curl-upload-example.js new file mode 100644 index 0000000..400fec2 --- /dev/null +++ b/nextjs/app/manage/_components/curl-upload-example.js @@ -0,0 +1,17 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export function CurlUploadExample({ path, token = '', fileName = 'datei.pdf' }) { + const [url, setUrl] = useState(path); + + useEffect(() => { + setUrl(new URL(path, window.location.origin).toString()); + }, [path]); + + const header = token + ? `-H 'Authorization: Bearer ${token}'` + : `-H 'X-File-Name: ${fileName}'`; + + return
{`curl -T ./${fileName} ${header} '${url}'`}
; +} diff --git a/nextjs/app/manage/admin/dashboard/page.js b/nextjs/app/manage/admin/dashboard/page.js index 23601d6..2b3c30c 100644 --- a/nextjs/app/manage/admin/dashboard/page.js +++ b/nextjs/app/manage/admin/dashboard/page.js @@ -15,6 +15,8 @@ import { } from '@/src/lib/format.js'; import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js'; +import { ActionDialog } from '../../_components/action-dialog.js'; +import { AdminTabs } from '../../_components/admin-tabs.js'; import { CopyLinkButton } from '../../_components/copy-link-button.js'; import { StatusMessage } from '../../_components/status-message.js'; @@ -77,38 +79,16 @@ export default async function AdminDashboardPage({ searchParams }) {

Metriken, Ereignisse und direkte Eingriffe in Uploads.

-
- - Dateimanager - - - Benutzer verwalten - -
- - -
-
+
+ + +
- - + {activeTab === 'stats' ? (
@@ -182,9 +162,7 @@ export default async function AdminDashboardPage({ searchParams }) {
Noch {formatCountdown(item.expires_at)}
-
- Aktionen -
+ ); @@ -281,9 +258,7 @@ export default async function AdminDashboardPage({ searchParams }) { )} -
- Aktionen -
+ Öffnen @@ -304,8 +279,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 0ac5eda..5ad41d9 100644 --- a/nextjs/app/manage/admin/files/page.js +++ b/nextjs/app/manage/admin/files/page.js @@ -15,6 +15,8 @@ import { adminFilesHref, resolveAdminPath, sanitizeRelativePath } from '@/src/li import { formatBytes, formatTimestamp, readSearchParam } from '@/src/lib/format.js'; import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js'; +import { ActionDialog } from '../../_components/action-dialog.js'; +import { AdminTabs } from '../../_components/admin-tabs.js'; import { StatusMessage } from '../../_components/status-message.js'; export const dynamic = 'force-dynamic'; @@ -63,9 +65,6 @@ export default async function AdminFilesPage({ searchParams }) {

Admin-Dateimanager

Dateien im DATA_DIR verwalten (ausgenommen _share).

- - Zur Adminübersicht - @@ -84,9 +83,6 @@ export default async function AdminFilesPage({ searchParams }) {

Admin-Dateimanager

Dateien im DATA_DIR verwalten (ausgenommen _share).

- - Zur Adminübersicht - @@ -135,20 +131,16 @@ export default async function AdminFilesPage({ searchParams }) {

Dateien im DATA_DIR verwalten (ausgenommen _share).

-
- - Zur Adminübersicht - -
- - -
-
+
+ + +
+
@@ -256,9 +248,7 @@ export default async function AdminFilesPage({ searchParams }) { {item.size != null ? formatBytes(item.size) : '-'} {item.modifiedAt ? formatTimestamp(item.modifiedAt) : '-'} -
- Aktionen -
+
@@ -295,8 +285,7 @@ export default async function AdminFilesPage({ searchParams }) { Löschen
-
-
+ ))} diff --git a/nextjs/app/manage/admin/tokens/page.js b/nextjs/app/manage/admin/tokens/page.js new file mode 100644 index 0000000..fbffeef --- /dev/null +++ b/nextjs/app/manage/admin/tokens/page.js @@ -0,0 +1,146 @@ +import { + adminCreateUploadTokenAction, + adminDisableUploadTokenAction, + adminLogoutAction, +} from '@/src/lib/actions.js'; +import { all, runCleanupIfNeeded } from '@/src/lib/db.js'; +import { formatTimestamp, readSearchParam } from '@/src/lib/format.js'; +import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js'; + +import { AdminTabs } from '../../_components/admin-tabs.js'; +import { CurlUploadExample } from '../../_components/curl-upload-example.js'; +import { StatusMessage } from '../../_components/status-message.js'; + +export const dynamic = 'force-dynamic'; + +export default async function AdminUploadTokensPage({ searchParams }) { + await runCleanupIfNeeded(); + await requireAdminUser(); + + const csrfToken = await ensureCsrfToken(); + const tokens = await all( + `SELECT id, label, scope_path, created_by, created_at, expires_at, last_used_at, disabled_at + FROM upload_tokens + ORDER BY created_at DESC + LIMIT 500` + ); + + const resolvedSearchParams = await searchParams; + const error = readSearchParam(resolvedSearchParams, 'error'); + const success = readSearchParam(resolvedSearchParams, 'success'); + const createdToken = readSearchParam(resolvedSearchParams, 'createdToken'); + + return ( +
+
+
+

Upload-Tokens

+

Bearer-Tokens für curl-Uploads in feste Zielordner.

+
+ +
+ + +
+
+ + + + + {createdToken ? ( +
+

Neuer Token

+

Token jetzt kopieren. Er wird nicht erneut angezeigt.

+
{createdToken}
+ +
+ ) : null} + +
+

Token erstellen

+
+ + + + + + + + + +
+
+ +
+

Aktive Tokens

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

Noch keine Upload-Tokens.

+ ) : ( +
+ + + + + + + + + + + + + + {tokens.map((token) => { + const disabled = Number(token.disabled_at || 0) > 0; + const expired = Number(token.expires_at || 0) > 0 && Number(token.expires_at) <= Date.now(); + + return ( + + + + + + + + + + ); + })} + +
NameZielordnerErstelltAblaufLetzte NutzungStatusAktionen
+ {token.label || `Token ${token.id}`} +
von {token.created_by || '-'}
+
{token.scope_path}{formatTimestamp(token.created_at)}{token.expires_at ? formatTimestamp(token.expires_at) : 'Kein Ablauf'}{token.last_used_at ? formatTimestamp(token.last_used_at) : '-'} + + {disabled ? 'Deaktiviert' : expired ? 'Abgelaufen' : 'Aktiv'} + + + {disabled ? null : ( +
+ + + +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/nextjs/app/manage/admin/users/page.js b/nextjs/app/manage/admin/users/page.js index 3bb72ac..979a195 100644 --- a/nextjs/app/manage/admin/users/page.js +++ b/nextjs/app/manage/admin/users/page.js @@ -9,6 +9,7 @@ import { all, runCleanupIfNeeded } from '@/src/lib/db.js'; import { formatTimestamp, readSearchParam } from '@/src/lib/format.js'; import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js'; +import { AdminTabs } from '../../_components/admin-tabs.js'; import { StatusMessage } from '../../_components/status-message.js'; export const dynamic = 'force-dynamic'; @@ -34,20 +35,16 @@ export default async function AdminUsersPage({ searchParams }) {

Konten erstellen, Passwort setzen oder Benutzer entfernen.

-
- - Zur Adminübersicht - -
- - -
-
+
+ + +
+
diff --git a/nextjs/src/lib/actions.js b/nextjs/src/lib/actions.js index ddef10b..5cb1a3c 100644 --- a/nextjs/src/lib/actions.js +++ b/nextjs/src/lib/actions.js @@ -24,6 +24,7 @@ import { resolveAdminPath, safeBaseName, sanitizeRelativePath, + sanitizeScopedUploadPath, } from './files.js'; import { checkLoginRateLimit, @@ -67,6 +68,11 @@ function getUploadId(formData, fieldName = 'uploadId') { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +function getTokenId(formData) { + const parsed = Number.parseInt(String(formData.get('tokenId') || ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + function parseHours(value, fallbackSeconds) { const parsed = Number.parseFloat(String(value || '')); if (Number.isFinite(parsed) && parsed > 0) { @@ -102,6 +108,14 @@ function createRandomId() { return toBase32(crypto.randomBytes(5)); } +function createUploadTokenSecret() { + return `fut_${crypto.randomBytes(32).toString('base64url')}`; +} + +function hashUploadToken(token) { + return crypto.createHash('sha256').update(String(token || '')).digest('hex'); +} + function uploadedFileFromForm(formData, fieldName = 'file') { const candidate = formData.get(fieldName); if (!candidate || typeof candidate === 'string') { @@ -675,6 +689,95 @@ export async function adminSaveUploadToPathAction(formData) { redirectWithSuccess(dashboardPath, 'Upload im Dateimanager gespeichert.'); } +export async function adminCreateUploadTokenAction(formData) { + try { + await verifyCsrf(formData); + } catch { + redirectWithError(`${managementBasePath}/admin/tokens`, 'CSRF-Prüfung fehlgeschlagen.'); + } + + const actor = await requireAdminUser(); + const label = String(formData.get('label') || '').trim().slice(0, 120); + const scopePath = sanitizeScopedUploadPath(formData.get('scopePath')); + if (!scopePath) { + redirectWithError(`${managementBasePath}/admin/tokens`, 'Zielordner ist erforderlich.'); + } + + const scopeAbsolutePath = resolveAdminPath(scopePath); + if (!scopeAbsolutePath) { + redirectWithError(`${managementBasePath}/admin/tokens`, 'Ungültiger Zielordner.'); + } + + try { + await fs.promises.mkdir(scopeAbsolutePath, { recursive: true }); + const stat = await fs.promises.stat(scopeAbsolutePath); + if (!stat.isDirectory()) { + redirectWithError(`${managementBasePath}/admin/tokens`, 'Ziel ist kein Ordner.'); + } + } catch (error) { + if (error && error.digest) { + throw error; + } + redirectWithError(`${managementBasePath}/admin/tokens`, 'Zielordner konnte nicht erstellt werden.'); + } + + const token = createUploadTokenSecret(); + const tokenHash = hashUploadToken(token); + const now = Date.now(); + const expiresSeconds = parseHours(formData.get('expiresHours'), 0); + const expiresAt = expiresSeconds > 0 + ? now + Math.min(expiresSeconds, maxRetentionSeconds) * 1000 + : null; + + try { + await run( + `INSERT INTO upload_tokens (label, token_hash, scope_path, created_by, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [label || null, tokenHash, scopePath, actor.username, now, expiresAt] + ); + } catch { + redirectWithError(`${managementBasePath}/admin/tokens`, 'Upload-Token konnte nicht erstellt werden.'); + } + + await logEvent( + 'admin_upload_token_create', + actor.username, + { scope_path: scopePath, label: label || null, expires_at: expiresAt }, + await getRequestMeta() + ); + + redirectWithSuccess( + buildPathWithQuery(`${managementBasePath}/admin/tokens`, { createdToken: token }), + 'Upload-Token erstellt. Token nur jetzt sichtbar.' + ); +} + +export async function adminDisableUploadTokenAction(formData) { + try { + await verifyCsrf(formData); + } catch { + redirectWithError(`${managementBasePath}/admin/tokens`, 'CSRF-Prüfung fehlgeschlagen.'); + } + + const actor = await requireAdminUser(); + const tokenId = getTokenId(formData); + if (!tokenId) { + redirectWithError(`${managementBasePath}/admin/tokens`, 'Ungültige Token-ID.'); + } + + const now = Date.now(); + const result = await run( + 'UPDATE upload_tokens SET disabled_at = ? WHERE id = ? AND disabled_at IS NULL', + [now, tokenId] + ); + if (!result || result.changes < 1) { + redirectWithError(`${managementBasePath}/admin/tokens`, 'Upload-Token nicht gefunden oder bereits deaktiviert.'); + } + + await logEvent('admin_upload_token_disable', actor.username, { token_id: tokenId }, await getRequestMeta()); + redirectWithSuccess(`${managementBasePath}/admin/tokens`, 'Upload-Token deaktiviert.'); +} + export async function adminMkdirAction(formData) { try { await verifyCsrf(formData); diff --git a/nextjs/src/lib/db.js b/nextjs/src/lib/db.js index 0ba1195..e2911c4 100644 --- a/nextjs/src/lib/db.js +++ b/nextjs/src/lib/db.js @@ -69,9 +69,22 @@ function initializeSchema(database) { fulfilled_by TEXT, notification_sent_at INTEGER )`); + database.run(`CREATE TABLE IF NOT EXISTS upload_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT, + token_hash TEXT NOT NULL UNIQUE, + scope_path TEXT NOT NULL, + created_by TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER, + last_used_at INTEGER, + disabled_at INTEGER + )`); database.run('CREATE INDEX IF NOT EXISTS upload_requests_owner_idx ON upload_requests(owner)'); database.run('CREATE INDEX IF NOT EXISTS upload_requests_expires_idx ON upload_requests(expires_at)'); database.run('CREATE INDEX IF NOT EXISTS upload_requests_completed_idx ON upload_requests(completed_at)'); + database.run('CREATE INDEX IF NOT EXISTS upload_tokens_hash_idx ON upload_tokens(token_hash)'); + database.run('CREATE INDEX IF NOT EXISTS upload_tokens_scope_idx ON upload_tokens(scope_path)'); 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); diff --git a/nextjs/src/lib/files.js b/nextjs/src/lib/files.js index 237ee2f..26f81eb 100644 --- a/nextjs/src/lib/files.js +++ b/nextjs/src/lib/files.js @@ -45,6 +45,43 @@ export function safeBaseName(value, fallback = 'file') { return base || fallback; } +export function sanitizeScopedUploadPath(value) { + const parts = String(value || '') + .replace(/\\/g, '/') + .split('/') + .filter(Boolean); + + if (parts.length === 0) { + return ''; + } + + for (const part of parts) { + if (!isValidNodeName(part) || part === '.' || part === '..') { + return ''; + } + } + + return parts.join('/'); +} + +export function resolveScopedAdminUploadPath(scopePath, uploadPath) { + const scopeAbsolutePath = resolveAdminPath(scopePath); + const cleanUploadPath = sanitizeScopedUploadPath(uploadPath); + if (!scopeAbsolutePath || !cleanUploadPath) { + return null; + } + + const targetPath = path.resolve(scopeAbsolutePath, cleanUploadPath); + if (targetPath === scopeAbsolutePath || !targetPath.startsWith(`${scopeAbsolutePath}${path.sep}`)) { + return null; + } + + return { + targetPath, + relativePath: sanitizeRelativePath(path.join(scopePath, cleanUploadPath)), + }; +} + export function sanitizeExtension(value) { const extension = path.extname(String(value || '')).toLowerCase(); if (!extension) { diff --git a/nextjs/src/lib/multipart.js b/nextjs/src/lib/multipart.js index c29e5af..2f402d3 100644 --- a/nextjs/src/lib/multipart.js +++ b/nextjs/src/lib/multipart.js @@ -131,14 +131,18 @@ export async function streamBodyToPath(request, options) { }, }); + const writeStream = fs.createWriteStream(targetPath, { flags: 'wx' }); + let opened = false; + writeStream.on('open', () => { + opened = true; + }); + try { - await pipeline( - Readable.fromWeb(request.body), - counter, - fs.createWriteStream(targetPath, { flags: 'wx' }) - ); + await pipeline(Readable.fromWeb(request.body), counter, writeStream); } catch (error) { - await fs.promises.rm(targetPath, { force: true }).catch(() => undefined); + if (opened) { + await fs.promises.rm(targetPath, { force: true }).catch(() => undefined); + } throw error; }