expressjs -> nextjs
This commit is contained in:
@@ -2,4 +2,6 @@ SERVICE_FQDN=files.example.com
|
|||||||
LETSENCRYPT_EMAIL=user@example.com
|
LETSENCRYPT_EMAIL=user@example.com
|
||||||
DATA_DIR=/storagebox
|
DATA_DIR=/storagebox
|
||||||
UPLOAD_TTL_SECONDS=604800
|
UPLOAD_TTL_SECONDS=604800
|
||||||
|
UPLOAD_MAX_BYTES=0
|
||||||
MANAGEMENT_ADMIN_HASH=
|
MANAGEMENT_ADMIN_HASH=
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
.env
|
.env
|
||||||
.logins
|
|
||||||
traefik/
|
traefik/
|
||||||
node_modules/
|
node_modules/
|
||||||
expressjs/data/
|
nextjs/data/
|
||||||
|
nextjs/.next/
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -1,3 +1,27 @@
|
|||||||
# lehnert.cloud/files
|
# lehnert.cloud/files
|
||||||
|
|
||||||
File server infrastructure hosted on [files.lehnert.cloud](https://files.lehnert.cloud).
|
File server infrastructure hosted on [files.lehnert.cloud](https://files.lehnert.cloud).
|
||||||
|
|
||||||
|
## Komponenten
|
||||||
|
|
||||||
|
- `webserver` (Apache) stellt das öffentliche Dateiverzeichnis bereit
|
||||||
|
- `nextjs` enthält die Next.js-App für Verwaltung und Authentifizierung
|
||||||
|
- `traefik` übernimmt TLS und Routing
|
||||||
|
|
||||||
|
## Management-UI
|
||||||
|
|
||||||
|
- Benutzer-Dashboard: `/manage/login`
|
||||||
|
- Admin-Dashboard: `/manage/admin`
|
||||||
|
- Datei-Downloads: `/_share/<datei>`
|
||||||
|
|
||||||
|
## Lokale Initialisierung
|
||||||
|
|
||||||
|
```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`)
|
||||||
|
2. Stack starten: `docker compose up --build`
|
||||||
|
3. Als Admin anmelden und Benutzer über die UI anlegen
|
||||||
|
|||||||
@@ -55,41 +55,37 @@ services:
|
|||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
expressjs:
|
nextjs:
|
||||||
build:
|
build:
|
||||||
context: ./expressjs
|
context: ./nextjs
|
||||||
|
|
||||||
container_name: expressjs
|
container_name: nextjs
|
||||||
stop_grace_period: 5s
|
stop_grace_period: 5s
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- BASE_PATH=/manage
|
|
||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
- DB_PATH=/app/data/uploads.sqlite
|
- DB_PATH=/app/data/uploads.sqlite
|
||||||
- LOGIN_FILE=/app/.logins
|
|
||||||
- UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS}
|
- UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS}
|
||||||
- MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH}
|
- MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH}
|
||||||
- TRUST_PROXY=true
|
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/app/data"
|
- "./data:/app/data"
|
||||||
- "./.logins:/app/.logins:ro"
|
|
||||||
- "${DATA_DIR}:/data"
|
- "${DATA_DIR}:/data"
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.express.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`))"
|
- "traefik.http.routers.nextjs.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`))"
|
||||||
- "traefik.http.routers.express.entrypoints=websecure"
|
- "traefik.http.routers.nextjs.entrypoints=websecure"
|
||||||
- "traefik.http.routers.express.tls=true"
|
- "traefik.http.routers.nextjs.tls=true"
|
||||||
- "traefik.http.routers.express.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.nextjs.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.express.service=express-svc"
|
- "traefik.http.routers.nextjs.service=nextjs-svc"
|
||||||
- "traefik.http.services.express-svc.loadbalancer.server.port=3000"
|
- "traefik.http.services.nextjs-svc.loadbalancer.server.port=3000"
|
||||||
- "traefik.http.routers.express.priority=10"
|
- "traefik.http.routers.nextjs.priority=10"
|
||||||
# Optional HTTP redirect
|
# Optional HTTP redirect
|
||||||
- "traefik.http.routers.express-http.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`))"
|
- "traefik.http.routers.nextjs-http.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`))"
|
||||||
- "traefik.http.routers.express-http.entrypoints=web"
|
- "traefik.http.routers.nextjs-http.entrypoints=web"
|
||||||
- "traefik.http.routers.express-http.middlewares=express-https-redirect"
|
- "traefik.http.routers.nextjs-http.middlewares=nextjs-https-redirect"
|
||||||
- "traefik.http.middlewares.express-https-redirect.redirectscheme.scheme=https"
|
- "traefik.http.middlewares.nextjs-https-redirect.redirectscheme.scheme=https"
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "files-lehnert-express",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"main": "src/server.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node src/server.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"bcryptjs": "^2.4.3",
|
|
||||||
"cookie-parser": "^1.4.6",
|
|
||||||
"dotenv": "^16.4.5",
|
|
||||||
"express": "^4.19.2",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"multer": "^1.4.5-lts.1",
|
|
||||||
"sqlite3": "^5.1.7"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,6 @@ mkdir -p ./data
|
|||||||
|
|
||||||
echo "Ensured ./traefik and ./data exist."
|
echo "Ensured ./traefik and ./data exist."
|
||||||
|
|
||||||
if [ ! -f .logins ]; then
|
|
||||||
cp .logins.example .logins
|
|
||||||
echo "Created .logins from .logins.example"
|
|
||||||
else
|
|
||||||
echo "Found existing .logins"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
echo "Created .env from .env.example"
|
echo "Created .env from .env.example"
|
||||||
@@ -28,6 +21,6 @@ fi
|
|||||||
|
|
||||||
echo "Initialization complete."
|
echo "Initialization complete."
|
||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
echo "1) Edit .env and set SERVICE_FQDN, LETSENCRYPT_EMAIL, DATA_DIR, UPLOAD_TTL_SECONDS"
|
echo "1) Edit .env and set SERVICE_FQDN, LETSENCRYPT_EMAIL, DATA_DIR, UPLOAD_TTL_SECONDS, optional UPLOAD_MAX_BYTES"
|
||||||
echo "2) Edit .logins to add users (bcrypt)"
|
echo "2) Set MANAGEMENT_ADMIN_HASH in .env for admin login"
|
||||||
echo "3) docker compose up --build"
|
echo "3) Start with docker compose up --build"
|
||||||
|
|||||||
5
nextjs/.dockerignore
Normal file
5
nextjs/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
npm-debug.log*
|
||||||
|
data
|
||||||
@@ -5,10 +5,14 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --omit=dev
|
RUN npm ci
|
||||||
|
|
||||||
COPY src ./src
|
COPY . ./
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
CMD ["node", "src/server.js"]
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
80
nextjs/app/%5Fshare/[filename]/route.js
Normal file
80
nextjs/app/%5Fshare/[filename]/route.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { shareDir } from '@/src/lib/config.js';
|
||||||
|
import { get, logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||||
|
import { getRequestMeta } from '@/src/lib/security.js';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function safeFilename(value) {
|
||||||
|
const fileName = String(value || '');
|
||||||
|
if (!fileName) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (fileName.includes('/') || fileName.includes('\\') || fileName.includes('..')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentDisposition(filename) {
|
||||||
|
const fallback = String(filename || 'download')
|
||||||
|
.replace(/[\r\n]/g, ' ')
|
||||||
|
.replace(/[\\"]/g, '_')
|
||||||
|
.replace(/[^ -~]/g, '_');
|
||||||
|
const encoded = encodeURIComponent(filename || 'download');
|
||||||
|
return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
await runCleanupIfNeeded();
|
||||||
|
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const fileName = safeFilename(resolvedParams.filename);
|
||||||
|
if (!fileName) {
|
||||||
|
return new NextResponse('Ungültiger Dateiname', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await get('SELECT id, original_name, stored_path FROM uploads WHERE stored_name = ?', [fileName]);
|
||||||
|
|
||||||
|
let filePath;
|
||||||
|
let downloadName;
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
filePath = row.stored_path;
|
||||||
|
downloadName = row.original_name || fileName;
|
||||||
|
|
||||||
|
const requestMeta = await getRequestMeta();
|
||||||
|
run('UPDATE uploads SET downloads = downloads + 1 WHERE id = ?', [row.id]).catch(() => undefined);
|
||||||
|
logEvent('download', null, { name: fileName, original: downloadName }, requestMeta).catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
filePath = path.join(shareDir, fileName);
|
||||||
|
downloadName = fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileStat;
|
||||||
|
try {
|
||||||
|
fileStat = await fs.promises.stat(filePath);
|
||||||
|
} catch {
|
||||||
|
return new NextResponse('Datei nicht gefunden', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStream = fs.createReadStream(filePath);
|
||||||
|
const webStream = Readable.toWeb(fileStream);
|
||||||
|
|
||||||
|
return new NextResponse(webStream, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': String(fileStat.size),
|
||||||
|
'Content-Disposition': contentDisposition(downloadName),
|
||||||
|
'Cache-Control': 'private, no-store',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
353
nextjs/app/globals.css
Normal file
353
nextjs/app/globals.css
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
:root {
|
||||||
|
--font-body: 'Manrope', sans-serif;
|
||||||
|
--font-heading: 'Space Grotesk', sans-serif;
|
||||||
|
--bg-main: #eef4f7;
|
||||||
|
--bg-accent: #d9ece4;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #f7fafc;
|
||||||
|
--text-main: #10243a;
|
||||||
|
--text-muted: #566b81;
|
||||||
|
--line: #d6e0ea;
|
||||||
|
--primary: #0f766e;
|
||||||
|
--primary-hover: #0e635c;
|
||||||
|
--primary-soft: #d8f3ea;
|
||||||
|
--danger: #b42318;
|
||||||
|
--danger-soft: #fee4e2;
|
||||||
|
--radius: 16px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
--shadow: 0 16px 36px -24px rgb(16 36 58 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
width: min(1200px, 100% - 2.5rem);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.2rem 0 3.5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
animation: page-enter 240ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell.narrow {
|
||||||
|
width: min(560px, 100% - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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.1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.centered {
|
||||||
|
text-align: center;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background: #ddf5ea;
|
||||||
|
border-color: #b7e8d3;
|
||||||
|
color: #10513f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
border-color: #f8b4af;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fff;
|
||||||
|
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.small {
|
||||||
|
padding: 0.38rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
background: #fff;
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary:hover {
|
||||||
|
background: #f4f8fb;
|
||||||
|
border-color: #c3d4e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: #f7b0a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger:hover {
|
||||||
|
background: #fccfc9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text-main);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.3rem 0.58rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.primary {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
border-color: #9ddac6;
|
||||||
|
color: #0c4f4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fff;
|
||||||
|
padding: 0.7rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 680px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0.62rem 0.66rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
border-top: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form.stacked {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs a {
|
||||||
|
color: #0f5f84;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes page-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(7px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.page-shell {
|
||||||
|
width: min(100% - 1.4rem, 1200px);
|
||||||
|
padding-top: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
min-width: 620px;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
nextjs/app/layout.js
Normal file
28
nextjs/app/layout.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Manrope, Space_Grotesk } from 'next/font/google';
|
||||||
|
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
const bodyFont = Manrope({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-body',
|
||||||
|
weight: ['400', '500', '600', '700'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const headingFont = Space_Grotesk({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-heading',
|
||||||
|
weight: ['500', '700'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Dateiverwaltung',
|
||||||
|
description: 'Dateiuploads und Admin-Verwaltung mit Next.js',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<html lang="de" className={`${bodyFont.variable} ${headingFont.variable}`}>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
nextjs/app/manage/_components/copy-link-button.js
Normal file
47
nextjs/app/manage/_components/copy-link-button.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
function wait(milliseconds) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, milliseconds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyLinkButton({ path, label }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function onCopy() {
|
||||||
|
const url = `${window.location.origin}${path}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const html = `<a href="${url}">${label || 'Download'}</a>`;
|
||||||
|
const clipboardItem = new ClipboardItem({
|
||||||
|
'text/html': new Blob([html], { type: 'text/html' }),
|
||||||
|
'text/plain': new Blob([url], { type: 'text/plain' }),
|
||||||
|
});
|
||||||
|
await navigator.clipboard.write([clipboardItem]);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
} catch {
|
||||||
|
const helper = document.createElement('textarea');
|
||||||
|
helper.value = url;
|
||||||
|
document.body.appendChild(helper);
|
||||||
|
helper.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(helper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCopied(true);
|
||||||
|
await wait(1800);
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="btn secondary" onClick={onCopy}>
|
||||||
|
{copied ? 'Kopiert' : 'Link kopieren'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
nextjs/app/manage/_components/status-message.js
Normal file
11
nextjs/app/manage/_components/status-message.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function StatusMessage({ error, success }) {
|
||||||
|
if (!error && !success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="status error">{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="status success">{success}</div>;
|
||||||
|
}
|
||||||
255
nextjs/app/manage/admin/dashboard/page.js
Normal file
255
nextjs/app/manage/admin/dashboard/page.js
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import {
|
||||||
|
adminDeleteUploadAction,
|
||||||
|
adminExtendUploadAction,
|
||||||
|
adminLogoutAction,
|
||||||
|
} from '@/src/lib/actions.js';
|
||||||
|
import { adminHash } from '@/src/lib/config.js';
|
||||||
|
import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||||
|
import {
|
||||||
|
formatBytes,
|
||||||
|
formatCountdown,
|
||||||
|
formatTimestamp,
|
||||||
|
parseLogDetail,
|
||||||
|
readSearchParam,
|
||||||
|
} from '@/src/lib/format.js';
|
||||||
|
import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js';
|
||||||
|
|
||||||
|
import { CopyLinkButton } from '../../_components/copy-link-button.js';
|
||||||
|
import { StatusMessage } from '../../_components/status-message.js';
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
const [
|
||||||
|
activeCount,
|
||||||
|
activeBytes,
|
||||||
|
distinctOwners,
|
||||||
|
totalUploads,
|
||||||
|
totalDownloads,
|
||||||
|
totalDeletes,
|
||||||
|
lastCleanup,
|
||||||
|
recentLogs,
|
||||||
|
allUploads,
|
||||||
|
] = await Promise.all([
|
||||||
|
get('SELECT COUNT(*) AS count FROM uploads'),
|
||||||
|
get('SELECT COALESCE(SUM(size_bytes), 0) AS total FROM uploads'),
|
||||||
|
get('SELECT COUNT(DISTINCT owner) AS count FROM uploads'),
|
||||||
|
get('SELECT COUNT(*) AS count FROM admin_logs WHERE event = ?', ['upload']),
|
||||||
|
get('SELECT COALESCE(SUM(downloads), 0) AS count FROM uploads'),
|
||||||
|
get('SELECT COUNT(*) AS count FROM admin_logs WHERE event IN (?, ?, ?)', [
|
||||||
|
'delete',
|
||||||
|
'cleanup',
|
||||||
|
'admin_delete',
|
||||||
|
]),
|
||||||
|
get('SELECT MAX(created_at) AS ts FROM admin_logs WHERE event = ?', ['cleanup']),
|
||||||
|
all(
|
||||||
|
'SELECT event, owner, detail, created_at, ip, user_agent FROM admin_logs ORDER BY created_at DESC LIMIT 250'
|
||||||
|
),
|
||||||
|
all(
|
||||||
|
'SELECT id, owner, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads ORDER BY uploaded_at DESC LIMIT 500'
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||||
|
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell">
|
||||||
|
<header className="page-header">
|
||||||
|
<div className="header-main">
|
||||||
|
<h1>Adminübersicht</h1>
|
||||||
|
<p className="muted">Metriken, Ereignisse und direkte Eingriffe in Uploads.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<a className="chip primary" href="/manage/admin/files">
|
||||||
|
Dateimanager
|
||||||
|
</a>
|
||||||
|
<a className="chip primary" href="/manage/admin/users">
|
||||||
|
Benutzer verwalten
|
||||||
|
</a>
|
||||||
|
<form className="inline-form" action={adminLogoutAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
<button className="btn secondary" type="submit">
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<StatusMessage error={error} success={success} />
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Statistiken</h2>
|
||||||
|
<div className="metric-grid">
|
||||||
|
<article className="metric">
|
||||||
|
<span className="muted">Aktive Uploads</span>
|
||||||
|
<strong>{activeCount?.count || 0}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="metric">
|
||||||
|
<span className="muted">Aktive Dateigröße</span>
|
||||||
|
<strong>{formatBytes(activeBytes?.total || 0)}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="metric">
|
||||||
|
<span className="muted">Aktive Nutzer</span>
|
||||||
|
<strong>{distinctOwners?.count || 0}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="metric">
|
||||||
|
<span className="muted">Uploads gesamt</span>
|
||||||
|
<strong>{totalUploads?.count || 0}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="metric">
|
||||||
|
<span className="muted">Downloads gesamt</span>
|
||||||
|
<strong>{totalDownloads?.count || 0}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="metric">
|
||||||
|
<span className="muted">Löschungen gesamt</span>
|
||||||
|
<strong>{totalDeletes?.count || 0}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="metric">
|
||||||
|
<span className="muted">Letztes Cleanup</span>
|
||||||
|
<strong>{lastCleanup?.ts ? formatTimestamp(lastCleanup.ts) : '-'}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Aktuelle Uploads</h2>
|
||||||
|
{allUploads.length === 0 ? (
|
||||||
|
<p className="muted">Noch keine Uploads vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nutzer</th>
|
||||||
|
<th>Datei</th>
|
||||||
|
<th>Größe</th>
|
||||||
|
<th>Hochgeladen</th>
|
||||||
|
<th>Ablauf</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allUploads.map((item) => {
|
||||||
|
const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`;
|
||||||
|
return (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>{item.owner}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{item.original_name}</strong>
|
||||||
|
<div className="muted mono">{item.stored_name}</div>
|
||||||
|
</td>
|
||||||
|
<td>{formatBytes(item.size_bytes)}</td>
|
||||||
|
<td>{formatTimestamp(item.uploaded_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div>{formatTimestamp(item.expires_at)}</div>
|
||||||
|
<div className="muted">Noch {formatCountdown(item.expires_at)}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="stack-actions">
|
||||||
|
<div className="row-actions">
|
||||||
|
<a className="btn secondary" href={sharePath}>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<CopyLinkButton path={sharePath} label={item.original_name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="inline-form" action={adminExtendUploadAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
<input type="hidden" name="uploadId" value={item.id} />
|
||||||
|
<input className="input small" name="extendHours" placeholder="Stunden" />
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Verlängern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form className="inline-form" action={adminDeleteUploadAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
<input type="hidden" name="uploadId" value={item.id} />
|
||||||
|
<button className="btn danger" type="submit">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Letzte Ereignisse</h2>
|
||||||
|
{recentLogs.length === 0 ? (
|
||||||
|
<p className="muted">Keine Logs vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeit</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Nutzer</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>User-Agent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentLogs.map((entry, index) => {
|
||||||
|
const details = parseLogDetail(entry.detail);
|
||||||
|
return (
|
||||||
|
<tr key={`${entry.created_at}-${entry.event}-${index}`}>
|
||||||
|
<td>{formatTimestamp(entry.created_at)}</td>
|
||||||
|
<td>{entry.event}</td>
|
||||||
|
<td>{entry.owner || '-'}</td>
|
||||||
|
<td className="mono">{entry.ip || '-'}</td>
|
||||||
|
<td>
|
||||||
|
{details.length === 0 ? (
|
||||||
|
<span className="muted">-</span>
|
||||||
|
) : (
|
||||||
|
<div className="stack-actions">
|
||||||
|
{details.map((detail) => (
|
||||||
|
<div key={`${detail.key}-${detail.value}`}>
|
||||||
|
<strong>{detail.key}:</strong> <span>{detail.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="mono">{entry.user_agent || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
nextjs/app/manage/admin/files/page.js
Normal file
284
nextjs/app/manage/admin/files/page.js
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
adminCopyPathAction,
|
||||||
|
adminDeletePathAction,
|
||||||
|
adminLogoutAction,
|
||||||
|
adminMkdirAction,
|
||||||
|
adminMovePathAction,
|
||||||
|
adminRenamePathAction,
|
||||||
|
adminUploadToPathAction,
|
||||||
|
} from '@/src/lib/actions.js';
|
||||||
|
import { runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||||
|
import { adminFilesHref, resolveAdminPath, sanitizeRelativePath } from '@/src/lib/files.js';
|
||||||
|
import { formatBytes, formatTimestamp, readSearchParam } from '@/src/lib/format.js';
|
||||||
|
import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js';
|
||||||
|
|
||||||
|
import { StatusMessage } from '../../_components/status-message.js';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function buildBreadcrumbs(relativePath) {
|
||||||
|
const segments = relativePath.split('/').filter(Boolean);
|
||||||
|
const breadcrumbs = [];
|
||||||
|
let currentPath = '';
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||||
|
breadcrumbs.push({
|
||||||
|
label: segment,
|
||||||
|
href: adminFilesHref(currentPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return breadcrumbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminFilesPage({ searchParams }) {
|
||||||
|
await runCleanupIfNeeded();
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const csrfToken = await ensureCsrfToken();
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const relativePath = sanitizeRelativePath(readSearchParam(resolvedSearchParams, 'path'));
|
||||||
|
|
||||||
|
const queryError = readSearchParam(resolvedSearchParams, 'error');
|
||||||
|
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||||
|
|
||||||
|
const absolutePath = resolveAdminPath(relativePath);
|
||||||
|
if (!absolutePath) {
|
||||||
|
return (
|
||||||
|
<main className="page-shell">
|
||||||
|
<header className="page-header">
|
||||||
|
<div className="header-main">
|
||||||
|
<h1>Admin-Dateimanager</h1>
|
||||||
|
<p className="muted">Dateien im DATA_DIR verwalten (ausgenommen _share).</p>
|
||||||
|
</div>
|
||||||
|
<a className="chip" href="/manage/admin/dashboard">
|
||||||
|
Zur Adminübersicht
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<StatusMessage error={queryError || 'Ungültiger Pfad.'} success={success} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.promises.readdir(absolutePath, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return (
|
||||||
|
<main className="page-shell">
|
||||||
|
<header className="page-header">
|
||||||
|
<div className="header-main">
|
||||||
|
<h1>Admin-Dateimanager</h1>
|
||||||
|
<p className="muted">Dateien im DATA_DIR verwalten (ausgenommen _share).</p>
|
||||||
|
</div>
|
||||||
|
<a className="chip" href="/manage/admin/dashboard">
|
||||||
|
Zur Adminübersicht
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<StatusMessage error="Ordner konnte nicht gelesen werden." success={success} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleEntries = entries.filter((entry) => entry.name !== '_share');
|
||||||
|
const details = await Promise.all(
|
||||||
|
visibleEntries.map(async (entry) => {
|
||||||
|
const childPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||||
|
const absoluteChildPath = path.join(absolutePath, entry.name);
|
||||||
|
|
||||||
|
let stat = null;
|
||||||
|
try {
|
||||||
|
stat = await fs.promises.stat(absoluteChildPath);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
childPath,
|
||||||
|
isDir: entry.isDirectory(),
|
||||||
|
size: stat && stat.isFile() ? stat.size : null,
|
||||||
|
modifiedAt: stat ? stat.mtimeMs : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
details.sort((left, right) => {
|
||||||
|
if (left.isDir !== right.isDir) {
|
||||||
|
return left.isDir ? -1 : 1;
|
||||||
|
}
|
||||||
|
return left.name.localeCompare(right.name, 'de');
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentPathRaw = relativePath ? path.dirname(relativePath) : '';
|
||||||
|
const parentPath = parentPathRaw === '.' ? '' : sanitizeRelativePath(parentPathRaw);
|
||||||
|
const breadcrumbs = buildBreadcrumbs(relativePath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell">
|
||||||
|
<header className="page-header">
|
||||||
|
<div className="header-main">
|
||||||
|
<h1>Admin-Dateimanager</h1>
|
||||||
|
<p className="muted">Dateien im DATA_DIR verwalten (ausgenommen _share).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<a className="chip" href="/manage/admin/dashboard">
|
||||||
|
Zur Adminübersicht
|
||||||
|
</a>
|
||||||
|
<form className="inline-form" action={adminLogoutAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
<button className="btn secondary" type="submit">
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<StatusMessage error={queryError} success={success} />
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="toolbar">
|
||||||
|
<span className="chip">Pfad: /{relativePath}</span>
|
||||||
|
{relativePath ? (
|
||||||
|
<a className="chip primary" href={adminFilesHref(parentPath)}>
|
||||||
|
Eine Ebene zurück
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="breadcrumbs">
|
||||||
|
<span className="muted">Position:</span>
|
||||||
|
<a href={adminFilesHref('')}>root</a>
|
||||||
|
{breadcrumbs.map((item) => (
|
||||||
|
<span key={item.href}>
|
||||||
|
{' / '}
|
||||||
|
<a href={item.href}>{item.label}</a>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel" style={{ display: 'grid', gap: '0.8rem', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
|
||||||
|
<div>
|
||||||
|
<h2>Ordner erstellen</h2>
|
||||||
|
<form className="form-grid" action={adminMkdirAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
<input type="hidden" name="path" value={relativePath} />
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
Ordnername
|
||||||
|
<input className="input" name="name" placeholder="z.B. Projekte" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Erstellen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Datei hochladen</h2>
|
||||||
|
<form className="form-grid" action={adminUploadToPathAction} encType="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
<input type="hidden" name="path" value={relativePath} />
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
Datei
|
||||||
|
<input className="input" name="file" type="file" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Hochladen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Inhalt</h2>
|
||||||
|
{details.length === 0 ? (
|
||||||
|
<p className="muted">Keine Einträge in diesem Ordner.</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Größe</th>
|
||||||
|
<th>Geändert</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{details.map((item) => (
|
||||||
|
<tr key={item.childPath}>
|
||||||
|
<td>
|
||||||
|
{item.isDir ? (
|
||||||
|
<a href={adminFilesHref(item.childPath)}>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span>{item.name}</span>
|
||||||
|
)}
|
||||||
|
<div className="muted mono">{item.childPath}</div>
|
||||||
|
</td>
|
||||||
|
<td>{item.isDir ? 'Ordner' : 'Datei'}</td>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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" 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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
nextjs/app/manage/admin/page.js
Normal file
66
nextjs/app/manage/admin/page.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { adminLoginAction } from '@/src/lib/actions.js';
|
||||||
|
import { adminHash } from '@/src/lib/config.js';
|
||||||
|
import { runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||||
|
import { readSearchParam } from '@/src/lib/format.js';
|
||||||
|
import { ensureCsrfToken, getAuthenticatedUser } from '@/src/lib/security.js';
|
||||||
|
|
||||||
|
import { StatusMessage } from '../_components/status-message.js';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function AdminLoginPage({ 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 zur Anmeldung
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (user?.admin) {
|
||||||
|
redirect('/manage/admin/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfToken = await ensureCsrfToken();
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||||
|
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell narrow">
|
||||||
|
<header className="page-header">
|
||||||
|
<div className="header-main">
|
||||||
|
<h1>Adminbereich</h1>
|
||||||
|
<p className="muted">Melde dich als Administrator an.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<StatusMessage error={error} success={success} />
|
||||||
|
<form className="form-grid" action={adminLoginAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
Admin-Passwort
|
||||||
|
<input className="input" name="password" type="password" autoComplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
nextjs/app/manage/admin/users/page.js
Normal file
124
nextjs/app/manage/admin/users/page.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
adminCreateUserAction,
|
||||||
|
adminDeleteUserAction,
|
||||||
|
adminLogoutAction,
|
||||||
|
adminResetUserAction,
|
||||||
|
} from '@/src/lib/actions.js';
|
||||||
|
import { all, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||||
|
import { formatTimestamp, readSearchParam } from '@/src/lib/format.js';
|
||||||
|
import { ensureCsrfToken, requireAdminUser } from '@/src/lib/security.js';
|
||||||
|
|
||||||
|
import { StatusMessage } from '../../_components/status-message.js';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function AdminUsersPage({ searchParams }) {
|
||||||
|
await runCleanupIfNeeded();
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const csrfToken = await ensureCsrfToken();
|
||||||
|
const users = await all('SELECT username, created_at FROM users ORDER BY username ASC');
|
||||||
|
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||||
|
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell">
|
||||||
|
<header className="page-header">
|
||||||
|
<div className="header-main">
|
||||||
|
<h1>Benutzerverwaltung</h1>
|
||||||
|
<p className="muted">Konten erstellen, Passwort setzen oder Benutzer entfernen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<a className="chip" href="/manage/admin/dashboard">
|
||||||
|
Zur Adminübersicht
|
||||||
|
</a>
|
||||||
|
<form className="inline-form" action={adminLogoutAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
<button className="btn secondary" type="submit">
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<StatusMessage error={error} success={success} />
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Neuen Benutzer anlegen</h2>
|
||||||
|
<form className="form-grid" action={adminCreateUserAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
Benutzername
|
||||||
|
<input className="input" name="username" autoComplete="username" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
Passwort
|
||||||
|
<input className="input" name="password" type="password" autoComplete="new-password" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Benutzer erstellen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Bestehende Benutzer</h2>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<p className="muted">Noch keine Benutzer vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Benutzername</th>
|
||||||
|
<th>Erstellt</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.username}>
|
||||||
|
<td>{user.username}</td>
|
||||||
|
<td>{formatTimestamp(user.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="stack-actions">
|
||||||
|
<form className="inline-form" action={adminResetUserAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
<input type="hidden" name="username" value={user.username} />
|
||||||
|
<input
|
||||||
|
className="input small"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Neues Passwort"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Passwort setzen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
Benutzer löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
nextjs/app/manage/dashboard/page.js
Normal file
149
nextjs/app/manage/dashboard/page.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
deleteOwnUploadAction,
|
||||||
|
extendOwnUploadAction,
|
||||||
|
uploadFileAction,
|
||||||
|
userLogoutAction,
|
||||||
|
} from '@/src/lib/actions.js';
|
||||||
|
import { all, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||||
|
import { formatBytes, formatCountdown, formatTimestamp, readSearchParam } from '@/src/lib/format.js';
|
||||||
|
import { ensureCsrfToken, requireAuthenticatedUser } from '@/src/lib/security.js';
|
||||||
|
|
||||||
|
import { CopyLinkButton } from '../_components/copy-link-button.js';
|
||||||
|
import { StatusMessage } from '../_components/status-message.js';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function DashboardPage({ searchParams }) {
|
||||||
|
await runCleanupIfNeeded();
|
||||||
|
|
||||||
|
const user = await requireAuthenticatedUser();
|
||||||
|
const csrfToken = await ensureCsrfToken();
|
||||||
|
const uploads = await all(
|
||||||
|
'SELECT id, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads WHERE owner = ? ORDER BY uploaded_at DESC',
|
||||||
|
[user.username]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||||
|
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell">
|
||||||
|
<header className="page-header">
|
||||||
|
<div className="header-main">
|
||||||
|
<h1>Dateiverwaltung</h1>
|
||||||
|
<p className="muted">Angemeldet als {user.username}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
{user.admin ? (
|
||||||
|
<a className="chip primary" href="/manage/admin/dashboard">
|
||||||
|
Adminbereich
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="inline-form" action={userLogoutAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
<button className="btn secondary" type="submit">
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Neue Datei hochladen</h2>
|
||||||
|
<StatusMessage error={error} success={success} />
|
||||||
|
|
||||||
|
<form className="form-grid" action={uploadFileAction} encType="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
Datei
|
||||||
|
<input className="input" name="file" type="file" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
Aufbewahrung in Stunden
|
||||||
|
<input className="input" name="retentionHours" placeholder="168" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Hochladen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Aktuelle Uploads</h2>
|
||||||
|
{uploads.length === 0 ? (
|
||||||
|
<p className="muted">Noch keine Uploads.</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Datei</th>
|
||||||
|
<th>Größe</th>
|
||||||
|
<th>Hochgeladen</th>
|
||||||
|
<th>Ablauf</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{uploads.map((item) => {
|
||||||
|
const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`;
|
||||||
|
return (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<strong>{item.original_name}</strong>
|
||||||
|
<div className="muted mono">{item.stored_name}</div>
|
||||||
|
</td>
|
||||||
|
<td>{formatBytes(item.size_bytes)}</td>
|
||||||
|
<td>{formatTimestamp(item.uploaded_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div>{formatTimestamp(item.expires_at)}</div>
|
||||||
|
<div className="muted">Noch {formatCountdown(item.expires_at)}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="stack-actions">
|
||||||
|
<div className="row-actions">
|
||||||
|
<a className="btn secondary" href={sharePath}>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<CopyLinkButton path={sharePath} label={item.original_name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="inline-form" action={extendOwnUploadAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
<input type="hidden" name="uploadId" value={item.id} />
|
||||||
|
<input
|
||||||
|
className="input small"
|
||||||
|
name="extendHours"
|
||||||
|
placeholder="Stunden"
|
||||||
|
/>
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Verlängern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form className="inline-form" action={deleteOwnUploadAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
<input type="hidden" name="uploadId" value={item.id} />
|
||||||
|
<button className="btn danger" type="submit">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
nextjs/app/manage/login/page.js
Normal file
65
nextjs/app/manage/login/page.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { userLoginAction } from '@/src/lib/actions.js';
|
||||||
|
import { runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||||
|
import { readSearchParam } from '@/src/lib/format.js';
|
||||||
|
import { ensureCsrfToken, getAuthenticatedUser } from '@/src/lib/security.js';
|
||||||
|
|
||||||
|
import { StatusMessage } from '../_components/status-message.js';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function LoginPage({ searchParams }) {
|
||||||
|
await runCleanupIfNeeded();
|
||||||
|
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (user) {
|
||||||
|
redirect('/manage/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfToken = await ensureCsrfToken();
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||||
|
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell narrow">
|
||||||
|
<header className="page-header">
|
||||||
|
<div className="header-main">
|
||||||
|
<h1>Dateiverwaltung</h1>
|
||||||
|
<p className="muted">Melde dich an, um Uploads zu verwalten.</p>
|
||||||
|
</div>
|
||||||
|
<a className="chip" href="/manage/admin">
|
||||||
|
Admin-Anmeldung
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<StatusMessage error={error} success={success} />
|
||||||
|
<form className="form-grid" action={userLoginAction}>
|
||||||
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
Benutzername
|
||||||
|
<input className="input" name="username" autoComplete="username" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
Passwort
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
nextjs/app/manage/page.js
Normal file
21
nextjs/app/manage/page.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||||
|
import { getAuthenticatedUser } from '@/src/lib/security.js';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function ManageIndexPage() {
|
||||||
|
await runCleanupIfNeeded();
|
||||||
|
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user) {
|
||||||
|
redirect('/manage/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.admin) {
|
||||||
|
redirect('/manage/admin/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect('/manage/dashboard');
|
||||||
|
}
|
||||||
13
nextjs/app/not-found.js
Normal file
13
nextjs/app/not-found.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<main className="page-shell narrow">
|
||||||
|
<section className="panel centered">
|
||||||
|
<h1>Seite nicht gefunden</h1>
|
||||||
|
<p className="muted">Die angeforderte Seite existiert nicht oder wurde verschoben.</p>
|
||||||
|
<a className="btn" href="/manage/login">
|
||||||
|
Zurück zur Anmeldung
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
nextjs/app/page.js
Normal file
5
nextjs/app/page.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
redirect('/manage');
|
||||||
|
}
|
||||||
10
nextjs/jsconfig.json
Normal file
10
nextjs/jsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
nextjs/next.config.mjs
Normal file
25
nextjs/next.config.mjs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const uploadMaxBytes = Number.parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10);
|
||||||
|
const actionBodySizeLimit = Number.isFinite(uploadMaxBytes) && uploadMaxBytes > 0
|
||||||
|
? `${uploadMaxBytes}`
|
||||||
|
: '1gb';
|
||||||
|
|
||||||
|
const nextConfig = {
|
||||||
|
poweredByHeader: false,
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: actionBodySizeLimit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serverExternalPackages: ['sqlite3'],
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:path*',
|
||||||
|
headers: [{ key: 'X-Content-Type-Options', value: 'nosniff' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
1782
expressjs/package-lock.json → nextjs/package-lock.json
generated
1782
expressjs/package-lock.json → nextjs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
nextjs/package.json
Normal file
18
nextjs/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "files-lehnert-next",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"next": "^16.2.1",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"sqlite3": "^5.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
nextjs/proxy.js
Normal file
40
nextjs/proxy.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const csrfCookieName = 'csrf';
|
||||||
|
const cookieSecure = process.env.COOKIE_SECURE === 'true';
|
||||||
|
|
||||||
|
function createToken() {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxy(request) {
|
||||||
|
const token = request.cookies.get(csrfCookieName)?.value;
|
||||||
|
if (token) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextToken = createToken();
|
||||||
|
const requestHeaders = new Headers(request.headers);
|
||||||
|
requestHeaders.set('x-csrf-token', nextToken);
|
||||||
|
|
||||||
|
const response = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set(csrfCookieName, nextToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: cookieSecure,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/manage/:path*'],
|
||||||
|
};
|
||||||
758
nextjs/src/lib/actions.js
Normal file
758
nextjs/src/lib/actions.js
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import {
|
||||||
|
adminHash,
|
||||||
|
managementBasePath,
|
||||||
|
maxRetentionSeconds,
|
||||||
|
maxUploadBytes,
|
||||||
|
shareDir,
|
||||||
|
uploadTtlSeconds,
|
||||||
|
} from './config.js';
|
||||||
|
import { get, logEvent, run, runCleanupIfNeeded } from './db.js';
|
||||||
|
import {
|
||||||
|
adminFilesHref,
|
||||||
|
isValidNodeName,
|
||||||
|
resolveAdminPath,
|
||||||
|
safeBaseName,
|
||||||
|
sanitizeExtension,
|
||||||
|
sanitizeRelativePath,
|
||||||
|
} from './files.js';
|
||||||
|
import {
|
||||||
|
checkLoginRateLimit,
|
||||||
|
clearAuthCookie,
|
||||||
|
clearLoginRateLimit,
|
||||||
|
getRequestMeta,
|
||||||
|
requireAdminUser,
|
||||||
|
requireAuthenticatedUser,
|
||||||
|
setAuthCookie,
|
||||||
|
verifyCsrf,
|
||||||
|
} from './security.js';
|
||||||
|
|
||||||
|
function buildPathWithQuery(pathname, params = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value) {
|
||||||
|
query.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = query.toString();
|
||||||
|
return serialized ? `${pathname}?${serialized}` : pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectWithError(pathname, message) {
|
||||||
|
redirect(buildPathWithQuery(pathname, { error: message }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectWithSuccess(pathname, message) {
|
||||||
|
redirect(buildPathWithQuery(pathname, { success: message }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadId(formData, fieldName = 'uploadId') {
|
||||||
|
const parsed = Number.parseInt(String(formData.get(fieldName) || ''), 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHours(value, fallbackSeconds) {
|
||||||
|
const parsed = Number.parseFloat(String(value || ''));
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return Math.round(parsed * 3600);
|
||||||
|
}
|
||||||
|
return fallbackSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase32(buffer) {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
let bits = 0;
|
||||||
|
let value = 0;
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
for (const byte of buffer) {
|
||||||
|
value = (value << 8) | byte;
|
||||||
|
bits += 8;
|
||||||
|
|
||||||
|
while (bits >= 5) {
|
||||||
|
output += alphabet[(value >>> (bits - 5)) & 31];
|
||||||
|
bits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bits > 0) {
|
||||||
|
output += alphabet[(value << (5 - bits)) & 31];
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRandomId() {
|
||||||
|
return toBase32(crypto.randomBytes(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedFileFromForm(formData, fieldName = 'file') {
|
||||||
|
const candidate = formData.get(fieldName);
|
||||||
|
if (!candidate || typeof candidate === 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof candidate.arrayBuffer !== 'function') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeUploadedFile(uploadedFile, targetPath) {
|
||||||
|
try {
|
||||||
|
if (typeof uploadedFile.stream === 'function') {
|
||||||
|
await pipeline(Readable.fromWeb(uploadedFile.stream()), fs.createWriteStream(targetPath));
|
||||||
|
} else {
|
||||||
|
const buffer = Buffer.from(await uploadedFile.arrayBuffer());
|
||||||
|
await fs.promises.writeFile(targetPath, buffer);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await fs.promises.rm(targetPath, { force: true }).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredSize = Number(uploadedFile.size || 0);
|
||||||
|
if (Number.isFinite(declaredSize) && declaredSize >= 0) {
|
||||||
|
return declaredSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await fs.promises.stat(targetPath);
|
||||||
|
return stat.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentRelativePath(relativePath) {
|
||||||
|
const parent = path.dirname(relativePath);
|
||||||
|
return parent === '.' ? '' : sanitizeRelativePath(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMoveCopyTarget(sourcePath, targetBasePath) {
|
||||||
|
let targetPath = targetBasePath;
|
||||||
|
try {
|
||||||
|
const targetStat = await fs.promises.stat(targetBasePath);
|
||||||
|
if (targetStat.isDirectory()) {
|
||||||
|
targetPath = path.join(targetBasePath, path.basename(sourcePath));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
return targetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPath(sourcePath, targetPath) {
|
||||||
|
const stat = await fs.promises.stat(sourcePath);
|
||||||
|
await fs.promises.cp(sourcePath, targetPath, {
|
||||||
|
recursive: stat.isDirectory(),
|
||||||
|
force: false,
|
||||||
|
errorOnExist: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userLoginAction(formData) {
|
||||||
|
await runCleanupIfNeeded();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/login`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitMinutes = await checkLoginRateLimit('user');
|
||||||
|
if (waitMinutes > 0) {
|
||||||
|
redirectWithError(
|
||||||
|
`${managementBasePath}/login`,
|
||||||
|
`Zu viele Anmeldeversuche. Bitte in ${waitMinutes} Minuten erneut versuchen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
if (!row || !bcrypt.compareSync(password, row.password_hash)) {
|
||||||
|
redirectWithError(`${managementBasePath}/login`, 'Anmeldung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await setAuthCookie({ sub: username });
|
||||||
|
await clearLoginRateLimit('user');
|
||||||
|
await logEvent('login', username, { ok: true }, await getRequestMeta());
|
||||||
|
redirect(`${managementBasePath}/dashboard`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userLogoutAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await clearAuthCookie();
|
||||||
|
redirect(`${managementBasePath}/login`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminLoginAction(formData) {
|
||||||
|
if (!adminHash) {
|
||||||
|
redirectWithError(`${managementBasePath}/admin`, 'Admin-Zugang ist nicht konfiguriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitMinutes = await checkLoginRateLimit('admin');
|
||||||
|
if (waitMinutes > 0) {
|
||||||
|
redirectWithError(
|
||||||
|
`${managementBasePath}/admin`,
|
||||||
|
`Zu viele Anmeldeversuche. Bitte in ${waitMinutes} Minuten erneut versuchen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = String(formData.get('password') || '');
|
||||||
|
if (!bcrypt.compareSync(password, adminHash)) {
|
||||||
|
redirectWithError(`${managementBasePath}/admin`, 'Anmeldung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await setAuthCookie({ sub: 'admin', admin: true });
|
||||||
|
await clearLoginRateLimit('admin');
|
||||||
|
await logEvent('admin_login', 'admin', { ok: true }, await getRequestMeta());
|
||||||
|
redirect(`${managementBasePath}/admin/dashboard`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminLogoutAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await clearAuthCookie();
|
||||||
|
redirect(`${managementBasePath}/admin`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFileAction(formData) {
|
||||||
|
await runCleanupIfNeeded();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/dashboard`, '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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) {
|
||||||
|
redirectWithError(
|
||||||
|
`${managementBasePath}/dashboard`,
|
||||||
|
`Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const originalName = safeBaseName(uploadedFile.name, 'upload');
|
||||||
|
const extension = sanitizeExtension(originalName);
|
||||||
|
const storedName = `${createRandomId()}${extension}`;
|
||||||
|
const storedPath = path.join(shareDir, storedName);
|
||||||
|
|
||||||
|
const retentionSeconds = parseHours(formData.get('retentionHours'), uploadTtlSeconds);
|
||||||
|
const cappedRetention = Math.min(retentionSeconds, maxRetentionSeconds);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sizeBytes = await writeUploadedFile(uploadedFile, storedPath);
|
||||||
|
await run(
|
||||||
|
`INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
user.username,
|
||||||
|
originalName,
|
||||||
|
storedName,
|
||||||
|
storedPath,
|
||||||
|
sizeBytes,
|
||||||
|
now,
|
||||||
|
now + cappedRetention * 1000,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/dashboard`, 'Upload fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent(
|
||||||
|
'upload',
|
||||||
|
user.username,
|
||||||
|
{ name: storedName, size: Number(uploadedFile.size || 0) },
|
||||||
|
await getRequestMeta()
|
||||||
|
);
|
||||||
|
|
||||||
|
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Upload abgeschlossen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOwnUploadAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await requireAuthenticatedUser();
|
||||||
|
const uploadId = getUploadId(formData);
|
||||||
|
if (!uploadId) {
|
||||||
|
redirectWithError(`${managementBasePath}/dashboard`, 'Ungültige Upload-ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadEntry = await get(
|
||||||
|
'SELECT id, stored_path FROM uploads WHERE id = ? AND owner = ?',
|
||||||
|
[uploadId, user.username]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uploadEntry) {
|
||||||
|
redirectWithError(`${managementBasePath}/dashboard`, 'Upload nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(uploadEntry.stored_path);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extendOwnUploadAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await requireAuthenticatedUser();
|
||||||
|
const uploadId = getUploadId(formData);
|
||||||
|
if (!uploadId) {
|
||||||
|
redirectWithError(`${managementBasePath}/dashboard`, 'Ungültige Upload-ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadEntry = await get(
|
||||||
|
'SELECT id, expires_at FROM uploads WHERE id = ? AND owner = ?',
|
||||||
|
[uploadId, user.username]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uploadEntry) {
|
||||||
|
redirectWithError(`${managementBasePath}/dashboard`, 'Upload nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionSeconds = parseHours(formData.get('extendHours'), uploadTtlSeconds);
|
||||||
|
const now = Date.now();
|
||||||
|
const baseExpiry = Math.max(uploadEntry.expires_at, now);
|
||||||
|
const maxExpiry = now + maxRetentionSeconds * 1000;
|
||||||
|
const nextExpiry = Math.min(baseExpiry + extensionSeconds * 1000, maxExpiry);
|
||||||
|
|
||||||
|
await run('UPDATE uploads SET expires_at = ? WHERE id = ?', [nextExpiry, uploadEntry.id]);
|
||||||
|
await logEvent(
|
||||||
|
'extend',
|
||||||
|
user.username,
|
||||||
|
{ id: uploadEntry.id, expires_at: nextExpiry },
|
||||||
|
await getRequestMeta()
|
||||||
|
);
|
||||||
|
|
||||||
|
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Aufbewahrung aktualisiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminCreateUserAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const username = String(formData.get('username') || '').trim();
|
||||||
|
const password = String(formData.get('password') || '');
|
||||||
|
if (!username || username.length > 200 || !password) {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/users`, 'Ungültige Eingabe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = bcrypt.hashSync(password, 12);
|
||||||
|
try {
|
||||||
|
await run('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)', [
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
Date.now(),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/users`, 'Benutzername existiert bereits.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent('admin_user_create', 'admin', { username }, await getRequestMeta());
|
||||||
|
redirectWithSuccess(`${managementBasePath}/admin/users`, 'Benutzer erstellt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminResetUserAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const username = String(formData.get('username') || '').trim();
|
||||||
|
const password = String(formData.get('password') || '');
|
||||||
|
if (!username || !password) {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminDeleteUserAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/users`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const username = String(formData.get('username') || '').trim();
|
||||||
|
if (!username) {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/users`, 'Ungültige Eingabe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await run('DELETE FROM users WHERE username = ?', [username]);
|
||||||
|
await logEvent('admin_user_delete', 'admin', { username }, await getRequestMeta());
|
||||||
|
redirectWithSuccess(`${managementBasePath}/admin/users`, 'Benutzer gelöscht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminDeleteUploadAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const uploadId = getUploadId(formData);
|
||||||
|
if (!uploadId) {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/dashboard`, '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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(uploadEntry.stored_path);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminExtendUploadAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const uploadId = getUploadId(formData);
|
||||||
|
if (!uploadId) {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/dashboard`, '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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionSeconds = parseHours(formData.get('extendHours'), uploadTtlSeconds);
|
||||||
|
const now = Date.now();
|
||||||
|
const baseExpiry = Math.max(uploadEntry.expires_at, now);
|
||||||
|
const maxExpiry = now + maxRetentionSeconds * 1000;
|
||||||
|
const nextExpiry = Math.min(baseExpiry + extensionSeconds * 1000, maxExpiry);
|
||||||
|
|
||||||
|
await run('UPDATE uploads SET expires_at = ? WHERE id = ?', [nextExpiry, uploadEntry.id]);
|
||||||
|
await logEvent(
|
||||||
|
'extend',
|
||||||
|
'admin',
|
||||||
|
{ id: uploadEntry.id, expires_at: nextExpiry },
|
||||||
|
await getRequestMeta()
|
||||||
|
);
|
||||||
|
|
||||||
|
redirectWithSuccess(`${managementBasePath}/admin/dashboard`, 'Aufbewahrung aktualisiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminMkdirAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const relativePath = sanitizeRelativePath(formData.get('path'));
|
||||||
|
const folderName = String(formData.get('name') || '').trim();
|
||||||
|
|
||||||
|
if (!isValidNodeName(folderName)) {
|
||||||
|
redirectWithError(adminFilesHref(relativePath), 'Ungültiger Ordnername.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = resolveAdminPath(relativePath);
|
||||||
|
if (!basePath) {
|
||||||
|
redirectWithError(adminFilesHref(relativePath), '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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent(
|
||||||
|
'admin_mkdir',
|
||||||
|
'admin',
|
||||||
|
{ path: sanitizeRelativePath(path.join(relativePath, folderName)) },
|
||||||
|
await getRequestMeta()
|
||||||
|
);
|
||||||
|
|
||||||
|
redirectWithSuccess(adminFilesHref(relativePath), 'Ordner erstellt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminUploadToPathAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const relativePath = sanitizeRelativePath(formData.get('path'));
|
||||||
|
const basePath = resolveAdminPath(relativePath);
|
||||||
|
if (!basePath) {
|
||||||
|
redirectWithError(adminFilesHref(relativePath), 'Ungültiger Pfad.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedFile = uploadedFileFromForm(formData, 'file');
|
||||||
|
if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) {
|
||||||
|
redirectWithError(adminFilesHref(relativePath), 'Keine Datei hochgeladen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) {
|
||||||
|
redirectWithError(
|
||||||
|
adminFilesHref(relativePath),
|
||||||
|
`Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = safeBaseName(uploadedFile.name, 'upload');
|
||||||
|
if (!isValidNodeName(fileName)) {
|
||||||
|
redirectWithError(adminFilesHref(relativePath), 'Ungültiger Dateiname.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeUploadedFile(uploadedFile, path.join(basePath, fileName));
|
||||||
|
} catch {
|
||||||
|
redirectWithError(adminFilesHref(relativePath), 'Datei konnte nicht hochgeladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent(
|
||||||
|
'admin_upload',
|
||||||
|
'admin',
|
||||||
|
{ path: sanitizeRelativePath(path.join(relativePath, fileName)) },
|
||||||
|
await getRequestMeta()
|
||||||
|
);
|
||||||
|
|
||||||
|
redirectWithSuccess(adminFilesHref(relativePath), 'Datei hochgeladen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminRenamePathAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const relativePath = sanitizeRelativePath(formData.get('path'));
|
||||||
|
const newName = String(formData.get('newName') || '').trim();
|
||||||
|
|
||||||
|
if (!relativePath) {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/files`, 'Root kann nicht umbenannt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidNodeName(newName)) {
|
||||||
|
redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Ungültiger Name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = resolveAdminPath(relativePath);
|
||||||
|
if (!sourcePath) {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/files`, 'Ungültiger Pfad.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = path.join(path.dirname(sourcePath), newName);
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(sourcePath, targetPath);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Pfad konnte nicht umbenannt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent(
|
||||||
|
'admin_rename',
|
||||||
|
'admin',
|
||||||
|
{
|
||||||
|
from: relativePath,
|
||||||
|
to: sanitizeRelativePath(path.join(path.dirname(relativePath), newName)),
|
||||||
|
},
|
||||||
|
await getRequestMeta()
|
||||||
|
);
|
||||||
|
|
||||||
|
redirectWithSuccess(adminFilesHref(parentRelativePath(relativePath)), 'Pfad umbenannt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminDeletePathAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const relativePath = sanitizeRelativePath(formData.get('path'));
|
||||||
|
if (!relativePath) {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/files`, 'Root kann nicht gelöscht werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = resolveAdminPath(relativePath);
|
||||||
|
if (!sourcePath) {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/files`, 'Ungültiger Pfad.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(sourcePath, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
redirectWithError(adminFilesHref(parentRelativePath(relativePath)), 'Pfad konnte nicht gelöscht werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent('admin_delete', 'admin', { path: relativePath }, await getRequestMeta());
|
||||||
|
redirectWithSuccess(adminFilesHref(parentRelativePath(relativePath)), 'Pfad gelöscht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminMovePathAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const currentPath = sanitizeRelativePath(formData.get('currentPath'));
|
||||||
|
const sourceRelativePath = sanitizeRelativePath(formData.get('path'));
|
||||||
|
const targetRelativePath = sanitizeRelativePath(formData.get('targetPath'));
|
||||||
|
|
||||||
|
if (!sourceRelativePath || !targetRelativePath) {
|
||||||
|
redirectWithError(adminFilesHref(currentPath), 'Ungültige Eingabe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = resolveAdminPath(sourceRelativePath);
|
||||||
|
const targetBasePath = resolveAdminPath(targetRelativePath);
|
||||||
|
if (!sourcePath || !targetBasePath) {
|
||||||
|
redirectWithError(adminFilesHref(currentPath), 'Ungültiger Pfad.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = await resolveMoveCopyTarget(sourcePath, targetBasePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(sourcePath, targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'EXDEV') {
|
||||||
|
try {
|
||||||
|
await copyPath(sourcePath, targetPath);
|
||||||
|
await fs.promises.rm(sourcePath, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht verschoben werden.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht verschoben werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent(
|
||||||
|
'admin_move',
|
||||||
|
'admin',
|
||||||
|
{ from: sourceRelativePath, to: targetRelativePath },
|
||||||
|
await getRequestMeta()
|
||||||
|
);
|
||||||
|
|
||||||
|
redirectWithSuccess(adminFilesHref(currentPath), 'Pfad verschoben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminCopyPathAction(formData) {
|
||||||
|
try {
|
||||||
|
await verifyCsrf(formData);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(`${managementBasePath}/admin/files`, 'CSRF-Prüfung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const currentPath = sanitizeRelativePath(formData.get('currentPath'));
|
||||||
|
const sourceRelativePath = sanitizeRelativePath(formData.get('path'));
|
||||||
|
const targetRelativePath = sanitizeRelativePath(formData.get('targetPath'));
|
||||||
|
|
||||||
|
if (!sourceRelativePath || !targetRelativePath) {
|
||||||
|
redirectWithError(adminFilesHref(currentPath), 'Ungültige Eingabe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = resolveAdminPath(sourceRelativePath);
|
||||||
|
const targetBasePath = resolveAdminPath(targetRelativePath);
|
||||||
|
if (!sourcePath || !targetBasePath) {
|
||||||
|
redirectWithError(adminFilesHref(currentPath), 'Ungültiger Pfad.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = await resolveMoveCopyTarget(sourcePath, targetBasePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyPath(sourcePath, targetPath);
|
||||||
|
} catch {
|
||||||
|
redirectWithError(adminFilesHref(currentPath), 'Pfad konnte nicht kopiert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent(
|
||||||
|
'admin_copy',
|
||||||
|
'admin',
|
||||||
|
{ from: sourceRelativePath, to: targetRelativePath },
|
||||||
|
await getRequestMeta()
|
||||||
|
);
|
||||||
|
|
||||||
|
redirectWithSuccess(adminFilesHref(currentPath), 'Pfad kopiert.');
|
||||||
|
}
|
||||||
16
nextjs/src/lib/config.js
Normal file
16
nextjs/src/lib/config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
function parseInteger(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const managementBasePath = '/manage';
|
||||||
|
export const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||||
|
export const dbPath = process.env.DB_PATH || path.join(dataDir, 'uploads.sqlite');
|
||||||
|
export const shareDir = path.join(dataDir, '_share');
|
||||||
|
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 cookieSecure = process.env.COOKIE_SECURE === 'true';
|
||||||
146
nextjs/src/lib/db.js
Normal file
146
nextjs/src/lib/db.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import sqlite3 from 'sqlite3';
|
||||||
|
|
||||||
|
import { dbPath, shareDir } from './config.js';
|
||||||
|
|
||||||
|
sqlite3.verbose();
|
||||||
|
|
||||||
|
const state = globalThis.__filesLehnertDbState || (globalThis.__filesLehnertDbState = {});
|
||||||
|
|
||||||
|
if (!state.db) {
|
||||||
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
|
fs.mkdirSync(shareDir, { recursive: true });
|
||||||
|
|
||||||
|
state.db = new sqlite3.Database(dbPath);
|
||||||
|
state.cleanupInFlight = false;
|
||||||
|
state.lastCleanupAt = 0;
|
||||||
|
|
||||||
|
initializeSchema(state.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = state.db;
|
||||||
|
|
||||||
|
function initializeSchema(database) {
|
||||||
|
database.serialize(() => {
|
||||||
|
database.run(`CREATE TABLE IF NOT EXISTS uploads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
stored_name TEXT NOT NULL,
|
||||||
|
stored_path TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
uploaded_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
downloads INTEGER DEFAULT 0
|
||||||
|
)`);
|
||||||
|
database.run('CREATE INDEX IF NOT EXISTS uploads_owner_idx ON uploads(owner)');
|
||||||
|
database.run('CREATE INDEX IF NOT EXISTS uploads_expires_idx ON uploads(expires_at)');
|
||||||
|
database.run(`CREATE TABLE IF NOT EXISTS admin_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
owner TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT
|
||||||
|
)`);
|
||||||
|
database.run('CREATE INDEX IF NOT EXISTS admin_logs_event_idx ON admin_logs(event)');
|
||||||
|
database.run('CREATE INDEX IF NOT EXISTS admin_logs_created_idx ON admin_logs(created_at)');
|
||||||
|
database.run(`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)`);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function run(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function onRun(error) {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(sql, params, (error, row) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function all(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (error, rows) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logEvent(event, owner, detail, requestMeta = {}) {
|
||||||
|
const payload = typeof detail === 'string' ? detail : JSON.stringify(detail || {});
|
||||||
|
const ip = requestMeta.ip || null;
|
||||||
|
const userAgent = requestMeta.userAgent || null;
|
||||||
|
|
||||||
|
return run(
|
||||||
|
'INSERT INTO admin_logs (event, owner, detail, created_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[event, owner || null, payload, Date.now(), ip, userAgent]
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupExpiredUploads() {
|
||||||
|
const now = Date.now();
|
||||||
|
const expired = await all('SELECT id, stored_path FROM uploads WHERE expires_at <= ?', [now]);
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
for (const entry of expired) {
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(entry.stored_path);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
await run('DELETE FROM uploads WHERE id = ?', [entry.id]);
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
await logEvent('cleanup', null, { removed });
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCleanupIfNeeded() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (state.cleanupInFlight || now - state.lastCleanupAt < 60_000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.cleanupInFlight = true;
|
||||||
|
state.lastCleanupAt = now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cleanupExpiredUploads();
|
||||||
|
} finally {
|
||||||
|
state.cleanupInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runCleanupIfNeeded().catch(() => undefined);
|
||||||
62
nextjs/src/lib/files.js
Normal file
62
nextjs/src/lib/files.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { dataDir, managementBasePath } from './config.js';
|
||||||
|
|
||||||
|
export function sanitizeRelativePath(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
.replace(/\/{2,}/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllowedAdminPath(relativePath) {
|
||||||
|
const parts = sanitizeRelativePath(relativePath).split('/').filter(Boolean);
|
||||||
|
return !parts.includes('_share');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAdminPath(relativePath) {
|
||||||
|
const clean = sanitizeRelativePath(relativePath);
|
||||||
|
if (!isAllowedAdminPath(clean)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = path.resolve(dataDir, clean);
|
||||||
|
if (target === dataDir || target.startsWith(`${dataDir}${path.sep}`)) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidNodeName(value) {
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (trimmed === '_share') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !trimmed.includes('/') && !trimmed.includes('\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeBaseName(value, fallback = 'file') {
|
||||||
|
const base = path.basename(String(value || '')).trim();
|
||||||
|
return base || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeExtension(value) {
|
||||||
|
const extension = path.extname(String(value || '')).toLowerCase();
|
||||||
|
if (!extension) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminFilesHref(relativePath = '') {
|
||||||
|
const clean = sanitizeRelativePath(relativePath);
|
||||||
|
if (!clean) {
|
||||||
|
return `${managementBasePath}/admin/files`;
|
||||||
|
}
|
||||||
|
return `${managementBasePath}/admin/files?path=${encodeURIComponent(clean)}`;
|
||||||
|
}
|
||||||
70
nextjs/src/lib/format.js
Normal file
70
nextjs/src/lib/format.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export function formatBytes(bytes) {
|
||||||
|
const size = Number(bytes) || 0;
|
||||||
|
if (size < 1024) {
|
||||||
|
return `${size} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||||
|
let value = size / 1024;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimestamp(timestamp) {
|
||||||
|
const value = Number(timestamp);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return new Date(value).toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCountdown(timestamp) {
|
||||||
|
const expiresAt = Number(timestamp) || 0;
|
||||||
|
const deltaMinutes = Math.floor(Math.max(0, expiresAt - Date.now()) / 60_000);
|
||||||
|
const hours = Math.floor(deltaMinutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days}d ${hours % 24}h`;
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${deltaMinutes % 60}m`;
|
||||||
|
}
|
||||||
|
return `${deltaMinutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLogDetail(detailText) {
|
||||||
|
if (!detailText) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(detailText);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return [{ key: 'detail', value: String(detailText) }];
|
||||||
|
}
|
||||||
|
return Object.entries(parsed).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: String(value),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [{ key: 'detail', value: String(detailText) }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSearchParam(searchParams, key) {
|
||||||
|
const rawValue = searchParams?.[key];
|
||||||
|
if (Array.isArray(rawValue)) {
|
||||||
|
return String(rawValue[0] || '');
|
||||||
|
}
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
203
nextjs/src/lib/security.js
Normal file
203
nextjs/src/lib/security.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { cookies, headers } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { cookieSecure } from './config.js';
|
||||||
|
|
||||||
|
const state = globalThis.__filesLehnertSecurityState || (globalThis.__filesLehnertSecurityState = {});
|
||||||
|
|
||||||
|
if (!state.jwtSecret) {
|
||||||
|
state.jwtSecret = crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.loginAttempts) {
|
||||||
|
state.loginAttempts = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authCookieName = 'auth';
|
||||||
|
const csrfCookieName = 'csrf';
|
||||||
|
const jwtMaxAgeSeconds = 2 * 60 * 60;
|
||||||
|
const loginWindowMs = 15 * 60 * 1000;
|
||||||
|
const loginMaxAttempts = 10;
|
||||||
|
|
||||||
|
function firstForwardedPart(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return value.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectedOrigin() {
|
||||||
|
const headerStore = await headers();
|
||||||
|
const host = headerStore.get('x-forwarded-host') || headerStore.get('host') || '';
|
||||||
|
const forwardedProto = firstForwardedPart(headerStore.get('x-forwarded-proto'));
|
||||||
|
const protocol = forwardedProto || (process.env.NODE_ENV === 'production' ? 'https' : 'http');
|
||||||
|
|
||||||
|
return host ? `${protocol}://${host}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifySameOrigin() {
|
||||||
|
const headerStore = await headers();
|
||||||
|
const source = headerStore.get('origin') || headerStore.get('referer');
|
||||||
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(source);
|
||||||
|
} catch {
|
||||||
|
throw new Error('origin-check-failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = await expectedOrigin();
|
||||||
|
if (!expected || parsed.origin !== expected) {
|
||||||
|
throw new Error('origin-check-failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureCsrfToken() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const cookieToken = cookieStore.get(csrfCookieName)?.value;
|
||||||
|
if (cookieToken) {
|
||||||
|
return cookieToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerStore = await headers();
|
||||||
|
const headerToken = headerStore.get('x-csrf-token');
|
||||||
|
if (headerToken) {
|
||||||
|
return headerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCsrf(formData) {
|
||||||
|
await verifySameOrigin();
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const expectedToken = cookieStore.get(csrfCookieName)?.value || '';
|
||||||
|
|
||||||
|
let providedToken = '';
|
||||||
|
if (formData && typeof formData.get === 'function') {
|
||||||
|
providedToken = String(formData.get('csrfToken') || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!providedToken) {
|
||||||
|
const headerStore = await headers();
|
||||||
|
providedToken = String(headerStore.get('x-csrf-token') || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedToken || !providedToken || expectedToken !== providedToken) {
|
||||||
|
throw new Error('csrf-token-mismatch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRequestMeta() {
|
||||||
|
const headerStore = await headers();
|
||||||
|
const forwardedFor = firstForwardedPart(headerStore.get('x-forwarded-for'));
|
||||||
|
const realIp = headerStore.get('x-real-ip') || '';
|
||||||
|
const ip = forwardedFor || realIp || 'unknown';
|
||||||
|
const userAgent = headerStore.get('user-agent') || '';
|
||||||
|
|
||||||
|
return { ip, userAgent };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthenticatedUser() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(authCookieName)?.value;
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, state.jwtSecret);
|
||||||
|
if (!payload || typeof payload !== 'object' || !payload.sub) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
username: String(payload.sub),
|
||||||
|
admin: Boolean(payload.admin),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAuthCookie(payload) {
|
||||||
|
const token = jwt.sign(payload, state.jwtSecret, { expiresIn: jwtMaxAgeSeconds });
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
cookieStore.set(authCookieName, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: jwtMaxAgeSeconds,
|
||||||
|
secure: cookieSecure,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAuthCookie() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(authCookieName, '', {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 0,
|
||||||
|
secure: cookieSecure,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuthenticatedUser() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user) {
|
||||||
|
await clearAuthCookie();
|
||||||
|
redirect('/manage/login');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdminUser() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user || !user.admin) {
|
||||||
|
await clearAuthCookie();
|
||||||
|
redirect('/manage/admin');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAttemptKey(type) {
|
||||||
|
const meta = await getRequestMeta();
|
||||||
|
return `${type}:${meta.ip || 'unknown'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkLoginRateLimit(type) {
|
||||||
|
const key = await loginAttemptKey(type);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const entry = state.loginAttempts.get(key) || {
|
||||||
|
count: 0,
|
||||||
|
resetAt: now + loginWindowMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (now > entry.resetAt) {
|
||||||
|
entry.count = 0;
|
||||||
|
entry.resetAt = now + loginWindowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count += 1;
|
||||||
|
state.loginAttempts.set(key, entry);
|
||||||
|
|
||||||
|
if (entry.count > loginMaxAttempts) {
|
||||||
|
return Math.ceil((entry.resetAt - now) / 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearLoginRateLimit(type) {
|
||||||
|
const key = await loginAttemptKey(type);
|
||||||
|
state.loginAttempts.delete(key);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user