feat: improve upload admin workflow

Add users.is_admin SQLite migration for promoted admins.

Support curl PUT request uploads, save uploads into admin file-manager paths, default 25 GiB uploads, tabbed UI, and local dev preview.
This commit is contained in:
Ludwig Lehnert
2026-06-18 17:09:07 +00:00
parent 61f10fd994
commit a2b6054440
19 changed files with 1148 additions and 396 deletions

View File

@@ -3,7 +3,7 @@ LETSENCRYPT_EMAIL=user@example.com
PUBLIC_BASE_URL=
DATA_DIR=/storagebox
UPLOAD_TTL_SECONDS=604800
UPLOAD_MAX_BYTES=0
UPLOAD_MAX_BYTES=26843545600
MANAGEMENT_ADMIN_HASH=
COOKIE_SECURE=true
SMTP_HOST=

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.env
.dev/
traefik/
node_modules/
nextjs/data/

View File

@@ -15,15 +15,31 @@ File server infrastructure hosted on [files.lehnert.cloud](https://files.lehnert
- Datei-Downloads: `/_share/<datei>`
- Upload-Anfragen: `/_request/<id>`
Upload-Anfragen akzeptieren Browser-Multipart und rohe PUT-Uploads auf `/_request/<id>/upload`.
Beispiel mit `curl`:
```bash
curl -T ./datei.pdf -H 'X-File-Name: datei.pdf' 'https://files.example.tld/_request/<id>/upload'
```
## Lokale Initialisierung
Dev-Vorschau ohne lokale Node/npm-Installation:
```bash
./scripts/dev
```
Admin-Benutzer: `john.doe` / `123456`. Adminbereich: `/manage/admin`, Passwort `123456`.
Dev-Container laufen mit `--rm`; `Ctrl-C`/`SIGTERM` entfernen den laufenden Container per `rm -f`.
```bash
./initialize.sh
```
Danach:
1. `.env` anpassen (`SERVICE_FQDN`, `LETSENCRYPT_EMAIL`, `DATA_DIR`, `UPLOAD_TTL_SECONDS`, `MANAGEMENT_ADMIN_HASH`, optional `UPLOAD_MAX_BYTES` und `COOKIE_SECURE`)
1. `.env` anpassen (`SERVICE_FQDN`, `LETSENCRYPT_EMAIL`, `DATA_DIR`, `UPLOAD_TTL_SECONDS`, `MANAGEMENT_ADMIN_HASH`, optional `UPLOAD_MAX_BYTES` Standard: 25 GiB, und `COOKIE_SECURE`)
2. Optional `PUBLIC_BASE_URL` setzen, falls absolute Links in E-Mails einen festen Host verwenden sollen
3. Für Upload-Anfragen mit E-Mail-Benachrichtigung SMTP setzen (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_MAIL`, `SMTP_NAME`; Absender: `SMTP_NAME <SMTP_MAIL>`)
4. Stack starten: `docker compose up --build`

View File

@@ -110,7 +110,7 @@ export default async function UploadRequestPage({ params, searchParams }) {
</section>
{state === 'open' ? (
<section className="panel panel-spotlight">
<section className="panel">
<h2>Datei hochladen</h2>
<form
className="form-grid"
@@ -132,6 +132,11 @@ export default async function UploadRequestPage({ params, searchParams }) {
Datei senden
</button>
</form>
<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>
</details>
</section>
) : null}

View File

@@ -14,7 +14,7 @@ import {
import { get, logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js';
import { safeBaseName } from '@/src/lib/files.js';
import { sendUploadRequestCompletedMail } from '@/src/lib/mailer.js';
import { streamMultipartToPath } from '@/src/lib/multipart.js';
import { streamBodyToPath, streamMultipartToPath } from '@/src/lib/multipart.js';
import { getRequestMeta } from '@/src/lib/security.js';
export const runtime = 'nodejs';
@@ -50,6 +50,113 @@ function redirectToRequest(requestId, params = {}) {
});
}
function textResponse(message, status = 200) {
return new NextResponse(`${message}\n`, {
status,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
function isMultipartRequest(request) {
return String(request.headers.get('content-type') || '')
.toLowerCase()
.includes('multipart/form-data');
}
function uploadErrorResponse(requestId, rawUpload, message, status = 400) {
if (rawUpload) {
return textResponse(message, status);
}
return redirectToRequest(requestId, { error: message });
}
function uploadSuccessResponse(requestId, rawUpload, message, downloadUrl = '') {
if (rawUpload) {
const body = downloadUrl ? `${message}\nDownload: ${downloadUrl}` : message;
return textResponse(body, 201);
}
return redirectToRequest(requestId, { success: message });
}
function contentDispositionFilename(value) {
const raw = String(value || '');
const encodedMatch = raw.match(/filename\*=UTF-8''([^;]+)/i);
if (encodedMatch) {
try {
return decodeURIComponent(encodedMatch[1].trim());
} catch {
return encodedMatch[1].trim();
}
}
const quotedMatch = raw.match(/filename="([^"]+)"/i);
if (quotedMatch) {
return quotedMatch[1];
}
const plainMatch = raw.match(/filename=([^;]+)/i);
return plainMatch ? plainMatch[1].trim() : '';
}
function rawUploadFilename(request, requestId) {
const url = new URL(request.url);
const candidate =
url.searchParams.get('filename') ||
url.searchParams.get('name') ||
request.headers.get('x-file-name') ||
request.headers.get('x-filename') ||
contentDispositionFilename(request.headers.get('content-disposition')) ||
`${requestId}.upload`;
return safeBaseName(candidate, 'upload');
}
function rawFulfilledBy(request) {
const url = new URL(request.url);
return String(url.searchParams.get('by') || request.headers.get('x-fulfilled-by') || '')
.trim()
.slice(0, 200);
}
async function parseUpload(request, requestId, storedPath) {
if (isMultipartRequest(request)) {
const parsed = await streamMultipartToPath(request, {
fileFieldName: 'file',
targetPath: storedPath,
maxFileBytes: maxUploadBytes,
});
return {
rawUpload: false,
fulfilledBy: String(parsed.fields.fulfilledBy || '').trim().slice(0, 200),
originalName: safeBaseName(parsed.file.filename, 'upload'),
sizeBytes: parsed.file.sizeBytes,
};
}
const contentLength = Number.parseInt(String(request.headers.get('content-length') || ''), 10);
if (maxUploadBytes > 0 && Number.isFinite(contentLength) && contentLength > maxUploadBytes) {
throw new Error('file-too-large');
}
const parsed = await streamBodyToPath(request, {
targetPath: storedPath,
maxBytes: maxUploadBytes,
});
return {
rawUpload: true,
fulfilledBy: rawFulfilledBy(request),
originalName: rawUploadFilename(request, requestId),
sizeBytes: parsed.sizeBytes,
};
}
function firstForwardedPart(value) {
if (!value) {
return '';
@@ -113,11 +220,12 @@ function createRandomId() {
return toBase32(crypto.randomBytes(5));
}
export async function POST(request, { params }) {
async function handleUpload(request, { params }) {
await runCleanupIfNeeded();
const resolvedParams = await params;
const requestId = normalizeRequestId(resolvedParams.id);
const rawUpload = !isMultipartRequest(request);
if (!isValidRequestId(requestId)) {
return new NextResponse('Ungültige Anfrage-ID', { status: 400 });
@@ -131,16 +239,20 @@ export async function POST(request, { params }) {
);
if (!uploadRequest) {
return redirectToRequest(requestId, { error: 'Anfrage nicht gefunden.' });
return uploadErrorResponse(requestId, rawUpload, 'Anfrage nicht gefunden.', 404);
}
if (Number(uploadRequest.completed_at || 0) > 0) {
if (rawUpload) {
return textResponse('Diese Anfrage wurde bereits erfüllt.', 409);
}
return redirectToRequest(requestId, { success: 'Diese Anfrage wurde bereits erfüllt.' });
}
const now = Date.now();
if (Number(uploadRequest.expires_at || 0) <= now) {
return redirectToRequest(requestId, { error: 'Diese Anfrage ist bereits abgelaufen.' });
return uploadErrorResponse(requestId, rawUpload, 'Diese Anfrage ist bereits abgelaufen.', 410);
}
let storedName = '';
@@ -158,35 +270,31 @@ export async function POST(request, { params }) {
}
if (!storedName || !storedPath) {
return redirectToRequest(requestId, { error: 'Upload-ID konnte nicht erzeugt werden.' });
return uploadErrorResponse(requestId, rawUpload, 'Upload-ID konnte nicht erzeugt werden.', 500);
}
let parsed;
try {
parsed = await streamMultipartToPath(request, {
fileFieldName: 'file',
targetPath: storedPath,
maxFileBytes: maxUploadBytes,
});
parsed = await parseUpload(request, requestId, storedPath);
} catch (error) {
if (error && error.message === 'file-too-large') {
return redirectToRequest(requestId, {
error: `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`,
});
return uploadErrorResponse(
requestId,
rawUpload,
`Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`,
413
);
}
if (error && error.message === 'missing-file') {
return redirectToRequest(requestId, { error: 'Keine Datei hochgeladen.' });
return uploadErrorResponse(requestId, rawUpload, 'Keine Datei hochgeladen.', 400);
}
return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' });
return uploadErrorResponse(requestId, rawUpload, 'Upload fehlgeschlagen.', 500);
}
const fulfilledBy = String(parsed.fields.fulfilledBy || '').trim().slice(0, 200);
const originalName = safeBaseName(parsed.file.filename, 'upload');
const uploadExpiry = Math.min(now + uploadTtlSeconds * 1000, now + maxRetentionSeconds * 1000);
let uploadId = null;
const sizeBytes = parsed.file.sizeBytes;
const { fulfilledBy, originalName, sizeBytes } = parsed;
try {
const insertResult = await run(
`INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at)
@@ -196,7 +304,7 @@ export async function POST(request, { params }) {
uploadId = insertResult.lastID;
} catch {
await fs.promises.rm(storedPath, { force: true }).catch(() => undefined);
return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' });
return uploadErrorResponse(requestId, rawUpload, 'Upload fehlgeschlagen.', 500);
}
const updateResult = await run(
@@ -215,6 +323,10 @@ export async function POST(request, { params }) {
if (!updateResult || updateResult.changes < 1) {
await run('DELETE FROM uploads WHERE id = ?', [uploadId]).catch(() => undefined);
await fs.promises.rm(storedPath, { force: true }).catch(() => undefined);
if (rawUpload) {
return textResponse('Diese Anfrage wurde bereits erfüllt.', 409);
}
return redirectToRequest(requestId, { success: 'Diese Anfrage wurde bereits erfüllt.' });
}
@@ -239,7 +351,12 @@ export async function POST(request, { params }) {
if (mailResult.ok) {
await run('UPDATE upload_requests SET notification_sent_at = ? WHERE id = ?', [Date.now(), requestId]);
return redirectToRequest(requestId, { success: 'Datei erfolgreich hochgeladen. Vielen Dank!' });
return uploadSuccessResponse(
requestId,
rawUpload,
'Datei erfolgreich hochgeladen. Vielen Dank!',
downloadUrl
);
}
await logEvent(
@@ -249,7 +366,18 @@ export async function POST(request, { params }) {
await getRequestMeta()
);
return redirectToRequest(requestId, {
success: 'Datei hochgeladen. Hinweis: E-Mail-Benachrichtigung konnte nicht gesendet werden.',
});
return uploadSuccessResponse(
requestId,
rawUpload,
'Datei hochgeladen. Hinweis: E-Mail-Benachrichtigung konnte nicht gesendet werden.',
downloadUrl
);
}
export async function POST(request, context) {
return handleUpload(request, context);
}
export async function PUT(request, context) {
return handleUpload(request, context);
}

View File

@@ -1,22 +1,19 @@
:root {
--font-body: 'Manrope', sans-serif;
--font-heading: 'Space Grotesk', sans-serif;
--bg-main: #eef4f7;
--bg-accent: #d9ece4;
--bg-main: #f4f6f8;
--surface: #ffffff;
--surface-soft: #f7fafc;
--surface-glass: rgb(255 255 255 / 0.72);
--text-main: #10243a;
--text-muted: #566b81;
--line: #d6e0ea;
--surface-soft: #f8fafc;
--text-main: #172033;
--text-muted: #64748b;
--line: #d9e1ea;
--primary: #0f766e;
--primary-hover: #0e635c;
--primary-soft: #d8f3ea;
--primary-hover: #0b5f59;
--primary-soft: #e2f4ef;
--danger: #b42318;
--danger-soft: #fee4e2;
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 16px 36px -24px rgb(16 36 58 / 0.45);
--radius: 12px;
--radius-sm: 8px;
}
* {
@@ -30,13 +27,10 @@ body {
}
body {
font-family: var(--font-body);
color: var(--text-main);
background:
radial-gradient(circle at 15% 20%, rgb(255 255 255 / 0.95) 0%, rgb(255 255 255 / 0.8) 35%, transparent 65%),
radial-gradient(circle at 85% 0%, rgb(217 236 228 / 0.75) 0%, transparent 45%),
linear-gradient(140deg, var(--bg-main), #f6f9fc 60%, #e8f2f7);
min-height: 100vh;
background: var(--bg-main);
color: var(--text-main);
font-family: var(--font-body);
}
a {
@@ -45,27 +39,35 @@ a {
h1,
h2,
h3 {
margin: 0;
font-family: var(--font-heading);
letter-spacing: -0.02em;
}
h3,
p {
margin: 0;
}
h1,
h2,
h3 {
font-family: var(--font-heading);
letter-spacing: -0.02em;
}
.page-shell {
width: min(1200px, 100% - 2.5rem);
width: min(1120px, 100% - 2rem);
margin: 0 auto;
padding: 2.2rem 0 3.5rem;
padding: 1.6rem 0 2.5rem;
display: grid;
gap: 1.6rem;
animation: page-enter 240ms ease-out both;
gap: 1rem;
}
.page-shell.narrow {
width: min(560px, 100% - 2rem);
width: min(560px, 100% - 1.5rem);
}
.page-header,
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.page-header {
@@ -73,57 +75,20 @@ p {
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.9rem;
background: linear-gradient(170deg, var(--surface-glass), rgb(255 255 255 / 0.5));
border: 1px solid rgb(214 224 234 / 0.9);
border-radius: var(--radius);
padding: 0.95rem 1.05rem;
backdrop-filter: blur(6px);
gap: 0.8rem;
padding: 1rem;
}
.header-main {
.header-main,
.panel,
.form-grid,
.stack-actions {
display: grid;
gap: 0.3rem;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
gap: 0.75rem;
}
.panel {
background: linear-gradient(180deg, var(--surface), var(--surface-soft));
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.2rem;
display: grid;
gap: 0.95rem;
}
.panel.panel-soft {
background: linear-gradient(180deg, #f5fafc, #ffffff);
}
.panel.panel-spotlight {
position: relative;
overflow: hidden;
}
.panel.panel-spotlight::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 90% 0%, rgb(15 118 110 / 0.12), transparent 45%),
radial-gradient(circle at 10% 100%, rgb(15 118 110 / 0.08), transparent 40%);
}
.panel.panel-spotlight > * {
position: relative;
padding: 1rem;
}
.panel.centered {
@@ -135,10 +100,69 @@ p {
color: var(--text-muted);
}
.toolbar,
.row-actions,
.inline-form,
.tabs,
.summary-line,
.breadcrumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.toolbar,
.row-actions,
.inline-form {
gap: 0.45rem;
}
.tabs {
gap: 0.4rem;
padding: 0.25rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.tab {
border-radius: var(--radius-sm);
color: var(--text-muted);
font-weight: 700;
padding: 0.48rem 0.75rem;
text-decoration: none;
}
.tab:hover,
.tab.active {
background: var(--primary-soft);
color: #0c4f4a;
}
.summary-line {
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.summary-line span,
.chip,
.badge {
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
}
.summary-line span {
padding: 0.28rem 0.6rem;
}
.status {
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 0.65rem 0.8rem;
border: 1px solid transparent;
font-size: 0.93rem;
}
@@ -154,16 +178,11 @@ p {
color: var(--danger);
}
.form-grid {
display: grid;
gap: 0.8rem;
}
.field {
display: grid;
gap: 0.35rem;
font-size: 0.92rem;
font-weight: 600;
font-weight: 700;
}
.input {
@@ -174,13 +193,15 @@ p {
color: var(--text-main);
font: inherit;
padding: 0.52rem 0.62rem;
transition: border-color 140ms ease, box-shadow 140ms ease;
}
.input:focus-visible {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgb(15 118 110 / 0.16);
.input:focus-visible,
.btn:focus-visible,
.tab:focus-visible,
.chip:focus-visible,
.action-menu > summary:focus-visible {
outline: 3px solid rgb(15 118 110 / 0.22);
outline-offset: 2px;
}
.input.small {
@@ -190,33 +211,27 @@ p {
.btn {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: var(--primary);
color: #fff;
text-decoration: none;
font: inherit;
font-weight: 600;
cursor: pointer;
font: inherit;
font-weight: 700;
padding: 0.48rem 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 140ms ease, transform 120ms ease, border-color 140ms ease;
text-decoration: none;
}
.btn:hover {
background: var(--primary-hover);
}
.btn:active {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
opacity: 0.65;
}
.btn.secondary {
@@ -226,14 +241,13 @@ p {
}
.btn.secondary:hover {
background: #f4f8fb;
border-color: #c3d4e2;
background: var(--surface-soft);
}
.btn.danger {
background: var(--danger-soft);
color: var(--danger);
border-color: #f7b0a8;
color: var(--danger);
}
.btn.danger:hover {
@@ -241,16 +255,11 @@ p {
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
border-radius: 999px;
background: #fff;
border: 1px solid var(--line);
gap: 0.35rem;
color: var(--text-main);
text-decoration: none;
font-size: 0.82rem;
padding: 0.3rem 0.58rem;
text-decoration: none;
}
.chip.primary {
@@ -260,14 +269,11 @@ p {
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid transparent;
padding: 0.18rem 0.5rem;
border-color: transparent;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.02em;
padding: 0.18rem 0.5rem;
}
.badge.primary {
@@ -290,49 +296,21 @@ p {
.metric-grid {
display: grid;
gap: 0.8rem;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.metric {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
padding: 0.7rem;
display: grid;
gap: 0.2rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.7rem;
}
.metric strong {
font-family: var(--font-heading);
font-size: 1.25rem;
letter-spacing: -0.02em;
}
.dashboard-top-grid {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.9fr);
}
.info-stack {
display: grid;
gap: 0.65rem;
}
.info-card {
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: #fff;
padding: 0.7rem 0.8rem;
display: grid;
gap: 0.2rem;
}
.info-card strong {
font-family: var(--font-heading);
letter-spacing: -0.02em;
font-size: 1.02rem;
}
.request-meta-wrap {
@@ -355,16 +333,11 @@ p {
font-weight: 700;
}
.request-meta-table tbody tr:first-child th,
.request-meta-table tbody tr:first-child td {
border-top: none;
}
.upload-progress {
display: grid;
gap: 0.45rem;
border-radius: var(--radius-sm);
border: 1px solid #bde1d9;
border-radius: var(--radius-sm);
background: #ecf8f4;
padding: 0.58rem 0.66rem;
}
@@ -378,73 +351,54 @@ p {
}
.upload-progress-track {
width: 100%;
height: 8px;
border-radius: 999px;
overflow: hidden;
border-radius: 999px;
background: #d7ebe4;
}
.upload-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #0f766e, #189181);
height: 100%;
background: var(--primary);
transition: width 140ms linear;
}
.table-wrap {
width: 100%;
overflow-x: auto;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 680px;
border-collapse: collapse;
}
th,
td {
border-top: 1px solid var(--line);
padding: 0.62rem 0.66rem;
text-align: left;
vertical-align: top;
padding: 0.62rem 0.66rem;
border-top: 1px solid var(--line);
font-size: 0.9rem;
}
thead th {
thead th,
tbody tr:first-child th,
tbody tr:first-child td {
border-top: none;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.74rem;
}
thead th {
background: var(--surface-soft);
color: var(--text-muted);
background: #f8fbfd;
}
tbody tr:hover {
background: #f7fbfc;
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.stack-actions {
display: grid;
gap: 0.45rem;
}
.inline-form {
display: flex;
gap: 0.4rem;
align-items: center;
flex-wrap: wrap;
font-size: 0.74rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.action-form-row {
@@ -456,15 +410,51 @@ tbody tr:hover {
min-width: 7rem;
}
.input.path-input {
min-width: 12rem;
}
.inline-form.stacked {
display: grid;
}
.breadcrumbs {
.check-row {
display: flex;
gap: 0.35rem;
align-items: center;
flex-wrap: wrap;
gap: 0.45rem;
color: var(--text-muted);
font-size: 0.92rem;
font-weight: 700;
}
.action-menu > summary {
display: inline-flex;
cursor: pointer;
list-style: none;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
font-weight: 700;
padding: 0.48rem 0.75rem;
}
.action-menu > summary::-webkit-details-marker {
display: none;
}
.action-menu[open] > summary {
background: var(--surface-soft);
}
.action-menu-panel {
display: grid;
gap: 0.55rem;
margin-top: 0.6rem;
min-width: 13rem;
}
.breadcrumbs {
gap: 0.35rem;
font-size: 0.88rem;
}
@@ -477,40 +467,46 @@ tbody tr:hover {
text-decoration: underline;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 0.8rem;
.help-box {
border-top: 1px solid var(--line);
padding-top: 0.75rem;
}
@keyframes page-enter {
from {
opacity: 0;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
.help-box summary {
cursor: pointer;
font-weight: 700;
}
pre {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #0f172a;
color: #e2e8f0;
font-size: 0.82rem;
padding: 0.75rem;
}
.mono,
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.mono {
font-size: 0.8rem;
}
@media (max-width: 900px) {
.page-shell {
width: min(100% - 1.4rem, 1200px);
padding-top: 1.3rem;
}
.page-header {
padding: 0.8rem;
width: min(100% - 1rem, 1120px);
padding-top: 1rem;
}
.page-header,
.panel {
padding: 0.85rem;
}
.dashboard-top-grid {
grid-template-columns: 1fr;
}
table {
min-width: 620px;
}

View File

@@ -63,7 +63,7 @@ export function UploadProgressForm({ csrfToken }) {
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const redirectPath = xhr.response?.redirect || '/manage/dashboard?success=Upload%20abgeschlossen.';
const redirectPath = xhr.response?.redirect || '/manage/dashboard?tab=upload&success=Upload%20abgeschlossen.';
window.location.assign(redirectPath);
return;
}

View File

@@ -2,8 +2,8 @@ import {
adminDeleteUploadAction,
adminExtendUploadAction,
adminLogoutAction,
adminSaveUploadToPathAction,
} from '@/src/lib/actions.js';
import { adminHash } from '@/src/lib/config.js';
import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js';
import { sharedLinkName } from '@/src/lib/files.js';
import {
@@ -23,20 +23,6 @@ export const dynamic = 'force-dynamic';
export default async function AdminDashboardPage({ searchParams }) {
await runCleanupIfNeeded();
if (!adminHash) {
return (
<main className="page-shell narrow">
<section className="panel centered">
<h1>Adminzugang nicht konfiguriert</h1>
<p className="muted">Setze MANAGEMENT_ADMIN_HASH in der Umgebungskonfiguration.</p>
<a className="btn secondary" href="/manage/login">
Zurück
</a>
</section>
</main>
);
}
await requireAdminUser();
const csrfToken = await ensureCsrfToken();
@@ -50,6 +36,7 @@ export default async function AdminDashboardPage({ searchParams }) {
lastCleanup,
recentLogs,
allUploads,
uploadRequests,
] = await Promise.all([
get('SELECT COUNT(*) AS count FROM uploads'),
get('SELECT COALESCE(SUM(size_bytes), 0) AS total FROM uploads'),
@@ -68,11 +55,19 @@ export default async function AdminDashboardPage({ searchParams }) {
all(
'SELECT id, owner, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads ORDER BY uploaded_at DESC LIMIT 500'
),
all(
`SELECT id, owner, note, created_at, expires_at, completed_at, upload_id, uploaded_original_name, uploaded_size_bytes
FROM upload_requests
ORDER BY created_at DESC
LIMIT 500`
),
]);
const resolvedSearchParams = await searchParams;
const error = readSearchParam(resolvedSearchParams, 'error');
const success = readSearchParam(resolvedSearchParams, 'success');
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
const activeTab = ['stats', 'uploads', 'requests', 'logs'].includes(requestedTab) ? requestedTab : 'stats';
return (
<main className="page-shell">
@@ -100,6 +95,22 @@ export default async function AdminDashboardPage({ searchParams }) {
<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>
{activeTab === 'stats' ? (
<section className="panel">
<h2>Statistiken</h2>
<div className="metric-grid">
@@ -133,7 +144,9 @@ export default async function AdminDashboardPage({ searchParams }) {
</article>
</div>
</section>
) : null}
{activeTab === 'uploads' ? (
<section className="panel">
<h2>Aktuelle Uploads</h2>
{allUploads.length === 0 ? (
@@ -169,7 +182,9 @@ export default async function AdminDashboardPage({ searchParams }) {
<div className="muted">Noch {formatCountdown(item.expires_at)}</div>
</td>
<td>
<div className="stack-actions">
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<div className="row-actions">
<a className="btn secondary" href={sharePath}>
Download
@@ -177,6 +192,21 @@ export default async function AdminDashboardPage({ searchParams }) {
<CopyLinkButton path={sharePath} label={item.original_name} />
</div>
<form className="inline-form stacked" action={adminSaveUploadToPathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="uploadId" value={item.id} />
<input type="hidden" name="redirectTab" value="uploads" />
<input
className="input small path-input"
name="targetPath"
placeholder="Ziel, z.B. inbox/"
required
/>
<button className="btn secondary" type="submit">
Im Dateimanager speichern
</button>
</form>
<form className="inline-form action-form-row">
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="uploadId" value={item.id} />
@@ -188,7 +218,8 @@ export default async function AdminDashboardPage({ searchParams }) {
Löschen
</button>
</form>
</div>
</div>
</details>
</td>
</tr>
);
@@ -198,7 +229,95 @@ export default async function AdminDashboardPage({ searchParams }) {
</div>
)}
</section>
) : null}
{activeTab === 'requests' ? (
<section className="panel">
<h2>Upload-Anfragen</h2>
{uploadRequests.length === 0 ? (
<p className="muted">Keine Upload-Anfragen vorhanden.</p>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Anfrage</th>
<th>Nutzer</th>
<th>Status</th>
<th>Erstellt</th>
<th>Ablauf</th>
<th>Datei</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{uploadRequests.map((entry) => {
const isCompleted = Number(entry.completed_at || 0) > 0;
const isExpired = !isCompleted && Number(entry.expires_at || 0) <= Date.now();
const requestPath = `/_request/${encodeURIComponent(entry.id)}`;
return (
<tr key={entry.id}>
<td>
<strong>{entry.id}</strong>
{entry.note ? <div className="muted">{entry.note}</div> : null}
</td>
<td>{entry.owner}</td>
<td>
<span className={`badge ${isCompleted ? 'success' : isExpired ? 'danger' : 'primary'}`}>
{isCompleted ? 'Abgeschlossen' : isExpired ? 'Abgelaufen' : 'Offen'}
</span>
</td>
<td>{formatTimestamp(entry.created_at)}</td>
<td>{formatTimestamp(entry.expires_at)}</td>
<td>
{entry.uploaded_original_name ? (
<>
<strong>{entry.uploaded_original_name}</strong>
<div className="muted">{formatBytes(entry.uploaded_size_bytes || 0)}</div>
</>
) : (
<span className="muted">-</span>
)}
</td>
<td>
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<a className="btn secondary" href={requestPath}>
Öffnen
</a>
{entry.upload_id ? (
<form className="inline-form stacked" action={adminSaveUploadToPathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="uploadId" value={entry.upload_id} />
<input type="hidden" name="redirectTab" value="requests" />
<input
className="input small path-input"
name="targetPath"
placeholder="Ziel, z.B. inbox/"
required
/>
<button className="btn secondary" type="submit">
Im Dateimanager speichern
</button>
</form>
) : null}
</div>
</details>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
) : null}
{activeTab === 'logs' ? (
<section className="panel">
<h2>Letzte Ereignisse</h2>
{recentLogs.length === 0 ? (
@@ -247,6 +366,7 @@ export default async function AdminDashboardPage({ searchParams }) {
</div>
)}
</section>
) : null}
</main>
);
}

View File

@@ -35,6 +35,12 @@ function buildBreadcrumbs(relativePath) {
return breadcrumbs;
}
function adminFilesTabHref(relativePath, tab) {
const baseHref = adminFilesHref(relativePath);
const separator = baseHref.includes('?') ? '&' : '?';
return `${baseHref}${separator}tab=${encodeURIComponent(tab)}`;
}
export default async function AdminFilesPage({ searchParams }) {
await runCleanupIfNeeded();
await requireAdminUser();
@@ -45,6 +51,8 @@ export default async function AdminFilesPage({ searchParams }) {
const queryError = readSearchParam(resolvedSearchParams, 'error');
const success = readSearchParam(resolvedSearchParams, 'success');
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
const activeTab = ['content', 'folder', 'upload'].includes(requestedTab) ? requestedTab : 'content';
const absolutePath = resolveAdminPath(relativePath);
if (!absolutePath) {
@@ -164,8 +172,20 @@ export default async function AdminFilesPage({ searchParams }) {
</div>
</section>
<section className="panel" style={{ display: 'grid', gap: '0.8rem', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
<div>
<nav className="tabs" aria-label="Dateimanager-Bereiche">
<a className={`tab ${activeTab === 'content' ? 'active' : ''}`} href={adminFilesTabHref(relativePath, 'content')}>
Inhalt
</a>
<a className={`tab ${activeTab === 'folder' ? 'active' : ''}`} href={adminFilesTabHref(relativePath, 'folder')}>
Neuer Ordner
</a>
<a className={`tab ${activeTab === 'upload' ? 'active' : ''}`} href={adminFilesTabHref(relativePath, 'upload')}>
Upload
</a>
</nav>
{activeTab === 'folder' ? (
<section className="panel">
<h2>Ordner erstellen</h2>
<form className="form-grid" action={adminMkdirAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
@@ -180,9 +200,11 @@ export default async function AdminFilesPage({ searchParams }) {
Erstellen
</button>
</form>
</div>
</section>
) : null}
<div>
{activeTab === 'upload' ? (
<section className="panel">
<h2>Datei hochladen</h2>
<form className="form-grid" action={adminUploadToPathAction} encType="multipart/form-data">
<input type="hidden" name="csrfToken" value={csrfToken} />
@@ -197,9 +219,10 @@ export default async function AdminFilesPage({ searchParams }) {
Hochladen
</button>
</form>
</div>
</section>
</section>
) : null}
{activeTab === 'content' ? (
<section className="panel">
<h2>Inhalt</h2>
{details.length === 0 ? (
@@ -233,44 +256,47 @@ export default async function AdminFilesPage({ searchParams }) {
<td>{item.size != null ? formatBytes(item.size) : '-'}</td>
<td>{item.modifiedAt ? formatTimestamp(item.modifiedAt) : '-'}</td>
<td>
<div className="stack-actions">
<form className="inline-form stacked" action={adminRenamePathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="path" value={item.childPath} />
<input className="input small" name="newName" placeholder="Neuer Name" required />
<button className="btn secondary" type="submit">
Umbenennen
</button>
</form>
<details className="action-menu">
<summary>Aktionen</summary>
<div className="action-menu-panel">
<form className="inline-form stacked" action={adminRenamePathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="path" value={item.childPath} />
<input className="input small" name="newName" placeholder="Neuer Name" required />
<button className="btn secondary" type="submit">
Umbenennen
</button>
</form>
<form className="inline-form stacked" action={adminMovePathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="path" value={item.childPath} />
<input type="hidden" name="currentPath" value={relativePath} />
<input className="input small" name="targetPath" placeholder="Zielpfad" required />
<button className="btn secondary" type="submit">
Verschieben
</button>
</form>
<form className="inline-form stacked" action={adminMovePathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="path" value={item.childPath} />
<input type="hidden" name="currentPath" value={relativePath} />
<input className="input small" name="targetPath" placeholder="Zielpfad" required />
<button className="btn secondary" type="submit">
Verschieben
</button>
</form>
<form className="inline-form stacked" action={adminCopyPathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="path" value={item.childPath} />
<input type="hidden" name="currentPath" value={relativePath} />
<input className="input small" name="targetPath" placeholder="Zielpfad" required />
<button className="btn secondary" type="submit">
Kopieren
</button>
</form>
<form className="inline-form stacked" action={adminCopyPathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="path" value={item.childPath} />
<input type="hidden" name="currentPath" value={relativePath} />
<input className="input small" name="targetPath" placeholder="Zielpfad" required />
<button className="btn secondary" type="submit">
Kopieren
</button>
</form>
<form className="inline-form" action={adminDeletePathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="path" value={item.childPath} />
<button className="btn danger" type="submit">
Löschen
</button>
</form>
</div>
<form className="inline-form" action={adminDeletePathAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="path" value={item.childPath} />
<button className="btn danger" type="submit">
Löschen
</button>
</form>
</div>
</details>
</td>
</tr>
))}
@@ -279,6 +305,7 @@ export default async function AdminFilesPage({ searchParams }) {
</div>
)}
</section>
) : null}
</main>
);
}

View File

@@ -3,6 +3,7 @@ import {
adminDeleteUserAction,
adminLogoutAction,
adminResetUserAction,
adminSetUserAdminAction,
} from '@/src/lib/actions.js';
import { all, runCleanupIfNeeded } from '@/src/lib/db.js';
import { formatTimestamp, readSearchParam } from '@/src/lib/format.js';
@@ -14,14 +15,16 @@ export const dynamic = 'force-dynamic';
export default async function AdminUsersPage({ searchParams }) {
await runCleanupIfNeeded();
await requireAdminUser();
const actor = await requireAdminUser();
const csrfToken = await ensureCsrfToken();
const users = await all('SELECT username, created_at FROM users ORDER BY username ASC');
const users = await all('SELECT username, created_at, is_admin FROM users ORDER BY username ASC');
const resolvedSearchParams = await searchParams;
const error = readSearchParam(resolvedSearchParams, 'error');
const success = readSearchParam(resolvedSearchParams, 'success');
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
const activeTab = requestedTab === 'create' ? 'create' : 'users';
return (
<main className="page-shell">
@@ -46,6 +49,16 @@ export default async function AdminUsersPage({ searchParams }) {
<StatusMessage error={error} success={success} />
<nav className="tabs" aria-label="Benutzer-Bereiche">
<a className={`tab ${activeTab === 'users' ? 'active' : ''}`} href="/manage/admin/users?tab=users">
Benutzer
</a>
<a className={`tab ${activeTab === 'create' ? 'active' : ''}`} href="/manage/admin/users?tab=create">
Neu
</a>
</nav>
{activeTab === 'create' ? (
<section className="panel">
<h2>Neuen Benutzer anlegen</h2>
<form className="form-grid" action={adminCreateUserAction}>
@@ -61,12 +74,19 @@ export default async function AdminUsersPage({ searchParams }) {
<input className="input" name="password" type="password" autoComplete="new-password" required />
</label>
<label className="check-row">
<input type="checkbox" name="isAdmin" value="1" />
Adminrechte vergeben
</label>
<button className="btn" type="submit">
Benutzer erstellen
</button>
</form>
</section>
) : null}
{activeTab === 'users' ? (
<section className="panel">
<h2>Bestehende Benutzer</h2>
{users.length === 0 ? (
@@ -77,6 +97,7 @@ export default async function AdminUsersPage({ searchParams }) {
<thead>
<tr>
<th>Benutzername</th>
<th>Rolle</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
@@ -85,9 +106,27 @@ export default async function AdminUsersPage({ searchParams }) {
{users.map((user) => (
<tr key={user.username}>
<td>{user.username}</td>
<td>
<span className={`badge ${user.is_admin ? 'primary' : ''}`}>
{user.is_admin ? 'Admin' : 'Benutzer'}
</span>
</td>
<td>{formatTimestamp(user.created_at)}</td>
<td>
<div className="stack-actions">
<form className="inline-form" action={adminSetUserAdminAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="username" value={user.username} />
<input type="hidden" name="isAdmin" value={user.is_admin ? '0' : '1'} />
<button
className="btn secondary"
type="submit"
disabled={actor.username === user.username && Boolean(user.is_admin)}
>
{user.is_admin ? 'Adminrechte entziehen' : 'Zum Admin machen'}
</button>
</form>
<form className="inline-form" action={adminResetUserAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="username" value={user.username} />
@@ -106,7 +145,7 @@ export default async function AdminUsersPage({ searchParams }) {
<form className="inline-form" action={adminDeleteUserAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="hidden" name="username" value={user.username} />
<button className="btn danger" type="submit">
<button className="btn danger" type="submit" disabled={actor.username === user.username}>
Benutzer löschen
</button>
</form>
@@ -119,6 +158,7 @@ export default async function AdminUsersPage({ searchParams }) {
</div>
)}
</section>
) : null}
</main>
);
}

View File

@@ -75,7 +75,7 @@ function errorResponse(request, message, status = 400) {
return new NextResponse(null, {
status: 303,
headers: {
location: dashboardHref({ error: message }),
location: dashboardHref({ tab: 'upload', error: message }),
},
});
}
@@ -84,7 +84,7 @@ function errorResponse(request, message, status = 400) {
}
function successResponse(request, message) {
const redirectPath = dashboardHref({ success: message });
const redirectPath = dashboardHref({ tab: 'upload', success: message });
if (expectsHtml(request)) {
return new NextResponse(null, {
status: 303,

View File

@@ -38,6 +38,8 @@ export default async function DashboardPage({ searchParams }) {
const success = readSearchParam(resolvedSearchParams, 'success');
const totalBytes = uploads.reduce((total, item) => total + (Number(item.size_bytes) || 0), 0);
const nowTs = Date.now();
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
const activeTab = ['upload', 'files', 'requests'].includes(requestedTab) ? requestedTab : 'upload';
return (
<main className="page-shell">
@@ -65,27 +67,31 @@ export default async function DashboardPage({ searchParams }) {
<StatusMessage error={error} success={success} />
<div className="dashboard-top-grid">
<section className="panel panel-spotlight">
<nav className="tabs" aria-label="Dashboard-Bereiche">
<a className={`tab ${activeTab === 'upload' ? 'active' : ''}`} href="/manage/dashboard?tab=upload">
Upload
</a>
<a className={`tab ${activeTab === 'files' ? 'active' : ''}`} href="/manage/dashboard?tab=files">
Dateien
</a>
<a className={`tab ${activeTab === 'requests' ? 'active' : ''}`} href="/manage/dashboard?tab=requests">
Anfragen
</a>
</nav>
<div className="summary-line">
<span>{uploads.length} aktive Uploads</span>
<span>{totalBytes > 0 ? formatBytes(totalBytes) : '0 B'} genutzt</span>
</div>
{activeTab === 'upload' ? (
<section className="panel">
<h2>Neue Datei hochladen</h2>
<UploadProgressForm csrfToken={csrfToken} />
</section>
) : null}
<aside className="panel panel-soft">
<h2>Schnellüberblick</h2>
<div className="info-stack">
<div className="info-card">
<strong>{uploads.length}</strong>
<span className="muted">aktive Uploads</span>
</div>
<div className="info-card">
<strong>{totalBytes > 0 ? formatBytes(totalBytes) : '0 B'}</strong>
<span className="muted">genutzter Speicher</span>
</div>
</div>
</aside>
</div>
{activeTab === 'requests' ? (
<section className="panel">
<h2>Upload-Anfragen</h2>
<p className="muted">Benachrichtigung erfolgt an deinen Benutzernamen (E-Mail-Adresse).</p>
@@ -162,7 +168,9 @@ export default async function DashboardPage({ searchParams }) {
</div>
)}
</section>
) : null}
{activeTab === 'files' ? (
<section className="panel">
<h2>Aktuelle Uploads</h2>
{uploads.length === 0 ? (
@@ -225,6 +233,7 @@ export default async function DashboardPage({ searchParams }) {
</div>
)}
</section>
) : null}
</main>
);
}

View File

@@ -2,7 +2,7 @@
const uploadMaxBytes = Number.parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10);
const actionBodySizeLimit = Number.isFinite(uploadMaxBytes) && uploadMaxBytes > 0
? `${uploadMaxBytes}`
: '1gb';
: '25gb';
const managementPath = '/manage';
const nextConfig = {

View File

@@ -37,7 +37,8 @@ import {
} from './security.js';
function buildPathWithQuery(pathname, params = {}) {
const query = new URLSearchParams();
const [pathOnly, existingQuery = ''] = String(pathname).split('?');
const query = new URLSearchParams(existingQuery);
for (const [key, value] of Object.entries(params)) {
if (value) {
@@ -46,7 +47,7 @@ function buildPathWithQuery(pathname, params = {}) {
}
const serialized = query.toString();
return serialized ? `${pathname}?${serialized}` : pathname;
return serialized ? `${pathOnly}?${serialized}` : pathOnly;
}
function redirectWithError(pathname, message) {
@@ -57,6 +58,10 @@ function redirectWithSuccess(pathname, message) {
redirect(buildPathWithQuery(pathname, { success: message }));
}
function adminFilesTabHref(relativePath, tab) {
return buildPathWithQuery(adminFilesHref(relativePath), { tab });
}
function getUploadId(formData, fieldName = 'uploadId') {
const parsed = Number.parseInt(String(formData.get(fieldName) || ''), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
@@ -156,6 +161,33 @@ async function copyPath(sourcePath, targetPath) {
});
}
function savedUploadFileName(originalName, storedName) {
const fallback = safeBaseName(storedName, 'upload');
const candidate = safeBaseName(originalName, fallback).replace(/[\\/]/g, '_');
return isValidNodeName(candidate) ? candidate : fallback;
}
async function resolveSavedUploadTarget(targetRelativePath, fileName) {
const targetPath = resolveAdminPath(targetRelativePath);
if (!targetPath) {
return null;
}
try {
const targetStat = await fs.promises.stat(targetPath);
if (targetStat.isDirectory()) {
return path.join(targetPath, fileName);
}
} catch {
}
if (!isValidNodeName(path.basename(targetPath))) {
return null;
}
return targetPath;
}
export async function userLoginAction(formData) {
await runCleanupIfNeeded();
@@ -175,13 +207,13 @@ export async function userLoginAction(formData) {
const username = String(formData.get('username') || '').trim();
const password = String(formData.get('password') || '');
const row = await get('SELECT password_hash FROM users WHERE username = ?', [username]);
const row = await get('SELECT password_hash, is_admin FROM users WHERE username = ?', [username]);
if (!row || !bcrypt.compareSync(password, row.password_hash)) {
redirectWithError(`${managementBasePath}/login`, 'Anmeldung fehlgeschlagen.');
}
await setAuthCookie({ sub: username });
await setAuthCookie({ sub: username, admin: Boolean(row.is_admin), auth: 'user' });
await clearLoginRateLimit('user');
await logEvent('login', username, { ok: true }, await getRequestMeta());
redirect(`${managementBasePath}/dashboard`);
@@ -191,7 +223,7 @@ export async function userLogoutAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
redirectWithError(`${managementBasePath}/dashboard?tab=upload`, 'CSRF-Prüfung fehlgeschlagen.');
}
await clearAuthCookie();
@@ -222,7 +254,7 @@ export async function adminLoginAction(formData) {
redirectWithError(`${managementBasePath}/admin`, 'Anmeldung fehlgeschlagen.');
}
await setAuthCookie({ sub: 'admin', admin: true });
await setAuthCookie({ sub: 'admin', admin: true, auth: 'admin' });
await clearLoginRateLimit('admin');
await logEvent('admin_login', 'admin', { ok: true }, await getRequestMeta());
redirect(`${managementBasePath}/admin/dashboard`);
@@ -232,7 +264,7 @@ export async function adminLogoutAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/admin/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'CSRF-Prüfung fehlgeschlagen.');
}
await clearAuthCookie();
@@ -245,18 +277,18 @@ export async function uploadFileAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'CSRF-Prüfung fehlgeschlagen.');
}
const user = await requireAuthenticatedUser();
const uploadedFile = uploadedFileFromForm(formData, 'file');
if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) {
redirectWithError(`${managementBasePath}/dashboard`, 'Keine Datei hochgeladen.');
redirectWithError(`${managementBasePath}/dashboard?tab=upload`, 'Keine Datei hochgeladen.');
}
if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) {
redirectWithError(
`${managementBasePath}/dashboard`,
`${managementBasePath}/dashboard?tab=upload`,
`Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`
);
}
@@ -285,7 +317,7 @@ export async function uploadFileAction(formData) {
]
);
} catch {
redirectWithError(`${managementBasePath}/dashboard`, 'Upload fehlgeschlagen.');
redirectWithError(`${managementBasePath}/dashboard?tab=upload`, 'Upload fehlgeschlagen.');
}
await logEvent(
@@ -295,7 +327,7 @@ export async function uploadFileAction(formData) {
await getRequestMeta()
);
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Upload abgeschlossen.');
redirectWithSuccess(`${managementBasePath}/dashboard?tab=upload`, 'Upload abgeschlossen.');
}
export async function deleteOwnUploadAction(formData) {
@@ -308,7 +340,7 @@ export async function deleteOwnUploadAction(formData) {
const user = await requireAuthenticatedUser();
const uploadId = getUploadId(formData);
if (!uploadId) {
redirectWithError(`${managementBasePath}/dashboard`, 'Ungültige Upload-ID.');
redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'Ungültige Upload-ID.');
}
const uploadEntry = await get(
@@ -317,7 +349,7 @@ export async function deleteOwnUploadAction(formData) {
);
if (!uploadEntry) {
redirectWithError(`${managementBasePath}/dashboard`, 'Upload nicht gefunden.');
redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'Upload nicht gefunden.');
}
try {
@@ -327,20 +359,20 @@ export async function deleteOwnUploadAction(formData) {
await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]);
await logEvent('delete', user.username, { id: uploadEntry.id }, await getRequestMeta());
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Upload gelöscht.');
redirectWithSuccess(`${managementBasePath}/dashboard?tab=files`, 'Upload gelöscht.');
}
export async function extendOwnUploadAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'CSRF-Prüfung fehlgeschlagen.');
}
const user = await requireAuthenticatedUser();
const uploadId = getUploadId(formData);
if (!uploadId) {
redirectWithError(`${managementBasePath}/dashboard`, 'Ungültige Upload-ID.');
redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'Ungültige Upload-ID.');
}
const uploadEntry = await get(
@@ -349,7 +381,7 @@ export async function extendOwnUploadAction(formData) {
);
if (!uploadEntry) {
redirectWithError(`${managementBasePath}/dashboard`, 'Upload nicht gefunden.');
redirectWithError(`${managementBasePath}/dashboard?tab=files`, 'Upload nicht gefunden.');
}
const extensionSeconds = parseHours(formData.get('extendHours'), uploadTtlSeconds);
@@ -366,7 +398,7 @@ export async function extendOwnUploadAction(formData) {
await getRequestMeta()
);
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Aufbewahrung aktualisiert.');
redirectWithSuccess(`${managementBasePath}/dashboard?tab=files`, 'Aufbewahrung aktualisiert.');
}
export async function createUploadRequestAction(formData) {
@@ -375,7 +407,7 @@ export async function createUploadRequestAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
redirectWithError(`${managementBasePath}/dashboard?tab=requests`, 'CSRF-Prüfung fehlgeschlagen.');
}
const user = await requireAuthenticatedUser();
@@ -395,7 +427,7 @@ export async function createUploadRequestAction(formData) {
}
if (!requestId) {
redirectWithError(`${managementBasePath}/dashboard`, 'Anfrage-ID konnte nicht erzeugt werden.');
redirectWithError(`${managementBasePath}/dashboard?tab=requests`, 'Anfrage-ID konnte nicht erzeugt werden.');
}
try {
@@ -404,7 +436,7 @@ export async function createUploadRequestAction(formData) {
[requestId, user.username, note || null, now, expiresAt]
);
} catch {
redirectWithError(`${managementBasePath}/dashboard`, 'Anfrage konnte nicht gespeichert werden.');
redirectWithError(`${managementBasePath}/dashboard?tab=requests`, 'Anfrage konnte nicht gespeichert werden.');
}
await logEvent(
@@ -414,44 +446,77 @@ export async function createUploadRequestAction(formData) {
await getRequestMeta()
);
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Upload-Anfrage erstellt.');
redirectWithSuccess(`${managementBasePath}/dashboard?tab=requests`, 'Upload-Anfrage erstellt.');
}
export async function adminCreateUserAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.');
redirectWithError(`${managementBasePath}/admin/users?tab=create`, 'CSRF-Prüfung fehlgeschlagen.');
}
await requireAdminUser();
const username = String(formData.get('username') || '').trim();
const password = String(formData.get('password') || '');
const isAdmin = String(formData.get('isAdmin') || '') === '1';
if (!username || username.length > 200 || !password) {
redirectWithError(`${managementBasePath}/admin/users`, 'Ungültige Eingabe.');
redirectWithError(`${managementBasePath}/admin/users?tab=create`, 'Ungültige Eingabe.');
}
const passwordHash = bcrypt.hashSync(password, 12);
try {
await run('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)', [
await run('INSERT INTO users (username, password_hash, created_at, is_admin) VALUES (?, ?, ?, ?)', [
username,
passwordHash,
Date.now(),
isAdmin ? 1 : 0,
]);
} catch {
redirectWithError(`${managementBasePath}/admin/users`, 'Benutzername existiert bereits.');
redirectWithError(`${managementBasePath}/admin/users?tab=create`, 'Benutzername existiert bereits.');
}
await logEvent('admin_user_create', 'admin', { username }, await getRequestMeta());
redirectWithSuccess(`${managementBasePath}/admin/users`, 'Benutzer erstellt.');
await logEvent('admin_user_create', 'admin', { username, is_admin: isAdmin }, await getRequestMeta());
redirectWithSuccess(`${managementBasePath}/admin/users?tab=users`, 'Benutzer erstellt.');
}
export async function adminSetUserAdminAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'CSRF-Prüfung fehlgeschlagen.');
}
const actor = await requireAdminUser();
const username = String(formData.get('username') || '').trim();
const isAdmin = String(formData.get('isAdmin') || '') === '1';
if (!username) {
redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Ungültige Eingabe.');
}
if (actor.username === username && !isAdmin) {
redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Eigene Adminrechte können nicht entzogen werden.');
}
const result = await run('UPDATE users SET is_admin = ? WHERE username = ?', [isAdmin ? 1 : 0, username]);
if (!result || result.changes < 1) {
redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Benutzer nicht gefunden.');
}
await logEvent('admin_user_role', 'admin', { username, is_admin: isAdmin }, await getRequestMeta());
redirectWithSuccess(
`${managementBasePath}/admin/users?tab=users`,
isAdmin ? 'Adminrechte vergeben.' : 'Adminrechte entzogen.'
);
}
export async function adminResetUserAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.');
redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'CSRF-Prüfung fehlgeschlagen.');
}
await requireAdminUser();
@@ -459,32 +524,36 @@ export async function adminResetUserAction(formData) {
const username = String(formData.get('username') || '').trim();
const password = String(formData.get('password') || '');
if (!username || !password) {
redirectWithError(`${managementBasePath}/admin/users`, 'Ungültige Eingabe.');
redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Ungültige Eingabe.');
}
const passwordHash = bcrypt.hashSync(password, 12);
await run('UPDATE users SET password_hash = ? WHERE username = ?', [passwordHash, username]);
await logEvent('admin_user_reset', 'admin', { username }, await getRequestMeta());
redirectWithSuccess(`${managementBasePath}/admin/users`, 'Passwort aktualisiert.');
redirectWithSuccess(`${managementBasePath}/admin/users?tab=users`, 'Passwort aktualisiert.');
}
export async function adminDeleteUserAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.');
redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'CSRF-Prüfung fehlgeschlagen.');
}
await requireAdminUser();
const actor = await requireAdminUser();
const username = String(formData.get('username') || '').trim();
if (!username) {
redirectWithError(`${managementBasePath}/admin/users`, 'Ungültige Eingabe.');
redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Ungültige Eingabe.');
}
if (actor.username === username) {
redirectWithError(`${managementBasePath}/admin/users?tab=users`, 'Eigener Benutzer kann nicht gelöscht werden.');
}
await run('DELETE FROM users WHERE username = ?', [username]);
await logEvent('admin_user_delete', 'admin', { username }, await getRequestMeta());
redirectWithSuccess(`${managementBasePath}/admin/users`, 'Benutzer gelöscht.');
redirectWithSuccess(`${managementBasePath}/admin/users?tab=users`, 'Benutzer gelöscht.');
}
export async function adminDeleteUploadAction(formData) {
@@ -498,12 +567,12 @@ export async function adminDeleteUploadAction(formData) {
const uploadId = getUploadId(formData);
if (!uploadId) {
redirectWithError(`${managementBasePath}/admin/dashboard`, 'Ungültige Upload-ID.');
redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Ungültige Upload-ID.');
}
const uploadEntry = await get('SELECT id, stored_path FROM uploads WHERE id = ?', [uploadId]);
if (!uploadEntry) {
redirectWithError(`${managementBasePath}/admin/dashboard`, 'Upload nicht gefunden.');
redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Upload nicht gefunden.');
}
try {
@@ -513,26 +582,26 @@ export async function adminDeleteUploadAction(formData) {
await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]);
await logEvent('delete', 'admin', { id: uploadEntry.id }, await getRequestMeta());
redirectWithSuccess(`${managementBasePath}/admin/dashboard`, 'Upload gelöscht.');
redirectWithSuccess(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Upload gelöscht.');
}
export async function adminExtendUploadAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/admin/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'CSRF-Prüfung fehlgeschlagen.');
}
await requireAdminUser();
const uploadId = getUploadId(formData);
if (!uploadId) {
redirectWithError(`${managementBasePath}/admin/dashboard`, 'Ungültige Upload-ID.');
redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Ungültige Upload-ID.');
}
const uploadEntry = await get('SELECT id, expires_at FROM uploads WHERE id = ?', [uploadId]);
if (!uploadEntry) {
redirectWithError(`${managementBasePath}/admin/dashboard`, 'Upload nicht gefunden.');
redirectWithError(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Upload nicht gefunden.');
}
const extensionSeconds = parseHours(formData.get('extendHours'), uploadTtlSeconds);
@@ -549,7 +618,61 @@ export async function adminExtendUploadAction(formData) {
await getRequestMeta()
);
redirectWithSuccess(`${managementBasePath}/admin/dashboard`, 'Aufbewahrung aktualisiert.');
redirectWithSuccess(`${managementBasePath}/admin/dashboard?tab=uploads`, 'Aufbewahrung aktualisiert.');
}
export async function adminSaveUploadToPathAction(formData) {
const requestedTab = String(formData.get('redirectTab') || 'uploads');
const redirectTab = ['uploads', 'requests'].includes(requestedTab) ? requestedTab : 'uploads';
const dashboardPath = `${managementBasePath}/admin/dashboard?tab=${redirectTab}`;
try {
await verifyCsrf(formData);
} catch {
redirectWithError(dashboardPath, 'CSRF-Prüfung fehlgeschlagen.');
}
await requireAdminUser();
const uploadId = getUploadId(formData);
const rawTargetPath = String(formData.get('targetPath') || '').trim();
const targetRelativePath = sanitizeRelativePath(rawTargetPath);
if (!uploadId || !rawTargetPath || !targetRelativePath) {
redirectWithError(dashboardPath, 'Upload und Zielpfad sind erforderlich.');
}
const uploadEntry = await get(
'SELECT id, original_name, stored_name, stored_path FROM uploads WHERE id = ?',
[uploadId]
);
if (!uploadEntry) {
redirectWithError(dashboardPath, 'Upload nicht gefunden.');
}
const fileName = savedUploadFileName(uploadEntry.original_name, uploadEntry.stored_name);
const targetPath = await resolveSavedUploadTarget(targetRelativePath, fileName);
if (!targetPath) {
redirectWithError(dashboardPath, 'Ungültiger Zielpfad.');
}
try {
await fs.promises.cp(uploadEntry.stored_path, targetPath, { force: false, errorOnExist: true });
} catch {
redirectWithError(
dashboardPath,
'Upload konnte nicht gespeichert werden. Ziel existiert evtl. bereits.'
);
}
const relativeTarget = sanitizeRelativePath(path.relative(resolveAdminPath(''), targetPath));
await logEvent(
'admin_upload_save',
'admin',
{ upload_id: uploadEntry.id, target: relativeTarget },
await getRequestMeta()
);
redirectWithSuccess(dashboardPath, 'Upload im Dateimanager gespeichert.');
}
export async function adminMkdirAction(formData) {
@@ -565,19 +688,19 @@ export async function adminMkdirAction(formData) {
const folderName = String(formData.get('name') || '').trim();
if (!isValidNodeName(folderName)) {
redirectWithError(adminFilesHref(relativePath), 'Ungültiger Ordnername.');
redirectWithError(adminFilesTabHref(relativePath, 'folder'), 'Ungültiger Ordnername.');
}
const basePath = resolveAdminPath(relativePath);
if (!basePath) {
redirectWithError(adminFilesHref(relativePath), 'Ungültiger Pfad.');
redirectWithError(adminFilesTabHref(relativePath, 'folder'), 'Ungültiger Pfad.');
}
const targetPath = path.join(basePath, folderName);
try {
await fs.promises.mkdir(targetPath, { recursive: true });
} catch {
redirectWithError(adminFilesHref(relativePath), 'Ordner konnte nicht erstellt werden.');
redirectWithError(adminFilesTabHref(relativePath, 'folder'), 'Ordner konnte nicht erstellt werden.');
}
await logEvent(
@@ -587,7 +710,7 @@ export async function adminMkdirAction(formData) {
await getRequestMeta()
);
redirectWithSuccess(adminFilesHref(relativePath), 'Ordner erstellt.');
redirectWithSuccess(adminFilesTabHref(relativePath, 'content'), 'Ordner erstellt.');
}
export async function adminUploadToPathAction(formData) {
@@ -602,30 +725,30 @@ export async function adminUploadToPathAction(formData) {
const relativePath = sanitizeRelativePath(formData.get('path'));
const basePath = resolveAdminPath(relativePath);
if (!basePath) {
redirectWithError(adminFilesHref(relativePath), 'Ungültiger Pfad.');
redirectWithError(adminFilesTabHref(relativePath, 'upload'), 'Ungültiger Pfad.');
}
const uploadedFile = uploadedFileFromForm(formData, 'file');
if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) {
redirectWithError(adminFilesHref(relativePath), 'Keine Datei hochgeladen.');
redirectWithError(adminFilesTabHref(relativePath, 'upload'), 'Keine Datei hochgeladen.');
}
if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) {
redirectWithError(
adminFilesHref(relativePath),
adminFilesTabHref(relativePath, 'upload'),
`Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`
);
}
const fileName = safeBaseName(uploadedFile.name, 'upload');
if (!isValidNodeName(fileName)) {
redirectWithError(adminFilesHref(relativePath), 'Ungültiger Dateiname.');
redirectWithError(adminFilesTabHref(relativePath, 'upload'), 'Ungültiger Dateiname.');
}
try {
await writeUploadedFile(uploadedFile, path.join(basePath, fileName));
} catch {
redirectWithError(adminFilesHref(relativePath), 'Datei konnte nicht hochgeladen werden.');
redirectWithError(adminFilesTabHref(relativePath, 'upload'), 'Datei konnte nicht hochgeladen werden.');
}
await logEvent(
@@ -635,7 +758,7 @@ export async function adminUploadToPathAction(formData) {
await getRequestMeta()
);
redirectWithSuccess(adminFilesHref(relativePath), 'Datei hochgeladen.');
redirectWithSuccess(adminFilesTabHref(relativePath, 'content'), 'Datei hochgeladen.');
}
export async function adminRenamePathAction(formData) {
@@ -655,7 +778,7 @@ export async function adminRenamePathAction(formData) {
}
if (!isValidNodeName(newName)) {
redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Ungültiger Name.');
redirectWithError(adminFilesTabHref(parentRelativePath(relativePath), 'content'), 'Ungültiger Name.');
}
const sourcePath = resolveAdminPath(relativePath);
@@ -667,7 +790,10 @@ export async function adminRenamePathAction(formData) {
try {
await fs.promises.rename(sourcePath, targetPath);
} catch {
redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Pfad konnte nicht umbenannt werden.');
redirectWithError(
adminFilesTabHref(parentRelativePath(relativePath), 'content'),
'Pfad konnte nicht umbenannt werden.'
);
}
await logEvent(
@@ -680,7 +806,7 @@ export async function adminRenamePathAction(formData) {
await getRequestMeta()
);
redirectWithSuccess(adminFilesHref(parentRelativePath(relativePath)), 'Pfad umbenannt.');
redirectWithSuccess(adminFilesTabHref(parentRelativePath(relativePath), 'content'), 'Pfad umbenannt.');
}
export async function adminDeletePathAction(formData) {
@@ -705,11 +831,14 @@ export async function adminDeletePathAction(formData) {
try {
await fs.promises.rm(sourcePath, { recursive: true, force: true });
} catch {
redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Pfad konnte nicht gelöscht werden.');
redirectWithError(
adminFilesTabHref(parentRelativePath(relativePath), 'content'),
'Pfad konnte nicht gelöscht werden.'
);
}
await logEvent('admin_delete', 'admin', { path: relativePath }, await getRequestMeta());
redirectWithSuccess(adminFilesHref(parentRelativePath(relativePath)), 'Pfad gelöscht.');
redirectWithSuccess(adminFilesTabHref(parentRelativePath(relativePath), 'content'), 'Pfad gelöscht.');
}
export async function adminMovePathAction(formData) {
@@ -726,13 +855,13 @@ export async function adminMovePathAction(formData) {
const targetRelativePath = sanitizeRelativePath(formData.get('targetPath'));
if (!sourceRelativePath || !targetRelativePath) {
redirectWithError(adminFilesHref(currentPath), 'Ungültige Eingabe.');
redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Ungültige Eingabe.');
}
const sourcePath = resolveAdminPath(sourceRelativePath);
const targetBasePath = resolveAdminPath(targetRelativePath);
if (!sourcePath || !targetBasePath) {
redirectWithError(adminFilesHref(currentPath), 'Ungültiger Pfad.');
redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Ungültiger Pfad.');
}
const targetPath = await resolveMoveCopyTarget(sourcePath, targetBasePath);
@@ -745,10 +874,10 @@ export async function adminMovePathAction(formData) {
await copyPath(sourcePath, targetPath);
await fs.promises.rm(sourcePath, { recursive: true, force: true });
} catch {
redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht verschoben werden.');
redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Pfad konnte nicht verschoben werden.');
}
} else {
redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht verschoben werden.');
redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Pfad konnte nicht verschoben werden.');
}
}
@@ -759,7 +888,7 @@ export async function adminMovePathAction(formData) {
await getRequestMeta()
);
redirectWithSuccess(adminFilesHref(currentPath), 'Pfad verschoben.');
redirectWithSuccess(adminFilesTabHref(currentPath, 'content'), 'Pfad verschoben.');
}
export async function adminCopyPathAction(formData) {
@@ -776,13 +905,13 @@ export async function adminCopyPathAction(formData) {
const targetRelativePath = sanitizeRelativePath(formData.get('targetPath'));
if (!sourceRelativePath || !targetRelativePath) {
redirectWithError(adminFilesHref(currentPath), 'Ungültige Eingabe.');
redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Ungültige Eingabe.');
}
const sourcePath = resolveAdminPath(sourceRelativePath);
const targetBasePath = resolveAdminPath(targetRelativePath);
if (!sourcePath || !targetBasePath) {
redirectWithError(adminFilesHref(currentPath), 'Ungültiger Pfad.');
redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Ungültiger Pfad.');
}
const targetPath = await resolveMoveCopyTarget(sourcePath, targetBasePath);
@@ -790,7 +919,7 @@ export async function adminCopyPathAction(formData) {
try {
await copyPath(sourcePath, targetPath);
} catch {
redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht kopiert werden.');
redirectWithError(adminFilesTabHref(currentPath, 'content'), 'Pfad konnte nicht kopiert werden.');
}
await logEvent(
@@ -800,5 +929,5 @@ export async function adminCopyPathAction(formData) {
await getRequestMeta()
);
redirectWithSuccess(adminFilesHref(currentPath), 'Pfad kopiert.');
redirectWithSuccess(adminFilesTabHref(currentPath, 'content'), 'Pfad kopiert.');
}

View File

@@ -29,7 +29,7 @@ export const serviceFqdn = String(process.env.SERVICE_FQDN || '').trim();
export const adminHash = process.env.MANAGEMENT_ADMIN_HASH || '';
export const uploadTtlSeconds = parseInteger(process.env.UPLOAD_TTL_SECONDS || '604800', 604800);
export const maxRetentionSeconds = 90 * 24 * 60 * 60;
export const maxUploadBytes = parseInteger(process.env.UPLOAD_MAX_BYTES || '0', 0);
export const maxUploadBytes = parseInteger(process.env.UPLOAD_MAX_BYTES || '26843545600', 26843545600);
export const cookieSecure = process.env.COOKIE_SECURE === 'true';
export const publicBaseUrl = normalizeBaseUrl(process.env.PUBLIC_BASE_URL || serviceFqdn);
export const smtpHost = String(process.env.SMTP_HOST || '').trim();

View File

@@ -51,7 +51,8 @@ function initializeSchema(database) {
database.run(`CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
created_at INTEGER NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0
)`);
database.run(`CREATE TABLE IF NOT EXISTS upload_requests (
id TEXT PRIMARY KEY,
@@ -74,6 +75,7 @@ function initializeSchema(database) {
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);
database.run('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0', () => undefined);
database.run('ALTER TABLE upload_requests ADD COLUMN upload_id INTEGER', () => undefined);
database.run('ALTER TABLE upload_requests ADD COLUMN uploaded_original_name TEXT', () => undefined);
database.run('ALTER TABLE upload_requests ADD COLUMN uploaded_stored_name TEXT', () => undefined);

View File

@@ -1,5 +1,6 @@
import fs from 'node:fs';
import { Readable } from 'node:stream';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import Busboy from 'busboy';
@@ -108,3 +109,43 @@ export async function streamMultipartToPath(request, options) {
Readable.fromWeb(request.body).pipe(parser);
});
}
export async function streamBodyToPath(request, options) {
const { targetPath, maxBytes = 0 } = options;
if (!request.body) {
throw new Error('missing-body');
}
let sizeBytes = 0;
const counter = new Transform({
transform(chunk, encoding, callback) {
sizeBytes += chunk.length;
if (maxBytes > 0 && sizeBytes > maxBytes) {
callback(new Error('file-too-large'));
return;
}
callback(null, chunk);
},
});
try {
await pipeline(
Readable.fromWeb(request.body),
counter,
fs.createWriteStream(targetPath, { flags: 'wx' })
);
} catch (error) {
await fs.promises.rm(targetPath, { force: true }).catch(() => undefined);
throw error;
}
if (sizeBytes <= 0) {
await fs.promises.rm(targetPath, { force: true }).catch(() => undefined);
throw new Error('missing-file');
}
return { sizeBytes };
}

View File

@@ -5,6 +5,7 @@ import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { cookieSecure } from './config.js';
import { get } from './db.js';
const state = globalThis.__filesLehnertSecurityState || (globalThis.__filesLehnertSecurityState = {});
@@ -131,9 +132,20 @@ export async function getAuthenticatedUser() {
if (!payload || typeof payload !== 'object' || !payload.sub) {
return null;
}
const username = String(payload.sub);
const authType = String(payload.auth || (username === 'admin' && payload.admin ? 'admin' : 'user'));
if (authType === 'admin' && payload.admin) {
return { username, admin: true };
}
const row = await get('SELECT is_admin FROM users WHERE username = ?', [username]);
if (!row) {
return null;
}
return {
username: String(payload.sub),
admin: Boolean(payload.admin),
username,
admin: Boolean(row.is_admin),
};
} catch {
return null;

226
scripts/dev Executable file
View File

@@ -0,0 +1,226 @@
#!/usr/bin/env bash
set -euo pipefail
script_path=${BASH_SOURCE[0]}
if [[ "$script_path" == */* ]]; then
script_dir=${script_path%/*}
else
script_dir=.
fi
cd "$script_dir/.."
root_dir=$PWD
if [[ -n "${CONTAINER_ENGINE:-}" ]]; then
engine=$CONTAINER_ENGINE
elif command -v podman >/dev/null 2>&1; then
engine=podman
elif command -v docker >/dev/null 2>&1; then
engine=docker
else
printf 'Need podman or docker in PATH.\n' >&2
exit 1
fi
if ! command -v "$engine" >/dev/null 2>&1; then
printf 'Container engine not found: %s\n' "$engine" >&2
exit 1
fi
image=${FILES_DEV_IMAGE:-files-dev-nextjs:latest}
container=${FILES_DEV_CONTAINER:-files-dev-nextjs}
host_port=${FILES_DEV_PORT:-3000}
dev_dir=${FILES_DEV_DIR:-$root_dir/.dev}
app_data_dir=$dev_dir/app-data
storage_dir=$dev_dir/storage
password_hash='$2a$12$8d4a4zH/tb5TYiP4wQpuFO4GheZarYDWVDGl2S90R4xdMd83JOPMm'
volume_arg() {
if [[ "$engine" == podman ]]; then
printf '%s:%s:Z' "$1" "$2"
else
printf '%s:%s' "$1" "$2"
fi
}
mkdir -p "$app_data_dir" "$storage_dir/_share"
printf 'Using %s\n' "$engine"
printf 'Building %s\n' "$image"
"$engine" build -t "$image" "$root_dir/nextjs"
printf 'Seeding dev user john.doe / 123456\n'
"$engine" run --rm -i \
-e DB_PATH=/app/data/uploads.sqlite \
-e DEV_PASSWORD_HASH="$password_hash" \
-v "$(volume_arg "$app_data_dir" /app/data)" \
-v "$(volume_arg "$storage_dir" /data)" \
"$image" node - <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const sqlite3 = require('sqlite3');
const dbPath = process.env.DB_PATH || '/app/data/uploads.sqlite';
const passwordHash = process.env.DEV_PASSWORD_HASH;
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync('/data/_share', { recursive: true });
const db = new sqlite3.Database(dbPath);
function run(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function onRun(error) {
if (error) {
reject(error);
return;
}
resolve(this);
});
});
}
function close() {
return new Promise((resolve, reject) => {
db.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
(async () => {
await run(`CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0
)`);
await run('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0').catch(() => undefined);
await run(
`INSERT INTO users (username, password_hash, created_at, is_admin)
VALUES (?, ?, ?, 1)
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash, is_admin = 1`,
['john.doe', passwordHash, Date.now()]
);
await close();
})().catch(async (error) => {
try {
await close();
} catch {
}
console.error(error);
process.exit(1);
});
NODE
if "$engine" container inspect "$container" >/dev/null 2>&1; then
printf 'Removing old %s\n' "$container"
"$engine" rm -f "$container" >/dev/null
fi
logs_pid=
watchdog_pid=
stop_watchdog() {
if [[ -n "$watchdog_pid" ]]; then
kill "$watchdog_pid" >/dev/null 2>&1 || true
wait "$watchdog_pid" >/dev/null 2>&1 || true
watchdog_pid=
fi
}
cleanup() {
status=$?
trap - EXIT INT TERM HUP QUIT
stop_watchdog
if [[ -n "$logs_pid" ]]; then
kill "$logs_pid" >/dev/null 2>&1 || true
wait "$logs_pid" >/dev/null 2>&1 || true
fi
if "$engine" container inspect "$container" >/dev/null 2>&1; then
printf '\nKilling %s\n' "$container" >&2
"$engine" rm -f "$container" >/dev/null 2>&1 || true
fi
exit "$status"
}
start_watchdog() {
local parent_pid=$$
(
trap '' INT TERM HUP QUIT
while kill -0 "$parent_pid" >/dev/null 2>&1; do
sleep 0.2
done
"$engine" rm -f "$container" >/dev/null 2>&1 || true
) &
watchdog_pid=$!
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
trap 'exit 131' QUIT
printf '\nPreview: http://localhost:%s\n' "$host_port"
printf 'User login: john.doe / 123456\n'
printf 'Admin area: /manage/admin, password 123456\n'
printf 'Data dir: %s\n' "$dev_dir"
printf 'Stop: Ctrl-C\n\n'
"$engine" run --rm -d \
--name "$container" \
--stop-timeout 1 \
-p "127.0.0.1:$host_port:3000" \
-e DATA_DIR=/data \
-e DB_PATH=/app/data/uploads.sqlite \
-e SERVICE_FQDN=localhost \
-e PUBLIC_BASE_URL="http://localhost:$host_port" \
-e UPLOAD_TTL_SECONDS=604800 \
-e UPLOAD_MAX_BYTES=26843545600 \
-e MANAGEMENT_ADMIN_HASH="$password_hash" \
-e COOKIE_SECURE=false \
-e SMTP_HOST= \
-e SMTP_PORT=587 \
-e SMTP_USER= \
-e SMTP_PASS= \
-e SMTP_MAIL= \
-e SMTP_NAME= \
-e PORT=3000 \
-v "$(volume_arg "$app_data_dir" /app/data)" \
-v "$(volume_arg "$storage_dir" /data)" \
"$image" >/dev/null
start_watchdog
"$engine" logs -f "$container" &
logs_pid=$!
set +e
container_status=$("$engine" wait "$container")
wait_status=$?
set -e
kill "$logs_pid" >/dev/null 2>&1 || true
wait "$logs_pid" >/dev/null 2>&1 || true
logs_pid=
stop_watchdog
trap - EXIT INT TERM HUP QUIT
if [[ $wait_status -ne 0 ]]; then
exit "$wait_status"
fi
if [[ "$container_status" =~ ^[0-9]+$ ]]; then
exit "$container_status"
fi