Files
dashback/src/api/index.js
2026-10-06 11:19:33 +02:00

261 lines
8.5 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 } from '../db/store.js';
import { closeAll as closeAllSse } from './sse.js';
import { closeAllManagers } from '../services/conversationManager.js';
import { shutdownImageCropWorkers } from '../services/imageCrop.js';
import { getOrCreateCert } from '../util/cert.js';
import v1Router from '../v1/index.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function loadRoutes(app, basePath = '') {
const routesDir = path.join(__dirname, 'routes');
async function walk(dir) {
for (const file of fs.readdirSync(dir)) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
await walk(fullPath);
} else if (file.endsWith('.js')) {
const routeModule = await import(pathToFileURL(fullPath).href);
if (routeModule.register) {
routeModule.register(app, basePath);
}
}
}
}
await walk(routesDir);
}
let server;
let httpsServer;
let starting;
let startedAt;
const PRIVATE_HOST_REGEX = /^(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 isAllowedOrigin(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 (PRIVATE_HOST_REGEX.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,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 && isAllowedOrigin(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 createApi() {
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 (startedAt) {
app.use((req, res, next) => {
res.setHeader('X-Server-Started-At', startedAt);
next();
});
}
await loadRoutes(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' });
}
console.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 listenServer(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
}
console.error(`Port ${port} (${protocol}) is already in use${detail}. Check with: lsof -i :${port}`);
} else {
console.error(`${protocol} server error:`, err.message);
}
reject(err);
});
instance.listen(port);
});
}
export async function startApi() {
if (server || httpsServer) {
return server;
}
if (starting) {
return starting;
}
starting = (async () => {
await ensureConnection();
startedAt = new Date().toISOString();
const app = await createApi();
const httpInstance = http.createServer(app);
await listenServer(httpInstance, env.port, 'HTTP');
server = httpInstance;
console.log(`API listening on port ${env.port} (HTTP)`);
if (env.httpsPort) {
try {
const tls = await getOrCreateCert();
const httpsInstance = https.createServer(tls, app);
await listenServer(httpsInstance, env.httpsPort, 'HTTPS');
httpsServer = httpsInstance;
console.log(`API listening on port ${env.httpsPort} (HTTPS self-signed)`);
} catch (err) {
console.error('Failed to start HTTPS server:', err.message);
}
}
return server;
})();
try {
return await starting;
} finally {
starting = 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();
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);
}