From 2a453ab6578ea268a6e931aecf3ce4ff75f58a20 Mon Sep 17 00:00:00 2001 From: khanon Date: Sat, 9 Sep 2023 22:21:38 +0000 Subject: [PATCH] Add temporary user tokens (khanon/oai-reverse-proxy!42) --- .env.example | 125 +++++++++---- .husky/pre-push | 4 + docs/assets/openapi-admin-users.yaml | 51 ++++- package-lock.json | 16 ++ package.json | 4 +- src/admin/api/users.ts | 30 ++- src/admin/auth.ts | 3 +- src/admin/login.ts | 8 +- src/admin/web/manage.ts | 175 ++++++++++++++---- src/admin/web/views/admin_create-user.ejs | 123 +++++++++++- src/admin/web/views/admin_index.ejs | 1 + src/admin/web/views/admin_view-user.ejs | 19 +- src/config.ts | 93 ++++------ src/shared/errors.ts | 12 ++ src/shared/inject-locals.ts | 19 +- src/shared/models.ts | 13 +- src/shared/stats.ts | 3 - src/shared/users/schema.ts | 44 +++-- src/shared/users/user-store.ts | 111 ++++++++--- src/shared/utils.ts | 26 +++ src/shared/views/partials/shared_flash.ejs | 25 +++ src/shared/views/partials/shared_header.ejs | 11 +- .../views/partials/shared_quota-info.ejs | 4 + .../views/partials/shared_user_ip_list.ejs | 14 ++ src/types/custom.d.ts | 2 + src/user/web/self-service.ts | 57 +++--- src/user/web/views/user_lookup.ejs | 24 ++- 27 files changed, 758 insertions(+), 259 deletions(-) create mode 100644 .husky/pre-push create mode 100644 src/shared/views/partials/shared_flash.ejs create mode 100644 src/shared/views/partials/shared_user_ip_list.ejs diff --git a/.env.example b/.env.example index f3e987b..31001e1 100644 --- a/.env.example +++ b/.env.example @@ -1,59 +1,102 @@ -# Copy this file to .env and fill in the values you wish to change. Most already -# have sensible defaults. See config.ts for more details. +# To customize your server, make a copy of this file to `.env` and edit any +# values you want to change. Be sure to remove the `#` at the beginning of each +# line you want to modify. -# PORT=7860 -# SERVER_TITLE=Coom Tunnel -# MODEL_RATE_LIMIT=4 -# MAX_OUTPUT_TOKENS_OPENAI=300 -# MAX_OUTPUT_TOKENS_ANTHROPIC=900 -# SHOW_TOKEN_COSTS=true -# LOG_LEVEL=info -# REJECT_DISALLOWED=false -# REJECT_MESSAGE="This content violates /aicg/'s acceptable use policy." -# CHECK_KEYS=true -# ALLOWED_MODEL_FAMILIES=claude,turbo,gpt4,gpt4-32k -# BLOCKED_ORIGINS=reddit.com,9gag.com -# BLOCK_MESSAGE="You must be over the age of majority in your country to use this service." -# BLOCK_REDIRECT="https://roblox.com/" - -# Note: CHECK_KEYS is disabled by default in local development mode, but enabled -# by default in production mode. - -# Optional settings for user management, access control, and quota enforcement. -# See `docs/user-management.md` to learn how to use these. -# See `docs/user-quotas.md` to learn how to use quotas. -# GATEKEEPER=none -# GATEKEEPER_STORE=memory -# MAX_IPS_PER_USER=20 -# TOKEN_QUOTA_TURBO=0 -# TOKEN_QUOTA_GPT4=0 -# TOKEN_QUOTA_CLAUDE=0 -# QUOTA_REFRESH_PERIOD=daily -# ALLOW_NICKNAME_CHANGES=true - -# Optional settings for prompt logging. See docs/logging-sheets.md. -# PROMPT_LOGGING=false +# All values have reasonable defaults, so you only need to change the ones you +# want to override. # ------------------------------------------------------------------------------ -# The values below are secret -- make sure they are set securely. Do NOT set -# them in the .env file of a public repository. +# General settings: + +# The title displayed on the info page. +# SERVER_TITLE=Coom Tunnel + +# Model requests allowed per minute per user. +# MODEL_RATE_LIMIT=4 + +# Max number of output tokens a user can request at once. +# MAX_OUTPUT_TOKENS_OPENAI=300 +# MAX_OUTPUT_TOKENS_ANTHROPIC=400 + +# Whether to show the estimated cost of consumed tokens on the info page. +# SHOW_TOKEN_COSTS=false + +# Whether to automatically check API keys for validity. +# Note: CHECK_KEYS is disabled by default in local development mode, but enabled +# by default in production mode. +# CHECK_KEYS=true + +# Which model types users are allowed to access. +# ALLOWED_MODEL_FAMILIES=claude,turbo,gpt4,gpt4-32k + +# URLs from which requests will be blocked. +# BLOCKED_ORIGINS=reddit.com,9gag.com +# Message to show when requests are blocked. +# BLOCK_MESSAGE="You must be over the age of majority in your country to use this service." +# Destination to redirect blocked requests to. +# BLOCK_REDIRECT="https://roblox.com/" + +# Whether to reject requests containing disallowed content. +# REJECT_DISALLOWED=false +# Message to show when requests are rejected. +# REJECT_MESSAGE="This content violates /aicg/'s acceptable use policy." + +# Whether prompts should be logged to Google Sheets. +# Requires additional setup. See `docs/google-sheets.md` for more information. +# PROMPT_LOGGING=false + +# The port to listen on. +# PORT=7860 + +# Detail level of logging. (trace | debug | info | warn | error) +# LOG_LEVEL=info + +# ------------------------------------------------------------------------------ +# Optional settings for user management, access control, and quota enforcement: +# See `docs/user-management.md` for more information and setup instructions. +# See `docs/user-quotas.md` to learn how to set up quotas. + +# Which access control method to use. (none | proxy_token | user_token) +# GATEKEEPER=none +# Which persistence method to use. (memory | firebase_rtdb) +# GATEKEEPER_STORE=memory + +# Maximum number of unique IPs a user can connect from. (0 for unlimited) +# MAX_IPS_PER_USER=0 + +# With user_token gatekeeper, whether to allow users to change their nickname. +# ALLOW_NICKNAME_CHANGES=true + +# Default token quotas for each model family. (0 for unlimited) +# TOKEN_QUOTA_TURBO=0 +# TOKEN_QUOTA_GPT4=0 +# TOKEN_QUOTA_GPT4_32K=0 +# TOKEN_QUOTA_CLAUDE=0 + +# How often to refresh token quotas. (hourly | daily) +# Leave unset to never automatically refresh quotas. +# QUOTA_REFRESH_PERIOD=daily + +# ------------------------------------------------------------------------------ +# Secrets and keys: +# Do not put any passwords or API keys directly in this file. # For Huggingface, set them via the Secrets section in your Space's config UI. # For Render, create a "secret file" called .env using the Environment tab. -# You can add multiple keys by separating them with a comma. +# You can add multiple API keys by separating them with a comma. OPENAI_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ANTHROPIC_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -# You can require a Bearer token for requests when using proxy_token gatekeeper. +# With proxy_key gatekeeper, the password users must provide to access the API. # PROXY_KEY=your-secret-key -# You can set an admin key for user management when using user_token gatekeeper. +# With user_token gatekeeper, the admin password used to manage users. # ADMIN_KEY=your-very-secret-key -# These are used to persist user data to Firebase across restarts. +# With firebase_rtdb gatekeeper storage, the Firebase project credentials. # FIREBASE_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # FIREBASE_RTDB_URL=https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.firebaseio.com -# These are used to log prompts to Google Sheets. +# With prompt logging, the Google Sheets credentials. # GOOGLE_SHEETS_SPREADSHEET_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # GOOGLE_SHEETS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..a43a286 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run type-check diff --git a/docs/assets/openapi-admin-users.yaml b/docs/assets/openapi-admin-users.yaml index f0987c3..648bb55 100644 --- a/docs/assets/openapi-admin-users.yaml +++ b/docs/assets/openapi-admin-users.yaml @@ -1,4 +1,4 @@ -# Shat out by GPT-4, I did not check for correctness beyond a cursory glance + openapi: 3.0.0 info: version: 1.0.0 @@ -26,6 +26,26 @@ paths: post: summary: Create a new user operationId: createUser + requestBody: + content: + application/json: + schema: + oneOf: + - type: object + properties: + type: + type: string + enum: ["normal", "special"] + - type: object + properties: + type: + type: string + enum: ["temporary"] + expiresAt: + type: integer + format: int64 + tokenLimits: + $ref: "#/components/schemas/TokenCount" responses: "200": description: The created user's token @@ -170,9 +190,24 @@ paths: type: object properties: error: - type: string + type: string components: schemas: + TokenCount: + type: object + properties: + turbo: + type: integer + format: int32 + gpt4: + type: integer + format: int32 + "gpt4-32k": + type: integer + format: int32 + claude: + type: integer + format: int32 User: type: object properties: @@ -182,15 +217,18 @@ components: type: array items: type: string + nickname: + type: string type: type: string enum: ["normal", "special"] promptCount: type: integer format: int32 - tokenCount: - type: integer - format: int32 + tokenLimits: + $ref: "#/components/schemas/TokenCount" + tokenCounts: + $ref: "#/components/schemas/TokenCount" createdAt: type: integer format: int64 @@ -202,3 +240,6 @@ components: format: int64 disabledReason: type: string + expiresAt: + type: integer + format: int64 diff --git a/package-lock.json b/package-lock.json index dece2bd..fd9a1ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "concurrently": "^8.0.1", "esbuild": "^0.17.16", "esbuild-register": "^3.4.2", + "husky": "^8.0.3", "nodemon": "^3.0.1", "source-map-support": "^0.5.21", "ts-node": "^10.9.1", @@ -2896,6 +2897,21 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true, + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", diff --git a/package.json b/package.json index 0047910..6e977fb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start:watch": "nodemon --require source-map-support/register build/server.js", "start:replit": "tsc && node build/server.js", "start": "node build/server.js", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "prepare": "husky install" }, "engines": { "node": ">=18.0.0" @@ -54,6 +55,7 @@ "concurrently": "^8.0.1", "esbuild": "^0.17.16", "esbuild-register": "^3.4.2", + "husky": "^8.0.3", "nodemon": "^3.0.1", "source-map-support": "^0.5.21", "ts-node": "^10.9.1", diff --git a/src/admin/api/users.ts b/src/admin/api/users.ts index a379e21..cddad57 100644 --- a/src/admin/api/users.ts +++ b/src/admin/api/users.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import { z } from "zod"; import * as userStore from "../../shared/users/user-store"; import { parseSort, sortBy } from "../../shared/utils"; -import { UserPartialSchema } from "../../shared/users/schema"; +import { UserPartialSchema, UserSchema } from "../../shared/users/schema"; const router = Router(); @@ -30,11 +30,32 @@ router.get("/:token", (req, res) => { /** * Creates a new user. + * Optionally accepts a JSON body containing `type`, and for temporary-type + * users, `tokenLimits` and `expiresAt` fields. * Returns the created user's token. * POST /admin/users */ router.post("/", (req, res) => { - const token = userStore.createUser(); + const body = req.body; + + const base = z.object({ + type: UserSchema.shape.type.exclude(["temporary"]).default("normal"), + }); + const tempUser = base + .extend({ + type: z.literal("temporary"), + expiresAt: UserSchema.shape.expiresAt, + tokenLimits: UserSchema.shape.tokenLimits, + }) + .required(); + + const schema = z.union([base, tempUser]); + const result = schema.safeParse(body); + if (!result.success) { + return res.status(400).json({ error: result.error }); + } + + const token = userStore.createUser({ ...result.data }); res.json({ token }); }); @@ -68,10 +89,7 @@ router.put("/", (req, res) => { return res.status(400).json({ error: result.error }); } const upserts = result.data.map((user) => userStore.upsertUser(user)); - res.json({ - upserted_users: upserts, - count: upserts.length, - }); + res.json({ upserted_users: upserts, count: upserts.length }); }); /** diff --git a/src/admin/auth.ts b/src/admin/auth.ts index 09e0726..162f758 100644 --- a/src/admin/auth.ts +++ b/src/admin/auth.ts @@ -49,5 +49,6 @@ function handleFailedLogin(req: Request, res: Response) { return res.status(401).json({ error: "Unauthorized" }); } delete req.session.adminToken; - return res.redirect("/admin/login?failed=true"); + req.session.flash = { type: "error", message: `Invalid admin key.` }; + return res.redirect("/admin/login"); } diff --git a/src/admin/login.ts b/src/admin/login.ts index b29aa9d..b49c260 100644 --- a/src/admin/login.ts +++ b/src/admin/login.ts @@ -2,12 +2,8 @@ import { Router } from "express"; const loginRouter = Router(); -loginRouter.get("/login", (req, res) => { - res.render("admin_login", { - flash: req.query.failed - ? { type: "error", message: "Invalid admin key" } - : null, - }); +loginRouter.get("/login", (_req, res) => { + res.render("admin_login"); }); loginRouter.post("/login", (req, res) => { diff --git a/src/admin/web/manage.ts b/src/admin/web/manage.ts index 98dfbd7..626fcb1 100644 --- a/src/admin/web/manage.ts +++ b/src/admin/web/manage.ts @@ -6,9 +6,14 @@ 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 { MODEL_FAMILIES } from "../../shared/models"; import { getTokenCostUsd, prettyTokens } from "../../shared/stats"; -import { UserPartialSchema } from "../../shared/users/schema"; +import { + User, + UserPartialSchema, + UserSchema, + UserTokenCounts, +} from "../../shared/users/schema"; const router = Router(); @@ -34,41 +39,62 @@ router.get("/create-user", (req, res) => { }); }); -router.post("/create-user", (_req, res) => { - userStore.createUser(); +router.post("/create-user", (req, res) => { + const body = req.body; + + const base = z.object({ type: UserSchema.shape.type.default("normal") }); + const tempUser = base + .extend({ + temporaryUserDuration: z.coerce + .number() + .int() + .min(1) + .max(10080 * 4), + }) + .merge( + MODEL_FAMILIES.reduce((schema, model) => { + return schema.extend({ + [`temporaryUserQuota_${model}`]: z.coerce.number().int().min(0), + }); + }, z.object({})) + ) + .transform((data: any) => { + const expiresAt = Date.now() + data.temporaryUserDuration * 60 * 1000; + const tokenLimits = MODEL_FAMILIES.reduce((limits, model) => { + limits[model] = data[`temporaryUserQuota_${model}`]; + return limits; + }, {} as UserTokenCounts); + return { ...data, expiresAt, tokenLimits }; + }); + + const createSchema = body.type === "temporary" ? tempUser : base; + const result = createSchema.safeParse(body); + if (!result.success) { + throw new HttpError( + 400, + result.error.issues.flatMap((issue) => issue.message).join(", ") + ); + } + + userStore.createUser({ ...result.data }); return res.redirect(`/admin/manage/create-user?created=true`); }); router.get("/view-user/:token", (req, res) => { const user = userStore.getUser(req.params.token); if (!user) throw new HttpError(404, "User not found"); - - if (req.query.refreshed) { - res.locals.flash = { - type: "success", - message: "User's quota was refreshed", - }; - } res.render("admin_view-user", { user }); }); router.get("/list-users", (req, res) => { - const sort = parseSort(req.query.sort) || ["sumCost", "createdAt"]; + const sort = parseSort(req.query.sort) || ["sumTokens", "createdAt"]; const requestedPageSize = Number(req.query.perPage) || Number(req.cookies.perPage) || 20; const perPage = Math.max(1, Math.min(1000, requestedPageSize)); const users = userStore .getUsers() .map((user) => { - const sums = { sumTokens: 0, sumCost: 0, prettyUsage: "" }; - Object.entries(user.tokenCounts).forEach(([model, tokens]) => { - const coalesced = tokens ?? 0; - sums.sumTokens += coalesced; - sums.sumCost += getTokenCostUsd(model as ModelFamily, coalesced); - }); - sums.prettyUsage = `${prettyTokens( - sums.sumTokens - )} ($${sums.sumCost.toFixed(2)}) `; + const sums = getSumsForUser(user); return { ...user, ...sums }; }) .sort(sortBy(sort, false)); @@ -95,9 +121,11 @@ router.post("/import-users", upload.single("users"), (req, res) => { if (!result.success) throw new HttpError(400, result.error.toString()); const upserts = result.data.map((user) => userStore.upsertUser(user)); - res.render("admin_import-users", { - flash: { type: "success", message: `${upserts.length} users imported` }, - }); + req.session.flash = { + type: "success", + message: `${upserts.length} users imported`, + }; + res.redirect("/admin/manage/import-users"); }); router.get("/export-users", (_req, res) => { @@ -155,39 +183,124 @@ router.post("/refresh-user-quota", (req, res) => { const user = userStore.getUser(req.body.token); if (!user) throw new HttpError(404, "User not found"); - userStore.refreshQuota(req.body.token); - return res.redirect(`/admin/manage/view-user/${req.body.token}?refreshed=1`); + userStore.refreshQuota(user.token); + req.session.flash = { + type: "success", + message: "User's quota was refreshed", + }; + return res.redirect(`/admin/manage/view-user/${user.token}`); }); router.post("/maintenance", (req, res) => { const action = req.body.action; - let message = ""; + let flash = { type: "", message: "" }; switch (action) { case "recheck": { keyPool.recheck("openai"); keyPool.recheck("anthropic"); const size = keyPool.list().length; - message = `success: Scheduled recheck of ${size} keys.`; + flash.type = "success"; + flash.message = `Scheduled recheck of ${size} keys.`; break; } case "resetQuotas": { const users = userStore.getUsers(); users.forEach((user) => userStore.refreshQuota(user.token)); const { claude, gpt4, turbo } = config.tokenQuota; - message = `success: All users' token quotas reset to ${turbo} (Turbo), ${gpt4} (GPT-4), ${claude} (Claude).`; + flash.type = "success"; + flash.message = `All users' token quotas reset to ${turbo} (Turbo), ${gpt4} (GPT-4), ${claude} (Claude).`; break; } case "resetCounts": { const users = userStore.getUsers(); users.forEach((user) => userStore.resetUsage(user.token)); - message = `success: All users' token usage records reset.`; + flash.type = "success"; + flash.message = `All users' token usage records reset.`; break; } default: { throw new HttpError(400, "Invalid action"); } } - return res.redirect(`/admin/manage?flash=${message}`); + + req.session.flash = flash; + + return res.redirect(`/admin/manage`); }); +router.get("/rentry-stats", (_req, res) => { + const users = userStore.getUsers(); + + let totalTokens = 0; + let totalCost = 0; + let totalPrompts = 0; + let totalIps = 0; + + const lines = users + .map((user) => { + const sums = getSumsForUser(user); + totalTokens += sums.sumTokens; + totalCost += sums.sumCost; + totalPrompts += user.promptCount; + totalIps += user.ip.length; + + const token = `...${user.token.slice(-5)}`; + const name = user.nickname + ? `${user.nickname.slice(0, 16).padEnd(16)} ${token}` + : `${"Anonymous".padEnd(16)} ${token}`; + const strUser = name.padEnd(25); + const strPrompts = `${user.promptCount} proompts`.padEnd(14); + const strIps = `${user.ip.length} IPs`.padEnd(8); + const strTokens = `${sums.prettyUsage} tokens`.padEnd(30); + + return { + strUser, + strPrompts, + strIps, + strTokens, + sort: user.promptCount, + }; + }) + .sort((a, b) => b.sort - a.sort) + .map( + (l) => `${l.strUser} | ${l.strPrompts} | ${l.strIps} | ${l.strTokens}` + ); + + const strTotalPrompts = `${totalPrompts} proompts`; + const strTotalIps = `${totalIps} IPs`; + const strTotalTokens = `${prettyTokens(totalTokens)} tokens`; + const strTotalCost = `US$${totalCost.toFixed(2)} cost`; + let header = `!!!Note ${users.length} users | ${strTotalPrompts} | ${strTotalIps} | ${strTotalTokens} | ${strTotalCost}`; + + const doc = []; + doc.push("# Stats"); + doc.push(header); + doc.push("```"); + doc.push(lines.join("\n")); + doc.push("```"); + doc.push(` -> *(as of ${new Date().toISOString()})* <-`); + res.setHeader( + "Content-Disposition", + `attachment; filename=proxy-stats-${new Date().toISOString()}.md` + ); + res.setHeader("Content-Type", "text/markdown"); + res.send(doc.join("\n")); +}); + +function getSumsForUser(user: User) { + const sums = MODEL_FAMILIES.reduce( + (s, model) => { + const tokens = user.tokenCounts[model] ?? 0; + s.sumTokens += tokens; + s.sumCost += getTokenCostUsd(model, tokens); + return s; + }, + { sumTokens: 0, sumCost: 0, prettyUsage: "" } + ); + sums.prettyUsage = `${prettyTokens(sums.sumTokens)} ($${sums.sumCost.toFixed( + 2 + )})`; + return sums; +} + export { router as usersWebRouter }; diff --git a/src/admin/web/views/admin_create-user.ejs b/src/admin/web/views/admin_create-user.ejs index 4c613bd..a46f7e3 100644 --- a/src/admin/web/views/admin_create-user.ejs +++ b/src/admin/web/views/admin_create-user.ejs @@ -1,18 +1,133 @@ <%- include("partials/shared_header", { title: "Create User - OAI Reverse Proxy Admin" }) %> - + + +

