diff --git a/.gitignore b/.gitignore
index 4f31df5..e16c576 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
.logins
traefik/
node_modules/
+expressjs/data/
diff --git a/expressjs/src/server.js b/expressjs/src/server.js
index 6793f69..08eda27 100644
--- a/expressjs/src/server.js
+++ b/expressjs/src/server.js
@@ -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 = '
';
+ for (const [key, val] of Object.entries(obj)) {
+ html += `- ${escapeHtml(key)}:
- ${escapeHtml(val)}
`;
+ }
+ html += '
';
+ return html;
+ } catch (e) {
+ return escapeHtml(detailJson);
+ }
+}
+
function renderFileManagerPage(title, body) {
return `
@@ -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 = `
@@ -934,8 +984,14 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
| ${formatTimestamp(entry.created_at)} |
${escapeHtml(entry.event)} |
- ${escapeHtml(entry.owner || '—')} |
- ${escapeHtml(entry.detail || '')} |
+
+ ${escapeHtml(entry.owner || '—')}
+ ${escapeHtml(entry.ip || '')}
+ |
+
+ ${formatDetail(entry.detail)}
+ ${entry.user_agent ? ` ${escapeHtml(entry.user_agent)} ` : ''}
+ |
`).join('');
@@ -971,6 +1027,7 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
}).join('');
const body = `
+
Adminübersicht
@@ -985,10 +1042,18 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
-
- Statistiken
- ${stats}
-
+
+
+
+ Aktivität (30 Tage)
+
+
+
+ Statistiken
+ ${stats}
+
+
+
Letzte Ereignisse
@@ -996,8 +1061,8 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
| Zeit |
Event |
- Nutzer |
- Details |
+ Nutzer / IP |
+ Details / User-Agent |
@@ -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 = \`\${name}\`;
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' }
+ }
+ }
+ });
`;
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', 'Benutzername existiert bereits.
', '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);
});