Add hierarchical customer file browser with folder ZIP downloads

- /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) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-16 14:29:56 +02:00
parent d4c1d1f4bf
commit fd5e917249
4 changed files with 197 additions and 38 deletions

View File

@ -11,6 +11,7 @@
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"express": "^4.21.0", "express": "^4.21.0",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"archiver": "^7.0.1",
"express-rate-limit": "^7.4.0", "express-rate-limit": "^7.4.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"multer": "^2.0.0", "multer": "^2.0.0",

View File

@ -292,7 +292,11 @@
</table> </table>
</div> </div>
</div> </div>
<p class="small" style="margin-top:.75rem">WebDAV-Server: <code id="webdavUrl"></code> — Login mit deinem eigenen Benutzer.</p> <p class="small" style="margin-top:.75rem">
WebDAV-Server: <code id="webdavUrl"></code><br>
Windows-UNC: <code id="webdavUnc"></code>
— Login mit deinem eigenen Benutzer.
</p>
</section> </section>
<!-- USERS TAB --> <!-- USERS TAB -->
@ -500,6 +504,24 @@ function show(view) {
document.getElementById(id).style.display = id === view ? '' : 'none'; 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) { function fmtSize(n) {
if (!n) return '0 B'; if (!n) return '0 B';
if (n < 1024) return n + ' B'; if (n < 1024) return n + ' B';
@ -551,8 +573,7 @@ async function bootstrap() {
} else { } else {
document.getElementById('createCustomerCard').style.display = ''; document.getElementById('createCustomerCard').style.display = '';
} }
document.getElementById('webdavUrl').textContent = setWebdavDisplay((status.webdav_url || '').trim() || `webdav://${location.hostname}:1900/`);
(status.webdav_url || '').trim() || `webdav://${location.hostname}:1900/`;
show('view-app'); show('view-app');
loadCustomers(); loadCustomers();
} }
@ -818,8 +839,7 @@ document.getElementById('settingsForm').addEventListener('submit', async (e) =>
logo_height_px: getSlider('logoHeight'), logo_height_px: getSlider('logoHeight'),
}); });
// Refresh main view in case the WebDAV-URL display needs an update. // Refresh main view in case the WebDAV-URL display needs an update.
document.getElementById('webdavUrl').textContent = setWebdavDisplay((fd.get('webdav_url') || '').trim() || `webdav://${location.hostname}:1900/`);
(fd.get('webdav_url') || '').trim() || `webdav://${location.hostname}:1900/`;
const msg = document.getElementById('settingsMsg'); const msg = document.getElementById('settingsMsg');
msg.textContent = '✓ Gespeichert'; msg.textContent = '✓ Gespeichert';
setTimeout(() => msg.textContent = '', 2000); setTimeout(() => msg.textContent = '', 2000);

View File