Create User Token

+

User token types:

+ +
+ + +
<% if (newToken) { %> -

Just created <%= recentUsers[0].token %>.

+

Just created <%= recentUsers[0].token %>.

<% } %> -

Recent Tokens

+

Recent Tokens

+ + + <%- include("partials/admin-footer") %> diff --git a/src/admin/web/views/admin_index.ejs b/src/admin/web/views/admin_index.ejs index 4050972..74079b8 100644 --- a/src/admin/web/views/admin_index.ejs +++ b/src/admin/web/views/admin_index.ejs @@ -18,6 +18,7 @@
  • Create User
  • Import Users
  • Export Users
  • +
  • Download Rentry Stats
  • Maintenance

    diff --git a/src/admin/web/views/admin_view-user.ejs b/src/admin/web/views/admin_view-user.ejs index b291196..445f975 100644 --- a/src/admin/web/views/admin_view-user.ejs +++ b/src/admin/web/views/admin_view-user.ejs @@ -50,14 +50,15 @@ IPs - Show all (<%- user.ip.length %>) - + <%- include("partials/shared_user_ip_list", { user }) %> + <% if (user.type === "temporary") { %> + + Expires At + <%- user.expiresAt %> + + <% } %> @@ -75,12 +76,6 @@

    Back to User List

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

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

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

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

    - <% } %> + <%- include("partials/shared_flash", { flashData: flash }) %> diff --git a/src/shared/views/partials/shared_quota-info.ejs b/src/shared/views/partials/shared_quota-info.ejs index b035688..38ad730 100644 --- a/src/shared/views/partials/shared_quota-info.ejs +++ b/src/shared/views/partials/shared_quota-info.ejs @@ -26,7 +26,11 @@ <%- prettyTokens(user.tokenLimits[key]) %> <%- prettyTokens(user.tokenLimits[key] - user.tokenCounts[key]) %> <% } %> + <% if (user.type === "temporary") { %> + N/A + <% } else { %> <%- prettyTokens(quota[key]) %> + <% } %> <% }) %> diff --git a/src/shared/views/partials/shared_user_ip_list.ejs b/src/shared/views/partials/shared_user_ip_list.ejs new file mode 100644 index 0000000..b1adfff --- /dev/null +++ b/src/shared/views/partials/shared_user_ip_list.ejs @@ -0,0 +1,14 @@ +Show all (<%- user.ip.length %>) + + + diff --git a/src/types/custom.d.ts b/src/types/custom.d.ts index d65d10f..9b65a87 100644 --- a/src/types/custom.d.ts +++ b/src/types/custom.d.ts @@ -31,6 +31,8 @@ declare global { declare module "express-session" { interface SessionData { adminToken?: string; + userToken?: string; csrf?: string; + flash?: { type: string; message: string }; } } diff --git a/src/user/web/self-service.ts b/src/user/web/self-service.ts index c75fd9c..f94f260 100644 --- a/src/user/web/self-service.ts +++ b/src/user/web/self-service.ts @@ -1,58 +1,61 @@ import { Router } from "express"; -import { UserSchema } from "../../shared/users/schema"; +import { UserPartialSchema } from "../../shared/users/schema"; import * as userStore from "../../shared/users/user-store"; -import { UserInputError } from "../../shared/errors"; +import { ForbiddenError, UserInputError } from "../../shared/errors"; import { sanitizeAndTrim } from "../../shared/utils"; import { config } from "../../config"; const router = Router(); +router.use((req, res, next) => { + if (req.session.userToken) { + res.locals.currentSelfServiceUser = + userStore.getUser(req.session.userToken) || null; + } + next(); +}); + router.get("/", (_req, res) => { res.redirect("/"); }); -router.get("/lookup", (req, res) => { - res.render("user_lookup", { user: null }); +router.get("/lookup", (_req, res) => { + res.render("user_lookup", { user: res.locals.currentSelfServiceUser }); }); 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." }, - }); + req.session.flash = { type: "error", message: "Invalid user token." }; + return res.redirect("/user/lookup"); } - res.render("user_lookup", { user }); + req.session.userToken = user.token; + return res.redirect("/user/lookup"); }); router.post("/edit-nickname", (req, res) => { - if (!config.allowNicknameChanges) - throw new UserInputError("Nickname changes are not allowed."); + const existing = res.locals.currentSelfServiceUser; - const nicknameUpdateSchema = UserSchema.pick({ token: true, nickname: true }) - .extend({ - nickname: UserSchema.shape.nickname.transform((v) => sanitizeAndTrim(v)), - }) - .strict(); + if (!existing) { + throw new ForbiddenError("Not logged in."); + } else if (!config.allowNicknameChanges || existing.disabledAt) { + throw new ForbiddenError("Nickname changes are not allowed."); + } - const result = nicknameUpdateSchema.safeParse(req.body); + const schema = UserPartialSchema.pick({ nickname: true }) + .strict() + .transform((v) => ({ nickname: sanitizeAndTrim(v.nickname) })); + + const result = schema.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" }, - }); + userStore.upsertUser({ token: existing.token, nickname: newNickname }); + req.session.flash = { type: "success", message: "Nickname updated." }; + return res.redirect("/user/lookup"); }); export { router as selfServiceRouter }; diff --git a/src/user/web/views/user_lookup.ejs b/src/user/web/views/user_lookup.ejs index 28033dc..f8d99df 100644 --- a/src/user/web/views/user_lookup.ejs +++ b/src/user/web/views/user_lookup.ejs @@ -1,6 +1,6 @@ <%- include("partials/shared_header", { title: "User Token Lookup" }) %>

    User Token Lookup

    -

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

    +

    Provide your user token to check your usage and quota information.

    @@ -9,6 +9,17 @@
    <% if (user) { %>
    +<% if (user.type === "temporary" && Boolean(user.disabledAt)) { %> +<%- include("partials/shared_flash", { flashData: { + type: "info", + message: "This temporary user token has expired and is no longer usable. These records will be deleted soon.", + } }) %> +<% } else if (user.disabledAt) { %> +<%- include("partials/shared_flash", { flashData: { + type: "warning", + message: "This user token has been disabled." + (user.disabledReason ? ` Reason: ${user.disabledReason}` : ""), + } }) %> +<% } %> @@ -39,6 +50,16 @@ + + + + + <% if (user.type === "temporary") { %> + + + + + <% } %>
    Last Used At <%- user.lastUsedAt || "never" %>
    IPs<%- maxIps ? ` (max ${maxIps})` : "" %> <%- include("partials/shared_user_ip_list", { user }) %>
    Expires At<%- user.expiresAt %>
    @@ -47,7 +68,6 @@