Add customer file browser, configurable WebDAV URL and harden both

- /u/:token/files lists files in the customer folder, /u/:token/file
  streams a download. Iterative walker with depth limit; symlinks are
  rejected at enumeration and via realpath containment on download;
  Content-Disposition filename is sanitized with an RFC 5987 fallback
- New "Private WebDAV-URL" field in admin settings, displayed under
  the customer table. Served via authenticated /status (not public
  /branding) so it does not leak to upload visitors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-16 14:14:05 +02:00
parent 2b1417ccf3
commit 386855d76a
3 changed files with 170 additions and 6 deletions
+12 -2
View File
@@ -404,7 +404,11 @@
<form id="settingsForm">
<div class="field"><label>Öffentliche Basis-URL</label>
<input name="public_base_url" placeholder="z. B. https://upload.example.com" />
<p class="small" style="margin:.35rem 0 0">Leer lassen, um aus jedem Request die aktuelle URL zu nutzen.</p>
<p class="small" style="margin:.35rem 0 0">Wird in den Kunden-Upload-Links eingesetzt. Leer lassen = aus dem Request abgeleitet.</p>
</div>
<div class="field"><label>Private WebDAV-URL</label>
<input name="webdav_url" placeholder="z. B. webdav://upload.example.com:1900/" />
<p class="small" style="margin:.35rem 0 0">Wird im Adminportal unter der Kundenliste angezeigt. Leer lassen = <code>webdav://&lt;host&gt;:1900/</code>.</p>
</div>
<div class="field"><label>Cron-Intervall (Minuten)</label>
<input name="janitor_interval_minutes" type="number" min="1" style="max-width: 8rem" />
@@ -547,7 +551,8 @@ async function bootstrap() {
} else {
document.getElementById('createCustomerCard').style.display = '';
}
document.getElementById('webdavUrl').textContent = `webdav://${location.hostname}:1900/`;
document.getElementById('webdavUrl').textContent =
(status.webdav_url || '').trim() || `webdav://${location.hostname}:1900/`;
show('view-app');
loadCustomers();
}
@@ -754,6 +759,7 @@ async function loadSettings() {
const s = await api.get('/settings');
const form = document.getElementById('settingsForm');
form.public_base_url.value = s.public_base_url || '';
form.webdav_url.value = s.webdav_url || '';
form.janitor_interval_minutes.value = s.janitor_interval_minutes || 30;
setSlider('logoWidth', s.logo_width_px || 0);
setSlider('logoHeight', s.logo_height_px || 0);
@@ -806,10 +812,14 @@ document.getElementById('settingsForm').addEventListener('submit', async (e) =>
try {
await api.send('PUT', '/settings', {
public_base_url: fd.get('public_base_url') || '',
webdav_url: fd.get('webdav_url') || '',
janitor_interval_minutes: parseInt(fd.get('janitor_interval_minutes') || '30', 10),
logo_width_px: getSlider('logoWidth'),
logo_height_px: getSlider('logoHeight'),
});
// Refresh main view in case the WebDAV-URL display needs an update.
document.getElementById('webdavUrl').textContent =
(fd.get('webdav_url') || '').trim() || `webdav://${location.hostname}:1900/`;
const msg = document.getElementById('settingsMsg');
msg.textContent = '✓ Gespeichert';
setTimeout(() => msg.textContent = '', 2000);
+80 -2
View File
@@ -122,6 +122,22 @@
padding: .15rem .55rem; border-radius: 999px; font-size: .75rem;
color: var(--text-muted); border: 1px solid var(--border-strong); }
.browser { margin-top: 2rem; }
.browser h2 { font-size: 1rem; margin: 0 0 .5rem; display: flex; align-items: center; gap: .5rem; }
.browser .count { color: var(--text-muted); font-size: .85rem; font-weight: normal; }
.browser .empty { color: var(--text-muted); font-size: .9rem; padding: 1rem; text-align: center;
border: 1px dashed var(--border); border-radius: var(--radius-sm); }
.browser .row {
display: grid; grid-template-columns: auto 1fr auto auto; gap: .75rem;
align-items: center; padding: .55rem .75rem; border: 1px solid var(--border);
background: var(--bg); border-radius: var(--radius-sm); margin-bottom: .35rem;
font-size: .88rem;
}
.browser .row .icon { font-size: 1rem; opacity: .7; }
.browser .row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.browser .row .meta { color: var(--text-muted); font-size: .8rem; font-variant-numeric: tabular-nums; }
.browser .row .dl { padding: .3rem .65rem; font-size: .8rem; }
footer { margin-top: 2rem; color: var(--text-dim); font-size: .8rem; }
</style>
</head>
@@ -159,6 +175,11 @@
</div>
</div>
<div id="list"></div>
<div class="browser">
<h2>Bisher hochgeladene Dateien <span class="count" id="fileCount"></span></h2>
<div id="fileBrowser"><div class="empty"> wird geladen </div></div>
</div>
</div>
</div>
@@ -205,9 +226,65 @@ async function init() {
info.textContent = 'Lade Dateien oder ganze Ordner hoch — die Ordnerstruktur bleibt erhalten.';
}
if (data.has_password) gate.style.display = 'block';
else main.style.display = 'block';
else { main.style.display = 'block'; loadFiles(); }
}
function authHeaders() {
return password ? { 'X-Upload-Password': password } : {};
}
async function loadFiles() {
const browser = document.getElementById('fileBrowser');
const count = document.getElementById('fileCount');
try {
const r = await fetch(`/u/${token}/files`, { headers: authHeaders() });
if (!r.ok) { browser.innerHTML = '<div class="empty">Konnte Dateien nicht laden.</div>'; count.textContent = ''; return; }
const files = await r.json();
count.textContent = files.length ? `(${files.length})` : '';
if (!files.length) {
browser.innerHTML = '<div class="empty">Noch keine Dateien hochgeladen.</div>';
return;
}
browser.innerHTML = files.map(f => {
const name = f.path.replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
const date = new Date(f.mtime).toLocaleString();
return `
<div class="row">
<div class="icon">📄</div>
<div>
<div class="name" title="${name}">${name}</div>
<div class="meta">${date}</div>
</div>
<div class="meta">${fmtSize(f.size)}</div>
<button type="button" class="btn dl" data-path="${name}">⬇ Download</button>
</div>`;
}).join('');
} catch (e) {
browser.innerHTML = '<div class="empty">Fehler beim Laden.</div>';
}
}
document.getElementById('fileBrowser').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-path]');
if (!btn) return;
const filePath = btn.dataset.path;
btn.disabled = true; const orig = btn.textContent; btn.textContent = '… lädt';
try {
const r = await fetch(`/u/${token}/file?path=${encodeURIComponent(filePath)}`, { headers: authHeaders() });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filePath.split('/').pop();
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (ex) {
alert('Download fehlgeschlagen: ' + ex.message);
} finally {
btn.disabled = false; btn.textContent = orig;
}
});
document.getElementById('pwBtn').onclick = async () => {
const pw = document.getElementById('pw').value;
const r = await fetch(`/u/${token}/auth`, {
@@ -215,7 +292,7 @@ document.getElementById('pwBtn').onclick = async () => {
body: JSON.stringify({ password: pw }),
});
const j = await r.json();
if (j.ok) { password = pw; gate.style.display='none'; main.style.display='block'; }
if (j.ok) { password = pw; gate.style.display='none'; main.style.display='block'; loadFiles(); }
else document.getElementById('pwErr').style.display='block';
};
@@ -272,6 +349,7 @@ async function uploadOne(file, relPath) {
async function uploadFiles(items) {
for (const { file, path } of items) await uploadOne(file, path);
loadFiles();
}
document.getElementById('fileInput').onchange = (e) => {