Files
timmy-talking-turd/server.mjs
T
timmy ba9afe0a1d feat(ingress): decide photo validity from bytes, budget, and header before any provider work (#16)
The declared data-URL MIME was a claim the boundary believed. Every image decision now comes from
the bytes: the container is sniffed, walked to its own end marker, refused when metadata segments
remain, and refused above a 4096px edge or 16MP. The request path adds a per-caller sliding-window
budget (429 + Retry-After) and a body deadline (408), so a flood, a stalled body, or a decode bomb
fails before a provider call.

Log hygiene is part of the contract: refusal copy is fixed prose, the bucket key is a salted hash
kept in memory only, and the acceptance test asserts no image bytes reach the process log.
2026-10-20 08:21:20 +00:00

124 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import http from 'node:http';
import { isIP } from 'node:net';
import { createHash, randomBytes } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { analyzePhoto, visionFailureResponse } from './src/vision-service.js';
import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js';
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js';
import { readJsonBody } from './src/http-body.js';
import { inspectImageBytes, IngressError } from './src/image-ingress.js';
import { createRateLimiter } from './src/rate-limit.js';
const root=fileURLToPath(new URL('.',import.meta.url));
const port=Number(process.env.PORT||4173);
const host=process.env.HOST||'0.0.0.0';
if(!isIP(host))throw new Error('HOST must be an IPv4 or IPv6 address.');
function resolveBasePath(value) {
const raw=String(value||'');
if(!raw||raw==='/')return '';
if(raw.length>128||!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*\/?$/.test(raw))throw new Error('TIMMY_BASE_PATH must be a canonical absolute URL path using plain unreserved characters.');
const normalized=raw.endsWith('/')?raw.slice(0,-1):raw;
if(normalized.split('/').some(segment=>segment==='.'||segment==='..'))throw new Error('TIMMY_BASE_PATH must not contain traversal segments.');
return normalized;
}
const basePath=resolveBasePath(process.env.TIMMY_BASE_PATH);
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);
const agentConfig=resolveHermesAgentConfig(process.env);
const agentService=createHermesAgentService({config:agentConfig});
const release=/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(process.env.TIMMY_RELEASE_TAG||'')?process.env.TIMMY_RELEASE_TAG:'development';
const commit=/^[0-9a-f]{12,40}$/.test(process.env.TIMMY_RELEASE_COMMIT||'')?process.env.TIMMY_RELEASE_COMMIT:'000000000000';
const publicBasePath=basePath||'/';
const appRoot=basePath?`${basePath}/`:'/';
const stagingLabel=process.env.TIMMY_STAGING_LABEL?`Staging · ${release} · ${commit.slice(0,12)}`:'';
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 escapeHtmlAttribute(value){return String(value).replace(/[&<>"]/g,character=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[character]));}
// Photo ingress (issue #16). Every limit here is decided from the request itself and fails before
// any provider call: a per-caller request budget, a deadline for a body that never arrives, a byte
// ceiling, and a container/pixel inspection of the decoded image. The bucket key is a salted hash
// that lives only in this process's memory, so no address and no image byte is ever logged.
const bodyTimeoutMs=Number(process.env.TIMMY_BODY_TIMEOUT_MS||15_000);
const analyzeLimiter=createRateLimiter({limit:Number(process.env.TIMMY_ANALYZE_RATE_LIMIT||12),windowMs:Number(process.env.TIMMY_ANALYZE_RATE_WINDOW_MS||60_000)});
const bucketSalt=randomBytes(16).toString('hex');
function clientBucket(req){return createHash('sha256').update(`${bucketSalt}:${req.socket.remoteAddress||'unknown'}`).digest('hex').slice(0,24);}
function readJson(req,maxBytes=6*1024*1024){return readJsonBody(req,{maxBytes,timeoutMs:bodyTimeoutMs});}
function ingressGuard(payload){
const raw=String(payload?.imageDataUrl||'');
const match=/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/.exec(raw);
// A payload that is not shaped like a data URL is left to the semantic validator, so the user still
// gets the specific "upload a JPEG, PNG, or WebP photo" copy rather than a second opinion here.
if(!match)return null;
return inspectImageBytes(Buffer.from(match[2],'base64'),{declaredMime:match[1]});
}
function sendIngressError(res,error){
if(error instanceof IngressError)return sendJson(res,400,{error:error.message,code:error.code});
return sendJson(res,error.status||400,{error:error.message,code:error.code});
}
function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''}
function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')}
function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'');if(site&&site!=='same-origin')throw new AgentGatewayError(403,'Cross-site agent requests are not allowed.')}
function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=${appRoot}; Max-Age=86400${secure}`}
function sendAgentError(res,error){const status=error instanceof AgentGatewayError?error.status:503;const message=error instanceof AgentGatewayError?error.message:'Hermes is temporarily unavailable. Your local journal still works.';return sendJson(res,status,{error:message})}
http.createServer(async(req,res)=>{
try{
const url=new URL(req.url,'http://localhost');
if(basePath&&url.pathname!==basePath&&!url.pathname.startsWith(`${basePath}/`)){res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});return res.end('Not found')}
if(basePath&&url.pathname===basePath){res.writeHead(308,{location:appRoot});return res.end()}
const appPath=basePath?(url.pathname.slice(basePath.length)||'/'):url.pathname;
if(appPath==='/api/healthz'&&req.method==='GET')return sendJson(res,200,{ok:true,release,commit,visionEnabled:visionConfig.enabled,agentEnabled:agentConfig.enabled});
if(appPath==='/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(appPath==='/api/analyze'&&req.method==='POST'){
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
// Cheap refusals first, in cost order: the request budget, then the body, then the image itself.
const budget=analyzeLimiter.take(clientBucket(req));
if(!budget.allowed){
res.setHeader('retry-after',String(Math.max(1,Math.ceil(budget.retryAfterMs/1000))));
return sendJson(res,429,{error:'Too many photos from this device. Wait a moment, then try again — your journal and manual logging still work.'});
}
let payload;
try{payload=await readJson(req)}
catch(error){
const sent=sendIngressError(res,error);
// The refusal is written first, then the half-read socket is dropped so a stalled body cannot
// hold a connection or its partial buffer open.
if(error?.code==='body_timeout'||error?.code==='too_large')req.destroy?.();
return sent;
}
try{ingressGuard(payload)}
catch(error){return sendIngressError(res,error)}
try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}
catch(error){const failure=visionFailureResponse(error);return sendJson(res,failure.status,failure.body)}
}
if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
if(appPath==='/api/agent/unlock'&&req.method==='POST'){
try{rejectCrossSite(req);const payload=await readJson(req,4096);const result=await agentService.unlock({origin:requestOrigin(req),accessCode:String(payload?.accessCode||'')});res.setHeader('set-cookie',agentCookie(result.cookieToken));return sendJson(res,200,result.public)}catch(error){return sendAgentError(res,error)}
}
if(appPath==='/api/agent/chat'&&req.method==='POST'){
try{rejectCrossSite(req);const payload=await readJson(req,128*1024);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload}))}catch(error){return sendAgentError(res,error)}
}
if(appPath.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(appPath);
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');
let body=await readFile(path);
if(appPath==='/'||appPath==='/index.html'){
const configMeta=`<meta name="timmy-base-path" content="${escapeHtmlAttribute(publicBasePath)}">\n <meta name="timmy-staging-label" content="${escapeHtmlAttribute(stagingLabel)}">`;
body=body.toString('utf8').replace(/(["'])\//g,`$1${appRoot}`).replace('<head>',`<head>\n <base href="${appRoot}">\n ${configMeta}`);
}
if(appPath==='/manifest.webmanifest'){
const manifest=JSON.parse(body.toString('utf8'));manifest.start_url=appRoot;manifest.scope=appRoot;manifest.icons=manifest.icons.map(icon=>({...icon,src:`${appRoot}${icon.src.replace(/^\//,'')}`}));body=JSON.stringify(manifest);
}
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(error){if(error instanceof AgentGatewayError)return sendAgentError(res,error);res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Not found')}
}).listen(port,host,()=>console.log(`Timmy is listening on http://${host}:${port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`));