From f05e1969944f207fe6341589b6e4665872fd36ab Mon Sep 17 00:00:00 2001 From: khanon Date: Sat, 2 Sep 2023 19:36:44 +0000 Subject: [PATCH] Refactor project structure and add user self-serve UI (khanon/oai-reverse-proxy!41) --- package-lock.json | 316 ++++++++++++++++++ package.json | 5 + src/admin/api/users.ts | 14 +- src/admin/auth.ts | 23 +- src/admin/common.ts | 119 ------- src/admin/login.ts | 11 +- src/admin/routes.ts | 25 +- src/admin/{ui/users.ts => web/manage.ts} | 57 ++-- .../web/views/admin_create-user.ejs} | 4 +- .../web/views/admin_error.ejs} | 2 +- .../web/views/admin_export-users.ejs} | 4 +- .../web/views/admin_import-users.ejs} | 4 +- .../web/views/admin_index.ejs} | 4 +- .../web/views/admin_list-users.ejs} | 6 +- .../web/views/admin_login.ejs} | 2 +- .../web/views/admin_view-user.ejs} | 68 ++-- .../web/views/partials}/admin-footer.ejs | 4 +- src/config.ts | 18 +- src/info-page.ts | 34 +- src/proxy/{auth => }/check-risu-token.ts | 2 +- src/proxy/{auth => }/gatekeeper.ts | 4 +- src/proxy/middleware/common.ts | 2 +- .../request/add-anthropic-preamble.ts | 2 +- src/proxy/middleware/request/add-key.ts | 2 +- .../middleware/request/apply-quota-limits.ts | 2 +- .../middleware/request/check-context-size.ts | 2 +- .../middleware/request/set-api-format.ts | 2 +- .../request/transform-outbound-payload.ts | 2 +- src/proxy/middleware/response/index.ts | 7 +- src/proxy/middleware/response/log-prompt.ts | 2 +- src/proxy/openai.ts | 4 +- src/proxy/queue.ts | 5 +- src/proxy/routes.ts | 4 +- src/server.ts | 16 +- src/shared/errors.ts | 11 + src/{admin/csrf.ts => shared/inject-csrf.ts} | 17 +- src/shared/inject-locals.ts | 37 ++ .../key-management/anthropic/checker.ts | 2 +- .../key-management/anthropic/provider.ts | 6 +- src/{ => shared}/key-management/index.ts | 8 +- src/{ => shared}/key-management/key-pool.ts | 4 +- .../key-management/openai/checker.ts | 4 +- .../key-management/openai/provider.ts | 6 +- src/{key-management => shared}/models.ts | 0 .../prompt-logging/backends/index.ts | 0 .../prompt-logging/backends/sheets.ts | 4 +- src/{ => shared}/prompt-logging/index.ts | 0 src/{ => shared}/prompt-logging/log-queue.ts | 2 +- src/{stats/index.ts => shared/stats.ts} | 14 +- src/{ => shared}/tokenization/claude.ts | 0 src/{ => shared}/tokenization/index.ts | 0 src/{ => shared}/tokenization/openai.ts | 0 src/{ => shared}/tokenization/tokenizer.ts | 2 +- src/shared/users/schema.ts | 76 +++++ .../auth => shared/users}/user-store.ts | 80 ++--- src/shared/utils.ts | 51 +++ .../views/partials/shared_header.ejs} | 20 +- .../views/partials/shared_pagination.ejs} | 0 .../views/partials/shared_quota-info.ejs | 33 ++ src/shared/with-session.ts | 20 ++ src/types/custom.d.ts | 11 +- src/user/routes.ts | 31 ++ src/user/web/self-service.ts | 54 +++ src/user/web/views/partials/user_footer.ejs | 15 + src/user/web/views/user_error.ejs | 8 + src/user/web/views/user_index.ejs | 14 + src/user/web/views/user_lookup.ejs | 66 ++++ 67 files changed, 993 insertions(+), 381 deletions(-) delete mode 100644 src/admin/common.ts rename src/admin/{ui/users.ts => web/manage.ts} (78%) rename src/{views/admin/create-user.ejs => admin/web/views/admin_create-user.ejs} (77%) rename src/{views/admin/error.ejs => admin/web/views/admin_error.ejs} (83%) rename src/{views/admin/export-users.ejs => admin/web/views/admin_export-users.ejs} (84%) rename src/{views/admin/import-users.ejs => admin/web/views/admin_import-users.ejs} (92%) rename src/{views/admin/index.ejs => admin/web/views/admin_index.ejs} (95%) rename src/{views/admin/list-users.ejs => admin/web/views/admin_list-users.ejs} (96%) rename src/{views/admin/login.ejs => admin/web/views/admin_login.ejs} (82%) rename src/{views/admin/view-user.ejs => admin/web/views/admin_view-user.ejs} (64%) rename src/{views/_partials => admin/web/views/partials}/admin-footer.ejs (81%) rename src/proxy/{auth => }/check-risu-token.ts (98%) rename src/proxy/{auth => }/gatekeeper.ts (94%) create mode 100644 src/shared/errors.ts rename src/{admin/csrf.ts => shared/inject-csrf.ts} (56%) create mode 100644 src/shared/inject-locals.ts rename src/{ => shared}/key-management/anthropic/checker.ts (99%) rename src/{ => shared}/key-management/anthropic/provider.ts (98%) rename src/{ => shared}/key-management/index.ts (92%) rename src/{ => shared}/key-management/key-pool.ts (98%) rename src/{ => shared}/key-management/openai/checker.ts (99%) rename src/{ => shared}/key-management/openai/provider.ts (98%) rename src/{key-management => shared}/models.ts (100%) rename src/{ => shared}/prompt-logging/backends/index.ts (100%) rename src/{ => shared}/prompt-logging/backends/sheets.ts (99%) rename src/{ => shared}/prompt-logging/index.ts (100%) rename src/{ => shared}/prompt-logging/log-queue.ts (98%) rename src/{stats/index.ts => shared/stats.ts} (71%) rename src/{ => shared}/tokenization/claude.ts (100%) rename src/{ => shared}/tokenization/index.ts (100%) rename src/{ => shared}/tokenization/openai.ts (100%) rename src/{ => shared}/tokenization/tokenizer.ts (97%) create mode 100644 src/shared/users/schema.ts rename src/{proxy/auth => shared/users}/user-store.ts (80%) create mode 100644 src/shared/utils.ts rename src/{views/_partials/admin-header.ejs => shared/views/partials/shared_header.ejs} (77%) rename src/{views/_partials/pagination.ejs => shared/views/partials/shared_pagination.ejs} (100%) create mode 100644 src/shared/views/partials/shared_quota-info.ejs create mode 100644 src/shared/with-session.ts create mode 100644 src/user/routes.ts create mode 100644 src/user/web/self-service.ts create mode 100644 src/user/web/views/partials/user_footer.ejs create mode 100644 src/user/web/views/user_error.ejs create mode 100644 src/user/web/views/user_index.ejs create mode 100644 src/user/web/views/user_lookup.ejs diff --git a/package-lock.json b/package-lock.json index f6eb51d..dece2bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6b4d18b..0047910 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/admin/api/users.ts b/src/admin/api/users.ts index 105dcc6..a379e21 100644 --- a/src/admin/api/users.ts +++ b/src/admin/api/users.ts @@ -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 }); } diff --git a/src/admin/auth.ts b/src/admin/auth.ts index 115fd77..09e0726 100644 --- a/src/admin/auth.ts +++ b/src/admin/auth.ts @@ -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"); } diff --git a/src/admin/common.ts b/src/admin/common.ts deleted file mode 100644 index 80161ae..0000000 --- a/src/admin/common.ts +++ /dev/null @@ -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 = 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; // 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) { - 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, ">"); - 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); - } -} diff --git a/src/admin/login.ts b/src/admin/login.ts index 26a1c06..b29aa9d 100644 --- a/src/admin/login.ts +++ b/src/admin/login.ts @@ -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"); diff --git a/src/admin/routes.ts b/src/admin/routes.ts index 6874a3e..46d9a20 100644 --- a/src/admin/routes.ts +++ b/src/admin/routes.ts @@ -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 }); } diff --git a/src/admin/ui/users.ts b/src/admin/web/manage.ts similarity index 78% rename from src/admin/ui/users.ts rename to src/admin/web/manage.ts index bed5df9..98dfbd7 100644 --- a/src/admin/ui/users.ts +++ b/src/admin/web/manage.ts @@ -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 }; diff --git a/src/views/admin/create-user.ejs b/src/admin/web/views/admin_create-user.ejs similarity index 77% rename from src/views/admin/create-user.ejs rename to src/admin/web/views/admin_create-user.ejs index 067ebfc..4c613bd 100644 --- a/src/views/admin/create-user.ejs +++ b/src/admin/web/views/admin_create-user.ejs @@ -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" }) %>

Create User Token

@@ -15,4 +15,4 @@
  • <%= user.token %>
  • <% }) %> -<%- include("../_partials/admin-footer") %> +<%- include("partials/admin-footer") %> diff --git a/src/views/admin/error.ejs b/src/admin/web/views/admin_error.ejs similarity index 83% rename from src/views/admin/error.ejs rename to src/admin/web/views/admin_error.ejs index 6c4072b..9ba1f03 100644 --- a/src/views/admin/error.ejs +++ b/src/admin/web/views/admin_error.ejs @@ -1,4 +1,4 @@ -<%- include("../_partials/admin-header", { title: "Error" }) %> +<%- include("partials/shared_header", { title: "Error" }) %>

    ⚠️ Error <%= status %>: <%= message %>

    <%= stack %>
    diff --git a/src/views/admin/export-users.ejs b/src/admin/web/views/admin_export-users.ejs similarity index 84% rename from src/views/admin/export-users.ejs rename to src/admin/web/views/admin_export-users.ejs index 5be632a..8e1043c 100644 --- a/src/views/admin/export-users.ejs +++ b/src/admin/web/views/admin_export-users.ejs @@ -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" }) %>

    Export Users

    Export users to JSON. The JSON will be an array of objects under the key @@ -25,4 +25,4 @@ } -<%- include("../_partials/admin-footer") %> +<%- include("partials/admin-footer") %> diff --git a/src/views/admin/import-users.ejs b/src/admin/web/views/admin_import-users.ejs similarity index 92% rename from src/views/admin/import-users.ejs rename to src/admin/web/views/admin_import-users.ejs index ba43978..b54a510 100644 --- a/src/views/admin/import-users.ejs +++ b/src/admin/web/views/admin_import-users.ejs @@ -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" }) %>

    Import Users

    Import users from JSON. The JSON should be an array of objects under the key @@ -45,4 +45,4 @@ -<%- include("../_partials/admin-footer") %> +<%- include("partials/admin-footer") %> diff --git a/src/views/admin/index.ejs b/src/admin/web/views/admin_index.ejs similarity index 95% rename from src/views/admin/index.ejs rename to src/admin/web/views/admin_index.ejs index 77c6e58..4050972 100644 --- a/src/views/admin/index.ejs +++ b/src/admin/web/views/admin_index.ejs @@ -1,4 +1,4 @@ -<%- include("../_partials/admin-header", { title: "OAI Reverse Proxy Admin" }) %> +<%- include("partials/shared_header", { title: "OAI Reverse Proxy Admin" }) %>

    OAI Reverse Proxy Admin

    <% if (!persistenceEnabled) { %>

    @@ -60,4 +60,4 @@ } -<%- include("../_partials/admin-footer") %> +<%- include("partials/admin-footer") %> diff --git a/src/views/admin/list-users.ejs b/src/admin/web/views/admin_list-users.ejs similarity index 96% rename from src/views/admin/list-users.ejs rename to src/admin/web/views/admin_list-users.ejs index 5181649..4acacb5 100644 --- a/src/views/admin/list-users.ejs +++ b/src/admin/web/views/admin_list-users.ejs @@ -1,4 +1,4 @@ -<%- include("../_partials/admin-header", { title: "Users - OAI Reverse Proxy Admin" }) %> +<%- include("partials/shared_header", { title: "Users - OAI Reverse Proxy Admin" }) %>

    User Token List

    <% if (users.length === 0) { %> @@ -60,7 +60,7 @@

    Showing <%= page * pageSize - pageSize + 1 %> to <%= users.length + page * pageSize - pageSize %> of <%= totalCount %> users.

    - <%- include("../_partials/pagination") %> + <%- include("partials/shared_pagination") %> <% } %> -<%- include("../_partials/admin-footer") %> +<%- include("partials/admin-footer") %> diff --git a/src/views/admin/login.ejs b/src/admin/web/views/admin_login.ejs similarity index 82% rename from src/views/admin/login.ejs rename to src/admin/web/views/admin_login.ejs index d36462c..2e07dac 100644 --- a/src/views/admin/login.ejs +++ b/src/admin/web/views/admin_login.ejs @@ -1,4 +1,4 @@ -<%- include("../_partials/admin-header", { title: "Login" }) %> +<%- include("partials/shared_header", { title: "Login" }) %>

    Login

    diff --git a/src/views/admin/view-user.ejs b/src/admin/web/views/admin_view-user.ejs similarity index 64% rename from src/views/admin/view-user.ejs rename to src/admin/web/views/admin_view-user.ejs index 12bbfba..b291196 100644 --- a/src/views/admin/view-user.ejs +++ b/src/admin/web/views/admin_view-user.ejs @@ -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" }) %>

    View User

    @@ -17,44 +17,20 @@ - + + - + - - - - - - - - @@ -85,6 +61,7 @@
    Nickname <%- user.nickname ?? "none" %> - ✏️ + ✏️
    Type<%- user.type %><%- user.type %> + ✏️ +
    Prompt CountPrompts <%- user.promptCount %>
    Token Counts -
      - <% Object.entries(user.tokenCounts).forEach(([key, count]) => { %> -
    • <%- key %>: <%- count %>
    • - <% }) %> -
    -
    Token Limits -
      - <% Object.entries(user.tokenLimits).forEach(([key, count]) => { %> -
    • <%- key %>: <%- count %>
    • - <% }) %> -
    -
    Created At <%- user.createdAt %>
    +

    Quota Information

    <% if (quotasEnabled) { %> @@ -92,6 +69,8 @@
    <% } %> +<%- include("partials/shared_quota-info", { quota, user }) %> +

    Back to User List

    @@ -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 @@ }); -<%- include("../_partials/admin-footer") %> +<%- include("partials/admin-footer") %> diff --git a/src/views/_partials/admin-footer.ejs b/src/admin/web/views/partials/admin-footer.ejs similarity index 81% rename from src/views/_partials/admin-footer.ejs rename to src/admin/web/views/partials/admin-footer.ejs index 93706ea..03422df 100644 --- a/src/views/_partials/admin-footer.ejs +++ b/src/admin/web/views/partials/admin-footer.ejs @@ -3,11 +3,11 @@ Index | Logout diff --git a/src/config.ts b/src/config.ts index 2b13742..3cf445f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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", diff --git a/src/info-page.ts b/src/info-page.ts index ceef1bf..ed50cbb 100644 --- a/src/info-page.ts +++ b/src/info-page.ts @@ -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); } diff --git a/src/proxy/auth/check-risu-token.ts b/src/proxy/check-risu-token.ts similarity index 98% rename from src/proxy/auth/check-risu-token.ts rename to src/proxy/check-risu-token.ts index c4e2d5f..115c8f9 100644 --- a/src/proxy/auth/check-risu-token.ts +++ b/src/proxy/check-risu-token.ts @@ -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" }); diff --git a/src/proxy/auth/gatekeeper.ts b/src/proxy/gatekeeper.ts similarity index 94% rename from src/proxy/auth/gatekeeper.ts rename to src/proxy/gatekeeper.ts index 18f640d..89b9c95 100644 --- a/src/proxy/auth/gatekeeper.ts +++ b/src/proxy/gatekeeper.ts @@ -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; diff --git a/src/proxy/middleware/common.ts b/src/proxy/middleware/common.ts index ab87d82..560ebb6 100644 --- a/src/proxy/middleware/common.ts +++ b/src/proxy/middleware/common.ts @@ -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"; diff --git a/src/proxy/middleware/request/add-anthropic-preamble.ts b/src/proxy/middleware/request/add-anthropic-preamble.ts index 7fedfa2..35f3602 100644 --- a/src/proxy/middleware/request/add-anthropic-preamble.ts +++ b/src/proxy/middleware/request/add-anthropic-preamble.ts @@ -1,4 +1,4 @@ -import { AnthropicKey, Key } from "../../../key-management"; +import { AnthropicKey, Key } from "../../../shared/key-management"; import { isCompletionRequest } from "../common"; import { ProxyRequestMiddleware } from "."; diff --git a/src/proxy/middleware/request/add-key.ts b/src/proxy/middleware/request/add-key.ts index 3082620..ef149b9 100644 --- a/src/proxy/middleware/request/add-key.ts +++ b/src/proxy/middleware/request/add-key.ts @@ -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 "."; diff --git a/src/proxy/middleware/request/apply-quota-limits.ts b/src/proxy/middleware/request/apply-quota-limits.ts index f5555ce..581de23 100644 --- a/src/proxy/middleware/request/apply-quota-limits.ts +++ b/src/proxy/middleware/request/apply-quota-limits.ts @@ -1,4 +1,4 @@ -import { hasAvailableQuota } from "../../auth/user-store"; +import { hasAvailableQuota } from "../../../shared/users/user-store"; import { isCompletionRequest } from "../common"; import { ProxyRequestMiddleware } from "."; diff --git a/src/proxy/middleware/request/check-context-size.ts b/src/proxy/middleware/request/check-context-size.ts index 31174a0..d28f4a0 100644 --- a/src/proxy/middleware/request/check-context-size.ts +++ b/src/proxy/middleware/request/check-context-size.ts @@ -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; diff --git a/src/proxy/middleware/request/set-api-format.ts b/src/proxy/middleware/request/set-api-format.ts index 57fbd5f..20d273f 100644 --- a/src/proxy/middleware/request/set-api-format.ts +++ b/src/proxy/middleware/request/set-api-format.ts @@ -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: { diff --git a/src/proxy/middleware/request/transform-outbound-payload.ts b/src/proxy/middleware/request/transform-outbound-payload.ts index 7585702..042ff83 100644 --- a/src/proxy/middleware/request/transform-outbound-payload.ts +++ b/src/proxy/middleware/request/transform-outbound-payload.ts @@ -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 "."; diff --git a/src/proxy/middleware/response/index.ts b/src/proxy/middleware/response/index.ts index e398481..0698dbf 100644 --- a/src/proxy/middleware/response/index.ts +++ b/src/proxy/middleware/response/index.ts @@ -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), diff --git a/src/proxy/middleware/response/log-prompt.ts b/src/proxy/middleware/response/log-prompt.ts index bdaf1a0..220fdef 100644 --- a/src/proxy/middleware/response/log-prompt.ts +++ b/src/proxy/middleware/response/log-prompt.ts @@ -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 "."; diff --git a/src/proxy/openai.ts b/src/proxy/openai.ts index 1e3e626..6c09837 100644 --- a/src/proxy/openai.ts +++ b/src/proxy/openai.ts @@ -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"; diff --git a/src/proxy/queue.ts b/src/proxy/queue.ts index 5e60c3c..3d8e811 100644 --- a/src/proxy/queue.ts +++ b/src/proxy/queue.ts @@ -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"; diff --git a/src/proxy/routes.ts b/src/proxy/routes.ts index 0ec7c7c..6280928 100644 --- a/src/proxy/routes.ts +++ b/src/proxy/routes.ts @@ -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"; diff --git a/src/server.ts b/src/server.ts index 8b1d37c..350d4e4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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) => { diff --git a/src/shared/errors.ts b/src/shared/errors.ts new file mode 100644 index 0000000..dc434a8 --- /dev/null +++ b/src/shared/errors.ts @@ -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); + } +} diff --git a/src/admin/csrf.ts b/src/shared/inject-csrf.ts similarity index 56% rename from src/admin/csrf.ts rename to src/shared/inject-csrf.ts index cb9ab43..7af3a9a 100644 --- a/src/admin/csrf.ts +++ b/src/shared/inject-csrf.ts @@ -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(); }; diff --git a/src/shared/inject-locals.ts b/src/shared/inject-locals.ts new file mode 100644 index 0000000..76f0627 --- /dev/null +++ b/src/shared/inject-locals.ts @@ -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, ">"); + 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(); +}; diff --git a/src/key-management/anthropic/checker.ts b/src/shared/key-management/anthropic/checker.ts similarity index 99% rename from src/key-management/anthropic/checker.ts rename to src/shared/key-management/anthropic/checker.ts index a28800f..d14ccfe 100644 --- a/src/key-management/anthropic/checker.ts +++ b/src/shared/key-management/anthropic/checker.ts @@ -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. */ diff --git a/src/key-management/anthropic/provider.ts b/src/shared/key-management/anthropic/provider.ts similarity index 98% rename from src/key-management/anthropic/provider.ts rename to src/shared/key-management/anthropic/provider.ts index d87a35c..ecf28d7 100644 --- a/src/key-management/anthropic/provider.ts +++ b/src/shared/key-management/anthropic/provider.ts @@ -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 diff --git a/src/key-management/index.ts b/src/shared/key-management/index.ts similarity index 92% rename from src/key-management/index.ts rename to src/shared/key-management/index.ts index c5c6a88..8cb426f 100644 --- a/src/key-management/index.ts +++ b/src/shared/key-management/index.ts @@ -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"; diff --git a/src/key-management/key-pool.ts b/src/shared/key-management/key-pool.ts similarity index 98% rename from src/key-management/key-pool.ts rename to src/shared/key-management/key-pool.ts index ebc2378..a91dc00 100644 --- a/src/key-management/key-pool.ts +++ b/src/shared/key-management/key-pool.ts @@ -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; diff --git a/src/key-management/openai/checker.ts b/src/shared/key-management/openai/checker.ts similarity index 99% rename from src/key-management/openai/checker.ts rename to src/shared/key-management/openai/checker.ts index 6b5bbca..e58f234 100644 --- a/src/key-management/openai/checker.ts +++ b/src/shared/key-management/openai/checker.ts @@ -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 diff --git a/src/key-management/openai/provider.ts b/src/shared/key-management/openai/provider.ts similarity index 98% rename from src/key-management/openai/provider.ts rename to src/shared/key-management/openai/provider.ts index 9a8b5b1..8fc35bb 100644 --- a/src/key-management/openai/provider.ts +++ b/src/shared/key-management/openai/provider.ts @@ -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[] = [ diff --git a/src/key-management/models.ts b/src/shared/models.ts similarity index 100% rename from src/key-management/models.ts rename to src/shared/models.ts diff --git a/src/prompt-logging/backends/index.ts b/src/shared/prompt-logging/backends/index.ts similarity index 100% rename from src/prompt-logging/backends/index.ts rename to src/shared/prompt-logging/backends/index.ts diff --git a/src/prompt-logging/backends/sheets.ts b/src/shared/prompt-logging/backends/sheets.ts similarity index 99% rename from src/prompt-logging/backends/sheets.ts rename to src/shared/prompt-logging/backends/sheets.ts index 14ded10..4e0164c 100644 --- a/src/prompt-logging/backends/sheets.ts +++ b/src/shared/prompt-logging/backends/sheets.ts @@ -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 diff --git a/src/prompt-logging/index.ts b/src/shared/prompt-logging/index.ts similarity index 100% rename from src/prompt-logging/index.ts rename to src/shared/prompt-logging/index.ts diff --git a/src/prompt-logging/log-queue.ts b/src/shared/prompt-logging/log-queue.ts similarity index 98% rename from src/prompt-logging/log-queue.ts rename to src/shared/prompt-logging/log-queue.ts index 15708e8..0e337d0 100644 --- a/src/prompt-logging/log-queue.ts +++ b/src/shared/prompt-logging/log-queue.ts @@ -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"; diff --git a/src/stats/index.ts b/src/shared/stats.ts similarity index 71% rename from src/stats/index.ts rename to src/shared/stats.ts index 3b66b11..8fa133e 100644 --- a/src/stats/index.ts +++ b/src/shared/stats.ts @@ -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"; diff --git a/src/tokenization/claude.ts b/src/shared/tokenization/claude.ts similarity index 100% rename from src/tokenization/claude.ts rename to src/shared/tokenization/claude.ts diff --git a/src/tokenization/index.ts b/src/shared/tokenization/index.ts similarity index 100% rename from src/tokenization/index.ts rename to src/shared/tokenization/index.ts diff --git a/src/tokenization/openai.ts b/src/shared/tokenization/openai.ts similarity index 100% rename from src/tokenization/openai.ts rename to src/shared/tokenization/openai.ts diff --git a/src/tokenization/tokenizer.ts b/src/shared/tokenization/tokenizer.ts similarity index 97% rename from src/tokenization/tokenizer.ts rename to src/shared/tokenization/tokenizer.ts index 379b828..0a86f5b 100644 --- a/src/tokenization/tokenizer.ts +++ b/src/shared/tokenization/tokenizer.ts @@ -1,5 +1,5 @@ import { Request } from "express"; -import { config } from "../config"; +import { config } from "../../config"; import { init as initClaude, getTokenCount as getClaudeTokenCount, diff --git a/src/shared/users/schema.ts b/src/shared/users/schema.ts new file mode 100644 index 0000000..502de61 --- /dev/null +++ b/src/shared/users/schema.ts @@ -0,0 +1,76 @@ +import { ZodType, z } from "zod"; +import type { ModelFamily } from "../models"; + +export const tokenCountsSchema: ZodType = 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; // 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) { + 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]: number; +} & { + [K in "gpt4-32k"]?: number | null; // null is not quite right but is more strict than undefined with += +}; +export type User = z.infer; +export type UserUpdate = z.infer; diff --git a/src/proxy/auth/user-store.ts b/src/shared/users/user-store.ts similarity index 80% rename from src/proxy/auth/user-store.ts rename to src/shared/users/user-store.ts index 7e62efa..bd98f73 100644 --- a/src/proxy/auth/user-store.ts +++ b/src/shared/users/user-store.ts @@ -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]: 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 & Pick; - const MAX_IPS_PER_USER = config.maxIpsPerUser; const users: Map = new Map(); const usersToFlush = new Set(); +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 = {}; + + 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); } diff --git a/src/shared/utils.ts b/src/shared/utils.ts new file mode 100644 index 0000000..465b59b --- /dev/null +++ b/src/shared/utils.ts @@ -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); +} diff --git a/src/views/_partials/admin-header.ejs b/src/shared/views/partials/shared_header.ejs similarity index 77% rename from src/views/_partials/admin-header.ejs rename to src/shared/views/partials/shared_header.ejs index 4f2daf8..aafbea6 100644 --- a/src/views/_partials/admin-header.ejs +++ b/src/shared/views/partials/shared_header.ejs @@ -63,15 +63,15 @@ - <% if (flash && flash.type === "error") { %> -

    - ⚠️ Error: <%= flash.message %> -

    - <% } %> - <% if (flash && flash.type === "success") { %> -

    - ✅ Success: <%= flash.message %> -

    - <% } %> + <% if (flash && flash.type === "error") { %> +

    + ⚠️ Error: <%= flash.message %> +

    + <% } %> + <% if (flash && flash.type === "success") { %> +

    + ✅ Success: <%= flash.message %> +

    + <% } %> diff --git a/src/views/_partials/pagination.ejs b/src/shared/views/partials/shared_pagination.ejs similarity index 100% rename from src/views/_partials/pagination.ejs rename to src/shared/views/partials/shared_pagination.ejs diff --git a/src/shared/views/partials/shared_quota-info.ejs b/src/shared/views/partials/shared_quota-info.ejs new file mode 100644 index 0000000..b035688 --- /dev/null +++ b/src/shared/views/partials/shared_quota-info.ejs @@ -0,0 +1,33 @@ +

    Next refresh:

    + + + + + + <% if (showTokenCosts) { %> + + <% } %> + + + + + + + <% Object.entries(quota).forEach(([key, limit]) => { %> + + + + <% if (showTokenCosts) { %> + + <% } %> + <% if (!user.tokenLimits[key]) { %> + + <% } else { %> + + + <% } %> + + + <% }) %> + +
    Model FamilyUsageCostLimitRemainingRefresh Amount
    <%- key %><%- prettyTokens(user.tokenCounts[key]) %>$<%- tokenCost(key, user.tokenCounts[key]).toFixed(2) %>unlimited<%- prettyTokens(user.tokenLimits[key]) %><%- prettyTokens(user.tokenLimits[key] - user.tokenCounts[key]) %><%- prettyTokens(quota[key]) %>
    diff --git a/src/shared/with-session.ts b/src/shared/with-session.ts new file mode 100644 index 0000000..2678622 --- /dev/null +++ b/src/shared/with-session.ts @@ -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 }; diff --git a/src/types/custom.d.ts b/src/types/custom.d.ts index efff1bb..d65d10f 100644 --- a/src/types/custom.d.ts +++ b/src/types/custom.d.ts @@ -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; + } +} diff --git a/src/user/routes.ts b/src/user/routes.ts new file mode 100644 index 0000000..2009f31 --- /dev/null +++ b/src/user/routes.ts @@ -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 }; diff --git a/src/user/web/self-service.ts b/src/user/web/self-service.ts new file mode 100644 index 0000000..581e8b3 --- /dev/null +++ b/src/user/web/self-service.ts @@ -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 }; diff --git a/src/user/web/views/partials/user_footer.ejs b/src/user/web/views/partials/user_footer.ejs new file mode 100644 index 0000000..4d0c636 --- /dev/null +++ b/src/user/web/views/partials/user_footer.ejs @@ -0,0 +1,15 @@ +
    + + + + diff --git a/src/user/web/views/user_error.ejs b/src/user/web/views/user_error.ejs new file mode 100644 index 0000000..3db82c4 --- /dev/null +++ b/src/user/web/views/user_error.ejs @@ -0,0 +1,8 @@ +<%- include("partials/shared_header", { title: "Error" }) %> +
    +

    ⚠️ Error <%= status %>: <%= message %>

    +
    <%= stack %>
    + Go Back +
    + + diff --git a/src/user/web/views/user_index.ejs b/src/user/web/views/user_index.ejs new file mode 100644 index 0000000..55b937b --- /dev/null +++ b/src/user/web/views/user_index.ejs @@ -0,0 +1,14 @@ + + + + + + <%= title %> + + + <%= pageHeader %> +
    +

    Service Info

    +
    <%= JSON.stringify(serviceInfo, null, 2) %>
    + + diff --git a/src/user/web/views/user_lookup.ejs b/src/user/web/views/user_lookup.ejs new file mode 100644 index 0000000..28033dc --- /dev/null +++ b/src/user/web/views/user_lookup.ejs @@ -0,0 +1,66 @@ +<%- include("partials/shared_header", { title: "User Token Lookup" }) %> +

    User Token Lookup

    +

    Provide your user token to check your token usage and modify your details.

    +
    + + + + +
    +<% if (user) { %> +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    User Token <%- "..." + user.token.slice(-5) %>
    Nickname<%- user.nickname ?? "none" %> + ✏️ +
    Type<%- user.type %>
    Prompts<%- user.promptCount %>
    Created At<%- user.createdAt %>
    Last Used At<%- user.lastUsedAt || "never" %>
    + +

    Quota Information

    +<%- include("partials/shared_quota-info", { quota, user }) %> + + + + + +<% } %> <%- include("partials/user_footer") %>