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 = ` +Admin-Zugang ist nicht konfiguriert.
')); + return; + } + const password = String(req.body.password || ''); + if (!bcrypt.compareSync(password, adminHash)) { + const body = ` +| Zeit | +Event | +Nutzer | +Details | +
|---|---|---|---|
| Keine Logs vorhanden. | |||