/** * 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 and email, * and answers their user. The email of an account from another instance is * that instance's: 400. A username changes only by `user:manage`, through * `PATCH /api/users/:key/username`. * - `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. 400 for an * account from another instance, which has no password here. * - `GET /api/me/playlists?contains=:dumpId` — signed in. Answers each of the * caller's playlists and whether it holds the dump. * - `GET /api/me/grants` — signed in. Answers the other instances the caller * let their account sign in to. * - `DELETE /api/me/grants/:host` — signed in. Withdraws that grant, which * ends the caller's sessions there. * * A change to the caller's email or password is told to those instances * (`notifyClients`). * * @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 { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { assertLocalUser, getUserById, updateUser, } from "../services/user-service.ts"; import { getSettings, updateSettings } from "../services/settings-service.ts"; import { getPlaylistMembershipsForDump } from "../services/playlist-service.ts"; import { broadcastUserUpdated } from "../services/ws-service.ts"; import { VALIDATION } from "../../shared/validation.ts"; import type { FederationGrant } from "../../shared/federation.ts"; import { listGrants, notifyClients, revokeGrant, } from "../services/identity-provider-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/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; if (body.email !== undefined) assertLocalUser(getUserById(userId)); // Only these fields: a stolen token alone must not change the role or the // password, which needs the current one. const updated = await updateUser(userId, { description: body.description, email: body.email, }); broadcastUserUpdated(toPublicUser(updated)); if (body.email !== undefined) await notifyClients(userId, { type: "update" }); 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); assertLocalUser(user); 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, }); await notifyClients(updated.id, { type: "revoke" }); 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; }); router.get("/grants", authMiddleware, (ctx: AuthContext) => { const responseBody: APIResponse = { success: true, data: listGrants(ctx.state.user.userId), }; ctx.response.body = responseBody; }); router.delete( "/grants/:host", authMiddleware, async (ctx: AuthContext<"/grants/:host">) => { await revokeGrant(ctx.state.user.userId, ctx.params.host); const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; }, ); export default router;