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

@@ -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', '<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) => {
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);
});
});
</script>
@@ -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'));
});