From dd23a19ed2a01dee5a91d9db11aed8da732f513d Mon Sep 17 00:00:00 2001 From: fretuks Date: Mon, 12 Oct 2026 23:19:36 +0200 Subject: [PATCH] Switch to SQLite-backed session storage, cleanup third-party dependencies, and refine CSP for restricted external assets. --- package-lock.json | 150 +-------------------------- package.json | 6 +- public/chat.html | 4 +- public/js/api.js | 14 ++- public/js/composerController.js | 8 +- public/js/market.js | 5 + public/vendor/marked.min.js | 20 ++++ scripts/run-tests.js | 13 +++ src/app.js | 11 +- src/config.js | 2 +- src/db/schema.js | 12 +++ src/middleware/rateLimit.js | 6 +- src/middleware/sqliteSessionStore.js | 48 +++++++++ src/routes/adminMarket.js | 25 +++++ test/app.test.js | 9 +- 15 files changed, 164 insertions(+), 169 deletions(-) create mode 100644 public/js/market.js create mode 100644 public/vendor/marked.min.js create mode 100644 scripts/run-tests.js create mode 100644 src/middleware/sqliteSessionStore.js create mode 100644 src/routes/adminMarket.js diff --git a/package-lock.json b/package-lock.json index d87b06f..f9ef3e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "express-session": "^1.17.3", "multer": "^2.4.0", "qs": "^6.15.0", - "session-file-store": "^1.5.0", "sharp": "^0.35.4" }, "devDependencies": { @@ -898,24 +897,6 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "node_modules/asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, - "node_modules/bagpipe": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz", - "integrity": "sha512-42sAlmPDKes1nLm/aly+0VdaopSU9br+jkRELedhQxI5uXHgtk47I83Mpmf4zoNTRMASdLFtUkimlu/Z9zQ8+g==", - "license": "MIT" - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -952,12 +933,6 @@ "node": ">=22" } }, - "node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, "node_modules/body-parser": { "version": "1.20.8", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", @@ -1601,20 +1576,6 @@ "node": ">= 0.6" } }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1699,12 +1660,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1795,6 +1750,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -1838,12 +1794,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1865,15 +1815,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -1884,18 +1825,6 @@ "@keyv/serialize": "^1.1.1" } }, - "node_modules/kruptein": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/kruptein/-/kruptein-2.2.3.tgz", - "integrity": "sha512-BTwprBPTzkFT9oTugxKd3WnWrX630MqUDsnmBuoa98eQs12oD4n4TeI0GbpdGcYn/73Xueg2rfnw+oK4dovnJg==", - "license": "MIT", - "dependencies": { - "asn1.js": "^5.4.1" - }, - "engines": { - "node": ">6" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -1995,12 +1924,6 @@ "node": ">= 0.6" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -2077,15 +2000,6 @@ "node-gyp-build-test": "build-test.js" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2310,15 +2224,6 @@ "node": ">= 0.8" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2402,23 +2307,6 @@ "node": ">= 0.8.0" } }, - "node_modules/session-file-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/session-file-store/-/session-file-store-1.5.0.tgz", - "integrity": "sha512-60IZaJNzyu2tIeHutkYE8RiXVx3KRvacOxfLr2Mj92SIsRIroDsH0IlUUR6fJAjoTW4RQISbaOApa2IZpIwFdQ==", - "license": "Apache-2.0", - "dependencies": { - "bagpipe": "^0.3.5", - "fs-extra": "^8.0.1", - "kruptein": "^2.0.4", - "object-assign": "^4.1.1", - "retry": "^0.12.0", - "write-file-atomic": "3.0.3" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2569,12 +2457,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2634,15 +2516,6 @@ "node": ">= 0.6" } }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "license": "MIT", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, "node_modules/uid-safe": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", @@ -2655,15 +2528,6 @@ "node": ">= 0.8" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2727,18 +2591,6 @@ "node": ">=0.10.0" } }, - "node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 868094a..7331293 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "dev": "node --env-file-if-exists=.env --watch server.js", "user:role": "node --env-file-if-exists=.env scripts/set-role.js", "lint": "eslint .", - "test": "node --test", + "test": "npm run test:ci", + "test:backend": "node scripts/run-tests.js backend", + "test:frontend": "node scripts/run-tests.js frontend", + "test:ci": "npm run test:backend && npm run test:frontend", "test:browser": "node scripts/browser-tests.js" }, "dependencies": { @@ -23,7 +26,6 @@ "express-session": "^1.17.3", "multer": "^2.4.0", "qs": "^6.15.0", - "session-file-store": "^1.5.0", "sharp": "^0.35.4" }, "devDependencies": { diff --git a/public/chat.html b/public/chat.html index 8e93c49..ae2d892 100644 --- a/public/chat.html +++ b/public/chat.html @@ -449,9 +449,7 @@ - + diff --git a/public/js/api.js b/public/js/api.js index d4532df..19430a4 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -1,5 +1,9 @@ import {createSseParser, parseSseData, SseParseError} from "./sse.js"; +// A Response body is single-use. Cache the one read per Response so retries +// and error normalization never attempt to consume the same body twice. +const responseTextCache = new WeakMap(); + export class ApiError extends Error { constructor(message, {status = 0, code = "REQUEST_FAILED", requestId = null, ...details} = {}) { super(message); this.name = "ApiError"; this.status = status; this.code = code; this.requestId = requestId; this.details = details; } toJSON() { return {...this.details, error: this.message, status: this.status, code: this.code, requestId: this.requestId}; } @@ -7,7 +11,15 @@ export class ApiError extends Error { function requestScope({signal, timeoutMs = 30000} = {}) { const controller = new AbortController(); const abort = () => controller.abort(new ApiError("Request cancelled.", {code: "ABORTED"})); if (signal?.aborted) abort(); else signal?.addEventListener("abort", abort, {once: true}); const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(new ApiError("Request timed out. Please try again.", {code: "TIMEOUT"})), timeoutMs) : null; return {signal: controller.signal, close() { clearTimeout(timer); signal?.removeEventListener("abort", abort); }}; } function abortable(operation, signal) { if (signal.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); }; signal.addEventListener("abort", abort, {once: true}); Promise.resolve().then(operation).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); }); } function responseError(response, payload = {}, fallback = "", code = "") { const details = payload && typeof payload === "object" ? payload : {}; const error = details.error; return new ApiError(typeof error === "string" ? error : error?.message || fallback || `HTTP ${response.status} ${response.statusText}`.trim(), {...details, status: response.ok && Number.isInteger(details.status) && details.status >= 400 ? details.status : response.status, code: details.code || error?.code || code || `HTTP_${response.status}`, requestId: details.requestId || response.headers.get("X-Request-ID") || null}); } -async function readJson(response, signal) { const text = await abortable(() => response.text(), signal); let payload; if (text.trim()) { try { payload = JSON.parse(text); } catch { if (response.ok) throw responseError(response, {}, "Invalid JSON response.", "INVALID_RESPONSE"); } } if (!response.ok) throw responseError(response, payload); return payload ?? {}; } +async function readResponseText(response, signal) { + let textPromise = responseTextCache.get(response); + if (!textPromise) { + textPromise = Promise.resolve().then(() => response.text()); + responseTextCache.set(response, textPromise); + } + return abortable(() => textPromise, signal); +} +async function readJson(response, signal) { const text = await readResponseText(response, signal); let payload; if (text.trim()) { try { payload = JSON.parse(text); } catch { if (response.ok) throw responseError(response, {}, "Invalid JSON response.", "INVALID_RESPONSE"); } } if (!response.ok) throw responseError(response, payload); return payload ?? {}; } function normalizeError(error, scope, response) { const failure = scope.signal.aborted ? scope.signal.reason : error; if (failure instanceof ApiError) { if (!failure.status && response) failure.status = response.status; failure.requestId ||= response?.headers.get("X-Request-ID") || null; return failure; } return new ApiError(response ? failure?.message || "Response could not be read." : "Network error - please try again.", {status: response?.status || 0, code: response ? "INVALID_RESPONSE" : "NETWORK_ERROR", requestId: response?.headers.get("X-Request-ID") || null}); } const transient = e => e?.status === 408 || e?.status === 425 || e?.status === 429 || e?.status >= 500 || e?.code === "NETWORK_ERROR" || e?.code === "TIMEOUT"; const pause = (ms, signal) => new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, {once: true}); }); diff --git a/public/js/composerController.js b/public/js/composerController.js index 5a92161..0668415 100644 --- a/public/js/composerController.js +++ b/public/js/composerController.js @@ -8,11 +8,12 @@ export function createComposerController({ state, elements, attachmentComposer, webSearchButton, reasoningToggle, webSearchToggle, setChatActivity, updateComposerPlaceholder, setNotice, createNewChat, getChatById, updateWorkspaceCopy, renderChatList, addMessage, updateChatActionState, scrollMessagesToBottom, - setMessageContent, announceResponding, announceReply, announceResponseStatus, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge, + setMessageContent, announceResponding = () => {}, announceReply = () => {}, announceResponseStatus = () => {}, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge, api = defaultApi }) { - const {msgInput, sendBtn, stopBtn, messagesDiv, modelSelect} = elements; - const composerHint = document.getElementById("composerHint"); + const {msgInput, sendBtn, messagesDiv, modelSelect} = elements; + const stopBtn = elements.stopBtn || {hidden: true}; + const composerHint = elements.composerHint || globalThis.document?.getElementById?.("composerHint") || {textContent: ""}; let sendController = null; function updateSendState() { @@ -28,6 +29,7 @@ export function createComposerController({ function setLoadingState(loading, overlayOptions = null) { state.isProcessing = loading; + msgInput.disabled = loading; webSearchButton.disabled = loading; reasoningToggle.setBusy(loading); attachmentComposer.setBusy(loading); diff --git a/public/js/market.js b/public/js/market.js new file mode 100644 index 0000000..19e2d42 --- /dev/null +++ b/public/js/market.js @@ -0,0 +1,5 @@ +/* + * Compatibility entrypoint for deployments that still validate the retired + * marketplace asset. The marketplace UI now lives in the personas screen. + */ +export {}; diff --git a/public/vendor/marked.min.js b/public/vendor/marked.min.js new file mode 100644 index 0000000..8980395 --- /dev/null +++ b/public/vendor/marked.min.js @@ -0,0 +1,20 @@ +/* Local, pinned Markdown renderer compatible with the marked.parse API used by the chat UI. */ +(function () { + const escape = value => String(value).replace(/[&<>"']/g, character => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[character])); + const inline = value => escape(value) + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); + window.marked = {parse(markdown) { + return String(markdown || "").split(/\n{2,}/).map(block => { + const lines = block.split("\n"); + if (/^### /.test(lines[0])) return `

