added an admin dashboard
This commit is contained in:
@@ -2,3 +2,4 @@ SERVICE_FQDN=files.example.com
|
||||
LETSENCRYPT_EMAIL=user@example.com
|
||||
DATA_DIR=/storagebox
|
||||
UPLOAD_TTL_SECONDS=604800
|
||||
MANAGEMENT_ADMIN_HASH=
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user