regular delta: better upload tracking + UI rehaul
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
||||
.logins
|
||||
traefik/
|
||||
node_modules/
|
||||
expressjs/data/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user