import { Router } from "@oak/oak"; import { APIErrorCode, APIException, type APIResponse, type AuthResponse, type AuthTokens, type Dump, fieldOf, type Invitee, isLoginUserRequest, isRegisterUserRequest, isRole, isUpdateUserRequest, type MediaTokenResponse, type PaginatedData, type Playlist, type PublicUser, type SelfUser, toPublicUser, toSelfUser, type User, type UserSuggestion, validateRegisterUserRequest, } from "../model/interfaces.ts"; import { createJWT, createMediaToken, verifyPassword } from "../lib/jwt.ts"; import { type RateLimit, takeToken } from "../lib/rate-limit.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 { claimInvite, releaseInvite } 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" }); // A burst of 10 attempts, then one every 30s. const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 }; // The session token, plus the media-only token that goes in file and // thumbnail URLs (see createMediaToken). async function issueTokens(user: User): Promise { return { token: await createJWT({ userId: user.id, username: user.username, role: user.role, }), mediaToken: await createMediaToken(user.id), }; } // Register a new user (requires a valid invite token) router.post("/register", async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isRegisterUserRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, validateRegisterUserRequest(body) ?? "Invalid request", ); } // Claim the invite up front (throws 404/409 if bad) and hand it back if the // account can't be created, so one invite can't open two accounts. const inviterId = claimInvite(body.inviteToken); let user; try { user = await createUser(body, inviterId); } catch (err) { releaseInvite(body.inviteToken); throw 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 responseBody: APIResponse = { success: true, data: { ...await issueTokens(user), user: toSelfUser(user) }, }; ctx.response.status = 201; ctx.response.body = responseBody; }); // Login router.post("/login", async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isLoginUserRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid request", ); } // Per account, so guessing one user's password is slow however many // connections it comes from. if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 429, "Too many login attempts — try again in a minute", ); } let user; try { user = getUserByUsername(body.username); } catch { // Same answer as a wrong password: don't confirm which usernames exist. throw new APIException( APIErrorCode.VALIDATION_ERROR, 401, "Invalid username or password", ); } const valid = await verifyPassword(body.password, user.passwordHash); if (!valid) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 401, "Invalid username or password", ); } const responseBody: APIResponse = { success: true, data: { ...await issueTokens(user), user: toSelfUser(user) }, }; ctx.response.body = responseBody; }); // Get current user profile router.get("/me", authMiddleware, (ctx: AuthContext) => { try { if (!ctx.state.user) { throw new APIException( APIErrorCode.UNAUTHORIZED, 401, "Not authenticated", ); } const responseBody: APIResponse = { success: true, data: toSelfUser(getUserById(ctx.state.user.userId)), }; ctx.response.body = responseBody; } catch (err) { console.error(err); throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Failed to fetch user profile", ); } }); // A media token for a session that predates them (see createMediaToken). router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => { const responseBody: APIResponse = { success: true, data: { mediaToken: await createMediaToken(ctx.state.user.userId) }, }; ctx.response.body = responseBody; }); // Request a password reset email (unauthenticated; always returns 200) router.post("/request-password-reset", async (ctx) => { const body: unknown = await ctx.request.body.json(); const email = fieldOf(body, "email"); const trimmed = typeof email === "string" ? email.trim() : ""; if (trimmed) { await requestPasswordReset(trimmed).catch((err) => console.error("[request-password-reset]", err) ); } const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; }); // Consume a reset token and set a new password (unauthenticated) router.post("/reset-password", async (ctx) => { const body: unknown = await ctx.request.body.json(); const token = fieldOf(body, "token"); const newPassword = fieldOf(body, "newPassword"); if (typeof token !== "string" || typeof newPassword !== "string") { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid request", ); } await resetPassword(token, newPassword); const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; }); // Change current user's password (requires current password verification) router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => { const body: unknown = await ctx.request.body.json(); const currentPassword = fieldOf(body, "currentPassword"); const newPassword = fieldOf(body, "newPassword"); 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", ); } // Changing the password revokes every token issued before it, this // session's included, so hand back fresh ones for the client to keep. const updated = await updateUser(ctx.state.user.userId, { password: newPassword, }); const responseBody: APIResponse = { success: true, data: await issueTokens(updated), }; ctx.response.body = responseBody; }); // Update current user profile (description, etc.) router.patch("/me", authMiddleware, async (ctx: AuthContext) => { const body: unknown = await ctx.request.body.json(); if (!isUpdateUserRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid request", ); } // Only the profile fields. The role is assigned by an admin, the password // changes through /me/change-password (which asks for the current one), and // the username is fixed — a token alone must not be able to change any of // them. const updated = await updateUser(ctx.state.user.userId, { description: body.description, email: body.email, }); const publicUser = toPublicUser(updated); broadcastUserUpdated(publicUser); const responseBody: APIResponse = { success: true, data: publicUser, }; ctx.response.body = responseBody; }); // 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: unknown = await ctx.request.body.json(); const role = fieldOf(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 publicUser = toPublicUser(updated); broadcastUserUpdated(publicUser); const responseBody: APIResponse = { success: true, data: publicUser, }; ctx.response.body = responseBody; }, ); // User search for @mention autocomplete router.get("/search", (ctx) => { const q = (ctx.request.url.searchParams.get("q") ?? "").trim(); const responseBody: APIResponse = { success: true, data: searchUsers(q, 8), }; ctx.response.body = responseBody; }); // Public user profile by internal ID (used when only userId is available, e.g. dump.userId) router.get("/by-id/:userId", (ctx) => { const responseBody: APIResponse = { success: true, data: toPublicUser(getUserById(ctx.params.userId)), }; ctx.response.body = responseBody; }); // 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); const responseBody: APIResponse> = { success: true, data: { items: items.map(toPublicUser), total, hasMore: page * limit < total, }, }; ctx.response.body = responseBody; }); // 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); const responseBody: APIResponse> = { success: true, data: { items, total, hasMore: page * limit < total }, }; ctx.response.body = responseBody; }); // 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, ); const responseBody: APIResponse> = { success: true, data: { items, total, hasMore: page * limit < total }, }; ctx.response.body = responseBody; }); // Invite tree for a user (public) router.get("/:username/invitees", (ctx) => { const user = getUserByUsername(ctx.params.username); const responseBody: APIResponse = { success: true, data: getInviteTree(user.id), }; ctx.response.body = responseBody; }); // Public user profile by username (no passwordHash) router.get("/:username", (ctx) => { const responseBody: APIResponse = { success: true, data: toPublicUser(getUserByUsername(ctx.params.username)), }; ctx.response.body = responseBody; }); // 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); const responseBody: APIResponse> = { success: true, data: { items, total, hasMore: page * limit < total }, }; ctx.response.body = responseBody; }); // 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, ); const responseBody: APIResponse> = { success: true, data: { items, total, hasMore: page * limit < total }, }; ctx.response.body = responseBody; }); export default router;