Switch to SQLite-backed session storage, cleanup third-party dependencies, and refine CSP for restricted external assets.
This commit is contained in:
+7
-4
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user