175 lines
6.3 KiB
TypeScript
175 lines
6.3 KiB
TypeScript
/**
|
||
* 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<SelfUser> = {
|
||
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<SelfUser> = {
|
||
success: true,
|
||
data: toSelfUser(updated),
|
||
};
|
||
ctx.response.body = responseBody;
|
||
});
|
||
|
||
router.get("/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||
const responseBody: APIResponse<MediaTokenResponse> = {
|
||
success: true,
|
||
data: { mediaToken: await createMediaToken(ctx.state.user.userId) },
|
||
};
|
||
ctx.response.body = responseBody;
|
||
});
|
||
|
||
router.get("/settings", authMiddleware, (ctx: AuthContext) => {
|
||
const responseBody: APIResponse<UserSettings> = {
|
||
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<UserSettings> = {
|
||
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<AuthTokens> = {
|
||
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<PlaylistMembership[]> = {
|
||
success: true,
|
||
data: getPlaylistMembershipsForDump(dumpId, ctx.state.user.userId),
|
||
};
|
||
ctx.response.body = responseBody;
|
||
});
|
||
|
||
export default router;
|