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, '''); } function csrfField(token) { return ``; } 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', '
Origin-Prüfung fehlgeschlagen.
')); 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', 'CSRF-Prüfung fehlgeschlagen.
')); 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 = `Zu viele Anmeldeversuche. Bitte in ${waitMinutes} Minuten erneut versuchen.
Admin-Zugang ist nicht konfiguriert.
')); return; } const user = getUserFromRequest(req); if (user?.admin) { res.redirect(baseUrl('/admin/dashboard')); return; } const body = `Admin-Zugang ist nicht konfiguriert.
')); return; } const password = String(req.body.password || ''); if (!bcrypt.compareSync(password, adminHash)) { const body = `| Aktive Uploads | ${activeCount.count} |
| Aktive Größe | ${formatBytes(activeBytes.total)} |
| Aktive Nutzer | ${distinctOwners.count} |
| Uploads gesamt | ${totalUploads.count} |
| Downloads gesamt | ${await get('SELECT SUM(downloads) as count FROM uploads').then(r => r.count || 0)} |
| Löschungen gesamt | ${totalDeletes.count} |
| Letztes Cleanup | ${lastCleanup.ts ? formatTimestamp(lastCleanup.ts) : '—'} |
| Zeit | Event | Nutzer | Details |
|---|---|---|---|
| Keine Logs vorhanden. | |||
| Datei | Größe | Läuft ab | Aktionen |
|---|
| Benutzername | Erstellt | Aktionen |
|---|
Ungültige Eingabe.
', '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', 'Benutzername existiert bereits.
', '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', 'Ungültige Eingabe.
', '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', 'Ungültige Eingabe.
', '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', 'Ungültiger Pfad.
')); return; } let entries; try { entries = await fs.promises.readdir(resolved, { withFileTypes: true }); } catch (err) { res.status(500).send(renderFileManagerPage('Admin-Dateien', 'Ordner kann nicht gelesen werden.
')); 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 `| Name | Typ | Größe | Geändert | Aktionen |
|---|
Ungültiger Ordnername.
')); return; } const base = resolveAdminPath(relativePath); if (!base) { res.status(400).send(renderFileManagerPage('Admin-Dateien', 'Ungültiger Pfad.
')); 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', 'Ungültiger Pfad.
')); return; } if (!req.file) { res.status(400).send(renderFileManagerPage('Admin-Dateien', 'Keine Datei hochgeladen.
')); return; } const filename = path.basename(req.file.originalname); if (filename === '_share') { res.status(400).send(renderFileManagerPage('Admin-Dateien', 'Ungültiger Dateiname.
')); 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', 'Root kann nicht umbenannt werden.
')); return; } if (!newName || newName.includes('/') || newName.includes('\\') || newName === '_share') { res.status(400).send(renderFileManagerPage('Admin-Dateien', 'Ungültiger neuer Name.
')); return; } const resolved = resolveAdminPath(relativePath); if (!resolved) { res.status(400).send(renderFileManagerPage('Admin-Dateien', 'Ungültiger Pfad.
')); 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', 'Root kann nicht gelöscht werden.
')); return; } const resolved = resolveAdminPath(relativePath); if (!resolved) { res.status(400).send(renderFileManagerPage('Admin-Dateien', 'Ungültiger Pfad.
')); 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', 'Ungültige Eingabe.
')); return; } const source = resolveAdminPath(relativePath); const targetBase = resolveAdminPath(targetPath); if (!source || !targetBase) { res.status(400).send(renderFileManagerPage('Admin-Dateien', 'Ungültiger Pfad.
')); 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', 'Ungültige Eingabe.
')); return; } const source = resolveAdminPath(relativePath); const targetBase = resolveAdminPath(targetPath); if (!source || !targetBase) { res.status(400).send(renderFileManagerPage('Admin-Dateien', 'Ungültiger Pfad.
')); 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', 'Upload nicht gefunden.
')); 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', 'Upload nicht gefunden.
')); 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 `| Datei | Größe | Läuft ab | Aktionen |
|---|
Upload nicht gefunden.
')); 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', 'Upload nicht gefunden.
')); 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', 'Seite nicht gefunden.
')); }); 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'));