Refactor project structure and add user self-serve UI (khanon/oai-reverse-proxy!41)

This commit is contained in:
khanon
2023-09-02 19:36:44 +00:00
parent 435b46ad4d
commit f05e196994
67 changed files with 993 additions and 381 deletions
+316
View File
@@ -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",
+5
View File
@@ -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",
+9 -5
View File
@@ -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
View File
@@ -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");
}
-119
View File
@@ -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, "&lt;")
.replace(/>/g, "&gt;");
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
View File
@@ -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
View File
@@ -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") %>
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -3
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) => {
+11
View File
@@ -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();
};
+37
View File
@@ -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, "&lt;")
.replace(/>/g, "&gt;");
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,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. */
@@ -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
@@ -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[] = [
@@ -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";
+9 -5
View File
@@ -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,
+76
View File
@@ -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);
}
+51
View File
@@ -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);
}
@@ -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>
+20
View File
@@ -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 };
+9 -2
View File
@@ -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;
}
}
+31
View File
@@ -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 };
+54
View File
@@ -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>
+8
View File
@@ -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>
+14
View File
@@ -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>
+66
View File
@@ -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") %>