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) => { - - + + `).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

${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)}
` : ''} +
@@ -996,8 +1061,8 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => { - - + + @@ -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); });
Zeit EventNutzerDetailsNutzer / IPDetails / User-Agent