regular delta: better upload tracking + UI rehaul

This commit is contained in:
Ludwig Lehnert
2026-02-03 09:50:29 +01:00
parent 6093a13140
commit 22e43c429e
2 changed files with 156 additions and 54 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
.logins .logins
traefik/ traefik/
node_modules/ node_modules/
expressjs/data/

View File

@@ -69,6 +69,8 @@ db.serialize(() => {
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
)`); )`);
db.run('ALTER TABLE uploads ADD COLUMN downloads INTEGER DEFAULT 0', (err) => { /* ignore if column exists */ }); db.run('ALTER TABLE uploads ADD COLUMN downloads INTEGER DEFAULT 0', (err) => { /* ignore if column exists */ });
db.run('ALTER TABLE admin_logs ADD COLUMN ip TEXT', (err) => { /* ignore if column exists */ });
db.run('ALTER TABLE admin_logs ADD COLUMN user_agent TEXT', (err) => { /* ignore if column exists */ });
db.run('CREATE INDEX IF NOT EXISTS admin_logs_event_idx ON admin_logs(event)'); 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)'); db.run('CREATE INDEX IF NOT EXISTS admin_logs_created_idx ON admin_logs(created_at)');
db.run(`CREATE TABLE IF NOT EXISTS users ( db.run(`CREATE TABLE IF NOT EXISTS users (
@@ -114,11 +116,13 @@ function all(sql, params = []) {
}); });
} }
function logEvent(event, owner, detail) { function logEvent(event, owner, detail, req = null) {
const payload = typeof detail === 'string' ? detail : JSON.stringify(detail || {}); const payload = typeof detail === 'string' ? detail : JSON.stringify(detail || {});
const ip = req ? (req.ip || req.socket.remoteAddress) : null;
const userAgent = req ? req.get('User-Agent') : null;
return run( return run(
'INSERT INTO admin_logs (event, owner, detail, created_at) VALUES (?, ?, ?, ?)', 'INSERT INTO admin_logs (event, owner, detail, created_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)',
[event, owner || null, payload, Date.now()] [event, owner || null, payload, Date.now(), ip, userAgent]
).catch(() => undefined); ).catch(() => undefined);
} }
@@ -310,6 +314,25 @@ function formatCountdown(ts) {
return `${minutes}m`; return `${minutes}m`;
} }
function formatDetail(detailJson) {
try {
const obj = JSON.parse(detailJson);
if (!obj || typeof obj !== 'object') return escapeHtml(detailJson);
// Check if empty object
if (Object.keys(obj).length === 0) return '';
let html = '<dl class="detail-list" style="margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 0.5rem; font-size: 0.85rem;">';
for (const [key, val] of Object.entries(obj)) {
html += `<dt style="font-weight: 500; color: var(--text-muted);">${escapeHtml(key)}:</dt><dd style="margin: 0;">${escapeHtml(val)}</dd>`;
}
html += '</dl>';
return html;
} catch (e) {
return escapeHtml(detailJson);
}
}
function renderFileManagerPage(title, body) { function renderFileManagerPage(title, body) {
return `<!doctype html> return `<!doctype html>
<html lang="de"> <html lang="de">
@@ -786,7 +809,7 @@ app.post(`${basePath}/login`, loginRateLimit('user'), async (req, res) => {
secure: process.env.COOKIE_SECURE === 'true', secure: process.env.COOKIE_SECURE === 'true',
}); });
clearLoginAttempts('user', req); clearLoginAttempts('user', req);
await logEvent('login', username, { ok: true }); await logEvent('login', username, { ok: true }, req);
res.redirect(baseUrl('/dashboard')); res.redirect(baseUrl('/dashboard'));
}); });
@@ -863,7 +886,7 @@ app.post(`${basePath}/admin/login`, loginRateLimit('admin'), async (req, res) =>
secure: process.env.COOKIE_SECURE === 'true', secure: process.env.COOKIE_SECURE === 'true',
}); });
clearLoginAttempts('admin', req); clearLoginAttempts('admin', req);
await logEvent('admin_login', 'admin', { ok: true }); await logEvent('admin_login', 'admin', { ok: true }, req);
res.redirect(baseUrl('/admin/dashboard')); res.redirect(baseUrl('/admin/dashboard'));
}); });
@@ -873,6 +896,8 @@ app.post(`${basePath}/admin/logout`, (req, res) => {
}); });
app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => { app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
const [ const [
activeCount, activeCount,
activeBytes, activeBytes,
@@ -882,6 +907,7 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
lastCleanup, lastCleanup,
recentLogs, recentLogs,
allUploads, allUploads,
chartDataRaw
] = await Promise.all([ ] = await Promise.all([
get('SELECT COUNT(*) as count FROM uploads'), get('SELECT COUNT(*) as count FROM uploads'),
get('SELECT COALESCE(SUM(size_bytes), 0) as total FROM uploads'), get('SELECT COALESCE(SUM(size_bytes), 0) as total FROM uploads'),
@@ -889,10 +915,34 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
get('SELECT COUNT(*) as count FROM admin_logs WHERE event = ?', ['upload']), 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 COUNT(*) as count FROM admin_logs WHERE event IN (?, ?)', ['delete', 'cleanup']),
get('SELECT MAX(created_at) as ts FROM admin_logs WHERE event = ?', ['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'), all('SELECT event, owner, detail, created_at, ip, user_agent FROM admin_logs ORDER BY created_at DESC LIMIT 500'),
all('SELECT id, owner, original_name, stored_name, size_bytes, expires_at FROM uploads ORDER BY uploaded_at DESC'), all('SELECT id, owner, original_name, stored_name, size_bytes, expires_at FROM uploads ORDER BY uploaded_at DESC'),
all('SELECT created_at, event FROM admin_logs WHERE created_at > ?', [thirtyDaysAgo])
]); ]);
// Process chart data
const dailyStats = {};
for (let i = 0; i < 30; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split('T')[0];
dailyStats[dateStr] = { upload: 0, download: 0, delete: 0 };
}
for (const row of chartDataRaw) {
const dateStr = new Date(row.created_at).toISOString().split('T')[0];
if (dailyStats[dateStr]) {
if (row.event === 'upload' || row.event === 'admin_upload') dailyStats[dateStr].upload++;
else if (row.event === 'download') dailyStats[dateStr].download++;
else if (row.event === 'delete' || row.event === 'admin_delete') dailyStats[dateStr].delete++;
}
}
const chartLabels = Object.keys(dailyStats).sort();
const chartUploads = chartLabels.map(d => dailyStats[d].upload);
const chartDownloads = chartLabels.map(d => dailyStats[d].download);
const chartDeletes = chartLabels.map(d => dailyStats[d].delete);
const stats = ` const stats = `
<div class="row"> <div class="row">
<table> <table>
@@ -934,8 +984,14 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
<tr> <tr>
<td>${formatTimestamp(entry.created_at)}</td> <td>${formatTimestamp(entry.created_at)}</td>
<td>${escapeHtml(entry.event)}</td> <td>${escapeHtml(entry.event)}</td>
<td>${escapeHtml(entry.owner || '—')}</td> <td>
<td>${escapeHtml(entry.detail || '')}</td> <div>${escapeHtml(entry.owner || '')}</div>
<div class="muted" style="font-size: 0.8rem;">${escapeHtml(entry.ip || '')}</div>
</td>
<td>
${formatDetail(entry.detail)}
${entry.user_agent ? `<div class="muted" style="font-size: 0.75rem; margin-top: 0.25rem; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(entry.user_agent)}">${escapeHtml(entry.user_agent)}</div>` : ''}
</td>
</tr> </tr>
`).join(''); `).join('');
@@ -971,6 +1027,7 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
}).join(''); }).join('');
const body = ` const body = `
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<header> <header>
<div> <div>
<h1>Adminübersicht</h1> <h1>Adminübersicht</h1>
@@ -985,10 +1042,18 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
</form> </form>
</div> </div>
</header> </header>
<div class="grid" style="grid-template-columns: 1fr 1fr; margin-bottom: 1.5rem;">
<section class="card">
<h2>Aktivität (30 Tage)</h2>
<canvas id="activityChart" style="max-height: 250px;"></canvas>
</section>
<section class="card"> <section class="card">
<h2>Statistiken</h2> <h2>Statistiken</h2>
${stats} ${stats}
</section> </section>
</div>
<section class="card"> <section class="card">
<h2>Letzte Ereignisse</h2> <h2>Letzte Ereignisse</h2>
<table> <table>
@@ -996,8 +1061,8 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
<tr> <tr>
<th>Zeit</th> <th>Zeit</th>
<th>Event</th> <th>Event</th>
<th>Nutzer</th> <th>Nutzer / IP</th>
<th>Details</th> <th>Details / User-Agent</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1033,6 +1098,7 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
const status = document.getElementById('upload-status'); const status = document.getElementById('upload-status');
const copyButtons = document.querySelectorAll('.copy-link'); const copyButtons = document.querySelectorAll('.copy-link');
if (uploadForm) {
uploadForm.addEventListener('submit', (event) => { uploadForm.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
status.textContent = ''; status.textContent = '';
@@ -1058,6 +1124,7 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
}); });
xhr.send(new FormData(uploadForm)); xhr.send(new FormData(uploadForm));
}); });
}
copyButtons.forEach((button) => { copyButtons.forEach((button) => {
const originalText = button.textContent; const originalText = button.textContent;
@@ -1067,7 +1134,6 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
const url = window.location.origin + path; const url = window.location.origin + path;
try { try {
// Try to write rich text (HTML link) + plain text fallback
const html = \`<a href="\${url}">\${name}</a>\`; const html = \`<a href="\${url}">\${name}</a>\`;
const blobHtml = new Blob([html], { type: 'text/html' }); const blobHtml = new Blob([html], { type: 'text/html' });
const blobText = new Blob([url], { type: 'text/plain' }); const blobText = new Blob([url], { type: 'text/plain' });
@@ -1078,7 +1144,6 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
await navigator.clipboard.write(data); await navigator.clipboard.write(data);
button.textContent = 'Kopiert!'; button.textContent = 'Kopiert!';
} catch (err) { } catch (err) {
// Fallback to simple text copy if Clipboard Item API fails or is not supported
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
button.textContent = 'Kopiert!'; button.textContent = 'Kopiert!';
@@ -1097,6 +1162,42 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
}, 2000); }, 2000);
}); });
}); });
// Chart.js
const ctx = document.getElementById('activityChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: ${JSON.stringify(chartLabels)},
datasets: [{
label: 'Uploads',
data: ${JSON.stringify(chartUploads)},
backgroundColor: '#0f766e',
borderRadius: 4
}, {
label: 'Downloads',
data: ${JSON.stringify(chartDownloads)},
backgroundColor: '#0ea5e9',
borderRadius: 4
}, {
label: 'Löschungen',
data: ${JSON.stringify(chartDeletes)},
backgroundColor: '#ef4444',
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { stacked: true, grid: { display: false } },
y: { stacked: true, beginAtZero: true }
},
plugins: {
legend: { position: 'bottom' }
}
}
});
</script> </script>
`; `;
res.send(renderPage('Adminübersicht', body, 'wide')); res.send(renderPage('Adminübersicht', body, 'wide'));
@@ -1198,7 +1299,7 @@ app.post(`${basePath}/admin/users/create`, requireAdminPage, async (req, res) =>
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Benutzername existiert bereits.</p>', 'wide')); res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Benutzername existiert bereits.</p>', 'wide'));
return; return;
} }
await logEvent('admin_user_create', 'admin', { username }); await logEvent('admin_user_create', 'admin', { username }, req);
res.redirect(baseUrl('/admin/users')); res.redirect(baseUrl('/admin/users'));
}); });
@@ -1211,7 +1312,7 @@ app.post(`${basePath}/admin/users/:username/reset`, requireAdminPage, async (req
} }
const hash = bcrypt.hashSync(password, 12); const hash = bcrypt.hashSync(password, 12);
await run('UPDATE users SET password_hash = ? WHERE username = ?', [hash, username]); await run('UPDATE users SET password_hash = ? WHERE username = ?', [hash, username]);
await logEvent('admin_user_reset', 'admin', { username }); await logEvent('admin_user_reset', 'admin', { username }, req);
res.redirect(baseUrl('/admin/users')); res.redirect(baseUrl('/admin/users'));
}); });
@@ -1222,7 +1323,7 @@ app.post(`${basePath}/admin/users/:username/delete`, requireAdminPage, async (re
return; return;
} }
await run('DELETE FROM users WHERE username = ?', [username]); await run('DELETE FROM users WHERE username = ?', [username]);
await logEvent('admin_user_delete', 'admin', { username }); await logEvent('admin_user_delete', 'admin', { username }, req);
res.redirect(baseUrl('/admin/users')); res.redirect(baseUrl('/admin/users'));
}); });
@@ -1517,7 +1618,7 @@ app.post(`${basePath}/admin/files/mkdir`, requireAdminPage, async (req, res) =>
} }
const target = path.join(base, name); const target = path.join(base, name);
await fs.promises.mkdir(target, { recursive: true }); await fs.promises.mkdir(target, { recursive: true });
await logEvent('admin_mkdir', 'admin', { path: path.join(relativePath, name) }); await logEvent('admin_mkdir', 'admin', { path: path.join(relativePath, name) }, req);
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(relativePath)}`)); res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(relativePath)}`));
}); });
@@ -1548,7 +1649,7 @@ app.post(`${basePath}/admin/files/upload`, requireAdminPage, upload.single('file
throw err; throw err;
} }
} }
await logEvent('admin_upload', 'admin', { path: path.join(relativePath, filename) }); await logEvent('admin_upload', 'admin', { path: path.join(relativePath, filename) }, req);
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(relativePath)}`)); res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(relativePath)}`));
}); });
@@ -1570,7 +1671,7 @@ app.post(`${basePath}/admin/files/rename`, requireAdminPage, async (req, res) =>
} }
const target = path.join(path.dirname(resolved), newName); const target = path.join(path.dirname(resolved), newName);
await fs.promises.rename(resolved, target); await fs.promises.rename(resolved, target);
await logEvent('admin_rename', 'admin', { from: relativePath, to: path.join(path.dirname(relativePath), newName) }); await logEvent('admin_rename', 'admin', { from: relativePath, to: path.join(path.dirname(relativePath), newName) }, req);
const parent = path.dirname(relativePath); const parent = path.dirname(relativePath);
const nextPath = parent === '.' ? '' : parent; const nextPath = parent === '.' ? '' : parent;
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`)); res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`));
@@ -1588,7 +1689,7 @@ app.post(`${basePath}/admin/files/delete`, requireAdminPage, async (req, res) =>
return; return;
} }
await fs.promises.rm(resolved, { recursive: true, force: true }); await fs.promises.rm(resolved, { recursive: true, force: true });
await logEvent('admin_delete', 'admin', { path: relativePath }); await logEvent('admin_delete', 'admin', { path: relativePath }, req);
const parent = path.dirname(relativePath); const parent = path.dirname(relativePath);
const nextPath = parent === '.' ? '' : parent; const nextPath = parent === '.' ? '' : parent;
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`)); res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`));
@@ -1628,7 +1729,7 @@ app.post(`${basePath}/admin/files/move`, requireAdminPage, async (req, res) => {
throw err; throw err;
} }
} }
await logEvent('admin_move', 'admin', { from: relativePath, to: targetPath }); await logEvent('admin_move', 'admin', { from: relativePath, to: targetPath }, req);
res.redirect(baseUrl('/admin/files')); res.redirect(baseUrl('/admin/files'));
}); });
@@ -1657,7 +1758,7 @@ app.post(`${basePath}/admin/files/copy`, requireAdminPage, async (req, res) => {
} }
await fs.promises.cp(source, target, { recursive: true, force: false }); await fs.promises.cp(source, target, { recursive: true, force: false });
await logEvent('admin_copy', 'admin', { from: relativePath, to: targetPath }); await logEvent('admin_copy', 'admin', { from: relativePath, to: targetPath }, req);
res.redirect(baseUrl('/admin/files')); res.redirect(baseUrl('/admin/files'));
}); });
@@ -1673,7 +1774,7 @@ app.post(`${basePath}/admin/files/:id/delete`, requireAdminPage, 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', 'admin', { id: uploadEntry.id }); await logEvent('delete', 'admin', { id: uploadEntry.id }, req);
res.redirect(baseUrl('/admin/dashboard')); res.redirect(baseUrl('/admin/dashboard'));
}); });
@@ -1694,7 +1795,7 @@ app.post(`${basePath}/admin/files/:id/extend`, requireAdminPage, async (req, res
const maxExpiry = now + maxRetentionSeconds * 1000; const maxExpiry = now + maxRetentionSeconds * 1000;
const nextExpiry = Math.min(base + extensionSeconds * 1000, maxExpiry); const nextExpiry = Math.min(base + extensionSeconds * 1000, maxExpiry);
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', 'admin', { id: uploadEntry.id, expires_at: nextExpiry }); await logEvent('extend', 'admin', { id: uploadEntry.id, expires_at: nextExpiry }, req);
res.redirect(baseUrl('/admin/dashboard')); res.redirect(baseUrl('/admin/dashboard'));
}); });
@@ -1904,7 +2005,7 @@ app.post(`${basePath}/api/upload`, requireAuthApi, upload.single('file'), async
now + cappedRetention * 1000, now + cappedRetention * 1000,
] ]
); );
await logEvent('upload', req.user.username, { name: storedName, size: req.file.size }); await logEvent('upload', req.user.username, { name: storedName, size: req.file.size }, req);
res.json({ ok: true, name: storedName }); res.json({ ok: true, name: storedName });
}); });
@@ -1924,7 +2025,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 }); await logEvent('delete', req.user.username, { id: uploadEntry.id }, req);
res.redirect(baseUrl('/dashboard')); res.redirect(baseUrl('/dashboard'));
}); });
@@ -1948,7 +2049,7 @@ app.post(`${basePath}/files/:id/extend`, requireAuthPage, async (req, res) => {
const maxExpiry = now + maxRetentionSeconds * 1000; const maxExpiry = now + maxRetentionSeconds * 1000;
const nextExpiry = Math.min(base + extensionSeconds * 1000, maxExpiry); const nextExpiry = Math.min(base + extensionSeconds * 1000, maxExpiry);
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 }); await logEvent('extend', req.user.username, { id: uploadEntry.id, expires_at: nextExpiry }, req);
res.redirect(baseUrl('/dashboard')); res.redirect(baseUrl('/dashboard'));
}); });
@@ -1976,7 +2077,7 @@ app.get('/_share/:filename', async (req, res) => {
// Log download // Log download
run('UPDATE uploads SET downloads = downloads + 1 WHERE id = ?', [row.id]).catch(() => undefined); run('UPDATE uploads SET downloads = downloads + 1 WHERE id = ?', [row.id]).catch(() => undefined);
logEvent('download', null, { name: filename, original: row.original_name }).catch(() => undefined); logEvent('download', null, { name: filename, original: row.original_name }, req).catch(() => undefined);
res.download(row.stored_path, row.original_name); res.download(row.stored_path, row.original_name);
}); });