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:
parent
b2d6c547a9
commit
82782c4f92
|
|
@ -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 =
|
||||
'<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();
|
||||
function applyInfo(data) {
|
||||
window._uploadInfo = data;
|
||||
document.getElementById('title').textContent = `Upload für ${data.name}`;
|
||||
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) {
|
||||
info.innerHTML = `<span class="expires">⏳ gültig bis ${new Date(data.expires_at).toLocaleString()}</span>`;
|
||||
} 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 =
|
||||
`<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';
|
||||
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,25 +338,10 @@ 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';
|
||||
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>'; count.textContent = ''; return; }
|
||||
const data = await r.json();
|
||||
const entries = data.entries || [];
|
||||
const entries = (data && data.entries) || [];
|
||||
count.textContent = entries.length ? `(${entries.length})` : '';
|
||||
if (!entries.length) {
|
||||
browser.innerHTML = '<div class="empty">Dieser Ordner ist leer.</div>';
|
||||
|
|
@ -321,7 +375,24 @@ async function loadFiles() {
|
|||
<button type="button" class="btn dl" data-path="${escPath}">⬇ Download</button>
|
||||
</div>`;
|
||||
}).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>';
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue