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 {}
|
} 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,25 +338,10 @@ 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) {
|
|
||||||
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 || [];
|
|
||||||
count.textContent = entries.length ? `(${entries.length})` : '';
|
count.textContent = entries.length ? `(${entries.length})` : '';
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
browser.innerHTML = '<div class="empty">Dieser Ordner ist leer.</div>';
|
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>
|
<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';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue