import http from 'http'; import https from 'https'; import fs from 'fs'; import path from 'path'; import { execSync } from 'child_process'; import { fileURLToPath, pathToFileURL } from 'url'; import express from 'express'; import cookieParser from 'cookie-parser'; import { loggerMiddleware } from './middleware/logger.js'; import env from '../config/env.js'; import { authMiddleware } from './middleware/auth.js'; import { ensureConnection } from '../db/store.js'; import { closeAll as closeAllSse } from './sse.js'; import { closeAllManagers } from '../services/conversationManager.js'; import { shutdownImageCropWorkers } from '../services/imageCrop.js'; import { shutdownJavascriptSandboxWorkers } from '../services/javascriptSandbox.js'; import { getOrCreateCert } from '../util/cert.js'; import { createLogger } from '../util/logger.js'; import v1Router from '../v1/index.js'; const apiLog = createLogger('api'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); async function registerRoutes(app, basePath = '') { const routesDir = path.join(__dirname, 'routes'); async function registerRouteFilesRecursively(dir) { for (const file of fs.readdirSync(dir)) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { await registerRouteFilesRecursively(fullPath); } else if (file.endsWith('.js')) { const routeModule = await import(pathToFileURL(fullPath).href); if (routeModule.register) { routeModule.register(app, basePath); } } } } await registerRouteFilesRecursively(routesDir); } let server; let httpsServer; let startupPromise; let serverStartedAt; const LOCAL_PRIVATE_HOST_PATTERN = /^(127\.0\.0\.1|localhost|192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})$/; function isAllowedCorsOrigin(origin) { if (!origin) return false; if (env.corsOrigins.includes(origin)) return true; try { const u = new URL(origin); if (u.hostname === 'quixpos.com' || u.hostname.endsWith('.quixpos.com')) { return true; } if (LOCAL_PRIVATE_HOST_PATTERN.test(u.hostname)) { return true; } } catch { // ignore invalid URL } return false; } function corsMiddleware(req, res, next) { const origin = req.headers.origin; if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) { res.setHeader('Access-Control-Allow-Origin', origin || '*'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); if (req.method === 'OPTIONS') { return res.sendStatus(204); } return next(); } if (req.path.startsWith('/v1')) { if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); res.setHeader('Access-Control-Allow-Credentials', 'true'); } else { res.setHeader('Access-Control-Allow-Origin', '*'); } const reqHeaders = req.headers['access-control-request-headers']; res.setHeader( 'Access-Control-Allow-Headers', reqHeaders || 'Content-Type, Authorization, x-auth-token, authtoken, auth-token, token, x-token' ); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); if (req.method === 'OPTIONS') { return res.sendStatus(204); } return next(); } if (origin && isAllowedCorsOrigin(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); res.setHeader('Access-Control-Allow-Credentials', 'true'); const reqHeaders = req.headers['access-control-request-headers']; res.setHeader( 'Access-Control-Allow-Headers', reqHeaders || 'Content-Type, Authorization, x-auth-token, authtoken, auth-token, token, x-token' ); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); } if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); } async function buildApiApp() { const app = express(); app.use((req, res, next) => { // Do not attach no-store to SSE or public endpoints if (req.path !== '/api/events' && !req.path.startsWith('/api/public') && !req.path.startsWith('/public')) { res.setHeader('Cache-Control', 'no-store'); } next(); }); app.use(corsMiddleware); app.use(express.json({ limit: env.bodyLimit })); app.use(cookieParser()); app.use(loggerMiddleware); app.use(authMiddleware); if (serverStartedAt) { app.use((req, res, next) => { res.setHeader('X-Server-Started-At', serverStartedAt); next(); }); } await registerRoutes(app, '/api'); app.use('/v1', v1Router); app.use('/api/v1', v1Router); app.use((_req, res) => { res.status(404).json({ error: 'Not found' }); }); app.use((err, _req, res, _next) => { if (err.type === 'entity.too.large') { return res.status(413).json({ error: 'Payload too large' }); } if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { return res.status(400).json({ error: 'Invalid JSON body' }); } apiLog.error('Unhandled API error:', err); const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500; res.status(status).json({ error: err.message || 'Internal server error' }); }); return app; } function listenOnPort(instance, port, protocol) { return new Promise((resolve, reject) => { instance.once('listening', () => { resolve(); }); instance.once('error', (err) => { if (err.code === 'EADDRINUSE') { let detail = ''; try { const pids = execSync(`lsof -ti :${port}`, { encoding: 'utf8' }).trim(); if (pids) detail = ` (PID ${pids.replace(/\n/g, ', ')})`; } catch { // lsof unavailable or no match } apiLog.error(`Port ${port} (${protocol}) is already in use${detail}. Check with: lsof -i :${port}`); } else { apiLog.error(`${protocol} server error:`, err.message); } reject(err); }); instance.listen(port); }); } export async function startApi() { if (server || httpsServer) { return server; } if (startupPromise) { return startupPromise; } startupPromise = (async () => { await ensureConnection(); serverStartedAt = new Date().toISOString(); const app = await buildApiApp(); const httpInstance = http.createServer(app); await listenOnPort(httpInstance, env.port, 'HTTP'); server = httpInstance; apiLog.info(`API listening on port ${env.port} (HTTP)`); if (env.httpsPort) { try { const tls = await getOrCreateCert(); const httpsInstance = https.createServer(tls, app); await listenOnPort(httpsInstance, env.httpsPort, 'HTTPS'); httpsServer = httpsInstance; apiLog.info(`API listening on port ${env.httpsPort} (HTTPS self-signed)`); } catch (err) { apiLog.error('Failed to start HTTPS server:', err.message); } } return server; })(); try { return await startupPromise; } finally { startupPromise = undefined; } } export async function stopApi() { if (!server && !httpsServer) return; // Long-lived SSE (and keep-alive) sockets otherwise block server.close() // forever — node --watch hangs on "Waiting for graceful termination...". closeAllSse(); closeAllManagers(); // Pooled image-crop workers are live threads and must be terminated too, or the // process stays alive after the last request has been served. await shutdownImageCropWorkers(); // Same for the pooled sandbox threads that run `javascript_execute` / applets. await shutdownJavascriptSandboxWorkers(); const closers = []; if (server) { const s = server; server = undefined; closers.push(new Promise((resolve, reject) => { s.close((err) => (err ? reject(err) : resolve())); s.closeAllConnections(); })); } if (httpsServer) { const s = httpsServer; httpsServer = undefined; closers.push(new Promise((resolve, reject) => { s.close((err) => (err ? reject(err) : resolve())); s.closeAllConnections(); })); } await Promise.all(closers); }