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) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-16 15:10:54 +02:00
parent fd5e917249
commit b2d6c547a9
2 changed files with 31 additions and 8 deletions

View File

@ -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 = `<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>`;
} 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.';
@ -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 = '<div class="empty">Konnte Dateien nicht laden.</div>'; count.textContent = ''; return; }
const data = await r.json();
const entries = data.entries || [];

View File

@ -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 || '');