v1.4.1 — Shared Albums #2
@@ -32,9 +32,8 @@ const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||
const FRAMBE_API_TOKEN = process.env.FRAMBE_API_TOKEN || '';
|
||||
const AUTH_ENABLED = !!ADMIN_PASSWORD;
|
||||
|
||||
// Session store: token -> { username, createdAt, expiresAt }
|
||||
const sessions = new Map();
|
||||
const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const SESSION_TTL = 24 * 60 * 60 * 1000;
|
||||
|
||||
function createSession(username) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
@@ -52,9 +51,8 @@ function validateSession(token) {
|
||||
}
|
||||
|
||||
function cleanupSessions() { const now = Date.now(); sessions.forEach((s, t) => { if (now > s.expiresAt) sessions.delete(t); }); }
|
||||
setInterval(cleanupSessions, 60 * 60 * 1000); // cleanup every hour
|
||||
setInterval(cleanupSessions, 60 * 60 * 1000);
|
||||
|
||||
// --- Admin auth middleware (cookie-based for browser) ---
|
||||
function requireAdminAuth(req, res, next) {
|
||||
if (!AUTH_ENABLED) return next();
|
||||
const cookie = req.headers.cookie || '';
|
||||
@@ -65,16 +63,13 @@ function requireAdminAuth(req, res, next) {
|
||||
return res.status(401).json({ error: 'Unauthorized', message: 'Admin login required' });
|
||||
}
|
||||
|
||||
// --- API token middleware (for external callers like Home Assistant) ---
|
||||
function requireApiToken(req, res, next) {
|
||||
const authHeader = req.headers.authorization || '';
|
||||
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
||||
const headerToken = req.headers['x-api-token'] || '';
|
||||
const queryToken = req.query.token || '';
|
||||
const provided = bearerToken || headerToken || queryToken;
|
||||
if (FRAMBE_API_TOKEN) {
|
||||
if (provided === FRAMBE_API_TOKEN) return next();
|
||||
}
|
||||
if (FRAMBE_API_TOKEN) { if (provided === FRAMBE_API_TOKEN) return next(); }
|
||||
if (AUTH_ENABLED) {
|
||||
const cookie = req.headers.cookie || '';
|
||||
const match = cookie.match(/frambe_session=([a-f0-9]+)/);
|
||||
@@ -88,63 +83,149 @@ function immichHeaders() { return { 'x-api-key': API_KEY, 'Accept': 'application
|
||||
function log(msg) { console.log('[Frambe] ' + msg); }
|
||||
function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); }
|
||||
|
||||
const clients = new Map();
|
||||
let clientNameStore = {};
|
||||
// --- Persistent frame registry (keyed by IP) ---
|
||||
const frameRegistry = new Map();
|
||||
const adminSockets = new Set();
|
||||
|
||||
function getClientIp(req) { return req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown'; }
|
||||
function generateClientId(ip) { return ip.replace(/[.:]/g, '_'); }
|
||||
function broadcastToAdmins(msg) { const d = JSON.stringify(msg); clients.forEach(c => { if (c.role === 'admin' && c.ws.readyState === WebSocket.OPEN) c.ws.send(d); }); }
|
||||
function getClientList() { const list = []; clients.forEach((c, id) => { if (c.role === 'frame') list.push({ id, ip: c.ip, name: c.name || clientNameStore[c.ip] || '', status: c.status || 'unknown', connectedAt: c.connectedAt, lastSeen: c.lastSeen, config: c.config || {} }); }); return list; }
|
||||
|
||||
function broadcastToAdmins(msg) {
|
||||
const d = JSON.stringify(msg);
|
||||
adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) ws.send(d); });
|
||||
}
|
||||
|
||||
function getClientList() {
|
||||
const list = [];
|
||||
frameRegistry.forEach((f, ip) => {
|
||||
list.push({ id: f.id, ip: ip, name: f.name || '', status: f.status || 'offline', firstSeen: f.firstSeen, lastSeen: f.lastSeen, connectedAt: f.connectedAt, config: f.config || {} });
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function findFrameById(id) {
|
||||
let found = null;
|
||||
frameRegistry.forEach(f => { if (f.id === id) found = f; });
|
||||
return found;
|
||||
}
|
||||
|
||||
function findFrameIpById(id) {
|
||||
let foundIp = null;
|
||||
frameRegistry.forEach((f, ip) => { if (f.id === id) foundIp = ip; });
|
||||
return foundIp;
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
wss.on('connection', (ws, req) => {
|
||||
const ip = getClientIp(req);
|
||||
const clientId = generateClientId(ip) + '_' + Date.now();
|
||||
log('WebSocket connected: ' + ip + ' (' + clientId + ')');
|
||||
const info = { ws, ip, role: 'frame', name: clientNameStore[ip] || '', status: 'connected', connectedAt: new Date().toISOString(), lastSeen: new Date().toISOString(), config: {} };
|
||||
clients.set(clientId, info);
|
||||
ws.send(JSON.stringify({ type: 'welcome', clientId, name: info.name }));
|
||||
const now = new Date().toISOString();
|
||||
let isAdmin = false;
|
||||
|
||||
log('WebSocket connected: ' + ip);
|
||||
ws.send(JSON.stringify({ type: 'welcome', ip }));
|
||||
|
||||
ws.on('message', raw => {
|
||||
try {
|
||||
const msg = JSON.parse(raw); info.lastSeen = new Date().toISOString();
|
||||
const msg = JSON.parse(raw);
|
||||
switch (msg.type) {
|
||||
case 'register':
|
||||
info.role = msg.role || 'frame';
|
||||
if (msg.role === 'admin') { log('Admin connected from ' + ip); ws.send(JSON.stringify({ type: 'clientList', clients: getClientList() })); }
|
||||
else { log('Frame registered: ' + ip); info.status = msg.status || 'idle'; info.config = msg.config || {}; broadcastToAdmins({ type: 'clientList', clients: getClientList() }); }
|
||||
if (msg.role === 'admin') {
|
||||
isAdmin = true;
|
||||
adminSockets.add(ws);
|
||||
log('Admin connected from ' + ip);
|
||||
ws.send(JSON.stringify({ type: 'clientList', clients: getClientList() }));
|
||||
} else {
|
||||
let frame = frameRegistry.get(ip);
|
||||
if (frame) {
|
||||
frame.ws = ws;
|
||||
frame.status = msg.status || 'idle';
|
||||
frame.connectedAt = now;
|
||||
frame.lastSeen = now;
|
||||
if (msg.config) frame.config = msg.config;
|
||||
log('Frame reconnected: ' + ip + ' ("' + frame.name + '")');
|
||||
} else {
|
||||
frame = { id: ip.replace(/[.:]/g, '_'), ip: ip, name: '', status: msg.status || 'idle', firstSeen: now, connectedAt: now, lastSeen: now, config: msg.config || {}, ws: ws };
|
||||
frameRegistry.set(ip, frame);
|
||||
log('Frame registered: ' + ip);
|
||||
}
|
||||
broadcastToAdmins({ type: 'clientList', clients: getClientList() });
|
||||
}
|
||||
break;
|
||||
case 'status':
|
||||
info.status = msg.status || info.status; if (msg.config) info.config = msg.config;
|
||||
broadcastToAdmins({ type: 'clientUpdate', clientId, client: { id: clientId, ip: info.ip, name: info.name, status: info.status, lastSeen: info.lastSeen, config: info.config } });
|
||||
|
||||
case 'status': {
|
||||
const frame = frameRegistry.get(ip);
|
||||
if (frame) {
|
||||
frame.status = msg.status || frame.status;
|
||||
frame.lastSeen = new Date().toISOString();
|
||||
if (msg.config) frame.config = msg.config;
|
||||
broadcastToAdmins({ type: 'clientUpdate', clientId: frame.id, client: { id: frame.id, ip, name: frame.name, status: frame.status, lastSeen: frame.lastSeen, config: frame.config } });
|
||||
}
|
||||
break;
|
||||
case 'adminCommand':
|
||||
const target = clients.get(msg.targetId);
|
||||
if (target && target.ws.readyState === WebSocket.OPEN) { target.ws.send(JSON.stringify({ type: 'command', action: msg.action, payload: msg.payload })); log('Command ' + msg.action + ' -> ' + msg.targetId); }
|
||||
else ws.send(JSON.stringify({ type: 'error', message: 'Client not found' }));
|
||||
}
|
||||
|
||||
case 'adminCommand': {
|
||||
const target = findFrameById(msg.targetId);
|
||||
if (target && target.ws && target.ws.readyState === WebSocket.OPEN) {
|
||||
target.ws.send(JSON.stringify({ type: 'command', action: msg.action, payload: msg.payload }));
|
||||
log('Command ' + msg.action + ' -> ' + msg.targetId);
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Client not found or offline' }));
|
||||
}
|
||||
break;
|
||||
case 'renameClient':
|
||||
const rt = clients.get(msg.targetId);
|
||||
if (rt) { rt.name = msg.name; clientNameStore[rt.ip] = msg.name; log('Renamed ' + msg.targetId + ' -> "' + msg.name + '"'); broadcastToAdmins({ type: 'clientList', clients: getClientList() }); }
|
||||
}
|
||||
|
||||
case 'renameClient': {
|
||||
const target = findFrameById(msg.targetId);
|
||||
if (target) {
|
||||
target.name = msg.name;
|
||||
log('Renamed ' + target.ip + ' -> "' + msg.name + '"');
|
||||
broadcastToAdmins({ type: 'clientList', clients: getClientList() });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'removeClient': {
|
||||
const targetIp = findFrameIpById(msg.targetId);
|
||||
if (targetIp) {
|
||||
const removed = frameRegistry.get(targetIp);
|
||||
if (removed && removed.ws && removed.ws.readyState === WebSocket.OPEN) removed.ws.close();
|
||||
frameRegistry.delete(targetIp);
|
||||
log('Removed client: ' + targetIp + ' ("' + (removed ? removed.name : '') + '")');
|
||||
broadcastToAdmins({ type: 'clientList', clients: getClientList() });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) { logErr('WS parse error: ' + e.message); }
|
||||
});
|
||||
ws.on('close', () => { log('WebSocket disconnected: ' + ip); clients.delete(clientId); broadcastToAdmins({ type: 'clientList', clients: getClientList() }); });
|
||||
|
||||
ws.on('close', () => {
|
||||
if (isAdmin) {
|
||||
adminSockets.delete(ws);
|
||||
log('Admin disconnected: ' + ip);
|
||||
} else {
|
||||
const frame = frameRegistry.get(ip);
|
||||
if (frame && frame.ws === ws) {
|
||||
frame.status = 'offline';
|
||||
frame.ws = null;
|
||||
frame.lastSeen = new Date().toISOString();
|
||||
log('Frame offline: ' + ip + ' ("' + (frame.name || '') + '")');
|
||||
broadcastToAdmins({ type: 'clientList', clients: getClientList() });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); });
|
||||
app.use(express.json());
|
||||
|
||||
// --- Auth endpoints ---
|
||||
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 }); });
|
||||
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
if (!AUTH_ENABLED) return res.json({ ok: true, message: 'Auth not enabled' });
|
||||
const { username, password } = req.body || {};
|
||||
if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
|
||||
const token = createSession(username);
|
||||
res.setHeader('Set-Cookie', `frambe_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_TTL / 1000}`);
|
||||
res.setHeader('Set-Cookie', 'frambe_session=' + token + '; Path=/; HttpOnly; SameSite=Strict; Max-Age=' + (SESSION_TTL / 1000));
|
||||
log('Admin login: ' + username);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
@@ -160,53 +241,57 @@ app.post('/api/auth/logout', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Login page (served without auth) ---
|
||||
app.get('/admin/login', (_req, res) => {
|
||||
if (!AUTH_ENABLED) return res.redirect('/admin');
|
||||
res.sendFile(path.join(__dirname, 'public', 'admin', 'login.html'));
|
||||
});
|
||||
|
||||
// --- Static files (non-admin pages don't require auth) ---
|
||||
app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, fp) => { if (fp.endsWith('.html') || fp.endsWith('.js') || fp.endsWith('.css')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } } }));
|
||||
|
||||
function mapAsset(a) { return { id: a.id, type: a.type, originalFileName: a.originalFileName, fileCreatedAt: a.fileCreatedAt, isFavorite: a.isFavorite, exifInfo: a.exifInfo ? { make: a.exifInfo.make, model: a.exifInfo.model, city: a.exifInfo.city, state: a.exifInfo.state, country: a.exifInfo.country, description: a.exifInfo.description, dateTimeOriginal: a.exifInfo.dateTimeOriginal } : null }; }
|
||||
function filterAssets(assets) { return INCLUDE_VIDEOS ? assets.filter(a => a.type === 'IMAGE' || a.type === 'VIDEO') : assets.filter(a => a.type === 'IMAGE'); }
|
||||
|
||||
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 }); });
|
||||
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/albums', async (_req, res) => { try { const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`${r.status}`); const a = await r.json(); log('Listed ' + a.length + ' albums'); res.json(a.map(x => ({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt }))); } 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/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/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/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?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true }))); } 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/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/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/albums', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/albums', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const a = await r.json(); log('Listed ' + a.length + ' albums'); res.json(a.map(x => ({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt }))); } 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/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/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/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/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 }); } });
|
||||
|
||||
// --- REST API: Client management (token-authenticated for Home Assistant etc.) ---
|
||||
app.get('/api/clients', requireApiToken, (_req, res) => {
|
||||
res.json({ ok: true, clients: getClientList() });
|
||||
});
|
||||
// --- REST API: Client management ---
|
||||
app.get('/api/clients', requireApiToken, (_req, res) => { res.json({ ok: true, clients: getClientList() }); });
|
||||
|
||||
app.post('/api/clients/:id/command', requireApiToken, (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { action, payload } = req.body || {};
|
||||
if (!action) return res.status(400).json({ ok: false, error: 'Missing action' });
|
||||
const target = clients.get(id);
|
||||
const target = findFrameById(id);
|
||||
if (!target) return res.status(404).json({ ok: false, error: 'Client not found' });
|
||||
if (target.role !== 'frame') return res.status(400).json({ ok: false, error: 'Target is not a frame client' });
|
||||
if (target.status === 'offline' || !target.ws) return res.status(410).json({ ok: false, error: 'Client is offline' });
|
||||
if (target.ws.readyState !== WebSocket.OPEN) return res.status(410).json({ ok: false, error: 'Client WebSocket not connected' });
|
||||
target.ws.send(JSON.stringify({ type: 'command', action, payload: payload || {} }));
|
||||
log('REST command ' + action + ' -> ' + id);
|
||||
res.json({ ok: true, action, targetId: id });
|
||||
});
|
||||
|
||||
// --- Admin dashboard (auth-protected) ---
|
||||
app.get('/admin', requireAdminAuth, (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin', 'index.html')); });
|
||||
app.delete('/api/clients/:id', requireApiToken, (req, res) => {
|
||||
const { id } = req.params;
|
||||
const targetIp = findFrameIpById(id);
|
||||
if (!targetIp) return res.status(404).json({ ok: false, error: 'Client not found' });
|
||||
const removed = frameRegistry.get(targetIp);
|
||||
if (removed && removed.ws && removed.ws.readyState === WebSocket.OPEN) removed.ws.close();
|
||||
frameRegistry.delete(targetIp);
|
||||
log('REST removed client: ' + targetIp);
|
||||
res.json({ ok: true, removedId: id });
|
||||
});
|
||||
|
||||
// --- Catch-all for frame SPA ---
|
||||
app.get('/admin', requireAdminAuth, (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin', 'index.html')); });
|
||||
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
|
||||
Reference in New Issue
Block a user