import http from 'node:http'; import { readFile, stat } from 'node:fs/promises'; import { extname, join, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; import { analyzePhoto } from './src/vision-service.js'; import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js'; const root=fileURLToPath(new URL('.',import.meta.url)); const port=Number(process.env.PORT||4173); const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.webmanifest':'application/manifest+json','.svg':'image/svg+xml'}; const visionConfig=resolveVisionConfig(process.env); function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));} function readJson(req,maxBytes=6*1024*1024){return new Promise((resolve,reject)=>{let size=0;const chunks=[];req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){reject(new Error('Photo request is too large.'));req.destroy();return}chunks.push(chunk)});req.on('end',()=>{try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))}catch{reject(new Error('Invalid JSON request.'))}});req.on('error',reject)})} http.createServer(async(req,res)=>{ try{ const url=new URL(req.url,'http://localhost'); if(url.pathname==='/api/vision-status'&&req.method==='GET'){ const provider=await probeVisionProvider(visionConfig); const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.'; return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy}); } if(url.pathname==='/api/analyze'&&req.method==='POST'){ if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'}); const payload=await readJson(req); try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))} catch(error){const safe=/consent|JPEG|PNG|WebP|empty|too large/i.test(error.message);return sendJson(res,safe?400:503,{error:error.message})} } if(url.pathname.startsWith('/api/'))return sendJson(res,404,{error:'Not found'}); if(req.method!=='GET'&&req.method!=='HEAD'){res.writeHead(405,{'allow':'GET, HEAD'});return res.end()} const pathname=decodeURIComponent(url.pathname); let path=normalize(join(root,pathname==='/'?'index.html':pathname)); if(!path.startsWith(root))throw new Error('bad path'); const info=await stat(path);if(info.isDirectory())path=join(path,'index.html'); const body=await readFile(path);res.writeHead(200,{'content-type':types[extname(path)]||'application/octet-stream','cache-control':'no-store','x-content-type-options':'nosniff'});if(req.method==='HEAD')res.end();else res.end(body); }catch{res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Not found')} }).listen(port,'0.0.0.0',()=>console.log(`Timmy is listening on http://0.0.0.0:${port} · AI ${visionConfig.enabled?'ready for provider':'disabled'}`));