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:
+73
-13
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user