From 82782c4f922e44326277e320bdd7a328f38d1486 Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Thu, 16 Apr 2026 15:36:07 +0200 Subject: [PATCH] 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) --- public/upload.html | 137 ++++++++++++++++++++++++++++++++++----------- src/server.js | 15 ++++- 2 files changed, 117 insertions(+), 35 deletions(-) diff --git a/public/upload.html b/public/upload.html index 3990ede..f9e44e5 100644 --- a/public/upload.html +++ b/public/upload.html @@ -225,28 +225,97 @@ async function applyBranding() { } catch {} } -async function init() { - await applyBranding(); - const r = await fetch(`/u/${token}/info`); - if (!r.ok) { - document.querySelector('.card').innerHTML = - '

Link ungültig oder abgelaufen

Bitte wende dich an deinen Ansprechpartner für einen neuen Link.

'; - return; - } - const data = await r.json(); +function applyInfo(data) { window._uploadInfo = data; document.getElementById('title').textContent = `Upload für ${data.name}`; if (data.expired) { - info.innerHTML = `⚠ Link ist abgelaufen — Uploads sind weiterhin möglich, der Datei-Browser ist deaktiviert.`; + info.innerHTML = `⚠ Link ist abgelaufen — Uploads sind weiterhin möglich, der Datei-Browser ist deaktiviert.`; } else if (data.expires_at) { info.innerHTML = `⏳ gültig bis ${new Date(data.expires_at).toLocaleString()}`; } else { 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 = + `

Link nicht mehr verfügbar

${reason}

`; +} + +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'; 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() { return password ? { 'X-Upload-Password': password } : {}; } @@ -269,31 +338,16 @@ function renderCrumbs() { cr.innerHTML = html; } -async function loadFiles() { +function renderFiles(data) { const browser = document.getElementById('fileBrowser'); const count = document.getElementById('fileCount'); - // Browser komplett ausblenden wenn Link abgelaufen ist - if (window._uploadInfo && window._uploadInfo.expired) { - document.querySelector('.browser').style.display = 'none'; + const entries = (data && data.entries) || []; + count.textContent = entries.length ? `(${entries.length})` : ''; + if (!entries.length) { + browser.innerHTML = '
Dieser Ordner ist leer.
'; 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 = '
Konnte Dateien nicht laden.
'; count.textContent = ''; return; } - const data = await r.json(); - const entries = data.entries || []; - count.textContent = entries.length ? `(${entries.length})` : ''; - if (!entries.length) { - browser.innerHTML = '
Dieser Ordner ist leer.
'; - return; - } - browser.innerHTML = entries.map(e => { + 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; @@ -321,7 +375,24 @@ async function loadFiles() { `; }).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 = '
Konnte Dateien nicht laden.
'; return; } + const data = await r.json(); + lastFilesSig = JSON.stringify(data); + renderFiles(data); + } catch { browser.innerHTML = '
Fehler beim Laden.
'; } } @@ -382,7 +453,7 @@ document.getElementById('pwBtn').onclick = async () => { body: JSON.stringify({ password: pw }), }); 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'; }; diff --git a/src/server.js b/src/server.js index e7e9b4d..dca2db1 100644 --- a/src/server.js +++ b/src/server.js @@ -64,6 +64,15 @@ const customerAuthLimiter = rateLimit({ legacyHeaders: false, 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 ---------- function slugify(name) { @@ -551,9 +560,10 @@ app.post('/u/:token/auth', customerAuthLimiter, async (req, res) => { res.json({ ok }); }); -app.get('/u/:token/info', (req, res) => { +app.get('/u/:token/info', customerPollLimiter, (req, res) => { const c = getCustomerByToken(req.params.token); if (!c || isArchived(c)) return res.status(404).json({ error: 'invalid' }); + res.setHeader('Cache-Control', 'no-store'); res.json({ name: c.name, has_password: !!c.password_hash, @@ -676,7 +686,7 @@ function requireNotExpired(req, res, 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 base = customerDir(c.slug); const sub = sanitizeRelPath(req.query.dir || ''); @@ -684,6 +694,7 @@ app.get('/u/:token/files', uploadAuth, requireNotExpired, (req, res) => { 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.setHeader('Cache-Control', 'no-store'); res.json({ dir: sub, entries }); });