/** * Serves the caller's own account: their user, settings, password and media * token, and which of their playlists hold a dump. What they follow, their * feed and their notifications are under `/api/me` too, served by * `routes/follows.ts` and `routes/notifications.ts`, and their avatar by * `routes/avatars.ts`. * * - `GET /api/me` — signed in. Answers the caller's own user. * - `PATCH /api/me` — signed in. Changes the caller's description, email and * username, and answers their user. A new username keeps the old one as an * alias, at most once in 30 days but for those with `user:manage`. * - `GET /api/me/media-token` — signed in. Answers a media token, for a * session that predates them. * - `GET /api/me/settings` — signed in. Answers the caller's settings. * - `PATCH /api/me/settings` — signed in. Changes some of the caller's * settings, and answers them all. * - `PUT /api/me/password` — signed in, with the current password. Sets a new * one, ending every other session, and answers fresh tokens. * - `GET /api/me/playlists?contains=:dumpId` — signed in. Answers each of the * caller's playlists and whether it holds the dump. * * @module */ import { Router } from "@oak/oak"; import { type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { AuthTokens, MediaTokenResponse, SelfUser, } from "../../shared/user.ts"; import type { PlaylistMembership } from "../../shared/playlist.ts"; import type { UserSettings } from "../../shared/settings.ts"; import { fieldOf, isUpdateUserRequest } from "../validators/user.ts"; import { isUpdateSettingsRequest } from "../validators/settings.ts"; import { toPublicUser, toSelfUser } from "../model/user.ts"; import { createMediaToken, issueTokens } from "../lib/jwt.ts"; import { verifyPassword } from "../lib/password.ts"; import { can } from "../lib/permissions.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { getUserById, renameUser, updateUser, } from "../services/user-service.ts"; import { getSettings, updateSettings } from "../services/settings-service.ts"; import { getPlaylistMembershipsForDump } from "../services/playlist-service.ts"; import { broadcastUserUpdated, updateClientUsername, } from "../services/ws-service.ts"; import { VALIDATION } from "../../shared/validation.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/me" }); router.get("/", authMiddleware, (ctx: AuthContext) => { const responseBody: APIResponse = { success: true, data: toSelfUser(getUserById(ctx.state.user.userId)), }; ctx.response.body = responseBody; }); router.patch("/", authMiddleware, async (ctx: AuthContext) => { const body: unknown = await ctx.request.body.json(); if (!isUpdateUserRequest(body)) { throw new APIException(400, "Invalid request"); } const userId = ctx.state.user.userId; // Only these fields: a stolen token alone must not change the role or the // password, which needs the current one. The rename first: it is the change // most likely refused, and a refusal should leave the rest as it was. if (body.username !== undefined) { const renamed = renameUser( userId, body.username, can(ctx.state.user, "user:manage"), ); updateClientUsername(userId, renamed.username); } const updated = await updateUser(userId, { description: body.description, email: body.email, }); broadcastUserUpdated(toPublicUser(updated)); const responseBody: APIResponse = { success: true, data: toSelfUser(updated), }; ctx.response.body = responseBody; }); router.get("/media-token", authMiddleware, async (ctx: AuthContext) => { const responseBody: APIResponse = { success: true, data: { mediaToken: await createMediaToken(ctx.state.user.userId) }, }; ctx.response.body = responseBody; }); router.get("/settings", authMiddleware, (ctx: AuthContext) => { const responseBody: APIResponse = { success: true, data: getSettings(ctx.state.user.userId), }; ctx.response.body = responseBody; }); router.patch("/settings", authMiddleware, async (ctx: AuthContext) => { const body: unknown = await ctx.request.body.json(); if (!isUpdateSettingsRequest(body)) { throw new APIException(400, "Invalid settings"); } const responseBody: APIResponse = { success: true, data: updateSettings(ctx.state.user.userId, body), }; ctx.response.body = responseBody; }); router.put("/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.get("/playlists", authMiddleware, (ctx: AuthContext) => { const dumpId = ctx.request.url.searchParams.get("contains"); if (!dumpId) throw new APIException(400, "contains is required"); const responseBody: APIResponse = { success: true, data: getPlaylistMembershipsForDump(dumpId, ctx.state.user.userId), }; ctx.response.body = responseBody; }); export default router;