366 lines
13 KiB
JavaScript
366 lines
13 KiB
JavaScript
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, getSchemaVersion, closeStore } from '../db/store.js';
|
|
import { closeAll as closeAllSse } from './sse.js';
|
|
import { closeAllManagers } from '../services/conversationManager.js';
|
|
import { shutdownImageCropWorkers } from '../services/imageCrop.js';
|
|
import { shutdownImageHashWorkers } from '../services/companyImport/imageHasher.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';
|
|
import { agentRouter } from './agent/index.js';
|
|
import { startBackendMonitor, stopBackendMonitor, isDevMode } from '../backendmonitor/server.js';
|
|
import { startBackupService, stopBackupService } from '../services/backupService.js';
|
|
import { startHealthMonitorService, stopHealthMonitorService } from '../services/healthMonitorService.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');
|
|
let loadedCount = 0;
|
|
|
|
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);
|
|
loadedCount++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await registerRouteFilesRecursively(routesDir);
|
|
return loadedCount;
|
|
}
|
|
|
|
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 === '/mcp' || req.path.startsWith('/.well-known') || req.path.startsWith('/oauth')) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id');
|
|
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
|
if (req.method === 'OPTIONS') {
|
|
return res.sendStatus(204);
|
|
}
|
|
return next();
|
|
}
|
|
|
|
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('/api/agent')) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
const reqHeaders = req.headers['access-control-request-headers'];
|
|
res.setHeader(
|
|
'Access-Control-Allow-Headers',
|
|
reqHeaders || 'Content-Type, Authorization, x-agent-token, x-auth-token, authtoken, auth-token, token, x-token, agentversion, clientversion, version'
|
|
);
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,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();
|
|
}
|
|
|
|
export 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.endsWith('/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(express.urlencoded({ extended: true, 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();
|
|
});
|
|
}
|
|
|
|
app.routeCount = await registerRoutes(app, '/api');
|
|
app.use('/api/agent', agentRouter);
|
|
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, host = env.host) {
|
|
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}) on ${host} is already in use${detail}. Check with: lsof -i :${port}`);
|
|
} else {
|
|
apiLog.error(`${protocol} server error:`, err.message);
|
|
}
|
|
reject(err);
|
|
});
|
|
|
|
instance.listen(port, host);
|
|
});
|
|
}
|
|
|
|
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 ${env.host}:${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 ${env.host}:${env.httpsPort} (HTTPS self-signed)`);
|
|
} catch (err) {
|
|
apiLog.error('Failed to start HTTPS server:', err.message);
|
|
}
|
|
}
|
|
|
|
if (isDevMode()) {
|
|
try {
|
|
await startBackendMonitor();
|
|
} catch (err) {
|
|
apiLog.error('Failed to start Backend Monitor Interface:', err.message);
|
|
}
|
|
}
|
|
|
|
try {
|
|
startBackupService();
|
|
} catch (err) {
|
|
apiLog.error('Failed to start Backup Service:', err.message);
|
|
}
|
|
|
|
try {
|
|
startHealthMonitorService();
|
|
} catch (err) {
|
|
apiLog.error('Failed to start Health Monitor Service:', err.message);
|
|
}
|
|
|
|
return server;
|
|
})();
|
|
|
|
try {
|
|
return await startupPromise;
|
|
} finally {
|
|
startupPromise = undefined;
|
|
}
|
|
}
|
|
|
|
export async function stopApi() {
|
|
await stopBackendMonitor();
|
|
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();
|
|
await shutdownImageHashWorkers();
|
|
// Same for the pooled sandbox threads that run `javascript_execute` / applets.
|
|
await shutdownJavascriptSandboxWorkers();
|
|
await stopBackupService();
|
|
await stopHealthMonitorService();
|
|
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);
|
|
}
|
|
|
|
export async function runSelfTest() {
|
|
const start = Date.now();
|
|
apiLog.info('Starting dashback self-test (loading modules without binding ports)...');
|
|
|
|
// 1. Check configuration
|
|
apiLog.info(`Config: host=${env.host} port=${env.port} dbProvider=${env.dbProvider}`);
|
|
|
|
// 2. Test database & store initialization
|
|
await ensureConnection();
|
|
const schemaVer = await getSchemaVersion();
|
|
apiLog.info(`Database & store: OK (provider: ${env.dbProvider}, schema v${schemaVer})`);
|
|
|
|
// 3. Build API app and register all route modules
|
|
const app = await buildApiApp();
|
|
apiLog.info(`Routes & middleware: OK (${app.routeCount || 'all'} route modules loaded)`);
|
|
|
|
// 4. Test key native/WASM dependencies
|
|
try {
|
|
const { optimizeImage } = await import('wasm-image-optimization');
|
|
const tinyPng = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
|
'base64'
|
|
);
|
|
await optimizeImage({ image: tinyPng, format: 'png' });
|
|
apiLog.info('WASM image pipeline: OK');
|
|
} catch (err) {
|
|
apiLog.warn(`WASM image pipeline warning: ${err.message}`);
|
|
}
|
|
|
|
// 5. Clean up store connection
|
|
try {
|
|
await closeStore();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
const duration = Date.now() - start;
|
|
apiLog.info(`Self-test PASSED: All modules and routes loaded successfully in ${duration}ms (no ports bound).`);
|
|
return true;
|
|
}
|