Refactor project structure and add user self-serve UI (khanon/oai-reverse-proxy!41)
This commit is contained in:
Generated
+316
@@ -18,13 +18,16 @@
|
||||
"dotenv": "^16.0.3",
|
||||
"ejs": "^3.1.9",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"firebase-admin": "^11.10.1",
|
||||
"googleapis": "^122.0.0",
|
||||
"http-proxy-middleware": "^3.0.0-beta.1",
|
||||
"memorystore": "^1.6.7",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-schedule": "^2.1.1",
|
||||
"pino": "^8.11.0",
|
||||
"pino-http": "^8.3.3",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"showdown": "^2.1.0",
|
||||
"tiktoken": "^1.0.10",
|
||||
"uuid": "^9.0.0",
|
||||
@@ -35,8 +38,10 @@
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cors": "^2.8.13",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/express-session": "^1.17.7",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node-schedule": "^2.1.0",
|
||||
"@types/sanitize-html": "^2.9.0",
|
||||
"@types/showdown": "^2.0.0",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"concurrently": "^8.0.1",
|
||||
@@ -834,6 +839,15 @@
|
||||
"@types/range-parser": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-session": {
|
||||
"version": "1.17.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.7.tgz",
|
||||
"integrity": "sha512-L25080PBYoRLu472HY/HNCxaXY8AaGgqGC8/p/8+BYMhG0RDOLQ1wpXOpAzr4Gi5TGozTKyJv5BVODM5UNyVMw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/glob": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz",
|
||||
@@ -942,6 +956,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.9.0.tgz",
|
||||
"integrity": "sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"htmlparser2": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.1.tgz",
|
||||
@@ -1734,6 +1757,14 @@
|
||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1768,6 +1799,68 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
|
||||
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
|
||||
@@ -2172,6 +2265,32 @@
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session": {
|
||||
"version": "1.17.3",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz",
|
||||
"integrity": "sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==",
|
||||
"dependencies": {
|
||||
"cookie": "0.4.2",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-headers": "~1.0.2",
|
||||
"parseurl": "~1.3.3",
|
||||
"safe-buffer": "5.2.1",
|
||||
"uid-safe": "~2.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session/node_modules/cookie": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz",
|
||||
"integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -2608,6 +2727,35 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
@@ -2864,6 +3012,14 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
@@ -3202,6 +3358,53 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/memorystore": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/memorystore/-/memorystore-1.6.7.tgz",
|
||||
"integrity": "sha512-OZnmNY/NDrKohPQ+hxp0muBcBKrzKNtHr55DbqSx9hLsYVNnomSAMRAtI7R64t3gf3ID7tHQA7mG4oL3Hu9hdw==",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.0",
|
||||
"lru-cache": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/memorystore/node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/memorystore/node_modules/lru-cache": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
|
||||
"integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
|
||||
"dependencies": {
|
||||
"pseudomap": "^1.0.2",
|
||||
"yallist": "^2.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/memorystore/node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"node_modules/memorystore/node_modules/yallist": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
|
||||
"integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A=="
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
|
||||
@@ -3320,6 +3523,23 @@
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
|
||||
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -3506,6 +3726,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
|
||||
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -3529,6 +3757,11 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -3550,6 +3783,11 @@
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
|
||||
"integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
|
||||
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@@ -3607,6 +3845,33 @@
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.1.0.tgz",
|
||||
"integrity": "sha512-KO0m2f1HkrPe9S0ldjx7za9BJjeHqBku5Ch8JyxETxT8dEFGz1PwgrHaOQupVYitpzbFSYm7nnljxD8dik2c+g=="
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.29",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz",
|
||||
"integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
@@ -3742,6 +4007,14 @@
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
|
||||
},
|
||||
"node_modules/random-bytes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -3941,6 +4214,30 @@
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.11.0.tgz",
|
||||
"integrity": "sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^8.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.5.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
|
||||
@@ -4073,6 +4370,14 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
|
||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
@@ -4414,6 +4719,17 @@
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uid-safe": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||
"dependencies": {
|
||||
"random-bytes": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
|
||||
@@ -25,13 +25,16 @@
|
||||
"dotenv": "^16.0.3",
|
||||
"ejs": "^3.1.9",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"firebase-admin": "^11.10.1",
|
||||
"googleapis": "^122.0.0",
|
||||
"http-proxy-middleware": "^3.0.0-beta.1",
|
||||
"memorystore": "^1.6.7",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-schedule": "^2.1.1",
|
||||
"pino": "^8.11.0",
|
||||
"pino-http": "^8.3.3",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"showdown": "^2.1.0",
|
||||
"tiktoken": "^1.0.10",
|
||||
"uuid": "^9.0.0",
|
||||
@@ -42,8 +45,10 @@
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cors": "^2.8.13",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/express-session": "^1.17.7",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node-schedule": "^2.1.0",
|
||||
"@types/sanitize-html": "^2.9.0",
|
||||
"@types/showdown": "^2.0.0",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"concurrently": "^8.0.1",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import * as userStore from "../../proxy/auth/user-store";
|
||||
import { UserSchema, UserSchemaWithToken, parseSort, sortBy } from "../common";
|
||||
import * as userStore from "../../shared/users/user-store";
|
||||
import { parseSort, sortBy } from "../../shared/utils";
|
||||
import { UserPartialSchema } from "../../shared/users/schema";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -44,11 +45,14 @@ router.post("/", (req, res) => {
|
||||
* PUT /admin/users/:token
|
||||
*/
|
||||
router.put("/:token", (req, res) => {
|
||||
const result = UserSchema.safeParse(req.body);
|
||||
const result = UserPartialSchema.safeParse({
|
||||
...req.body,
|
||||
token: req.params.token,
|
||||
});
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
userStore.upsertUser({ ...result.data, token: req.params.token });
|
||||
userStore.upsertUser(result.data);
|
||||
res.json(userStore.getUser(req.params.token));
|
||||
});
|
||||
|
||||
@@ -59,7 +63,7 @@ router.put("/:token", (req, res) => {
|
||||
* PUT /admin/users
|
||||
*/
|
||||
router.put("/", (req, res) => {
|
||||
const result = z.array(UserSchemaWithToken).safeParse(req.body.users);
|
||||
const result = z.array(UserPartialSchema).safeParse(req.body.users);
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
|
||||
+9
-14
@@ -10,14 +10,10 @@ export const authorize: ({ via }: AuthorizeParams) => RequestHandler =
|
||||
({ via }) =>
|
||||
(req, res, next) => {
|
||||
const bearerToken = req.headers.authorization?.slice("Bearer ".length);
|
||||
const cookieToken = req.cookies["admin-token"];
|
||||
const cookieToken = req.session.adminToken;
|
||||
const token = via === "cookie" ? cookieToken : bearerToken;
|
||||
const attempts = failedAttempts.get(req.ip) ?? 0;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
if (!ADMIN_KEY) {
|
||||
req.log.warn(
|
||||
{ ip: req.ip },
|
||||
@@ -34,16 +30,15 @@ export const authorize: ({ via }: AuthorizeParams) => RequestHandler =
|
||||
return res.status(401).json({ error: "Too many attempts" });
|
||||
}
|
||||
|
||||
if (token !== ADMIN_KEY) {
|
||||
req.log.warn(
|
||||
{ ip: req.ip, attempts, token },
|
||||
`Attempted admin request with invalid token`
|
||||
);
|
||||
return handleFailedLogin(req, res);
|
||||
if (token && token === ADMIN_KEY) {
|
||||
return next();
|
||||
}
|
||||
|
||||
req.log.info({ ip: req.ip }, `Admin request authorized`);
|
||||
next();
|
||||
req.log.warn(
|
||||
{ ip: req.ip, attempts, invalidToken: String(token) },
|
||||
`Attempted admin request with invalid token`
|
||||
);
|
||||
return handleFailedLogin(req, res);
|
||||
};
|
||||
|
||||
function handleFailedLogin(req: Request, res: Response) {
|
||||
@@ -53,6 +48,6 @@ function handleFailedLogin(req: Request, res: Response) {
|
||||
if (req.accepts("json", "html") === "json") {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
res.clearCookie("admin-token");
|
||||
delete req.session.adminToken;
|
||||
return res.redirect("/admin/login?failed=true");
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { ZodType, z } from "zod";
|
||||
import { RequestHandler } from "express";
|
||||
import { Query } from "express-serve-static-core";
|
||||
import { config } from "../config";
|
||||
import { UserTokenCounts } from "../proxy/auth/user-store";
|
||||
|
||||
export function parseSort(sort: Query["sort"]) {
|
||||
if (!sort) return null;
|
||||
if (typeof sort === "string") return sort.split(",");
|
||||
if (Array.isArray(sort)) return sort.splice(3) as string[];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sortBy(fields: string[], asc = true) {
|
||||
return (a: any, b: any) => {
|
||||
for (const field of fields) {
|
||||
if (a[field] !== b[field]) {
|
||||
// always sort nulls to the end
|
||||
if (a[field] == null) return 1;
|
||||
if (b[field] == null) return -1;
|
||||
|
||||
const valA = Array.isArray(a[field]) ? a[field].length : a[field];
|
||||
const valB = Array.isArray(b[field]) ? b[field].length : b[field];
|
||||
|
||||
const result = valA < valB ? -1 : 1;
|
||||
return asc ? result : -result;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
export function paginate(set: unknown[], page: number, pageSize: number = 20) {
|
||||
const p = Math.max(1, Math.min(page, Math.ceil(set.length / pageSize)));
|
||||
return {
|
||||
page: p,
|
||||
items: set.slice((p - 1) * pageSize, p * pageSize),
|
||||
pageSize,
|
||||
pageCount: Math.ceil(set.length / pageSize),
|
||||
totalCount: set.length,
|
||||
nextPage: p * pageSize < set.length ? p + 1 : null,
|
||||
prevPage: p > 1 ? p - 1 : null,
|
||||
};
|
||||
}
|
||||
|
||||
const tokenCountsSchema: ZodType<UserTokenCounts> = z
|
||||
.object({
|
||||
turbo: z.number().optional(),
|
||||
gpt4: z.number().optional(),
|
||||
"gpt4-32k": z.number().optional(),
|
||||
claude: z.number().optional(),
|
||||
})
|
||||
.refine(zodModelFamilyRefinement, {
|
||||
message:
|
||||
"If provided, a tokenCounts object must include all model families",
|
||||
}) as ZodType<UserTokenCounts>; // refinement ensures the type correctness but zod doesn't know that
|
||||
|
||||
export const UserSchema = z
|
||||
.object({
|
||||
ip: z.array(z.string()).optional(),
|
||||
nickname: z.string().max(80).optional(),
|
||||
type: z.enum(["normal", "special"]).optional(),
|
||||
promptCount: z.number().optional(),
|
||||
tokenCount: z.any().optional(), // never used, but remains for compatibility
|
||||
tokenCounts: tokenCountsSchema.optional(),
|
||||
tokenLimits: tokenCountsSchema.optional(),
|
||||
createdAt: z.number().optional(),
|
||||
lastUsedAt: z.number().optional(),
|
||||
disabledAt: z.number().optional(),
|
||||
disabledReason: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// gpt4-32k was added after the initial release, so this tries to allow for
|
||||
// data imported from older versions of the app which may be missing the
|
||||
// new model family.
|
||||
// Otherwise, all model families must be present.
|
||||
function zodModelFamilyRefinement(data: Record<string, number>) {
|
||||
const keys = Object.keys(data).sort();
|
||||
const validSets = [
|
||||
["claude", "gpt4", "turbo"],
|
||||
["claude", "gpt4", "gpt4-32k", "turbo"],
|
||||
];
|
||||
return validSets.some((set) => keys.join(",") === set.join(","));
|
||||
}
|
||||
|
||||
export const UserSchemaWithToken = UserSchema.extend({
|
||||
token: z.string(),
|
||||
}).strict();
|
||||
|
||||
export const injectLocals: RequestHandler = (req, res, next) => {
|
||||
const quota = config.tokenQuota;
|
||||
res.locals.quotasEnabled =
|
||||
quota.turbo > 0 || quota.gpt4 > 0 || quota.claude > 0;
|
||||
|
||||
res.locals.persistenceEnabled = config.gatekeeperStore !== "memory";
|
||||
|
||||
if (req.query.flash) {
|
||||
const content = String(req.query.flash)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const match = content.match(/^([a-z]+):(.*)/);
|
||||
if (match) {
|
||||
res.locals.flash = { type: match[1], message: match[2] };
|
||||
} else {
|
||||
res.locals.flash = { type: "error", message: content };
|
||||
}
|
||||
} else {
|
||||
res.locals.flash = null;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+4
-7
@@ -3,7 +3,7 @@ import { Router } from "express";
|
||||
const loginRouter = Router();
|
||||
|
||||
loginRouter.get("/login", (req, res) => {
|
||||
res.render("admin/login", {
|
||||
res.render("admin_login", {
|
||||
flash: req.query.failed
|
||||
? { type: "error", message: "Invalid admin key" }
|
||||
: null,
|
||||
@@ -11,20 +11,17 @@ loginRouter.get("/login", (req, res) => {
|
||||
});
|
||||
|
||||
loginRouter.post("/login", (req, res) => {
|
||||
res.cookie("admin-token", req.body.token, {
|
||||
maxAge: 1000 * 60 * 60 * 24 * 14,
|
||||
httpOnly: true,
|
||||
});
|
||||
req.session.adminToken = req.body.token;
|
||||
res.redirect("/admin");
|
||||
});
|
||||
|
||||
loginRouter.get("/logout", (req, res) => {
|
||||
res.clearCookie("admin-token");
|
||||
delete req.session.adminToken;
|
||||
res.redirect("/admin/login");
|
||||
});
|
||||
|
||||
loginRouter.get("/", (req, res) => {
|
||||
if (req.cookies["admin-token"]) {
|
||||
if (req.session.adminToken) {
|
||||
return res.redirect("/admin/manage");
|
||||
}
|
||||
res.redirect("/admin/login");
|
||||
|
||||
+15
-10
@@ -1,11 +1,12 @@
|
||||
import express, { Router } from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { authorize } from "./auth";
|
||||
import { HttpError, injectLocals } from "./common";
|
||||
import { injectCsrfToken, checkCsrfToken } from "./csrf";
|
||||
import { HttpError } from "../shared/errors";
|
||||
import { injectLocals } from "../shared/inject-locals";
|
||||
import { withSession } from "../shared/with-session";
|
||||
import { injectCsrfToken, checkCsrfToken } from "../shared/inject-csrf";
|
||||
import { loginRouter } from "./login";
|
||||
import { usersApiRouter as apiRouter } from "./api/users";
|
||||
import { usersUiRouter as uiRouter } from "./ui/users";
|
||||
import { usersWebRouter as webRouter } from "./web/manage";
|
||||
|
||||
const adminRouter = Router();
|
||||
|
||||
@@ -13,34 +14,38 @@ adminRouter.use(
|
||||
express.json({ limit: "20mb" }),
|
||||
express.urlencoded({ extended: true, limit: "20mb" })
|
||||
);
|
||||
adminRouter.use(cookieParser());
|
||||
adminRouter.use(withSession);
|
||||
adminRouter.use(injectCsrfToken);
|
||||
|
||||
adminRouter.use("/users", authorize({ via: "header" }), apiRouter);
|
||||
|
||||
adminRouter.use(checkCsrfToken); // All UI routes require CSRF token
|
||||
adminRouter.use(checkCsrfToken);
|
||||
adminRouter.use(injectLocals);
|
||||
adminRouter.use("/", loginRouter);
|
||||
adminRouter.use("/manage", authorize({ via: "cookie" }), uiRouter);
|
||||
adminRouter.use("/manage", authorize({ via: "cookie" }), webRouter);
|
||||
|
||||
adminRouter.use(
|
||||
(
|
||||
err: Error,
|
||||
_req: express.Request,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
_next: express.NextFunction
|
||||
) => {
|
||||
const data: any = { message: err.message, stack: err.stack };
|
||||
if (err instanceof HttpError) {
|
||||
data.status = err.status;
|
||||
return res.status(err.status).render("admin/error", data);
|
||||
res.status(err.status);
|
||||
if (req.accepts(["html", "json"]) === "json") {
|
||||
return res.json({ error: data });
|
||||
}
|
||||
return res.render("admin_error", data);
|
||||
} else if (err.name === "ForbiddenError") {
|
||||
data.status = 403;
|
||||
if (err.message === "invalid csrf token") {
|
||||
data.message =
|
||||
"Invalid CSRF token; try refreshing the previous page before submitting again.";
|
||||
}
|
||||
return res.status(403).render("admin/error", { ...data, flash: null });
|
||||
return res.status(403).render("admin_error", { ...data, flash: null });
|
||||
}
|
||||
res.status(500).json({ error: data });
|
||||
}
|
||||
|
||||
@@ -2,17 +2,13 @@ import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import { config } from "../../config";
|
||||
import * as userStore from "../../proxy/auth/user-store";
|
||||
import {
|
||||
UserSchemaWithToken,
|
||||
parseSort,
|
||||
sortBy,
|
||||
paginate,
|
||||
UserSchema,
|
||||
HttpError,
|
||||
} from "../common";
|
||||
import { ModelFamily, keyPool } from "../../key-management";
|
||||
import { getTokenCostUsd, prettyTokens } from "../../stats";
|
||||
import { HttpError } from "../../shared/errors";
|
||||
import * as userStore from "../../shared/users/user-store";
|
||||
import { parseSort, sortBy, paginate } from "../../shared/utils";
|
||||
import { keyPool } from "../../shared/key-management";
|
||||
import { ModelFamily } from "../../shared/models";
|
||||
import { getTokenCostUsd, prettyTokens } from "../../shared/stats";
|
||||
import { UserPartialSchema } from "../../shared/users/schema";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -32,7 +28,7 @@ router.get("/create-user", (req, res) => {
|
||||
.getUsers()
|
||||
.sort(sortBy(["createdAt"], false))
|
||||
.slice(0, 5);
|
||||
res.render("admin/create-user", {
|
||||
res.render("admin_create-user", {
|
||||
recentUsers,
|
||||
newToken: !!req.query.created,
|
||||
});
|
||||
@@ -53,7 +49,7 @@ router.get("/view-user/:token", (req, res) => {
|
||||
message: "User's quota was refreshed",
|
||||
};
|
||||
}
|
||||
res.render("admin/view-user", { user });
|
||||
res.render("admin_view-user", { user });
|
||||
});
|
||||
|
||||
router.get("/list-users", (req, res) => {
|
||||
@@ -66,8 +62,9 @@ router.get("/list-users", (req, res) => {
|
||||
.map((user) => {
|
||||
const sums = { sumTokens: 0, sumCost: 0, prettyUsage: "" };
|
||||
Object.entries(user.tokenCounts).forEach(([model, tokens]) => {
|
||||
sums.sumTokens += tokens;
|
||||
sums.sumCost += getTokenCostUsd(model as ModelFamily, tokens);
|
||||
const coalesced = tokens ?? 0;
|
||||
sums.sumTokens += coalesced;
|
||||
sums.sumCost += getTokenCostUsd(model as ModelFamily, coalesced);
|
||||
});
|
||||
sums.prettyUsage = `${prettyTokens(
|
||||
sums.sumTokens
|
||||
@@ -79,7 +76,7 @@ router.get("/list-users", (req, res) => {
|
||||
const page = Number(req.query.page) || 1;
|
||||
const { items, ...pagination } = paginate(users, page, perPage);
|
||||
|
||||
return res.render("admin/list-users", {
|
||||
return res.render("admin_list-users", {
|
||||
sort: sort.join(","),
|
||||
users: items,
|
||||
...pagination,
|
||||
@@ -87,24 +84,24 @@ router.get("/list-users", (req, res) => {
|
||||
});
|
||||
|
||||
router.get("/import-users", (_req, res) => {
|
||||
res.render("admin/import-users");
|
||||
res.render("admin_import-users");
|
||||
});
|
||||
|
||||
router.post("/import-users", upload.single("users"), (req, res) => {
|
||||
if (!req.file) throw new HttpError(400, "No file uploaded");
|
||||
|
||||
const data = JSON.parse(req.file.buffer.toString());
|
||||
const result = z.array(UserSchemaWithToken).safeParse(data.users);
|
||||
const result = z.array(UserPartialSchema).safeParse(data.users);
|
||||
if (!result.success) throw new HttpError(400, result.error.toString());
|
||||
|
||||
const upserts = result.data.map((user) => userStore.upsertUser(user));
|
||||
res.render("admin/import-users", {
|
||||
res.render("admin_import-users", {
|
||||
flash: { type: "success", message: `${upserts.length} users imported` },
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/export-users", (_req, res) => {
|
||||
res.render("admin/export-users");
|
||||
res.render("admin_export-users");
|
||||
});
|
||||
|
||||
router.get("/export-users.json", (_req, res) => {
|
||||
@@ -115,15 +112,23 @@ router.get("/export-users.json", (_req, res) => {
|
||||
});
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.render("admin/index");
|
||||
res.render("admin_index");
|
||||
});
|
||||
|
||||
router.post("/edit-user/:token", (req, res) => {
|
||||
const result = UserSchema.safeParse(req.body);
|
||||
if (!result.success) throw new HttpError(400, result.error.toString());
|
||||
const result = UserPartialSchema.safeParse({
|
||||
...req.body,
|
||||
token: req.params.token,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
result.error.issues.flatMap((issue) => issue.message).join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
userStore.upsertUser({ ...result.data, token: req.params.token });
|
||||
return res.sendStatus(204);
|
||||
userStore.upsertUser(result.data);
|
||||
return res.status(200).json({ success: true });
|
||||
});
|
||||
|
||||
router.post("/reactivate-user/:token", (req, res) => {
|
||||
@@ -185,4 +190,4 @@ router.post("/maintenance", (req, res) => {
|
||||
return res.redirect(`/admin/manage?flash=${message}`);
|
||||
});
|
||||
|
||||
export { router as usersUiRouter };
|
||||
export { router as usersWebRouter };
|
||||
@@ -1,4 +1,4 @@
|
||||
<%- include("../_partials/admin-header", { title: "Create User - OAI Reverse Proxy Admin" }) %>
|
||||
<%- include("partials/shared_header", { title: "Create User - OAI Reverse Proxy Admin" }) %>
|
||||
<!--
|
||||
-->
|
||||
<h1>Create User Token</h1>
|
||||
@@ -15,4 +15,4 @@
|
||||
<li><a href="/admin/manage/view-user/<%= user.token %>"><%= user.token %></a></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<%- include("../_partials/admin-footer") %>
|
||||
<%- include("partials/admin-footer") %>
|
||||
@@ -1,4 +1,4 @@
|
||||
<%- include("../_partials/admin-header", { title: "Error" }) %>
|
||||
<%- include("partials/shared_header", { title: "Error" }) %>
|
||||
<div id="error-content" style="color: red; background-color: #eedddd; padding: 1em">
|
||||
<p><strong>⚠️ Error <%= status %>:</strong> <%= message %></p>
|
||||
<pre><%= stack %></pre>
|
||||
@@ -1,4 +1,4 @@
|
||||
<%- include("../_partials/admin-header", { title: "Export Users - OAI Reverse Proxy Admin" }) %>
|
||||
<%- include("partials/shared_header", { title: "Export Users - OAI Reverse Proxy Admin" }) %>
|
||||
<h1>Export Users</h1>
|
||||
<p>
|
||||
Export users to JSON. The JSON will be an array of objects under the key
|
||||
@@ -25,4 +25,4 @@
|
||||
}
|
||||
</script>
|
||||
<button onclick="exportUsers()">Export</button>
|
||||
<%- include("../_partials/admin-footer") %>
|
||||
<%- include("partials/admin-footer") %>
|
||||
@@ -1,4 +1,4 @@
|
||||
<%- include("../_partials/admin-header", { title: "Import Users - OAI Reverse Proxy Admin" }) %>
|
||||
<%- include("partials/shared_header", { title: "Import Users - OAI Reverse Proxy Admin" }) %>
|
||||
<h1>Import Users</h1>
|
||||
<p>
|
||||
Import users from JSON. The JSON should be an array of objects under the key
|
||||
@@ -45,4 +45,4 @@
|
||||
<input type="submit" value="Import" />
|
||||
</form>
|
||||
</form>
|
||||
<%- include("../_partials/admin-footer") %>
|
||||
<%- include("partials/admin-footer") %>
|
||||
@@ -1,4 +1,4 @@
|
||||
<%- include("../_partials/admin-header", { title: "OAI Reverse Proxy Admin" }) %>
|
||||
<%- include("partials/shared_header", { title: "OAI Reverse Proxy Admin" }) %>
|
||||
<h1>OAI Reverse Proxy Admin</h1>
|
||||
<% if (!persistenceEnabled) { %>
|
||||
<p style="color: red; background-color: #eedddd; padding: 1em">
|
||||
@@ -60,4 +60,4 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<%- include("../_partials/admin-footer") %>
|
||||
<%- include("partials/admin-footer") %>
|
||||
@@ -1,4 +1,4 @@
|
||||
<%- include("../_partials/admin-header", { title: "Users - OAI Reverse Proxy Admin" }) %>
|
||||
<%- include("partials/shared_header", { title: "Users - OAI Reverse Proxy Admin" }) %>
|
||||
<h1>User Token List</h1>
|
||||
|
||||
<% if (users.length === 0) { %>
|
||||
@@ -60,7 +60,7 @@
|
||||
</ul>
|
||||
|
||||
<p>Showing <%= page * pageSize - pageSize + 1 %> to <%= users.length + page * pageSize - pageSize %> of <%= totalCount %> users.</p>
|
||||
<%- include("../_partials/pagination") %>
|
||||
<%- include("partials/shared_pagination") %>
|
||||
<% } %>
|
||||
|
||||
<script>
|
||||
@@ -117,4 +117,4 @@
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<%- include("../_partials/admin-footer") %>
|
||||
<%- include("partials/admin-footer") %>
|
||||
@@ -1,4 +1,4 @@
|
||||
<%- include("../_partials/admin-header", { title: "Login" }) %>
|
||||
<%- include("partials/shared_header", { title: "Login" }) %>
|
||||
<h1>Login</h1>
|
||||
<form action="/admin/login" method="post">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||
@@ -1,4 +1,4 @@
|
||||
<%- include("../_partials/admin-header", { title: "View User - OAI Reverse Proxy Admin" }) %>
|
||||
<%- include("partials/shared_header", { title: "View User - OAI Reverse Proxy Admin" }) %>
|
||||
<h1>View User</h1>
|
||||
|
||||
<table class="striped">
|
||||
@@ -17,44 +17,20 @@
|
||||
<th scope="row">Nickname</th>
|
||||
<td><%- user.nickname ?? "none" %></td>
|
||||
<td class="actions">
|
||||
<a
|
||||
title="Edit"
|
||||
id="edit-nickname"
|
||||
href="#"
|
||||
data-field="nickname"
|
||||
data-token="<%= user.token %>"
|
||||
>✏️</a
|
||||
>
|
||||
<a title="Edit" id="edit-nickname" href="#" data-field="nickname" data-token="<%= user.token %>">✏️</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Type</th>
|
||||
<td colspan="2"><%- user.type %></td>
|
||||
<td><%- user.type %></td>
|
||||
<td class="actions">
|
||||
<a title="Edit" id="edit-type" href="#" data-field="type" data-token="<%= user.token %>">✏️</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Prompt Count</th>
|
||||
<th scope="row">Prompts</th>
|
||||
<td colspan="2"><%- user.promptCount %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Token Counts</th>
|
||||
<td colspan="2">
|
||||
<ul style="padding-left: 1em; margin: 0">
|
||||
<% Object.entries(user.tokenCounts).forEach(([key, count]) => { %>
|
||||
<li><strong><%- key %></strong>: <%- count %></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Token Limits</th>
|
||||
<td colspan="2">
|
||||
<ul style="padding-left: 1em; margin: 0">
|
||||
<% Object.entries(user.tokenLimits).forEach(([key, count]) => { %>
|
||||
<li><strong><%- key %></strong>: <%- count %></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Created At</th>
|
||||
<td colspan="2"><%- user.createdAt %></td>
|
||||
@@ -85,6 +61,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Quota Information</h3>
|
||||
<% if (quotasEnabled) { %>
|
||||
<form action="/admin/manage/refresh-user-quota" method="POST">
|
||||
<input type="hidden" name="token" value="<%- user.token %>" />
|
||||
@@ -92,6 +69,8 @@
|
||||
<button type="submit" class="btn btn-primary">Refresh Quotas for User</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<%- include("partials/shared_quota-info", { quota, user }) %>
|
||||
|
||||
|
||||
<p><a href="/admin/manage/list-users">Back to User List</a></p>
|
||||
|
||||
@@ -107,28 +86,29 @@
|
||||
e.preventDefault();
|
||||
const token = a.dataset.token;
|
||||
const field = a.dataset.field;
|
||||
const value = prompt(`Enter new value for '${field}'':`);
|
||||
let value = prompt(`Enter new value for '${field}'':`);
|
||||
if (value !== null) {
|
||||
if (value === "") {
|
||||
value = null;
|
||||
}
|
||||
fetch(`/admin/manage/edit-user/${token}`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
[field]: value,
|
||||
_csrf: document
|
||||
.querySelector("meta[name=csrf-token]")
|
||||
.getAttribute("content"),
|
||||
_csrf: document.querySelector("meta[name=csrf-token]").getAttribute("content"),
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
})
|
||||
.then((res) => Promise.all([res.ok, res.text()]))
|
||||
.then(([ok, text]) => {
|
||||
if (!ok) {
|
||||
document.body.innerHTML = text;
|
||||
return;
|
||||
}
|
||||
.then((res) => Promise.all([res.ok, res.json()]))
|
||||
.then(([ok, json]) => {
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams();
|
||||
params.set("flash", `success: User's ${field} updated.`);
|
||||
if (!ok) {
|
||||
params.set("flash", `error: ${json.error.message}`);
|
||||
} else {
|
||||
params.set("flash", `success: User's ${field} updated.`);
|
||||
}
|
||||
url.search = params.toString();
|
||||
window.location.assign(url);
|
||||
});
|
||||
@@ -137,4 +117,4 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<%- include("../_partials/admin-footer") %>
|
||||
<%- include("partials/admin-footer") %>
|
||||
+2
-2
@@ -3,11 +3,11 @@
|
||||
<a href="/admin">Index</a> | <a href="/admin/logout">Logout</a>
|
||||
</footer>
|
||||
<script>
|
||||
document.querySelectorAll("td").forEach(function(td) {
|
||||
document.querySelectorAll("td,time").forEach(function(td) {
|
||||
if (td.innerText.match(/^\d{13}$/)) {
|
||||
if (td.innerText == 0) return 'never';
|
||||
var date = new Date(parseInt(td.innerText));
|
||||
td.innerText = date.toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
||||
td.innerText = date.toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
+17
-1
@@ -1,7 +1,7 @@
|
||||
import dotenv from "dotenv";
|
||||
import type firebase from "firebase-admin";
|
||||
import pino from "pino";
|
||||
import type { ModelFamily } from "./key-management/models";
|
||||
import type { ModelFamily } from "./shared/models";
|
||||
dotenv.config();
|
||||
|
||||
// Can't import the usual logger here because it itself needs the config.
|
||||
@@ -97,6 +97,8 @@ type Config = {
|
||||
googleSheetsSpreadsheetId?: string;
|
||||
/** Whether to periodically check keys for usage and validity. */
|
||||
checkKeys?: boolean;
|
||||
/** Whether to show token costs in the UI. */
|
||||
showTokenCosts?: boolean;
|
||||
/**
|
||||
* Comma-separated list of origins to block. Requests matching any of these
|
||||
* origins or referers will be rejected.
|
||||
@@ -183,6 +185,7 @@ export const config: Config = {
|
||||
),
|
||||
logLevel: getEnvWithDefault("LOG_LEVEL", "info"),
|
||||
checkKeys: getEnvWithDefault("CHECK_KEYS", !isDev),
|
||||
showTokenCosts: getEnvWithDefault("SHOW_TOKEN_COSTS", false),
|
||||
promptLogging: getEnvWithDefault("PROMPT_LOGGING", false),
|
||||
promptLoggingBackend: getEnvWithDefault("PROMPT_LOGGING_BACKEND", undefined),
|
||||
googleSheetsKey: getEnvWithDefault("GOOGLE_SHEETS_KEY", undefined),
|
||||
@@ -205,6 +208,18 @@ export const config: Config = {
|
||||
quotaRefreshPeriod: getEnvWithDefault("QUOTA_REFRESH_PERIOD", undefined),
|
||||
} as const;
|
||||
|
||||
function generateCookieSecret() {
|
||||
if (process.env.COOKIE_SECRET !== undefined) {
|
||||
return process.env.COOKIE_SECRET;
|
||||
}
|
||||
|
||||
const seed = "" + config.adminKey + config.openaiKey + config.anthropicKey;
|
||||
const crypto = require("crypto");
|
||||
return crypto.createHash("sha256").update(seed).digest("hex");
|
||||
}
|
||||
|
||||
export const COOKIE_SECRET = generateCookieSecret();
|
||||
|
||||
export async function assertConfigIsValid() {
|
||||
if (process.env.TURBO_ONLY === "true") {
|
||||
startupLogger.warn(
|
||||
@@ -282,6 +297,7 @@ export const OMITTED_KEYS: (keyof Config)[] = [
|
||||
"proxyKey",
|
||||
"adminKey",
|
||||
"checkKeys",
|
||||
"showTokenCosts",
|
||||
"googleSheetsKey",
|
||||
"firebaseKey",
|
||||
"firebaseRtdbUrl",
|
||||
|
||||
+20
-14
@@ -2,17 +2,12 @@ import fs from "fs";
|
||||
import { Request, Response } from "express";
|
||||
import showdown from "showdown";
|
||||
import { config, listConfig } from "./config";
|
||||
import {
|
||||
AnthropicKey,
|
||||
ModelFamily,
|
||||
OpenAIKey,
|
||||
OpenAIModelFamily,
|
||||
keyPool,
|
||||
} from "./key-management";
|
||||
import { AnthropicKey, OpenAIKey, keyPool } from "./shared/key-management";
|
||||
import { ModelFamily, OpenAIModelFamily } from "./shared/models";
|
||||
import { getUniqueIps } from "./proxy/rate-limit";
|
||||
import { getEstimatedWaitTime, getQueueLength } from "./proxy/queue";
|
||||
import { logger } from "./logger";
|
||||
import { getTokenCostUsd, prettyTokens } from "./stats";
|
||||
import { getTokenCostUsd, prettyTokens } from "./shared/stats";
|
||||
|
||||
const INFO_PAGE_TTL = 2000;
|
||||
let infoPageHtml: string | undefined;
|
||||
@@ -67,6 +62,11 @@ export const handleInfoPage = (req: Request, res: Response) => {
|
||||
res.send(cacheInfoPageHtml(baseUrl));
|
||||
};
|
||||
|
||||
function getCostString(cost: number) {
|
||||
if (!config.showTokenCosts) return "";
|
||||
return ` ($${cost.toFixed(2)})`;
|
||||
}
|
||||
|
||||
function cacheInfoPageHtml(baseUrl: string) {
|
||||
const keys = keyPool.list();
|
||||
|
||||
@@ -87,7 +87,9 @@ function cacheInfoPageHtml(baseUrl: string) {
|
||||
...(anthropicKeys ? { anthropic: baseUrl + "/proxy/anthropic" } : {}),
|
||||
},
|
||||
proompts,
|
||||
tookens: `${prettyTokens(tokens)} ($${tokenCost.toFixed(2)})`,
|
||||
...(config.showTokenCosts
|
||||
? { tookens: `${prettyTokens(tokens)}${getCostString(tokenCost)}` }
|
||||
: { tookens: tokens }),
|
||||
...(config.modelRateLimit ? { proomptersNow: getUniqueIps() } : {}),
|
||||
openaiKeys,
|
||||
anthropicKeys,
|
||||
@@ -235,7 +237,7 @@ function getOpenAIInfo() {
|
||||
const cost = getTokenCostUsd(f, tokens);
|
||||
|
||||
info[f] = {
|
||||
usage: `${prettyTokens(tokens)} tokens ($${cost.toFixed(2)})`,
|
||||
usage: `${prettyTokens(tokens)} tokens${getCostString(cost)}`,
|
||||
activeKeys: modelStats.get(`${f}__active`) || 0,
|
||||
trialKeys: modelStats.get(`${f}__trial`) || 0,
|
||||
revokedKeys: modelStats.get(`${f}__revoked`) || 0,
|
||||
@@ -276,11 +278,12 @@ function getAnthropicInfo() {
|
||||
const tokens = modelStats.get("claude__tokens") || 0;
|
||||
const cost = getTokenCostUsd("claude", tokens);
|
||||
|
||||
const unchecked = serviceStats.get("anthropicUncheckedKeys") || 0;
|
||||
const unchecked =
|
||||
(config.checkKeys && serviceStats.get("anthropicUncheckedKeys")) || 0;
|
||||
|
||||
return {
|
||||
claude: {
|
||||
usage: `${prettyTokens(tokens)} tokens ($${cost.toFixed(2)})`,
|
||||
usage: `${prettyTokens(tokens)} tokens${getCostString(cost)}`,
|
||||
...(unchecked > 0 ? { status: `Checking ${unchecked} keys...` } : {}),
|
||||
activeKeys: claudeInfo.active,
|
||||
...(config.checkKeys ? { pozzedKeys: claudeInfo.pozzed } : {}),
|
||||
@@ -343,8 +346,11 @@ Logs are anonymous and do not contain IP addresses or timestamps. [You can see t
|
||||
infoBody += "\n\n" + waits.join(" / ");
|
||||
|
||||
if (customGreeting) {
|
||||
infoBody += `\n## Server Greeting\n
|
||||
${customGreeting}`;
|
||||
infoBody += `\n## Server Greeting\n${customGreeting}`;
|
||||
}
|
||||
|
||||
if (config.gatekeeper === "user_token") {
|
||||
infoBody += `\n\n---\n\n[User lookup](/user/lookup)`;
|
||||
}
|
||||
return converter.makeHtml(infoBody);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import crypto from "crypto";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { logger } from "../../logger";
|
||||
import { logger } from "../logger";
|
||||
|
||||
const log = logger.child({ module: "check-risu-token" });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Request, RequestHandler } from "express";
|
||||
import { config } from "../../config";
|
||||
import { authenticate, getUser } from "./user-store";
|
||||
import { config } from "../config";
|
||||
import { authenticate, getUser } from "../shared/users/user-store";
|
||||
|
||||
const GATEKEEPER = config.gatekeeper;
|
||||
const PROXY_KEY = config.proxyKey;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response } from "express";
|
||||
import httpProxy from "http-proxy";
|
||||
import { ZodError } from "zod";
|
||||
import { AIService } from "../../key-management";
|
||||
import { AIService } from "../../shared/key-management";
|
||||
import { QuotaExceededError } from "./request/apply-quota-limits";
|
||||
|
||||
const OPENAI_CHAT_COMPLETION_ENDPOINT = "/v1/chat/completions";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnthropicKey, Key } from "../../../key-management";
|
||||
import { AnthropicKey, Key } from "../../../shared/key-management";
|
||||
import { isCompletionRequest } from "../common";
|
||||
import { ProxyRequestMiddleware } from ".";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Key, OpenAIKey, keyPool } from "../../../key-management";
|
||||
import { Key, OpenAIKey, keyPool } from "../../../shared/key-management";
|
||||
import { isCompletionRequest } from "../common";
|
||||
import { ProxyRequestMiddleware } from ".";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hasAvailableQuota } from "../../auth/user-store";
|
||||
import { hasAvailableQuota } from "../../../shared/users/user-store";
|
||||
import { isCompletionRequest } from "../common";
|
||||
import { ProxyRequestMiddleware } from ".";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request } from "express";
|
||||
import { z } from "zod";
|
||||
import { config } from "../../../config";
|
||||
import { OpenAIPromptMessage, countTokens } from "../../../tokenization";
|
||||
import { OpenAIPromptMessage, countTokens } from "../../../shared/tokenization";
|
||||
import { RequestPreprocessor } from ".";
|
||||
|
||||
const CLAUDE_MAX_CONTEXT = config.maxContextTokensAnthropic;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request } from "express";
|
||||
import { AIService } from "../../../key-management";
|
||||
import { AIService } from "../../../shared/key-management";
|
||||
import { RequestPreprocessor } from ".";
|
||||
|
||||
export const setApiFormat = (api: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request } from "express";
|
||||
import { z } from "zod";
|
||||
import { config } from "../../../config";
|
||||
import { OpenAIPromptMessage } from "../../../tokenization";
|
||||
import { OpenAIPromptMessage } from "../../../shared/tokenization";
|
||||
import { isCompletionRequest } from "../common";
|
||||
import { RequestPreprocessor } from ".";
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ import * as http from "http";
|
||||
import util from "util";
|
||||
import zlib from "zlib";
|
||||
import { logger } from "../../../logger";
|
||||
import { getOpenAIModelFamily, keyPool } from "../../../key-management";
|
||||
import { keyPool } from "../../../shared/key-management";
|
||||
import { getOpenAIModelFamily } from "../../../shared/models";
|
||||
import { enqueue, trackWaitTime } from "../../queue";
|
||||
import {
|
||||
incrementPromptCount,
|
||||
incrementTokenCount,
|
||||
} from "../../auth/user-store";
|
||||
} from "../../../shared/users/user-store";
|
||||
import {
|
||||
getCompletionForService,
|
||||
isCompletionRequest,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
} from "../common";
|
||||
import { handleStreamedResponse } from "./handle-streamed-response";
|
||||
import { logPrompt } from "./log-prompt";
|
||||
import { countTokens } from "../../../tokenization";
|
||||
import { countTokens } from "../../../shared/tokenization";
|
||||
|
||||
const DECODER_MAP = {
|
||||
gzip: util.promisify(zlib.gunzip),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request } from "express";
|
||||
import { config } from "../../../config";
|
||||
import { logQueue } from "../../../prompt-logging";
|
||||
import { logQueue } from "../../../shared/prompt-logging";
|
||||
import { getCompletionForService, isCompletionRequest } from "../common";
|
||||
import { ProxyResHandlerWithBody } from ".";
|
||||
|
||||
|
||||
+2
-2
@@ -2,12 +2,12 @@ import { RequestHandler, Request, Router } from "express";
|
||||
import * as http from "http";
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
import { config } from "../config";
|
||||
import { keyPool } from "../shared/key-management";
|
||||
import {
|
||||
ModelFamily,
|
||||
OpenAIModelFamily,
|
||||
getOpenAIModelFamily,
|
||||
keyPool,
|
||||
} from "../key-management";
|
||||
} from "../shared/models";
|
||||
import { logger } from "../logger";
|
||||
import { createQueueMiddleware } from "./queue";
|
||||
import { ipLimiter } from "./rate-limit";
|
||||
|
||||
+2
-3
@@ -16,13 +16,12 @@
|
||||
*/
|
||||
|
||||
import type { Handler, Request } from "express";
|
||||
import { keyPool, SupportedModel } from "../shared/key-management";
|
||||
import {
|
||||
getClaudeModelFamily,
|
||||
getOpenAIModelFamily,
|
||||
keyPool,
|
||||
ModelFamily,
|
||||
SupportedModel,
|
||||
} from "../key-management";
|
||||
} from "../shared/models";
|
||||
import { logger } from "../logger";
|
||||
import { AGNAI_DOT_CHAT_IP } from "./rate-limit";
|
||||
import { buildFakeSseMessage } from "./middleware/common";
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ subset of the API is supported. Kobold requests must be transformed into
|
||||
equivalent OpenAI requests. */
|
||||
|
||||
import * as express from "express";
|
||||
import { gatekeeper } from "./auth/gatekeeper";
|
||||
import { checkRisuToken } from "./auth/check-risu-token";
|
||||
import { gatekeeper } from "./gatekeeper";
|
||||
import { checkRisuToken } from "./check-risu-token";
|
||||
import { kobold } from "./kobold";
|
||||
import { openai } from "./openai";
|
||||
import { anthropic } from "./anthropic";
|
||||
|
||||
+11
-5
@@ -6,15 +6,16 @@ import path from "path";
|
||||
import pinoHttp from "pino-http";
|
||||
import childProcess from "child_process";
|
||||
import { logger } from "./logger";
|
||||
import { keyPool } from "./key-management";
|
||||
import { keyPool } from "./shared/key-management";
|
||||
import { adminRouter } from "./admin/routes";
|
||||
import { proxyRouter } from "./proxy/routes";
|
||||
import { handleInfoPage } from "./info-page";
|
||||
import { logQueue } from "./prompt-logging";
|
||||
import { logQueue } from "./shared/prompt-logging";
|
||||
import { start as startRequestQueue } from "./proxy/queue";
|
||||
import { init as initUserStore } from "./proxy/auth/user-store";
|
||||
import { init as initTokenizers } from "./tokenization";
|
||||
import { init as initUserStore } from "./shared/users/user-store";
|
||||
import { init as initTokenizers } from "./shared/tokenization";
|
||||
import { checkOrigin } from "./proxy/check-origin";
|
||||
import { userRouter } from "./user/routes";
|
||||
|
||||
const PORT = config.port;
|
||||
|
||||
@@ -51,7 +52,11 @@ app.use(
|
||||
app.set("trust proxy", true);
|
||||
|
||||
app.set("view engine", "ejs");
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
app.set("views", [
|
||||
path.join(__dirname, "admin/web/views"),
|
||||
path.join(__dirname, "user/web/views"),
|
||||
path.join(__dirname, "shared/views"),
|
||||
]);
|
||||
|
||||
app.get("/health", (_req, res) => res.sendStatus(200));
|
||||
app.use(cors());
|
||||
@@ -61,6 +66,7 @@ app.use(checkOrigin);
|
||||
app.get("/", handleInfoPage);
|
||||
app.use("/admin", adminRouter);
|
||||
app.use("/proxy", proxyRouter);
|
||||
app.use("/user", userRouter);
|
||||
|
||||
// 500 and 404
|
||||
app.use((err: any, _req: unknown, res: express.Response, _next: unknown) => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export class HttpError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserInputError extends HttpError {
|
||||
constructor(message: string) {
|
||||
super(400, message);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { doubleCsrf } from "csrf-csrf";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import express from "express";
|
||||
|
||||
const CSRF_SECRET = uuid();
|
||||
import { COOKIE_SECRET } from "../config";
|
||||
|
||||
const { generateToken, doubleCsrfProtection } = doubleCsrf({
|
||||
getSecret: () => CSRF_SECRET,
|
||||
getSecret: () => COOKIE_SECRET,
|
||||
cookieName: "csrf",
|
||||
cookieOptions: { sameSite: "strict", path: "/" },
|
||||
getTokenFromRequest: (req) => {
|
||||
@@ -16,12 +14,11 @@ const { generateToken, doubleCsrfProtection } = doubleCsrf({
|
||||
});
|
||||
|
||||
const injectCsrfToken: express.RequestHandler = (req, res, next) => {
|
||||
res.locals.csrfToken = generateToken(res, req);
|
||||
// force generation of new token on back button
|
||||
// TODO: implement session-based CSRF tokens
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.setHeader("Pragma", "no-cache");
|
||||
res.setHeader("Expires", "0");
|
||||
const session = req.session;
|
||||
if (!session.csrf) {
|
||||
session.csrf = generateToken(res, req);
|
||||
}
|
||||
res.locals.csrfToken = session.csrf;
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { RequestHandler } from "express";
|
||||
import sanitize from "sanitize-html";
|
||||
import { config } from "../config";
|
||||
import { getTokenCostUsd, prettyTokens } from "./stats";
|
||||
import * as userStore from "./users/user-store";
|
||||
|
||||
export const injectLocals: RequestHandler = (req, res, next) => {
|
||||
// config-related locals
|
||||
const quota = config.tokenQuota;
|
||||
res.locals.quotasEnabled =
|
||||
quota.turbo > 0 || quota.gpt4 > 0 || quota.claude > 0;
|
||||
res.locals.quota = quota;
|
||||
res.locals.nextQuotaRefresh = userStore.getNextQuotaRefresh();
|
||||
res.locals.persistenceEnabled = config.gatekeeperStore !== "memory";
|
||||
res.locals.showTokenCosts = config.showTokenCosts;
|
||||
|
||||
// flash message
|
||||
if (req.query.flash) {
|
||||
const content = sanitize(String(req.query.flash))
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const match = content.match(/^([a-z]+):(.*)/);
|
||||
if (match) {
|
||||
res.locals.flash = { type: match[1], message: match[2] };
|
||||
} else {
|
||||
res.locals.flash = { type: "error", message: content };
|
||||
}
|
||||
} else {
|
||||
res.locals.flash = null;
|
||||
}
|
||||
|
||||
// utils
|
||||
res.locals.prettyTokens = prettyTokens;
|
||||
res.locals.tokenCost = getTokenCostUsd;
|
||||
|
||||
next();
|
||||
};
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { logger } from "../../logger";
|
||||
import { logger } from "../../../logger";
|
||||
import type { AnthropicKey, AnthropicKeyProvider } from "./provider";
|
||||
|
||||
/** Minimum time in between any two key checks. */
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import crypto from "crypto";
|
||||
import { Key, KeyProvider } from "..";
|
||||
import { config } from "../../config";
|
||||
import { logger } from "../../logger";
|
||||
import type { AnthropicModelFamily } from "../models";
|
||||
import { config } from "../../../config";
|
||||
import { logger } from "../../../logger";
|
||||
import type { AnthropicModelFamily } from "../../models";
|
||||
import { AnthropicKeyChecker } from "./checker";
|
||||
|
||||
// https://docs.anthropic.com/claude/reference/selecting-a-model
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
AnthropicModel,
|
||||
} from "./anthropic/provider";
|
||||
import { KeyPool } from "./key-pool";
|
||||
import type { ModelFamily } from "./models";
|
||||
import type { ModelFamily } from "../models";
|
||||
|
||||
export type AIService = "openai" | "anthropic";
|
||||
export type Model = OpenAIModel | AnthropicModel;
|
||||
@@ -66,9 +66,3 @@ export type SupportedModel = (typeof SUPPORTED_MODELS)[number];
|
||||
export { OPENAI_SUPPORTED_MODELS, ANTHROPIC_SUPPORTED_MODELS };
|
||||
export { AnthropicKey } from "./anthropic/provider";
|
||||
export { OpenAIKey } from "./openai/provider";
|
||||
export type {
|
||||
OpenAIModelFamily,
|
||||
AnthropicModelFamily,
|
||||
ModelFamily,
|
||||
} from "./models";
|
||||
export { getOpenAIModelFamily, getClaudeModelFamily } from "./models";
|
||||
@@ -3,8 +3,8 @@ import schedule from "node-schedule";
|
||||
import { AnthropicKeyProvider, AnthropicKeyUpdate } from "./anthropic/provider";
|
||||
import { Key, Model, KeyProvider, AIService } from "./index";
|
||||
import { OpenAIKeyProvider, OpenAIKeyUpdate } from "./openai/provider";
|
||||
import { config } from "../config";
|
||||
import { logger } from "../logger";
|
||||
import { config } from "../../config";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
type AllowedPartial = OpenAIKeyUpdate | AnthropicKeyUpdate;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { logger } from "../../logger";
|
||||
import { logger } from "../../../logger";
|
||||
import type { OpenAIKey, OpenAIKeyProvider } from "./provider";
|
||||
import type { OpenAIModelFamily } from "../models";
|
||||
import type { OpenAIModelFamily } from "../../models";
|
||||
|
||||
/** Minimum time in between any two key checks. */
|
||||
const MIN_CHECK_INTERVAL = 3 * 1000; // 3 seconds
|
||||
+3
-3
@@ -6,10 +6,10 @@ import fs from "fs";
|
||||
import http from "http";
|
||||
import path from "path";
|
||||
import { KeyProvider, Key, Model } from "../index";
|
||||
import { config } from "../../config";
|
||||
import { logger } from "../../logger";
|
||||
import { config } from "../../../config";
|
||||
import { logger } from "../../../logger";
|
||||
import { OpenAIKeyChecker } from "./checker";
|
||||
import { OpenAIModelFamily, getOpenAIModelFamily } from "../models";
|
||||
import { OpenAIModelFamily, getOpenAIModelFamily } from "../../models";
|
||||
|
||||
export type OpenAIModel = "gpt-3.5-turbo" | "gpt-4" | "gpt-4-32k";
|
||||
export const OPENAI_SUPPORTED_MODELS: readonly OpenAIModel[] = [
|
||||
+2
-2
@@ -8,8 +8,8 @@ support because it relies on local state to match up with the remote state. */
|
||||
import { google, sheets_v4 } from "googleapis";
|
||||
import type { CredentialBody } from "google-auth-library";
|
||||
import type { GaxiosResponse } from "googleapis-common";
|
||||
import { config } from "../../config";
|
||||
import { logger } from "../../logger";
|
||||
import { config } from "../../../config";
|
||||
import { logger } from "../../../logger";
|
||||
import { PromptLogEntry } from "..";
|
||||
|
||||
// There is always a sheet called __index__ which contains a list of all the
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Queues incoming prompts/responses and periodically flushes them to configured
|
||||
* logging backend. */
|
||||
|
||||
import { logger } from "../logger";
|
||||
import { logger } from "../../logger";
|
||||
import { PromptLogEntry } from ".";
|
||||
import { sheets } from "./backends";
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ModelFamily } from "../key-management";
|
||||
import { config } from "../config";
|
||||
import { ModelFamily } from "./models";
|
||||
|
||||
// technically slightly underestimates, because completion tokens cost more
|
||||
// than prompt tokens but we don't track those separately right now
|
||||
export function getTokenCostUsd(model: ModelFamily, tokens: number) {
|
||||
if (!config.showTokenCosts) return 0;
|
||||
|
||||
let cost = 0;
|
||||
switch (model) {
|
||||
case "gpt4-32k":
|
||||
@@ -18,15 +21,16 @@ export function getTokenCostUsd(model: ModelFamily, tokens: number) {
|
||||
cost = 0.00001102;
|
||||
break;
|
||||
}
|
||||
return cost * tokens;
|
||||
return cost * Math.max(0, tokens);
|
||||
}
|
||||
|
||||
export function prettyTokens(tokens: number): string {
|
||||
if (tokens < 1000) {
|
||||
const absTokens = Math.abs(tokens);
|
||||
if (absTokens < 1000) {
|
||||
return tokens.toString();
|
||||
} else if (tokens < 1000000) {
|
||||
} else if (absTokens < 1000000) {
|
||||
return (tokens / 1000).toFixed(1) + "k";
|
||||
} else if (tokens < 1000000000) {
|
||||
} else if (absTokens < 1000000000) {
|
||||
return (tokens / 1000000).toFixed(2) + "m";
|
||||
} else {
|
||||
return (tokens / 1000000000).toFixed(2) + "b";
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request } from "express";
|
||||
import { config } from "../config";
|
||||
import { config } from "../../config";
|
||||
import {
|
||||
init as initClaude,
|
||||
getTokenCount as getClaudeTokenCount,
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ZodType, z } from "zod";
|
||||
import type { ModelFamily } from "../models";
|
||||
|
||||
export const tokenCountsSchema: ZodType<UserTokenCounts> = z
|
||||
.object({
|
||||
turbo: z.number().optional(),
|
||||
gpt4: z.number().optional(),
|
||||
"gpt4-32k": z.number().optional().default(0),
|
||||
claude: z.number().optional(),
|
||||
})
|
||||
.refine(zodModelFamilyRefinement, {
|
||||
message:
|
||||
"If provided, a tokenCounts object must include all model families",
|
||||
}) as ZodType<UserTokenCounts>; // refinement ensures the type correctness but zod doesn't know that
|
||||
|
||||
export const UserSchema = z
|
||||
.object({
|
||||
/** The user's personal access token. */
|
||||
token: z.string(),
|
||||
/** The IP addresses the user has connected from. */
|
||||
ip: z.array(z.string()),
|
||||
/** The user's nickname. */
|
||||
nickname: z.string().max(80).nullish(),
|
||||
/**
|
||||
* The user's privilege level.
|
||||
* - `normal`: Default role. Subject to usual rate limits and quotas.
|
||||
* - `special`: Special role. Higher quotas and exempt from
|
||||
* auto-ban/lockout.
|
||||
**/
|
||||
type: z.enum(["normal", "special"]),
|
||||
/** The number of prompts the user has made. */
|
||||
promptCount: z.number(),
|
||||
/**
|
||||
* @deprecated Use `tokenCounts` instead.
|
||||
* Never used; retained for backwards compatibility.
|
||||
*/
|
||||
tokenCount: z.any().optional(),
|
||||
/** The number of tokens the user has consumed, by model family. */
|
||||
tokenCounts: tokenCountsSchema,
|
||||
/** The maximum number of tokens the user can consume, by model family. */
|
||||
tokenLimits: tokenCountsSchema,
|
||||
/** The time at which the user was created. */
|
||||
createdAt: z.number(),
|
||||
/** The time at which the user last connected. */
|
||||
lastUsedAt: z.number().nullish(),
|
||||
/** The time at which the user was disabled, if applicable. */
|
||||
disabledAt: z.number().nullish(),
|
||||
/** The reason for which the user was disabled, if applicable. */
|
||||
disabledReason: z.string().nullish(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const UserPartialSchema = UserSchema.partial().extend({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
// gpt4-32k was added after the initial release, so this tries to allow for
|
||||
// data imported from older versions of the app which may be missing the
|
||||
// new model family.
|
||||
// Otherwise, all model families must be present.
|
||||
function zodModelFamilyRefinement(data: Record<string, number>) {
|
||||
const keys = Object.keys(data).sort();
|
||||
const validSets = [
|
||||
["claude", "gpt4", "turbo"],
|
||||
["claude", "gpt4", "gpt4-32k", "turbo"],
|
||||
];
|
||||
return validSets.some((set) => keys.join(",") === set.join(","));
|
||||
}
|
||||
|
||||
export type UserTokenCounts = {
|
||||
[K in Exclude<ModelFamily, "gpt4-32k">]: number;
|
||||
} & {
|
||||
[K in "gpt4-32k"]?: number | null; // null is not quite right but is more strict than undefined with +=
|
||||
};
|
||||
export type User = z.infer<typeof UserSchema>;
|
||||
export type UserUpdate = z.infer<typeof UserPartialSchema>;
|
||||
@@ -11,57 +11,17 @@ import admin from "firebase-admin";
|
||||
import schedule from "node-schedule";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { config, getFirebaseApp } from "../../config";
|
||||
import { ModelFamily } from "../../key-management";
|
||||
import { ModelFamily } from "../models";
|
||||
import { logger } from "../../logger";
|
||||
import { User, UserUpdate } from "./schema";
|
||||
|
||||
const log = logger.child({ module: "users" });
|
||||
|
||||
export type UserTokenCounts = {
|
||||
[K in Exclude<ModelFamily, "gpt4-32k">]: number;
|
||||
} & {
|
||||
[K in "gpt4-32k"]?: number; // Optional because it was added later
|
||||
};
|
||||
|
||||
export interface User {
|
||||
/** The user's personal access token. */
|
||||
token: string;
|
||||
/** The user's nickname. */
|
||||
nickname?: string;
|
||||
/** The IP addresses the user has connected from. */
|
||||
ip: string[];
|
||||
/** The user's privilege level. */
|
||||
type: UserType;
|
||||
/** The number of prompts the user has made. */
|
||||
promptCount: number;
|
||||
/** @deprecated Use `tokenCounts` instead. */
|
||||
tokenCount?: never;
|
||||
/** The number of tokens the user has consumed, by model family. */
|
||||
tokenCounts: UserTokenCounts;
|
||||
/** The maximum number of tokens the user can consume, by model family. */
|
||||
tokenLimits: UserTokenCounts;
|
||||
/** The time at which the user was created. */
|
||||
createdAt: number;
|
||||
/** The time at which the user last connected. */
|
||||
lastUsedAt?: number;
|
||||
/** The time at which the user was disabled, if applicable. */
|
||||
disabledAt?: number;
|
||||
/** The reason for which the user was disabled, if applicable. */
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Possible privilege levels for a user.
|
||||
* - `normal`: Default role. Subject to usual rate limits and quotas.
|
||||
* - `special`: Special role. Higher quotas and exempt from auto-ban/lockout.
|
||||
*/
|
||||
export type UserType = "normal" | "special";
|
||||
|
||||
type UserUpdate = Partial<User> & Pick<User, "token">;
|
||||
|
||||
const MAX_IPS_PER_USER = config.maxIpsPerUser;
|
||||
|
||||
const users: Map<string, User> = new Map();
|
||||
const usersToFlush = new Set<string>();
|
||||
let quotaRefreshJob: schedule.Job | null = null;
|
||||
|
||||
export async function init() {
|
||||
log.info({ store: config.gatekeeperStore }, "Initializing user store...");
|
||||
@@ -69,12 +29,12 @@ export async function init() {
|
||||
await initFirebase();
|
||||
}
|
||||
if (config.quotaRefreshPeriod) {
|
||||
const quotaRefreshJob = schedule.scheduleJob(getRefreshCrontab(), () => {
|
||||
quotaRefreshJob = schedule.scheduleJob(getRefreshCrontab(), () => {
|
||||
for (const user of users.values()) {
|
||||
refreshQuota(user.token);
|
||||
}
|
||||
log.info(
|
||||
{ users: users.size, nextRefresh: quotaRefreshJob.nextInvocation() },
|
||||
{ users: users.size, nextRefresh: quotaRefreshJob!.nextInvocation() },
|
||||
"Token quotas refreshed."
|
||||
);
|
||||
});
|
||||
@@ -92,6 +52,11 @@ export async function init() {
|
||||
log.info("User store initialized.");
|
||||
}
|
||||
|
||||
export function getNextQuotaRefresh() {
|
||||
if (!quotaRefreshJob) return "never (manual refresh only)";
|
||||
return quotaRefreshJob.nextInvocation().getTime();
|
||||
}
|
||||
|
||||
/** Creates a new user and returns their token. */
|
||||
export function createUser() {
|
||||
const token = uuid();
|
||||
@@ -120,10 +85,13 @@ export function getUsers() {
|
||||
|
||||
/**
|
||||
* Upserts the given user. Intended for use with the /admin API for updating
|
||||
* user information via JSON. Use other functions for more specific operations.
|
||||
* arbitrary fields on a user; use the other functions in this module for
|
||||
* specific use cases. `undefined` values are left unchanged. `null` will delete
|
||||
* the property from the user.
|
||||
*
|
||||
* Returns the upserted user.
|
||||
*/
|
||||
export function upsertUser(user: UserUpdate) {
|
||||
// TODO: May need better merging for nested objects
|
||||
const existing: User = users.get(user.token) ?? {
|
||||
token: user.token,
|
||||
ip: [],
|
||||
@@ -134,10 +102,19 @@ export function upsertUser(user: UserUpdate) {
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
users.set(user.token, {
|
||||
...existing,
|
||||
...user,
|
||||
});
|
||||
const updates: Partial<User> = {};
|
||||
|
||||
for (const field of Object.entries(user)) {
|
||||
const [key, value] = field as [keyof User, any]; // already validated by zod
|
||||
if (value === undefined || key === "token") continue;
|
||||
if (value === null) {
|
||||
delete existing[key];
|
||||
} else {
|
||||
updates[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
users.set(user.token, Object.assign(existing, updates));
|
||||
usersToFlush.add(user.token);
|
||||
|
||||
// Immediately schedule a flush to the database if we're using Firebase.
|
||||
@@ -165,6 +142,7 @@ export function incrementTokenCount(
|
||||
const user = users.get(token);
|
||||
if (!user) return;
|
||||
const modelFamily = getModelFamilyForQuotaUsage(model);
|
||||
user.tokenCounts[modelFamily] ??= 0;
|
||||
user.tokenCounts[modelFamily] += consumption;
|
||||
usersToFlush.add(token);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Query } from "express-serve-static-core";
|
||||
import sanitize from "sanitize-html";
|
||||
|
||||
export function parseSort(sort: Query["sort"]) {
|
||||
if (!sort) return null;
|
||||
if (typeof sort === "string") return sort.split(",");
|
||||
if (Array.isArray(sort)) return sort.splice(3) as string[];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sortBy(fields: string[], asc = true) {
|
||||
return (a: any, b: any) => {
|
||||
for (const field of fields) {
|
||||
if (a[field] !== b[field]) {
|
||||
// always sort nulls to the end
|
||||
if (a[field] == null) return 1;
|
||||
if (b[field] == null) return -1;
|
||||
|
||||
const valA = Array.isArray(a[field]) ? a[field].length : a[field];
|
||||
const valB = Array.isArray(b[field]) ? b[field].length : b[field];
|
||||
|
||||
const result = valA < valB ? -1 : 1;
|
||||
return asc ? result : -result;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
export function paginate(set: unknown[], page: number, pageSize: number = 20) {
|
||||
const p = Math.max(1, Math.min(page, Math.ceil(set.length / pageSize)));
|
||||
return {
|
||||
page: p,
|
||||
items: set.slice((p - 1) * pageSize, p * pageSize),
|
||||
pageSize,
|
||||
pageCount: Math.ceil(set.length / pageSize),
|
||||
totalCount: set.length,
|
||||
nextPage: p * pageSize < set.length ? p + 1 : null,
|
||||
prevPage: p > 1 ? p - 1 : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeAndTrim(
|
||||
input?: string | null,
|
||||
options: sanitize.IOptions = {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
}
|
||||
) {
|
||||
return sanitize((input ?? "").trim(), options);
|
||||
}
|
||||
+10
-10
@@ -63,15 +63,15 @@
|
||||
</style>
|
||||
</head>
|
||||
<body style="font-family: sans-serif; background-color: #f0f0f0; padding: 1em;">
|
||||
<% if (flash && flash.type === "error") { %>
|
||||
<p style="color: red; background-color: #eedddd; padding: 1em">
|
||||
<strong>⚠️ Error:</strong> <%= flash.message %>
|
||||
</p>
|
||||
<% } %>
|
||||
<% if (flash && flash.type === "success") { %>
|
||||
<p style="color: green; background-color: #ddffee; padding: 1em">
|
||||
<strong>✅ Success:</strong> <%= flash.message %>
|
||||
</p>
|
||||
<% } %>
|
||||
<% if (flash && flash.type === "error") { %>
|
||||
<p style="color: red; background-color: #eedddd; padding: 1em">
|
||||
<strong>⚠️ Error:</strong> <%= flash.message %>
|
||||
</p>
|
||||
<% } %>
|
||||
<% if (flash && flash.type === "success") { %>
|
||||
<p style="color: green; background-color: #ddffee; padding: 1em">
|
||||
<strong>✅ Success:</strong> <%= flash.message %>
|
||||
</p>
|
||||
<% } %>
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<p>Next refresh: <time><%- nextQuotaRefresh %></time></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Model Family</th>
|
||||
<th scope="col">Usage</th>
|
||||
<% if (showTokenCosts) { %>
|
||||
<th scope="col">Cost</th>
|
||||
<% } %>
|
||||
<th scope="col">Limit</th>
|
||||
<th scope="col">Remaining</th>
|
||||
<th scope="col">Refresh Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% Object.entries(quota).forEach(([key, limit]) => { %>
|
||||
<tr>
|
||||
<th scope="row"><%- key %></th>
|
||||
<td><%- prettyTokens(user.tokenCounts[key]) %></td>
|
||||
<% if (showTokenCosts) { %>
|
||||
<td>$<%- tokenCost(key, user.tokenCounts[key]).toFixed(2) %></td>
|
||||
<% } %>
|
||||
<% if (!user.tokenLimits[key]) { %>
|
||||
<td colspan="2" style="text-align: center">unlimited</td>
|
||||
<% } else { %>
|
||||
<td><%- prettyTokens(user.tokenLimits[key]) %></td>
|
||||
<td><%- prettyTokens(user.tokenLimits[key] - user.tokenCounts[key]) %></td>
|
||||
<% } %>
|
||||
<td><%- prettyTokens(quota[key]) %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,20 @@
|
||||
import cookieParser from "cookie-parser";
|
||||
import expressSession from "express-session";
|
||||
import MemoryStore from "memorystore";
|
||||
import { COOKIE_SECRET } from "../config";
|
||||
|
||||
const ONE_WEEK = 1000 * 60 * 60 * 24 * 7;
|
||||
|
||||
const cookieParserMiddleware = cookieParser(COOKIE_SECRET);
|
||||
|
||||
const sessionMiddleware = expressSession({
|
||||
secret: COOKIE_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: new (MemoryStore(expressSession))({ checkPeriod: ONE_WEEK }),
|
||||
cookie: { sameSite: "strict", maxAge: ONE_WEEK, signed: true },
|
||||
});
|
||||
|
||||
const withSession = [cookieParserMiddleware, sessionMiddleware];
|
||||
|
||||
export { withSession };
|
||||
Vendored
+9
-2
@@ -1,6 +1,6 @@
|
||||
import { Express } from "express-serve-static-core";
|
||||
import { AIService, Key } from "../key-management/index";
|
||||
import { User } from "../proxy/auth/user-store";
|
||||
import { AIService, Key } from "../shared/key-management/index";
|
||||
import { User } from "../shared/users/user-store";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
@@ -27,3 +27,10 @@ declare global {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
adminToken?: string;
|
||||
csrf?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import express, { Router } from "express";
|
||||
import { injectCsrfToken, checkCsrfToken } from "../shared/inject-csrf";
|
||||
import { selfServiceRouter } from "./web/self-service";
|
||||
import { injectLocals } from "../shared/inject-locals";
|
||||
import { withSession } from "../shared/with-session";
|
||||
|
||||
const userRouter = Router();
|
||||
|
||||
userRouter.use(
|
||||
express.json({ limit: "1mb" }),
|
||||
express.urlencoded({ extended: true, limit: "1mb" })
|
||||
);
|
||||
userRouter.use(withSession);
|
||||
userRouter.use(injectCsrfToken, checkCsrfToken);
|
||||
userRouter.use(injectLocals);
|
||||
|
||||
userRouter.use(selfServiceRouter);
|
||||
|
||||
userRouter.use(
|
||||
(
|
||||
err: Error,
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
_next: express.NextFunction
|
||||
) => {
|
||||
const data: any = { message: err.message, stack: err.stack, status: 500 };
|
||||
res.status(500).render("user_error", { ...data, flash: null });
|
||||
}
|
||||
);
|
||||
|
||||
export { userRouter };
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Router } from "express";
|
||||
import { UserSchema } from "../../shared/users/schema";
|
||||
import * as userStore from "../../shared/users/user-store";
|
||||
import { UserInputError } from "../../shared/errors";
|
||||
import { sanitizeAndTrim } from "../../shared/utils";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.redirect("/");
|
||||
});
|
||||
|
||||
router.get("/lookup", (req, res) => {
|
||||
res.render("user_lookup", { user: null });
|
||||
});
|
||||
|
||||
router.post("/lookup", (req, res) => {
|
||||
const token = req.body.token;
|
||||
const user = userStore.getUser(token);
|
||||
if (!user) {
|
||||
return res.status(401).render("user_lookup", {
|
||||
user: null,
|
||||
flash: { type: "error", message: "Invalid user token." },
|
||||
});
|
||||
}
|
||||
res.render("user_lookup", { user });
|
||||
});
|
||||
|
||||
router.post("/edit-nickname", (req, res) => {
|
||||
const nicknameUpdateSchema = UserSchema.pick({ token: true, nickname: true })
|
||||
.extend({
|
||||
nickname: UserSchema.shape.nickname.transform((v) => sanitizeAndTrim(v)),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const result = nicknameUpdateSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
throw new UserInputError(result.error.message);
|
||||
}
|
||||
|
||||
const existing = userStore.getUser(result.data.token);
|
||||
if (!existing) {
|
||||
throw new UserInputError("Invalid user token.");
|
||||
}
|
||||
|
||||
const newNickname = result.data.nickname || null;
|
||||
userStore.upsertUser({ ...existing, nickname: newNickname });
|
||||
res.render("user_lookup", {
|
||||
user: { ...existing, nickname: newNickname },
|
||||
flash: { type: "success", message: "Nickname updated" },
|
||||
});
|
||||
});
|
||||
|
||||
export { router as selfServiceRouter };
|
||||
@@ -0,0 +1,15 @@
|
||||
<hr />
|
||||
<footer>
|
||||
<a href="/user">Index</a>
|
||||
</footer>
|
||||
<script>
|
||||
document.querySelectorAll("td,time").forEach(function(td) {
|
||||
if (td.innerText.match(/^\d{13}$/)) {
|
||||
if (td.innerText == 0) return 'never';
|
||||
const date = new Date(parseInt(td.innerText));
|
||||
td.innerText = date.toString();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<%- include("partials/shared_header", { title: "Error" }) %>
|
||||
<div id="error-content" style="color: red; background-color: #eedddd; padding: 1em">
|
||||
<p><strong>⚠️ Error <%= status %>:</strong> <%= message %></p>
|
||||
<pre><%= stack %></pre>
|
||||
<a href="#" onclick="window.history.back()">Go Back</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="robots" content="noindex" />
|
||||
<title><%= title %></title>
|
||||
</head>
|
||||
<body style="font-family: sans-serif; background-color: #f0f0f0; padding: 1em;">
|
||||
<%= pageHeader %>
|
||||
<hr />
|
||||
<h2>Service Info</h2>
|
||||
<pre><%= JSON.stringify(serviceInfo, null, 2) %></pre>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<%- include("partials/shared_header", { title: "User Token Lookup" }) %>
|
||||
<h1>User Token Lookup</h1>
|
||||
<p>Provide your user token to check your token usage and modify your details.</p>
|
||||
<form action="/user/lookup" method="post">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||
<label for="token">User Token</label>
|
||||
<input type="password" name="token" value="<%= user?.token %>" />
|
||||
<input type="submit" value="Lookup" />
|
||||
</form>
|
||||
<% if (user) { %>
|
||||
<hr />
|
||||
<table class="striped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">User Token</th>
|
||||
<td colspan="2"><code> <%- "..." + user.token.slice(-5) %> </code></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">Nickname</th>
|
||||
<td><%- user.nickname ?? "none" %></td>
|
||||
<td class="actions">
|
||||
<a title="Edit" id="edit-nickname" href="#" onclick="updateNickname()">✏️</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Type</th>
|
||||
<td colspan="2"><%- user.type %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Prompts</th>
|
||||
<td colspan="2"><%- user.promptCount %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Created At</th>
|
||||
<td colspan="2"><%- user.createdAt %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Last Used At</th>
|
||||
<td colspan="2"><%- user.lastUsedAt || "never" %></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Quota Information</h3>
|
||||
<%- include("partials/shared_quota-info", { quota, user }) %>
|
||||
|
||||
<form id="edit-nickname-form" style="display: none" action="/user/edit-nickname" method="post">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||
<input type="hidden" name="token" value="<%= user.token %>" />
|
||||
<input type="hidden" name="nickname" value="<%= user.nickname %>" />
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function updateNickname() {
|
||||
const form = document.getElementById("edit-nickname-form");
|
||||
const existing = form.nickname.value;
|
||||
const value = prompt("Enter a new nickname", existing);
|
||||
if (value !== null) {
|
||||
form.nickname.value = value;
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<% } %> <%- include("partials/user_footer") %>
|
||||
Reference in New Issue
Block a user