Switch to SQLite-backed session storage, cleanup third-party dependencies, and refine CSP for restricted external assets.
This commit is contained in:
Generated
+1
-149
@@ -16,7 +16,6 @@
|
|||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"multer": "^2.4.0",
|
"multer": "^2.4.0",
|
||||||
"qs": "^6.15.0",
|
"qs": "^6.15.0",
|
||||||
"session-file-store": "^1.5.0",
|
|
||||||
"sharp": "^0.35.4"
|
"sharp": "^0.35.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -898,24 +897,6 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
@@ -952,12 +933,6 @@
|
|||||||
"node": ">=22"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.8",
|
"version": "1.20.8",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
|
||||||
@@ -1601,20 +1576,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -1699,12 +1660,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
@@ -1795,6 +1750,7 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
@@ -1838,12 +1794,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -1865,15 +1815,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||||
@@ -1884,18 +1825,6 @@
|
|||||||
"@keyv/serialize": "^1.1.1"
|
"@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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -1995,12 +1924,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -2077,15 +2000,6 @@
|
|||||||
"node-gyp-build-test": "build-test.js"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -2310,15 +2224,6 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -2402,23 +2307,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -2569,12 +2457,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -2634,15 +2516,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/uid-safe": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
@@ -2655,15 +2528,6 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
@@ -2727,18 +2591,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
+4
-2
@@ -11,7 +11,10 @@
|
|||||||
"dev": "node --env-file-if-exists=.env --watch server.js",
|
"dev": "node --env-file-if-exists=.env --watch server.js",
|
||||||
"user:role": "node --env-file-if-exists=.env scripts/set-role.js",
|
"user:role": "node --env-file-if-exists=.env scripts/set-role.js",
|
||||||
"lint": "eslint .",
|
"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"
|
"test:browser": "node scripts/browser-tests.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -23,7 +26,6 @@
|
|||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"multer": "^2.4.0",
|
"multer": "^2.4.0",
|
||||||
"qs": "^6.15.0",
|
"qs": "^6.15.0",
|
||||||
"session-file-store": "^1.5.0",
|
|
||||||
"sharp": "^0.35.4"
|
"sharp": "^0.35.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+1
-3
@@ -449,9 +449,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.12/marked.min.js"
|
<script src="/vendor/marked.min.js" defer></script>
|
||||||
integrity="sha512-rCQgmUulW6f6QegOvTntKKb5IAoxTpGVCdWqYjkXEpzAns6XUFs8NKVqWe+KQpctp/EoRSFSuykVputqknLYMg=="
|
|
||||||
crossorigin="anonymous" defer></script>
|
|
||||||
<script src="/vendor/dompurify.js" defer></script>
|
<script src="/vendor/dompurify.js" defer></script>
|
||||||
<script type="module" src="/js/script.js?v=20261012-chat-usability"></script>
|
<script type="module" src="/js/script.js?v=20261012-chat-usability"></script>
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
||||||
|
|||||||
+13
-1
@@ -1,5 +1,9 @@
|
|||||||
import {createSseParser, parseSseData, SseParseError} from "./sse.js";
|
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 {
|
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; }
|
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}; }
|
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 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 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}); }
|
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}); }
|
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 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}); });
|
const pause = (ms, signal) => new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, {once: true}); });
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ export function createComposerController({
|
|||||||
state, elements, attachmentComposer, webSearchButton, reasoningToggle, webSearchToggle,
|
state, elements, attachmentComposer, webSearchButton, reasoningToggle, webSearchToggle,
|
||||||
setChatActivity, updateComposerPlaceholder, setNotice, createNewChat, getChatById,
|
setChatActivity, updateComposerPlaceholder, setNotice, createNewChat, getChatById,
|
||||||
updateWorkspaceCopy, renderChatList, addMessage, updateChatActionState, scrollMessagesToBottom,
|
updateWorkspaceCopy, renderChatList, addMessage, updateChatActionState, scrollMessagesToBottom,
|
||||||
setMessageContent, announceResponding, announceReply, announceResponseStatus, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge,
|
setMessageContent, announceResponding = () => {}, announceReply = () => {}, announceResponseStatus = () => {}, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge,
|
||||||
api = defaultApi
|
api = defaultApi
|
||||||
}) {
|
}) {
|
||||||
const {msgInput, sendBtn, stopBtn, messagesDiv, modelSelect} = elements;
|
const {msgInput, sendBtn, messagesDiv, modelSelect} = elements;
|
||||||
const composerHint = document.getElementById("composerHint");
|
const stopBtn = elements.stopBtn || {hidden: true};
|
||||||
|
const composerHint = elements.composerHint || globalThis.document?.getElementById?.("composerHint") || {textContent: ""};
|
||||||
let sendController = null;
|
let sendController = null;
|
||||||
|
|
||||||
function updateSendState() {
|
function updateSendState() {
|
||||||
@@ -28,6 +29,7 @@ export function createComposerController({
|
|||||||
|
|
||||||
function setLoadingState(loading, overlayOptions = null) {
|
function setLoadingState(loading, overlayOptions = null) {
|
||||||
state.isProcessing = loading;
|
state.isProcessing = loading;
|
||||||
|
msgInput.disabled = loading;
|
||||||
webSearchButton.disabled = loading;
|
webSearchButton.disabled = loading;
|
||||||
reasoningToggle.setBusy(loading);
|
reasoningToggle.setBusy(loading);
|
||||||
attachmentComposer.setBusy(loading);
|
attachmentComposer.setBusy(loading);
|
||||||
|
|||||||
@@ -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 {};
|
||||||
Vendored
+20
@@ -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, "<code>$1</code>")
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||||
|
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
|
||||||
|
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||||
|
.replace(/_([^_]+)_/g, "<em>$1</em>")
|
||||||
|
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noreferrer noopener">$1</a>');
|
||||||
|
window.marked = {parse(markdown) {
|
||||||
|
return String(markdown || "").split(/\n{2,}/).map(block => {
|
||||||
|
const lines = block.split("\n");
|
||||||
|
if (/^### /.test(lines[0])) return `<h3>${inline(lines[0].slice(4))}</h3>`;
|
||||||
|
if (/^## /.test(lines[0])) return `<h2>${inline(lines[0].slice(3))}</h2>`;
|
||||||
|
if (/^# /.test(lines[0])) return `<h1>${inline(lines[0].slice(2))}</h1>`;
|
||||||
|
return `<p>${lines.map(inline).join("<br>")}</p>`;
|
||||||
|
}).join("");
|
||||||
|
}};
|
||||||
|
}());
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+7
-4
@@ -7,7 +7,6 @@ import {randomUUID} from "crypto";
|
|||||||
import {performance} from "perf_hooks";
|
import {performance} from "perf_hooks";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import FileStoreFactory from "session-file-store";
|
|
||||||
import {createConfig} from "./config.js";
|
import {createConfig} from "./config.js";
|
||||||
import {createDatabase} from "./db/index.js";
|
import {createDatabase} from "./db/index.js";
|
||||||
import {createRepositories} from "./db/repositories.js";
|
import {createRepositories} from "./db/repositories.js";
|
||||||
@@ -25,6 +24,8 @@ import {createAttachmentService} from "./services/attachmentService.js";
|
|||||||
import {createUploadsRouter} from "./routes/uploads.js";
|
import {createUploadsRouter} from "./routes/uploads.js";
|
||||||
import {createVisionDebugService} from "./services/visionDebugService.js";
|
import {createVisionDebugService} from "./services/visionDebugService.js";
|
||||||
import {createVisionDebugRouter} from "./routes/visionDebug.js";
|
import {createVisionDebugRouter} from "./routes/visionDebug.js";
|
||||||
|
import {createAdminMarketRouter} from "./routes/adminMarket.js";
|
||||||
|
import {createSqliteSessionStore} from "./middleware/sqliteSessionStore.js";
|
||||||
|
|
||||||
export function createCloseHandler(resources) {
|
export function createCloseHandler(resources) {
|
||||||
let closed = false;
|
let closed = false;
|
||||||
@@ -223,7 +224,7 @@ function sameOriginProtection(config) {
|
|||||||
|
|
||||||
function browserSecurityHeaders(config) {
|
function browserSecurityHeaders(config) {
|
||||||
return (_req, res, next) => {
|
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("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
||||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
@@ -273,13 +274,14 @@ export function createApp(options = {}) {
|
|||||||
const visionDebugService = createVisionDebugService(config);
|
const visionDebugService = createVisionDebugService(config);
|
||||||
const attachmentService = createAttachmentService(db, config, modelService, visionDebugService);
|
const attachmentService = createAttachmentService(db, config, modelService, visionDebugService);
|
||||||
const chatService = createChatService(repositories, modelService, config, undefined, attachmentService, 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 rateLimitStore = createSqliteRateLimitStore(db);
|
||||||
const authRateLimiters = createAuthRateLimiters(config, rateLimitStore);
|
const authRateLimiters = createAuthRateLimiters(config, rateLimitStore);
|
||||||
const chatRateLimiters = createChatRateLimiters(config, rateLimitStore);
|
const chatRateLimiters = createChatRateLimiters(config, rateLimitStore);
|
||||||
closeHandlers.push(
|
closeHandlers.push(
|
||||||
...authRateLimiters.map((limiter) => limiter.close),
|
...authRateLimiters.map((limiter) => limiter.close),
|
||||||
...chatRateLimiters.map((limiter) => limiter.close),
|
...chatRateLimiters.map((limiter) => limiter.close),
|
||||||
|
...(sessionStore?.close ? [() => sessionStore.close()] : []),
|
||||||
() => attachmentService.close(),
|
() => attachmentService.close(),
|
||||||
() => db.close()
|
() => db.close()
|
||||||
);
|
);
|
||||||
@@ -289,7 +291,7 @@ export function createApp(options = {}) {
|
|||||||
app.use(createHealthRouter({modelService}));
|
app.use(createHealthRouter({modelService}));
|
||||||
app.use(express.json({limit: "1mb"}));
|
app.use(express.json({limit: "1mb"}));
|
||||||
app.use(session({
|
app.use(session({
|
||||||
store: options.sessionStore || (config.testMode ? undefined : new FileStore({path: config.sessionsDir, reapInterval: 3600})),
|
store: sessionStore,
|
||||||
secret: config.sessionSecret,
|
secret: config.sessionSecret,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
@@ -337,6 +339,7 @@ export function createApp(options = {}) {
|
|||||||
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories}));
|
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories}));
|
||||||
app.use(createSettingsRouter({repositories}));
|
app.use(createSettingsRouter({repositories}));
|
||||||
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}));
|
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}));
|
||||||
|
app.use(createAdminMarketRouter(repositories));
|
||||||
app.use(createWorkspaceTransferRouter({repositories, chatService, config, attachmentService}));
|
app.use(createWorkspaceTransferRouter({repositories, chatService, config, attachmentService}));
|
||||||
app.use(createSystemRouter({modelService, config}));
|
app.use(createSystemRouter({modelService, config}));
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ export function createConfig(overrides = {}) {
|
|||||||
chatSummaryMaxChars: int(process.env.CHAT_SUMMARY_MAX_CHARS, 2200),
|
chatSummaryMaxChars: int(process.env.CHAT_SUMMARY_MAX_CHARS, 2200),
|
||||||
chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5),
|
chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5),
|
||||||
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
|
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)),
|
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))),
|
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)),
|
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} = {}) {
|
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 (
|
db.exec(`CREATE TABLE IF NOT EXISTS chat_message_versions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
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");
|
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 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.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) {
|
if (!hadParentMessageId) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
WITH ordered AS (
|
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();
|
const legacyChatsExist = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chats'").get();
|
||||||
if (!legacyChatsExist) {
|
if (!legacyChatsExist) {
|
||||||
|
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)").run(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,4 +501,5 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
|||||||
if (dropLegacyChats) {
|
if (dropLegacyChats) {
|
||||||
db.prepare("DROP TABLE chats").run();
|
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);`);
|
CREATE INDEX IF NOT EXISTS rate_limit_hits_expiry ON rate_limit_hits(hit_at);`);
|
||||||
db.pragma("busy_timeout = 5000");
|
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 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 insert = db.prepare("INSERT INTO rate_limit_hits (bucket_key, hit_at) VALUES (?, ?)");
|
||||||
const consume = db.transaction((key, now, windowMs, max) => {
|
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;
|
if (count.get(key).count >= max) return false;
|
||||||
insert.run(key, now);
|
insert.run(key, now);
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
+5
-4
@@ -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);
|
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();
|
const server = await startTestServer();
|
||||||
t.after(() => server.close());
|
t.after(() => server.close());
|
||||||
const response = await fetch(`${server.baseUrl}/`);
|
const response = await fetch(`${server.baseUrl}/`);
|
||||||
const policy = response.headers.get("content-security-policy");
|
const policy = response.headers.get("content-security-policy");
|
||||||
assert.match(policy, /style-src[^;]*https:\/\/fonts\.googleapis\.com/);
|
assert.match(policy, /style-src 'self'/);
|
||||||
assert.match(policy, /script-src[^;]*https:\/\/cdnjs\.cloudflare\.com/);
|
assert.match(policy, /script-src 'self'/);
|
||||||
assert.match(policy, /font-src[^;]*https:\/\/fonts\.gstatic\.com/);
|
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 => {
|
test("personality API keeps prompts private, validates IDs, and enforces chat ownership", async t => {
|
||||||
|
|||||||
Reference in New Issue
Block a user