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

This commit is contained in:
khannurien
2026-06-28 06:21:23 +00:00
parent b567e390d9
commit 810faaf21a
22 changed files with 341 additions and 60 deletions

View File

@@ -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 {

View 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';`,
);
}
}

View 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;`);
}
}

View File

@@ -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
View 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);
}

View File

@@ -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();
};
}

View File

@@ -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,

View File

@@ -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";
}

View File

@@ -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 };

View File

@@ -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,

View File

@@ -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();

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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(),

View File

@@ -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