added download tracking; other minor UI improvements

This commit is contained in:
Ludwig Lehnert
2026-01-29 20:11:45 +01:00
parent 4b7f48d782
commit faf21d54d1

View File

@@ -68,6 +68,7 @@ db.serialize(() => {
detail TEXT, detail TEXT,
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('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 (
@@ -559,10 +560,15 @@ function renderPage(title, body, mainClass = '') {
} }
button.secondary:hover { border-color: var(--text-muted); background: var(--bg); } button.secondary:hover { border-color: var(--text-muted); background: var(--bg); }
.row { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } .row table { margin-bottom: 0; }
.row .card { margin: 0; padding: 1.25rem; text-align: center; } .row td { padding: 0.5rem 1rem; border-bottom: 1px solid var(--border); }
.row .card strong { display: block; font-size: 0.8rem; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.05em; margin-bottom: 0.5rem; } .row tr:last-child td { border-bottom: none; }
.row .card .muted { font-size: 1.5rem; color: var(--text-main); font-weight: 600; } .row strong { color: var(--text-muted); font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.05em; }
.row .muted { font-size: 1.1rem; color: var(--text-main); font-weight: 600; text-align: right; }
.truncate { max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stack { width: 100%; margin-top: 0.25rem; }
.stack button { width: 100%; justify-content: center; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.925rem; } table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.925rem; }
th { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; } th { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
@@ -889,12 +895,38 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
const stats = ` const stats = `
<div class="row"> <div class="row">
<div class="card"><strong>Aktive Uploads</strong><div class="muted">${activeCount.count}</div></div> <table>
<div class="card"><strong>Aktive Größe</strong><div class="muted">${formatBytes(activeBytes.total)}</div></div> <tbody>
<div class="card"><strong>Aktive Nutzer</strong><div class="muted">${distinctOwners.count}</div></div> <tr>
<div class="card"><strong>Uploads gesamt</strong><div class="muted">${totalUploads.count}</div></div> <td><strong>Aktive Uploads</strong></td>
<div class="card"><strong>Löschungen gesamt</strong><div class="muted">${totalDeletes.count}</div></div> <td class="muted">${activeCount.count}</td>
<div class="card"><strong>Letztes Cleanup</strong><div class="muted">${lastCleanup.ts ? formatTimestamp(lastCleanup.ts) : '—'}</div></div> </tr>
<tr>
<td><strong>Aktive Größe</strong></td>
<td class="muted">${formatBytes(activeBytes.total)}</td>
</tr>
<tr>
<td><strong>Aktive Nutzer</strong></td>
<td class="muted">${distinctOwners.count}</td>
</tr>
<tr>
<td><strong>Uploads gesamt</strong></td>
<td class="muted">${totalUploads.count}</td>
</tr>
<tr>
<td><strong>Downloads gesamt</strong></td>
<td class="muted">${await get('SELECT SUM(downloads) as count FROM uploads').then(r => r.count || 0)}</td>
</tr>
<tr>
<td><strong>Löschungen gesamt</strong></td>
<td class="muted">${totalDeletes.count}</td>
</tr>
<tr>
<td><strong>Letztes Cleanup</strong></td>
<td class="muted">${lastCleanup.ts ? formatTimestamp(lastCleanup.ts) : '—'}</td>
</tr>
</tbody>
</table>
</div> </div>
`; `;
@@ -912,10 +944,10 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
const fileHref = encodeURI(fileUrl); const fileHref = encodeURI(fileUrl);
return ` return `
<tr> <tr>
<td>${escapeHtml(item.owner)}</td> <td class="truncate" title="${escapeHtml(item.owner)}">${escapeHtml(item.owner)}</td>
<td> <td>
<div><strong>${escapeHtml(item.original_name)}</strong></div> <div class="truncate" title="${escapeHtml(item.original_name)}"><strong>${escapeHtml(item.original_name)}</strong></div>
<div class="muted"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div> <div class="muted truncate"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div>
</td> </td>
<td>${formatBytes(item.size_bytes)}</td> <td>${formatBytes(item.size_bytes)}</td>
<td> <td>
@@ -932,6 +964,9 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
<input name="extendHours" placeholder="Stunden hinzufügen" /> <input name="extendHours" placeholder="Stunden hinzufügen" />
<button type="submit">Verlängern</button> <button type="submit">Verlängern</button>
</form> </form>
<div class="stack">
<button type="button" class="secondary copy-link" data-path="${fileHref}" data-name="${escapeHtml(item.original_name)}">Link kopieren</button>
</div>
</td> </td>
</tr> </tr>
`; `;
@@ -974,12 +1009,11 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
</section> </section>
<section class="card"> <section class="card">
<h2>Aktive Uploads</h2> <h2>Aktuelle Uploads</h2>
${allUploads.length ? ` ${uploads.length ? `
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Nutzer</th>
<th>Datei</th> <th>Datei</th>
<th>Größe</th> <th>Größe</th>
<th>Läuft ab</th> <th>Läuft ab</th>
@@ -987,11 +1021,84 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${adminUploadsRows} ${rows}
</tbody> </tbody>
</table> </table>
` : '<div class="muted">Keine aktiven Uploads.</div>'} ` : '<div class="muted">Noch keine Uploads.</div>'}
</section> </section>
<script>
const uploadForm = document.getElementById('upload-form');
const csrfToken = ${JSON.stringify(res.locals.csrfToken)};
const progress = document.getElementById('upload-progress');
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 {
status.textContent = 'Upload fehlgeschlagen.';
}
});
xhr.addEventListener('error', () => {
status.textContent = 'Upload fehlgeschlagen.';
});
xhr.send(new FormData(uploadForm));
});
copyButtons.forEach((button) => {
const originalText = button.textContent;
button.addEventListener('click', async () => {
const path = button.dataset.path || '';
const name = button.dataset.name || 'Download';
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' });
const data = [new ClipboardItem({
'text/html': blobHtml,
'text/plain': blobText,
})];
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!';
} catch (fallbackErr) {
const helper = document.createElement('textarea');
helper.value = url;
document.body.appendChild(helper);
helper.select();
document.execCommand('copy');
document.body.removeChild(helper);
button.textContent = 'Kopiert!';
}
}
setTimeout(() => {
button.textContent = originalText;
}, 2000);
});
});
</script>
`; `;
res.send(renderPage('Adminübersicht', body, 'wide')); res.send(renderPage('Adminübersicht', body, 'wide'));
}); });
@@ -1603,9 +1710,9 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
const fileHref = encodeURI(fileUrl); const fileHref = encodeURI(fileUrl);
return ` return `
<tr> <tr>
<td> <td class="truncate">
<div><strong>${escapeHtml(item.original_name)}</strong></div> <div class="truncate" title="${escapeHtml(item.original_name)}"><strong>${escapeHtml(item.original_name)}</strong></div>
<div class="muted"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div> <div class="muted truncate"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div>
</td> </td>
<td>${formatBytes(item.size_bytes)}</td> <td>${formatBytes(item.size_bytes)}</td>
<td> <td>
@@ -1622,9 +1729,9 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
<input name="extendHours" placeholder="Stunden hinzufügen" /> <input name="extendHours" placeholder="Stunden hinzufügen" />
<button type="submit">Verlängern</button> <button type="submit">Verlängern</button>
</form> </form>
</td> <div class="stack">
<td> <button type="button" class="secondary copy-link" data-path="${fileHref}" data-name="${escapeHtml(item.original_name)}">Link kopieren</button>
<button type="button" class="secondary copy-link" data-path="${fileHref}">Link kopieren</button> </div>
</td> </td>
</tr> </tr>
`; `;
@@ -1670,7 +1777,6 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
<th>Größe</th> <th>Größe</th>
<th>Läuft ab</th> <th>Läuft ab</th>
<th>Aktionen</th> <th>Aktionen</th>
<th>Link</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1716,11 +1822,26 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
const originalText = button.textContent; const originalText = button.textContent;
button.addEventListener('click', async () => { button.addEventListener('click', async () => {
const path = button.dataset.path || ''; const path = button.dataset.path || '';
const name = button.dataset.name || 'Download';
const url = window.location.origin + path; 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' });
const data = [new ClipboardItem({
'text/html': blobHtml,
'text/plain': blobText,
})];
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 { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
button.textContent = 'Kopiert!'; button.textContent = 'Kopiert!';
} catch (err) { } catch (fallbackErr) {
const helper = document.createElement('textarea'); const helper = document.createElement('textarea');
helper.value = url; helper.value = url;
document.body.appendChild(helper); document.body.appendChild(helper);
@@ -1729,6 +1850,7 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
document.body.removeChild(helper); document.body.removeChild(helper);
button.textContent = 'Kopiert!'; button.textContent = 'Kopiert!';
} }
}
setTimeout(() => { setTimeout(() => {
button.textContent = originalText; button.textContent = originalText;
}, 2000); }, 2000);
@@ -1841,12 +1963,13 @@ app.get('/_share/:filename', async (req, res) => {
return; return;
} }
const row = await get('SELECT original_name, stored_path FROM uploads WHERE stored_name = ?', [filename]); const row = await get('SELECT id, original_name, stored_path FROM uploads WHERE stored_name = ?', [filename]);
if (!row) { if (!row) {
// If not found in DB, check if it exists on disk (legacy or manual files) // If not found in DB, check if it exists on disk (legacy or manual files)
const filePath = path.join(shareDir, filename); const filePath = path.join(shareDir, filename);
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
// Log download for legacy/manual files if needed, or just skip
res.download(filePath, filename); // Fallback: download with stored name res.download(filePath, filename); // Fallback: download with stored name
return; return;
} }
@@ -1854,6 +1977,10 @@ app.get('/_share/:filename', async (req, res) => {
return; return;
} }
// 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);
res.download(row.stored_path, row.original_name); res.download(row.stored_path, row.original_name);
}); });