/** * Serves sign-up and user profiles, with what each user posted, upvoted, made, * follows and invited, and the dumps and playlists under them. A user's `:key` * is their id or `@name`, a current or former name in any case * (`resolveUserKey`). A profile sent to anyone goes through `toPublicUser`, * which drops the email. The caller's own account is `routes/me.ts`'s. * * - `POST /api/users` — public, with a valid invite. Creates an account, sends * the welcome email, and answers the user and their tokens. * - `GET /api/users?q=` — public. Answers up to 8 users whose name starts * with `q`, for @mention suggestions. * - `GET /api/users/:key` — public. Answers a user's profile. * - `GET /api/users/:key/dumps?page=&limit=` — optional sign-in. Answers a * page of their dumps, the private ones only to themselves. * - `GET /api/users/:key/dumps/:slug` — optional sign-in. Answers their dump * whose slug is or was `slug`, with its current slug; a private one 404s * but for its poster. * - `GET /api/users/:key/upvoted?page=&limit=` — optional sign-in. Answers a * page of the dumps they upvoted, private ones only to themselves. * - `GET /api/users/:key/playlists?page=&limit=` — optional sign-in. Answers a * page of the playlists they made, the private ones only to themselves. * - `GET /api/users/:key/playlists/:slug` — optional sign-in. Answers their * playlist whose slug is or was `slug`, with its current slug and the dumps * the caller may see; a private one 404s but for its owner. * - `GET /api/users/:key/follow/users?page=&limit=` — public. Answers a page of * the users they follow. * - `GET /api/users/:key/follow/playlists?page=&limit=` — public. Answers a * page of the public playlists they follow. * - `GET /api/users/:key/invitees` — public. Answers the tree of users they * invited. * - `PATCH /api/users/:key/role` — permission `user:manage`. Sets another * user's role. * - `PATCH /api/users/:key/username` — permission `user:manage`. Renames a * user, the caller included. It is the only way a name changes. * * @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 Invitee, isRole, type PublicUser, type UserSuggestion, } from "../../shared/user.ts"; import type { Dump } from "../../shared/dump.ts"; import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts"; import { fieldOf, isRegisterUserRequest, validateRegisterUserRequest, } from "../validators/user.ts"; import { toPublicUser, toSelfUser } from "../model/user.ts"; import { issueTokens } 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, renameUser, resolveUserKey, searchUsers, sendWelcomeEmail, updateUser, } from "../services/user-service.ts"; import { claimInvite, releaseInvite } from "../services/invite-service.ts"; import { broadcastUserUpdated, updateClientUsername, } from "../services/ws-service.ts"; import { getDumpByPath, getDumpsByUser, getVotedDumpsByUser, } from "../services/dump-service.ts"; import { getPlaylist, getPlaylistByPath, listPlaylistsByUser, } from "../services/playlist-service.ts"; import { getFollowedPlaylistsByUser, getFollowedUsersByUser, } from "../services/follow-service.ts"; /** * 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" }); router.post("/", 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; } sendWelcomeEmail(user); const responseBody: APIResponse = { success: true, data: { ...await issueTokens(user), user: toSelfUser(user) }, }; ctx.response.status = 201; ctx.response.body = responseBody; }); router.get("/", (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("/:key", (ctx) => { const responseBody: APIResponse = { success: true, data: toPublicUser(resolveUserKey(ctx.params.key)), }; ctx.response.body = responseBody; }); router.get("/:key/dumps", async (ctx) => { const user = resolveUserKey(ctx.params.key); 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("/:key/dumps/:slug", async (ctx) => { const user = resolveUserKey(ctx.params.key); const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const responseBody: APIResponse = { success: true, data: getDumpByPath(user.id, ctx.params.slug, requestingUserId), }; ctx.response.body = responseBody; }); router.get("/:key/upvoted", async (ctx) => { const user = resolveUserKey(ctx.params.key); 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; }); router.get("/:key/playlists", async (ctx) => { const user = resolveUserKey(ctx.params.key); 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("/:key/playlists/:slug", async (ctx) => { const user = resolveUserKey(ctx.params.key); const requestingUserId = await parseOptionalAuth(ctx); const responseBody: APIResponse = { success: true, data: getPlaylist( getPlaylistByPath(user.id, ctx.params.slug), requestingUserId, ), }; ctx.response.body = responseBody; }); router.get("/:key/follow/users", (ctx) => { const user = resolveUserKey(ctx.params.key); 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("/:key/follow/playlists", (ctx) => { const user = resolveUserKey(ctx.params.key); 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("/:key/invitees", (ctx) => { const user = resolveUserKey(ctx.params.key); const responseBody: APIResponse = { success: true, data: getInviteTree(user.id), }; ctx.response.body = responseBody; }); router.patch( "/:key/role", authMiddleware, requirePermission("user:manage"), async (ctx: AuthContext<"/:key/role">) => { const body: unknown = await ctx.request.body.json(); const role = fieldOf(body, "role"); if (!isRole(role)) { throw new APIException(400, "Invalid role"); } const user = resolveUserKey(ctx.params.key); // So that an admin can't demote themselves by mistake and leave no admin. if (user.id === ctx.state.user.userId) { throw new APIException(400, "Cannot change your own role"); } const updated = await updateUser(user.id, { role }); const publicUser = toPublicUser(updated); broadcastUserUpdated(publicUser); const responseBody: APIResponse = { success: true, data: publicUser, }; ctx.response.body = responseBody; }, ); router.patch( "/:key/username", authMiddleware, requirePermission("user:manage"), async (ctx: AuthContext<"/:key/username">) => { const body: unknown = await ctx.request.body.json(); const username = fieldOf(body, "username"); if (typeof username !== "string") { throw new APIException(400, "Invalid request"); } const user = resolveUserKey(ctx.params.key); const publicUser = toPublicUser(renameUser(user.id, username)); updateClientUsername(publicUser.id, publicUser.username); broadcastUserUpdated(publicUser); const responseBody: APIResponse = { success: true, data: publicUser, }; ctx.response.body = responseBody; }, ); export default router;