From 386855d76a372c716f547c4bed4ed84997ad9a03 Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Thu, 16 Apr 2026 14:14:05 +0200 Subject: [PATCH] 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) --- public/admin/index.html | 14 ++++++- public/upload.html | 82 ++++++++++++++++++++++++++++++++++++++++- src/server.js | 80 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 170 insertions(+), 6 deletions(-) diff --git a/public/admin/index.html b/public/admin/index.html index e5b42f7..c5cb472 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -404,7 +404,11 @@
-

Leer lassen, um aus jedem Request die aktuelle URL zu nutzen.

+

Wird in den Kunden-Upload-Links eingesetzt. Leer lassen = aus dem Request abgeleitet.

+
+
+ +

Wird im Adminportal unter der Kundenliste angezeigt. Leer lassen = webdav://<host>:1900/.

@@ -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); diff --git a/public/upload.html b/public/upload.html index 1c7b285..60d5eaa 100644 --- a/public/upload.html +++ b/public/upload.html @@ -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; } @@ -159,6 +175,11 @@
+ +
+

Bisher hochgeladene Dateien

+
– wird geladen –
+
@@ -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 = '
Konnte Dateien nicht laden.
'; count.textContent = ''; return; } + const files = await r.json(); + count.textContent = files.length ? `(${files.length})` : ''; + if (!files.length) { + browser.innerHTML = '
Noch keine Dateien hochgeladen.
'; + return; + } + browser.innerHTML = files.map(f => { + const name = f.path.replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); + const date = new Date(f.mtime).toLocaleString(); + return ` +
+
📄
+
+
${name}
+
${date}
+
+
${fmtSize(f.size)}
+ +
`; + }).join(''); + } catch (e) { + browser.innerHTML = '
Fehler beim Laden.
'; + } +} + +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) => { diff --git a/src/server.js b/src/server.js index c1cc011..9c5c012 100644 --- a/src/server.js +++ b/src/server.js @@ -138,11 +138,14 @@ publicApi.get('/branding', (req, res) => { publicApi.get('/status', (req, res) => { const u = auth.getSessionUser(req); - res.json({ + const payload = { setup_required: !auth.hasAnyUser(), authenticated: !!u, user: u ? { id: u.id, username: u.username, role: u.role } : null, - }); + }; + // WebDAV URL is internal infra info — only expose it to authenticated users. + if (u) payload.webdav_url = settings.get('webdav_url', ''); + res.json(payload); }); publicApi.post('/setup', loginLimiter, async (req, res) => { @@ -261,6 +264,7 @@ api.post('/me/password', loginLimiter, auth.requireAuth, async (req, res) => { api.get('/settings', auth.requireAdmin, (req, res) => { res.json({ public_base_url: settings.get('public_base_url', ''), + webdav_url: settings.get('webdav_url', ''), janitor_interval_minutes: parseInt(settings.get('janitor_interval_minutes', '30'), 10), logo_filename: settings.get('logo_filename', ''), logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10), @@ -281,6 +285,9 @@ api.put('/settings', auth.requireAdmin, (req, res) => { if (b.public_base_url !== undefined) { settings.set('public_base_url', String(b.public_base_url || '').trim().replace(/\/+$/, '')); } + if (b.webdav_url !== undefined) { + settings.set('webdav_url', String(b.webdav_url || '').trim().replace(/\/+$/, '')); + } if (b.janitor_interval_minutes !== undefined) { const n = Math.max(1, parseInt(b.janitor_interval_minutes, 10) || 30); settings.set('janitor_interval_minutes', String(n)); @@ -591,6 +598,75 @@ function uploadAuth(req, res, next) { } } +const MAX_WALK_DEPTH = 20; + +// Iterative walk with depth limit. Symlinks (file or directory) are skipped +// so a malicious WebDAV upload cannot point out of the customer directory. +function listCustomerFiles(baseDir) { + const out = []; + const queue = [{ dir: baseDir, depth: 0 }]; + while (queue.length) { + const { dir, depth } = queue.shift(); + if (depth > MAX_WALK_DEPTH) continue; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } + for (const e of entries) { + if (e.isSymbolicLink()) continue; + const abs = path.join(dir, e.name); + if (e.isDirectory()) { + queue.push({ dir: abs, depth: depth + 1 }); + } else if (e.isFile()) { + try { + const st = fs.lstatSync(abs); + if (!st.isFile()) continue; + out.push({ + path: path.relative(baseDir, abs).split(path.sep).join('/'), + size: st.size, + mtime: st.mtimeMs, + }); + } catch {} + } + } + } + return out.sort((a, b) => b.mtime - a.mtime); +} + +// Strip control chars / CRLF / quotes for Content-Disposition. +// Anything non-ASCII-safe is replaced with "_"; the original is offered +// via RFC 5987 filename* so international names still work in modern clients. +function cdFilename(name) { + const safe = String(name).replace(/[\x00-\x1f\x7f"\\]+/g, '_').slice(0, 200) || 'download'; + const encoded = encodeURIComponent(name); + return `attachment; filename="${safe}"; filename*=UTF-8''${encoded}`; +} + +app.get('/u/:token/files', uploadAuth, (req, res) => { + const c = req._customer; + res.json(listCustomerFiles(customerDir(c.slug))); +}); + +app.get('/u/:token/file', uploadAuth, (req, res) => { + const c = req._customer; + const base = customerDir(c.slug); + const rel = sanitizeRelPath(req.query.path || ''); + if (!rel) return res.status(400).end(); + let abs; + try { abs = safeJoin(base, rel); } catch { return res.status(400).end(); } + // Defeat symlinks: resolve the real path and re-check containment. + let real; + try { real = fs.realpathSync(abs); } catch { return res.status(404).end(); } + const baseReal = fs.realpathSync(base); + if (real !== baseReal && !real.startsWith(baseReal + path.sep)) { + return res.status(404).end(); + } + let st; + try { st = fs.lstatSync(real); } catch { return res.status(404).end(); } + if (!st.isFile()) return res.status(404).end(); + res.setHeader('Content-Disposition', cdFilename(path.basename(real))); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.sendFile(real); +}); + app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => { const c = req._customer; const f = req.file;