added download tracking; other minor UI improvements
This commit is contained in:
@@ -68,6 +68,7 @@ db.serialize(() => {
|
||||
detail TEXT,
|
||||
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_created_idx ON admin_logs(created_at)');
|
||||
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); }
|
||||
|
||||
.row { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
||||
.row .card { margin: 0; padding: 1.25rem; text-align: center; }
|
||||
.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 .card .muted { font-size: 1.5rem; color: var(--text-main); font-weight: 600; }
|
||||
.row table { margin-bottom: 0; }
|
||||
.row td { padding: 0.5rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
.row tr:last-child td { border-bottom: none; }
|
||||
.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; }
|
||||
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 = `
|
||||
<div class="row">
|
||||
<div class="card"><strong>Aktive Uploads</strong><div class="muted">${activeCount.count}</div></div>
|
||||
<div class="card"><strong>Aktive Größe</strong><div class="muted">${formatBytes(activeBytes.total)}</div></div>
|
||||
<div class="card"><strong>Aktive Nutzer</strong><div class="muted">${distinctOwners.count}</div></div>
|
||||
<div class="card"><strong>Uploads gesamt</strong><div class="muted">${totalUploads.count}</div></div>
|
||||
<div class="card"><strong>Löschungen gesamt</strong><div class="muted">${totalDeletes.count}</div></div>
|
||||
<div class="card"><strong>Letztes Cleanup</strong><div class="muted">${lastCleanup.ts ? formatTimestamp(lastCleanup.ts) : '—'}</div></div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Aktive Uploads</strong></td>
|
||||
<td class="muted">${activeCount.count}</td>
|
||||
</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>
|
||||
`;
|
||||
|
||||
@@ -912,10 +944,10 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
|
||||
const fileHref = encodeURI(fileUrl);
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(item.owner)}</td>
|
||||
<td class="truncate" title="${escapeHtml(item.owner)}">${escapeHtml(item.owner)}</td>
|
||||
<td>
|
||||
<div><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="truncate" title="${escapeHtml(item.original_name)}"><strong>${escapeHtml(item.original_name)}</strong></div>
|
||||
<div class="muted truncate"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div>
|
||||
</td>
|
||||
<td>${formatBytes(item.size_bytes)}</td>
|
||||
<td>
|
||||
@@ -932,6 +964,9 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
|
||||
<input name="extendHours" placeholder="Stunden hinzufügen" />
|
||||
<button type="submit">Verlängern</button>
|
||||
</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>
|
||||
</tr>
|
||||
`;
|
||||
@@ -974,12 +1009,11 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Aktive Uploads</h2>
|
||||
${allUploads.length ? `
|
||||
<h2>Aktuelle Uploads</h2>
|
||||
${uploads.length ? `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nutzer</th>
|
||||
<th>Datei</th>
|
||||
<th>Größe</th>
|
||||
<th>Läuft ab</th>
|
||||
@@ -987,11 +1021,84 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${adminUploadsRows}
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
` : '<div class="muted">Keine aktiven Uploads.</div>'}
|
||||
` : '<div class="muted">Noch keine Uploads.</div>'}
|
||||
</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'));
|
||||
});
|
||||
@@ -1603,9 +1710,9 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
||||
const fileHref = encodeURI(fileUrl);
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div><strong>${escapeHtml(item.original_name)}</strong></div>
|
||||
<div class="muted"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div>
|
||||
<td class="truncate">
|
||||
<div class="truncate" title="${escapeHtml(item.original_name)}"><strong>${escapeHtml(item.original_name)}</strong></div>
|
||||
<div class="muted truncate"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div>
|
||||
</td>
|
||||
<td>${formatBytes(item.size_bytes)}</td>
|
||||
<td>
|
||||
@@ -1622,9 +1729,9 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
||||
<input name="extendHours" placeholder="Stunden hinzufügen" />
|
||||
<button type="submit">Verlängern</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="secondary copy-link" data-path="${fileHref}">Link kopieren</button>
|
||||
<div class="stack">
|
||||
<button type="button" class="secondary copy-link" data-path="${fileHref}" data-name="${escapeHtml(item.original_name)}">Link kopieren</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -1670,7 +1777,6 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
||||
<th>Größe</th>
|
||||
<th>Läuft ab</th>
|
||||
<th>Aktionen</th>
|
||||
<th>Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1716,11 +1822,26 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
||||
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 (err) {
|
||||
} catch (fallbackErr) {
|
||||
const helper = document.createElement('textarea');
|
||||
helper.value = url;
|
||||
document.body.appendChild(helper);
|
||||
@@ -1729,6 +1850,7 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
||||
document.body.removeChild(helper);
|
||||
button.textContent = 'Kopiert!';
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
}, 2000);
|
||||
@@ -1841,12 +1963,13 @@ app.get('/_share/:filename', async (req, res) => {
|
||||
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 not found in DB, check if it exists on disk (legacy or manual files)
|
||||
const filePath = path.join(shareDir, filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
// Log download for legacy/manual files if needed, or just skip
|
||||
res.download(filePath, filename); // Fallback: download with stored name
|
||||
return;
|
||||
}
|
||||
@@ -1854,6 +1977,10 @@ app.get('/_share/:filename', async (req, res) => {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user