218 lines
6.0 KiB
TypeScript
218 lines
6.0 KiB
TypeScript
import { Router } from "express";
|
|
import { Query } from "express-serve-static-core";
|
|
import multer from "multer";
|
|
import { z } from "zod";
|
|
import * as userStore from "../../proxy/auth/user-store";
|
|
|
|
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);
|
|
}
|
|
},
|
|
});
|
|
|
|
const usersRouter = Router();
|
|
|
|
export const UserSchema = z
|
|
.object({
|
|
ip: z.array(z.string()).optional(),
|
|
type: z.enum(["normal", "special"]).optional(),
|
|
promptCount: z.number().optional(),
|
|
tokenCount: z.number().optional(),
|
|
createdAt: z.number().optional(),
|
|
lastUsedAt: z.number().optional(),
|
|
disabledAt: z.number().optional(),
|
|
disabledReason: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
const UserSchemaWithToken = UserSchema.extend({
|
|
token: z.string(),
|
|
}).strict();
|
|
|
|
function paginate(set: unknown[], page: number, pageSize: number = 20) {
|
|
return {
|
|
page,
|
|
items: set.slice((page - 1) * pageSize, page * pageSize),
|
|
pageSize,
|
|
pageCount: Math.ceil(set.length / pageSize),
|
|
totalCount: set.length,
|
|
nextPage: page * pageSize < set.length ? page + 1 : null,
|
|
prevPage: page > 1 ? page - 1 : null,
|
|
};
|
|
}
|
|
|
|
function parseSort(sort: Query["sort"]) {
|
|
if (!sort) return null;
|
|
if (typeof sort === "string") return sort.split(",");
|
|
if (Array.isArray(sort)) return sort.splice(3) as string[];
|
|
return null;
|
|
}
|
|
|
|
function sortBy(fields: string[], asc = true) {
|
|
return (a: any, b: any) => {
|
|
for (const field of fields) {
|
|
if (a[field] !== b[field]) {
|
|
// always sort nulls to the end
|
|
if (a[field] == null) return 1;
|
|
if (b[field] == null) return -1;
|
|
|
|
const valA = Array.isArray(a[field]) ? a[field].length : a[field];
|
|
const valB = Array.isArray(b[field]) ? b[field].length : b[field];
|
|
|
|
const result = valA < valB ? -1 : 1;
|
|
return asc ? result : -result;
|
|
}
|
|
}
|
|
return 0;
|
|
};
|
|
}
|
|
|
|
function isFromUi(req: any) {
|
|
return req.accepts("json", "html") === "html";
|
|
}
|
|
|
|
// UI-specific routes
|
|
usersRouter.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,
|
|
});
|
|
});
|
|
|
|
usersRouter.get("/import-users", (req, res) => {
|
|
const imported = Number(req.query.imported) || 0;
|
|
res.render("admin/import-users", { imported });
|
|
});
|
|
|
|
usersRouter.post("/import-users", upload.single("users"), (req, res) => {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: "No file uploaded" });
|
|
}
|
|
const data = JSON.parse(req.file.buffer.toString());
|
|
const result = z.array(UserSchemaWithToken).safeParse(data.users);
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
const upserts = result.data.map((user) => userStore.upsertUser(user));
|
|
res.redirect(`/admin/users/import-users?imported=${upserts.length}`);
|
|
});
|
|
|
|
usersRouter.get("/export-users", (req, res) => {
|
|
const users = userStore.getUsers();
|
|
res.render("admin/export-users", { users });
|
|
});
|
|
|
|
// API routes
|
|
/**
|
|
* Returns a list of all users, sorted by prompt count and then last used time.
|
|
* GET /admin/users
|
|
*/
|
|
usersRouter.get("/", (req, res) => {
|
|
const sort = parseSort(req.query.sort) || ["promptCount", "lastUsedAt"];
|
|
const users = userStore.getUsers().sort(sortBy(sort, false));
|
|
|
|
if (isFromUi(req)) {
|
|
const page = Number(req.query.page) || 1;
|
|
const { items, ...pagination } = paginate(users, page);
|
|
|
|
return res.render("admin/list-users", {
|
|
sort: sort.join(","),
|
|
users: items,
|
|
...pagination,
|
|
});
|
|
}
|
|
|
|
res.json({ users, count: users.length });
|
|
});
|
|
|
|
/**
|
|
* Returns the user with the given token.
|
|
* GET /admin/users/:token
|
|
*/
|
|
usersRouter.get("/:token", (req, res) => {
|
|
const user = userStore.getUser(req.params.token);
|
|
if (!user) {
|
|
return res.status(404).json({ error: "Not found" });
|
|
}
|
|
res.json(user);
|
|
});
|
|
|
|
/**
|
|
* Creates a new user.
|
|
* Returns the created user's token.
|
|
* POST /admin/users
|
|
*/
|
|
usersRouter.post("/", (req, res) => {
|
|
const token = userStore.createUser();
|
|
if (isFromUi(req)) {
|
|
return res.redirect(`/admin/users/create-user?created=true`);
|
|
}
|
|
res.json({ token });
|
|
});
|
|
|
|
/**
|
|
* Updates the user with the given token, creating them if they don't exist.
|
|
* Accepts a JSON body containing at least one field on the User type.
|
|
* Returns the upserted user.
|
|
* PUT /admin/users/:token
|
|
*/
|
|
usersRouter.put("/:token", (req, res) => {
|
|
const result = UserSchema.safeParse(req.body);
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
userStore.upsertUser({ ...result.data, token: req.params.token });
|
|
res.json(userStore.getUser(req.params.token));
|
|
});
|
|
|
|
/**
|
|
* Bulk-upserts users given a list of User updates.
|
|
* Accepts a JSON body with the field `users` containing an array of updates.
|
|
* Returns an object containing the upserted users and the number of upserts.
|
|
* PUT /admin/users
|
|
*/
|
|
usersRouter.put("/", (req, res) => {
|
|
const result = z.array(UserSchemaWithToken).safeParse(req.body.users);
|
|
if (!result.success) {
|
|
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,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Disables the user with the given token. Optionally accepts a `disabledReason`
|
|
* query parameter.
|
|
* Returns the disabled user.
|
|
* DELETE /admin/users/:token
|
|
*/
|
|
usersRouter.delete("/:token", (req, res) => {
|
|
const user = userStore.getUser(req.params.token);
|
|
const disabledReason = z
|
|
.string()
|
|
.optional()
|
|
.safeParse(req.query.disabledReason);
|
|
if (!disabledReason.success) {
|
|
return res.status(400).json({ error: disabledReason.error });
|
|
}
|
|
if (!user) {
|
|
return res.status(404).json({ error: "Not found" });
|
|
}
|
|
userStore.disableUser(req.params.token, disabledReason.data);
|
|
res.json(userStore.getUser(req.params.token));
|
|
});
|
|
|
|
export { usersRouter };
|