added an admin dashboard

This commit is contained in:
Ludwig Lehnert
2026-01-12 18:22:56 +01:00
parent a79da3ffce
commit 2aa23f7b5b
3 changed files with 195 additions and 3 deletions

View File

@@ -2,3 +2,4 @@ SERVICE_FQDN=files.example.com
LETSENCRYPT_EMAIL=user@example.com LETSENCRYPT_EMAIL=user@example.com
DATA_DIR=/storagebox DATA_DIR=/storagebox
UPLOAD_TTL_SECONDS=604800 UPLOAD_TTL_SECONDS=604800
MANAGEMENT_ADMIN_HASH=

View File

@@ -67,6 +67,7 @@ services:
- DB_PATH=/app/data/uploads.sqlite - DB_PATH=/app/data/uploads.sqlite
- LOGIN_FILE=/app/.logins - LOGIN_FILE=/app/.logins
- UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS} - UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS}
- MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH}
- PORT=3000 - PORT=3000
volumes: volumes:

View File

@@ -18,6 +18,7 @@ const port = parseInt(process.env.PORT || '3000', 10);
const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uploads.sqlite'); const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uploads.sqlite');
const loginFile = process.env.LOGIN_FILE || path.join(__dirname, '..', '..', '.logins'); 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 uploadTtlSeconds = parseInt(process.env.UPLOAD_TTL_SECONDS || '604800', 10);
const maxUploadBytes = parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10); const maxUploadBytes = parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10);
const shareDir = path.join(dataDir, '_share'); 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_owner_idx ON uploads(owner)');
db.run('CREATE INDEX IF NOT EXISTS uploads_expires_idx ON uploads(expires_at)'); 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 = []) { 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) { function parseLogins(contents) {
const entries = new Map(); const entries = new Map();
const lines = contents.split(/\r?\n/); const lines = contents.split(/\r?\n/);
@@ -229,6 +247,7 @@ function renderPage(title, body) {
input { border: 1px solid var(--line); background: #fff; } input { border: 1px solid var(--line); background: #fff; }
button { border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; } button { border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
button.secondary { background: transparent; color: var(--accent); } 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; } 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; } 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); } progress { width: 100%; height: 12px; accent-color: var(--accent); }
@@ -258,7 +277,7 @@ function getUserFromRequest(req) {
if (!payload?.sub) { if (!payload?.sub) {
return null; return null;
} }
return { username: payload.sub }; return { username: payload.sub, admin: Boolean(payload.admin) };
} catch (err) { } catch (err) {
return null; return null;
} }
@@ -275,6 +294,17 @@ function requireAuthPage(req, res, next) {
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) { function requireAuthApi(req, res, next) {
const user = getUserFromRequest(req); const user = getUserFromRequest(req);
if (!user) { if (!user) {
@@ -289,6 +319,7 @@ function requireAuthApi(req, res, next) {
async function cleanupExpired() { async function cleanupExpired() {
const now = Date.now(); const now = Date.now();
const expired = await all('SELECT id, stored_path FROM uploads WHERE expires_at <= ?', [now]); const expired = await all('SELECT id, stored_path FROM uploads WHERE expires_at <= ?', [now]);
let removed = 0;
for (const entry of expired) { for (const entry of expired) {
try { try {
await fs.promises.unlink(entry.stored_path); await fs.promises.unlink(entry.stored_path);
@@ -296,6 +327,10 @@ async function cleanupExpired() {
// File might already be gone. // File might already be gone.
} }
await run('DELETE FROM uploads WHERE id = ?', [entry.id]); 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, maxAge: jwtMaxAgeMs,
secure: process.env.COOKIE_SECURE === 'true', secure: process.env.COOKIE_SECURE === 'true',
}); });
await logEvent('login', username, { ok: true });
res.redirect(baseUrl('/dashboard')); res.redirect(baseUrl('/dashboard'));
}); });
@@ -391,6 +427,153 @@ app.post(`${basePath}/logout`, (req, res) => {
res.redirect(baseUrl('/login')); 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')}">
<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`, 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')}">
<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',
});
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 = `
<div class="row">
<div class="card"><strong>Aktive Uploads</strong><div class="muted">${activeCount.count}</div></div>
<div class="card"><strong>Aktive Größe</strong><div class="muted">${formatBytes(activeBytes.total)}</div></div>
<div class="card"><strong>Aktive Nutzer</strong><div class="muted">${distinctOwners.count}</div></div>
<div class="card"><strong>Uploads gesamt</strong><div class="muted">${totalUploads.count}</div></div>
<div class="card"><strong>Löschungen gesamt</strong><div class="muted">${totalDeletes.count}</div></div>
<div class="card"><strong>Letztes Cleanup</strong><div class="muted">${lastCleanup.ts ? formatTimestamp(lastCleanup.ts) : '—'}</div></div>
</div>
`;
const logRows = recentLogs.map((entry) => `
<tr>
<td>${formatTimestamp(entry.created_at)}</td>
<td>${entry.event}</td>
<td>${entry.owner || '—'}</td>
<td>${entry.detail || ''}</td>
</tr>
`).join('');
const body = `
<header>
<div>
<h1>Adminübersicht</h1>
<div class="muted">Systemstatistiken und Logs</div>
</div>
<form method="post" action="${baseUrl('/admin/logout')}">
<button type="submit" class="secondary">Abmelden</button>
</form>
</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>
`;
res.send(renderPage('Adminübersicht', body));
});
app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => { app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
const uploads = await all( 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', '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) => { copyButtons.forEach((button) => {
const originalText = button.textContent;
button.addEventListener('click', async () => { button.addEventListener('click', async () => {
const path = button.dataset.path || ''; const path = button.dataset.path || '';
const url = window.location.origin + path; const url = window.location.origin + path;
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
status.textContent = 'Link kopiert.'; button.textContent = 'Kopiert!';
} catch (err) { } catch (err) {
const helper = document.createElement('textarea'); const helper = document.createElement('textarea');
helper.value = url; helper.value = url;
@@ -515,8 +699,11 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
helper.select(); helper.select();
document.execCommand('copy'); document.execCommand('copy');
document.body.removeChild(helper); document.body.removeChild(helper);
status.textContent = 'Link kopiert.'; button.textContent = 'Kopiert!';
} }
setTimeout(() => {
button.textContent = originalText;
}, 2000);
}); });
}); });
</script> </script>
@@ -567,6 +754,7 @@ app.post(`${basePath}/api/upload`, requireAuthApi, upload.single('file'), async
now + retentionSeconds * 1000, now + retentionSeconds * 1000,
] ]
); );
await logEvent('upload', req.user.username, { name: storedName, size: req.file.size });
res.json({ ok: true, name: storedName }); res.json({ ok: true, name: storedName });
}); });
@@ -586,6 +774,7 @@ app.post(`${basePath}/files/:id/delete`, requireAuthPage, async (req, res) => {
// Ignore missing files. // Ignore missing files.
} }
await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]); await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]);
await logEvent('delete', req.user.username, { id: uploadEntry.id });
res.redirect(baseUrl('/dashboard')); 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 base = Math.max(uploadEntry.expires_at, Date.now());
const nextExpiry = base + extensionSeconds * 1000; const nextExpiry = base + extensionSeconds * 1000;
await run('UPDATE uploads SET expires_at = ? WHERE id = ?', [nextExpiry, uploadEntry.id]); 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')); res.redirect(baseUrl('/dashboard'));
}); });