39 lines
3.2 KiB
JavaScript
39 lines
3.2 KiB
JavaScript
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'}`));
|