v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,49 +1,37 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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/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.
|
||||
* - `GET /api/users/me/settings` — signed in. Answers the caller's settings.
|
||||
* - `PATCH /api/users/me/settings` — signed in. Changes some of the caller's
|
||||
* settings, and answers them all.
|
||||
* - `POST /api/users/unsubscribe?token=` — public, with a digest's unsubscribe
|
||||
* token. Turns that digest off and answers its cadence. Mail clients post
|
||||
* here for a one-click unsubscribe (RFC 8058), and so does the client's
|
||||
* `/unsubscribe` page.
|
||||
* - `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
|
||||
* - `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/:username/votes?page=&limit=` — optional sign-in. Answers a
|
||||
* - `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, with no rate limit.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -54,32 +42,20 @@ 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 type { Playlist, PlaylistWithDumps } from "../../shared/playlist.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,
|
||||
verifyUnsubscribeToken,
|
||||
} from "../lib/jwt.ts";
|
||||
import { hashPassword, verifyPassword } from "../lib/password.ts";
|
||||
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
import { toPublicUser, toSelfUser } from "../model/user.ts";
|
||||
import { issueTokens } from "../lib/jwt.ts";
|
||||
import {
|
||||
type AuthContext,
|
||||
authMiddleware,
|
||||
@@ -90,64 +66,39 @@ import { parsePagination } from "../lib/pagination.ts";
|
||||
import {
|
||||
createUser,
|
||||
getInviteTree,
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
renameUser,
|
||||
resolveUserKey,
|
||||
searchUsers,
|
||||
sendWelcomeEmail,
|
||||
updateUser,
|
||||
} from "../services/user-service.ts";
|
||||
import { claimInvite, releaseInvite } from "../services/invite-service.ts";
|
||||
import {
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
} from "../services/password-reset-service.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
import {
|
||||
DIGEST_SETTING,
|
||||
type DigestCadence,
|
||||
type UserSettings,
|
||||
} from "../../shared/settings.ts";
|
||||
import { isUpdateSettingsRequest } from "../validators/settings.ts";
|
||||
import { getSettings, updateSettings } from "../services/settings-service.ts";
|
||||
import { broadcastUserUpdated } from "../services/ws-service.ts";
|
||||
broadcastUserUpdated,
|
||||
updateClientUsername,
|
||||
} from "../services/ws-service.ts";
|
||||
import {
|
||||
getDumpByPath,
|
||||
getDumpsByUser,
|
||||
getVotedDumpsByUser,
|
||||
} from "../services/dump-service.ts";
|
||||
import { listPlaylistsByUser } from "../services/playlist-service.ts";
|
||||
import {
|
||||
getPlaylist,
|
||||
getPlaylistByPath,
|
||||
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<string> = 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<AuthTokens> {
|
||||
return {
|
||||
token: await createJWT({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
}),
|
||||
mediaToken: await createMediaToken(user.id),
|
||||
};
|
||||
}
|
||||
|
||||
router.post("/register", async (ctx) => {
|
||||
router.post("/", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
if (!isRegisterUserRequest(body)) {
|
||||
@@ -179,208 +130,7 @@ router.post("/register", async (ctx) => {
|
||||
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<AuthResponse> = {
|
||||
success: true,
|
||||
data: { ...await issueTokens(user), user: toSelfUser(user) },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
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<MediaTokenResponse> = {
|
||||
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<null> = { 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<null> = { 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<AuthTokens> = {
|
||||
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<PublicUser> = {
|
||||
success: true,
|
||||
data: publicUser,
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/me/settings", authMiddleware, (ctx: AuthContext) => {
|
||||
const responseBody: APIResponse<UserSettings> = {
|
||||
success: true,
|
||||
data: getSettings(ctx.state.user.userId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.patch("/me/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;
|
||||
});
|
||||
|
||||
// The body is ignored: a mail client's one-click post sends a form saying
|
||||
// "List-Unsubscribe=One-Click", the client's page sends nothing.
|
||||
router.post("/unsubscribe", async (ctx) => {
|
||||
const token = ctx.request.url.searchParams.get("token") ?? "";
|
||||
const payload = await verifyUnsubscribeToken(token);
|
||||
if (!payload) throw new APIException(400, "Invalid unsubscribe link");
|
||||
// Throws a 404 once the account is gone.
|
||||
getUserById(payload.userId);
|
||||
updateSettings(payload.userId, { [DIGEST_SETTING[payload.cadence]]: false });
|
||||
const responseBody: APIResponse<{ cadence: DigestCadence }> = {
|
||||
success: true,
|
||||
data: { cadence: payload.cadence },
|
||||
};
|
||||
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<PublicUser> = {
|
||||
success: true,
|
||||
data: publicUser,
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/search", (ctx) => {
|
||||
router.get("/", (ctx) => {
|
||||
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
|
||||
const responseBody: APIResponse<UserSuggestion[]> = {
|
||||
success: true,
|
||||
@@ -389,75 +139,16 @@ router.get("/search", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/by-id/:userId", (ctx) => {
|
||||
router.get("/:key", (ctx) => {
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
data: toPublicUser(getUserById(ctx.params.userId)),
|
||||
data: toPublicUser(resolveUserKey(ctx.params.key)),
|
||||
};
|
||||
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<PaginatedData<PublicUser>> = {
|
||||
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<PaginatedData<Playlist>> = {
|
||||
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<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:username/invitees", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const responseBody: APIResponse<Invitee[]> = {
|
||||
success: true,
|
||||
data: getInviteTree(user.id),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:username", (ctx) => {
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
data: toPublicUser(getUserByUsername(ctx.params.username)),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:username/dumps", async (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
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;
|
||||
@@ -474,8 +165,18 @@ router.get("/:username/dumps", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:username/votes", async (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
router.get("/:key/dumps/:slug", async (ctx) => {
|
||||
const user = resolveUserKey(ctx.params.key);
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const responseBody: APIResponse<Dump> = {
|
||||
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(
|
||||
@@ -491,4 +192,116 @@ router.get("/:username/votes", async (ctx) => {
|
||||
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<PaginatedData<Playlist>> = {
|
||||
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<PlaylistWithDumps> = {
|
||||
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<PaginatedData<PublicUser>> = {
|
||||
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<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:key/invitees", (ctx) => {
|
||||
const user = resolveUserKey(ctx.params.key);
|
||||
const responseBody: APIResponse<Invitee[]> = {
|
||||
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<PublicUser> = {
|
||||
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, true));
|
||||
updateClientUsername(publicUser.id, publicUser.username);
|
||||
broadcastUserUpdated(publicUser);
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
data: publicUser,
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user