mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
167 lines
5.8 KiB
TypeScript
167 lines
5.8 KiB
TypeScript
import { Router } from "express";
|
|
import type { Logger } from "../../logger.js";
|
|
import type { UserStore } from "../../data/users.js";
|
|
import { UsernameTakenError } from "../../data/users.js";
|
|
import type { SessionStore } from "../../data/sessions.js";
|
|
import type { AuditStore } from "../../data/audit.js";
|
|
import { extractSessionToken } from "../auth/validateSession.js";
|
|
|
|
function isValidUsername(v: unknown): v is string {
|
|
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
|
|
}
|
|
|
|
function isValidPassword(v: unknown): v is string {
|
|
return typeof v === "string" && v.length >= 8 && v.length <= 200;
|
|
}
|
|
|
|
export function createUsersRouter(
|
|
users: UserStore,
|
|
sessions: SessionStore,
|
|
audit: AuditStore,
|
|
logger: Logger
|
|
): Router {
|
|
const router = Router();
|
|
|
|
router.get("/", (_req, res) => {
|
|
res.json({ users: users.listUsers() });
|
|
});
|
|
|
|
router.post("/", async (req, res) => {
|
|
const { username, password, role: roleInput } = req.body ?? {};
|
|
if (!isValidUsername(username) || !isValidPassword(password)) {
|
|
res.status(400).json({ error: "invalid username or password" });
|
|
return;
|
|
}
|
|
const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member";
|
|
try {
|
|
const u = await users.createUser(username, password, role);
|
|
try {
|
|
audit.record({
|
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
|
targetUserId: u.id, targetUsername: u.username,
|
|
action: "user.created",
|
|
});
|
|
} catch (auditErr) {
|
|
logger.warn({ err: auditErr, action: "user.created" }, "audit insert failed");
|
|
}
|
|
logger.info({ createdBy: req.user!.id, newUserId: u.id, username, role }, "User created");
|
|
res.status(201).json({ id: u.id, username: u.username, role: u.role });
|
|
} catch (err) {
|
|
if (err instanceof UsernameTakenError) {
|
|
res.status(409).json({ error: "username taken" });
|
|
return;
|
|
}
|
|
logger.error({ err }, "createUser failed");
|
|
res.status(500).json({ error: "internal" });
|
|
}
|
|
});
|
|
|
|
router.delete("/:id", (req, res) => {
|
|
const targetId = req.params.id;
|
|
// Snapshot target's username BEFORE deletion for audit
|
|
const target = users.findById(targetId);
|
|
if (!target) {
|
|
res.status(404).json({ error: "not found" });
|
|
return;
|
|
}
|
|
if (targetId === req.user!.id) {
|
|
res.status(400).json({ error: "cannot delete self" });
|
|
return;
|
|
}
|
|
const result = users.deleteUserIfNotLastAdmin(targetId);
|
|
if (result === "not_found") {
|
|
res.status(404).json({ error: "not found" });
|
|
return;
|
|
}
|
|
if (result === "would_orphan") {
|
|
res.status(400).json({ error: "cannot delete last admin" });
|
|
return;
|
|
}
|
|
// FK CASCADE removes sessions; explicit call is belt-and-suspenders
|
|
sessions.deleteAllForUser(targetId);
|
|
try {
|
|
audit.record({
|
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
|
targetUserId: target.id, targetUsername: target.username,
|
|
action: "user.deleted",
|
|
});
|
|
} catch (auditErr) {
|
|
logger.warn({ err: auditErr, action: "user.deleted" }, "audit insert failed");
|
|
}
|
|
logger.info({ deletedBy: req.user!.id, deletedUserId: targetId }, "User deleted");
|
|
res.status(204).end();
|
|
});
|
|
|
|
router.post("/:id/reset-password", async (req, res) => {
|
|
const { newPassword } = req.body ?? {};
|
|
if (!isValidPassword(newPassword)) {
|
|
res.status(400).json({ error: "invalid password" });
|
|
return;
|
|
}
|
|
const targetId = req.params.id;
|
|
const target = users.findById(targetId);
|
|
if (!target) {
|
|
res.status(404).json({ error: "not found" });
|
|
return;
|
|
}
|
|
await users.changePassword(targetId, newPassword);
|
|
// Invalidate all sessions for the target user (except current actor's if it's the same user)
|
|
const exceptToken = targetId === req.user!.id
|
|
? (extractSessionToken(req.headers.cookie) ?? undefined)
|
|
: undefined;
|
|
sessions.deleteAllForUser(targetId, exceptToken);
|
|
try {
|
|
audit.record({
|
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
|
targetUserId: target.id, targetUsername: target.username,
|
|
action: "user.password_reset",
|
|
});
|
|
} catch (auditErr) {
|
|
logger.warn({ err: auditErr, action: "user.password_reset" }, "audit insert failed");
|
|
}
|
|
logger.info({ resetBy: req.user!.id, targetUserId: targetId }, "Password reset");
|
|
res.status(204).end();
|
|
});
|
|
|
|
router.patch("/:id/role", (req, res) => {
|
|
const targetId = req.params.id;
|
|
const { role: newRole } = req.body ?? {};
|
|
if (newRole !== "admin" && newRole !== "member") {
|
|
res.status(400).json({ error: "invalid role" });
|
|
return;
|
|
}
|
|
// Snapshot the target's old role and username for audit (BEFORE the atomic update,
|
|
// so we record what actually changed; if the user is gone we'll skip audit).
|
|
const targetBefore = users.findById(targetId);
|
|
if (!targetBefore) {
|
|
res.status(404).json({ error: "not found" });
|
|
return;
|
|
}
|
|
const result = users.setRoleIfNotLastAdmin(targetId, newRole);
|
|
if (result === "not_found") {
|
|
res.status(404).json({ error: "not found" });
|
|
return;
|
|
}
|
|
if (result === "would_orphan") {
|
|
res.status(400).json({ error: "cannot demote last admin" });
|
|
return;
|
|
}
|
|
// Only audit when the role actually changed
|
|
if (targetBefore.role !== newRole) {
|
|
try {
|
|
audit.record({
|
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
|
targetUserId: targetBefore.id, targetUsername: targetBefore.username,
|
|
action: "user.role_changed",
|
|
});
|
|
} catch (auditErr) {
|
|
logger.warn({ err: auditErr, action: "user.role_changed" }, "audit insert failed");
|
|
}
|
|
logger.info({ actorId: req.user!.id, targetId, newRole }, "User role changed");
|
|
}
|
|
res.status(204).end();
|
|
});
|
|
|
|
return router;
|
|
}
|