From fd5e9172490fb10ff45ed5d5a2c5fd81bbd38329 Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Thu, 16 Apr 2026 14:29:56 +0200 Subject: [PATCH] Add hierarchical customer file browser with folder ZIP downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /u/:token/files now lists a single directory level with type info, /u/:token/zip streams a ZIP of any folder (whole customer dir by default). Both paths apply realpath containment so a symlink dropped into the customer folder via WebDAV cannot escape — listing now 404s on out-of-base symlinks the same way the file download already did. - Frontend gets breadcrumbs, folder navigation and per-folder/whole- current-folder ZIP buttons; UNC \\HOST@PORT\DavWWWRoot\ form is derived from the configured WebDAV URL and shown next to it. Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 + public/admin/index.html | 30 ++++++++-- public/upload.html | 118 +++++++++++++++++++++++++++++++++------- src/server.js | 86 ++++++++++++++++++++++++----- 4 files changed, 197 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index 8f3b482..5fd0523 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "better-sqlite3": "^11.3.0", "express": "^4.21.0", "express-basic-auth": "^1.2.1", + "archiver": "^7.0.1", "express-rate-limit": "^7.4.0", "helmet": "^7.1.0", "multer": "^2.0.0", diff --git a/public/admin/index.html b/public/admin/index.html index c5cb472..7ffacbb 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -292,7 +292,11 @@ -

WebDAV-Server: — Login mit deinem eigenen Benutzer.

+

+ WebDAV-Server:
+ Windows-UNC: + — Login mit deinem eigenen Benutzer. +

