/** * Serves accounts (sign-up, sign-in, tokens, passwords) and user profiles, * with what each user posted, upvoted, follows and invited. A profile sent to * anyone but its user goes through `toPublicUser`, which drops the email. * * - `POST /api/users/register` — public, with a valid invite. Creates an * account, sends the welcome email, and answers the user and their tokens. * - `POST /api/users/login` — public. Answers the user and their tokens; * rate-limited per account. * - `GET /api/users/me` — signed in. Answers the caller's own user. * - `GET /api/users/me/media-token` — signed in. Answers a media token, for a * session that predates them. * - `POST /api/users/request-password-reset` — public. Emails a reset link to * the account with that address, if any; always answers 200. * - `POST /api/users/reset-password` — public, with a reset token. Sets a new * password. * - `POST /api/users/me/change-password` — signed in, with the current * password. Sets a new one, ending every other session, and answers fresh * tokens. * - `PATCH /api/users/me` — signed in. Updates the caller's profile fields: * description and email. * - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another * user's role. * - `GET /api/users/search?q=` — public. Answers up to 8 users whose name * starts with `q`, for @mention suggestions. * - `GET /api/users/by-id/:userId` — public. Answers a user's profile by id. * - `GET /api/users/:username/followed-users?page=&limit=` — public. Answers a * page of the users they follow. * - `GET /api/users/:username/followed-playlists?page=&limit=` — public. * Answers a page of the public playlists they follow. * - `GET /api/users/:username/playlists?page=&limit=` — optional sign-in. * Answers a page of their playlists, the private ones only to themselves. * - `GET /api/users/:username/invitees` — public. Answers the tree of users * they invited. * - `GET /api/users/:username` — public. Answers a user's profile. * - `GET /api/users/:username/dumps?page=&limit=` — optional sign-in. Answers a * page of their dumps, the private ones only to themselves. * - `GET /api/users/:username/votes?page=&limit=` — optional sign-in. Answers a * page of the dumps they upvoted, private ones only to themselves. * * @module */ import { Router } from "@oak/oak"; import { 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 } from "../lib/jwt.ts"; import { hashPassword, verifyPassword } from "../lib/password.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"; // Hashed once, at startup, for the sign-ins of usernames that don't exist. const UNKNOWN_USER_HASH: Promise = hashPassword(crypto.randomUUID()); /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * An `APIException` thrown by a handler becomes the JSON error response there. */ const router: Router = new Router({ prefix: "/api/users" }); /** Allows a burst of 10 login attempts per account, then one every 30s. */ const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 }; /** * Returns a new session token and media token for `user`, as sign-up, sign-in * and a password change answer them. */ async function issueTokens(user: User): Promise { return { token: await createJWT({ userId: user.id, username: user.username, role: user.role, }), mediaToken: await createMediaToken(user.id), }; } router.post("/register", async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isRegisterUserRequest(body)) { throw new APIException( 400, validateRegisterUserRequest(body) ?? "Invalid request", ); } // Claimed before the async account creation, so that one invite can't open // two accounts, and handed back if the creation fails. const inviterId = claimInvite(body.inviteToken); let user; try { user = await createUser(body, inviterId); } catch (err) { releaseInvite(body.inviteToken); throw err; } // Not awaited: a slow or failing SMTP server mustn't hold up the sign-up. 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; }); router.post("/login", async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isLoginUserRequest(body)) { throw new APIException(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( 429, "Too many login attempts — try again in a minute", ); } let user; try { user = getUserByUsername(body.username); } catch { user = undefined; } // An unknown username gets the same answer as a wrong password, after the // same work, so neither the message nor the timing tells which exist. const valid = await verifyPassword( body.password, user?.passwordHash ?? await UNKNOWN_USER_HASH, ); if (!user || !valid) { throw new APIException(401, "Invalid username or password"); } const responseBody: APIResponse = { success: true, data: { ...await issueTokens(user), user: toSelfUser(user) }, }; ctx.response.body = responseBody; }); router.get("/me", authMiddleware, (ctx: AuthContext) => { const responseBody: APIResponse = { success: true, data: toSelfUser(getUserById(ctx.state.user.userId)), }; ctx.response.body = responseBody; }); 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; }); // Answers the same whatever happens, so it can't tell which addresses have an // account. 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; }); 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(400, "Invalid request"); } await resetPassword(token, newPassword); const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; }); 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(400, "Invalid request"); } if ( newPassword.length < VALIDATION.PASSWORD_MIN || newPassword.length > VALIDATION.PASSWORD_MAX ) { throw new APIException( 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(403, "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; }); router.patch("/me", authMiddleware, async (ctx: AuthContext) => { const body: unknown = await ctx.request.body.json(); if (!isUpdateUserRequest(body)) { throw new APIException(400, "Invalid request"); } // Only the profile fields: a stolen token alone must not change the role, // the password (which needs the current one) or the username (which is // fixed). 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; }); 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(400, "Invalid role"); } // So that an admin can't demote themselves by mistake and leave no admin. if (ctx.params.userId === ctx.state.user.userId) { throw new APIException(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; }, ); 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; }); router.get("/by-id/:userId", (ctx) => { const responseBody: APIResponse = { success: true, data: toPublicUser(getUserById(ctx.params.userId)), }; ctx.response.body = responseBody; }); 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; }); 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; }); 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; }); router.get("/:username/invitees", (ctx) => { const user = getUserByUsername(ctx.params.username); const responseBody: APIResponse = { success: true, data: getInviteTree(user.id), }; ctx.response.body = responseBody; }); router.get("/:username", (ctx) => { const responseBody: APIResponse = { success: true, data: toPublicUser(getUserByUsername(ctx.params.username)), }; ctx.response.body = responseBody; }); 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; }); 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;