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:
+98
-20
@@ -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; }
|
||||
</style>
|
||||
@@ -178,6 +191,8 @@
|
||||
|
||||
<div class="browser">
|
||||
<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>
|
||||
</div>
|
||||
@@ -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 = `<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() {
|
||||
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 = '<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>';
|
||||
const data = await r.json();
|
||||
const entries = data.entries || [];
|
||||
count.textContent = entries.length ? `(${entries.length})` : '';
|
||||
if (!entries.length) {
|
||||
browser.innerHTML = '<div class="empty">Dieser Ordner ist leer.</div>';
|
||||
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 `
|
||||
<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 `
|
||||
<div class="row">
|
||||
<div class="icon">📄</div>
|
||||
@@ -255,34 +305,62 @@ async function loadFiles() {
|
||||
<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 class="meta">${fmtSize(e.size)}</div>
|
||||
<button type="button" class="btn dl" data-path="${escPath}">⬇ Download</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
} catch (ex) {
|
||||
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';
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user