v3: added a roles/permissions system, added user management tab on user profile
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
This commit is contained in:
@@ -2,6 +2,8 @@ import type { DatabaseSync } from "node:sqlite";
|
||||
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
|
||||
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
|
||||
import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts";
|
||||
import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts";
|
||||
import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@@ -15,6 +17,8 @@ const MIGRATIONS: Migration[] = [
|
||||
{ name: "0001_comment_likes", up: up0001CommentLikes },
|
||||
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
|
||||
{ name: "0003_dump_backlinks", up: up0003DumpBacklinks },
|
||||
{ name: "0004_user_roles", up: up0004UserRoles },
|
||||
{ name: "0005_drop_is_admin", up: up0005DropIsAdmin },
|
||||
];
|
||||
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
|
||||
22
api/db/migrations/0004_user_roles.ts
Normal file
22
api/db/migrations/0004_user_roles.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with the `role` column) or an existing one that predates it.
|
||||
export function up(db: DatabaseSync): void {
|
||||
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!userCols.some((c) => c.name === "role")) {
|
||||
db.exec(
|
||||
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';`,
|
||||
);
|
||||
}
|
||||
|
||||
// Backfill existing admins to role 'admin'. Guarded because the legacy
|
||||
// is_admin column is absent on fresh databases (and dropped by 0005).
|
||||
if (userCols.some((c) => c.name === "is_admin")) {
|
||||
db.exec(
|
||||
`UPDATE users SET role = 'admin' WHERE is_admin = 1 AND role != 'admin';`,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
api/db/migrations/0005_drop_is_admin.ts
Normal file
13
api/db/migrations/0005_drop_is_admin.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Drops the legacy is_admin column now that `role` is the single source of
|
||||
// truth. Idempotent: skips when the column is already gone (fresh databases
|
||||
// created from schema.sql never had it). Must run after 0004 has backfilled.
|
||||
export function up(db: DatabaseSync): void {
|
||||
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (userCols.some((c) => c.name === "is_admin")) {
|
||||
db.exec(`ALTER TABLE users DROP COLUMN is_admin;`);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
avatar_mime TEXT,
|
||||
|
||||
27
api/lib/permissions.ts
Normal file
27
api/lib/permissions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { type AuthPayload, type Role } from "../model/interfaces.ts";
|
||||
|
||||
// Named capabilities checked at enforcement points. Extend this union as new
|
||||
// gated capabilities are added (e.g. "invite:manage").
|
||||
export type Permission = "dump:moderate" | "comment:moderate" | "user:manage";
|
||||
|
||||
const ALL_PERMISSIONS: readonly Permission[] = [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"user:manage",
|
||||
];
|
||||
|
||||
// Roles are fixed in code. `admin` is a superset of every permission (current
|
||||
// and future); `moderator` gets the content-moderation permissions only.
|
||||
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
||||
admin: ALL_PERMISSIONS,
|
||||
moderator: ["dump:moderate", "comment:moderate"],
|
||||
user: [],
|
||||
};
|
||||
|
||||
export function hasPermission(role: Role, permission: Permission): boolean {
|
||||
return ROLE_PERMISSIONS[role].includes(permission);
|
||||
}
|
||||
|
||||
export function can(payload: AuthPayload, permission: Permission): boolean {
|
||||
return hasPermission(payload.role, permission);
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Context, Next, State } from "@oak/oak";
|
||||
import {
|
||||
type Next,
|
||||
type RouteParams,
|
||||
type RouterContext,
|
||||
type State,
|
||||
} from "@oak/oak";
|
||||
import { verifyJWT } from "../lib/jwt.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
@@ -6,16 +11,24 @@ import {
|
||||
type AuthPayload,
|
||||
} from "../model/interfaces.ts";
|
||||
import { getUserById } from "../services/user-service.ts";
|
||||
|
||||
export interface AuthContext extends Context {
|
||||
state: AuthState;
|
||||
}
|
||||
import { can, type Permission } from "../lib/permissions.ts";
|
||||
|
||||
export interface AuthState extends State {
|
||||
user: AuthPayload;
|
||||
}
|
||||
|
||||
export async function authMiddleware(ctx: AuthContext, next: Next) {
|
||||
// A router context whose state is guaranteed to carry the authenticated user
|
||||
// (populated by authMiddleware). Generic over the route path so `ctx.params`
|
||||
// stays typed per-route — e.g. `AuthContext<"/:userId/role">`.
|
||||
export type AuthContext<
|
||||
R extends string = string,
|
||||
P extends RouteParams<R> = RouteParams<R>,
|
||||
> = RouterContext<R, P, AuthState>;
|
||||
|
||||
export async function authMiddleware<R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
): Promise<void> {
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
@@ -44,8 +57,11 @@ export async function authMiddleware(ctx: AuthContext, next: Next) {
|
||||
await next();
|
||||
}
|
||||
|
||||
export async function adminOnlyMiddleware(ctx: AuthContext, next: Next) {
|
||||
if (!ctx.state.user?.isAdmin) {
|
||||
export async function adminOnlyMiddleware<R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
): Promise<void> {
|
||||
if (ctx.state.user.role !== "admin") {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
@@ -55,3 +71,21 @@ export async function adminOnlyMiddleware(ctx: AuthContext, next: Next) {
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
// Gates a route on a named permission. Must run after authMiddleware. Generic
|
||||
// over the route path so it composes with path-typed route handlers.
|
||||
export function requirePermission(permission: Permission) {
|
||||
return async <R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
): Promise<void> => {
|
||||
if (!ctx.state.user || !can(ctx.state.user, permission)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
"Insufficient permissions",
|
||||
);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
||||
import {
|
||||
type Comment,
|
||||
Dump,
|
||||
isRole,
|
||||
type Notification,
|
||||
type NotificationType,
|
||||
type Playlist,
|
||||
type RichContent,
|
||||
type Role,
|
||||
type User,
|
||||
} from "./interfaces.ts";
|
||||
import {
|
||||
@@ -53,7 +55,7 @@ if (userCount.count === 0) {
|
||||
const hash = scryptSync("admin", salt, 64).toString("hex");
|
||||
const passwordHash = `${hash}.${salt}`;
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, is_admin, created_at, email) VALUES (?, 'admin', ?, 1, datetime('now'), 'admin@localhost')`,
|
||||
`INSERT INTO users (id, username, password_hash, role, created_at, email) VALUES (?, 'admin', ?, 'admin', datetime('now'), 'admin@localhost')`,
|
||||
).run(crypto.randomUUID(), passwordHash);
|
||||
console.log("Created default admin user (username: admin, password: admin)");
|
||||
}
|
||||
@@ -101,7 +103,7 @@ export interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
is_admin: number;
|
||||
role: Role;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
avatar_mime: string | null;
|
||||
@@ -153,7 +155,7 @@ export function isUserRow(obj: unknown): obj is UserRow {
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
"username" in obj && typeof obj.username === "string" &&
|
||||
"password_hash" in obj && typeof obj.password_hash === "string" &&
|
||||
"is_admin" in obj && typeof obj.is_admin === "number" &&
|
||||
"role" in obj && isRole(obj.role) &&
|
||||
"created_at" in obj && typeof obj.created_at === "string" &&
|
||||
"updated_at" in obj &&
|
||||
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
|
||||
@@ -221,7 +223,7 @@ export function userRowToApi(row: UserRow): User {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
isAdmin: Boolean(row.is_admin),
|
||||
role: row.role,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||
avatarMime: row.avatar_mime ?? undefined,
|
||||
@@ -238,7 +240,7 @@ export function userApiToRow(user: User): UserRow {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
password_hash: user.passwordHash,
|
||||
is_admin: user.isAdmin ? 1 : 0,
|
||||
role: user.role,
|
||||
created_at: user.createdAt.toISOString(),
|
||||
updated_at: user.updatedAt?.toISOString() ?? null,
|
||||
avatar_mime: user.avatarMime ?? null,
|
||||
|
||||
@@ -39,11 +39,19 @@ export interface Dump {
|
||||
* Authentication
|
||||
*/
|
||||
|
||||
export type Role = "user" | "moderator" | "admin";
|
||||
|
||||
export const ROLES: readonly Role[] = ["user", "moderator", "admin"];
|
||||
|
||||
export function isRole(value: unknown): value is Role {
|
||||
return typeof value === "string" && (ROLES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
isAdmin: boolean;
|
||||
role: Role;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
avatarMime?: string;
|
||||
@@ -67,7 +75,7 @@ export interface RegisterUserRequest {
|
||||
export interface UpdateUserRequest {
|
||||
username?: string;
|
||||
password?: string;
|
||||
isAdmin?: boolean;
|
||||
role?: Role;
|
||||
description?: string | null;
|
||||
email?: string;
|
||||
}
|
||||
@@ -128,7 +136,7 @@ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ("isAdmin" in o && typeof o.isAdmin !== "boolean") return false;
|
||||
if ("role" in o && !isRole(o.role)) return false;
|
||||
if (
|
||||
"description" in o && typeof o.description !== "string" &&
|
||||
o.description !== null
|
||||
@@ -152,7 +160,7 @@ export interface AuthResponse {
|
||||
export interface AuthPayload {
|
||||
userId: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
role: Role;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
@@ -161,7 +169,7 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
||||
typeof obj === "object" &&
|
||||
"userId" in obj && typeof obj.userId === "string" &&
|
||||
"username" in obj && typeof obj.username === "string" &&
|
||||
"isAdmin" in obj && typeof obj.isAdmin === "boolean" &&
|
||||
"role" in obj && isRole(obj.role) &&
|
||||
"exp" in obj && typeof obj.exp === "number";
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isUpdateCommentRequest,
|
||||
} from "../model/interfaces.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
import {
|
||||
@@ -66,7 +67,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||
// PATCH /api/comments/:commentId — auth required
|
||||
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||
const userId = ctx.state.user.userId;
|
||||
const isAdmin = ctx.state.user.isAdmin ?? false;
|
||||
const canModerate = can(ctx.state.user, "comment:moderate");
|
||||
const body = await ctx.request.body.json();
|
||||
if (!isUpdateCommentRequest(body)) {
|
||||
throw new APIException(
|
||||
@@ -79,7 +80,7 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||
ctx.params.commentId,
|
||||
body.body,
|
||||
userId,
|
||||
isAdmin,
|
||||
canModerate,
|
||||
);
|
||||
if (!isPrivate) broadcastCommentUpdated(comment);
|
||||
const responseBody: APIResponse<Comment> = { success: true, data: comment };
|
||||
@@ -110,11 +111,11 @@ router.get("/comments/:commentId/likers", async (ctx) => {
|
||||
// DELETE /api/comments/:commentId — auth required
|
||||
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
|
||||
const userId = ctx.state.user.userId;
|
||||
const isAdmin = ctx.state.user.isAdmin ?? false;
|
||||
const canModerate = can(ctx.state.user, "comment:moderate");
|
||||
const { dumpId, isPrivate } = deleteComment(
|
||||
ctx.params.commentId,
|
||||
userId,
|
||||
isAdmin,
|
||||
canModerate,
|
||||
);
|
||||
if (!isPrivate) broadcastCommentDeleted(ctx.params.commentId, dumpId);
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
import { validateImageUpload } from "../lib/upload.ts";
|
||||
@@ -126,10 +127,10 @@ router.get("/", async (ctx) => {
|
||||
|
||||
router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user?.userId;
|
||||
const userId = ctx.state.user.userId;
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !ctx.state.user?.isAdmin) {
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
@@ -162,7 +163,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
|
||||
router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user?.userId;
|
||||
const userId = ctx.state.user.userId;
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
if (!isUpdateDumpRequest(body)) {
|
||||
@@ -175,7 +176,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
|
||||
if (userId !== dump.userId && !ctx.state.user?.isAdmin) {
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
@@ -190,10 +191,10 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
|
||||
router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user?.userId;
|
||||
const userId = ctx.state.user.userId;
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !ctx.state.user?.isAdmin) {
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
@@ -232,10 +233,10 @@ router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
|
||||
|
||||
router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user?.userId;
|
||||
const userId = ctx.state.user.userId;
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !ctx.state.user?.isAdmin) {
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
@@ -250,10 +251,10 @@ router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
|
||||
|
||||
router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user?.userId;
|
||||
const userId = ctx.state.user.userId;
|
||||
const dump = getDump(dumpId, userId);
|
||||
|
||||
if (userId !== dump.userId && !ctx.state.user?.isAdmin) {
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
@@ -268,10 +269,10 @@ router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
|
||||
|
||||
router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user?.userId;
|
||||
const userId = ctx.state.user.userId;
|
||||
const dump = getDump(dumpId, userId);
|
||||
|
||||
if (userId !== dump.userId && !ctx.state.user?.isAdmin) {
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
403,
|
||||
|
||||
@@ -4,13 +4,18 @@ import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
isLoginUserRequest,
|
||||
isRole,
|
||||
isUpdateUserRequest,
|
||||
type PaginatedData,
|
||||
validateRegisterUserRequest,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
import { createJWT, verifyPassword } from "../lib/jwt.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import {
|
||||
type AuthContext,
|
||||
authMiddleware,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.ts";
|
||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
import {
|
||||
@@ -86,7 +91,7 @@ router.post("/register", async (ctx) => {
|
||||
const authToken = await createJWT({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
@@ -124,7 +129,7 @@ router.post("/login", async (ctx) => {
|
||||
const token = await createJWT({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
@@ -250,12 +255,42 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
"Invalid request",
|
||||
);
|
||||
}
|
||||
const updated = await updateUser(ctx.state.user.userId, body);
|
||||
// Self-service profile updates must never set the role. Role assignment is
|
||||
// done out-of-band (DB/seed); stripping it here prevents self-promotion.
|
||||
const { role: _role, ...safeBody } = body;
|
||||
const updated = await updateUser(ctx.state.user.userId, safeBody);
|
||||
const { passwordHash: _, email: _email, ...publicUser } = updated;
|
||||
broadcastUserUpdated(publicUser);
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
});
|
||||
|
||||
// Set another user's role. Requires the user:manage permission.
|
||||
router.patch(
|
||||
"/:userId/role",
|
||||
authMiddleware,
|
||||
requirePermission("user:manage"),
|
||||
async (ctx: AuthContext<"/:userId/role">) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const role: unknown = body?.role;
|
||||
if (!isRole(role)) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid role");
|
||||
}
|
||||
// Disallow changing your own role: prevents an admin from accidentally
|
||||
// locking themselves out, and self-promotion is meaningless anyway.
|
||||
if (ctx.params.userId === ctx.state.user.userId) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Cannot change your own role",
|
||||
);
|
||||
}
|
||||
const updated = await updateUser(ctx.params.userId, { role });
|
||||
const { passwordHash: _, email: _email, ...publicUser } = updated;
|
||||
broadcastUserUpdated(publicUser);
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
},
|
||||
);
|
||||
|
||||
// User search for @mention autocomplete
|
||||
router.get("/search", (ctx) => {
|
||||
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
|
||||
|
||||
@@ -81,7 +81,7 @@ export function getCommentLikers(
|
||||
).get(commentId) as { count: number } | undefined;
|
||||
|
||||
const rawRows = db.prepare(
|
||||
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at,
|
||||
`SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at,
|
||||
u.avatar_mime, u.description, u.invited_by, u.email,
|
||||
i.username as invited_by_username
|
||||
FROM users u
|
||||
|
||||
@@ -91,7 +91,7 @@ export function updateComment(
|
||||
commentId: string,
|
||||
body: string,
|
||||
requestingUserId: string,
|
||||
isAdmin: boolean,
|
||||
canModerate: boolean,
|
||||
): { comment: Comment; dumpId: string; isPrivate: boolean } {
|
||||
const row = db.prepare(
|
||||
`SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
|
||||
@@ -107,7 +107,7 @@ export function updateComment(
|
||||
"Cannot edit a deleted comment",
|
||||
);
|
||||
}
|
||||
if (existing.userId !== requestingUserId && !isAdmin) {
|
||||
if (existing.userId !== requestingUserId && !canModerate) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
@@ -143,7 +143,7 @@ export function updateComment(
|
||||
export function deleteComment(
|
||||
commentId: string,
|
||||
requestingUserId: string,
|
||||
isAdmin: boolean,
|
||||
canModerate: boolean,
|
||||
): { dumpId: string; isPrivate: boolean } {
|
||||
const row = db.prepare(
|
||||
`SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
|
||||
@@ -152,7 +152,7 @@ export function deleteComment(
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||
}
|
||||
const comment = fetchComment(commentId);
|
||||
if (comment.userId !== requestingUserId && !isAdmin) {
|
||||
if (comment.userId !== requestingUserId && !canModerate) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
|
||||
@@ -269,7 +269,7 @@ export function getFollowedUsersByUser(
|
||||
).get(userId) as { count: number } | undefined;
|
||||
|
||||
const rawRows = db.prepare(
|
||||
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at,
|
||||
`SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at,
|
||||
u.avatar_mime, u.description, u.invited_by, u.email,
|
||||
i.username as invited_by_username
|
||||
FROM users u
|
||||
|
||||
@@ -12,7 +12,7 @@ import { hashPassword } from "../lib/jwt.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
|
||||
const USER_SELECT =
|
||||
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email,
|
||||
`SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email,
|
||||
i.username as invited_by_username
|
||||
FROM users u
|
||||
LEFT JOIN users i ON i.id = u.invited_by`;
|
||||
@@ -39,13 +39,13 @@ export async function createUser(
|
||||
const passwordHash = await hashPassword(request.password);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, is_admin, created_at, invited_by, email)
|
||||
`INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
userId,
|
||||
request.username,
|
||||
passwordHash,
|
||||
0,
|
||||
"user",
|
||||
createdAt.toISOString(),
|
||||
inviterId,
|
||||
request.email,
|
||||
@@ -55,7 +55,7 @@ export async function createUser(
|
||||
id: userId,
|
||||
username: request.username,
|
||||
passwordHash,
|
||||
isAdmin: false,
|
||||
role: "user",
|
||||
createdAt,
|
||||
email: request.email,
|
||||
};
|
||||
@@ -155,11 +155,11 @@ export async function updateUser(
|
||||
const updatedUserRow = userApiToRow(updatedUser);
|
||||
|
||||
const userResult = db.prepare(
|
||||
`UPDATE users SET username = ?, password_hash = ?, is_admin = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`,
|
||||
`UPDATE users SET username = ?, password_hash = ?, role = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`,
|
||||
).run(
|
||||
updatedUserRow.username,
|
||||
updatedUserRow.password_hash,
|
||||
updatedUserRow.is_admin,
|
||||
updatedUserRow.role,
|
||||
updatedUserRow.description,
|
||||
updatedUserRow.email,
|
||||
now.toISOString(),
|
||||
|
||||
@@ -81,7 +81,7 @@ export function getDumpVoters(
|
||||
).get(dumpId) as { count: number } | undefined;
|
||||
|
||||
const rawRows = db.prepare(
|
||||
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at,
|
||||
`SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at,
|
||||
u.avatar_mime, u.description, u.invited_by, u.email,
|
||||
i.username as invited_by_username
|
||||
FROM users u
|
||||
|
||||
Reference in New Issue
Block a user