Add temporary user tokens (khanon/oai-reverse-proxy!42)
This commit is contained in:
parent
5728e235dc
commit
2a453ab657
125
.env.example
125
.env.example
|
@ -1,59 +1,102 @@
|
||||||
# Copy this file to .env and fill in the values you wish to change. Most already
|
# To customize your server, make a copy of this file to `.env` and edit any
|
||||||
# have sensible defaults. See config.ts for more details.
|
# values you want to change. Be sure to remove the `#` at the beginning of each
|
||||||
|
# line you want to modify.
|
||||||
|
|
||||||
# PORT=7860
|
# All values have reasonable defaults, so you only need to change the ones you
|
||||||
# SERVER_TITLE=Coom Tunnel
|
# want to override.
|
||||||
# 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
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# The values below are secret -- make sure they are set securely. Do NOT set
|
# General settings:
|
||||||
# them in the .env file of a public repository.
|
|
||||||
|
# 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 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.
|
# 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
|
OPENAI_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
ANTHROPIC_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
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
|
# 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
|
# 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_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
# FIREBASE_RTDB_URL=https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.firebaseio.com
|
# 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_SPREADSHEET_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
# GOOGLE_SHEETS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
# GOOGLE_SHEETS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npm run type-check
|
|
@ -1,4 +1,4 @@
|
||||||
# Shat out by GPT-4, I did not check for correctness beyond a cursory glance
|
|
||||||
openapi: 3.0.0
|
openapi: 3.0.0
|
||||||
info:
|
info:
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
@ -26,6 +26,26 @@ paths:
|
||||||
post:
|
post:
|
||||||
summary: Create a new user
|
summary: Create a new user
|
||||||
operationId: createUser
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: The created user's token
|
description: The created user's token
|
||||||
|
@ -170,9 +190,24 @@ paths:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
error:
|
error:
|
||||||
type: string
|
type: string
|
||||||
components:
|
components:
|
||||||
schemas:
|
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:
|
User:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
@ -182,15 +217,18 @@ components:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
nickname:
|
||||||
|
type: string
|
||||||
type:
|
type:
|
||||||
type: string
|
type: string
|
||||||
enum: ["normal", "special"]
|
enum: ["normal", "special"]
|
||||||
promptCount:
|
promptCount:
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
tokenCount:
|
tokenLimits:
|
||||||
type: integer
|
$ref: "#/components/schemas/TokenCount"
|
||||||
format: int32
|
tokenCounts:
|
||||||
|
$ref: "#/components/schemas/TokenCount"
|
||||||
createdAt:
|
createdAt:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
@ -202,3 +240,6 @@ components:
|
||||||
format: int64
|
format: int64
|
||||||
disabledReason:
|
disabledReason:
|
||||||
type: string
|
type: string
|
||||||
|
expiresAt:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
|
|
@ -47,6 +47,7 @@
|
||||||
"concurrently": "^8.0.1",
|
"concurrently": "^8.0.1",
|
||||||
"esbuild": "^0.17.16",
|
"esbuild": "^0.17.16",
|
||||||
"esbuild-register": "^3.4.2",
|
"esbuild-register": "^3.4.2",
|
||||||
|
"husky": "^8.0.3",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
|
@ -2896,6 +2897,21 @@
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
|
|
@ -8,7 +8,8 @@
|
||||||
"start:watch": "nodemon --require source-map-support/register build/server.js",
|
"start:watch": "nodemon --require source-map-support/register build/server.js",
|
||||||
"start:replit": "tsc && node build/server.js",
|
"start:replit": "tsc && node build/server.js",
|
||||||
"start": "node build/server.js",
|
"start": "node build/server.js",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
@ -54,6 +55,7 @@
|
||||||
"concurrently": "^8.0.1",
|
"concurrently": "^8.0.1",
|
||||||
"esbuild": "^0.17.16",
|
"esbuild": "^0.17.16",
|
||||||
"esbuild-register": "^3.4.2",
|
"esbuild-register": "^3.4.2",
|
||||||
|
"husky": "^8.0.3",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { Router } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import * as userStore from "../../shared/users/user-store";
|
import * as userStore from "../../shared/users/user-store";
|
||||||
import { parseSort, sortBy } from "../../shared/utils";
|
import { parseSort, sortBy } from "../../shared/utils";
|
||||||
import { UserPartialSchema } from "../../shared/users/schema";
|
import { UserPartialSchema, UserSchema } from "../../shared/users/schema";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
@ -30,11 +30,32 @@ router.get("/:token", (req, res) => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new user.
|
* 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.
|
* Returns the created user's token.
|
||||||
* POST /admin/users
|
* POST /admin/users
|
||||||
*/
|
*/
|
||||||
router.post("/", (req, res) => {
|
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 });
|
res.json({ token });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -68,10 +89,7 @@ router.put("/", (req, res) => {
|
||||||
return res.status(400).json({ error: result.error });
|
return res.status(400).json({ error: result.error });
|
||||||
}
|
}
|
||||||
const upserts = result.data.map((user) => userStore.upsertUser(user));
|
const upserts = result.data.map((user) => userStore.upsertUser(user));
|
||||||
res.json({
|
res.json({ upserted_users: upserts, count: upserts.length });
|
||||||
upserted_users: upserts,
|
|
||||||
count: upserts.length,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -49,5 +49,6 @@ function handleFailedLogin(req: Request, res: Response) {
|
||||||
return res.status(401).json({ error: "Unauthorized" });
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
delete req.session.adminToken;
|
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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,12 +2,8 @@ import { Router } from "express";
|
||||||
|
|
||||||
const loginRouter = Router();
|
const loginRouter = Router();
|
||||||
|
|
||||||
loginRouter.get("/login", (req, res) => {
|
loginRouter.get("/login", (_req, res) => {
|
||||||
res.render("admin_login", {
|
res.render("admin_login");
|
||||||
flash: req.query.failed
|
|
||||||
? { type: "error", message: "Invalid admin key" }
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
loginRouter.post("/login", (req, res) => {
|
loginRouter.post("/login", (req, res) => {
|
||||||
|
|
|
@ -6,9 +6,14 @@ import { HttpError } from "../../shared/errors";
|
||||||
import * as userStore from "../../shared/users/user-store";
|
import * as userStore from "../../shared/users/user-store";
|
||||||
import { parseSort, sortBy, paginate } from "../../shared/utils";
|
import { parseSort, sortBy, paginate } from "../../shared/utils";
|
||||||
import { keyPool } from "../../shared/key-management";
|
import { keyPool } from "../../shared/key-management";
|
||||||
import { ModelFamily } from "../../shared/models";
|
import { MODEL_FAMILIES } from "../../shared/models";
|
||||||
import { getTokenCostUsd, prettyTokens } from "../../shared/stats";
|
import { getTokenCostUsd, prettyTokens } from "../../shared/stats";
|
||||||
import { UserPartialSchema } from "../../shared/users/schema";
|
import {
|
||||||
|
User,
|
||||||
|
UserPartialSchema,
|
||||||
|
UserSchema,
|
||||||
|
UserTokenCounts,
|
||||||
|
} from "../../shared/users/schema";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
@ -34,41 +39,62 @@ router.get("/create-user", (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/create-user", (_req, res) => {
|
router.post("/create-user", (req, res) => {
|
||||||
userStore.createUser();
|
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`);
|
return res.redirect(`/admin/manage/create-user?created=true`);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/view-user/:token", (req, res) => {
|
router.get("/view-user/:token", (req, res) => {
|
||||||
const user = userStore.getUser(req.params.token);
|
const user = userStore.getUser(req.params.token);
|
||||||
if (!user) throw new HttpError(404, "User not found");
|
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 });
|
res.render("admin_view-user", { user });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/list-users", (req, res) => {
|
router.get("/list-users", (req, res) => {
|
||||||
const sort = parseSort(req.query.sort) || ["sumCost", "createdAt"];
|
const sort = parseSort(req.query.sort) || ["sumTokens", "createdAt"];
|
||||||
const requestedPageSize =
|
const requestedPageSize =
|
||||||
Number(req.query.perPage) || Number(req.cookies.perPage) || 20;
|
Number(req.query.perPage) || Number(req.cookies.perPage) || 20;
|
||||||
const perPage = Math.max(1, Math.min(1000, requestedPageSize));
|
const perPage = Math.max(1, Math.min(1000, requestedPageSize));
|
||||||
const users = userStore
|
const users = userStore
|
||||||
.getUsers()
|
.getUsers()
|
||||||
.map((user) => {
|
.map((user) => {
|
||||||
const sums = { sumTokens: 0, sumCost: 0, prettyUsage: "" };
|
const sums = getSumsForUser(user);
|
||||||
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)}) `;
|
|
||||||
return { ...user, ...sums };
|
return { ...user, ...sums };
|
||||||
})
|
})
|
||||||
.sort(sortBy(sort, false));
|
.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());
|
if (!result.success) throw new HttpError(400, result.error.toString());
|
||||||
|
|
||||||
const upserts = result.data.map((user) => userStore.upsertUser(user));
|
const upserts = result.data.map((user) => userStore.upsertUser(user));
|
||||||
res.render("admin_import-users", {
|
req.session.flash = {
|
||||||
flash: { type: "success", message: `${upserts.length} users imported` },
|
type: "success",
|
||||||
});
|
message: `${upserts.length} users imported`,
|
||||||
|
};
|
||||||
|
res.redirect("/admin/manage/import-users");
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/export-users", (_req, res) => {
|
router.get("/export-users", (_req, res) => {
|
||||||
|
@ -155,39 +183,124 @@ router.post("/refresh-user-quota", (req, res) => {
|
||||||
const user = userStore.getUser(req.body.token);
|
const user = userStore.getUser(req.body.token);
|
||||||
if (!user) throw new HttpError(404, "User not found");
|
if (!user) throw new HttpError(404, "User not found");
|
||||||
|
|
||||||
userStore.refreshQuota(req.body.token);
|
userStore.refreshQuota(user.token);
|
||||||
return res.redirect(`/admin/manage/view-user/${req.body.token}?refreshed=1`);
|
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) => {
|
router.post("/maintenance", (req, res) => {
|
||||||
const action = req.body.action;
|
const action = req.body.action;
|
||||||
let message = "";
|
let flash = { type: "", message: "" };
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "recheck": {
|
case "recheck": {
|
||||||
keyPool.recheck("openai");
|
keyPool.recheck("openai");
|
||||||
keyPool.recheck("anthropic");
|
keyPool.recheck("anthropic");
|
||||||
const size = keyPool.list().length;
|
const size = keyPool.list().length;
|
||||||
message = `success: Scheduled recheck of ${size} keys.`;
|
flash.type = "success";
|
||||||
|
flash.message = `Scheduled recheck of ${size} keys.`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "resetQuotas": {
|
case "resetQuotas": {
|
||||||
const users = userStore.getUsers();
|
const users = userStore.getUsers();
|
||||||
users.forEach((user) => userStore.refreshQuota(user.token));
|
users.forEach((user) => userStore.refreshQuota(user.token));
|
||||||
const { claude, gpt4, turbo } = config.tokenQuota;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "resetCounts": {
|
case "resetCounts": {
|
||||||
const users = userStore.getUsers();
|
const users = userStore.getUsers();
|
||||||
users.forEach((user) => userStore.resetUsage(user.token));
|
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;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new HttpError(400, "Invalid action");
|
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 };
|
export { router as usersWebRouter };
|
||||||
|
|
|
@ -1,18 +1,133 @@
|
||||||
<%- include("partials/shared_header", { title: "Create User - OAI Reverse Proxy Admin" }) %>
|
<%- include("partials/shared_header", { title: "Create User - OAI Reverse Proxy Admin" }) %>
|
||||||
<!--
|
|
||||||
-->
|
<style>
|
||||||
|
#temporaryUserOptions {
|
||||||
|
margin-top: 1em;
|
||||||
|
max-width: 30em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#temporaryUserOptions h3 {
|
||||||
|
margin-bottom: -0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
max-width: 10em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temporary-user-fieldset {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr); /* Four equal-width columns */
|
||||||
|
column-gap: 1em;
|
||||||
|
row-gap: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-label {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<h1>Create User Token</h1>
|
<h1>Create User Token</h1>
|
||||||
|
<p>User token types:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Normal</strong> - Standard users.
|
||||||
|
<li><strong>Special</strong> - Exempt from token quotas and <code>MAX_IPS_PER_USER</code> enforcement.</li>
|
||||||
|
<li><strong>Temporary</strong> - Disabled after a specified duration. Quotas never refresh.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<form action="/admin/manage/create-user" method="post">
|
<form action="/admin/manage/create-user" method="post">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||||
|
<label for="type">Type</label>
|
||||||
|
<select name="type">
|
||||||
|
<option value="normal">Normal</option>
|
||||||
|
<option value="special">Special</option>
|
||||||
|
<option value="temporary">Temporary</option>
|
||||||
|
</select>
|
||||||
<input type="submit" value="Create" />
|
<input type="submit" value="Create" />
|
||||||
|
<fieldset id="temporaryUserOptions" style="display: none">
|
||||||
|
<legend>Temporary User Options</legend>
|
||||||
|
<div class="temporary-user-fieldset">
|
||||||
|
<p class="full-width">
|
||||||
|
Temporary users will be disabled after the specified duration, and their records will be deleted 72 hours after that.
|
||||||
|
These options apply only to new
|
||||||
|
temporary users; existing ones use whatever options were in effect when they were created.
|
||||||
|
</p>
|
||||||
|
<label for="temporaryUserDuration" class="full-width">Access duration (in minutes)</label>
|
||||||
|
<input type="number" name="temporaryUserDuration" id="temporaryUserDuration" value="60" class="full-width" />
|
||||||
|
<!-- convenience calculations -->
|
||||||
|
<span>6 hours:</span><code>360</code>
|
||||||
|
<span>12 hours:</span><code>720</code>
|
||||||
|
<span>1 day:</span><code>1440</code>
|
||||||
|
<span>1 week:</span><code>10080</code>
|
||||||
|
<h3 class="full-width">Token Quotas</h3>
|
||||||
|
<p class="full-width">Temporary users' quotas are never refreshed.</p>
|
||||||
|
<% Object.entries(quota).forEach(function([model, tokens]) { %>
|
||||||
|
<label class="quota-label" for="temporaryUserQuota_<%= model %>"><%= model %></label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="temporaryUserQuota_<%= model %>"
|
||||||
|
id="temporaryUserQuota_<%= model %>"
|
||||||
|
value="0"
|
||||||
|
data-fieldtype="tokenquota"
|
||||||
|
data-default="<%= tokens %>" />
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
<% if (newToken) { %>
|
<% if (newToken) { %>
|
||||||
<p>Just created <code><%= recentUsers[0].token %></code>.</p>
|
<p>Just created <code><%= recentUsers[0].token %></code>.</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<h3>Recent Tokens</h2>
|
<h2>Recent Tokens</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<% recentUsers.forEach(function(user) { %>
|
<% recentUsers.forEach(function(user) { %>
|
||||||
<li><a href="/admin/manage/view-user/<%= user.token %>"><%= user.token %></a></li>
|
<li><a href="/admin/manage/view-user/<%= user.token %>"><%= user.token %></a></li>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const typeInput = document.querySelector("select[name=type]");
|
||||||
|
const temporaryUserOptions = document.querySelector("#temporaryUserOptions");
|
||||||
|
typeInput.addEventListener("change", function () {
|
||||||
|
localStorage.setItem("admin__create-user__type", typeInput.value);
|
||||||
|
if (typeInput.value === "temporary") {
|
||||||
|
temporaryUserOptions.style.display = "block";
|
||||||
|
} else {
|
||||||
|
temporaryUserOptions.style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadDefaults() {
|
||||||
|
const defaultType = localStorage.getItem("admin__create-user__type");
|
||||||
|
if (defaultType) {
|
||||||
|
typeInput.value = defaultType;
|
||||||
|
typeInput.dispatchEvent(new Event("change"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationInput = document.querySelector("input[name=temporaryUserDuration]");
|
||||||
|
const defaultDuration = localStorage.getItem("admin__create-user__duration");
|
||||||
|
durationInput.addEventListener("change", function () {
|
||||||
|
localStorage.setItem("admin__create-user__duration", durationInput.value);
|
||||||
|
});
|
||||||
|
if (defaultDuration) {
|
||||||
|
durationInput.value = defaultDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenQuotaInputs = document.querySelectorAll("input[data-fieldtype=tokenquota]");
|
||||||
|
tokenQuotaInputs.forEach(function (input) {
|
||||||
|
const defaultQuota = localStorage.getItem("admin__create-user__quota__" + input.id);
|
||||||
|
input.addEventListener("change", function () {
|
||||||
|
localStorage.setItem("admin__create-user__quota__" + input.id, input.value);
|
||||||
|
});
|
||||||
|
if (defaultQuota) {
|
||||||
|
input.value = defaultQuota;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDefaults();
|
||||||
|
</script>
|
||||||
|
|
||||||
<%- include("partials/admin-footer") %>
|
<%- include("partials/admin-footer") %>
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
<li><a href="/admin/manage/create-user">Create User</a></li>
|
<li><a href="/admin/manage/create-user">Create User</a></li>
|
||||||
<li><a href="/admin/manage/import-users">Import Users</a></li>
|
<li><a href="/admin/manage/import-users">Import Users</a></li>
|
||||||
<li><a href="/admin/manage/export-users">Export Users</a></li>
|
<li><a href="/admin/manage/export-users">Export Users</a></li>
|
||||||
|
<li><a href="/admin/manage/rentry-stats">Download Rentry Stats</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Maintenance</h3>
|
<h3>Maintenance</h3>
|
||||||
<form id="maintenanceForm" action="/admin/manage/maintenance" method="post">
|
<form id="maintenanceForm" action="/admin/manage/maintenance" method="post">
|
||||||
|
|
|
@ -50,14 +50,15 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">IPs</th>
|
<th scope="row">IPs</th>
|
||||||
<td colspan="2">
|
<td colspan="2">
|
||||||
<a href="#" id="ip-list-toggle">Show all (<%- user.ip.length %>)</a>
|
<%- include("partials/shared_user_ip_list", { user }) %>
|
||||||
<ol id="ip-list" style="display: none; padding-left: 1em; margin: 0">
|
|
||||||
<% user.ip.forEach((ip) => { %>
|
|
||||||
<li><code><%- ip %></code></li>
|
|
||||||
<% }) %>
|
|
||||||
</ol>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<% if (user.type === "temporary") { %>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Expires At</th>
|
||||||
|
<td colspan="2"><%- user.expiresAt %></td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
@ -75,12 +76,6 @@
|
||||||
<p><a href="/admin/manage/list-users">Back to User List</a></p>
|
<p><a href="/admin/manage/list-users">Back to User List</a></p>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById("ip-list-toggle").addEventListener("click", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
document.getElementById("ip-list").style.display = "block";
|
|
||||||
document.getElementById("ip-list-toggle").style.display = "none";
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll("td.actions a[data-field]").forEach(function (a) {
|
document.querySelectorAll("td.actions a[data-field]").forEach(function (a) {
|
||||||
a.addEventListener("click", function (e) {
|
a.addEventListener("click", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
@ -21,44 +21,43 @@ type Config = {
|
||||||
/**
|
/**
|
||||||
* The proxy key to require for requests. Only applicable if the user
|
* The proxy key to require for requests. Only applicable if the user
|
||||||
* management mode is set to 'proxy_key', and required if so.
|
* management mode is set to 'proxy_key', and required if so.
|
||||||
**/
|
*/
|
||||||
proxyKey?: string;
|
proxyKey?: string;
|
||||||
/**
|
/**
|
||||||
* The admin key used to access the /admin API. Required if the user
|
* The admin key used to access the /admin API or UI. Required if the user
|
||||||
* management mode is set to 'user_token'.
|
* management mode is set to 'user_token'.
|
||||||
**/
|
*/
|
||||||
adminKey?: string;
|
adminKey?: string;
|
||||||
/**
|
/**
|
||||||
* Which user management mode to use.
|
* Which user management mode to use.
|
||||||
*
|
* - `none`: No user management. Proxy is open to all requests with basic
|
||||||
* `none`: No user management. Proxy is open to all requests with basic
|
* abuse protection.
|
||||||
* abuse protection.
|
* - `proxy_key`: A specific proxy key must be provided in the Authorization
|
||||||
*
|
* header to use the proxy.
|
||||||
* `proxy_key`: A specific proxy key must be provided in the Authorization
|
* - `user_token`: Users must be created via by admins and provide their
|
||||||
* header to use the proxy.
|
* personal access token in the Authorization header to use the proxy.
|
||||||
*
|
* Configure this function and add users via the admin API or UI.
|
||||||
* `user_token`: Users must be created via the /admin REST API and provide
|
|
||||||
* their personal access token in the Authorization header to use the proxy.
|
|
||||||
* Configure this function and add users via the /admin API.
|
|
||||||
*/
|
*/
|
||||||
gatekeeper: "none" | "proxy_key" | "user_token";
|
gatekeeper: "none" | "proxy_key" | "user_token";
|
||||||
/**
|
/**
|
||||||
* Persistence layer to use for user management.
|
* Persistence layer to use for user management.
|
||||||
*
|
* - `memory`: Users are stored in memory and are lost on restart (default)
|
||||||
* `memory`: Users are stored in memory and are lost on restart (default)
|
* - `firebase_rtdb`: Users are stored in a Firebase Realtime Database;
|
||||||
*
|
* requires `firebaseKey` and `firebaseRtdbUrl` to be set.
|
||||||
* `firebase_rtdb`: Users are stored in a Firebase Realtime Database; requires
|
*/
|
||||||
* `firebaseKey` and `firebaseRtdbUrl` to be set.
|
|
||||||
**/
|
|
||||||
gatekeeperStore: "memory" | "firebase_rtdb";
|
gatekeeperStore: "memory" | "firebase_rtdb";
|
||||||
/** URL of the Firebase Realtime Database if using the Firebase RTDB store. */
|
/** URL of the Firebase Realtime Database if using the Firebase RTDB store. */
|
||||||
firebaseRtdbUrl?: string;
|
firebaseRtdbUrl?: string;
|
||||||
/** Base64-encoded Firebase service account key if using the Firebase RTDB store. */
|
/**
|
||||||
|
* Base64-encoded Firebase service account key if using the Firebase RTDB
|
||||||
|
* store. Note that you should encode the *entire* JSON key file, not just the
|
||||||
|
* `private_key` field inside it.
|
||||||
|
*/
|
||||||
firebaseKey?: string;
|
firebaseKey?: string;
|
||||||
/**
|
/**
|
||||||
* Maximum number of IPs per user, after which their token is disabled.
|
* Maximum number of IPs per user, after which their token is disabled.
|
||||||
* Users with the manually-assigned `special` role are exempt from this limit.
|
* Users with the manually-assigned `special` role are exempt from this limit.
|
||||||
* By default, this is 0, meaning that users are not IP-limited.
|
* - Defaults to 0, which means that users are not IP-limited.
|
||||||
*/
|
*/
|
||||||
maxIpsPerUser: number;
|
maxIpsPerUser: number;
|
||||||
/** Per-IP limit for requests per minute to OpenAI's completions endpoint. */
|
/** Per-IP limit for requests per minute to OpenAI's completions endpoint. */
|
||||||
|
@ -67,14 +66,14 @@ type Config = {
|
||||||
* For OpenAI, the maximum number of context tokens (prompt + max output) a
|
* For OpenAI, the maximum number of context tokens (prompt + max output) a
|
||||||
* user can request before their request is rejected.
|
* user can request before their request is rejected.
|
||||||
* Context limits can help prevent excessive spend.
|
* Context limits can help prevent excessive spend.
|
||||||
* Defaults to 0, which means no limit beyond OpenAI's stated maximums.
|
* - Defaults to 0, which means no limit beyond OpenAI's stated maximums.
|
||||||
*/
|
*/
|
||||||
maxContextTokensOpenAI: number;
|
maxContextTokensOpenAI: number;
|
||||||
/**
|
/**
|
||||||
* For Anthropic, the maximum number of context tokens a user can request.
|
* For Anthropic, the maximum number of context tokens a user can request.
|
||||||
* Claude context limits can prevent requests from tying up concurrency slots
|
* Claude context limits can prevent requests from tying up concurrency slots
|
||||||
* for too long, which can lengthen queue times for other users.
|
* for too long, which can lengthen queue times for other users.
|
||||||
* Defaults to 0, which means no limit beyond Anthropic's stated maximums.
|
* - Defaults to 0, which means no limit beyond Anthropic's stated maximums.
|
||||||
*/
|
*/
|
||||||
maxContextTokensAnthropic: number;
|
maxContextTokensAnthropic: number;
|
||||||
/** For OpenAI, the maximum number of sampled tokens a user can request. */
|
/** For OpenAI, the maximum number of sampled tokens a user can request. */
|
||||||
|
@ -85,8 +84,8 @@ type Config = {
|
||||||
rejectDisallowed?: boolean;
|
rejectDisallowed?: boolean;
|
||||||
/** Message to return when rejecting requests. */
|
/** Message to return when rejecting requests. */
|
||||||
rejectMessage?: string;
|
rejectMessage?: string;
|
||||||
/** Pino log level. */
|
/** Verbosity level of diagnostic logging. */
|
||||||
logLevel?: "debug" | "info" | "warn" | "error";
|
logLevel: "trace" | "debug" | "info" | "warn" | "error";
|
||||||
/** Whether prompts and responses should be logged to persistent storage. */
|
/** Whether prompts and responses should be logged to persistent storage. */
|
||||||
promptLogging?: boolean;
|
promptLogging?: boolean;
|
||||||
/** Which prompt logging backend to use. */
|
/** Which prompt logging backend to use. */
|
||||||
|
@ -96,57 +95,41 @@ type Config = {
|
||||||
/** Google Sheets spreadsheet ID. */
|
/** Google Sheets spreadsheet ID. */
|
||||||
googleSheetsSpreadsheetId?: string;
|
googleSheetsSpreadsheetId?: string;
|
||||||
/** Whether to periodically check keys for usage and validity. */
|
/** Whether to periodically check keys for usage and validity. */
|
||||||
checkKeys?: boolean;
|
checkKeys: boolean;
|
||||||
/** Whether to show token costs in the UI. */
|
/** Whether to publicly show total token costs on the info page. */
|
||||||
showTokenCosts?: boolean;
|
showTokenCosts: boolean;
|
||||||
/**
|
/**
|
||||||
* Comma-separated list of origins to block. Requests matching any of these
|
* Comma-separated list of origins to block. Requests matching any of these
|
||||||
* origins or referers will be rejected.
|
* origins or referers will be rejected.
|
||||||
* Partial matches are allowed, so `reddit` will match `www.reddit.com`.
|
* - Partial matches are allowed, so `reddit` will match `www.reddit.com`.
|
||||||
* Include only the hostname, not the protocol or path, e.g:
|
* - Include only the hostname, not the protocol or path, e.g:
|
||||||
* `reddit.com,9gag.com,gaiaonline.com`
|
* `reddit.com,9gag.com,gaiaonline.com`
|
||||||
*/
|
*/
|
||||||
blockedOrigins?: string;
|
blockedOrigins?: string;
|
||||||
/**
|
/** Message to return when rejecting requests from blocked origins. */
|
||||||
* Message to return when rejecting requests from blocked origins.
|
|
||||||
*/
|
|
||||||
blockMessage?: string;
|
blockMessage?: string;
|
||||||
/**
|
/** Desination URL to redirect blocked requests to, for non-JSON requests. */
|
||||||
* Desination URL to redirect blocked requests to, for non-JSON requests.
|
|
||||||
*/
|
|
||||||
blockRedirect?: string;
|
blockRedirect?: string;
|
||||||
/** Which model families to allow requests for. Applies only to OpenAI. */
|
/** Which model families to allow requests for. Applies only to OpenAI. */
|
||||||
allowedModelFamilies: ModelFamily[];
|
allowedModelFamilies: ModelFamily[];
|
||||||
/**
|
/**
|
||||||
* The number of (LLM) tokens a user can consume before requests are rejected.
|
* The number of (LLM) tokens a user can consume before requests are rejected.
|
||||||
* Limits include both prompt and response tokens. `special` users are exempt.
|
* Limits include both prompt and response tokens. `special` users are exempt.
|
||||||
* Defaults to 0, which means no limit.
|
* - Defaults to 0, which means no limit.
|
||||||
*
|
* - Changes are not automatically applied to existing users. Use the
|
||||||
* Note: Changes are not automatically applied to existing users. Use the
|
|
||||||
* admin API or UI to update existing users, or use the QUOTA_REFRESH_PERIOD
|
* admin API or UI to update existing users, or use the QUOTA_REFRESH_PERIOD
|
||||||
* setting to periodically set all users' quotas to these values.
|
* setting to periodically set all users' quotas to these values.
|
||||||
*/
|
*/
|
||||||
tokenQuota: {
|
tokenQuota: { [key in ModelFamily]: number };
|
||||||
/** Token allowance for GPT-3.5 Turbo models. */
|
|
||||||
turbo: number;
|
|
||||||
/** Token allowance for GPT-4 models. */
|
|
||||||
gpt4: number;
|
|
||||||
/** Token allowance for GPT-4 32k models. */
|
|
||||||
"gpt4-32k": number;
|
|
||||||
/** Token allowance for Claude models. */
|
|
||||||
claude: number;
|
|
||||||
};
|
|
||||||
/**
|
/**
|
||||||
* The period over which to enforce token quotas. Quotas will be fully reset
|
* The period over which to enforce token quotas. Quotas will be fully reset
|
||||||
* at the start of each period, server time. Unused quota does not roll over.
|
* at the start of each period, server time. Unused quota does not roll over.
|
||||||
* You can also provide a cron expression for a custom schedule.
|
* You can also provide a cron expression for a custom schedule. If not set,
|
||||||
* Defaults to no automatic quota refresh.
|
* quotas will never automatically refresh.
|
||||||
|
* - Defaults to unset, which means quotas will never automatically refresh.
|
||||||
*/
|
*/
|
||||||
quotaRefreshPeriod?: "hourly" | "daily" | string;
|
quotaRefreshPeriod?: "hourly" | "daily" | string;
|
||||||
/**
|
/** Whether to allow users to change their own nicknames via the UI. */
|
||||||
* Whether to allow users to change their nickname via the UI. Defaults to
|
|
||||||
* true.
|
|
||||||
**/
|
|
||||||
allowNicknameChanges: boolean;
|
allowNicknameChanges: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -9,3 +9,15 @@ export class UserInputError extends HttpError {
|
||||||
super(400, message);
|
super(400, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends HttpError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(403, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends HttpError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(404, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { RequestHandler } from "express";
|
import { RequestHandler } from "express";
|
||||||
import sanitize from "sanitize-html";
|
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import { getTokenCostUsd, prettyTokens } from "./stats";
|
import { getTokenCostUsd, prettyTokens } from "./stats";
|
||||||
import * as userStore from "./users/user-store";
|
import * as userStore from "./users/user-store";
|
||||||
|
@ -13,23 +12,17 @@ export const injectLocals: RequestHandler = (req, res, next) => {
|
||||||
res.locals.nextQuotaRefresh = userStore.getNextQuotaRefresh();
|
res.locals.nextQuotaRefresh = userStore.getNextQuotaRefresh();
|
||||||
res.locals.persistenceEnabled = config.gatekeeperStore !== "memory";
|
res.locals.persistenceEnabled = config.gatekeeperStore !== "memory";
|
||||||
res.locals.showTokenCosts = config.showTokenCosts;
|
res.locals.showTokenCosts = config.showTokenCosts;
|
||||||
|
res.locals.maxIps = config.maxIpsPerUser;
|
||||||
|
|
||||||
// flash message
|
// flash messages
|
||||||
if (req.query.flash) {
|
if (req.session.flash) {
|
||||||
const content = sanitize(String(req.query.flash))
|
res.locals.flash = req.session.flash;
|
||||||
.replace(/</g, "<")
|
delete req.session.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 {
|
} else {
|
||||||
res.locals.flash = null;
|
res.locals.flash = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// utils
|
// view helpers
|
||||||
res.locals.prettyTokens = prettyTokens;
|
res.locals.prettyTokens = prettyTokens;
|
||||||
res.locals.tokenCost = getTokenCostUsd;
|
res.locals.tokenCost = getTokenCostUsd;
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,10 @@ import { logger } from "../logger";
|
||||||
export type OpenAIModelFamily = "turbo" | "gpt4" | "gpt4-32k";
|
export type OpenAIModelFamily = "turbo" | "gpt4" | "gpt4-32k";
|
||||||
export type AnthropicModelFamily = "claude";
|
export type AnthropicModelFamily = "claude";
|
||||||
export type ModelFamily = OpenAIModelFamily | AnthropicModelFamily;
|
export type ModelFamily = OpenAIModelFamily | AnthropicModelFamily;
|
||||||
export type ModelFamilyMap = { [regex: string]: ModelFamily };
|
|
||||||
|
export const MODEL_FAMILIES = (<A extends readonly ModelFamily[]>(
|
||||||
|
arr: A & ([ModelFamily] extends [A[number]] ? unknown : never)
|
||||||
|
) => arr)(["turbo", "gpt4", "gpt4-32k", "claude"] as const);
|
||||||
|
|
||||||
export const OPENAI_MODEL_FAMILY_MAP: { [regex: string]: OpenAIModelFamily } = {
|
export const OPENAI_MODEL_FAMILY_MAP: { [regex: string]: OpenAIModelFamily } = {
|
||||||
"^gpt-4-32k-\\d{4}$": "gpt4-32k",
|
"^gpt-4-32k-\\d{4}$": "gpt4-32k",
|
||||||
|
@ -25,3 +28,11 @@ export function getOpenAIModelFamily(model: string): OpenAIModelFamily {
|
||||||
export function getClaudeModelFamily(_model: string): ModelFamily {
|
export function getClaudeModelFamily(_model: string): ModelFamily {
|
||||||
return "claude";
|
return "claude";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assertIsKnownModelFamily(
|
||||||
|
modelFamily: string
|
||||||
|
): asserts modelFamily is ModelFamily {
|
||||||
|
if (!MODEL_FAMILIES.includes(modelFamily as ModelFamily)) {
|
||||||
|
throw new Error(`Unknown model family: ${modelFamily}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import { config } from "../config";
|
|
||||||
import { ModelFamily } from "./models";
|
import { ModelFamily } from "./models";
|
||||||
|
|
||||||
// technically slightly underestimates, because completion tokens cost more
|
// technically slightly underestimates, because completion tokens cost more
|
||||||
// than prompt tokens but we don't track those separately right now
|
// than prompt tokens but we don't track those separately right now
|
||||||
export function getTokenCostUsd(model: ModelFamily, tokens: number) {
|
export function getTokenCostUsd(model: ModelFamily, tokens: number) {
|
||||||
if (!config.showTokenCosts) return 0;
|
|
||||||
|
|
||||||
let cost = 0;
|
let cost = 0;
|
||||||
switch (model) {
|
switch (model) {
|
||||||
case "gpt4-32k":
|
case "gpt4-32k":
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { ZodType, z } from "zod";
|
import { ZodType, z } from "zod";
|
||||||
import type { ModelFamily } from "../models";
|
import type { ModelFamily } from "../models";
|
||||||
|
import { makeOptionalPropsNullable } from "../utils";
|
||||||
|
|
||||||
export const tokenCountsSchema: ZodType<UserTokenCounts> = z
|
export const tokenCountsSchema: ZodType<UserTokenCounts> = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -15,44 +16,51 @@ export const tokenCountsSchema: ZodType<UserTokenCounts> = z
|
||||||
|
|
||||||
export const UserSchema = z
|
export const UserSchema = z
|
||||||
.object({
|
.object({
|
||||||
/** The user's personal access token. */
|
/** User's personal access token. */
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
/** The IP addresses the user has connected from. */
|
/** IP addresses the user has connected from. */
|
||||||
ip: z.array(z.string()),
|
ip: z.array(z.string()),
|
||||||
/** The user's nickname. */
|
/** User's nickname. */
|
||||||
nickname: z.string().max(80).nullish(),
|
nickname: z.string().max(80).optional(),
|
||||||
/**
|
/**
|
||||||
* The user's privilege level.
|
* The user's privilege level.
|
||||||
* - `normal`: Default role. Subject to usual rate limits and quotas.
|
* - `normal`: Default role. Subject to usual rate limits and quotas.
|
||||||
* - `special`: Special role. Higher quotas and exempt from
|
* - `special`: Special role. Higher quotas and exempt from
|
||||||
* auto-ban/lockout.
|
* auto-ban/lockout.
|
||||||
**/
|
**/
|
||||||
type: z.enum(["normal", "special"]),
|
type: z.enum(["normal", "special", "temporary"]),
|
||||||
/** The number of prompts the user has made. */
|
/** Number of prompts the user has made. */
|
||||||
promptCount: z.number(),
|
promptCount: z.number(),
|
||||||
/**
|
/**
|
||||||
* @deprecated Use `tokenCounts` instead.
|
* @deprecated Use `tokenCounts` instead.
|
||||||
* Never used; retained for backwards compatibility.
|
* Never used; retained for backwards compatibility.
|
||||||
*/
|
*/
|
||||||
tokenCount: z.any().optional(),
|
tokenCount: z.any().optional(),
|
||||||
/** The number of tokens the user has consumed, by model family. */
|
/** Number of tokens the user has consumed, by model family. */
|
||||||
tokenCounts: tokenCountsSchema,
|
tokenCounts: tokenCountsSchema,
|
||||||
/** The maximum number of tokens the user can consume, by model family. */
|
/** Maximum number of tokens the user can consume, by model family. */
|
||||||
tokenLimits: tokenCountsSchema,
|
tokenLimits: tokenCountsSchema,
|
||||||
/** The time at which the user was created. */
|
/** Time at which the user was created. */
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
/** The time at which the user last connected. */
|
/** Time at which the user last connected. */
|
||||||
lastUsedAt: z.number().nullish(),
|
lastUsedAt: z.number().optional(),
|
||||||
/** The time at which the user was disabled, if applicable. */
|
/** Time at which the user was disabled, if applicable. */
|
||||||
disabledAt: z.number().nullish(),
|
disabledAt: z.number().optional(),
|
||||||
/** The reason for which the user was disabled, if applicable. */
|
/** Reason for which the user was disabled, if applicable. */
|
||||||
disabledReason: z.string().nullish(),
|
disabledReason: z.string().optional(),
|
||||||
|
/** Time at which the user will expire and be disabled (for temp users). */
|
||||||
|
expiresAt: z.number().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export const UserPartialSchema = UserSchema.partial().extend({
|
/**
|
||||||
token: z.string(),
|
* Variant of `UserSchema` which allows for partial updates, and makes any
|
||||||
});
|
* optional properties on the base schema nullable. Null values are used to
|
||||||
|
* indicate that the property should be deleted from the user object.
|
||||||
|
*/
|
||||||
|
export const UserPartialSchema = makeOptionalPropsNullable(UserSchema)
|
||||||
|
.partial()
|
||||||
|
.extend({ token: z.string() });
|
||||||
|
|
||||||
// gpt4-32k was added after the initial release, so this tries to allow for
|
// 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
|
// data imported from older versions of the app which may be missing the
|
||||||
|
|
|
@ -22,6 +22,7 @@ const MAX_IPS_PER_USER = config.maxIpsPerUser;
|
||||||
const users: Map<string, User> = new Map();
|
const users: Map<string, User> = new Map();
|
||||||
const usersToFlush = new Set<string>();
|
const usersToFlush = new Set<string>();
|
||||||
let quotaRefreshJob: schedule.Job | null = null;
|
let quotaRefreshJob: schedule.Job | null = null;
|
||||||
|
let userCleanupJob: schedule.Job | null = null;
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
log.info({ store: config.gatekeeperStore }, "Initializing user store...");
|
log.info({ store: config.gatekeeperStore }, "Initializing user store...");
|
||||||
|
@ -29,16 +30,8 @@ export async function init() {
|
||||||
await initFirebase();
|
await initFirebase();
|
||||||
}
|
}
|
||||||
if (config.quotaRefreshPeriod) {
|
if (config.quotaRefreshPeriod) {
|
||||||
quotaRefreshJob = schedule.scheduleJob(getRefreshCrontab(), () => {
|
const crontab = getRefreshCrontab();
|
||||||
for (const user of users.values()) {
|
quotaRefreshJob = schedule.scheduleJob(crontab, refreshAllQuotas);
|
||||||
refreshQuota(user.token);
|
|
||||||
}
|
|
||||||
log.info(
|
|
||||||
{ users: users.size, nextRefresh: quotaRefreshJob!.nextInvocation() },
|
|
||||||
"Token quotas refreshed."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!quotaRefreshJob) {
|
if (!quotaRefreshJob) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Unable to schedule quota refresh. Is QUOTA_REFRESH_PERIOD set correctly?"
|
"Unable to schedule quota refresh. Is QUOTA_REFRESH_PERIOD set correctly?"
|
||||||
|
@ -49,26 +42,42 @@ export async function init() {
|
||||||
"Scheduled token quota refresh."
|
"Scheduled token quota refresh."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userCleanupJob = schedule.scheduleJob("* * * * *", cleanupExpiredTokens);
|
||||||
|
|
||||||
log.info("User store initialized.");
|
log.info("User store initialized.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNextQuotaRefresh() {
|
/**
|
||||||
if (!quotaRefreshJob) return "never (manual refresh only)";
|
* Creates a new user and returns their token. Optionally accepts parameters
|
||||||
return quotaRefreshJob.nextInvocation().getTime();
|
* for setting an expiry date and/or token limits for temporary users.
|
||||||
}
|
**/
|
||||||
|
export function createUser(createOptions?: {
|
||||||
/** Creates a new user and returns their token. */
|
type?: User["type"];
|
||||||
export function createUser() {
|
expiresAt?: number;
|
||||||
|
tokenLimits?: User["tokenLimits"];
|
||||||
|
}) {
|
||||||
const token = uuid();
|
const token = uuid();
|
||||||
users.set(token, {
|
const newUser: User = {
|
||||||
token,
|
token,
|
||||||
ip: [],
|
ip: [],
|
||||||
type: "normal",
|
type: "normal",
|
||||||
promptCount: 0,
|
promptCount: 0,
|
||||||
tokenCounts: { turbo: 0, gpt4: 0, "gpt4-32k": 0, claude: 0 },
|
tokenCounts: { turbo: 0, gpt4: 0, "gpt4-32k": 0, claude: 0 },
|
||||||
tokenLimits: { ...config.tokenQuota },
|
tokenLimits: createOptions?.tokenLimits ?? { ...config.tokenQuota },
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (createOptions?.type === "temporary") {
|
||||||
|
Object.assign(newUser, {
|
||||||
|
type: "temporary",
|
||||||
|
expiresAt: createOptions.expiresAt,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(newUser, { type: createOptions?.type ?? "normal" });
|
||||||
|
}
|
||||||
|
|
||||||
|
users.set(token, newUser);
|
||||||
usersToFlush.add(token);
|
usersToFlush.add(token);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
@ -114,6 +123,14 @@ export function upsertUser(user: UserUpdate) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Write firebase migration to backfill gpt4-32k token counts
|
||||||
|
if (updates.tokenCounts) {
|
||||||
|
updates.tokenCounts["gpt4-32k"] ??= 0;
|
||||||
|
}
|
||||||
|
if (updates.tokenLimits) {
|
||||||
|
updates.tokenLimits["gpt4-32k"] ??= 0;
|
||||||
|
}
|
||||||
|
|
||||||
users.set(user.token, Object.assign(existing, updates));
|
users.set(user.token, Object.assign(existing, updates));
|
||||||
usersToFlush.add(user.token);
|
usersToFlush.add(user.token);
|
||||||
|
|
||||||
|
@ -161,7 +178,7 @@ export function authenticate(token: string, ip: string) {
|
||||||
const ipLimit =
|
const ipLimit =
|
||||||
user.type === "special" || !MAX_IPS_PER_USER ? Infinity : MAX_IPS_PER_USER;
|
user.type === "special" || !MAX_IPS_PER_USER ? Infinity : MAX_IPS_PER_USER;
|
||||||
if (user.ip.length > ipLimit) {
|
if (user.ip.length > ipLimit) {
|
||||||
disableUser(token, "Too many IP addresses associated with this token.");
|
disableUser(token, "IP address limit exceeded.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -223,6 +240,48 @@ export function disableUser(token: string, reason?: string) {
|
||||||
usersToFlush.add(token);
|
usersToFlush.add(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getNextQuotaRefresh() {
|
||||||
|
if (!quotaRefreshJob) return "never (manual refresh only)";
|
||||||
|
return quotaRefreshJob.nextInvocation().getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up expired temporary tokens by disabling tokens past their access
|
||||||
|
* expiry date and permanently deleting tokens three days after their access
|
||||||
|
* expiry date.
|
||||||
|
*/
|
||||||
|
function cleanupExpiredTokens() {
|
||||||
|
const now = Date.now();
|
||||||
|
let disabled = 0;
|
||||||
|
let deleted = 0;
|
||||||
|
for (const user of users.values()) {
|
||||||
|
if (user.type !== "temporary") continue;
|
||||||
|
if (user.expiresAt && user.expiresAt < now && !user.disabledAt) {
|
||||||
|
disableUser(user.token, "Temporary token expired.");
|
||||||
|
disabled++;
|
||||||
|
}
|
||||||
|
if (user.disabledAt && user.disabledAt + 72 * 60 * 60 * 1000 < now) {
|
||||||
|
users.delete(user.token);
|
||||||
|
usersToFlush.add(user.token);
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.debug({ disabled, deleted }, "Expired tokens cleaned up.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAllQuotas() {
|
||||||
|
let count = 0;
|
||||||
|
for (const user of users.values()) {
|
||||||
|
if (user.type === "temporary") continue;
|
||||||
|
refreshQuota(user.token);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
log.info(
|
||||||
|
{ refreshed: count, nextRefresh: quotaRefreshJob!.nextInvocation() },
|
||||||
|
"Token quotas refreshed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Firebase persistence is pretend right now and just polls the in-memory
|
// TODO: Firebase persistence is pretend right now and just polls the in-memory
|
||||||
// store to sync it with Firebase when it changes. Will refactor to abstract
|
// store to sync it with Firebase when it changes. Will refactor to abstract
|
||||||
// persistence layer later so we can support multiple stores.
|
// persistence layer later so we can support multiple stores.
|
||||||
|
@ -253,10 +312,12 @@ async function flushUsers() {
|
||||||
const db = admin.database(app);
|
const db = admin.database(app);
|
||||||
const usersRef = db.ref("users");
|
const usersRef = db.ref("users");
|
||||||
const updates: Record<string, User> = {};
|
const updates: Record<string, User> = {};
|
||||||
|
const deletions = [];
|
||||||
|
|
||||||
for (const token of usersToFlush) {
|
for (const token of usersToFlush) {
|
||||||
const user = users.get(token);
|
const user = users.get(token);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
deletions.push(token);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
updates[token] = user;
|
updates[token] = user;
|
||||||
|
@ -264,13 +325,17 @@ async function flushUsers() {
|
||||||
|
|
||||||
usersToFlush.clear();
|
usersToFlush.clear();
|
||||||
|
|
||||||
const numUpdates = Object.keys(updates).length;
|
const numUpdates = Object.keys(updates).length + deletions.length;
|
||||||
if (numUpdates === 0) {
|
if (numUpdates === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await usersRef.update(updates);
|
await usersRef.update(updates);
|
||||||
log.info({ users: Object.keys(updates).length }, "Flushed users to Firebase");
|
await Promise.all(deletions.map((token) => usersRef.child(token).remove()));
|
||||||
|
log.info(
|
||||||
|
{ users: Object.keys(updates).length, deletions: deletions.length },
|
||||||
|
"Flushed changes to Firebase"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: use key-management/models.ts for family mapping
|
// TODO: use key-management/models.ts for family mapping
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Query } from "express-serve-static-core";
|
import { Query } from "express-serve-static-core";
|
||||||
import sanitize from "sanitize-html";
|
import sanitize from "sanitize-html";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export function parseSort(sort: Query["sort"]) {
|
export function parseSort(sort: Query["sort"]) {
|
||||||
if (!sort) return null;
|
if (!sort) return null;
|
||||||
|
@ -49,3 +50,28 @@ export function sanitizeAndTrim(
|
||||||
) {
|
) {
|
||||||
return sanitize((input ?? "").trim(), options);
|
return sanitize((input ?? "").trim(), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://github.com/colinhacks/zod/discussions/2050#discussioncomment-5018870
|
||||||
|
export function makeOptionalPropsNullable<Schema extends z.AnyZodObject>(
|
||||||
|
schema: Schema
|
||||||
|
) {
|
||||||
|
const entries = Object.entries(schema.shape) as [
|
||||||
|
keyof Schema["shape"],
|
||||||
|
z.ZodTypeAny
|
||||||
|
][];
|
||||||
|
const newProps = entries.reduce(
|
||||||
|
(acc, [key, value]) => {
|
||||||
|
acc[key] =
|
||||||
|
value instanceof z.ZodOptional ? value.unwrap().nullable() : value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as {
|
||||||
|
[key in keyof Schema["shape"]]: Schema["shape"][key] extends z.ZodOptional<
|
||||||
|
infer T
|
||||||
|
>
|
||||||
|
? z.ZodNullable<T>
|
||||||
|
: Schema["shape"][key];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return z.object(newProps);
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
<% if (flashData) {
|
||||||
|
let flashStyle = { title: "", style: "" };
|
||||||
|
switch (flashData.type) {
|
||||||
|
case "success":
|
||||||
|
flashStyle.title = "✅ Success:";
|
||||||
|
flashStyle.style = "color: green; background-color: #ddffee; padding: 1em";
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
flashStyle.title = "⚠️ Error:";
|
||||||
|
flashStyle.style = "color: red; background-color: #eedddd; padding: 1em";
|
||||||
|
break;
|
||||||
|
case "warning":
|
||||||
|
flashStyle.title = "⚠️ Alert:";
|
||||||
|
flashStyle.style = "color: darkorange; background-color: #ffeecc; padding: 1em";
|
||||||
|
break;
|
||||||
|
case "info":
|
||||||
|
flashStyle.title = "ℹ️ Notice:";
|
||||||
|
flashStyle.style = "color: blue; background-color: #ddeeff; padding: 1em";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
%>
|
||||||
|
<p style="<%= flashStyle.style %>">
|
||||||
|
<strong><%= flashStyle.title %></strong> <%= flashData.message %>
|
||||||
|
</p>
|
||||||
|
<% } %>
|
|
@ -63,15 +63,6 @@
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body style="font-family: sans-serif; background-color: #f0f0f0; padding: 1em;">
|
<body style="font-family: sans-serif; background-color: #f0f0f0; padding: 1em;">
|
||||||
<% if (flash && flash.type === "error") { %>
|
<%- include("partials/shared_flash", { flashData: flash }) %>
|
||||||
<p style="color: red; background-color: #eedddd; padding: 1em">
|
|
||||||
<strong>⚠️ Error:</strong> <%= flash.message %>
|
|
||||||
</p>
|
|
||||||
<% } %>
|
|
||||||
<% if (flash && flash.type === "success") { %>
|
|
||||||
<p style="color: green; background-color: #ddffee; padding: 1em">
|
|
||||||
<strong>✅ Success:</strong> <%= flash.message %>
|
|
||||||
</p>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,11 @@
|
||||||
<td><%- prettyTokens(user.tokenLimits[key]) %></td>
|
<td><%- prettyTokens(user.tokenLimits[key]) %></td>
|
||||||
<td><%- prettyTokens(user.tokenLimits[key] - user.tokenCounts[key]) %></td>
|
<td><%- prettyTokens(user.tokenLimits[key] - user.tokenCounts[key]) %></td>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
<% if (user.type === "temporary") { %>
|
||||||
|
<td>N/A</td>
|
||||||
|
<% } else { %>
|
||||||
<td><%- prettyTokens(quota[key]) %></td>
|
<td><%- prettyTokens(quota[key]) %></td>
|
||||||
|
<% } %>
|
||||||
</tr>
|
</tr>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
<a href="#" id="ip-list-toggle">Show all (<%- user.ip.length %>)</a>
|
||||||
|
<ol id="ip-list" style="display: none; padding-left: 1em; margin: 0">
|
||||||
|
<% user.ip.forEach((ip) => { %>
|
||||||
|
<li><code><%- ip %></code></li>
|
||||||
|
<% }) %>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById("ip-list-toggle").addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById("ip-list").style.display = "block";
|
||||||
|
document.getElementById("ip-list-toggle").style.display = "none";
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -31,6 +31,8 @@ declare global {
|
||||||
declare module "express-session" {
|
declare module "express-session" {
|
||||||
interface SessionData {
|
interface SessionData {
|
||||||
adminToken?: string;
|
adminToken?: string;
|
||||||
|
userToken?: string;
|
||||||
csrf?: string;
|
csrf?: string;
|
||||||
|
flash?: { type: string; message: string };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,58 +1,61 @@
|
||||||
import { Router } from "express";
|
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 * as userStore from "../../shared/users/user-store";
|
||||||
import { UserInputError } from "../../shared/errors";
|
import { ForbiddenError, UserInputError } from "../../shared/errors";
|
||||||
import { sanitizeAndTrim } from "../../shared/utils";
|
import { sanitizeAndTrim } from "../../shared/utils";
|
||||||
import { config } from "../../config";
|
import { config } from "../../config";
|
||||||
|
|
||||||
const router = Router();
|
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) => {
|
router.get("/", (_req, res) => {
|
||||||
res.redirect("/");
|
res.redirect("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/lookup", (req, res) => {
|
router.get("/lookup", (_req, res) => {
|
||||||
res.render("user_lookup", { user: null });
|
res.render("user_lookup", { user: res.locals.currentSelfServiceUser });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/lookup", (req, res) => {
|
router.post("/lookup", (req, res) => {
|
||||||
const token = req.body.token;
|
const token = req.body.token;
|
||||||
const user = userStore.getUser(token);
|
const user = userStore.getUser(token);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).render("user_lookup", {
|
req.session.flash = { type: "error", message: "Invalid user token." };
|
||||||
user: null,
|
return res.redirect("/user/lookup");
|
||||||
flash: { type: "error", message: "Invalid user token." },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
res.render("user_lookup", { user });
|
req.session.userToken = user.token;
|
||||||
|
return res.redirect("/user/lookup");
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/edit-nickname", (req, res) => {
|
router.post("/edit-nickname", (req, res) => {
|
||||||
if (!config.allowNicknameChanges)
|
const existing = res.locals.currentSelfServiceUser;
|
||||||
throw new UserInputError("Nickname changes are not allowed.");
|
|
||||||
|
|
||||||
const nicknameUpdateSchema = UserSchema.pick({ token: true, nickname: true })
|
if (!existing) {
|
||||||
.extend({
|
throw new ForbiddenError("Not logged in.");
|
||||||
nickname: UserSchema.shape.nickname.transform((v) => sanitizeAndTrim(v)),
|
} else if (!config.allowNicknameChanges || existing.disabledAt) {
|
||||||
})
|
throw new ForbiddenError("Nickname changes are not allowed.");
|
||||||
.strict();
|
}
|
||||||
|
|
||||||
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) {
|
if (!result.success) {
|
||||||
throw new UserInputError(result.error.message);
|
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;
|
const newNickname = result.data.nickname || null;
|
||||||
userStore.upsertUser({ ...existing, nickname: newNickname });
|
userStore.upsertUser({ token: existing.token, nickname: newNickname });
|
||||||
res.render("user_lookup", {
|
req.session.flash = { type: "success", message: "Nickname updated." };
|
||||||
user: { ...existing, nickname: newNickname },
|
return res.redirect("/user/lookup");
|
||||||
flash: { type: "success", message: "Nickname updated" },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { router as selfServiceRouter };
|
export { router as selfServiceRouter };
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%- include("partials/shared_header", { title: "User Token Lookup" }) %>
|
<%- include("partials/shared_header", { title: "User Token Lookup" }) %>
|
||||||
<h1>User Token Lookup</h1>
|
<h1>User Token Lookup</h1>
|
||||||
<p>Provide your user token to check your token usage and modify your details.</p>
|
<p>Provide your user token to check your usage and quota information.</p>
|
||||||
<form action="/user/lookup" method="post">
|
<form action="/user/lookup" method="post">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||||
<label for="token">User Token</label>
|
<label for="token">User Token</label>
|
||||||
|
@ -9,6 +9,17 @@
|
||||||
</form>
|
</form>
|
||||||
<% if (user) { %>
|
<% if (user) { %>
|
||||||
<hr />
|
<hr />
|
||||||
|
<% 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}` : ""),
|
||||||
|
} }) %>
|
||||||
|
<% } %>
|
||||||
<table class="striped">
|
<table class="striped">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -39,6 +50,16 @@
|
||||||
<th scope="row">Last Used At</th>
|
<th scope="row">Last Used At</th>
|
||||||
<td colspan="2"><%- user.lastUsedAt || "never" %></td>
|
<td colspan="2"><%- user.lastUsedAt || "never" %></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">IPs<%- maxIps ? ` (max ${maxIps})` : "" %> </th>
|
||||||
|
<td colspan="2"><%- include("partials/shared_user_ip_list", { user }) %></td>
|
||||||
|
</tr>
|
||||||
|
<% if (user.type === "temporary") { %>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Expires At</th>
|
||||||
|
<td colspan="2"><%- user.expiresAt %></td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
@ -47,7 +68,6 @@
|
||||||
|
|
||||||
<form id="edit-nickname-form" style="display: none" action="/user/edit-nickname" method="post">
|
<form id="edit-nickname-form" style="display: none" action="/user/edit-nickname" method="post">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||||
<input type="hidden" name="token" value="<%= user.token %>" />
|
|
||||||
<input type="hidden" name="nickname" value="<%= user.nickname %>" />
|
<input type="hidden" name="nickname" value="<%= user.nickname %>" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue