feat: add scoped upload tokens
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
123
nextjs/app/%5Ftoken-upload/[...path]/route.js
Normal file
123
nextjs/app/%5Ftoken-upload/[...path]/route.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
36
nextjs/app/manage/_components/action-dialog.js
Normal file
36
nextjs/app/manage/_components/action-dialog.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
nextjs/app/manage/_components/admin-tabs.js
Normal file
22
nextjs/app/manage/_components/admin-tabs.js
Normal 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>
|
||||
);
|
||||
}
|
||||
17
nextjs/app/manage/_components/curl-upload-example.js
Normal file
17
nextjs/app/manage/_components/curl-upload-example.js
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
146
nextjs/app/manage/admin/tokens/page.js
Normal file
146
nextjs/app/manage/admin/tokens/page.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user