2011 lines
68 KiB
JavaScript
2011 lines
68 KiB
JavaScript
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const bcrypt = require('bcryptjs');
|
|
const cookieParser = require('cookie-parser');
|
|
const dotenv = require('dotenv');
|
|
const express = require('express');
|
|
const jwt = require('jsonwebtoken');
|
|
const multer = require('multer');
|
|
const sqlite3 = require('sqlite3').verbose();
|
|
|
|
dotenv.config({ path: path.join(__dirname, '..', '..', '.env') });
|
|
|
|
const basePath = (process.env.BASE_PATH || '/manage').replace(/\/+$/, '') || '/manage';
|
|
const port = parseInt(process.env.PORT || '3000', 10);
|
|
const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
|
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uploads.sqlite');
|
|
const adminHash = process.env.MANAGEMENT_ADMIN_HASH || '';
|
|
const uploadTtlSeconds = parseInt(process.env.UPLOAD_TTL_SECONDS || '604800', 10);
|
|
const maxRetentionSeconds = 90 * 24 * 60 * 60;
|
|
const maxUploadBytes = parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10);
|
|
const shareDir = path.join(dataDir, '_share');
|
|
|
|
const jwtSecret = crypto.randomBytes(32).toString('hex');
|
|
const jwtMaxAgeMs = 2 * 60 * 60 * 1000;
|
|
|
|
fs.mkdirSync(shareDir, { recursive: true });
|
|
const tempDir = path.join(os.tmpdir(), 'uploads');
|
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
|
|
const upload = multer({
|
|
dest: tempDir,
|
|
limits: maxUploadBytes > 0 ? { fileSize: maxUploadBytes } : undefined,
|
|
});
|
|
|
|
const app = express();
|
|
const trustProxy = process.env.TRUST_PROXY === 'true';
|
|
app.set('trust proxy', trustProxy);
|
|
app.use(cookieParser());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
app.use(express.json());
|
|
app.use((req, res, next) => {
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
next();
|
|
});
|
|
|
|
const db = new sqlite3.Database(dbPath);
|
|
|
|
db.serialize(() => {
|
|
db.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
|
|
)`);
|
|
db.run('CREATE INDEX IF NOT EXISTS uploads_owner_idx ON uploads(owner)');
|
|
db.run('CREATE INDEX IF NOT EXISTS uploads_expires_idx ON uploads(expires_at)');
|
|
db.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
|
|
)`);
|
|
db.run('ALTER TABLE uploads ADD COLUMN downloads INTEGER DEFAULT 0', (err) => { /* ignore if column exists */ });
|
|
db.run('CREATE INDEX IF NOT EXISTS admin_logs_event_idx ON admin_logs(event)');
|
|
db.run('CREATE INDEX IF NOT EXISTS admin_logs_created_idx ON admin_logs(created_at)');
|
|
db.run(`CREATE TABLE IF NOT EXISTS users (
|
|
username TEXT PRIMARY KEY,
|
|
password_hash TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
)`);
|
|
});
|
|
|
|
function run(sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.run(sql, params, function (err) {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
resolve(this);
|
|
});
|
|
});
|
|
}
|
|
|
|
function get(sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.get(sql, params, (err, row) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
resolve(row);
|
|
});
|
|
});
|
|
}
|
|
|
|
function all(sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.all(sql, params, (err, rows) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
resolve(rows);
|
|
});
|
|
});
|
|
}
|
|
|
|
function logEvent(event, owner, detail) {
|
|
const payload = typeof detail === 'string' ? detail : JSON.stringify(detail || {});
|
|
return run(
|
|
'INSERT INTO admin_logs (event, owner, detail, created_at) VALUES (?, ?, ?, ?)',
|
|
[event, owner || null, payload, Date.now()]
|
|
).catch(() => undefined);
|
|
}
|
|
|
|
const csrfCookieName = 'csrf';
|
|
const csrfCookieOptions = {
|
|
httpOnly: true,
|
|
sameSite: 'strict',
|
|
secure: process.env.COOKIE_SECURE === 'true',
|
|
};
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function csrfField(token) {
|
|
return `<input type="hidden" name="csrfToken" value="${escapeHtml(token)}" />`;
|
|
}
|
|
|
|
function ensureCsrfToken(req, res, next) {
|
|
let token = req.cookies[csrfCookieName];
|
|
if (!token) {
|
|
token = crypto.randomBytes(32).toString('hex');
|
|
res.cookie(csrfCookieName, token, csrfCookieOptions);
|
|
}
|
|
res.locals.csrfToken = token;
|
|
next();
|
|
}
|
|
|
|
function isSameOrigin(req) {
|
|
const origin = req.get('origin');
|
|
const referer = req.get('referer');
|
|
const header = origin || referer;
|
|
if (!header) {
|
|
return true;
|
|
}
|
|
try {
|
|
const parsed = new URL(header);
|
|
const expected = `${req.protocol}://${req.get('host')}`;
|
|
return parsed.origin === expected;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function csrfGuard(req, res, next) {
|
|
if (!isSameOrigin(req)) {
|
|
if (req.path.startsWith(`${basePath}/api/`)) {
|
|
res.status(403).json({ error: 'Origin check failed' });
|
|
return;
|
|
}
|
|
res.status(403).send(renderPage('Zugriff verweigert', '<p class="card">Origin-Prüfung fehlgeschlagen.</p>'));
|
|
return;
|
|
}
|
|
|
|
const token = req.cookies[csrfCookieName];
|
|
const provided = req.body?.csrfToken || req.query?.csrfToken || req.get('x-csrf-token');
|
|
if (!token || !provided || token !== provided) {
|
|
if (req.path.startsWith(`${basePath}/api/`)) {
|
|
res.status(403).json({ error: 'CSRF token mismatch' });
|
|
return;
|
|
}
|
|
res.status(403).send(renderPage('Zugriff verweigert', '<p class="card">CSRF-Prüfung fehlgeschlagen.</p>'));
|
|
return;
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
const loginAttempts = new Map();
|
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
|
const LOGIN_MAX_ATTEMPTS = 10;
|
|
|
|
function loginRateLimit(type) {
|
|
return (req, res, next) => {
|
|
const ip = req.ip || 'unknown';
|
|
const key = `${type}:${ip}`;
|
|
const now = Date.now();
|
|
const entry = loginAttempts.get(key) || { count: 0, resetAt: now + LOGIN_WINDOW_MS };
|
|
if (now > entry.resetAt) {
|
|
entry.count = 0;
|
|
entry.resetAt = now + LOGIN_WINDOW_MS;
|
|
}
|
|
entry.count += 1;
|
|
loginAttempts.set(key, entry);
|
|
if (entry.count > LOGIN_MAX_ATTEMPTS) {
|
|
const waitMinutes = Math.ceil((entry.resetAt - now) / 60000);
|
|
const body = `<section class="card"><p>Zu viele Anmeldeversuche. Bitte in ${waitMinutes} Minuten erneut versuchen.</p></section>`;
|
|
res.status(429).send(renderPage('Zu viele Versuche', body));
|
|
return;
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
function clearLoginAttempts(type, req) {
|
|
const ip = req.ip || 'unknown';
|
|
loginAttempts.delete(`${type}:${ip}`);
|
|
}
|
|
|
|
async function getUserHash(username) {
|
|
const row = await get('SELECT password_hash FROM users WHERE username = ?', [username]);
|
|
return row ? row.password_hash : null;
|
|
}
|
|
|
|
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 sanitizeBaseName(originalName) {
|
|
const ext = path.extname(originalName || '');
|
|
const base = path.basename(originalName || 'datei', ext);
|
|
// Allow more characters but keep it safe for URLs and FS
|
|
const cleaned = base
|
|
.replace(/[^\w\-. ]/g, '') // Allow words, dashes, dots, spaces
|
|
.trim()
|
|
.replace(/\s+/g, '-'); // Replace spaces with dashes for better URLs
|
|
return cleaned || 'datei';
|
|
}
|
|
|
|
function sanitizeExtension(originalName) {
|
|
const ext = path.extname(originalName || '').toLowerCase();
|
|
if (!ext) {
|
|
return '';
|
|
}
|
|
if (!/^\.[a-z0-9]{1,10}$/.test(ext)) {
|
|
return '';
|
|
}
|
|
return ext;
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes < 1024) {
|
|
return `${bytes} B`;
|
|
}
|
|
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
let value = bytes / 1024;
|
|
let idx = 0;
|
|
while (value >= 1024 && idx < units.length - 1) {
|
|
value /= 1024;
|
|
idx += 1;
|
|
}
|
|
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[idx]}`;
|
|
}
|
|
|
|
function formatTimestamp(ts) {
|
|
const date = new Date(ts);
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function formatCountdown(ts) {
|
|
const delta = Math.max(0, ts - Date.now());
|
|
const minutes = Math.floor(delta / 60000);
|
|
const hours = Math.floor(minutes / 60);
|
|
const days = Math.floor(hours / 24);
|
|
if (days > 0) {
|
|
return `${days}d ${hours % 24}h`;
|
|
}
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes % 60}m`;
|
|
}
|
|
return `${minutes}m`;
|
|
}
|
|
|
|
function renderFileManagerPage(title, body) {
|
|
return `<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>${title}</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f8fafc;
|
|
--card-bg: #ffffff;
|
|
--text-main: #0f172a;
|
|
--text-muted: #64748b;
|
|
--border: #e2e8f0;
|
|
--primary: #0f766e;
|
|
--primary-hover: #0d9488;
|
|
--primary-light: #f0fdfa;
|
|
--danger: #ef4444;
|
|
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
|
--radius: 0.75rem;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text-main);
|
|
line-height: 1.5;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
main { max-width: 1200px; margin: 0 auto; padding: 2rem 1.5rem; }
|
|
|
|
header {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
margin-bottom: 2rem;
|
|
padding-bottom: 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
h1 { margin: 0; font-size: 1.5rem; font-weight: 700; color: var(--text-main); }
|
|
h2 { margin: 0 0 1rem; font-size: 1.1rem; font-weight: 600; }
|
|
.muted { color: var(--text-muted); font-size: 0.875rem; }
|
|
|
|
.card {
|
|
background: var(--card-bg);
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow);
|
|
padding: 1.5rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }
|
|
|
|
.tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.375rem 0.75rem;
|
|
border-radius: 9999px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-main);
|
|
text-decoration: none;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
transition: all 0.2s;
|
|
}
|
|
.tag:hover { border-color: var(--text-muted); }
|
|
.tag.primary {
|
|
background: var(--primary-light);
|
|
border-color: #ccfbf1;
|
|
color: var(--primary);
|
|
}
|
|
.tag.primary:hover { border-color: var(--primary); }
|
|
.tag span { color: var(--text-muted); }
|
|
|
|
.browser-shell { display: grid; gap: 1.5rem; }
|
|
.browser-bar { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }
|
|
.crumbs { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; font-size: 0.95rem; }
|
|
.crumbs a { color: var(--primary); text-decoration: none; font-weight: 500; }
|
|
.crumbs a:hover { text-decoration: underline; }
|
|
.crumbs span { color: var(--text-muted); }
|
|
|
|
.grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
|
|
|
form { display: grid; gap: 1rem; }
|
|
label { display: grid; gap: 0.375rem; font-weight: 500; font-size: 0.9rem; }
|
|
|
|
input, button, select {
|
|
font: inherit;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 0.5rem;
|
|
outline: none;
|
|
}
|
|
input, select {
|
|
border: 1px solid var(--border);
|
|
background: #fff;
|
|
transition: border-color 0.2s;
|
|
}
|
|
input:focus, select:focus {
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 2px var(--primary-light);
|
|
}
|
|
|
|
button {
|
|
border: 1px solid transparent;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
button:hover { background: var(--primary-hover); }
|
|
button.secondary {
|
|
background: white;
|
|
border-color: var(--border);
|
|
color: var(--text-main);
|
|
}
|
|
button.secondary:hover { border-color: var(--text-muted); background: var(--bg); }
|
|
button.danger {
|
|
background: #fee2e2;
|
|
color: var(--danger);
|
|
}
|
|
button.danger:hover { background: #fecaca; }
|
|
|
|
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.95rem; }
|
|
th { text-align: left; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
tr:last-child td { border-bottom: none; }
|
|
tbody tr:hover { background: var(--bg); }
|
|
|
|
.name { display: inline-flex; align-items: center; gap: 0.75rem; font-weight: 500; }
|
|
.folder { color: var(--primary); text-decoration: none; }
|
|
.folder:hover { text-decoration: underline; }
|
|
|
|
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
|
.actions button { font-size: 0.8rem; padding: 0.25rem 0.6rem; }
|
|
|
|
dialog {
|
|
border: none;
|
|
border-radius: var(--radius);
|
|
padding: 0;
|
|
width: min(480px, 95vw);
|
|
box-shadow: var(--shadow-lg);
|
|
background: var(--card-bg);
|
|
}
|
|
dialog::backdrop { background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); }
|
|
.dialog-card { padding: 1.5rem; display: grid; gap: 1.25rem; }
|
|
.dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
${body}
|
|
</main>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function renderPage(title, body, mainClass = '') {
|
|
return `<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>${title}</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f8fafc;
|
|
--card-bg: #ffffff;
|
|
--text-main: #0f172a;
|
|
--text-muted: #64748b;
|
|
--border: #e2e8f0;
|
|
--primary: #0f172a; /* Darker primary for admin/user dashboard */
|
|
--primary-hover: #334155;
|
|
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
|
--radius: 0.75rem;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text-main);
|
|
line-height: 1.5;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
main { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
|
|
main.wide { max-width: 1200px; }
|
|
|
|
header {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
h1 { margin: 0; font-size: 1.75rem; font-weight: 700; }
|
|
h2 { margin: 0 0 1.25rem; font-size: 1.1rem; font-weight: 600; }
|
|
.muted { color: var(--text-muted); font-size: 0.875rem; }
|
|
|
|
.card {
|
|
background: var(--card-bg);
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow);
|
|
padding: 1.5rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
form { display: grid; gap: 1rem; }
|
|
label { display: grid; gap: 0.375rem; font-weight: 500; font-size: 0.9rem; }
|
|
|
|
input, button {
|
|
font: inherit;
|
|
padding: 0.6rem 0.8rem;
|
|
border-radius: 0.5rem;
|
|
outline: none;
|
|
}
|
|
input {
|
|
border: 1px solid var(--border);
|
|
background: #fff;
|
|
transition: border-color 0.2s;
|
|
}
|
|
input:focus {
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 2px #e2e8f0;
|
|
}
|
|
button {
|
|
border: 1px solid transparent;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
button:hover { background: var(--primary-hover); }
|
|
button.secondary {
|
|
background: white;
|
|
border-color: var(--border);
|
|
color: var(--text-main);
|
|
}
|
|
button.secondary:hover { border-color: var(--text-muted); background: var(--bg); }
|
|
|
|
.row table { margin-bottom: 0; }
|
|
.row td { padding: 0.5rem 1rem; border-bottom: 1px solid var(--border); }
|
|
.row tr:last-child td { border-bottom: none; }
|
|
.row strong { color: var(--text-muted); font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.row .muted { font-size: 1.1rem; color: var(--text-main); font-weight: 600; text-align: right; }
|
|
|
|
.truncate { max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.stack { width: 100%; margin-top: 0.25rem; }
|
|
.stack button { width: 100%; justify-content: center; }
|
|
|
|
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.925rem; }
|
|
th { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
td { padding: 0.75rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
tr:last-child td { border-bottom: none; }
|
|
tbody tr:hover { background: var(--bg); }
|
|
|
|
progress { width: 100%; height: 0.75rem; border-radius: 99px; overflow: hidden; }
|
|
progress::-webkit-progress-bar { background-color: var(--border); }
|
|
progress::-webkit-progress-value { background-color: var(--primary); }
|
|
|
|
.actions { display: flex; gap: 0.5rem; align-items: center; }
|
|
.actions form { display: flex; gap: 0.5rem; }
|
|
.actions input, .actions button { font-size: 0.8rem; padding: 0.35rem 0.6rem; }
|
|
|
|
.pill { display: inline-flex; padding: 0.25rem 0.75rem; border-radius: 999px; background: #fee2e2; color: #991b1b; font-size: 0.85rem; font-weight: 500; margin-bottom: 1rem; }
|
|
a { color: inherit; text-decoration-color: var(--border); }
|
|
a:hover { color: var(--primary); text-decoration-color: var(--primary); }
|
|
|
|
/* Toolbar helpers for admin */
|
|
.toolbar { display: flex; gap: 0.75rem; }
|
|
.tag { display: inline-flex; align-items: center; padding: 0.375rem 0.75rem; border-radius: 999px; background: #fff; border: 1px solid var(--border); text-decoration: none; font-size: 0.875rem; font-weight: 500; }
|
|
.tag.primary { background: #f1f5f9; color: var(--text-main); }
|
|
.tag:hover { border-color: var(--text-muted); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="${mainClass}">
|
|
${body}
|
|
</main>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function baseUrl(pathname) {
|
|
return `${basePath}${pathname}`;
|
|
}
|
|
|
|
function getUserFromRequest(req) {
|
|
const token = req.cookies?.auth;
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
try {
|
|
const payload = jwt.verify(token, jwtSecret);
|
|
if (!payload?.sub) {
|
|
return null;
|
|
}
|
|
return { username: payload.sub, admin: Boolean(payload.admin) };
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function requireAuthPage(req, res, next) {
|
|
const user = getUserFromRequest(req);
|
|
if (!user) {
|
|
res.clearCookie('auth');
|
|
res.redirect(baseUrl('/login'));
|
|
return;
|
|
}
|
|
req.user = user;
|
|
next();
|
|
}
|
|
|
|
function requireAdminPage(req, res, next) {
|
|
const user = getUserFromRequest(req);
|
|
if (!user || !user.admin) {
|
|
res.clearCookie('auth');
|
|
res.redirect(baseUrl('/admin'));
|
|
return;
|
|
}
|
|
req.user = user;
|
|
next();
|
|
}
|
|
|
|
function isAllowedAdminPath(relativePath) {
|
|
const parts = relativePath.split('/').filter(Boolean);
|
|
return !parts.includes('_share');
|
|
}
|
|
|
|
function resolveAdminPath(relativePath) {
|
|
const cleaned = relativePath.replace(/\\/g, '/');
|
|
if (!isAllowedAdminPath(cleaned)) {
|
|
return null;
|
|
}
|
|
const target = path.resolve(dataDir, cleaned);
|
|
if (target === dataDir || target.startsWith(`${dataDir}${path.sep}`)) {
|
|
return target;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function requireAuthApi(req, res, next) {
|
|
const user = getUserFromRequest(req);
|
|
if (!user) {
|
|
res.clearCookie('auth');
|
|
res.status(401).json({ error: 'Unauthorized' });
|
|
return;
|
|
}
|
|
req.user = user;
|
|
next();
|
|
}
|
|
|
|
async function cleanupExpired() {
|
|
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 (err) {
|
|
// File might already be gone.
|
|
}
|
|
await run('DELETE FROM uploads WHERE id = ?', [entry.id]);
|
|
removed += 1;
|
|
}
|
|
if (removed > 0) {
|
|
await logEvent('cleanup', null, { removed });
|
|
}
|
|
}
|
|
|
|
app.use(ensureCsrfToken);
|
|
app.use((req, res, next) => {
|
|
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
|
csrfGuard(req, res, next);
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
|
|
const cleanupTimer = setInterval(() => {
|
|
cleanupExpired().catch(() => undefined);
|
|
}, 60 * 1000);
|
|
cleanupExpired().catch(() => undefined);
|
|
|
|
app.get(`${basePath}/`, (req, res) => {
|
|
const user = getUserFromRequest(req);
|
|
if (user) {
|
|
res.redirect(baseUrl('/dashboard'));
|
|
return;
|
|
}
|
|
res.redirect(baseUrl('/login'));
|
|
});
|
|
|
|
app.get(`${basePath}/login`, (req, res) => {
|
|
const user = getUserFromRequest(req);
|
|
if (user) {
|
|
res.redirect(baseUrl('/dashboard'));
|
|
return;
|
|
}
|
|
const body = `
|
|
<header>
|
|
<div>
|
|
<h1>Dateiverwaltung</h1>
|
|
<div class="muted">Bitte anmelden, um Uploads zu verwalten.</div>
|
|
</div>
|
|
</header>
|
|
<section class="card">
|
|
<form method="post" action="${baseUrl('/login')}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<label>
|
|
Benutzername
|
|
<input name="username" autocomplete="username" required />
|
|
</label>
|
|
<label>
|
|
Passwort
|
|
<input type="password" name="password" autocomplete="current-password" required />
|
|
</label>
|
|
<button type="submit">Anmelden</button>
|
|
</form>
|
|
</section>
|
|
`;
|
|
res.send(renderPage('Anmeldung', body));
|
|
});
|
|
|
|
app.post(`${basePath}/login`, loginRateLimit('user'), async (req, res) => {
|
|
const username = String(req.body.username || '').trim();
|
|
const password = String(req.body.password || '');
|
|
const hash = await getUserHash(username);
|
|
|
|
if (!hash || !bcrypt.compareSync(password, hash)) {
|
|
const body = `
|
|
<header>
|
|
<div>
|
|
<h1>Dateiverwaltung</h1>
|
|
<div class="muted">Bitte anmelden, um Uploads zu verwalten.</div>
|
|
</div>
|
|
</header>
|
|
<section class="card">
|
|
<div class="pill">Anmeldung fehlgeschlagen</div>
|
|
<form method="post" action="${baseUrl('/login')}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<label>
|
|
Benutzername
|
|
<input name="username" autocomplete="username" required />
|
|
</label>
|
|
<label>
|
|
Passwort
|
|
<input type="password" name="password" autocomplete="current-password" required />
|
|
</label>
|
|
<button type="submit">Anmelden</button>
|
|
</form>
|
|
</section>
|
|
`;
|
|
res.status(401).send(renderPage('Anmeldung', body));
|
|
return;
|
|
}
|
|
|
|
const token = jwt.sign({ sub: username }, jwtSecret, { expiresIn: '2h' });
|
|
res.cookie('auth', token, {
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
maxAge: jwtMaxAgeMs,
|
|
secure: process.env.COOKIE_SECURE === 'true',
|
|
});
|
|
clearLoginAttempts('user', req);
|
|
await logEvent('login', username, { ok: true });
|
|
res.redirect(baseUrl('/dashboard'));
|
|
});
|
|
|
|
app.post(`${basePath}/logout`, (req, res) => {
|
|
res.clearCookie('auth');
|
|
res.redirect(baseUrl('/login'));
|
|
});
|
|
|
|
app.get(`${basePath}/admin`, async (req, res) => {
|
|
if (!adminHash) {
|
|
res.status(404).send(renderPage('Admin', '<p class="card">Admin-Zugang ist nicht konfiguriert.</p>'));
|
|
return;
|
|
}
|
|
const user = getUserFromRequest(req);
|
|
if (user?.admin) {
|
|
res.redirect(baseUrl('/admin/dashboard'));
|
|
return;
|
|
}
|
|
const body = `
|
|
<header>
|
|
<div>
|
|
<h1>Adminbereich</h1>
|
|
<div class="muted">Admin-Passwort eingeben.</div>
|
|
</div>
|
|
</header>
|
|
<section class="card">
|
|
<form method="post" action="${baseUrl('/admin/login')}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<label>
|
|
Admin-Passwort
|
|
<input type="password" name="password" autocomplete="current-password" required />
|
|
</label>
|
|
<button type="submit">Anmelden</button>
|
|
</form>
|
|
</section>
|
|
`;
|
|
res.send(renderPage('Admin', body));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/login`, loginRateLimit('admin'), async (req, res) => {
|
|
if (!adminHash) {
|
|
res.status(404).send(renderPage('Admin', '<p class="card">Admin-Zugang ist nicht konfiguriert.</p>'));
|
|
return;
|
|
}
|
|
const password = String(req.body.password || '');
|
|
if (!bcrypt.compareSync(password, adminHash)) {
|
|
const body = `
|
|
<header>
|
|
<div>
|
|
<h1>Adminbereich</h1>
|
|
<div class="muted">Admin-Passwort eingeben.</div>
|
|
</div>
|
|
</header>
|
|
<section class="card">
|
|
<div class="pill">Anmeldung fehlgeschlagen</div>
|
|
<form method="post" action="${baseUrl('/admin/login')}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<label>
|
|
Admin-Passwort
|
|
<input type="password" name="password" autocomplete="current-password" required />
|
|
</label>
|
|
<button type="submit">Anmelden</button>
|
|
</form>
|
|
</section>
|
|
`;
|
|
res.status(401).send(renderPage('Admin', body));
|
|
return;
|
|
}
|
|
const token = jwt.sign({ sub: 'admin', admin: true }, jwtSecret, { expiresIn: '2h' });
|
|
res.cookie('auth', token, {
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
maxAge: jwtMaxAgeMs,
|
|
secure: process.env.COOKIE_SECURE === 'true',
|
|
});
|
|
clearLoginAttempts('admin', req);
|
|
await logEvent('admin_login', 'admin', { ok: true });
|
|
res.redirect(baseUrl('/admin/dashboard'));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/logout`, (req, res) => {
|
|
res.clearCookie('auth');
|
|
res.redirect(baseUrl('/admin'));
|
|
});
|
|
|
|
app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
|
|
const [
|
|
activeCount,
|
|
activeBytes,
|
|
distinctOwners,
|
|
totalUploads,
|
|
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 COUNT(*) as count FROM admin_logs WHERE event IN (?, ?)', ['delete', 'cleanup']),
|
|
get('SELECT MAX(created_at) as ts FROM admin_logs WHERE event = ?', ['cleanup']),
|
|
all('SELECT event, owner, detail, created_at FROM admin_logs ORDER BY created_at DESC LIMIT 500'),
|
|
all('SELECT id, owner, original_name, stored_name, size_bytes, expires_at FROM uploads ORDER BY uploaded_at DESC'),
|
|
]);
|
|
|
|
const stats = `
|
|
<div class="row">
|
|
<table>
|
|
<tbody>
|
|
<tr>
|
|
<td><strong>Aktive Uploads</strong></td>
|
|
<td class="muted">${activeCount.count}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Aktive Größe</strong></td>
|
|
<td class="muted">${formatBytes(activeBytes.total)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Aktive Nutzer</strong></td>
|
|
<td class="muted">${distinctOwners.count}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Uploads gesamt</strong></td>
|
|
<td class="muted">${totalUploads.count}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Downloads gesamt</strong></td>
|
|
<td class="muted">${await get('SELECT SUM(downloads) as count FROM uploads').then(r => r.count || 0)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Löschungen gesamt</strong></td>
|
|
<td class="muted">${totalDeletes.count}</td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Letztes Cleanup</strong></td>
|
|
<td class="muted">${lastCleanup.ts ? formatTimestamp(lastCleanup.ts) : '—'}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
const logRows = recentLogs.map((entry) => `
|
|
<tr>
|
|
<td>${formatTimestamp(entry.created_at)}</td>
|
|
<td>${escapeHtml(entry.event)}</td>
|
|
<td>${escapeHtml(entry.owner || '—')}</td>
|
|
<td>${escapeHtml(entry.detail || '')}</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
const adminUploadsRows = allUploads.map((item) => {
|
|
const fileUrl = `/_share/${item.stored_name}`;
|
|
const fileHref = encodeURI(fileUrl);
|
|
return `
|
|
<tr>
|
|
<td class="truncate" title="${escapeHtml(item.owner)}">${escapeHtml(item.owner)}</td>
|
|
<td>
|
|
<div class="truncate" title="${escapeHtml(item.original_name)}"><strong>${escapeHtml(item.original_name)}</strong></div>
|
|
<div class="muted truncate"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div>
|
|
</td>
|
|
<td>${formatBytes(item.size_bytes)}</td>
|
|
<td>
|
|
<div>${formatTimestamp(item.expires_at)}</div>
|
|
<div class="muted">Noch ${formatCountdown(item.expires_at)}</div>
|
|
</td>
|
|
<td class="actions">
|
|
<form method="post" action="${baseUrl(`/admin/files/${item.id}/delete`)}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<button type="submit" class="secondary">Löschen</button>
|
|
</form>
|
|
<form method="post" action="${baseUrl(`/admin/files/${item.id}/extend`)}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<input name="extendHours" placeholder="Stunden hinzufügen" />
|
|
<button type="submit">Verlängern</button>
|
|
</form>
|
|
<div class="stack">
|
|
<button type="button" class="secondary copy-link" data-path="${fileHref}" data-name="${escapeHtml(item.original_name)}">Link kopieren</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
const body = `
|
|
<header>
|
|
<div>
|
|
<h1>Adminübersicht</h1>
|
|
<div class="muted">Systemstatistiken und Logs</div>
|
|
</div>
|
|
<div class="toolbar">
|
|
<a class="tag primary" href="${baseUrl('/admin/files')}">Dateimanager</a>
|
|
<a class="tag primary" href="${baseUrl('/admin/users')}">Benutzer verwalten</a>
|
|
<form method="post" action="${baseUrl('/admin/logout')}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<button type="submit" class="secondary">Abmelden</button>
|
|
</form>
|
|
</div>
|
|
</header>
|
|
<section class="card">
|
|
<h2>Statistiken</h2>
|
|
${stats}
|
|
</section>
|
|
<section class="card">
|
|
<h2>Letzte Ereignisse</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Zeit</th>
|
|
<th>Event</th>
|
|
<th>Nutzer</th>
|
|
<th>Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${logRows || '<tr><td colspan="4" class="muted">Keine Logs vorhanden.</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Aktuelle Uploads</h2>
|
|
${uploads.length ? `
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Datei</th>
|
|
<th>Größe</th>
|
|
<th>Läuft ab</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rows}
|
|
</tbody>
|
|
</table>
|
|
` : '<div class="muted">Noch keine Uploads.</div>'}
|
|
</section>
|
|
|
|
<script>
|
|
const uploadForm = document.getElementById('upload-form');
|
|
const csrfToken = ${JSON.stringify(res.locals.csrfToken)};
|
|
const progress = document.getElementById('upload-progress');
|
|
const status = document.getElementById('upload-status');
|
|
const copyButtons = document.querySelectorAll('.copy-link');
|
|
|
|
uploadForm.addEventListener('submit', (event) => {
|
|
event.preventDefault();
|
|
status.textContent = '';
|
|
progress.value = 0;
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', ${JSON.stringify(baseUrl('/api/upload'))});
|
|
xhr.setRequestHeader('X-CSRF-Token', csrfToken);
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
progress.value = Math.round((e.loaded / e.total) * 100);
|
|
}
|
|
});
|
|
xhr.addEventListener('load', () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
status.textContent = 'Upload abgeschlossen. Liste wird aktualisiert...';
|
|
window.location.reload();
|
|
} else {
|
|
status.textContent = 'Upload fehlgeschlagen.';
|
|
}
|
|
});
|
|
xhr.addEventListener('error', () => {
|
|
status.textContent = 'Upload fehlgeschlagen.';
|
|
});
|
|
xhr.send(new FormData(uploadForm));
|
|
});
|
|
|
|
copyButtons.forEach((button) => {
|
|
const originalText = button.textContent;
|
|
button.addEventListener('click', async () => {
|
|
const path = button.dataset.path || '';
|
|
const name = button.dataset.name || 'Download';
|
|
const url = window.location.origin + path;
|
|
|
|
try {
|
|
// Try to write rich text (HTML link) + plain text fallback
|
|
const html = \`<a href="\${url}">\${name}</a>\`;
|
|
const blobHtml = new Blob([html], { type: 'text/html' });
|
|
const blobText = new Blob([url], { type: 'text/plain' });
|
|
const data = [new ClipboardItem({
|
|
'text/html': blobHtml,
|
|
'text/plain': blobText,
|
|
})];
|
|
await navigator.clipboard.write(data);
|
|
button.textContent = 'Kopiert!';
|
|
} catch (err) {
|
|
// Fallback to simple text copy if Clipboard Item API fails or is not supported
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
button.textContent = 'Kopiert!';
|
|
} catch (fallbackErr) {
|
|
const helper = document.createElement('textarea');
|
|
helper.value = url;
|
|
document.body.appendChild(helper);
|
|
helper.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(helper);
|
|
button.textContent = 'Kopiert!';
|
|
}
|
|
}
|
|
setTimeout(() => {
|
|
button.textContent = originalText;
|
|
}, 2000);
|
|
});
|
|
});
|
|
</script>
|
|
`;
|
|
res.send(renderPage('Adminübersicht', body, 'wide'));
|
|
});
|
|
|
|
app.get(`${basePath}/admin/users`, requireAdminPage, async (req, res) => {
|
|
const users = await all('SELECT username, created_at FROM users ORDER BY username ASC');
|
|
|
|
const rows = users.map((user) => {
|
|
const username = escapeHtml(user.username);
|
|
const encoded = encodeURIComponent(user.username);
|
|
return `
|
|
<tr>
|
|
<td>${username}</td>
|
|
<td>${formatTimestamp(user.created_at)}</td>
|
|
<td class="actions">
|
|
<form method="post" action="${baseUrl(`/admin/users/${encoded}/reset`)}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<input name="password" type="password" placeholder="Neues Passwort" required />
|
|
<button type="submit">Passwort setzen</button>
|
|
</form>
|
|
<form method="post" action="${baseUrl(`/admin/users/${encoded}/delete`)}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<button type="submit" class="secondary">Löschen</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
const body = `
|
|
<header>
|
|
<div>
|
|
<h1>Benutzer verwalten</h1>
|
|
<div class="muted">Zugänge im System verwalten.</div>
|
|
</div>
|
|
<div class="toolbar">
|
|
<a class="tag" href="${baseUrl('/admin/dashboard')}">Zur Adminübersicht</a>
|
|
<form method="post" action="${baseUrl('/admin/logout')}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<button type="submit" class="secondary">Abmelden</button>
|
|
</form>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="card">
|
|
<h2>Neuen Benutzer anlegen</h2>
|
|
<form method="post" action="${baseUrl('/admin/users/create')}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<label>
|
|
Benutzername
|
|
<input name="username" autocomplete="username" required />
|
|
</label>
|
|
<label>
|
|
Passwort
|
|
<input name="password" type="password" autocomplete="new-password" required />
|
|
</label>
|
|
<button type="submit">Erstellen</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Benutzerliste</h2>
|
|
${users.length ? `
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Benutzername</th>
|
|
<th>Erstellt</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rows}
|
|
</tbody>
|
|
</table>
|
|
` : '<div class="muted">Keine Benutzer vorhanden.</div>'}
|
|
</section>
|
|
`;
|
|
|
|
res.send(renderPage('Benutzer verwalten', body, 'wide'));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/users/create`, requireAdminPage, async (req, res) => {
|
|
const username = String(req.body.username || '').trim();
|
|
const password = String(req.body.password || '');
|
|
if (!username || username.length > 200 || !password) {
|
|
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Ungültige Eingabe.</p>', 'wide'));
|
|
return;
|
|
}
|
|
const hash = bcrypt.hashSync(password, 12);
|
|
try {
|
|
await run('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)', [
|
|
username,
|
|
hash,
|
|
Date.now(),
|
|
]);
|
|
} catch (err) {
|
|
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Benutzername existiert bereits.</p>', 'wide'));
|
|
return;
|
|
}
|
|
await logEvent('admin_user_create', 'admin', { username });
|
|
res.redirect(baseUrl('/admin/users'));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/users/:username/reset`, requireAdminPage, async (req, res) => {
|
|
const username = decodeURIComponent(req.params.username || '');
|
|
const password = String(req.body.password || '');
|
|
if (!username || !password) {
|
|
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Ungültige Eingabe.</p>', 'wide'));
|
|
return;
|
|
}
|
|
const hash = bcrypt.hashSync(password, 12);
|
|
await run('UPDATE users SET password_hash = ? WHERE username = ?', [hash, username]);
|
|
await logEvent('admin_user_reset', 'admin', { username });
|
|
res.redirect(baseUrl('/admin/users'));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/users/:username/delete`, requireAdminPage, async (req, res) => {
|
|
const username = decodeURIComponent(req.params.username || '');
|
|
if (!username) {
|
|
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Ungültige Eingabe.</p>', 'wide'));
|
|
return;
|
|
}
|
|
await run('DELETE FROM users WHERE username = ?', [username]);
|
|
await logEvent('admin_user_delete', 'admin', { username });
|
|
res.redirect(baseUrl('/admin/users'));
|
|
});
|
|
|
|
app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
|
const relativePath = String(req.query.path || '').replace(/^\/+/, '');
|
|
const resolved = resolveAdminPath(relativePath);
|
|
if (!resolved) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
|
|
return;
|
|
}
|
|
|
|
let entries;
|
|
try {
|
|
entries = await fs.promises.readdir(resolved, { withFileTypes: true });
|
|
} catch (err) {
|
|
res.status(500).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ordner kann nicht gelesen werden.</p>'));
|
|
return;
|
|
}
|
|
|
|
const parentPath = relativePath ? relativePath.split('/').slice(0, -1).join('/') : '';
|
|
|
|
const filtered = entries.filter((entry) => entry.name !== '_share');
|
|
const details = await Promise.all(
|
|
filtered.map(async (entry) => {
|
|
const childPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
let stat = null;
|
|
try {
|
|
stat = await fs.promises.stat(path.join(resolved, entry.name));
|
|
} catch (err) {
|
|
stat = null;
|
|
}
|
|
return {
|
|
entry,
|
|
childPath,
|
|
isDir: entry.isDirectory(),
|
|
size: stat && stat.isFile() ? stat.size : null,
|
|
modifiedAt: stat ? stat.mtimeMs : null,
|
|
};
|
|
})
|
|
);
|
|
|
|
const dirs = details.filter((item) => item.isDir);
|
|
const files = details.filter((item) => !item.isDir);
|
|
|
|
const rowForEntry = (item) => {
|
|
const { entry, childPath, isDir, size, modifiedAt } = item;
|
|
const href = baseUrl(`/admin/files?path=${encodeURIComponent(childPath)}`);
|
|
const escapedName = escapeHtml(entry.name);
|
|
const escapedPath = escapeHtml(childPath);
|
|
return `
|
|
<tr>
|
|
<td>
|
|
<span class="name">
|
|
${isDir ? '<strong>DIR</strong>' : 'FILE'}
|
|
${isDir ? `<a class="folder" href="${href}">${escapedName}</a>` : escapedName}
|
|
</span>
|
|
</td>
|
|
<td>${isDir ? 'Ordner' : 'Datei'}</td>
|
|
<td>${size ? formatBytes(size) : '—'}</td>
|
|
<td>${modifiedAt ? formatTimestamp(modifiedAt) : '—'}</td>
|
|
<td class="actions">
|
|
<button type="button" class="secondary rename-trigger" data-path="${escapedPath}" data-name="${escapedName}">Umbenennen</button>
|
|
<button type="button" class="secondary move-trigger" data-path="${escapedPath}" data-name="${escapedName}">Verschieben</button>
|
|
<button type="button" class="secondary copy-trigger" data-path="${escapedPath}" data-name="${escapedName}">Kopieren</button>
|
|
<button type="button" class="secondary delete-trigger" data-path="${escapedPath}" data-name="${escapedName}">Löschen</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
};
|
|
|
|
const tableRows = [
|
|
...dirs.map((entry) => rowForEntry(entry)),
|
|
...files.map((entry) => rowForEntry(entry)),
|
|
].join('');
|
|
|
|
const body = `
|
|
<header>
|
|
<div>
|
|
<h1>Admin-Dateimanager</h1>
|
|
<div class="muted">Verwalten aller Dateien (außer _share).</div>
|
|
</div>
|
|
<div class="toolbar">
|
|
<a class="tag" href="${baseUrl('/admin/dashboard')}">Zur Adminübersicht</a>
|
|
<form method="post" action="${baseUrl('/admin/logout')}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<button type="submit" class="secondary">Abmelden</button>
|
|
</form>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="card browser-shell">
|
|
<div class="browser-bar">
|
|
<span class="tag">Pfad <span>/${escapeHtml(relativePath || '')}</span></span>
|
|
${relativePath ? `<a class="tag primary" href="${baseUrl(`/admin/files?path=${encodeURIComponent(parentPath)}`)}">← Zurück</a>` : ''}
|
|
<div class="crumbs">
|
|
<span>Position:</span>
|
|
${relativePath ? relativePath.split('/').map((segment, idx, parts) => {
|
|
const crumbPath = parts.slice(0, idx + 1).join('/');
|
|
return `<a href="${baseUrl(`/admin/files?path=${encodeURIComponent(crumbPath)}`)}">${escapeHtml(segment)}</a>`;
|
|
}).join('<span>/</span>') : '<span>Root</span>'}
|
|
</div>
|
|
</div>
|
|
<div class="grid">
|
|
<div>
|
|
<h2>Ordner erstellen</h2>
|
|
<form method="post" action="${baseUrl('/admin/files/mkdir')}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<input type="hidden" name="path" value="${escapeHtml(relativePath)}" />
|
|
<label>
|
|
Ordnername
|
|
<input name="name" placeholder="z.B. Projekte" required />
|
|
</label>
|
|
<button type="submit">Erstellen</button>
|
|
</form>
|
|
</div>
|
|
<div>
|
|
<h2>Datei hochladen</h2>
|
|
<form method="post" action="${baseUrl(`/admin/files/upload?csrfToken=${encodeURIComponent(res.locals.csrfToken)}`)}" enctype="multipart/form-data">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<input type="hidden" name="path" value="${escapeHtml(relativePath)}" />
|
|
<label>
|
|
Datei
|
|
<input type="file" name="file" required />
|
|
</label>
|
|
<button type="submit">Hochladen</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Inhalt</h2>
|
|
${tableRows ? `
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Typ</th>
|
|
<th>Größe</th>
|
|
<th>Geändert</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${tableRows}
|
|
</tbody>
|
|
</table>
|
|
` : '<div class="muted">Keine Eintraege in diesem Ordner.</div>'}
|
|
</section>
|
|
|
|
<dialog id="rename-dialog">
|
|
<form method="post" action="${baseUrl('/admin/files/rename')}" class="dialog-card">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<input type="hidden" name="path" id="rename-path" />
|
|
<h2>Umbenennen</h2>
|
|
<div class="muted" id="rename-current"></div>
|
|
<label>
|
|
Neuer Name
|
|
<input name="newName" id="rename-name" required />
|
|
</label>
|
|
<div class="dialog-actions">
|
|
<button type="button" class="secondary dialog-close">Abbrechen</button>
|
|
<button type="submit">Speichern</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<dialog id="delete-dialog">
|
|
<form method="post" action="${baseUrl('/admin/files/delete')}" class="dialog-card">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<input type="hidden" name="path" id="delete-path" />
|
|
<h2>Löschen</h2>
|
|
<div class="muted" id="delete-current"></div>
|
|
<div class="dialog-actions">
|
|
<button type="button" class="secondary dialog-close">Abbrechen</button>
|
|
<button type="submit">Löschen</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<dialog id="move-dialog">
|
|
<form method="post" action="${baseUrl('/admin/files/move')}" class="dialog-card">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<input type="hidden" name="path" id="move-path" />
|
|
<h2>Verschieben</h2>
|
|
<div class="muted" id="move-current"></div>
|
|
<label>
|
|
Zielpfad (relativ)
|
|
<input name="targetPath" id="move-target" placeholder="z.B. archiv/ordner" required />
|
|
</label>
|
|
<div class="dialog-actions">
|
|
<button type="button" class="secondary dialog-close">Abbrechen</button>
|
|
<button type="submit">Verschieben</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<dialog id="copy-dialog">
|
|
<form method="post" action="${baseUrl('/admin/files/copy')}" class="dialog-card">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<input type="hidden" name="path" id="copy-path" />
|
|
<h2>Kopieren</h2>
|
|
<div class="muted" id="copy-current"></div>
|
|
<label>
|
|
Zielpfad (relativ)
|
|
<input name="targetPath" id="copy-target" placeholder="z.B. archiv/ordner" required />
|
|
</label>
|
|
<div class="dialog-actions">
|
|
<button type="button" class="secondary dialog-close">Abbrechen</button>
|
|
<button type="submit">Kopieren</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<script>
|
|
const renameDialog = document.getElementById('rename-dialog');
|
|
const deleteDialog = document.getElementById('delete-dialog');
|
|
const moveDialog = document.getElementById('move-dialog');
|
|
const copyDialog = document.getElementById('copy-dialog');
|
|
const renamePath = document.getElementById('rename-path');
|
|
const renameName = document.getElementById('rename-name');
|
|
const renameCurrent = document.getElementById('rename-current');
|
|
const deletePath = document.getElementById('delete-path');
|
|
const deleteCurrent = document.getElementById('delete-current');
|
|
const movePath = document.getElementById('move-path');
|
|
const moveTarget = document.getElementById('move-target');
|
|
const moveCurrent = document.getElementById('move-current');
|
|
const copyPath = document.getElementById('copy-path');
|
|
const copyTarget = document.getElementById('copy-target');
|
|
const copyCurrent = document.getElementById('copy-current');
|
|
|
|
document.querySelectorAll('.rename-trigger').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
renamePath.value = btn.dataset.path || '';
|
|
renameName.value = btn.dataset.name || '';
|
|
renameCurrent.textContent = 'Aktuell: ' + (btn.dataset.name || '');
|
|
renameDialog.showModal();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.move-trigger').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
movePath.value = btn.dataset.path || '';
|
|
moveTarget.value = '';
|
|
moveCurrent.textContent = 'Quelle: ' + (btn.dataset.name || '');
|
|
moveDialog.showModal();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.copy-trigger').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
copyPath.value = btn.dataset.path || '';
|
|
copyTarget.value = '';
|
|
copyCurrent.textContent = 'Quelle: ' + (btn.dataset.name || '');
|
|
copyDialog.showModal();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.delete-trigger').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
deletePath.value = btn.dataset.path || '';
|
|
deleteCurrent.textContent = 'Datei/Ordner: ' + (btn.dataset.name || '');
|
|
deleteDialog.showModal();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.dialog-close').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
renameDialog.close();
|
|
deleteDialog.close();
|
|
moveDialog.close();
|
|
copyDialog.close();
|
|
});
|
|
});
|
|
</script>
|
|
`;
|
|
|
|
res.send(renderFileManagerPage('Admin-Dateien', body));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/files/mkdir`, requireAdminPage, async (req, res) => {
|
|
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
|
|
const name = String(req.body.name || '').trim();
|
|
if (!name || name.includes('/') || name.includes('\\') || name === '_share') {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Ordnername.</p>'));
|
|
return;
|
|
}
|
|
const base = resolveAdminPath(relativePath);
|
|
if (!base) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
|
|
return;
|
|
}
|
|
const target = path.join(base, name);
|
|
await fs.promises.mkdir(target, { recursive: true });
|
|
await logEvent('admin_mkdir', 'admin', { path: path.join(relativePath, name) });
|
|
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(relativePath)}`));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/files/upload`, requireAdminPage, upload.single('file'), async (req, res) => {
|
|
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
|
|
const base = resolveAdminPath(relativePath);
|
|
if (!base) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
|
|
return;
|
|
}
|
|
if (!req.file) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Keine Datei hochgeladen.</p>'));
|
|
return;
|
|
}
|
|
const filename = path.basename(req.file.originalname);
|
|
if (filename === '_share') {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Dateiname.</p>'));
|
|
return;
|
|
}
|
|
const target = path.join(base, filename);
|
|
try {
|
|
await fs.promises.rename(req.file.path, target);
|
|
} catch (err) {
|
|
if (err.code === 'EXDEV') {
|
|
await fs.promises.copyFile(req.file.path, target);
|
|
await fs.promises.unlink(req.file.path);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
await logEvent('admin_upload', 'admin', { path: path.join(relativePath, filename) });
|
|
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(relativePath)}`));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/files/rename`, requireAdminPage, async (req, res) => {
|
|
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
|
|
const newName = String(req.body.newName || '').trim();
|
|
if (!relativePath) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Root kann nicht umbenannt werden.</p>'));
|
|
return;
|
|
}
|
|
if (!newName || newName.includes('/') || newName.includes('\\') || newName === '_share') {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger neuer Name.</p>'));
|
|
return;
|
|
}
|
|
const resolved = resolveAdminPath(relativePath);
|
|
if (!resolved) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
|
|
return;
|
|
}
|
|
const target = path.join(path.dirname(resolved), newName);
|
|
await fs.promises.rename(resolved, target);
|
|
await logEvent('admin_rename', 'admin', { from: relativePath, to: path.join(path.dirname(relativePath), newName) });
|
|
const parent = path.dirname(relativePath);
|
|
const nextPath = parent === '.' ? '' : parent;
|
|
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/files/delete`, requireAdminPage, async (req, res) => {
|
|
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
|
|
if (!relativePath) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Root kann nicht gelöscht werden.</p>'));
|
|
return;
|
|
}
|
|
const resolved = resolveAdminPath(relativePath);
|
|
if (!resolved) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
|
|
return;
|
|
}
|
|
await fs.promises.rm(resolved, { recursive: true, force: true });
|
|
await logEvent('admin_delete', 'admin', { path: relativePath });
|
|
const parent = path.dirname(relativePath);
|
|
const nextPath = parent === '.' ? '' : parent;
|
|
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/files/move`, requireAdminPage, async (req, res) => {
|
|
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
|
|
const targetPath = String(req.body.targetPath || '').replace(/^\/+/, '');
|
|
if (!relativePath || !targetPath) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültige Eingabe.</p>'));
|
|
return;
|
|
}
|
|
const source = resolveAdminPath(relativePath);
|
|
const targetBase = resolveAdminPath(targetPath);
|
|
if (!source || !targetBase) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
|
|
return;
|
|
}
|
|
|
|
let target = targetBase;
|
|
try {
|
|
const stat = await fs.promises.stat(targetBase);
|
|
if (stat.isDirectory()) {
|
|
target = path.join(targetBase, path.basename(source));
|
|
}
|
|
} catch (err) {
|
|
// targetBase does not exist; treat as file/dir path.
|
|
}
|
|
|
|
try {
|
|
await fs.promises.rename(source, target);
|
|
} catch (err) {
|
|
if (err.code === 'EXDEV') {
|
|
await fs.promises.cp(source, target, { recursive: true, force: false });
|
|
await fs.promises.rm(source, { recursive: true, force: true });
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
await logEvent('admin_move', 'admin', { from: relativePath, to: targetPath });
|
|
res.redirect(baseUrl('/admin/files'));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/files/copy`, requireAdminPage, async (req, res) => {
|
|
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
|
|
const targetPath = String(req.body.targetPath || '').replace(/^\/+/, '');
|
|
if (!relativePath || !targetPath) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültige Eingabe.</p>'));
|
|
return;
|
|
}
|
|
const source = resolveAdminPath(relativePath);
|
|
const targetBase = resolveAdminPath(targetPath);
|
|
if (!source || !targetBase) {
|
|
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
|
|
return;
|
|
}
|
|
|
|
let target = targetBase;
|
|
try {
|
|
const stat = await fs.promises.stat(targetBase);
|
|
if (stat.isDirectory()) {
|
|
target = path.join(targetBase, path.basename(source));
|
|
}
|
|
} catch (err) {
|
|
// targetBase does not exist; treat as file/dir path.
|
|
}
|
|
|
|
await fs.promises.cp(source, target, { recursive: true, force: false });
|
|
await logEvent('admin_copy', 'admin', { from: relativePath, to: targetPath });
|
|
res.redirect(baseUrl('/admin/files'));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/files/:id/delete`, requireAdminPage, async (req, res) => {
|
|
const uploadEntry = await get('SELECT id, stored_path FROM uploads WHERE id = ?', [req.params.id]);
|
|
if (!uploadEntry) {
|
|
res.status(404).send(renderPage('Nicht gefunden', '<p class="card">Upload nicht gefunden.</p>'));
|
|
return;
|
|
}
|
|
try {
|
|
await fs.promises.unlink(uploadEntry.stored_path);
|
|
} catch (err) {
|
|
// Ignore missing files.
|
|
}
|
|
await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]);
|
|
await logEvent('delete', 'admin', { id: uploadEntry.id });
|
|
res.redirect(baseUrl('/admin/dashboard'));
|
|
});
|
|
|
|
app.post(`${basePath}/admin/files/:id/extend`, requireAdminPage, async (req, res) => {
|
|
const uploadEntry = await get('SELECT id, expires_at FROM uploads WHERE id = ?', [req.params.id]);
|
|
if (!uploadEntry) {
|
|
res.status(404).send(renderPage('Nicht gefunden', '<p class="card">Upload nicht gefunden.</p>'));
|
|
return;
|
|
}
|
|
|
|
const override = parseFloat(req.body.extendHours || '');
|
|
const extensionSeconds = Number.isFinite(override) && override > 0
|
|
? Math.round(override * 3600)
|
|
: uploadTtlSeconds;
|
|
|
|
const now = Date.now();
|
|
const base = Math.max(uploadEntry.expires_at, now);
|
|
const maxExpiry = now + maxRetentionSeconds * 1000;
|
|
const nextExpiry = Math.min(base + 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 });
|
|
res.redirect(baseUrl('/admin/dashboard'));
|
|
});
|
|
|
|
app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
|
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',
|
|
[req.user.username]
|
|
);
|
|
|
|
const rows = uploads.map((item) => {
|
|
const fileUrl = `/_share/${item.stored_name}`;
|
|
const fileHref = encodeURI(fileUrl);
|
|
return `
|
|
<tr>
|
|
<td class="truncate">
|
|
<div class="truncate" title="${escapeHtml(item.original_name)}"><strong>${escapeHtml(item.original_name)}</strong></div>
|
|
<div class="muted truncate"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div>
|
|
</td>
|
|
<td>${formatBytes(item.size_bytes)}</td>
|
|
<td>
|
|
<div>${formatTimestamp(item.expires_at)}</div>
|
|
<div class="muted">Noch ${formatCountdown(item.expires_at)}</div>
|
|
</td>
|
|
<td class="actions">
|
|
<form method="post" action="${baseUrl(`/files/${item.id}/delete`)}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<button type="submit" class="secondary">Löschen</button>
|
|
</form>
|
|
<form method="post" action="${baseUrl(`/files/${item.id}/extend`)}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<input name="extendHours" placeholder="Stunden hinzufügen" />
|
|
<button type="submit">Verlängern</button>
|
|
</form>
|
|
<div class="stack">
|
|
<button type="button" class="secondary copy-link" data-path="${fileHref}" data-name="${escapeHtml(item.original_name)}">Link kopieren</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
const body = `
|
|
<header>
|
|
<div>
|
|
<h1>Dateiverwaltung</h1>
|
|
<div class="muted">Angemeldet als ${escapeHtml(req.user.username)}</div>
|
|
</div>
|
|
<form method="post" action="${baseUrl('/logout')}">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<button type="submit" class="secondary">Abmelden</button>
|
|
</form>
|
|
</header>
|
|
|
|
<section class="card">
|
|
<h2>Datei hochladen</h2>
|
|
<form id="upload-form">
|
|
${csrfField(res.locals.csrfToken)}
|
|
<label>
|
|
Datei
|
|
<input type="file" name="file" required />
|
|
</label>
|
|
<label>
|
|
Aufbewahrung (Stunden)
|
|
<input name="retentionHours" placeholder="${uploadTtlSeconds / 3600}" />
|
|
</label>
|
|
<button type="submit">Hochladen</button>
|
|
<progress id="upload-progress" value="0" max="100"></progress>
|
|
<div id="upload-status" class="muted"></div>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Aktuelle Uploads</h2>
|
|
${uploads.length ? `
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Datei</th>
|
|
<th>Größe</th>
|
|
<th>Läuft ab</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rows}
|
|
</tbody>
|
|
</table>
|
|
` : '<div class="muted">Noch keine Uploads.</div>'}
|
|
</section>
|
|
|
|
<script>
|
|
const uploadForm = document.getElementById('upload-form');
|
|
const csrfToken = ${JSON.stringify(res.locals.csrfToken)};
|
|
const progress = document.getElementById('upload-progress');
|
|
const status = document.getElementById('upload-status');
|
|
const copyButtons = document.querySelectorAll('.copy-link');
|
|
uploadForm.addEventListener('submit', (event) => {
|
|
event.preventDefault();
|
|
status.textContent = '';
|
|
progress.value = 0;
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', ${JSON.stringify(baseUrl('/api/upload'))});
|
|
xhr.setRequestHeader('X-CSRF-Token', csrfToken);
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
progress.value = Math.round((e.loaded / e.total) * 100);
|
|
}
|
|
});
|
|
xhr.addEventListener('load', () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
status.textContent = 'Upload abgeschlossen. Liste wird aktualisiert...';
|
|
window.location.reload();
|
|
} else {
|
|
status.textContent = 'Upload fehlgeschlagen.';
|
|
}
|
|
});
|
|
xhr.addEventListener('error', () => {
|
|
status.textContent = 'Upload fehlgeschlagen.';
|
|
});
|
|
xhr.send(new FormData(uploadForm));
|
|
});
|
|
|
|
copyButtons.forEach((button) => {
|
|
const originalText = button.textContent;
|
|
button.addEventListener('click', async () => {
|
|
const path = button.dataset.path || '';
|
|
const name = button.dataset.name || 'Download';
|
|
const url = window.location.origin + path;
|
|
|
|
try {
|
|
// Try to write rich text (HTML link) + plain text fallback
|
|
const html = \`<a href="\${url}">\${name}</a>\`;
|
|
const blobHtml = new Blob([html], { type: 'text/html' });
|
|
const blobText = new Blob([url], { type: 'text/plain' });
|
|
const data = [new ClipboardItem({
|
|
'text/html': blobHtml,
|
|
'text/plain': blobText,
|
|
})];
|
|
await navigator.clipboard.write(data);
|
|
button.textContent = 'Kopiert!';
|
|
} catch (err) {
|
|
// Fallback to simple text copy if Clipboard Item API fails or is not supported
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
button.textContent = 'Kopiert!';
|
|
} catch (fallbackErr) {
|
|
const helper = document.createElement('textarea');
|
|
helper.value = url;
|
|
document.body.appendChild(helper);
|
|
helper.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(helper);
|
|
button.textContent = 'Kopiert!';
|
|
}
|
|
}
|
|
setTimeout(() => {
|
|
button.textContent = originalText;
|
|
}, 2000);
|
|
});
|
|
});
|
|
</script>
|
|
`;
|
|
|
|
res.send(renderPage('Übersicht', body));
|
|
});
|
|
|
|
app.post(`${basePath}/api/upload`, requireAuthApi, upload.single('file'), async (req, res) => {
|
|
if (!req.file) {
|
|
res.status(400).json({ error: 'No file uploaded' });
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
// Don't sanitize rigorously anymore, we rely on content-disposition header for safety
|
|
// but we still want a safe filename for storage
|
|
const ext = path.extname(req.file.originalname);
|
|
const token = createRandomId();
|
|
// We keep the original filename in the DB but store it with a safe ID on disk
|
|
const storedName = `${token}${ext}`;
|
|
const storedPath = path.join(shareDir, storedName);
|
|
|
|
const retentionOverride = parseFloat(req.body.retentionHours || '');
|
|
const retentionSeconds = Number.isFinite(retentionOverride) && retentionOverride > 0
|
|
? Math.round(retentionOverride * 3600)
|
|
: uploadTtlSeconds;
|
|
const cappedRetention = Math.min(retentionSeconds, maxRetentionSeconds);
|
|
|
|
try {
|
|
await fs.promises.rename(req.file.path, storedPath);
|
|
} catch (err) {
|
|
if (err.code === 'EXDEV') {
|
|
await fs.promises.copyFile(req.file.path, storedPath);
|
|
await fs.promises.unlink(req.file.path);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
await run(
|
|
`INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
req.user.username,
|
|
req.file.originalname, // Store exact original name
|
|
storedName,
|
|
storedPath,
|
|
req.file.size,
|
|
now,
|
|
now + cappedRetention * 1000,
|
|
]
|
|
);
|
|
await logEvent('upload', req.user.username, { name: storedName, size: req.file.size });
|
|
|
|
res.json({ ok: true, name: storedName });
|
|
});
|
|
|
|
app.post(`${basePath}/files/:id/delete`, requireAuthPage, async (req, res) => {
|
|
const uploadEntry = await get('SELECT id, stored_path FROM uploads WHERE id = ? AND owner = ?', [
|
|
req.params.id,
|
|
req.user.username,
|
|
]);
|
|
if (!uploadEntry) {
|
|
res.status(404).send(renderPage('Nicht gefunden', '<p class="card">Upload nicht gefunden.</p>'));
|
|
return;
|
|
}
|
|
try {
|
|
await fs.promises.unlink(uploadEntry.stored_path);
|
|
} catch (err) {
|
|
// Ignore missing files.
|
|
}
|
|
await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]);
|
|
await logEvent('delete', req.user.username, { id: uploadEntry.id });
|
|
res.redirect(baseUrl('/dashboard'));
|
|
});
|
|
|
|
app.post(`${basePath}/files/:id/extend`, requireAuthPage, async (req, res) => {
|
|
const uploadEntry = await get('SELECT id, expires_at FROM uploads WHERE id = ? AND owner = ?', [
|
|
req.params.id,
|
|
req.user.username,
|
|
]);
|
|
if (!uploadEntry) {
|
|
res.status(404).send(renderPage('Nicht gefunden', '<p class="card">Upload nicht gefunden.</p>'));
|
|
return;
|
|
}
|
|
|
|
const override = parseFloat(req.body.extendHours || '');
|
|
const extensionSeconds = Number.isFinite(override) && override > 0
|
|
? Math.round(override * 3600)
|
|
: uploadTtlSeconds;
|
|
|
|
const now = Date.now();
|
|
const base = Math.max(uploadEntry.expires_at, now);
|
|
const maxExpiry = now + maxRetentionSeconds * 1000;
|
|
const nextExpiry = Math.min(base + extensionSeconds * 1000, maxExpiry);
|
|
await run('UPDATE uploads SET expires_at = ? WHERE id = ?', [nextExpiry, uploadEntry.id]);
|
|
await logEvent('extend', req.user.username, { id: uploadEntry.id, expires_at: nextExpiry });
|
|
res.redirect(baseUrl('/dashboard'));
|
|
});
|
|
|
|
app.get('/_share/:filename', async (req, res) => {
|
|
const filename = req.params.filename;
|
|
// Security check: ensure no path traversal
|
|
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
|
|
res.status(400).send('Invalid filename');
|
|
return;
|
|
}
|
|
|
|
const row = await get('SELECT id, original_name, stored_path FROM uploads WHERE stored_name = ?', [filename]);
|
|
|
|
if (!row) {
|
|
// If not found in DB, check if it exists on disk (legacy or manual files)
|
|
const filePath = path.join(shareDir, filename);
|
|
if (fs.existsSync(filePath)) {
|
|
// Log download for legacy/manual files if needed, or just skip
|
|
res.download(filePath, filename); // Fallback: download with stored name
|
|
return;
|
|
}
|
|
res.status(404).send('File not found');
|
|
return;
|
|
}
|
|
|
|
// Log download
|
|
run('UPDATE uploads SET downloads = downloads + 1 WHERE id = ?', [row.id]).catch(() => undefined);
|
|
logEvent('download', null, { name: filename, original: row.original_name }).catch(() => undefined);
|
|
|
|
res.download(row.stored_path, row.original_name);
|
|
});
|
|
|
|
app.use((req, res) => {
|
|
res.status(404).send(renderPage('Nicht gefunden', '<p class="card">Seite nicht gefunden.</p>'));
|
|
});
|
|
|
|
const server = app.listen(port, () => {
|
|
console.log(`Express server listening on ${port} with base path ${basePath}`);
|
|
});
|
|
|
|
function shutdown(signal) {
|
|
clearInterval(cleanupTimer);
|
|
server.close(() => {
|
|
db.close(() => {
|
|
console.log(`Shutdown complete (${signal}).`);
|
|
process.exit(0);
|
|
});
|
|
});
|
|
setTimeout(() => {
|
|
console.error('Forced shutdown after timeout.');
|
|
process.exit(1);
|
|
}, 5000).unref();
|
|
}
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|