diff --git a/.env.example b/.env.example index 1a4a08d..a6e76a2 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ SERVICE_FQDN=files.example.com LETSENCRYPT_EMAIL=user@example.com DATA_DIR=/storagebox UPLOAD_TTL_SECONDS=604800 +MANAGEMENT_ADMIN_HASH= diff --git a/docker-compose.yml b/docker-compose.yml index 078aa12..145ca0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,6 +67,7 @@ services: - DB_PATH=/app/data/uploads.sqlite - LOGIN_FILE=/app/.logins - UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS} + - MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH} - PORT=3000 volumes: diff --git a/expressjs/src/server.js b/expressjs/src/server.js index 70d7718..4151885 100644 --- a/expressjs/src/server.js +++ b/expressjs/src/server.js @@ -18,6 +18,7 @@ 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 loginFile = process.env.LOGIN_FILE || path.join(__dirname, '..', '..', '.logins'); +const adminHash = process.env.MANAGEMENT_ADMIN_HASH || ''; const uploadTtlSeconds = parseInt(process.env.UPLOAD_TTL_SECONDS || '604800', 10); const maxUploadBytes = parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10); const shareDir = path.join(dataDir, '_share'); @@ -54,6 +55,15 @@ db.serialize(() => { )`); 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('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)'); }); function run(sql, params = []) { @@ -92,6 +102,14 @@ function all(sql, params = []) { }); } +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); +} + function parseLogins(contents) { const entries = new Map(); const lines = contents.split(/\r?\n/); @@ -229,6 +247,7 @@ function renderPage(title, body) { input { border: 1px solid var(--line); background: #fff; } button { border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; } button.secondary { background: transparent; color: var(--accent); } + .row { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); } table { width: 100%; border-collapse: collapse; font-size: 0.95rem; } th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--line); vertical-align: top; } progress { width: 100%; height: 12px; accent-color: var(--accent); } @@ -258,7 +277,7 @@ function getUserFromRequest(req) { if (!payload?.sub) { return null; } - return { username: payload.sub }; + return { username: payload.sub, admin: Boolean(payload.admin) }; } catch (err) { return null; } @@ -275,6 +294,17 @@ function requireAuthPage(req, res, next) { 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 requireAuthApi(req, res, next) { const user = getUserFromRequest(req); if (!user) { @@ -289,6 +319,7 @@ function requireAuthApi(req, res, 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); @@ -296,6 +327,10 @@ async function cleanupExpired() { // File might already be gone. } await run('DELETE FROM uploads WHERE id = ?', [entry.id]); + removed += 1; + } + if (removed > 0) { + await logEvent('cleanup', null, { removed }); } } @@ -383,6 +418,7 @@ app.post(`${basePath}/login`, async (req, res) => { maxAge: jwtMaxAgeMs, secure: process.env.COOKIE_SECURE === 'true', }); + await logEvent('login', username, { ok: true }); res.redirect(baseUrl('/dashboard')); }); @@ -391,6 +427,153 @@ app.post(`${basePath}/logout`, (req, res) => { res.redirect(baseUrl('/login')); }); +app.get(`${basePath}/admin`, async (req, res) => { + if (!adminHash) { + res.status(404).send(renderPage('Admin', '

Admin-Zugang ist nicht konfiguriert.

')); + return; + } + const user = getUserFromRequest(req); + if (user?.admin) { + res.redirect(baseUrl('/admin/dashboard')); + return; + } + const body = ` +
+
+

Adminbereich

+
Admin-Passwort eingeben.
+
+
+
+
+ + +
+
+ `; + res.send(renderPage('Admin', body)); +}); + +app.post(`${basePath}/admin/login`, async (req, res) => { + if (!adminHash) { + res.status(404).send(renderPage('Admin', '

Admin-Zugang ist nicht konfiguriert.

')); + return; + } + const password = String(req.body.password || ''); + if (!bcrypt.compareSync(password, adminHash)) { + const body = ` +
+
+

Adminbereich

+
Admin-Passwort eingeben.
+
+
+
+
Anmeldung fehlgeschlagen
+
+ + +
+
+ `; + 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', + }); + 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, + ] = 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'), + ]); + + const stats = ` +
+
Aktive Uploads
${activeCount.count}
+
Aktive Größe
${formatBytes(activeBytes.total)}
+
Aktive Nutzer
${distinctOwners.count}
+
Uploads gesamt
${totalUploads.count}
+
Löschungen gesamt
${totalDeletes.count}
+
Letztes Cleanup
${lastCleanup.ts ? formatTimestamp(lastCleanup.ts) : '—'}
+
+ `; + + const logRows = recentLogs.map((entry) => ` + + ${formatTimestamp(entry.created_at)} + ${entry.event} + ${entry.owner || '—'} + ${entry.detail || ''} + + `).join(''); + + const body = ` +
+
+

Adminübersicht

+
Systemstatistiken und Logs
+
+
+ +
+
+
+

Statistiken

+ ${stats} +
+
+

Letzte Ereignisse

+ + + + + + + + + + + ${logRows || ''} + +
ZeitEventNutzerDetails
Keine Logs vorhanden.
+
+ `; + res.send(renderPage('Adminübersicht', body)); +}); + 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', @@ -502,12 +685,13 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => { }); copyButtons.forEach((button) => { + const originalText = button.textContent; button.addEventListener('click', async () => { const path = button.dataset.path || ''; const url = window.location.origin + path; try { await navigator.clipboard.writeText(url); - status.textContent = 'Link kopiert.'; + button.textContent = 'Kopiert!'; } catch (err) { const helper = document.createElement('textarea'); helper.value = url; @@ -515,8 +699,11 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => { helper.select(); document.execCommand('copy'); document.body.removeChild(helper); - status.textContent = 'Link kopiert.'; + button.textContent = 'Kopiert!'; } + setTimeout(() => { + button.textContent = originalText; + }, 2000); }); }); @@ -567,6 +754,7 @@ app.post(`${basePath}/api/upload`, requireAuthApi, upload.single('file'), async now + retentionSeconds * 1000, ] ); + await logEvent('upload', req.user.username, { name: storedName, size: req.file.size }); res.json({ ok: true, name: storedName }); }); @@ -586,6 +774,7 @@ app.post(`${basePath}/files/:id/delete`, requireAuthPage, async (req, res) => { // 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')); }); @@ -607,6 +796,7 @@ app.post(`${basePath}/files/:id/extend`, requireAuthPage, async (req, res) => { const base = Math.max(uploadEntry.expires_at, Date.now()); const nextExpiry = base + extensionSeconds * 1000; 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')); });