From b2d6c547a970a4ac15af0f771dc6788216c15b7f Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Thu, 16 Apr 2026 15:10:54 +0200 Subject: [PATCH] Let expired customer links keep accepting uploads uploadAuth now only blocks archived/missing tokens. A new requireNotExpired middleware sits in front of /files, /file and /zip, so the file browser closes (410 Gone) once the link expires while the upload form stays open. /info reports the expired flag so the page can hide the browser section and show a warning banner. Co-Authored-By: Claude Opus 4.6 (1M context) --- public/upload.html | 14 +++++++++++++- src/server.js | 25 ++++++++++++++++++------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/public/upload.html b/public/upload.html index a83ec44..3990ede 100644 --- a/public/upload.html +++ b/public/upload.html @@ -234,8 +234,11 @@ async function init() { return; } const data = await r.json(); + window._uploadInfo = data; document.getElementById('title').textContent = `Upload für ${data.name}`; - if (data.expires_at) { + if (data.expired) { + 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.'; @@ -269,10 +272,19 @@ function renderCrumbs() { async function loadFiles() { 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 = '
Konnte Dateien nicht laden.
'; count.textContent = ''; return; } const data = await r.json(); const entries = data.entries || []; diff --git a/src/server.js b/src/server.js index e024379..e7e9b4d 100644 --- a/src/server.js +++ b/src/server.js @@ -537,14 +537,15 @@ app.use('/admin/api', api); // ---------- Customer Upload Portal ---------- app.get('/u/:token', (req, res) => { const c = getCustomerByToken(req.params.token); + // Expiry no longer blocks the page — only archive/missing token does. + // Uploads stay open; the file browser is gated separately below. if (!c || isArchived(c)) return res.status(404).send('Link nicht gefunden.'); - if (isExpired(c)) return res.status(410).send('Link ist abgelaufen.'); res.sendFile(path.join(__dirname, '..', 'public', 'upload.html')); }); app.post('/u/:token/auth', customerAuthLimiter, async (req, res) => { const c = getCustomerByToken(req.params.token); - if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' }); + if (!c || isArchived(c)) return res.status(404).json({ error: 'invalid' }); if (!c.password_hash) return res.json({ ok: true }); const ok = await bcrypt.compare(req.body.password || '', c.password_hash); res.json({ ok }); @@ -552,11 +553,12 @@ app.post('/u/:token/auth', customerAuthLimiter, async (req, res) => { app.get('/u/:token/info', (req, res) => { const c = getCustomerByToken(req.params.token); - if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' }); + if (!c || isArchived(c)) return res.status(404).json({ error: 'invalid' }); res.json({ name: c.name, has_password: !!c.password_hash, expires_at: c.expires_at, + expired: isExpired(c), }); }); @@ -583,9 +585,12 @@ const upload = multer({ limits: { fileSize: 10 * 1024 * 1024 * 1024 }, }); +// Customer-side auth: archive/token check + optional password. +// Expiry intentionally does NOT block here; uploads stay possible after +// the link expired. Use requireNotExpired below to gate read-only actions. function uploadAuth(req, res, next) { const c = getCustomerByToken(req.params.token); - if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' }); + if (!c || isArchived(c)) return res.status(404).json({ error: 'invalid' }); if (c.password_hash) { const provided = req.headers['x-upload-password'] || ''; bcrypt.compare(provided, c.password_hash).then(ok => { @@ -665,7 +670,13 @@ function cdFilename(name) { return `attachment; filename="${safe}"; filename*=UTF-8''${encoded}`; } -app.get('/u/:token/files', uploadAuth, (req, res) => { +// Browse-only guard (after uploadAuth). Uploads are unaffected. +function requireNotExpired(req, res, next) { + if (isExpired(req._customer)) return res.status(410).json({ error: 'expired' }); + next(); +} + +app.get('/u/:token/files', uploadAuth, requireNotExpired, (req, res) => { const c = req._customer; const base = customerDir(c.slug); const sub = sanitizeRelPath(req.query.dir || ''); @@ -676,7 +687,7 @@ app.get('/u/:token/files', uploadAuth, (req, res) => { res.json({ dir: sub, entries }); }); -app.get('/u/:token/file', uploadAuth, (req, res) => { +app.get('/u/:token/file', uploadAuth, requireNotExpired, (req, res) => { const c = req._customer; const base = customerDir(c.slug); const rel = sanitizeRelPath(req.query.path || ''); @@ -699,7 +710,7 @@ app.get('/u/:token/file', uploadAuth, (req, res) => { }); // Stream a ZIP of a folder (or the whole customer dir when dir param is empty). -app.get('/u/:token/zip', uploadAuth, (req, res) => { +app.get('/u/:token/zip', uploadAuth, requireNotExpired, (req, res) => { const c = req._customer; const base = customerDir(c.slug); const sub = sanitizeRelPath(req.query.dir || '');