Add auto-refresh on the customer page and harden the new poll endpoints

The upload page now polls /info and /files every 20s while visible:
new uploads (also via WebDAV), expiry changes and link deactivation
appear without a manual reload. A pollBusy flag prevents overlapping
fetches on slow connections, and visibilitychange stops the timer in
backgrounded tabs. /info and /files get Cache-Control: no-store so the
browser cannot serve stale state, plus a 60/min/IP customerPollLimiter
to cap abuse from leaked tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-16 15:36:07 +02:00
parent b2d6c547a9
commit 82782c4f92
2 changed files with 117 additions and 35 deletions

View File

@ -225,28 +225,97 @@ async function applyBranding() {
} catch {} } catch {}
} }
async function init() { function applyInfo(data) {
await applyBranding();
const r = await fetch(`/u/${token}/info`);
if (!r.ok) {
document.querySelector('.card').innerHTML =
'<h1>Link ungültig oder abgelaufen</h1><p class="subtitle">Bitte wende dich an deinen Ansprechpartner für einen neuen Link.</p>';
return;
}
const data = await r.json();
window._uploadInfo = data; window._uploadInfo = data;
document.getElementById('title').textContent = `Upload für ${data.name}`; document.getElementById('title').textContent = `Upload für ${data.name}`;
if (data.expired) { if (data.expired) {
info.innerHTML = `<span class="expires" style="color:var(--warn,#f59e0b); border-color:var(--warn,#f59e0b)">⚠ Link ist abgelaufen — Uploads sind weiterhin möglich, der Datei-Browser ist deaktiviert.</span>`; info.innerHTML = `<span class="expires" style="color:#f59e0b; border-color:#f59e0b">⚠ Link ist abgelaufen — Uploads sind weiterhin möglich, der Datei-Browser ist deaktiviert.</span>`;
} else if (data.expires_at) { } else if (data.expires_at) {
info.innerHTML = `<span class="expires">⏳ gültig bis ${new Date(data.expires_at).toLocaleString()}</span>`; info.innerHTML = `<span class="expires">⏳ gültig bis ${new Date(data.expires_at).toLocaleString()}</span>`;
} else { } else {
info.textContent = 'Lade Dateien oder ganze Ordner hoch — die Ordnerstruktur bleibt erhalten.'; info.textContent = 'Lade Dateien oder ganze Ordner hoch — die Ordnerstruktur bleibt erhalten.';
} }
// Browser visibility synced to expiry
const browser = document.querySelector('.browser');
if (browser) browser.style.display = data.expired ? 'none' : '';
}
function showLinkGone(reason) {
stopPolling();
document.querySelector('.card').innerHTML =
`<h1>Link nicht mehr verfügbar</h1><p class="subtitle">${reason}</p>`;
}
async function init() {
await applyBranding();
const r = await fetch(`/u/${token}/info`);
if (!r.ok) {
showLinkGone('Bitte wende dich an deinen Ansprechpartner für einen neuen Link.');
return;
}
const data = await r.json();
applyInfo(data);
if (data.has_password) gate.style.display = 'block'; if (data.has_password) gate.style.display = 'block';
else { main.style.display = 'block'; loadFiles(); } else { main.style.display = 'block'; loadFiles(); }
startPolling();
} }
// --- Auto-refresh ---
const POLL_INTERVAL_MS = 20000;
let pollTimer = null;
let pollBusy = false;
let lastInfoSig = '';
let lastFilesSig = '';
async function pollState() {
if (pollBusy) return;
pollBusy = true;
try {
const r = await fetch(`/u/${token}/info`);
if (r.status === 404) {
showLinkGone('Der Upload-Link wurde deaktiviert oder gelöscht.');
return;
}
if (!r.ok) return;
const data = await r.json();
const sig = JSON.stringify(data);
if (sig !== lastInfoSig) {
lastInfoSig = sig;
applyInfo(data);
// Newly expired or unexpired → reload list
if (!data.expired && main.style.display !== 'none') loadFiles();
}
if (!data.expired && main.style.display !== 'none') await refreshFilesIfChanged();
} catch {}
finally { pollBusy = false; }
}
async function refreshFilesIfChanged() {
try {
const url = `/u/${token}/files${currentDir ? '?dir=' + encodeURIComponent(currentDir) : ''}`;
const r = await fetch(url, { headers: authHeaders() });
if (r.status === 410) { applyInfo({ ...(window._uploadInfo || {}), expired: true }); return; }
if (!r.ok) return;
const data = await r.json();
const sig = JSON.stringify(data);
if (sig === lastFilesSig) return;
lastFilesSig = sig;
renderFiles(data);
} catch {}
}
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(pollState, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) stopPolling();
else if (window._uploadInfo) startPolling();
});
function authHeaders() { function authHeaders() {
return password ? { 'X-Upload-Password': password } : {}; return password ? { 'X-Upload-Password': password } : {};
} }
@ -269,31 +338,16 @@ function renderCrumbs() {
cr.innerHTML = html; cr.innerHTML = html;
} }
async function loadFiles() { function renderFiles(data) {
const browser = document.getElementById('fileBrowser'); const browser = document.getElementById('fileBrowser');
const count = document.getElementById('fileCount'); const count = document.getElementById('fileCount');
// Browser komplett ausblenden wenn Link abgelaufen ist const entries = (data && data.entries) || [];
if (window._uploadInfo && window._uploadInfo.expired) { count.textContent = entries.length ? `(${entries.length})` : '';
document.querySelector('.browser').style.display = 'none'; if (!entries.length) {
browser.innerHTML = '<div class="empty">Dieser Ordner ist leer.</div>';
return; return;
} }
renderCrumbs(); browser.innerHTML = entries.map(e => {
try {
const url = `/u/${token}/files${currentDir ? '?dir=' + encodeURIComponent(currentDir) : ''}`;
const r = await fetch(url, { headers: authHeaders() });
if (r.status === 410) {
document.querySelector('.browser').style.display = 'none';
return;
}
if (!r.ok) { browser.innerHTML = '<div class="empty">Konnte Dateien nicht laden.</div>'; count.textContent = ''; return; }
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 = entries.map(e => {
const name = esc(e.name); const name = esc(e.name);
const date = new Date(e.mtime).toLocaleString(); const date = new Date(e.mtime).toLocaleString();
const fullPath = currentDir ? currentDir + '/' + e.name : e.name; const fullPath = currentDir ? currentDir + '/' + e.name : e.name;
@ -321,7 +375,24 @@ async function loadFiles() {
<button type="button" class="btn dl" data-path="${escPath}">⬇ Download</button> <button type="button" class="btn dl" data-path="${escPath}">⬇ Download</button>
</div>`; </div>`;
}).join(''); }).join('');
} catch (ex) { }
async function loadFiles() {
const browser = document.getElementById('fileBrowser');
if (window._uploadInfo && window._uploadInfo.expired) {
document.querySelector('.browser').style.display = 'none';
return;
}
renderCrumbs();
try {
const url = `/u/${token}/files${currentDir ? '?dir=' + encodeURIComponent(currentDir) : ''}`;
const r = await fetch(url, { headers: authHeaders() });
if (r.status === 410) { document.querySelector('.browser').style.display = 'none'; return; }
if (!r.ok) { browser.innerHTML = '<div class="empty">Konnte Dateien nicht laden.</div>'; return; }
const data = await r.json();
lastFilesSig = JSON.stringify(data);
renderFiles(data);
} catch {
browser.innerHTML = '<div class="empty">Fehler beim Laden.</div>'; browser.innerHTML = '<div class="empty">Fehler beim Laden.</div>';
} }
} }
@ -382,7 +453,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'; loadFiles(); } if (j.ok) { password = pw; gate.style.display='none'; main.style.display='block'; loadFiles(); startPolling(); }
else document.getElementById('pwErr').style.display='block'; else document.getElementById('pwErr').style.display='block';
}; };

View File

@ -64,6 +64,15 @@ const customerAuthLimiter = rateLimit({
legacyHeaders: false, legacyHeaders: false,
message: { error: 'too many attempts, try again later' }, message: { error: 'too many attempts, try again later' },
}); });
// Polling endpoints — generous but not unlimited, mainly to cap abuse from
// leaked tokens spamming the cheap read endpoints.
const customerPollLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'rate limited' },
});
// ---------- Helpers ---------- // ---------- Helpers ----------
function slugify(name) { function slugify(name) {
@ -551,9 +560,10 @@ app.post('/u/:token/auth', customerAuthLimiter, async (req, res) => {
res.json({ ok }); res.json({ ok });
}); });
app.get('/u/:token/info', (req, res) => { app.get('/u/:token/info', customerPollLimiter, (req, res) => {
const c = getCustomerByToken(req.params.token); const c = getCustomerByToken(req.params.token);
if (!c || isArchived(c)) return res.status(404).json({ error: 'invalid' }); if (!c || isArchived(c)) return res.status(404).json({ error: 'invalid' });
res.setHeader('Cache-Control', 'no-store');
res.json({ res.json({
name: c.name, name: c.name,
has_password: !!c.password_hash, has_password: !!c.password_hash,
@ -676,7 +686,7 @@ function requireNotExpired(req, res, next) {
next(); next();
} }
app.get('/u/:token/files', uploadAuth, requireNotExpired, (req, res) => { app.get('/u/:token/files', customerPollLimiter, uploadAuth, requireNotExpired, (req, res) => {
const c = req._customer; const c = req._customer;
const base = customerDir(c.slug); const base = customerDir(c.slug);
const sub = sanitizeRelPath(req.query.dir || ''); const sub = sanitizeRelPath(req.query.dir || '');
@ -684,6 +694,7 @@ app.get('/u/:token/files', uploadAuth, requireNotExpired, (req, res) => {
try { entries = listCustomerDir(base, sub); } try { entries = listCustomerDir(base, sub); }
catch { return res.status(400).json({ error: 'invalid path' }); } catch { return res.status(400).json({ error: 'invalid path' }); }
if (entries === null) return res.status(404).json({ error: 'not found' }); if (entries === null) return res.status(404).json({ error: 'not found' });
res.setHeader('Cache-Control', 'no-store');
res.json({ dir: sub, entries }); res.json({ dir: sub, entries });
}); });