feat: add scoped upload tokens

This commit is contained in:
Ludwig Lehnert
2026-06-18 19:17:51 +00:00
parent a2b6054440
commit 0ed32f7623
17 changed files with 592 additions and 95 deletions

View File

@@ -22,6 +22,14 @@ Beispiel mit `curl`:
curl -T ./datei.pdf -H 'X-File-Name: datei.pdf' 'https://files.example.tld/_request/<id>/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 <token>' 'https://files.example.tld/_token-upload/subdir/file.txt'
```
## Lokale Initialisierung
Dev-Vorschau ohne lokale Node/npm-Installation:

View File

@@ -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"

View File

@@ -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 }) {
<details className="help-box">
<summary>Upload per curl</summary>
<pre><code>{`curl -T ./datei.pdf -H 'X-File-Name: datei.pdf' 'https://HOST/_request/${requestEntry.id}/upload'`}</code></pre>
<CurlUploadExample path={`/_request/${requestEntry.id}/upload`} />
</details>
</section>
) : null}

View File

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

View File

@@ -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;

View File

@@ -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 (
<>
<button className="btn secondary" type="button" onClick={openDialog}>
{label}
</button>
<dialog className="action-dialog" ref={dialogRef} onClick={closeOnBackdrop}>
<div className="dialog-header">
<h3>{title}</h3>
<form method="dialog">
<button className="btn secondary" type="submit">
Schließen
</button>
</form>
</div>
<div className="action-dialog-body">{children}</div>
</dialog>
</>
);
}

View File

@@ -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 (
<nav className="tabs" aria-label="Admin-Bereiche">
{adminTabs.map((tab) => (
<a key={tab.key} className={`tab ${active === tab.key ? 'active' : ''}`} href={tab.href}>
{tab.label}
</a>
))}
</nav>
);
}

View File

@@ -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 <pre><code>{`curl -T ./${fileName} ${header} '${url}'`}</code></pre>;
}

View File

@@ -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 }) {
<p className="muted">Metriken, Ereignisse und direkte Eingriffe in Uploads.</p>
</div>
<div className="toolbar">
<a className="chip primary" href="/manage/admin/files">
Dateimanager
</a>
<a className="chip primary" href="/manage/admin/users">
Benutzer verwalten
</a>
<form className="inline-form" action={adminLogoutAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<button className="btn secondary" type="submit">
Abmelden
</button>
</form>
</div>
<form className="inline-form" action={adminLogoutAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<button className="btn secondary" type="submit">
Abmelden
</button>
</form>
</header>
<StatusMessage error={error} success={success} />
<nav className="tabs" aria-label="Admin-Bereiche">
<a className={`tab ${activeTab === 'stats' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=stats">
Statistik
</a>
<a className={`tab ${activeTab === 'uploads' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=uploads">
Uploads
</a>
<a className={`tab ${activeTab === 'requests' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=requests">
Anfragen
</a>
<a className={`tab ${activeTab === 'logs' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=logs">
Logs
</a>
</nav>
<AdminTabs active={activeTab} />
{activeTab === 'stats' ? (
<section className="panel">
@@ -182,9 +162,7 @@ export default async function AdminDashboardPage({ searchParams }) {
<div className="muted">Noch {formatCountdown(item.expires_at)}</div>
</td>
<td>
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<ActionDialog title={item.original_name}>
<div className="row-actions">
<a className="btn secondary" href={sharePath}>
Download
@@ -218,8 +196,7 @@ export default async function AdminDashboardPage({ searchParams }) {
Löschen
</button>
</form>
</div>
</details>
</ActionDialog>
</td>
</tr>
);
@@ -281,9 +258,7 @@ export default async function AdminDashboardPage({ searchParams }) {
)}
</td>
<td>
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<ActionDialog title={`Anfrage ${entry.id}`}>
<a className="btn secondary" href={requestPath}>
Öffnen
</a>
@@ -304,8 +279,7 @@ export default async function AdminDashboardPage({ searchParams }) {
</button>
</form>
) : null}
</div>
</details>
</ActionDialog>
</td>
</tr>
);

View File

@@ -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 }) {
<h1>Admin-Dateimanager</h1>
<p className="muted">Dateien im DATA_DIR verwalten (ausgenommen _share).</p>
</div>
<a className="chip" href="/manage/admin/dashboard">
Zur Adminübersicht
</a>
</header>
<StatusMessage error={queryError || 'Ungültiger Pfad.'} success={success} />
@@ -84,9 +83,6 @@ export default async function AdminFilesPage({ searchParams }) {
<h1>Admin-Dateimanager</h1>
<p className="muted">Dateien im DATA_DIR verwalten (ausgenommen _share).</p>
</div>
<a className="chip" href="/manage/admin/dashboard">
Zur Adminübersicht
</a>
</header>
<StatusMessage error="Ordner konnte nicht gelesen werden." success={success} />
@@ -135,20 +131,16 @@ export default async function AdminFilesPage({ searchParams }) {
<p className="muted">Dateien im DATA_DIR verwalten (ausgenommen _share).</p>
</div>
<div className="toolbar">
<a className="chip" href="/manage/admin/dashboard">
Zur Adminübersicht
</a>
<form className="inline-form" action={adminLogoutAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<button className="btn secondary" type="submit">
Abmelden
</button>
</form>
</div>
<form className="inline-form" action={adminLogoutAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<button className="btn secondary" type="submit">
Abmelden
</button>
</form>
</header>
<StatusMessage error={queryError} success={success} />
<AdminTabs active="files" />
<section className="panel">
<div className="toolbar">
@@ -256,9 +248,7 @@ export default async function AdminFilesPage({ searchParams }) {
<td>{item.size != null ? formatBytes(item.size) : '-'}</td>
<td>{item.modifiedAt ? formatTimestamp(item.modifiedAt) : '-'}</td>
<td>
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<ActionDialog title={item.name}>
<form className="inline-form stacked" action={adminRenamePathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="path" value={item.childPath} />
@@ -295,8 +285,7 @@ export default async function AdminFilesPage({ searchParams }) {
Löschen
</button>
</form>
</div>
</details>
</ActionDialog>
</td>
</tr>
))}

View File

@@ -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 (
<main className="page-shell">
<header className="page-header">
<div className="header-main">
<h1>Upload-Tokens</h1>
<p className="muted">Bearer-Tokens für curl-Uploads in feste Zielordner.</p>
</div>
<form className="inline-form" action={adminLogoutAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<button className="btn secondary" type="submit">
Abmelden
</button>
</form>
</header>
<StatusMessage error={error} success={success} />
<AdminTabs active="tokens" />
{createdToken ? (
<section className="panel">
<h2>Neuer Token</h2>
<p className="muted">Token jetzt kopieren. Er wird nicht erneut angezeigt.</p>
<pre><code>{createdToken}</code></pre>
<CurlUploadExample path="/_token-upload/unterordner/datei.txt" token={createdToken} fileName="datei.txt" />
</section>
) : null}
<section className="panel">
<h2>Token erstellen</h2>
<form className="form-grid" action={adminCreateUploadTokenAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<label className="field">
Name (optional)
<input className="input" name="label" placeholder="z.B. Kunde A" />
</label>
<label className="field">
Zielordner im Dateimanager
<input className="input" name="scopePath" placeholder="z.B. eingang/kunde-a" required />
</label>
<label className="field">
Ablauf in Stunden (optional)
<input className="input" name="expiresHours" placeholder="leer = kein Ablauf" />
</label>
<button className="btn" type="submit">
Token erstellen
</button>
</form>
</section>
<section className="panel">
<h2>Aktive Tokens</h2>
{tokens.length === 0 ? (
<p className="muted">Noch keine Upload-Tokens.</p>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Zielordner</th>
<th>Erstellt</th>
<th>Ablauf</th>
<th>Letzte Nutzung</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={token.id}>
<td>
<strong>{token.label || `Token ${token.id}`}</strong>
<div className="muted">von {token.created_by || '-'}</div>
</td>
<td className="mono">{token.scope_path}</td>
<td>{formatTimestamp(token.created_at)}</td>
<td>{token.expires_at ? formatTimestamp(token.expires_at) : 'Kein Ablauf'}</td>
<td>{token.last_used_at ? formatTimestamp(token.last_used_at) : '-'}</td>
<td>
<span className={`badge ${disabled || expired ? 'danger' : 'primary'}`}>
{disabled ? 'Deaktiviert' : expired ? 'Abgelaufen' : 'Aktiv'}
</span>
</td>
<td>
{disabled ? null : (
<form className="inline-form" action={adminDisableUploadTokenAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="tokenId" value={token.id} />
<button className="btn danger" type="submit">
Deaktivieren
</button>
</form>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}

View File

@@ -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 }) {
<p className="muted">Konten erstellen, Passwort setzen oder Benutzer entfernen.</p>
</div>
<div className="toolbar">
<a className="chip" href="/manage/admin/dashboard">
Zur Adminübersicht
</a>
<form className="inline-form" action={adminLogoutAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<button className="btn secondary" type="submit">
Abmelden
</button>
</form>
</div>
<form className="inline-form" action={adminLogoutAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<button className="btn secondary" type="submit">
Abmelden
</button>
</form>
</header>
<StatusMessage error={error} success={success} />
<AdminTabs active="users" />
<nav className="tabs" aria-label="Benutzer-Bereiche">
<a className={`tab ${activeTab === 'users' ? 'active' : ''}`} href="/manage/admin/users?tab=users">

View File

@@ -49,20 +49,12 @@ export default async function DashboardPage({ searchParams }) {
<p className="muted">Angemeldet als {user.username}</p>
</div>
<div className="toolbar">
{user.admin ? (
<a className="chip primary" href="/manage/admin/dashboard">
Adminbereich
</a>
) : null}
<form className="inline-form" action={userLogoutAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<button className="btn secondary" type="submit">
Abmelden
</button>
</form>
</div>
<form className="inline-form" action={userLogoutAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<button className="btn secondary" type="submit">
Abmelden
</button>
</form>
</header>
<StatusMessage error={error} success={success} />
@@ -77,6 +69,14 @@ export default async function DashboardPage({ searchParams }) {
<a className={`tab ${activeTab === 'requests' ? 'active' : ''}`} href="/manage/dashboard?tab=requests">
Anfragen
</a>
{user.admin ? (
<>
<a className="tab" href="/manage/admin/dashboard?tab=stats">Admin</a>
<a className="tab" href="/manage/admin/files">Dateimanager</a>
<a className="tab" href="/manage/admin/users">Benutzer</a>
<a className="tab" href="/manage/admin/tokens">Upload-Tokens</a>
</>
) : null}
</nav>
<div className="summary-line">

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) {

View File

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