@ -137,6 +137,19 @@
.browser .row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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 .meta { color: var(--text-muted); font-size: .8rem; font-variant-numeric: tabular-nums; }
.browser .row .dl { padding: .3rem .65rem; font-size: .8rem; } .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; } footer { margin-top: 2rem; color: var(--text-dim); font-size: .8rem; }
</style> </style>
@ -178,6 +191,8 @@
<div class="browser"> <div class="browser">
<h2>Bisher hochgeladene und/oder empfangene Dateien <span class="count" id="fileCount"></span></h2> <h2>Bisher hochgeladene und/oder empfangene Dateien <span class="count" id="fileCount"></span></h2>
<div class="crumbs" id="crumbs"></div>
<div class="toolbar"><button type="button" class="btn dl" id="zipCurrent">⬇ Aktuellen Ordner als ZIP</button></div>
<div id="fileBrowser"><div class="empty"> wird geladen </div></div> <div id="fileBrowser"><div class="empty"> wird geladen </div></div>
</div> </div>
</div> </div>
@ -233,21 +248,56 @@ function authHeaders() {
return password ? { 'X-Upload-Password': password } : {}; return password ? { 'X-Upload-Password': password } : {};
} }
let currentDir = '';
function esc(s) {
return String(s).replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
}
function renderCrumbs() {
const cr = document.getElementById('crumbs');
const segs = currentDir ? currentDir.split('/') : [];
let acc = '';
let html = `<button data-go="">🏠 Hauptordner</button>`;
for (const s of segs) {
acc = acc ? acc + '/' + s : s;
html += `<span></span><button data-go="${esc(acc)}">${esc(s)}</button>`;
}
cr.innerHTML = html;
}
async function loadFiles() { async function loadFiles() {
const browser = document.getElementById('fileBrowser'); const browser = document.getElementById('fileBrowser');
const count = document.getElementById('fileCount'); const count = document.getElementById('fileCount');
renderCrumbs();
try { 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 = '<div class="empty">Konnte Dateien nicht laden.</div>'; count.textContent = ''; return; } if (!r.ok) { browser.innerHTML = '<div class="empty">Konnte Dateien nicht laden.</div>'; count.textContent = ''; return; }
const files = await r.json(); const data = await r.json();
count.textContent = files.length ? `(${files.length})` : ''; const entries = data.entries || [];
if (!files.length) { count.textContent = entries.length ? `(${entries.length})` : '';
browser.innerHTML = '<div class="empty">Noch keine Dateien hochgeladen.</div>'; if (!entries.length) {
browser.innerHTML = '<div class="empty">Dieser Ordner ist leer.</div>';
return; return;
} }
browser.innerHTML = files.map(f => { browser.innerHTML = entries.map(e => {
const name = f.path.replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c])); const name = esc(e.name);
const date = new Date(f.mtime).toLocaleString(); const date = new Date(e.mtime).toLocaleString();
const fullPath = currentDir ? currentDir + '/' + e.name : e.name;
const escPath = esc(fullPath);
if (e.type === 'dir') {
return `
<div class="row dir">
<div class="icon">📁</div>
<div>
<div class="name" title="${name}" data-open="${escPath}">${name}/</div>
<div class="meta">${date}</div>
</div>
<div class="meta">Ordner</div>
<button type="button" class="btn dl" data-zip="${escPath}">⬇ ZIP</button>
</div>`;
}
return ` return `
<div class="row"> <div class="row">
<div class="icon">📄</div> <div class="icon">📄</div>
@ -255,34 +305,62 @@ async function loadFiles() {
<div class="name" title="${name}">${name}</div> <div class="name" title="${name}">${name}</div>
<div class="meta">${date}</div> <div class="meta">${date}</div>
</div> </div>
<div class="meta">${fmtSize(f.size)}</div> <div class="meta">${fmtSize(e.size)}</div>
<button type="button" class="btn dl" data-path="${name}">⬇ Download</button> <button type="button" class="btn dl" data-path="${escPath}">⬇ Download</button>
</div>`; </div>`;
}).join(''); }).join('');
} catch (e) { } catch (ex) {
browser.innerHTML = '<div class="empty">Fehler beim Laden.</div>'; browser.innerHTML = '<div class="empty">Fehler beim Laden.</div>';
} }
} }
document.getElementById('fileBrowser').addEventListener('click', async (e) => { async function streamDownload(url, suggestedName, btn) {
const btn = e.target.closest('button[data-path]'); const orig = btn.textContent;
if (!btn) return; btn.disabled = true; btn.textContent = '… lädt';
const filePath = btn.dataset.path;
btn.disabled = true; const orig = btn.textContent; btn.textContent = '… lädt';
try { 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}`); if (!r.ok) throw new Error(`HTTP ${r.status}`);
const blob = await r.blob(); const blob = await r.blob();
const url = URL.createObjectURL(blob); const objUrl = URL.createObjectURL(blob);
const a = document.createElement('a'); 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(); document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000); setTimeout(() => URL.revokeObjectURL(objUrl), 1000);
} catch (ex) { } catch (ex) {
alert('Download fehlgeschlagen: ' + ex.message); alert('Download fehlgeschlagen: ' + ex.message);
} finally { } finally {
btn.disabled = false; btn.textContent = orig; 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 () => { document.getElementById('pwBtn').onclick = async () => {

View File

@ -5,6 +5,7 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const multer = require('multer'); const multer = require('multer');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const archiver = require('archiver');
const { nanoid } = require('nanoid'); const { nanoid } = require('nanoid');
const db = require('./db'); const db = require('./db');
const auth = require('./auth'); const auth = require('./auth');
@ -600,14 +601,44 @@ function uploadAuth(req, res, next) {
const MAX_WALK_DEPTH = 20; const MAX_WALK_DEPTH = 20;
// Iterative walk with depth limit. Symlinks (file or directory) are skipped // List a single directory level (non-recursive). Symlinks are skipped, and
// so a malicious WebDAV upload cannot point out of the customer directory. // the requested sub-path is resolved via realpath to guarantee it has not
function listCustomerFiles(baseDir) { // 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 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) { while (queue.length) {
const { dir, depth } = queue.shift(); const { dir, depth } = queue.shift();
if (depth > MAX_WALK_DEPTH) continue; if (depth > maxDepth) continue;
let entries; let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
for (const e of entries) { for (const e of entries) {
@ -618,17 +649,11 @@ function listCustomerFiles(baseDir) {
} else if (e.isFile()) { } else if (e.isFile()) {
try { try {
const st = fs.lstatSync(abs); const st = fs.lstatSync(abs);
if (!st.isFile()) continue; if (st.isFile()) yield { abs, rel: path.relative(rootAbs, abs).split(path.sep).join('/') };
out.push({
path: path.relative(baseDir, abs).split(path.sep).join('/'),
size: st.size,
mtime: st.mtimeMs,
});
} catch {} } catch {}
} }
} }
} }
return out.sort((a, b) => b.mtime - a.mtime);
} }
// Strip control chars / CRLF / quotes for Content-Disposition. // Strip control chars / CRLF / quotes for Content-Disposition.
@ -642,7 +667,13 @@ function cdFilename(name) {
app.get('/u/:token/files', uploadAuth, (req, res) => { app.get('/u/:token/files', uploadAuth, (req, res) => {
const c = req._customer; 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) => { app.get('/u/:token/file', uploadAuth, (req, res) => {
@ -667,6 +698,35 @@ app.get('/u/:token/file', uploadAuth, (req, res) => {
res.sendFile(real); 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) => { app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => {
const c = req._customer; const c = req._customer;
const f = req.file; const f = req.file;