feat: add scoped upload tokens
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user