Files
gerbeur/api/routes/users.ts
khannurien 810faaf21a
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
v3: added a roles/permissions system, added user management tab on user profile
2026-06-28 06:21:23 +00:00

412 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
isLoginUserRequest,
isRole,
isUpdateUserRequest,
type PaginatedData,
validateRegisterUserRequest,
} from "../model/interfaces.ts";
import { createJWT, verifyPassword } from "../lib/jwt.ts";
import {
type AuthContext,
authMiddleware,
requirePermission,
} from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import {
createUser,
getInviteTree,
getUserById,
getUserByUsername,
searchUsers,
updateUser,
} from "../services/user-service.ts";
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
import {
requestPasswordReset,
resetPassword,
} from "../services/password-reset-service.ts";
import { isEmailEnabled, sendEmail } from "../services/email-service.ts";
import {
FROM_EMAIL,
OG_SITE_NAME,
VALIDATION,
WELCOME_EMAIL_BODY,
} from "../config.ts";
import { marked } from "marked";
import { broadcastUserUpdated } from "../services/ws-service.ts";
import {
getDumpsByUser,
getVotedDumpsByUser,
} from "../services/dump-service.ts";
import { listPlaylistsByUser } from "../services/playlist-service.ts";
import {
getFollowedPlaylistsByUser,
getFollowedUsersByUser,
} from "../services/follow-service.ts";
// Users router
const router = new Router({ prefix: "/api/users" });
// Register a new user (requires a valid invite token)
router.post("/register", async (ctx) => {
const body = await ctx.request.body.json();
const registerError = validateRegisterUserRequest(body);
if (registerError) {
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError);
}
// Validate invite — throws 404/409 if bad
const inviterId = validateInvite(body.inviteToken);
const user = await createUser(body, inviterId);
// Mark invite as used only after the user row is committed
try {
redeemInvite(body.inviteToken);
} catch (err) {
console.error("[register] redeemInvite failed (user created):", err);
}
// Send welcome email (fire-and-forget)
if (isEmailEnabled()) {
const emailMarkdown = WELCOME_EMAIL_BODY
.replaceAll("{{username}}", user.username)
.replaceAll("{{site_name}}", OG_SITE_NAME);
sendEmail({
from: FROM_EMAIL,
to: user.email,
subject: `Welcome to ${OG_SITE_NAME}`,
text: emailMarkdown,
html: await marked(emailMarkdown),
}).catch((err) => console.error("[register] welcome email failed:", err));
}
const authToken = await createJWT({
userId: user.id,
username: user.username,
role: user.role,
});
const { passwordHash: _, ...publicUser } = user;
ctx.response.status = 201;
ctx.response.body = {
success: true,
data: { token: authToken, user: publicUser },
};
});
// Login
router.post("/login", async (ctx) => {
try {
const body = await ctx.request.body.json();
if (!isLoginUserRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
const user = getUserByUsername(body.username);
const valid = await verifyPassword(body.password, user.passwordHash);
if (!valid) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
401,
"Invalid username or password",
);
}
const token = await createJWT({
userId: user.id,
username: user.username,
role: user.role,
});
const { passwordHash: _, ...publicUser } = user;
ctx.response.body = {
success: true,
data: {
token,
user: publicUser,
},
};
} catch (err) {
console.error(err);
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Failed to login");
}
});
// Get current user profile
router.get("/me", authMiddleware, (ctx: AuthContext) => {
try {
if (!ctx.state.user) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Not authenticated",
);
}
const { passwordHash: _, ...publicUser } = getUserById(
ctx.state.user.userId,
);
ctx.response.body = {
success: true,
data: publicUser,
};
} catch (err) {
console.error(err);
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Failed to fetch user profile",
);
}
});
// Request a password reset email (unauthenticated; always returns 200)
router.post("/request-password-reset", async (ctx) => {
const body = await ctx.request.body.json();
const email = typeof body?.email === "string" ? body.email.trim() : "";
if (email) {
await requestPasswordReset(email).catch((err) =>
console.error("[request-password-reset]", err)
);
}
ctx.response.body = { success: true };
});
// Consume a reset token and set a new password (unauthenticated)
router.post("/reset-password", async (ctx) => {
const body = await ctx.request.body.json();
const { token, newPassword } = (body ?? {}) as Record<string, unknown>;
if (typeof token !== "string" || typeof newPassword !== "string") {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
await resetPassword(token, newPassword);
ctx.response.body = { success: true };
});
// Change current user's password (requires current password verification)
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
const body = await ctx.request.body.json();
const { currentPassword, newPassword } = (body ?? {}) as Record<
string,
unknown
>;
if (typeof currentPassword !== "string" || typeof newPassword !== "string") {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
if (
newPassword.length < VALIDATION.PASSWORD_MIN ||
newPassword.length > VALIDATION.PASSWORD_MAX
) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Password must be ${VALIDATION.PASSWORD_MIN}${VALIDATION.PASSWORD_MAX} characters`,
);
}
const user = getUserById(ctx.state.user.userId);
const valid = await verifyPassword(currentPassword, user.passwordHash);
if (!valid) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
401,
"Current password is incorrect",
);
}
await updateUser(ctx.state.user.userId, { password: newPassword });
ctx.response.body = { success: true };
});
// Update current user profile (description, etc.)
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
const body = await ctx.request.body.json();
if (!isUpdateUserRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
// 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();
const results = searchUsers(q, 8);
ctx.response.body = { success: true, data: results };
});
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
router.get("/by-id/:userId", (ctx) => {
const user = getUserById(ctx.params.userId);
const { passwordHash: _, email: _email, ...publicUser } = user;
ctx.response.body = { success: true, data: publicUser };
});
// Followed users for a user (public)
router.get("/:username/followed-users", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedUsersByUser(user.id, page, limit);
ctx.response.body = {
success: true,
data: {
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
total,
hasMore: page * limit < total,
},
};
});
// Followed playlists for a user (public only)
router.get("/:username/followed-playlists", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit);
ctx.response.body = {
success: true,
data: {
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
};
});
// Playlists by user (optional auth: include private only if requester === owner)
router.get("/:username/playlists", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = listPlaylistsByUser(
user.id,
requestingUserId,
page,
limit,
);
ctx.response.body = {
success: true,
data: {
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
};
});
// Invite tree for a user (public)
router.get("/:username/invitees", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const tree = getInviteTree(user.id);
ctx.response.body = { success: true, data: tree };
});
// Public user profile by username (no passwordHash)
router.get("/:username", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const { passwordHash: _, email: _email, ...publicUser } = user;
ctx.response.body = { success: true, data: publicUser };
});
// Dumps posted by user (optional auth: owner sees their private dumps)
router.get("/:username/dumps", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const includePrivate = requestingUserId === user.id;
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
ctx.response.body = {
success: true,
data: {
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
};
});
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
router.get("/:username/votes", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getVotedDumpsByUser(
user.id,
page,
limit,
requestingUserId,
);
ctx.response.body = {
success: true,
data: {
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
};
});
export default router;