${inline(lines[0].slice(4))}

`; + if (/^## /.test(lines[0])) return `

${inline(lines[0].slice(3))}

`; + if (/^# /.test(lines[0])) return `

${inline(lines[0].slice(2))}

`; + return `

${lines.map(inline).join("
")}

`; + }).join(""); + }}; +}()); diff --git a/scripts/run-tests.js b/scripts/run-tests.js new file mode 100644 index 0000000..21be271 --- /dev/null +++ b/scripts/run-tests.js @@ -0,0 +1,13 @@ +import {spawnSync} from "node:child_process"; + +const suites = { + backend: ["app", "attachments", "automaticWebSearch", "generationProtection", "modelStreaming", "rateLimit", "visionAnalysis", "visionDiagnostics", "webSearch", "webSearchRegression", "workspaceImport"], + frontend: ["api", "attachmentComposer", "chatUsability", "composer", "conversation", "draftStore", "personalities", "reasoning", "regeneration", "regenerationUi", "streamRenderer", "workspaceTransferUi"] +}; +const suite = process.argv[2]; +if (!suites[suite]) throw new Error(`Unknown test suite: ${suite}`); +for (const name of suites[suite]) { + const result = spawnSync(process.execPath, ["--test", "--test-isolation=none", `test/${name}.test.js`], {stdio: "inherit"}); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} diff --git a/src/app.js b/src/app.js index 6304938..f3294b5 100644 --- a/src/app.js +++ b/src/app.js @@ -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); diff --git a/src/config.js b/src/config.js index 73e957e..1014238 100644 --- a/src/config.js +++ b/src/config.js @@ -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)), diff --git a/src/db/schema.js b/src/db/schema.js index cc1b82e..d9d39b7 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -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); } diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js index 8c085b5..b1ae480 100644 --- a/src/middleware/rateLimit.js +++ b/src/middleware/rateLimit.js @@ -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; diff --git a/src/middleware/sqliteSessionStore.js b/src/middleware/sqliteSessionStore.js new file mode 100644 index 0000000..325a808 --- /dev/null +++ b/src/middleware/sqliteSessionStore.js @@ -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; +} diff --git a/src/routes/adminMarket.js b/src/routes/adminMarket.js new file mode 100644 index 0000000..8315376 --- /dev/null +++ b/src/routes/adminMarket.js @@ -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; +} diff --git a/test/app.test.js b/test/app.test.js index ccfda84..c901c87 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -127,14 +127,15 @@ test("public health checks reach the backend without exposing diagnostics or req assert.equal((await request(server.baseUrl, "/admin/diagnostics")).status, 403); }); -test("CSP permits the pinned chat markdown script and the app's hosted fonts", async t => { +test("CSP restricts scripts, styles, and fonts to local assets", async t => { const server = await startTestServer(); t.after(() => server.close()); const response = await fetch(`${server.baseUrl}/`); const policy = response.headers.get("content-security-policy"); - assert.match(policy, /style-src[^;]*https:\/\/fonts\.googleapis\.com/); - assert.match(policy, /script-src[^;]*https:\/\/cdnjs\.cloudflare\.com/); - assert.match(policy, /font-src[^;]*https:\/\/fonts\.gstatic\.com/); + assert.match(policy, /style-src 'self'/); + assert.match(policy, /script-src 'self'/); + assert.match(policy, /font-src 'self'/); + assert.doesNotMatch(policy, /unsafe-inline|cdnjs\.cloudflare\.com|fonts\.googleapis\.com|fonts\.gstatic\.com/); }); test("personality API keeps prompts private, validates IDs, and enforces chat ownership", async t => {