Add CSRF protection to server-rendered views (khanon/oai-reverse-proxy!34)
This commit is contained in:
Generated
+9
@@ -14,6 +14,7 @@
|
||||
"cookie-parser": "^1.4.6",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cors": "^2.8.5",
|
||||
"csrf-csrf": "^2.3.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"ejs": "^3.1.9",
|
||||
"express": "^4.18.2",
|
||||
@@ -1676,6 +1677,14 @@
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/csrf-csrf": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-2.3.0.tgz",
|
||||
"integrity": "sha512-bUVpFobukoKdE2h0VNTgRmPelVnsGcnVavUOCYLFBnl6ss98bW7hPFWsQyuHMVdYK2NGRlQvthUEb4iX5nUb1w==",
|
||||
"dependencies": {
|
||||
"http-errors": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.29.3",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"cookie-parser": "^1.4.6",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cors": "^2.8.5",
|
||||
"csrf-csrf": "^2.3.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"ejs": "^3.1.9",
|
||||
"express": "^4.18.2",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import * as userStore from "../../proxy/auth/user-store";
|
||||
import { UserSchema, UserSchemaWithToken, parseSort, sortBy } from "../common";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Returns a list of all users, sorted by prompt count and then last used time.
|
||||
* GET /admin/users
|
||||
*/
|
||||
router.get("/", (req, res) => {
|
||||
const sort = parseSort(req.query.sort) || ["promptCount", "lastUsedAt"];
|
||||
const users = userStore.getUsers().sort(sortBy(sort, false));
|
||||
res.json({ users, count: users.length });
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the user with the given token.
|
||||
* GET /admin/users/:token
|
||||
*/
|
||||
router.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
|
||||
*/
|
||||
router.post("/", (req, res) => {
|
||||
const token = userStore.createUser();
|
||||
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
|
||||
*/
|
||||
router.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
|
||||
*/
|
||||
router.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
|
||||
*/
|
||||
router.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 { router as usersApiRouter };
|
||||
+37
-32
@@ -4,43 +4,47 @@ import { config } from "../config";
|
||||
const ADMIN_KEY = config.adminKey;
|
||||
const failedAttempts = new Map<string, number>();
|
||||
|
||||
export const auth: RequestHandler = (req, res, next) => {
|
||||
const bearerToken = req.headers.authorization?.slice("Bearer ".length);
|
||||
const cookieToken = req.cookies["admin-token"];
|
||||
const token = bearerToken ?? cookieToken;
|
||||
const attempts = failedAttempts.get(req.ip) ?? 0;
|
||||
type AuthorizeParams = { via: "cookie" | "header" };
|
||||
|
||||
if (!token) {
|
||||
return res.redirect("/admin/login");
|
||||
}
|
||||
export const authorize: ({ via }: AuthorizeParams) => RequestHandler =
|
||||
({ via }) =>
|
||||
(req, res, next) => {
|
||||
const bearerToken = req.headers.authorization?.slice("Bearer ".length);
|
||||
const cookieToken = req.cookies["admin-token"];
|
||||
const token = via === "cookie" ? cookieToken : bearerToken;
|
||||
const attempts = failedAttempts.get(req.ip) ?? 0;
|
||||
|
||||
if (!ADMIN_KEY) {
|
||||
req.log.warn(
|
||||
{ ip: req.ip },
|
||||
`Blocked admin request because no admin key is configured`
|
||||
);
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
if (attempts > 5) {
|
||||
req.log.warn(
|
||||
{ ip: req.ip, token: bearerToken },
|
||||
`Blocked admin request due to too many failed attempts`
|
||||
);
|
||||
return res.status(401).json({ error: "Too many attempts" });
|
||||
}
|
||||
if (!ADMIN_KEY) {
|
||||
req.log.warn(
|
||||
{ ip: req.ip },
|
||||
`Blocked admin request because no admin key is configured`
|
||||
);
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
if (token !== ADMIN_KEY) {
|
||||
req.log.warn(
|
||||
{ ip: req.ip, attempts, token },
|
||||
`Attempted admin request with invalid token`
|
||||
);
|
||||
return handleFailedLogin(req, res);
|
||||
}
|
||||
if (attempts > 5) {
|
||||
req.log.warn(
|
||||
{ ip: req.ip, token: bearerToken },
|
||||
`Blocked admin request due to too many failed attempts`
|
||||
);
|
||||
return res.status(401).json({ error: "Too many attempts" });
|
||||
}
|
||||
|
||||
req.log.info({ ip: req.ip }, `Admin request authorized`);
|
||||
next();
|
||||
};
|
||||
if (token !== ADMIN_KEY) {
|
||||
req.log.warn(
|
||||
{ ip: req.ip, attempts, token },
|
||||
`Attempted admin request with invalid token`
|
||||
);
|
||||
return handleFailedLogin(req, res);
|
||||
}
|
||||
|
||||
req.log.info({ ip: req.ip }, `Admin request authorized`);
|
||||
next();
|
||||
};
|
||||
|
||||
function handleFailedLogin(req: Request, res: Response) {
|
||||
const attempts = failedAttempts.get(req.ip) ?? 0;
|
||||
@@ -49,5 +53,6 @@ function handleFailedLogin(req: Request, res: Response) {
|
||||
if (req.accepts("json", "html") === "json") {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
res.clearCookie("admin-token");
|
||||
return res.redirect("/admin/login?failed=true");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod";
|
||||
import { Query } from "express-serve-static-core";
|
||||
|
||||
export function parseSort(sort: Query["sort"]) {
|
||||
if (!sort) return null;
|
||||
if (typeof sort === "string") return sort.split(",");
|
||||
if (Array.isArray(sort)) return sort.splice(3) as string[];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sortBy(fields: string[], asc = true) {
|
||||
return (a: any, b: any) => {
|
||||
for (const field of fields) {
|
||||
if (a[field] !== b[field]) {
|
||||
// always sort nulls to the end
|
||||
if (a[field] == null) return 1;
|
||||
if (b[field] == null) return -1;
|
||||
|
||||
const valA = Array.isArray(a[field]) ? a[field].length : a[field];
|
||||
const valB = Array.isArray(b[field]) ? b[field].length : b[field];
|
||||
|
||||
const result = valA < valB ? -1 : 1;
|
||||
return asc ? result : -result;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
export function paginate(set: unknown[], page: number, pageSize: number = 20) {
|
||||
const p = Math.max(1, Math.min(page, Math.ceil(set.length / pageSize)));
|
||||
return {
|
||||
page: p,
|
||||
items: set.slice((p - 1) * pageSize, p * pageSize),
|
||||
pageSize,
|
||||
pageCount: Math.ceil(set.length / pageSize),
|
||||
totalCount: set.length,
|
||||
nextPage: p * pageSize < set.length ? p + 1 : null,
|
||||
prevPage: p > 1 ? p - 1 : null,
|
||||
};
|
||||
}
|
||||
|
||||
export 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();
|
||||
|
||||
export const UserSchemaWithToken = UserSchema.extend({
|
||||
token: z.string(),
|
||||
}).strict();
|
||||
@@ -1,217 +0,0 @@
|
||||
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 };
|
||||
@@ -0,0 +1,24 @@
|
||||
import { doubleCsrf } from "csrf-csrf";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import express from "express";
|
||||
|
||||
const CSRF_SECRET = uuid();
|
||||
|
||||
const { generateToken, doubleCsrfProtection } = doubleCsrf({
|
||||
getSecret: () => CSRF_SECRET,
|
||||
cookieName: "csrf",
|
||||
cookieOptions: { sameSite: "strict", path: "/" },
|
||||
getTokenFromRequest: (req) => req.body["_csrf"] || req.query["_csrf"],
|
||||
});
|
||||
|
||||
const injectCsrfToken: express.RequestHandler = (req, res, next) => {
|
||||
res.locals.csrfToken = generateToken(res, req);
|
||||
// force generation of new token on back button
|
||||
// TODO: implement session-based CSRF tokens
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.setHeader("Pragma", "no-cache");
|
||||
res.setHeader("Expires", "0");
|
||||
next();
|
||||
};
|
||||
|
||||
export { injectCsrfToken, doubleCsrfProtection as checkCsrfToken };
|
||||
@@ -19,4 +19,11 @@ loginRouter.get("/logout", (req, res) => {
|
||||
res.redirect("/admin/login");
|
||||
});
|
||||
|
||||
loginRouter.get("/", (req, res) => {
|
||||
if (req.cookies["admin-token"]) {
|
||||
return res.redirect("/admin/manage");
|
||||
}
|
||||
res.redirect("/admin/login");
|
||||
});
|
||||
|
||||
export { loginRouter };
|
||||
+14
-12
@@ -1,9 +1,10 @@
|
||||
import express, { Router } from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { config } from "../config";
|
||||
import { auth } from "./auth";
|
||||
import { loginRouter } from "./controllers/login";
|
||||
import { usersRouter } from "./controllers/users";
|
||||
import { authorize } from "./auth";
|
||||
import { injectCsrfToken, checkCsrfToken } from "./csrf";
|
||||
import { usersApiRouter as apiRouter } from "./api/users";
|
||||
import { usersUiRouter as uiRouter } from "./ui/users";
|
||||
import { loginRouter } from "./login";
|
||||
|
||||
const adminRouter = Router();
|
||||
|
||||
@@ -12,14 +13,15 @@ adminRouter.use(
|
||||
express.urlencoded({ extended: true, limit: "20mb" })
|
||||
);
|
||||
adminRouter.use(cookieParser());
|
||||
adminRouter.use(injectCsrfToken);
|
||||
|
||||
adminRouter.use("/", loginRouter);
|
||||
adminRouter.use(auth);
|
||||
adminRouter.use("/users", usersRouter);
|
||||
adminRouter.get("/", (_req, res) => {
|
||||
res.render("admin/index", {
|
||||
isPersistenceEnabled: config.gatekeeperStore !== "memory",
|
||||
});
|
||||
});
|
||||
adminRouter.use("/", checkCsrfToken, loginRouter);
|
||||
adminRouter.use("/users", authorize({ via: "header" }), apiRouter);
|
||||
adminRouter.use(
|
||||
"/manage",
|
||||
authorize({ via: "cookie" }),
|
||||
checkCsrfToken,
|
||||
uiRouter
|
||||
);
|
||||
|
||||
export { adminRouter };
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import { config } from "../../config";
|
||||
import * as userStore from "../../proxy/auth/user-store";
|
||||
import {
|
||||
UserSchemaWithToken,
|
||||
parseSort,
|
||||
sortBy,
|
||||
paginate,
|
||||
UserSchema,
|
||||
} from "../common";
|
||||
|
||||
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) => {
|
||||
userStore.createUser();
|
||||
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) {
|
||||
return res.status(404).send("User not found");
|
||||
}
|
||||
res.render("admin/view-user", { user });
|
||||
});
|
||||
|
||||
router.get("/list-users", (req, res) => {
|
||||
const sort = parseSort(req.query.sort) || ["promptCount", "lastUsedAt"];
|
||||
const requestedPageSize =
|
||||
Number(req.query.perPage) || Number(req.cookies.perPage) || 20;
|
||||
const perPage = Math.max(1, Math.min(1000, requestedPageSize));
|
||||
const users = userStore.getUsers().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) => {
|
||||
const imported = Number(req.query.imported) || 0;
|
||||
res.render("admin/import-users", { imported });
|
||||
});
|
||||
|
||||
router.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/manage/import-users?imported=${upserts.length}`);
|
||||
});
|
||||
|
||||
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", {
|
||||
isPersistenceEnabled: config.gatekeeperStore !== "memory",
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/edit-user/:token", (req, res) => {
|
||||
const result = UserSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return res.status(400).send(result.error);
|
||||
}
|
||||
userStore.upsertUser({ ...result.data, token: req.params.token });
|
||||
return res.sendStatus(204);
|
||||
});
|
||||
|
||||
router.post("/reactivate-user/:token", (req, res) => {
|
||||
const user = userStore.getUser(req.params.token);
|
||||
if (!user) {
|
||||
return res.status(404).send("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) {
|
||||
return res.status(404).send("User not found");
|
||||
}
|
||||
userStore.disableUser(req.params.token, req.body.reason);
|
||||
return res.sendStatus(204);
|
||||
});
|
||||
|
||||
|
||||
export { router as usersUiRouter };
|
||||
@@ -2,6 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="csrf-token" content="<%= csrfToken %>">
|
||||
<title><%= title %></title>
|
||||
<style>
|
||||
.pagination {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<div>
|
||||
<label for="pageSize">Page Size</label>
|
||||
<select id="pageSize" onchange="setPageSize(this.value)" style="margin-bottom: 1rem;">
|
||||
<option value="10" <% if (pageSize === 10) { %>selected<% } %>>10</option>
|
||||
<option value="20" <% if (pageSize === 20) { %>selected<% } %>>20</option>
|
||||
<option value="50" <% if (pageSize === 50) { %>selected<% } %>>50</option>
|
||||
<option value="100" <% if (pageSize === 100) { %>selected<% } %>>100</option>
|
||||
<option value="200" <% if (pageSize === 200) { %>selected<% } %>>200</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function getPageSize() {
|
||||
var match = window.location.search.match(/perPage=(\d+)/);
|
||||
if (match) return parseInt(match[1]); else return document.cookie.match(/perPage=(\d+)/)?.[1] ?? 10;
|
||||
}
|
||||
function setPageSize(size) {
|
||||
document.cookie = "perPage=" + size + "; path=/admin";
|
||||
window.location.reload();
|
||||
}
|
||||
document.getElementById("pageSize").value = getPageSize();
|
||||
</script>
|
||||
@@ -2,7 +2,8 @@
|
||||
<!--
|
||||
-->
|
||||
<h1>Create User Token</h1>
|
||||
<form action="/admin/users" method="post">
|
||||
<form action="/admin/manage/create-user" method="post">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||
<input type="submit" value="Create" />
|
||||
</form>
|
||||
<% if (newToken) { %>
|
||||
@@ -11,7 +12,7 @@
|
||||
<h3>Recent Tokens</h2>
|
||||
<ul>
|
||||
<% recentUsers.forEach(function(user) { %>
|
||||
<li><a href="/admin/users/<%= user.token %>"><%= user.token %></a></li>
|
||||
<li><a href="/admin/manage/view-user/<%= user.token %>"><%= user.token %></a></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<%- include("../_partials/admin-footer") %>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<script>
|
||||
function exportUsers() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", "/admin/users", true);
|
||||
xhr.open("GET", "/admin/manage/export-users.json", true);
|
||||
xhr.responseType = "blob";
|
||||
xhr.onload = function() {
|
||||
if (this.status === 200) {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
If a user with the same token already exists, the existing user will be
|
||||
updated with the new values.
|
||||
</p>
|
||||
<form action="/admin/users/import-users" method="post" enctype="multipart/form-data">
|
||||
<form action="/admin/manage/import-users?_csrf=<%= csrfToken %>" method="post" enctype="multipart/form-data">
|
||||
<input type="file" name="users" />
|
||||
<input type="submit" value="Import" />
|
||||
</form>
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
</p>
|
||||
<% } %>
|
||||
<ul>
|
||||
<li><a href="/admin/users">List Users</a></li>
|
||||
<li><a href="/admin/users/create-user">Create User</a></li>
|
||||
<li><a href="/admin/users/import-users">Import Users</a></li>
|
||||
<li><a href="/admin/users/export-users">Export Users</a></li>
|
||||
<li><a href="/admin/manage/list-users">List Users</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/export-users">Export Users</a></li>
|
||||
</ul>
|
||||
<%- include("../_partials/admin-footer") %>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<%- include("../_partials/admin-header", { title: "Users - OAI Reverse Proxy Admin" }) %>
|
||||
<h1>User Token List</h1>
|
||||
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||
|
||||
<% if (users.length === 0) { %>
|
||||
<p>No users found.</p>
|
||||
<% } else { %>
|
||||
@@ -8,18 +10,20 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token</th>
|
||||
<th <% if (sort.includes("ip")) { %>class="active"<% } %> ><a href="/admin/users?sort=ip">IPs</a></th>
|
||||
<th <% if (sort.includes("promptCount")) { %>class="active"<% } %> ><a href="/admin/users?sort=promptCount">Prompts</a></th>
|
||||
<th <% if (sort.includes("ip")) { %>class="active"<% } %> ><a href="/admin/manage/list-users?sort=ip">IPs</a></th>
|
||||
<th <% if (sort.includes("promptCount")) { %>class="active"<% } %> ><a href="/admin/manage/list-users?sort=promptCount">Prompts</a></th>
|
||||
<th>Type</th>
|
||||
<th <% if (sort.includes("createdAt")) { %>class="active"<% } %> ><a href="/admin/users?sort=createdAt">Created (UTC)</a></th>
|
||||
<th <% if (sort.includes("lastUsedAt")) { %>class="active"<% } %> ><a href="/admin/users?sort=lastUsedAt">Last Used (UTC)</a></th>
|
||||
<th <% if (sort.includes("createdAt")) { %>class="active"<% } %> ><a href="/admin/manage/list-users?sort=createdAt">Created (UTC)</a></th>
|
||||
<th <% if (sort.includes("lastUsedAt")) { %>class="active"<% } %> ><a href="/admin/manage/list-users?sort=lastUsedAt">Last Used (UTC)</a></th>
|
||||
<th colspan="2">Banned?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% users.forEach(function(user){ %>
|
||||
<tr>
|
||||
<td><%= user.token %></td>
|
||||
<td>
|
||||
<code><a href="/admin/manage/view-user/<%= user.token %>"><%= user.token %></a></code>
|
||||
</td>
|
||||
<td><%= user.ip.length %></td>
|
||||
<td><%= user.promptCount %></td>
|
||||
<td><%= user.type %></td>
|
||||
@@ -39,15 +43,16 @@
|
||||
|
||||
<ul class="pagination">
|
||||
<% if (page > 1) { %>
|
||||
<li><a href="/admin/users?sort=<%= sort %>&page=<%= page - 1 %>">«</a></li>
|
||||
<li><a href="/admin/manage/list-users?sort=<%= sort %>&page=<%= page - 1 %>">«</a></li>
|
||||
<% } %> <% for (var i = 1; i <= pageCount; i++) { %>
|
||||
<li <% if (i === page) { %>class="active"<% } %>><a href="/admin/users?sort=<%= sort %>&page=<%= i %>"><%= i %></a></li>
|
||||
<li <% if (i === page) { %>class="active"<% } %>><a href="/admin/manage/list-users?sort=<%= sort %>&page=<%= i %>"><%= i %></a></li>
|
||||
<% } %> <% if (page < pageCount) { %>
|
||||
<li><a href="/admin/users?sort=<%= sort %>&page=<%= page + 1 %>">»</a></li>
|
||||
<li><a href="/admin/manage/list-users?sort=<%= sort %>&page=<%= page + 1 %>">»</a></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
|
||||
<p>Showing <%= page * pageSize - pageSize + 1 %> to <%= users.length + page * pageSize - pageSize %> of <%= totalCount %> users.</p>
|
||||
<%- include("../_partials/pagination") %>
|
||||
<% } %>
|
||||
|
||||
<script>
|
||||
@@ -57,9 +62,14 @@
|
||||
var token = a.getAttribute("data-token");
|
||||
if (confirm("Are you sure you want to ban this user?")) {
|
||||
let reason = prompt("Reason for ban:");
|
||||
fetch("/admin/users/" + token + "?disabledReason=" + encodeURIComponent(reason), {
|
||||
method: "DELETE",
|
||||
}).then(() => window.location.reload());
|
||||
fetch(
|
||||
"/admin/manage/disable-user/" + token,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ reason, _csrf: document.querySelector("meta[name=csrf-token]").getAttribute("content") }),
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}).then(() => window.location.reload());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -70,11 +80,13 @@
|
||||
var token = a.getAttribute("data-token");
|
||||
if (confirm("Are you sure you want to unban this user?")) {
|
||||
fetch(
|
||||
"/admin/users/" + token,
|
||||
{ method: "PUT",
|
||||
body: JSON.stringify({ disabledReason: "", disabledAt: 0 }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
"/admin/manage/reactivate-user/" + token,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ _csrf: document.querySelector("meta[name=csrf-token]").getAttribute("content") }),
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
).then(() => window.location.reload());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<p style="color: red;">Please try again.</p>
|
||||
<% } %>
|
||||
<form action="/admin/login" method="post">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||
<label for="token">Admin Key</label>
|
||||
<input type="password" name="token" />
|
||||
<input type="submit" value="Login" />
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<%- include("../_partials/admin-header", { title: "View User - OAI Reverse Proxy Admin" }) %>
|
||||
<h1>View User</h1>
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col">Value</th>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">Token</th>
|
||||
<td><%- user.token %></td>
|
||||
<tr>
|
||||
<th scope="row">Type</th>
|
||||
<td><%- user.type %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Prompt Count</th>
|
||||
<td><%- user.promptCount %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Token Count</th>
|
||||
<td><%- user.tokenCount %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Created At</th>
|
||||
<td><%- user.createdAt %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Last Used At</th>
|
||||
<td><%- user.lastUsedAt || "never" %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Disabled At</th>
|
||||
<td><%- user.disabledAt %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Disabled Reason</th>
|
||||
<td><%- user.disabledReason %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">IPs</th>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<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>
|
||||
|
||||
<%- include("../_partials/admin-footer") %>
|
||||
Reference in New Issue
Block a user