added download tracking; other minor UI improvements
This commit is contained in:
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user