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

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