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:
@@ -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
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
.env
|
||||
.dev/
|
||||
traefik/
|
||||
node_modules/
|
||||
nextjs/data/
|
||||
|
||||
18
README.md
18
README.md
@@ -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`
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
226
scripts/dev
Executable 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
|
||||
Reference in New Issue
Block a user