Add CSRF protection to server-rendered views (khanon/oai-reverse-proxy!34)

This commit is contained in:
khanon
2023-08-09 23:11:26 +00:00
parent 6f4e581bf2
commit 268165e2be
19 changed files with 506 additions and 285 deletions
+9
View File
@@ -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",
+1
View File
@@ -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",
+95
View File
@@ -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
View File
@@ -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");
}
+58
View File
@@ -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();
-217
View File
@@ -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 };
+24
View File
@@ -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
View File
@@ -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 };
+135
View File
@@ -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 };
+1
View File
@@ -2,6 +2,7 @@
<html>
<head>
<meta charset="utf-8">
<meta name="csrf-token" content="<%= csrfToken %>">
<title><%= title %></title>
<style>
.pagination {
+23
View File
@@ -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>
+3 -2
View File
@@ -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") %>
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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>
+4 -4
View File
@@ -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") %>
+28 -16
View File
@@ -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 %>">&laquo;</a></li>
<li><a href="/admin/manage/list-users?sort=<%= sort %>&page=<%= page - 1 %>">&laquo;</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 %>">&raquo;</a></li>
<li><a href="/admin/manage/list-users?sort=<%= sort %>&page=<%= page + 1 %>">&raquo;</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());
}
});
+1
View File
@@ -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" />
+64
View File
@@ -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") %>