171 lines
16 KiB
JavaScript
171 lines
16 KiB
JavaScript
const express = require('express');
|
|
const fetch = require('node-fetch');
|
|
const path = require('path');
|
|
const http = require('http');
|
|
const crypto = require('crypto');
|
|
const sharp = require('sharp');
|
|
const { WebSocketServer, WebSocket } = require('ws');
|
|
require('dotenv').config();
|
|
|
|
const VERSION = '1.4.1';
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
const PORT = process.env.PORT || 3000;
|
|
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
|
|
const API_KEY = process.env.IMMICH_API_KEY || '';
|
|
const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30;
|
|
const TRANSITION_DURATION = parseInt(process.env.TRANSITION_DURATION, 10) || 2;
|
|
const SHOW_CLOCK = process.env.SHOW_CLOCK !== 'false';
|
|
const SHOW_DATE = process.env.SHOW_DATE !== 'false';
|
|
const SHOW_EXIF = process.env.SHOW_EXIF !== 'false';
|
|
const SHOW_PROGRESS = process.env.SHOW_PROGRESS !== 'false';
|
|
const IMAGE_FIT = process.env.IMAGE_FIT || 'contain';
|
|
const BACKGROUND_BLUR = process.env.BACKGROUND_BLUR !== 'false';
|
|
const SHUFFLE = process.env.SHUFFLE !== 'false';
|
|
const ALBUM_ID = process.env.ALBUM_ID || '';
|
|
const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
|
|
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
|
|
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
|
|
const IMAGE_MAX_WIDTH = parseInt(process.env.IMAGE_MAX_WIDTH, 10) || 1920;
|
|
const IMAGE_QUALITY = parseInt(process.env.IMAGE_QUALITY, 10) || 80;
|
|
const IMAGE_CACHE_MAX = parseInt(process.env.IMAGE_CACHE_MAX, 10) || 100;
|
|
const imageCache = new Map();
|
|
function cacheGet(key) { const e=imageCache.get(key);if(!e)return null;imageCache.delete(key);imageCache.set(key,e);return e; }
|
|
function cacheSet(key,value) { if(imageCache.size>=IMAGE_CACHE_MAX){imageCache.delete(imageCache.keys().next().value);}imageCache.set(key,value); }
|
|
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
|
const FRAMBE_API_TOKEN = process.env.FRAMBE_API_TOKEN || '';
|
|
const AUTH_ENABLED = !!ADMIN_PASSWORD;
|
|
const sessions = new Map();
|
|
const SESSION_TTL = 24 * 60 * 60 * 1000;
|
|
function createSession(username) { const token=crypto.randomBytes(32).toString('hex'),now=Date.now();sessions.set(token,{username,createdAt:now,expiresAt:now+SESSION_TTL});return token; }
|
|
function validateSession(token) { if(!token)return false;const s=sessions.get(token);if(!s)return false;if(Date.now()>s.expiresAt){sessions.delete(token);return false;}return true; }
|
|
function cleanupSessions() { const now=Date.now();sessions.forEach((s,t)=>{if(now>s.expiresAt)sessions.delete(t);}); }
|
|
setInterval(cleanupSessions, 60 * 60 * 1000);
|
|
function requireAdminAuth(req,res,next) { if(!AUTH_ENABLED)return next();const cookie=req.headers.cookie||'';const match=cookie.match(/frambe_session=([a-f0-9]+)/);if(match&&validateSession(match[1]))return next();if(req.headers['accept']&&req.headers['accept'].includes('application/json'))return res.status(401).json({error:'Not authenticated'});return res.redirect('/admin/login'); }
|
|
function requireApiToken(req,res,next) { if(!FRAMBE_API_TOKEN)return next();const auth=req.headers['authorization']||'',hdr=req.headers['x-api-token']||'',qry=req.query.token||'',provided=auth.startsWith('Bearer ')?auth.slice(7):(hdr||qry);if(provided===FRAMBE_API_TOKEN)return next();return res.status(401).json({error:'Invalid API token'}); }
|
|
function immichHeaders() { return {'x-api-key':API_KEY,'Content-Type':'application/json'}; }
|
|
function log(msg) { console.log('['+new Date().toISOString()+'] '+msg); }
|
|
function logErr(msg) { console.error('['+new Date().toISOString()+'] ERROR '+msg); }
|
|
function filterAssets(assets) { return assets.filter(a=>{if(a.isTrashed)return false;if(!INCLUDE_VIDEOS&&a.type==='VIDEO')return false;return true;}); }
|
|
function mapAsset(a) { return {id:a.id,type:a.type,fileCreatedAt:a.fileCreatedAt,fileModifiedAt:a.fileModifiedAt,isFavorite:a.isFavorite,exifInfo:a.exifInfo?{dateTimeOriginal:a.exifInfo.dateTimeOriginal,city:a.exifInfo.city,state:a.exifInfo.state,country:a.exifInfo.country,make:a.exifInfo.make,model:a.exifInfo.model}:null,originalMimeType:a.originalMimeType,duration:a.duration}; }
|
|
|
|
const wss = new WebSocketServer({ server });
|
|
const clients = new Map();
|
|
const adminSockets = new Set();
|
|
|
|
function getClientList() {
|
|
const now = Date.now();
|
|
return Array.from(clients.values()).map(c => ({
|
|
id: c.id, name: c.name||'', ip: c.ip, userAgent: c.userAgent,
|
|
connectedAt: c.connectedAt, firstSeen: c.firstSeen, lastSeen: c.lastSeen,
|
|
status: c.status, online: (now - c.lastSeen) < 35000,
|
|
config: c.config||{}, source: c.source||null,
|
|
}));
|
|
}
|
|
|
|
function broadcastAdminClients() {
|
|
const msg = JSON.stringify({ type: 'clientList', clients: getClientList() });
|
|
adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch(e) {} } });
|
|
}
|
|
|
|
function getClientIp(req) {
|
|
return (req.headers['x-forwarded-for']||'').split(',')[0].trim() || req.socket.remoteAddress || 'unknown';
|
|
}
|
|
|
|
wss.on('connection', (ws, req) => {
|
|
const id = crypto.randomBytes(8).toString('hex');
|
|
const now = Date.now();
|
|
const ua = req.headers['user-agent'] || 'unknown';
|
|
const ip = getClientIp(req);
|
|
let isAdmin = false;
|
|
ws.send(JSON.stringify({ type: 'hello', clientId: id }));
|
|
ws.on('message', (raw) => {
|
|
try {
|
|
const msg = JSON.parse(raw);
|
|
if (msg.type === 'register') {
|
|
if (msg.role === 'admin') {
|
|
isAdmin = true;
|
|
adminSockets.add(ws);
|
|
log('WS admin: ' + ip);
|
|
ws.send(JSON.stringify({ type: 'clientList', clients: getClientList() }));
|
|
} else {
|
|
const pid = msg.persistentId || null;
|
|
const existing = pid ? Array.from(clients.values()).find(c => c.persistentId === pid) : null;
|
|
const effectiveId = existing ? existing.id : id;
|
|
const firstName = existing ? existing.name : '';
|
|
const firstSeen = existing ? existing.firstSeen : now;
|
|
if (existing) { clients.delete(Array.from(clients.entries()).find(([,c]) => c.persistentId === pid)?.[0]); }
|
|
clients.set(effectiveId, { id:effectiveId, persistentId:pid, ws, name:firstName, ip, userAgent:ua, connectedAt:now, firstSeen, lastSeen:Date.now(), status:msg.status||'idle', config:msg.config||{}, source:msg.source||null });
|
|
log('WS frame: ' + effectiveId + (pid?' [persistent]':'') + ' (' + ip + ')');
|
|
broadcastAdminClients();
|
|
}
|
|
} else if (msg.type === 'ping') {
|
|
ws.send(JSON.stringify({ type: 'pong' }));
|
|
} else if (msg.type === 'status') {
|
|
const c = clients.get(id);
|
|
if (c) { c.status=msg.status||c.status; c.lastSeen=Date.now(); if(msg.config)c.config=msg.config; if(msg.source!==undefined)c.source=msg.source; broadcastAdminClients(); }
|
|
} else if (msg.type === 'adminCommand') {
|
|
const target = Array.from(clients.values()).find(c => c.id === 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('Cmd "' + msg.action + '" -> ' + msg.targetId);
|
|
} else {
|
|
ws.send(JSON.stringify({ type:'error', message:'Client not found or offline' }));
|
|
}
|
|
} else if (msg.type === 'renameClient') {
|
|
const target = Array.from(clients.values()).find(c => c.id === msg.targetId);
|
|
if (target) { target.name = msg.name; broadcastAdminClients(); }
|
|
} else if (msg.type === 'removeClient') {
|
|
const entry = Array.from(clients.entries()).find(([,c]) => c.id === msg.targetId);
|
|
if (entry) { clients.delete(entry[0]); broadcastAdminClients(); }
|
|
}
|
|
} catch(e) { logErr('WS: ' + e.message); }
|
|
});
|
|
ws.on('close', () => {
|
|
if (isAdmin) { adminSockets.delete(ws); log('WS admin left: ' + ip); }
|
|
else { const c=clients.get(id); if(c){c.status='offline';c.ws=null;c.lastSeen=Date.now();broadcastAdminClients();} log('WS frame left: '+id); }
|
|
});
|
|
});
|
|
|
|
function sendToClient(clientId, payload) {
|
|
const c = Array.from(clients.values()).find(x => x.id === clientId);
|
|
if (!c || !c.ws || c.ws.readyState !== WebSocket.OPEN) return false;
|
|
c.ws.send(JSON.stringify(payload)); return true;
|
|
}
|
|
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: false }));
|
|
app.use((req,res,next)=>{res.set('Cache-Control','no-store');next();});
|
|
app.use((req,_res,next)=>{log(req.method+' '+req.path);next();});
|
|
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(express.static(path.join(__dirname,'public')));
|
|
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.post('/api/auth/login',(req,res)=>{if(!AUTH_ENABLED)return res.json({ok:true});const{username,password}=req.body;if(username===ADMIN_USERNAME&&password===ADMIN_PASSWORD){const token=createSession(username);res.setHeader('Set-Cookie','frambe_session='+token+'; HttpOnly; Path=/; Max-Age='+(SESSION_TTL/1000));return res.json({ok:true});}return res.status(401).json({ok:false,error:'Invalid credentials'});});
|
|
app.post('/api/auth/logout',(req,res)=>{const cookie=req.headers.cookie||'',match=cookie.match(/frambe_session=([a-f0-9]+)/);if(match)sessions.delete(match[1]);res.setHeader('Set-Cookie','frambe_session=; HttpOnly; Path=/; Max-Age=0');res.json({ok:true});});
|
|
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(''+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: '+rOwn.status);const aOwn=await rOwn.json(),sharedRaw=rShared.ok?await rShared.json():[];const seen=new Set(),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('Albums: '+result.length);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(),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/: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),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/optimized',async(req,res)=>{try{const id=req.params.id,ck=id+':'+IMAGE_MAX_WIDTH+':'+IMAGE_QUALITY,hit=cacheGet(ck);if(hit){res.set('Content-Type','image/jpeg');res.set('Cache-Control','public, max-age=86400');return res.send(hit);}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);const buf=await r.buffer(),opt=await sharp(buf).resize({width:IMAGE_MAX_WIDTH,withoutEnlargement:true}).jpeg({quality:IMAGE_QUALITY}).toBuffer();cacheSet(ck,opt);res.set('Content-Type','image/jpeg');res.set('Cache-Control','public, max-age=86400');res.send(opt);}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/clients',requireApiToken,(_req,res)=>{res.json({ok:true,clients:getClientList()});});
|
|
app.post('/api/clients/:id/command',requireApiToken,(req,res)=>{const{action,payload}=req.body;if(!action)return res.status(400).json({error:'action required'});const sent=sendToClient(req.params.id,{type:'command',action,payload:payload||{}});if(!sent)return res.status(404).json({error:'Client not found or offline'});res.json({ok:true});});
|
|
app.delete('/api/clients/:id',requireApiToken,(req,res)=>{const entry=Array.from(clients.entries()).find(([,c])=>c.id===req.params.id);if(!entry)return res.status(404).json({error:'Not found'});clients.delete(entry[0]);broadcastAdminClients();res.json({ok:true});});
|
|
app.get('*',(_req,res)=>{res.sendFile(path.join(__dirname,'public','index.html'));});
|
|
|
|
server.listen(PORT,()=>{
|
|
log('--- Frambe v'+VERSION+' ---');
|
|
log('Port: '+PORT+' | Immich: '+IMMICH_URL);
|
|
log('API key: '+(API_KEY?'set':'NOT SET')+' | Auth: '+(AUTH_ENABLED?'enabled':'disabled')+' | Token: '+(FRAMBE_API_TOKEN?'set':'not set'));
|
|
});
|