Files
KrisHD_AI/src/app.js
T
Fretux 5624eead82
CI / check (22) (push) Failing after 15m50s
CI / check (24) (push) Failing after 15m49s
Update CSP to allow external fonts, scripts, and add corresponding test coverage
2026-10-07 23:51:32 +02:00

387 lines
14 KiB
JavaScript

/**
* @file Verbindet Express, Sitzungen, Datenbank und Routen und verwaltet den Server-Lebenszyklus.
*/
import fs from "fs";
import {createRequire} from "node:module";
import {randomUUID} from "crypto";
import {performance} from "perf_hooks";
import express from "express";
import session from "express-session";
import FileStoreFactory from "session-file-store";
import {createConfig} from "./config.js";
import {createDatabase} from "./db/index.js";
import {createRepositories} from "./db/repositories.js";
import {createAuthRateLimiters, createChatRateLimiters} from "./middleware/rateLimiters.js";
import {createModelService} from "./services/modelService.js";
import {createChatService} from "./services/chatService.js";
import {createAuthRouter} from "./routes/auth.js";
import {createChatsRouter} from "./routes/chats.js";
import {createPagesRouter} from "./routes/pages.js";
import {createSettingsRouter} from "./routes/settings.js";
import {createHealthRouter, createSystemRouter} from "./routes/system.js";
import {createAttachmentService} from "./services/attachmentService.js";
import {createUploadsRouter} from "./routes/uploads.js";
import {createVisionDebugService} from "./services/visionDebugService.js";
import {createVisionDebugRouter} from "./routes/visionDebug.js";
export function createCloseHandler(resources) {
let closed = false;
return async () => {
if (closed) return;
closed = true;
const errors = [];
for (const close of resources) {
try {
await close();
} catch (error) {
errors.push(error);
}
}
if (errors.length) {
throw new AggregateError(errors, "Failed to close one or more resources");
}
};
}
function createLifecycleMonitors(app, config) {
const resources = [];
if (config.eventLoopLagMonitorEnabled) {
let last = performance.now();
const lagMonitor = setInterval(() => {
const now = performance.now();
const drift = now - last - 1000;
last = now;
if (drift > config.eventLoopLagWarnMs) {
console.warn("EVENT LOOP LAG", Math.round(drift), "ms", new Date().toISOString());
}
}, 1000);
lagMonitor.unref();
resources.push(() => clearInterval(lagMonitor));
}
if (config.slowRequestLoggingEnabled) {
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const ms = Date.now() - start;
if (ms > config.slowRequestWarnMs) {
console.warn("SLOW REQ", `${ms}ms`, req.method, req.url);
}
});
next();
});
}
return resources;
}
function safeRequestId(value) {
const candidate = String(value || "").trim();
if (/^[A-Za-z0-9._:-]{8,128}$/.test(candidate)) return candidate;
return randomUUID();
}
function createObservability(config) {
const counters = {
startedAt: new Date().toISOString(),
requestsTotal: 0,
responsesByStatus: {},
errorsTotal: 0,
slowRequestsTotal: 0,
lastFailure: null
};
const recordError = (req, error, status = 500) => {
counters.errorsTotal += 1;
counters.lastFailure = {
at: new Date().toISOString(),
requestId: req?.id || null,
method: req?.method || null,
path: req?.originalUrl || req?.url || null,
status,
message: String(error?.message || error || "Unknown error").slice(0, 500)
};
};
const middleware = (req, res, next) => {
const start = Date.now();
req.id = safeRequestId(req.get("X-Request-ID"));
res.setHeader("X-Request-ID", req.id);
res.on("finish", () => {
const durationMs = Date.now() - start;
const status = res.statusCode;
const statusKey = String(status);
counters.requestsTotal += 1;
counters.responsesByStatus[statusKey] = (counters.responsesByStatus[statusKey] || 0) + 1;
if (durationMs > config.slowRequestWarnMs) counters.slowRequestsTotal += 1;
if (status >= 500 && counters.lastFailure?.requestId !== req.id) {
recordError(req, `HTTP ${status}`, status);
}
if (config.structuredRequestLoggingEnabled) {
console.log(JSON.stringify({
type: "request",
requestId: req.id,
method: req.method,
path: req.originalUrl || req.url,
status,
durationMs,
slow: durationMs > config.slowRequestWarnMs,
user: req.session?.user || null
}));
}
});
next();
};
return {
middleware,
recordError,
getSnapshot() {
return {
...counters,
responsesByStatus: {...counters.responsesByStatus}
};
}
};
}
function wantsJson(req) {
const accept = req.get("Accept") || "";
const contentType = req.get("Content-Type") || "";
return req.xhr
|| req.path.startsWith("/api/")
|| accept.includes("application/json")
|| contentType.includes("application/json");
}
function notFoundHandler(req, res) {
if (wantsJson(req)) {
return res.status(404).json({error: "Not found"});
}
return res.status(404).type("text").send("Not found");
}
function errorHandler(err, req, res, next) {
if (res.headersSent) {
return next(err);
}
if (err?.type === "entity.parse.failed") {
req.app?.locals?.observability?.recordError(req, err, 400);
return res.status(400).json({error: "Invalid JSON"});
}
const status = Number.isInteger(err.status) ? err.status : Number.isInteger(err.statusCode) ? err.statusCode : 500;
const message = status >= 500 ? "Internal server error" : err.message;
req.app?.locals?.observability?.recordError(req, err, status);
if (status >= 500) {
console.warn("REQUEST ERROR", status, req.method, req.url, err);
} else {
console.warn("REQUEST ERROR", status, req.method, req.url, message);
}
if (wantsJson(req)) {
return res.status(status).json({error: message});
}
return res.status(status).type("text").send(message);
}
function sameOriginProtection(config) {
return (req, res, next) => {
if (!config.sameOriginProtectionEnabled || !["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
return next();
}
const secFetchSite = req.get("Sec-Fetch-Site");
if (secFetchSite && !["same-origin", "none"].includes(secFetchSite)) {
return res.status(403).json({error: "Cross-origin request blocked"});
}
const origin = req.get("Origin");
if (!origin) return next();
let parsedOrigin;
try {
parsedOrigin = new URL(origin);
} catch {
return res.status(403).json({error: "Cross-origin request blocked"});
}
if (parsedOrigin.protocol !== `${req.protocol}:` || parsedOrigin.host !== req.get("Host")) {
return res.status(403).json({error: "Cross-origin request blocked"});
}
return next();
};
}
function browserSecurityHeaders(config) {
return (_req, res, next) => {
res.setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com; worker-src 'self' blob:");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
res.setHeader("X-Content-Type-Options", "nosniff");
if (process.env.NODE_ENV === "production" && config.cookieSecure) res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
next();
};
}
function enforceSessionVersion(repositories) {
return (req, res, next) => {
if (!req.session.user) return next();
const user = repositories.getUser(req.session.user);
const currentVersion = Number(user?.session_version || 0);
const sessionVersion = Number(req.session.sessionVersion || 0);
if (user && currentVersion === sessionVersion) {
// Read the role on every request so revocation also affects existing sessions.
req.account = user;
return next();
}
req.session.destroy(() => {
if (req.path === "/session") {
return res.json({user: null});
}
return res.status(403).json({error: "Session expired. Please log in again."});
});
};
}
export function createApp(options = {}) {
const config = createConfig(options.config);
const app = express();
app.set("trust proxy", config.trustProxy);
if (config.dbPath !== ":memory:") {
fs.mkdirSync(config.dataDir, {recursive: true});
fs.mkdirSync(config.sessionsDir, {recursive: true});
fs.mkdirSync(config.uploadTmpDir, {recursive: true});
}
const closeHandlers = createLifecycleMonitors(app, config);
const observability = createObservability(config);
const db = createDatabase(config);
const repositories = createRepositories(db, config);
const modelService = options.modelService || createModelService(config, options.modelOverrides);
const visionDebugService = createVisionDebugService(config);
const attachmentService = createAttachmentService(db, config, modelService, visionDebugService);
const chatService = createChatService(repositories, modelService, config, undefined, attachmentService, visionDebugService);
const FileStore = FileStoreFactory(session);
const authRateLimiters = createAuthRateLimiters(config);
const chatRateLimiters = createChatRateLimiters(config);
closeHandlers.push(
...authRateLimiters.map((limiter) => limiter.close),
...chatRateLimiters.map((limiter) => limiter.close),
() => attachmentService.close(),
() => db.close()
);
app.use(observability.middleware);
app.use(browserSecurityHeaders(config));
app.use(createHealthRouter({modelService}));
app.use(express.json({limit: "1mb"}));
app.use(session({
store: options.sessionStore || (config.testMode ? undefined : new FileStore({path: config.sessionsDir, reapInterval: 3600})),
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
secure: config.cookieSecure,
httpOnly: true,
sameSite: "lax",
maxAge: config.sessionMaxAgeMs
}
}));
app.use(sameOriginProtection(config));
app.use(enforceSessionVersion(repositories));
app.use((req, res, next) => {
if (["DELETE", "POST"].includes(req.method)) res.on("finish", () => { void attachmentService.cleanup(); });
next();
});
app.locals.config = config;
app.locals.db = db;
app.locals.repositories = repositories;
app.locals.modelService = modelService;
app.locals.attachmentService = attachmentService;
app.locals.visionDebugService = visionDebugService;
app.locals.observability = observability;
app.locals.close = createCloseHandler(closeHandlers);
app.use(createPagesRouter(config));
const require = createRequire(import.meta.url);
const dompurifyPath = require.resolve("dompurify").replace(/purify\.cjs\.js$/, "purify.min.js");
app.get("/vendor/dompurify.js", (_req, res) => {
res.set("Cache-Control", "no-cache");
res.sendFile(dompurifyPath);
});
app.use(express.static(config.publicDir, {
maxAge: config.staticMaxAge,
etag: config.staticEtag,
setHeaders(res, filePath) {
// HTML and its modules must not drift apart after a deployment.
if (/\.(?:js|css)$/.test(filePath)) res.setHeader("Cache-Control", "no-cache");
},
fallthrough: config.staticFallthrough
}));
app.use(createAuthRouter({repositories, authRateLimiters, config}));
app.use(createUploadsRouter(attachmentService, config));
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories}));
app.use(createSettingsRouter({repositories}));
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}));
app.use(createSystemRouter({modelService, config}));
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}
export function startServer(options = {}) {
const app = createApp(options);
const {port} = app.locals.config;
const server = app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
const close = async () => {
const errors = [];
try {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
} catch (error) {
errors.push(error);
}
try {
await app.locals.close();
} catch (error) {
if (error instanceof AggregateError) {
errors.push(...error.errors);
} else {
errors.push(error);
}
}
if (errors.length) {
throw new AggregateError(errors, "Failed to close server cleanly");
}
};
if (options.handleSignals !== false) {
let shuttingDown = false;
const shutdown = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
close()
.then(() => process.exit(0))
.catch((error) => {
console.error(`Failed to shut down after ${signal}`, error);
process.exit(1);
});
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
}
return {app, server, close};
}