Switch to SQLite-backed session storage, cleanup third-party dependencies, and refine CSP for restricted external assets.
CI / check (22) (push) Failing after 4m51s
CI / check (24) (push) Failing after 3h11m30s

This commit is contained in:
2026-10-12 23:19:36 +02:00
parent 7723125c93
commit dd23a19ed2
15 changed files with 164 additions and 169 deletions
+7 -4
View File
@@ -7,7 +7,6 @@ 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";
@@ -25,6 +24,8 @@ import {createAttachmentService} from "./services/attachmentService.js";
import {createUploadsRouter} from "./routes/uploads.js";
import {createVisionDebugService} from "./services/visionDebugService.js";
import {createVisionDebugRouter} from "./routes/visionDebug.js";
import {createAdminMarketRouter} from "./routes/adminMarket.js";
import {createSqliteSessionStore} from "./middleware/sqliteSessionStore.js";
export function createCloseHandler(resources) {
let closed = false;
@@ -223,7 +224,7 @@ function sameOriginProtection(config) {
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("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'; script-src 'self'; font-src 'self' data:; 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");
@@ -273,13 +274,14 @@ export function createApp(options = {}) {
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 sessionStore = options.sessionStore || (config.testMode ? undefined : createSqliteSessionStore(db));
const rateLimitStore = createSqliteRateLimitStore(db);
const authRateLimiters = createAuthRateLimiters(config, rateLimitStore);
const chatRateLimiters = createChatRateLimiters(config, rateLimitStore);
closeHandlers.push(
...authRateLimiters.map((limiter) => limiter.close),
...chatRateLimiters.map((limiter) => limiter.close),
...(sessionStore?.close ? [() => sessionStore.close()] : []),
() => attachmentService.close(),
() => db.close()
);
@@ -289,7 +291,7 @@ export function createApp(options = {}) {
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})),
store: sessionStore,
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
@@ -337,6 +339,7 @@ export function createApp(options = {}) {
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories}));
app.use(createSettingsRouter({repositories}));
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}));
app.use(createAdminMarketRouter(repositories));
app.use(createWorkspaceTransferRouter({repositories, chatService, config, attachmentService}));
app.use(createSystemRouter({modelService, config}));
app.use(notFoundHandler);
+1 -1
View File
@@ -64,7 +64,7 @@ export function createConfig(overrides = {}) {
chatSummaryMaxChars: int(process.env.CHAT_SUMMARY_MAX_CHARS, 2200),
chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5),
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
modelRequestYTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000),
modelRequestTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000),
modelConnectTimeoutMs: Math.max(1, int(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000)),
modelInactivityTimeoutMs: Math.max(1, int(process.env.MODEL_INACTIVITY_TIMEOUT_MS, int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000))),
modelMaxGenerationMs: Math.max(1, int(process.env.MODEL_MAX_GENERATION_MS, 600000)),
+12
View File
@@ -241,6 +241,10 @@ export function initializeSchema(db) {
}
export function runMigrations(db, {dropLegacyChats = true} = {}) {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
db.exec(`CREATE TABLE IF NOT EXISTS chat_message_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
@@ -328,6 +332,12 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
ensureColumn("chat_sessions", chatSessionColumns, "system_instruction", "ALTER TABLE chat_sessions ADD COLUMN system_instruction TEXT");
db.prepare("UPDATE chat_sessions SET is_pinned = 0 WHERE is_pinned IS NULL").run();
db.prepare("UPDATE chat_sessions SET context_summary_message_id = 0 WHERE context_summary_message_id IS NULL").run();
db.exec(`
CREATE INDEX IF NOT EXISTS chat_sessions_owner_updated ON chat_sessions(username, updated_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS chat_sessions_owner_archived_pinned ON chat_sessions(username, archived_at, is_pinned, updated_at DESC);
CREATE INDEX IF NOT EXISTS chat_messages_chat_created ON chat_messages(chat_id, created_at, id);
CREATE INDEX IF NOT EXISTS chat_memories_owner_chat ON chat_memories(username, chat_id, updated_at DESC);
`);
if (!hadParentMessageId) {
db.exec(`
WITH ordered AS (
@@ -458,6 +468,7 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
const legacyChatsExist = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chats'").get();
if (!legacyChatsExist) {
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)").run(1);
return;
}
@@ -490,4 +501,5 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
if (dropLegacyChats) {
db.prepare("DROP TABLE chats").run();
}
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)").run(1);
}
+4 -2
View File
@@ -73,11 +73,13 @@ export function createSqliteRateLimitStore(db) {
);
CREATE INDEX IF NOT EXISTS rate_limit_hits_expiry ON rate_limit_hits(hit_at);`);
db.pragma("busy_timeout = 5000");
const prune = db.prepare("DELETE FROM rate_limit_hits WHERE bucket_key = ? AND hit_at <= ?");
// Prune globally on every write. Pruning by bucket only leaves expired rows
// for inactive keys in the database forever.
const prune = db.prepare("DELETE FROM rate_limit_hits WHERE hit_at <= ?");
const count = db.prepare("SELECT COUNT(*) AS count FROM rate_limit_hits WHERE bucket_key = ?");
const insert = db.prepare("INSERT INTO rate_limit_hits (bucket_key, hit_at) VALUES (?, ?)");
const consume = db.transaction((key, now, windowMs, max) => {
prune.run(key, now - windowMs);
prune.run(now - windowMs);
if (count.get(key).count >= max) return false;
insert.run(key, now);
return true;
+48
View File
@@ -0,0 +1,48 @@
import session from "express-session";
// Small shared session store backed by the same SQLite database as the app.
// Expired rows are pruned on access/write, so no process-local timer is needed.
export function createSqliteSessionStore(db) {
db.exec(`CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
sess TEXT NOT NULL,
expires_at INTEGER
); CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires_at);`);
const prune = db.prepare("DELETE FROM sessions WHERE expires_at IS NOT NULL AND expires_at <= ?");
const get = db.prepare("SELECT sess, expires_at FROM sessions WHERE sid = ?");
const put = db.prepare("INSERT INTO sessions(sid, sess, expires_at) VALUES(?, ?, ?) ON CONFLICT(sid) DO UPDATE SET sess=excluded.sess, expires_at=excluded.expires_at");
const remove = db.prepare("DELETE FROM sessions WHERE sid = ?");
const store = new session.Store();
store.get = (sid, callback) => {
try {
const now = Date.now();
prune.run(now);
const row = get.get(sid);
if (!row) return callback(null, null);
if (row.expires_at != null && row.expires_at <= now) {
remove.run(sid);
return callback(null, null);
}
return callback(null, JSON.parse(row.sess));
} catch (error) { return callback(error); }
};
store.set = (sid, sess, callback) => {
try {
const expiresAt = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : null;
prune.run(Date.now());
put.run(sid, JSON.stringify(sess), Number.isFinite(expiresAt) ? expiresAt : null);
return callback?.(null);
} catch (error) { return callback?.(error); }
};
store.destroy = (sid, callback) => {
try { remove.run(sid); return callback?.(null); } catch (error) { return callback?.(error); }
};
store.touch = (sid, sess, callback) => {
try {
const expiresAt = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : null;
put.run(sid, JSON.stringify(sess), Number.isFinite(expiresAt) ? expiresAt : null);
return callback?.(null);
} catch (error) { return callback?.(error); }
};
return store;
}
+25
View File
@@ -0,0 +1,25 @@
import express from "express";
import {requireLogin, requireAdmin} from "../middleware/auth.js";
export function createAdminMarketRouter(repositories) {
const router = express.Router();
router.use(requireLogin, requireAdmin);
router.get("/admin/reports", (_req, res) => res.json({
reports: repositories.listMarketReports(),
personas: repositories.listMarketPersonas({admin: true, username: _req.session.user})
}));
router.post("/admin/reports/:id", (req, res) => {
repositories.updateMarketReportStatus(Number(req.params.id), String(req.body?.status || "reviewed").trim() || "reviewed");
return res.json({message: "Report updated"});
});
router.post("/admin/personas/market/:id/moderate", (req, res) => {
const status = String(req.body?.status || "approved").trim() || "approved";
repositories.updateMarketModeration(Number(req.params.id), {
status,
notes: req.body?.notes == null ? null : String(req.body.notes).trim() || null,
softDelete: Boolean(req.body?.softDelete)
});
return res.json({persona: repositories.getMarketPersona(Number(req.params.id))});
});
return router;
}