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:
parent
2b1417ccf3
commit
386855d76a
|
|
@ -404,7 +404,11 @@
|
||||||
<form id="settingsForm">
|
<form id="settingsForm">
|
||||||
<div class="field"><label>Öffentliche Basis-URL</label>
|
<div class="field"><label>Öffentliche Basis-URL</label>
|
||||||
<input name="public_base_url" placeholder="z. B. https://upload.example.com" />
|
<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://<host>:1900/</code>.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Cron-Intervall (Minuten)</label>
|
<div class="field"><label>Cron-Intervall (Minuten)</label>
|
||||||
<input name="janitor_interval_minutes" type="number" min="1" style="max-width: 8rem" />
|
<input name="janitor_interval_minutes" type="number" min="1" style="max-width: 8rem" />
|
||||||
|
|
@ -547,7 +551,8 @@ async function bootstrap() {
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('createCustomerCard').style.display = '';
|
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');
|
show('view-app');
|
||||||
loadCustomers();
|
loadCustomers();
|
||||||
}
|
}
|
||||||
|
|
@ -754,6 +759,7 @@ async function loadSettings() {
|
||||||
const s = await api.get('/settings');
|
const s = await api.get('/settings');
|
||||||
const form = document.getElementById('settingsForm');
|
const form = document.getElementById('settingsForm');
|
||||||
form.public_base_url.value = s.public_base_url || '';
|
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;
|
form.janitor_interval_minutes.value = s.janitor_interval_minutes || 30;
|
||||||
setSlider('logoWidth', s.logo_width_px || 0);
|
setSlider('logoWidth', s.logo_width_px || 0);
|
||||||
setSlider('logoHeight', s.logo_height_px || 0);
|
setSlider('logoHeight', s.logo_height_px || 0);
|
||||||
|
|
@ -806,10 +812,14 @@ document.getElementById('settingsForm').addEventListener('submit', async (e) =>
|
||||||
try {
|
try {
|
||||||
await api.send('PUT', '/settings', {
|
await api.send('PUT', '/settings', {
|
||||||
public_base_url: fd.get('public_base_url') || '',
|
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),
|
janitor_interval_minutes: parseInt(fd.get('janitor_interval_minutes') || '30', 10),
|
||||||
logo_width_px: getSlider('logoWidth'),
|
logo_width_px: getSlider('logoWidth'),
|
||||||
logo_height_px: getSlider('logoHeight'),
|
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');
|
const msg = document.getElementById('settingsMsg');
|
||||||
msg.textContent = '✓ Gespeichert';
|
msg.textContent = '✓ Gespeichert';
|
||||||
setTimeout(() => msg.textContent = '', 2000);
|
setTimeout(() => msg.textContent = '', 2000);
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,22 @@
|
||||||
padding: .15rem .55rem; border-radius: 999px; font-size: .75rem;
|
padding: .15rem .55rem; border-radius: 999px; font-size: .75rem;
|
||||||
color: var(--text-muted); border: 1px solid var(--border-strong); }
|
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; }
|
footer { margin-top: 2rem; color: var(--text-dim); font-size: .8rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -159,6 +175,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="list"></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -205,9 +226,65 @@ async function init() {
|
||||||
info.textContent = 'Lade Dateien oder ganze Ordner hoch — die Ordnerstruktur bleibt erhalten.';
|
info.textContent = 'Lade Dateien oder ganze Ordner hoch — die Ordnerstruktur bleibt erhalten.';
|
||||||
}
|
}
|
||||||
if (data.has_password) gate.style.display = 'block';
|
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 => ({'<':'<','>':'>','&':'&','"':'"'}[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 () => {
|
document.getElementById('pwBtn').onclick = async () => {
|
||||||
const pw = document.getElementById('pw').value;
|
const pw = document.getElementById('pw').value;
|
||||||
const r = await fetch(`/u/${token}/auth`, {
|
const r = await fetch(`/u/${token}/auth`, {
|
||||||
|
|
@ -215,7 +292,7 @@ document.getElementById('pwBtn').onclick = async () => {
|
||||||
body: JSON.stringify({ password: pw }),
|
body: JSON.stringify({ password: pw }),
|
||||||
});
|
});
|
||||||
const j = await r.json();
|
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';
|
else document.getElementById('pwErr').style.display='block';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -272,6 +349,7 @@ async function uploadOne(file, relPath) {
|
||||||
|
|
||||||
async function uploadFiles(items) {
|
async function uploadFiles(items) {
|
||||||
for (const { file, path } of items) await uploadOne(file, path);
|
for (const { file, path } of items) await uploadOne(file, path);
|
||||||
|
loadFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('fileInput').onchange = (e) => {
|
document.getElementById('fileInput').onchange = (e) => {
|
||||||
|
|
|
||||||
|
|
@ -138,11 +138,14 @@ publicApi.get('/branding', (req, res) => {
|
||||||
|
|
||||||
publicApi.get('/status', (req, res) => {
|
publicApi.get('/status', (req, res) => {
|
||||||
const u = auth.getSessionUser(req);
|
const u = auth.getSessionUser(req);
|
||||||
res.json({
|
const payload = {
|
||||||
setup_required: !auth.hasAnyUser(),
|
setup_required: !auth.hasAnyUser(),
|
||||||
authenticated: !!u,
|
authenticated: !!u,
|
||||||
user: u ? { id: u.id, username: u.username, role: u.role } : null,
|
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) => {
|
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) => {
|
api.get('/settings', auth.requireAdmin, (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
public_base_url: settings.get('public_base_url', ''),
|
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),
|
janitor_interval_minutes: parseInt(settings.get('janitor_interval_minutes', '30'), 10),
|
||||||
logo_filename: settings.get('logo_filename', ''),
|
logo_filename: settings.get('logo_filename', ''),
|
||||||
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
|
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) {
|
if (b.public_base_url !== undefined) {
|
||||||
settings.set('public_base_url', String(b.public_base_url || '').trim().replace(/\/+$/, ''));
|
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) {
|
if (b.janitor_interval_minutes !== undefined) {
|
||||||
const n = Math.max(1, parseInt(b.janitor_interval_minutes, 10) || 30);
|
const n = Math.max(1, parseInt(b.janitor_interval_minutes, 10) || 30);
|
||||||
settings.set('janitor_interval_minutes', String(n));
|
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) => {
|
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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue