import { Router } from "@oak/oak"; import { APIErrorCode, type APIResponse, type PaginatedData, } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { type AuthResponse, type AuthTokens, type Invitee, isRole, type MediaTokenResponse, type PublicUser, type SelfUser, type UserSuggestion, } from "../../shared/user.ts"; import type { Dump } from "../../shared/dump.ts"; import { fieldOf, isLoginUserRequest, isRegisterUserRequest, isUpdateUserRequest, validateRegisterUserRequest, } from "../validators/user.ts"; import type { Playlist } from "../../shared/playlist.ts"; import { toPublicUser, toSelfUser, type User } from "../model/user.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, WELCOME_EMAIL_BODY } from "../config.ts"; import { VALIDATION } from "../../shared/validation.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, hasMore } = getFollowedUsersByUser(user.id, page, limit); const responseBody: APIResponse> = { success: true, data: { items: items.map(toPublicUser), hasMore, }, }; 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, hasMore } = getFollowedPlaylistsByUser(user.id, page, limit); const responseBody: APIResponse> = { success: true, data: { items, hasMore }, }; 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, hasMore } = listPlaylistsByUser( user.id, requestingUserId, page, limit, ); const responseBody: APIResponse> = { success: true, data: { items, hasMore }, }; 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, hasMore } = getDumpsByUser( user.id, page, limit, includePrivate, ); const responseBody: APIResponse> = { success: true, data: { items, hasMore }, }; 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, hasMore } = getVotedDumpsByUser( user.id, page, limit, requestingUserId, ); const responseBody: APIResponse> = { success: true, data: { items, hasMore }, }; ctx.response.body = responseBody; }); export default router;