fix: add unprotected /admin/login route, fix auth redirect loop

This commit is contained in:
2026-06-09 15:56:48 +10:00
parent daa2bb2f96
commit 6fef7b75cf
+9 -40
View File
@@ -27,7 +27,6 @@ const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300; const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false'; const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
// --- Image optimization ---
const IMAGE_MAX_WIDTH = parseInt(process.env.IMAGE_MAX_WIDTH, 10) || 1920; const IMAGE_MAX_WIDTH = parseInt(process.env.IMAGE_MAX_WIDTH, 10) || 1920;
const IMAGE_QUALITY = parseInt(process.env.IMAGE_QUALITY, 10) || 80; const IMAGE_QUALITY = parseInt(process.env.IMAGE_QUALITY, 10) || 80;
const IMAGE_CACHE_MAX = parseInt(process.env.IMAGE_CACHE_MAX, 10) || 100; const IMAGE_CACHE_MAX = parseInt(process.env.IMAGE_CACHE_MAX, 10) || 100;
@@ -48,7 +47,6 @@ function cacheSet(key, value) {
imageCache.set(key, value); imageCache.set(key, value);
} }
// --- Auth configuration ---
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin'; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
const FRAMBE_API_TOKEN = process.env.FRAMBE_API_TOKEN || ''; const FRAMBE_API_TOKEN = process.env.FRAMBE_API_TOKEN || '';
@@ -83,7 +81,7 @@ function requireAdminAuth(req, res, next) {
if (req.headers['accept'] && req.headers['accept'].includes('application/json')) { if (req.headers['accept'] && req.headers['accept'].includes('application/json')) {
return res.status(401).json({ error: 'Not authenticated' }); return res.status(401).json({ error: 'Not authenticated' });
} }
return res.redirect('/admin/login.html'); return res.redirect('/admin/login');
} }
function requireApiToken(req, res, next) { function requireApiToken(req, res, next) {
@@ -96,7 +94,6 @@ function requireApiToken(req, res, next) {
return res.status(401).json({ error: 'Invalid API token' }); return res.status(401).json({ error: 'Invalid API token' });
} }
// --- Helpers ---
function immichHeaders() { return { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }; } function immichHeaders() { return { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }; }
function log(msg) { console.log('[' + new Date().toISOString() + '] ' + msg); } function log(msg) { console.log('[' + new Date().toISOString() + '] ' + msg); }
function logErr(msg) { console.error('[' + new Date().toISOString() + '] ERROR ' + msg); } function logErr(msg) { console.error('[' + new Date().toISOString() + '] ERROR ' + msg); }
@@ -129,7 +126,6 @@ function mapAsset(a) {
}; };
} }
// --- WebSocket ---
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
const clients = new Map(); const clients = new Map();
@@ -152,10 +148,8 @@ wss.on('connection', (ws, req) => {
const ua = req.headers['user-agent'] || 'unknown'; const ua = req.headers['user-agent'] || 'unknown';
clients.set(id, { id, ws, userAgent: ua, connectedAt: now, firstSeen: now, lastSeen: now, status: 'connected' }); clients.set(id, { id, ws, userAgent: ua, connectedAt: now, firstSeen: now, lastSeen: now, status: 'connected' });
log('WS connect: ' + id + ' (' + ua.substring(0, 60) + ')'); log('WS connect: ' + id + ' (' + ua.substring(0, 60) + ')');
ws.send(JSON.stringify({ type: 'hello', clientId: id })); ws.send(JSON.stringify({ type: 'hello', clientId: id }));
broadcastAdminClients(); broadcastAdminClients();
ws.on('message', (raw) => { ws.on('message', (raw) => {
try { try {
const msg = JSON.parse(raw); const msg = JSON.parse(raw);
@@ -163,9 +157,8 @@ wss.on('connection', (ws, req) => {
if (c) { c.lastSeen = Date.now(); c.status = msg.status || c.status; } if (c) { c.lastSeen = Date.now(); c.status = msg.status || c.status; }
if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); broadcastAdminClients(); } if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); broadcastAdminClients(); }
else if (msg.type === 'status') { if (c) c.status = msg.status; broadcastAdminClients(); } else if (msg.type === 'status') { if (c) c.status = msg.status; broadcastAdminClients(); }
} catch (e) { /* ignore */ } } catch (e) { }
}); });
ws.on('close', () => { ws.on('close', () => {
const c = clients.get(id); const c = clients.get(id);
if (c) { c.online = false; c.lastSeen = Date.now(); } if (c) { c.online = false; c.lastSeen = Date.now(); }
@@ -177,7 +170,7 @@ wss.on('connection', (ws, req) => {
function broadcastAdminClients() { function broadcastAdminClients() {
const list = getClientList(); const list = getClientList();
const msg = JSON.stringify({ type: 'clients', clients: list }); const msg = JSON.stringify({ type: 'clients', clients: list });
wss.clients.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch (e) { /* ignore */ } } }); wss.clients.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch (e) { } } });
} }
function sendToClient(clientId, payload) { function sendToClient(clientId, payload) {
@@ -187,22 +180,15 @@ function sendToClient(clientId, payload) {
return true; return true;
} }
// --- Middleware ---
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: false })); app.use(express.urlencoded({ extended: false }));
app.use((req, res, next) => { app.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
res.set('Cache-Control', 'no-store');
next();
});
// --- Request logging ---
app.use((req, _res, next) => { log(req.method + ' ' + req.path); next(); }); app.use((req, _res, next) => { log(req.method + ' ' + req.path); next(); });
// --- Static --- app.get('/admin/login', (_req, res) => { if (!AUTH_ENABLED) return res.redirect('/admin'); res.sendFile(path.join(__dirname, 'public', 'admin', 'login.html')); });
app.use('/admin', requireAdminAuth, express.static(path.join(__dirname, 'public/admin'))); app.use('/admin', requireAdminAuth, express.static(path.join(__dirname, 'public/admin')));
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
// --- API ---
app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); }); app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); });
app.get('/api/auth/status', (_req, res) => { res.json({ authEnabled: AUTH_ENABLED, apiTokenEnabled: !!FRAMBE_API_TOKEN }); }); app.get('/api/auth/status', (_req, res) => { res.json({ authEnabled: AUTH_ENABLED, apiTokenEnabled: !!FRAMBE_API_TOKEN }); });
@@ -227,54 +213,39 @@ app.post('/api/auth/logout', (req, res) => {
}); });
app.get('/api/config', (_req, res) => { res.json({ version: VERSION, slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION, showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS, imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE, albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL, includeVideos: INCLUDE_VIDEOS, connected: !!API_KEY, authEnabled: AUTH_ENABLED, imageMaxWidth: IMAGE_MAX_WIDTH, imageQuality: IMAGE_QUALITY }); }); app.get('/api/config', (_req, res) => { res.json({ version: VERSION, slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION, showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS, imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE, albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL, includeVideos: INCLUDE_VIDEOS, connected: !!API_KEY, authEnabled: AUTH_ENABLED, imageMaxWidth: IMAGE_MAX_WIDTH, imageQuality: IMAGE_QUALITY }); });
app.get('/api/server-info', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/server/version', { headers: immichHeaders() }); if (!r.ok) throw new Error('Immich returned ' + r.status); const v = await r.json(); log('Immich OK v' + v.major + '.' + v.minor + '.' + v.patch); res.json({ ok: true, version: v }); } catch (e) { logErr('Immich failed: ' + e.message); res.status(502).json({ ok: false, error: e.message }); } }); app.get('/api/server-info', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/server/version', { headers: immichHeaders() }); if (!r.ok) throw new Error('Immich returned ' + r.status); const v = await r.json(); res.json({ ok: true, version: v }); } catch (e) { res.status(502).json({ ok: false, error: e.message }); } });
app.get('/api/albums', async (_req, res) => { try { const [rOwn, rShared] = await Promise.all([fetch(IMMICH_URL + '/api/albums', { headers: immichHeaders() }), fetch(IMMICH_URL + '/api/albums?shared=true', { headers: immichHeaders() })]); if (!rOwn.ok) throw new Error('Own albums: ' + rOwn.status); const aOwn = await rOwn.json(); const sharedRaw = rShared.ok ? await rShared.json() : []; const seen = new Set(); const result = []; for (const x of aOwn) { if (!seen.has(x.id)) { seen.add(x.id); result.push({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt, shared: false }); } } for (const x of (Array.isArray(sharedRaw) ? sharedRaw : [])) { if (!seen.has(x.id)) { seen.add(x.id); result.push({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt, shared: true }); } } log('Listed ' + result.length + ' albums (' + (result.length - aOwn.length) + ' shared)'); res.json(result); } catch (e) { logErr('Albums: ' + e.message); res.status(502).json({ error: e.message }); } }); app.get('/api/albums', async (_req, res) => { try { const [rOwn, rShared] = await Promise.all([fetch(IMMICH_URL + '/api/albums', { headers: immichHeaders() }), fetch(IMMICH_URL + '/api/albums?shared=true', { headers: immichHeaders() })]); if (!rOwn.ok) throw new Error('Own albums: ' + rOwn.status); const aOwn = await rOwn.json(); const sharedRaw = rShared.ok ? await rShared.json() : []; const seen = new Set(); const result = []; for (const x of aOwn) { if (!seen.has(x.id)) { seen.add(x.id); result.push({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt, shared: false }); } } for (const x of (Array.isArray(sharedRaw) ? sharedRaw : [])) { if (!seen.has(x.id)) { seen.add(x.id); result.push({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt, shared: true }); } } log('Listed ' + result.length + ' albums (' + (result.length - aOwn.length) + ' shared)'); res.json(result); } catch (e) { logErr('Albums: ' + e.message); res.status(502).json({ error: e.message }); } });
app.get('/api/albums/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/albums/' + req.params.id, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const al = await r.json(); const a = filterAssets(al.assets || []).map(mapAsset); log('Album "' + al.albumName + '": ' + a.length + ' assets'); res.json({ id: al.id, albumName: al.albumName, assetCount: a.length, assets: a }); } catch (e) { logErr('Album: ' + e.message); res.status(502).json({ error: e.message }); } }); app.get('/api/albums/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/albums/' + req.params.id, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const al = await r.json(); const a = filterAssets(al.assets || []).map(mapAsset); res.json({ id: al.id, albumName: al.albumName, assetCount: a.length, assets: a }); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json((d.people || d || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }))); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/people', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json((d.people || d || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }))); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people/' + req.params.id + '/assets', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const raw = await r.json(); res.json(filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/people/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people/' + req.params.id + '/assets', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const raw = await r.json(); res.json(filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people/:id/thumbnail', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people/' + req.params.id + '/thumbnail', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/people/:id/thumbnail', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people/' + req.params.id + '/thumbnail', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/random', async (req, res) => { try { const c = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(IMMICH_URL + '/api/assets/random?count=' + c, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); res.json(filterAssets(await r.json()).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/assets/random', async (req, res) => { try { const c = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(IMMICH_URL + '/api/assets/random?count=' + c, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); res.json(filterAssets(await r.json()).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/favorites', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/search/metadata', { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json(filterAssets(d.assets && d.assets.items ? d.assets.items : []).map(a => { var m = mapAsset(a); m.isFavorite = true; return m; })); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/assets/favorites', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/search/metadata', { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json(filterAssets(d.assets && d.assets.items ? d.assets.items : []).map(a => { var m = mapAsset(a); m.isFavorite = true; return m; })); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/thumbnail', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/thumbnail?size=' + (req.query.size || 'preview'), { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/assets/:id/thumbnail', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/thumbnail?size=' + (req.query.size || 'preview'), { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/optimized', async (req, res) => { app.get('/api/assets/:id/optimized', async (req, res) => {
const id = req.params.id; const id = req.params.id;
const cacheKey = id + ':' + IMAGE_MAX_WIDTH + ':' + IMAGE_QUALITY; const cacheKey = id + ':' + IMAGE_MAX_WIDTH + ':' + IMAGE_QUALITY;
const cached = cacheGet(cacheKey); const cached = cacheGet(cacheKey);
if (cached) { if (cached) { res.set('Content-Type', 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); return res.send(cached); }
res.set('Content-Type', 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400');
res.set('X-Cache', 'HIT');
return res.send(cached);
}
try { try {
const r = await fetch(IMMICH_URL + '/api/assets/' + id + '/thumbnail?size=preview', { headers: { 'x-api-key': API_KEY } }); const r = await fetch(IMMICH_URL + '/api/assets/' + id + '/thumbnail?size=preview', { headers: { 'x-api-key': API_KEY } });
if (!r.ok) throw new Error('' + r.status); if (!r.ok) throw new Error('' + r.status);
const buf = await r.buffer(); const buf = await r.buffer();
const optimized = await sharp(buf).resize({ width: IMAGE_MAX_WIDTH, withoutEnlargement: true }).jpeg({ quality: IMAGE_QUALITY }).toBuffer(); const optimized = await sharp(buf).resize({ width: IMAGE_MAX_WIDTH, withoutEnlargement: true }).jpeg({ quality: IMAGE_QUALITY }).toBuffer();
cacheSet(cacheKey, optimized); cacheSet(cacheKey, optimized);
res.set('Content-Type', 'image/jpeg'); res.set('Content-Type', 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); res.send(optimized);
res.set('Cache-Control', 'public, max-age=86400');
res.set('X-Cache', 'MISS');
res.send(optimized);
} catch (e) { res.status(502).json({ error: e.message }); } } catch (e) { res.status(502).json({ error: e.message }); }
}); });
app.get('/api/assets/:id/video', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/video/playback', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'video/mp4'); res.set('Cache-Control', 'public, max-age=86400'); const cl = r.headers.get('content-length'); if (cl) res.set('Content-Length', cl); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/assets/:id/video', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/video/playback', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'video/mp4'); res.set('Cache-Control', 'public, max-age=86400'); const cl = r.headers.get('content-length'); if (cl) res.set('Content-Length', cl); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/original', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/original', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/assets/:id/original', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/original', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/clients', requireApiToken, (_req, res) => { res.json({ ok: true, clients: getClientList() }); }); app.get('/api/clients', requireApiToken, (_req, res) => { res.json({ ok: true, clients: getClientList() }); });
app.post('/api/clients/:id/command', requireApiToken, (req, res) => { app.post('/api/clients/:id/command', requireApiToken, (req, res) => {
const { action, payload } = req.body; const { action, payload } = req.body;
if (!action) return res.status(400).json({ error: 'action required' }); if (!action) return res.status(400).json({ error: 'action required' });
const sent = sendToClient(req.params.id, { type: 'command', action, payload: payload || {} }); const sent = sendToClient(req.params.id, { type: 'command', action, payload: payload || {} });
if (!sent) return res.status(404).json({ error: 'Client not found or offline' }); if (!sent) return res.status(404).json({ error: 'Client not found or offline' });
log('Command "' + action + '" sent to ' + req.params.id);
res.json({ ok: true }); res.json({ ok: true });
}); });
app.delete('/api/clients/:id', requireApiToken, (req, res) => { app.delete('/api/clients/:id', requireApiToken, (req, res) => {
const c = clients.get(req.params.id); const c = clients.get(req.params.id);
if (!c) return res.status(404).json({ error: 'Not found' }); if (!c) return res.status(404).json({ error: 'Not found' });
@@ -283,10 +254,8 @@ app.delete('/api/clients/:id', requireApiToken, (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// --- SPA fallback ---
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
// --- Start ---
server.listen(PORT, () => { server.listen(PORT, () => {
log('--- Frambe v' + VERSION + ' ---'); log('--- Frambe v' + VERSION + ' ---');
log('Listening on port ' + PORT); log('Listening on port ' + PORT);