import { Router } from "express"; import multer from "multer"; import { z } from "zod"; import { config } from "../../config"; 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 { MODEL_FAMILIES } from "../../shared/models"; import { getTokenCostUsd, prettyTokens } from "../../shared/stats"; import { User, UserPartialSchema, UserSchema, UserTokenCounts, } from "../../shared/users/schema"; const router = Router(); const upload = multer({ storage: multer.memoryStorage(), fileFilter: (_req, file, cb) => { if (file.mimetype !== "application/json") { cb(new Error("Invalid file type")); } else { cb(null, true); } }, }); router.get("/create-user", (req, res) => { const recentUsers = userStore .getUsers() .sort(sortBy(["createdAt"], false)) .slice(0, 5); res.render("admin_create-user", { recentUsers, newToken: !!req.query.created, }); }); 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"); res.render("admin_view-user", { user }); }); router.get("/list-users", (req, res) => { 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 = getSumsForUser(user); return { ...user, ...sums }; }) .sort(sortBy(sort, false)); const page = Number(req.query.page) || 1; const { items, ...pagination } = paginate(users, page, perPage); return res.render("admin_list-users", { sort: sort.join(","), users: items, ...pagination, }); }); router.get("/import-users", (_req, res) => { res.render("admin_import-users"); }); router.post("/import-users", upload.single("users"), (req, res) => { if (!req.file) throw new HttpError(400, "No file uploaded"); const data = JSON.parse(req.file.buffer.toString()); const result = z.array(UserPartialSchema).safeParse(data.users); if (!result.success) throw new HttpError(400, result.error.toString()); const upserts = result.data.map((user) => userStore.upsertUser(user)); req.session.flash = { type: "success", message: `${upserts.length} users imported`, }; res.redirect("/admin/manage/import-users"); }); router.get("/export-users", (_req, res) => { res.render("admin_export-users"); }); router.get("/export-users.json", (_req, res) => { const users = userStore.getUsers(); res.setHeader("Content-Disposition", "attachment; filename=users.json"); res.setHeader("Content-Type", "application/json"); res.send(JSON.stringify({ users }, null, 2)); }); router.get("/", (_req, res) => { res.render("admin_index"); }); router.post("/edit-user/:token", (req, res) => { const result = UserPartialSchema.safeParse({ ...req.body, token: req.params.token, }); if (!result.success) { throw new HttpError( 400, result.error.issues.flatMap((issue) => issue.message).join(", ") ); } userStore.upsertUser(result.data); return res.status(200).json({ success: true }); }); router.post("/reactivate-user/:token", (req, res) => { const user = userStore.getUser(req.params.token); if (!user) throw new HttpError(404, "User not found"); userStore.upsertUser({ token: user.token, disabledAt: 0, disabledReason: "", }); return res.sendStatus(204); }); router.post("/disable-user/:token", (req, res) => { const user = userStore.getUser(req.params.token); if (!user) throw new HttpError(404, "User not found"); userStore.disableUser(req.params.token, req.body.reason); return res.sendStatus(204); }); 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(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 flash = { type: "", message: "" }; switch (action) { case "recheck": { keyPool.recheck("openai"); keyPool.recheck("anthropic"); const size = keyPool.list().length; 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; 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)); flash.type = "success"; flash.message = `All users' token usage records reset.`; break; } default: { throw new HttpError(400, "Invalid action"); } } 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 };