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
traefik/
node_modules/
expressjs/data/

View File

@@ -69,6 +69,8 @@ db.serialize(() => {
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 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_created_idx ON admin_logs(created_at)');
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 ip = req ? (req.ip || req.socket.remoteAddress) : null;
const userAgent = req ? req.get('User-Agent') : null;
return run(
'INSERT INTO admin_logs (event, owner, detail, created_at) VALUES (?, ?, ?, ?)',
[event, owner || null, payload, Date.now()]
'INSERT INTO admin_logs (event, owner, detail, created_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)',
[event, owner || null, payload, Date.now(), ip, userAgent]
).catch(() => undefined);
}
@@ -310,6 +314,25 @@ function formatCountdown(ts) {
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) {
return `<!doctype html>
<html lang="de">
@@ -786,7 +809,7 @@ app.post(`${basePath}/login`, loginRateLimit('user'), async (req, res) => {
secure: process.env.COOKIE_SECURE === 'true',
});
clearLoginAttempts('user', req);
await logEvent('login', username, { ok: true });
await logEvent('login', username, { ok: true }, req);
res.redirect(baseUrl('/dashboard'));
});
@@ -863,7 +886,7 @@ app.post(`${basePath}/admin/login`, loginRateLimit('admin'), async (req, res) =>
secure: process.env.COOKIE_SECURE === 'true',
});
clearLoginAttempts('admin', req);
await logEvent('admin_login', 'admin', { ok: true });
await logEvent('admin_login', 'admin', { ok: true }, req);
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) => {
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
const [
activeCount,
activeBytes,
@@ -882,6 +907,7 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
lastCleanup,
recentLogs,
allUploads,
chartDataRaw
] = await Promise.all([
get('SELECT COUNT(*) as count 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 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'),
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 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 = `
<div class="row">
<table>
@@ -934,8 +984,14 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
<tr>
<td>${formatTimestamp(entry.created_at)}</td>
<td>${escapeHtml(entry.event)}</td>
<td>${escapeHtml(entry.owner || '—')}</td>
<td>${escapeHtml(entry.detail || '')}</td>
<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>
`).join('');
@@ -971,6 +1027,7 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
}).join('');
const body = `
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<header>
<div>
<h1>Adminübersicht</h1>
@@ -985,10 +1042,18 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
</form>
</div>
</header>
<section class="card">
<h2>Statistiken</h2>
${stats}
</section>
<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">
<h2>Statistiken</h2>
${stats}
</section>
</div>
<section class="card">
<h2>Letzte Ereignisse</h2>
<table>
@@ -996,8 +1061,8 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
<tr>
<th>Zeit</th>
<th>Event</th>
<th>Nutzer</th>
<th>Details</th>
<th>Nutzer / IP</th>
<th>Details / User-Agent</th>
</tr>
</thead>
<tbody>
@@ -1033,31 +1098,33 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
const status = document.getElementById('upload-status');
const copyButtons = document.querySelectorAll('.copy-link');
uploadForm.addEventListener('submit', (event) => {
event.preventDefault();
status.textContent = '';
progress.value = 0;
const xhr = new XMLHttpRequest();
xhr.open('POST', ${JSON.stringify(baseUrl('/api/upload'))});
xhr.setRequestHeader('X-CSRF-Token', csrfToken);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
progress.value = Math.round((e.loaded / e.total) * 100);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
status.textContent = 'Upload abgeschlossen. Liste wird aktualisiert...';
window.location.reload();
} else {
if (uploadForm) {
uploadForm.addEventListener('submit', (event) => {
event.preventDefault();
status.textContent = '';
progress.value = 0;
const xhr = new XMLHttpRequest();
xhr.open('POST', ${JSON.stringify(baseUrl('/api/upload'))});
xhr.setRequestHeader('X-CSRF-Token', csrfToken);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
progress.value = Math.round((e.loaded / e.total) * 100);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
status.textContent = 'Upload abgeschlossen. Liste wird aktualisiert...';
window.location.reload();
} else {
status.textContent = 'Upload fehlgeschlagen.';
}
});
xhr.addEventListener('error', () => {
status.textContent = 'Upload fehlgeschlagen.';
}
});
xhr.send(new FormData(uploadForm));
});
xhr.addEventListener('error', () => {
status.textContent = 'Upload fehlgeschlagen.';
});
xhr.send(new FormData(uploadForm));
});
}
copyButtons.forEach((button) => {
const originalText = button.textContent;
@@ -1067,7 +1134,6 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
const url = window.location.origin + path;
try {
// Try to write rich text (HTML link) + plain text fallback
const html = \`<a href="\${url}">\${name}</a>\`;
const blobHtml = new Blob([html], { type: 'text/html' });
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);
button.textContent = 'Kopiert!';
} catch (err) {
// Fallback to simple text copy if Clipboard Item API fails or is not supported
try {
await navigator.clipboard.writeText(url);
button.textContent = 'Kopiert!';
@@ -1097,6 +1162,42 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
}, 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>
`;
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'));
return;
}
await logEvent('admin_user_create', 'admin', { username });
await logEvent('admin_user_create', 'admin', { username }, req);
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);
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'));
});
@@ -1222,7 +1323,7 @@ app.post(`${basePath}/admin/users/:username/delete`, requireAdminPage, async (re
return;
}
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'));
});
@@ -1517,7 +1618,7 @@ app.post(`${basePath}/admin/files/mkdir`, requireAdminPage, async (req, res) =>
}
const target = path.join(base, name);
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)}`));
});
@@ -1548,7 +1649,7 @@ app.post(`${basePath}/admin/files/upload`, requireAdminPage, upload.single('file
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)}`));
});
@@ -1570,7 +1671,7 @@ app.post(`${basePath}/admin/files/rename`, requireAdminPage, async (req, res) =>
}
const target = path.join(path.dirname(resolved), newName);
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 nextPath = parent === '.' ? '' : parent;
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`));
@@ -1588,7 +1689,7 @@ app.post(`${basePath}/admin/files/delete`, requireAdminPage, async (req, res) =>
return;
}
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 nextPath = parent === '.' ? '' : parent;
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`));
@@ -1628,7 +1729,7 @@ app.post(`${basePath}/admin/files/move`, requireAdminPage, async (req, res) => {
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'));
});
@@ -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 logEvent('admin_copy', 'admin', { from: relativePath, to: targetPath });
await logEvent('admin_copy', 'admin', { from: relativePath, to: targetPath }, req);
res.redirect(baseUrl('/admin/files'));
});
@@ -1673,7 +1774,7 @@ app.post(`${basePath}/admin/files/:id/delete`, requireAdminPage, async (req, res
// Ignore missing files.
}
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'));
});
@@ -1694,7 +1795,7 @@ app.post(`${basePath}/admin/files/:id/extend`, requireAdminPage, async (req, res
const maxExpiry = now + maxRetentionSeconds * 1000;
const nextExpiry = Math.min(base + extensionSeconds * 1000, maxExpiry);
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'));
});
@@ -1904,7 +2005,7 @@ app.post(`${basePath}/api/upload`, requireAuthApi, upload.single('file'), async
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 });
});
@@ -1924,7 +2025,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 });
await logEvent('delete', req.user.username, { id: uploadEntry.id }, req);
res.redirect(baseUrl('/dashboard'));
});
@@ -1948,7 +2049,7 @@ app.post(`${basePath}/files/:id/extend`, requireAuthPage, async (req, res) => {
const maxExpiry = now + maxRetentionSeconds * 1000;
const nextExpiry = Math.min(base + extensionSeconds * 1000, maxExpiry);
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'));
});
@@ -1976,7 +2077,7 @@ app.get('/_share/:filename', async (req, res) => {
// Log download
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);
});