@@ -500,6 +504,24 @@ function show(view) { document.getElementById(id).style.display = id === view ? '' : 'none'; } } +function deriveUnc(webdavUrl) { + try { + let s = String(webdavUrl || '').trim(); + if (!s) return null; + if (!/^[a-z]+:\/\//i.test(s)) s = 'http://' + s; + const u = new URL(s); + const host = u.hostname; + const port = u.port || '1900'; + if (!host) return null; + return `\\\\${host}@${port}\\DavWWWRoot\\`; + } catch { return null; } +} +function setWebdavDisplay(url) { + document.getElementById('webdavUrl').textContent = url; + const unc = deriveUnc(url); + document.getElementById('webdavUnc').textContent = unc || '–'; +} + function fmtSize(n) { if (!n) return '0 B'; if (n < 1024) return n + ' B'; @@ -551,8 +573,7 @@ async function bootstrap() { } else { document.getElementById('createCustomerCard').style.display = ''; } - document.getElementById('webdavUrl').textContent = - (status.webdav_url || '').trim() || `webdav://${location.hostname}:1900/`; + setWebdavDisplay((status.webdav_url || '').trim() || `webdav://${location.hostname}:1900/`); show('view-app'); loadCustomers(); } @@ -818,8 +839,7 @@ document.getElementById('settingsForm').addEventListener('submit', async (e) => 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/`; + setWebdavDisplay((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 ec978dc..a83ec44 100644 --- a/public/upload.html +++ b/public/upload.html @@ -137,6 +137,19 @@ .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; } + .browser .row.dir .name { cursor: pointer; color: var(--primary); } + .browser .row.dir .name:hover { text-decoration: underline; } + .browser .crumbs { + display: flex; flex-wrap: wrap; gap: .25rem; align-items: center; + margin-bottom: .5rem; font-size: .9rem; + } + .browser .crumbs button { + background: transparent; border: none; color: var(--primary); + font: inherit; cursor: pointer; padding: .15rem .35rem; border-radius: 4px; + } + .browser .crumbs button:hover { background: color-mix(in srgb, var(--primary) 12%, transparent); } + .browser .crumbs span { color: var(--text-dim); } + .browser .toolbar { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; } footer { margin-top: 2rem; color: var(--text-dim); font-size: .8rem; } @@ -178,6 +191,8 @@

Bisher hochgeladene und/oder empfangene Dateien

+
+
– wird geladen –
@@ -233,21 +248,56 @@ function authHeaders() { return password ? { 'X-Upload-Password': password } : {}; } +let currentDir = ''; + +function esc(s) { + return String(s).replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); +} + +function renderCrumbs() { + const cr = document.getElementById('crumbs'); + const segs = currentDir ? currentDir.split('/') : []; + let acc = ''; + let html = ``; + for (const s of segs) { + acc = acc ? acc + '/' + s : s; + html += ``; + } + cr.innerHTML = html; +} + async function loadFiles() { const browser = document.getElementById('fileBrowser'); const count = document.getElementById('fileCount'); + renderCrumbs(); try { - const r = await fetch(`/u/${token}/files`, { headers: authHeaders() }); + const url = `/u/${token}/files${currentDir ? '?dir=' + encodeURIComponent(currentDir) : ''}`; + const r = await fetch(url, { 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.
'; + const data = await r.json(); + const entries = data.entries || []; + count.textContent = entries.length ? `(${entries.length})` : ''; + if (!entries.length) { + browser.innerHTML = '
Dieser Ordner ist leer.
'; return; } - browser.innerHTML = files.map(f => { - const name = f.path.replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); - const date = new Date(f.mtime).toLocaleString(); + browser.innerHTML = entries.map(e => { + const name = esc(e.name); + const date = new Date(e.mtime).toLocaleString(); + const fullPath = currentDir ? currentDir + '/' + e.name : e.name; + const escPath = esc(fullPath); + if (e.type === 'dir') { + return ` +
+
📁
+
+
${name}/
+
${date}
+
+
Ordner
+ +
`; + } return `
📄
@@ -255,34 +305,62 @@ async function loadFiles() {
${name}
${date}
-
${fmtSize(f.size)}
- +
${fmtSize(e.size)}
+ `; }).join(''); - } catch (e) { + } catch (ex) { 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'; +async function streamDownload(url, suggestedName, btn) { + const orig = btn.textContent; + btn.disabled = true; btn.textContent = '… lädt'; try { - const r = await fetch(`/u/${token}/file?path=${encodeURIComponent(filePath)}`, { headers: authHeaders() }); + const r = await fetch(url, { headers: authHeaders() }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const blob = await r.blob(); - const url = URL.createObjectURL(blob); + const objUrl = URL.createObjectURL(blob); const a = document.createElement('a'); - a.href = url; a.download = filePath.split('/').pop(); + a.href = objUrl; a.download = suggestedName; document.body.appendChild(a); a.click(); a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); + setTimeout(() => URL.revokeObjectURL(objUrl), 1000); } catch (ex) { alert('Download fehlgeschlagen: ' + ex.message); } finally { btn.disabled = false; btn.textContent = orig; } +} + +document.getElementById('crumbs').addEventListener('click', (e) => { + const btn = e.target.closest('button[data-go]'); + if (!btn) return; + currentDir = btn.dataset.go; + loadFiles(); +}); + +document.getElementById('zipCurrent').addEventListener('click', (e) => { + const url = `/u/${token}/zip${currentDir ? '?dir=' + encodeURIComponent(currentDir) : ''}`; + const name = (currentDir ? currentDir.split('/').pop() : 'alle-dateien') + '.zip'; + streamDownload(url, name, e.currentTarget); +}); + +document.getElementById('fileBrowser').addEventListener('click', async (e) => { + const open = e.target.closest('[data-open]'); + if (open) { currentDir = open.dataset.open; loadFiles(); return; } + const fileBtn = e.target.closest('button[data-path]'); + if (fileBtn) { + const p = fileBtn.dataset.path; + streamDownload(`/u/${token}/file?path=${encodeURIComponent(p)}`, p.split('/').pop(), fileBtn); + return; + } + const zipBtn = e.target.closest('button[data-zip]'); + if (zipBtn) { + const p = zipBtn.dataset.zip; + streamDownload(`/u/${token}/zip?dir=${encodeURIComponent(p)}`, p.split('/').pop() + '.zip', zipBtn); + return; + } }); document.getElementById('pwBtn').onclick = async () => { diff --git a/src/server.js b/src/server.js index 9c5c012..e024379 100644 --- a/src/server.js +++ b/src/server.js @@ -5,6 +5,7 @@ const path = require('path'); const fs = require('fs'); const multer = require('multer'); const bcrypt = require('bcrypt'); +const archiver = require('archiver'); const { nanoid } = require('nanoid'); const db = require('./db'); const auth = require('./auth'); @@ -600,14 +601,44 @@ 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) { +// List a single directory level (non-recursive). Symlinks are skipped, and +// the requested sub-path is resolved via realpath to guarantee it has not +// been redirected out of the customer folder by an existing symlink. +function listCustomerDir(baseDir, sub) { + const dirAbs = sub ? safeJoin(baseDir, sub) : baseDir; + let real, baseReal; + try { real = fs.realpathSync(dirAbs); baseReal = fs.realpathSync(baseDir); } + catch { return null; } + if (real !== baseReal && !real.startsWith(baseReal + path.sep)) return null; + let entries; + try { entries = fs.readdirSync(real, { withFileTypes: true }); } + catch { return null; } const out = []; - const queue = [{ dir: baseDir, depth: 0 }]; + for (const e of entries) { + if (e.isSymbolicLink()) continue; + const abs = path.join(real, e.name); + try { + const st = fs.lstatSync(abs); + if (st.isDirectory()) { + out.push({ name: e.name, type: 'dir', mtime: st.mtimeMs }); + } else if (st.isFile()) { + out.push({ name: e.name, type: 'file', size: st.size, mtime: st.mtimeMs }); + } + } catch {} + } + // Folders first, then files, each sorted newest-first + return out.sort((a, b) => { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; + return b.mtime - a.mtime; + }); +} + +// Iterative, depth-limited, symlink-skipping walker — used for ZIP packing. +function* walkSafe(rootAbs, maxDepth = MAX_WALK_DEPTH) { + const queue = [{ dir: rootAbs, depth: 0 }]; while (queue.length) { const { dir, depth } = queue.shift(); - if (depth > MAX_WALK_DEPTH) continue; + if (depth > maxDepth) continue; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const e of entries) { @@ -618,17 +649,11 @@ function listCustomerFiles(baseDir) { } 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, - }); + if (st.isFile()) yield { abs, rel: path.relative(rootAbs, abs).split(path.sep).join('/') }; } catch {} } } } - return out.sort((a, b) => b.mtime - a.mtime); } // Strip control chars / CRLF / quotes for Content-Disposition. @@ -642,7 +667,13 @@ function cdFilename(name) { app.get('/u/:token/files', uploadAuth, (req, res) => { const c = req._customer; - res.json(listCustomerFiles(customerDir(c.slug))); + const base = customerDir(c.slug); + const sub = sanitizeRelPath(req.query.dir || ''); + let entries; + try { entries = listCustomerDir(base, sub); } + catch { return res.status(400).json({ error: 'invalid path' }); } + if (entries === null) return res.status(404).json({ error: 'not found' }); + res.json({ dir: sub, entries }); }); app.get('/u/:token/file', uploadAuth, (req, res) => { @@ -667,6 +698,35 @@ app.get('/u/:token/file', uploadAuth, (req, res) => { res.sendFile(real); }); +// Stream a ZIP of a folder (or the whole customer dir when dir param is empty). +app.get('/u/:token/zip', uploadAuth, (req, res) => { + const c = req._customer; + const base = customerDir(c.slug); + const sub = sanitizeRelPath(req.query.dir || ''); + const dirAbs = sub ? safeJoin(base, sub) : base; + // Containment check after symlink resolution + let real; + try { real = fs.realpathSync(dirAbs); } catch { return res.status(404).end(); } + const baseReal = fs.realpathSync(base); + if (real !== baseReal && !real.startsWith(baseReal + path.sep)) { + return res.status(404).end(); + } + if (!fs.lstatSync(real).isDirectory()) return res.status(404).end(); + + const zipName = (sub ? path.basename(sub) : c.slug) + '.zip'; + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', cdFilename(zipName)); + res.setHeader('X-Content-Type-Options', 'nosniff'); + + const archive = archiver('zip', { zlib: { level: 6 } }); + archive.on('error', err => { console.error('[zip]', err.message); try { res.end(); } catch {} }); + archive.pipe(res); + for (const { abs, rel } of walkSafe(real)) { + archive.file(abs, { name: rel }); + } + archive.finalize(); +}); + app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => { const c = req._customer; const f = req.file;