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:
@@ -3,10 +3,10 @@
|
||||
* and the client's `Avatar` component loads it, with the user's `updatedAt` as
|
||||
* a cache-busting version.
|
||||
*
|
||||
* - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart
|
||||
* - `PUT /api/me/avatar` — signed in. Replaces the caller's avatar (multipart
|
||||
* `file`, at most 5 MB), sends `user_updated` to everyone, and answers the
|
||||
* caller's user.
|
||||
* - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without
|
||||
* - `GET /api/users/:key/avatar` — public. Serves a user's avatar; 404 without
|
||||
* one.
|
||||
*
|
||||
* @module
|
||||
@@ -14,7 +14,11 @@
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { getUserById, updateUserAvatar } from "../services/user-service.ts";
|
||||
import {
|
||||
getUserById,
|
||||
resolveUserKey,
|
||||
updateUserAvatar,
|
||||
} from "../services/user-service.ts";
|
||||
import {
|
||||
broadcastUserUpdated,
|
||||
updateClientAvatar,
|
||||
@@ -36,7 +40,7 @@ import { AVATARS_DIR } from "../config.ts";
|
||||
*/
|
||||
const router: Router = new Router();
|
||||
|
||||
router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
router.put("/api/me/avatar", authMiddleware, async (ctx) => {
|
||||
const authPayload = ctx.state.user;
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
@@ -70,12 +74,10 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/api/avatars/:userId", async (ctx) => {
|
||||
const { userId } = ctx.params;
|
||||
|
||||
router.get("/api/users/:key/avatar", async (ctx) => {
|
||||
let user;
|
||||
try {
|
||||
user = getUserById(userId);
|
||||
user = resolveUserKey(ctx.params.key);
|
||||
} catch {
|
||||
ctx.response.status = 404;
|
||||
return;
|
||||
@@ -86,7 +88,7 @@ router.get("/api/avatars/:userId", async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await serveUploadedFile(ctx, `${AVATARS_DIR}/${userId}`, user.avatarMime);
|
||||
await serveUploadedFile(ctx, `${AVATARS_DIR}/${user.id}`, user.avatarMime);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Serves the Bandcamp release the native player plays, resolved from its page
|
||||
* when playback starts. On any failure the player falls back to the embed.
|
||||
*
|
||||
* - `GET /api/bandcamp/tracks?url=&force=` — public. Answers the page's
|
||||
* tracks and their stream URLs; `force=1` skips the server's cache.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
|
||||
import type { Tralbum } from "../../shared/player.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 bandcampRouter: Router = new Router();
|
||||
|
||||
// Public: it reads a public page and answers the URLs that page gives any
|
||||
// visitor.
|
||||
bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
const force = ctx.request.url.searchParams.get("force") === "1";
|
||||
|
||||
const responseBody: APIResponse<Tralbum> = {
|
||||
success: true,
|
||||
data: await resolveBandcamp(url, { force }),
|
||||
};
|
||||
|
||||
// Not in a shared cache: the signed URLs belong to one page fetch. An hour
|
||||
// is well inside their 24 hours.
|
||||
ctx.response.headers.set("Cache-Control", "private, max-age=3600");
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default bandcampRouter;
|
||||
@@ -1,11 +1,8 @@
|
||||
/**
|
||||
* Serves the categories dumps are filed under and their feeds, and changes them
|
||||
* for those with `category:manage`.
|
||||
* Serves the categories dumps are filed under, and changes them for those with
|
||||
* `category:manage`. A category's feed is `GET /api/dumps?category=:slug`.
|
||||
*
|
||||
* - `GET /api/categories` — public. Answers every category, in order.
|
||||
* - `GET /api/categories/:slug` — public. Answers one category.
|
||||
* - `GET /api/categories/:slug/dumps?page=&limit=` — optional sign-in. Answers
|
||||
* a page of its dumps, newest first, with the caller's private ones.
|
||||
* - `POST /api/categories` — permission `category:manage`. Creates a category.
|
||||
* - `PUT /api/categories/:id` — permission `category:manage`. Updates a
|
||||
* category.
|
||||
@@ -17,24 +14,19 @@
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { Category } from "../../shared/category.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import {
|
||||
isCreateCategoryRequest,
|
||||
isUpdateCategoryRequest,
|
||||
} from "../validators/category.ts";
|
||||
|
||||
import { authMiddleware, requirePermission } from "../middleware/auth.ts";
|
||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
import {
|
||||
createCategory,
|
||||
deleteCategory,
|
||||
getCategoryBySlug,
|
||||
listCategories,
|
||||
listDumpsByCategory,
|
||||
updateCategory,
|
||||
} from "../services/category-service.ts";
|
||||
|
||||
@@ -52,31 +44,6 @@ router.get("/", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:slug", (ctx) => {
|
||||
const category = getCategoryBySlug(ctx.params.slug);
|
||||
const responseBody: APIResponse<Category> = {
|
||||
success: true,
|
||||
data: category,
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:slug/dumps", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = listDumpsByCategory(
|
||||
ctx.params.slug,
|
||||
page,
|
||||
limit,
|
||||
requestingUserId,
|
||||
);
|
||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
authMiddleware,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
/**
|
||||
* Posts, reads, edits and deletes dumps, for the index feed, the dump page, its
|
||||
* edit page and the create form. `:dumpId` takes an id or a slug. A private
|
||||
* dump answers 404 to anyone but its poster, moderators included.
|
||||
* Posts, reads, edits and deletes dumps, for the feeds, the dump page, its
|
||||
* edit page and the create form. `:dumpId` is a dump's id; the dump page reads
|
||||
* a dump by its path, through `GET /api/users/:key/dumps/:slug`. A private
|
||||
* dump answers 404 to anyone but its poster, moderators included. Its file and
|
||||
* thumbnail are served by `routes/files.ts` and `routes/thumbnails.ts`, its
|
||||
* comments by `routes/comments.ts`.
|
||||
*
|
||||
* - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file
|
||||
* (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`).
|
||||
* - `GET /api/dumps/by-url?url=` — optional sign-in. Answers the dumps already
|
||||
* - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the
|
||||
* dumps the caller may see, newest first.
|
||||
* - `GET /api/dumps?category=&page=&limit=` — optional sign-in. The same, in
|
||||
* the category with that slug; 404 when there is none.
|
||||
* - `GET /api/dumps?url=` — optional sign-in. Answers the dumps already
|
||||
* pointing at the same thing as `url`, for the create form's duplicate hint.
|
||||
* - `GET /api/dumps/:dumpId` — optional sign-in. Answers a dump.
|
||||
* - `GET /api/dumps/:dumpId/voters?page=&limit=` — optional sign-in. Answers a
|
||||
@@ -14,21 +21,12 @@
|
||||
* linked to or from it.
|
||||
* - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the
|
||||
* playlists it appears in.
|
||||
* - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the
|
||||
* dumps the caller may see, newest first.
|
||||
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
|
||||
* `dump:moderate`. Replaces a file dump's upload and comment, and any of its
|
||||
* title, categories and visibility (multipart).
|
||||
* - `PUT /api/dumps/:dumpId` — signed in, as its poster or with
|
||||
* `dump:moderate`. Updates its title, comment, visibility, categories, and a
|
||||
* link's URL.
|
||||
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
|
||||
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5
|
||||
* MB).
|
||||
* - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
|
||||
* `dump:moderate`. Removes its custom thumbnail.
|
||||
* - `POST /api/dumps/:dumpId/refresh-metadata` — signed in, as its poster or
|
||||
* with `dump:moderate`. Fetches a link's preview again.
|
||||
* `dump:moderate`. Updates its title, slug, comment, visibility, categories,
|
||||
* and a link's URL. A new slug keeps the old one as an alias, 3 times a day
|
||||
* at most but for those with `dump:moderate`.
|
||||
* - `POST /api/dumps/:dumpId/metadata` — signed in, as its poster or with
|
||||
* `dump:moderate`. Fetches a link's preview again.
|
||||
* - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with
|
||||
* `dump:moderate`. Deletes it.
|
||||
*
|
||||
@@ -43,6 +41,7 @@ import type { Dump, DumpUrlMatch } from "../../shared/dump.ts";
|
||||
import {
|
||||
isCreateUrlDumpRequest,
|
||||
isUpdateDumpRequest,
|
||||
parseCategoryIds,
|
||||
} from "../validators/dump.ts";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../shared/user.ts";
|
||||
@@ -52,8 +51,6 @@ import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts";
|
||||
import { THUMBNAILS_DIR } from "../config.ts";
|
||||
import {
|
||||
createFileDump,
|
||||
createUrlDump,
|
||||
@@ -62,11 +59,9 @@ import {
|
||||
getDump,
|
||||
listDumps,
|
||||
refreshDumpMetadata,
|
||||
removeDumpThumbnail,
|
||||
replaceFileDump,
|
||||
setDumpThumbnail,
|
||||
updateDump,
|
||||
} from "../services/dump-service.ts";
|
||||
import { listDumpsByCategory } from "../services/category-service.ts";
|
||||
import { getDumpVoters } from "../services/vote-service.ts";
|
||||
import { getRelatedDumps } from "../services/backlink-service.ts";
|
||||
import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
||||
@@ -77,24 +72,8 @@ import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/dumps" });
|
||||
|
||||
// Each handler resolves `:dumpId` with getDump, which checks privacy and takes
|
||||
// a slug, and passes `dump.id` on: the services' writes match on the id alone.
|
||||
|
||||
/**
|
||||
* Parses a multipart form's `categoryIds`, a JSON array sent as a string. A
|
||||
* missing or malformed value counts as no category.
|
||||
*/
|
||||
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
|
||||
if (typeof value !== "string" || !value) return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// Each handler resolves `:dumpId` with getDump, which checks privacy, before
|
||||
// acting on the dump.
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
@@ -140,17 +119,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Registered ahead of "/:dumpId" so the literal path wins over the parameter.
|
||||
router.get("/by-url", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
const responseBody: APIResponse<DumpUrlMatch[]> = {
|
||||
success: true,
|
||||
data: findDumpsByUrl(url, requestingUserId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||
@@ -194,8 +162,21 @@ router.get("/:dumpId/playlists", async (ctx) => {
|
||||
|
||||
router.get("/", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = listDumps(page, limit, requestingUserId);
|
||||
const params = ctx.request.url.searchParams;
|
||||
const url = params.get("url");
|
||||
if (url !== null) {
|
||||
const responseBody: APIResponse<DumpUrlMatch[]> = {
|
||||
success: true,
|
||||
data: findDumpsByUrl(url, requestingUserId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
return;
|
||||
}
|
||||
const { page, limit } = parsePagination(params);
|
||||
const category = params.get("category");
|
||||
const { items, hasMore } = category
|
||||
? listDumpsByCategory(category, page, limit, requestingUserId)
|
||||
: listDumps(page, limit, requestingUserId);
|
||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
@@ -203,40 +184,6 @@ router.get("/", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const formData = await ctx.request.body.formData();
|
||||
const file = formData.get("file");
|
||||
const comment = formData.get("comment");
|
||||
const title = formData.get("title");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(400, "A file is required");
|
||||
}
|
||||
|
||||
const updatedDump = await replaceFileDump(
|
||||
dump.id,
|
||||
file,
|
||||
typeof comment === "string" && comment ? comment : undefined,
|
||||
typeof title === "string" && title ? title : undefined,
|
||||
formData.has("categoryIds")
|
||||
? parseCategoryIds(formData.get("categoryIds"))
|
||||
: undefined,
|
||||
formData.has("isPrivate")
|
||||
? formData.get("isPrivate") === "true"
|
||||
: undefined,
|
||||
);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
@@ -252,54 +199,16 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const updatedDump = await updateDump(dump.id, body);
|
||||
const updatedDump = await updateDump(
|
||||
dump.id,
|
||||
body,
|
||||
can(ctx.state.user, "dump:moderate"),
|
||||
);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const formData = await ctx.request.body.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(400, "Missing file field");
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const mime = validateImageUpload(data);
|
||||
|
||||
// Written before setDumpThumbnail announces it, or clients would fetch it
|
||||
// too early.
|
||||
await replaceUploadedFile(`${THUMBNAILS_DIR}/${dump.id}-custom`, data);
|
||||
const updatedDump = setDumpThumbnail(dump.id, mime);
|
||||
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const updatedDump = await removeDumpThumbnail(dump.id);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
|
||||
router.post("/:dumpId/metadata", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
const dump = getDump(dumpId, userId);
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
/**
|
||||
* Serves file dumps' uploads to the players, viewers and download links of the
|
||||
* frontend, which builds the URL with `dumpFileUrl`.
|
||||
* Serves and replaces file dumps' uploads, a sub-resource of their dump. The
|
||||
* players, viewers and download links of the frontend build the URL with
|
||||
* `dumpFileUrl`, and its edit page replaces the file.
|
||||
*
|
||||
* - `GET /api/files/:dumpId?v=&token=` — public; a private dump's file needs
|
||||
* its poster's media `token`. Serves the file, inline when that is safe and
|
||||
* as a download otherwise, with byte ranges.
|
||||
* - `GET /api/dumps/:dumpId/file?v=&token=` — public; a private dump's file
|
||||
* needs its poster's media `token`. Serves the file, inline when that is
|
||||
* safe and as a download otherwise, with byte ranges.
|
||||
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
|
||||
* `dump:moderate`. Replaces a file dump's upload and comment, and any of its
|
||||
* title, slug, categories and visibility (multipart).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import { DUMPS_DIR } from "../config.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import { serveFile } from "../lib/serve-file.ts";
|
||||
import { dumpFileVersion, getDump } from "../services/dump-service.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { parseCategoryIds } from "../validators/dump.ts";
|
||||
import {
|
||||
dumpFileVersion,
|
||||
getDump,
|
||||
replaceFileDump,
|
||||
} from "../services/dump-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/files" });
|
||||
const router: Router = new Router({ prefix: "/api/dumps" });
|
||||
|
||||
/**
|
||||
* Builds a `Content-Disposition` value for a file called `name`. `Headers.set`
|
||||
@@ -54,7 +67,7 @@ function isInlineSafe(mime: string): boolean {
|
||||
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase());
|
||||
}
|
||||
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
router.get("/:dumpId/file", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
// The id names a file on disk: nothing but an id's characters gets through.
|
||||
@@ -110,4 +123,40 @@ router.get("/:dumpId", async (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
const userId = ctx.state.user.userId;
|
||||
const dump = getDump(ctx.params.dumpId, userId);
|
||||
const canModerate = can(ctx.state.user, "dump:moderate");
|
||||
if (userId !== dump.userId && !canModerate) {
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const formData = await ctx.request.body.formData();
|
||||
const file = formData.get("file");
|
||||
const comment = formData.get("comment");
|
||||
const title = formData.get("title");
|
||||
const slug = formData.get("slug");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(400, "A file is required");
|
||||
}
|
||||
|
||||
const updatedDump = await replaceFileDump(
|
||||
dump.id,
|
||||
file,
|
||||
typeof comment === "string" && comment ? comment : undefined,
|
||||
typeof title === "string" && title ? title : undefined,
|
||||
formData.has("categoryIds")
|
||||
? parseCategoryIds(formData.get("categoryIds"))
|
||||
: undefined,
|
||||
formData.has("isPrivate")
|
||||
? formData.get("isPrivate") === "true"
|
||||
: undefined,
|
||||
typeof slug === "string" && slug ? slug : undefined,
|
||||
canModerate,
|
||||
);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
/**
|
||||
* Serves the endpoints for what the caller follows, and the index's "followed"
|
||||
* tab built from it. What another user follows is served by `routes/users.ts`.
|
||||
* Serves what the caller follows, and the index's "follow" tab built from it.
|
||||
* What another user follows is served by `routes/users.ts`.
|
||||
*
|
||||
* - `GET /api/follows/status` — signed in. Answers the ids of the users and
|
||||
* - `GET /api/me/follow` — signed in. Answers the ids of the users and
|
||||
* playlists they follow.
|
||||
* - `GET /api/follows/feed/users?page=&limit=&category=` — signed in. Answers a
|
||||
* page of the public dumps of the users they follow, newest first, optionally
|
||||
* in one category.
|
||||
* - `GET /api/follows/feed/playlists?page=&limit=&category=` — signed in.
|
||||
* - `PUT /api/me/follow/users/:userId` — signed in. Follows a user.
|
||||
* - `DELETE /api/me/follow/users/:userId` — signed in. Stops following a user.
|
||||
* - `PUT /api/me/follow/playlists/:playlistId` — signed in. Follows a public
|
||||
* playlist.
|
||||
* - `DELETE /api/me/follow/playlists/:playlistId` — signed in. Stops following
|
||||
* a playlist.
|
||||
* - `GET /api/me/feed?from=users&page=&limit=&category=` — signed in. Answers
|
||||
* a page of the public dumps of the users they follow, newest first,
|
||||
* optionally in one category.
|
||||
* - `GET /api/me/feed?from=playlists&page=&limit=&category=` — signed in.
|
||||
* Answers a page of the public dumps in the public playlists they follow,
|
||||
* most recently added first, optionally in one category.
|
||||
* - `POST /api/follows/users/:userId` — signed in. Follows a user.
|
||||
* - `DELETE /api/follows/users/:userId` — signed in. Stops following a user.
|
||||
* - `POST /api/follows/playlists/:playlistId` — signed in. Follows a public
|
||||
* playlist.
|
||||
* - `DELETE /api/follows/playlists/:playlistId` — signed in. Stops following a
|
||||
* playlist.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { APIResponse, PaginatedData } from "../../shared/api.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import type { FollowStatus } from "../../shared/follow.ts";
|
||||
@@ -56,65 +57,53 @@ const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false };
|
||||
* 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/follows" });
|
||||
const router: Router = new Router({ prefix: "/api/me" });
|
||||
|
||||
router.get("/status", authMiddleware, (ctx) => {
|
||||
router.get("/follow", authMiddleware, (ctx) => {
|
||||
const status = getFollowStatus(ctx.state.user.userId);
|
||||
const body: APIResponse<FollowStatus> = { success: true, data: status };
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
router.get("/feed/users", authMiddleware, (ctx) => {
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = categoryId === "missing"
|
||||
? EMPTY_FEED
|
||||
: getFollowedUsersDumpFeed(ctx.state.user.userId, page, limit, categoryId);
|
||||
const data: PaginatedData<Dump> = {
|
||||
items,
|
||||
hasMore,
|
||||
};
|
||||
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
router.get("/feed/playlists", authMiddleware, (ctx) => {
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = categoryId === "missing"
|
||||
? EMPTY_FEED
|
||||
: getFollowedPlaylistsDumpFeed(
|
||||
ctx.state.user.userId,
|
||||
page,
|
||||
limit,
|
||||
categoryId,
|
||||
);
|
||||
const data: PaginatedData<Dump> = {
|
||||
items,
|
||||
hasMore,
|
||||
};
|
||||
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
router.post("/users/:userId", authMiddleware, (ctx) => {
|
||||
router.put("/follow/users/:userId", authMiddleware, (ctx) => {
|
||||
followUser(ctx.state.user.userId, ctx.params.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
router.delete("/users/:userId", authMiddleware, (ctx) => {
|
||||
router.delete("/follow/users/:userId", authMiddleware, (ctx) => {
|
||||
unfollowUser(ctx.state.user.userId, ctx.params.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
router.post("/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
router.put("/follow/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
followPlaylist(ctx.state.user.userId, ctx.params.playlistId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
router.delete("/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
router.delete("/follow/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||
unfollowPlaylist(ctx.state.user.userId, ctx.params.playlistId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
router.get("/feed", authMiddleware, (ctx) => {
|
||||
const params = ctx.request.url.searchParams;
|
||||
const from = params.get("from");
|
||||
if (from !== "users" && from !== "playlists") {
|
||||
throw new APIException(400, "from must be users or playlists");
|
||||
}
|
||||
const { page, limit } = parsePagination(params);
|
||||
const categoryId = resolveCategoryFilter(params);
|
||||
const userId = ctx.state.user.userId;
|
||||
const { items, hasMore } = categoryId === "missing"
|
||||
? EMPTY_FEED
|
||||
: from === "users"
|
||||
? getFollowedUsersDumpFeed(userId, page, limit, categoryId)
|
||||
: getFollowedPlaylistsDumpFeed(userId, page, limit, categoryId);
|
||||
const body: APIResponse<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
};
|
||||
ctx.response.body = body;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
174
api/routes/me.ts
Normal file
174
api/routes/me.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -3,9 +3,9 @@
|
||||
* page, which marks them all read when it opens. New ones arrive over the
|
||||
* WebSocket instead, as `notification_created`.
|
||||
*
|
||||
* - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them,
|
||||
* newest first.
|
||||
* - `POST /api/notifications/read-all` — signed in. Marks them all read.
|
||||
* - `GET /api/me/notifications?page=&limit=` — signed in. Answers a page of
|
||||
* them, newest first.
|
||||
* - `POST /api/me/notifications/read` — signed in. Marks them all read.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
* 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/notifications" });
|
||||
const router: Router = new Router({ prefix: "/api/me/notifications" });
|
||||
|
||||
router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
@@ -40,7 +40,7 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
|
||||
router.post("/read", authMiddleware, (ctx: AuthContext) => {
|
||||
markAllRead(ctx.state.user.userId);
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
56
api/routes/password-resets.ts
Normal file
56
api/routes/password-resets.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Resets forgotten passwords. The client's `/login` page asks for a reset
|
||||
* link, which the email takes to the `/reset-password` page, whose token
|
||||
* names the reset.
|
||||
*
|
||||
* - `POST /api/password-resets` — public. Emails a reset link to the account
|
||||
* with that `email`, if any; always answers 200.
|
||||
* - `PUT /api/password-resets/:token` — public, with a reset token. Sets the
|
||||
* `newPassword`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { fieldOf } from "../validators/user.ts";
|
||||
import {
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
} from "../services/password-reset-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/password-resets" });
|
||||
|
||||
// Answers the same whatever happens, so it can't tell which addresses have an
|
||||
// account.
|
||||
router.post("/", 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("[password-resets]", err)
|
||||
);
|
||||
}
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.put("/:token", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const newPassword = fieldOf(body, "newPassword");
|
||||
if (typeof newPassword !== "string") {
|
||||
throw new APIException(400, "Invalid request");
|
||||
}
|
||||
await resetPassword(ctx.params.token, newPassword);
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,29 +1,32 @@
|
||||
/**
|
||||
* Serves playlists, their dumps and their covers. `:playlistId` takes an id or
|
||||
* a slug.
|
||||
* Serves playlists, their dumps and their covers. `:playlistId` is a
|
||||
* playlist's id; its page reads it by its path, through
|
||||
* `GET /api/users/:key/playlists/:slug`.
|
||||
*
|
||||
* - `GET /api/playlists/by-dump/:dumpId/memberships` — signed in. Answers each
|
||||
* of the caller's playlists and whether it holds the dump.
|
||||
* - `POST /api/playlists` — signed in. Creates a playlist.
|
||||
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
|
||||
* with the dumps the caller may see in it. A private one 404s but for its
|
||||
* owner.
|
||||
* - `PUT /api/playlists/:playlistId` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Updates its title, description and visibility.
|
||||
* `playlist:moderate`. Updates its title, slug, description and visibility.
|
||||
* A new slug keeps the old one as an alias, 3 times a day at most but for
|
||||
* those with `playlist:moderate`.
|
||||
* - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Deletes it.
|
||||
* - `POST /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
|
||||
* or with `playlist:moderate`. Adds a dump at the top.
|
||||
* - `DELETE /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
|
||||
* or with `playlist:moderate`. Removes a dump.
|
||||
* - `POST /api/playlists/:playlistId/image` — signed in, as its owner or with
|
||||
* - `PUT /api/playlists/:playlistId/cover` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB).
|
||||
* - `GET /api/playlists/:playlistId/image?token=` — public, but a private
|
||||
* - `GET /api/playlists/:playlistId/cover?token=` — public, but a private
|
||||
* playlist's cover needs its owner's media `token`. Serves its cover, or 404s
|
||||
* without one.
|
||||
* - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Reorders the dumps the caller can see in it.
|
||||
*
|
||||
* Which of the caller's playlists hold a dump is `GET /api/me/playlists`'s.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -36,11 +39,7 @@ import {
|
||||
isReorderPlaylistRequest,
|
||||
isUpdatePlaylistRequest,
|
||||
} from "../validators/playlist.ts";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistMembership,
|
||||
PlaylistWithDumps,
|
||||
} from "../../shared/playlist.ts";
|
||||
import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
|
||||
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import {
|
||||
@@ -50,7 +49,6 @@ import {
|
||||
getEditablePlaylist,
|
||||
getPlaylist,
|
||||
getPlaylistImageInfo,
|
||||
getPlaylistMembershipsForDump,
|
||||
removeDumpFromPlaylist,
|
||||
reorderPlaylist,
|
||||
setPlaylistImage,
|
||||
@@ -71,17 +69,6 @@ const router: Router<AuthState> = new Router<AuthState>({
|
||||
prefix: "/api/playlists",
|
||||
});
|
||||
|
||||
// Before /:playlistId, which would otherwise match "by-dump".
|
||||
router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
const userId = ctx.state.user.userId;
|
||||
const responseBody: APIResponse<PlaylistMembership[]> = {
|
||||
success: true,
|
||||
data: getPlaylistMembershipsForDump(dumpId, userId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post("/", authMiddleware, async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isCreatePlaylistRequest(body)) {
|
||||
@@ -148,7 +135,7 @@ router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
|
||||
ctx.response.status = 204;
|
||||
});
|
||||
|
||||
router.post("/:playlistId/image", authMiddleware, async (ctx) => {
|
||||
router.put("/:playlistId/cover", authMiddleware, async (ctx) => {
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
throw new APIException(400, "Expected multipart/form-data");
|
||||
@@ -182,7 +169,7 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:playlistId/image", async (ctx) => {
|
||||
router.get("/:playlistId/cover", async (ctx) => {
|
||||
// An <img> can't send an Authorization header, so a private playlist's owner
|
||||
// is known by the media token in the query string, as for dump files.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
/**
|
||||
* Fetches link previews for the create form, and proxies the remote images
|
||||
* that the frontend's thumbnails can't load directly.
|
||||
* Reads other sites for the client, as `api/remote/` does for the server: the
|
||||
* previews of links on the create form, the images its thumbnails can't load
|
||||
* directly, and the tracks the players list. On any failure of the last two,
|
||||
* the player falls back to the embed.
|
||||
*
|
||||
* - `GET /api/preview?url=` — signed in. Answers the preview of a link, and
|
||||
* whether its page could be reached.
|
||||
* - `GET /api/proxy-image?url=` — public. Serves a remote raster image through
|
||||
* this site; 400 when it isn't one, 502 when its host can't be reached.
|
||||
* - `GET /api/remote/preview?url=` — signed in. Answers the preview of a link,
|
||||
* and whether its page could be reached.
|
||||
* - `GET /api/remote/image?url=` — public. Serves a remote raster image
|
||||
* through this site; 400 when it isn't one, 502 when its host can't be
|
||||
* reached.
|
||||
* - `GET /api/remote/bandcamp?url=&force=` — public. Answers a Bandcamp
|
||||
* page's tracks and their stream URLs, for the native player; `force=1`
|
||||
* skips the server's cache.
|
||||
* - `GET /api/remote/youtube?list=` — public. Answers a YouTube playlist's
|
||||
* tracklist, which the global player lists beside the embed.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -13,6 +21,8 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { isValidHttpUrl } from "../remote/fetch.ts";
|
||||
import { fetchProxiedImage } from "../remote/image-proxy.ts";
|
||||
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
|
||||
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
|
||||
import { tryFetchRichContent } from "../services/rich-content-service.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
@@ -20,16 +30,17 @@ import {
|
||||
type APIResponse,
|
||||
} from "../../shared/api.ts";
|
||||
import type { LinkPreview } from "../../shared/dump.ts";
|
||||
import type { Tralbum, YouTubePlaylist } from "../../shared/player.ts";
|
||||
import { authMiddleware } from "../middleware/auth.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 previewRouter: Router = new Router();
|
||||
const router: Router = new Router({ prefix: "/api/remote" });
|
||||
|
||||
// Signed-in only: an open preview would let anyone make the server fetch pages.
|
||||
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
||||
router.get("/preview", authMiddleware, async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
const responseBody: APIFailure = {
|
||||
@@ -69,7 +80,7 @@ function sendImage(
|
||||
|
||||
// The frontend sends a thumbnail here when it is plain HTTP, which an HTTPS
|
||||
// page won't load, or when its host's hotlink protection refused the browser.
|
||||
previewRouter.get("/api/proxy-image", async (ctx) => {
|
||||
router.get("/image", async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
ctx.response.status = 400;
|
||||
@@ -88,4 +99,33 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
export default previewRouter;
|
||||
// Public: it reads a public page and answers the URLs that page gives any
|
||||
// visitor.
|
||||
router.get("/bandcamp", async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
const force = ctx.request.url.searchParams.get("force") === "1";
|
||||
|
||||
const responseBody: APIResponse<Tralbum> = {
|
||||
success: true,
|
||||
data: await resolveBandcamp(url, { force }),
|
||||
};
|
||||
|
||||
// Not in a shared cache: the signed URLs belong to one page fetch. An hour
|
||||
// is well inside their 24 hours.
|
||||
ctx.response.headers.set("Cache-Control", "private, max-age=3600");
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Public, like /bandcamp: it only reads a public page.
|
||||
router.get("/youtube", async (ctx) => {
|
||||
const listId = ctx.request.url.searchParams.get("list") ?? "";
|
||||
const responseBody: APIResponse<YouTubePlaylist> = {
|
||||
success: true,
|
||||
data: await resolveYouTubePlaylist(listId),
|
||||
};
|
||||
|
||||
ctx.response.headers.set("Cache-Control", "public, max-age=3600");
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
74
api/routes/sessions.ts
Normal file
74
api/routes/sessions.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Signs users in, from the client's `/login` page. A session is a token the
|
||||
* client keeps; signing out forgets it, with no request.
|
||||
*
|
||||
* - `POST /api/sessions` — public. Answers the user and their tokens;
|
||||
* rate-limited per account.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { AuthResponse } from "../../shared/user.ts";
|
||||
import { isLoginUserRequest } from "../validators/user.ts";
|
||||
import { toSelfUser } from "../model/user.ts";
|
||||
import { issueTokens } from "../lib/jwt.ts";
|
||||
import { hashPassword, verifyPassword } from "../lib/password.ts";
|
||||
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
import { getUserByUsername } from "../services/user-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());
|
||||
|
||||
/** Allows a burst of 10 login attempts per account, then one every 30s. */
|
||||
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
|
||||
|
||||
/**
|
||||
* 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/sessions" });
|
||||
|
||||
router.post("/", 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;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,34 +1,51 @@
|
||||
/**
|
||||
* Serves dumps' thumbnails to the cards and pages that show a file dump, which
|
||||
* build the URL with `dumpThumbnailUrl`. Thumbnails made from an upload are
|
||||
* generated with ffmpeg on first request and kept in `THUMBNAILS_DIR`.
|
||||
* Serves and changes dumps' thumbnails, a sub-resource of their dump. The
|
||||
* cards and pages that show a file dump build the URL with
|
||||
* `dumpThumbnailUrl`, and the edit page uploads a custom one. Thumbnails made
|
||||
* from an upload are generated with ffmpeg on first request and kept in
|
||||
* `THUMBNAILS_DIR`.
|
||||
*
|
||||
* - `GET /api/thumbnails/:dumpId?v=&token=` — public; a private dump's
|
||||
* - `GET /api/dumps/:dumpId/thumbnail?v=&token=` — public; a private dump's
|
||||
* thumbnail needs its poster's media `token`. Serves the dump's custom
|
||||
* thumbnail, else one made from its upload: a still of a video, or a
|
||||
* scaled-down copy of an image (the image itself when none can be made); 404
|
||||
* when there is neither.
|
||||
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
|
||||
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5
|
||||
* MB).
|
||||
* - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
|
||||
* `dump:moderate`. Removes its custom thumbnail.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import {
|
||||
dumpFileVersion,
|
||||
getDump,
|
||||
getDumpThumbnailInfo,
|
||||
removeDumpThumbnail,
|
||||
setDumpThumbnail,
|
||||
} from "../services/dump-service.ts";
|
||||
import { serveFile } from "../lib/serve-file.ts";
|
||||
import { serveUploadedFile } from "../lib/upload.ts";
|
||||
import {
|
||||
replaceUploadedFile,
|
||||
serveUploadedFile,
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.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/thumbnails" });
|
||||
const router: Router = new Router({ prefix: "/api/dumps" });
|
||||
|
||||
/**
|
||||
* Spares a process per uncached thumbnail request: whether ffmpeg runs is
|
||||
@@ -169,7 +186,7 @@ async function ensureGenerated(
|
||||
return await pending.then(() => true, () => false);
|
||||
}
|
||||
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
router.get("/:dumpId/thumbnail", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
// The id names files on disk: nothing but an id's characters gets through.
|
||||
@@ -253,4 +270,59 @@ router.get("/:dumpId", async (ctx) => {
|
||||
throw new APIException(404, "No thumbnail for this dump");
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the dump `ctx` names if the caller may change it, as its poster or
|
||||
* with `dump:moderate`.
|
||||
*
|
||||
* @throws {APIException} 404 when the caller can't see it, 403 when they may
|
||||
* not change it.
|
||||
*/
|
||||
function editableDump(ctx: AuthContext<"/:dumpId/thumbnail">): Dump {
|
||||
const userId = ctx.state.user.userId;
|
||||
const dump = getDump(ctx.params.dumpId, userId);
|
||||
if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) {
|
||||
throw new APIException(403, "Not authorized to update dump");
|
||||
}
|
||||
return dump;
|
||||
}
|
||||
|
||||
router.put(
|
||||
"/:dumpId/thumbnail",
|
||||
authMiddleware,
|
||||
async (ctx: AuthContext<"/:dumpId/thumbnail">) => {
|
||||
const dump = editableDump(ctx);
|
||||
const formData = await ctx.request.body.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(400, "Missing file field");
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const mime = validateImageUpload(data);
|
||||
|
||||
// Written before setDumpThumbnail announces it, or clients would fetch it
|
||||
// too early.
|
||||
await replaceUploadedFile(`${THUMBNAILS_DIR}/${dump.id}-custom`, data);
|
||||
const responseBody: APIResponse<Dump> = {
|
||||
success: true,
|
||||
data: setDumpThumbnail(dump.id, mime),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:dumpId/thumbnail",
|
||||
authMiddleware,
|
||||
async (ctx: AuthContext<"/:dumpId/thumbnail">) => {
|
||||
const dump = editableDump(ctx);
|
||||
const responseBody: APIResponse<Dump> = {
|
||||
success: true,
|
||||
data: await removeDumpThumbnail(dump.id),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
49
api/routes/unsubscribe.ts
Normal file
49
api/routes/unsubscribe.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Turns a digest off from the link in its email, without signing in. Mail
|
||||
* clients post here for a one-click unsubscribe (RFC 8058), and so does the
|
||||
* client's `/unsubscribe` page.
|
||||
*
|
||||
* - `POST /api/unsubscribe?token=` — public, with a digest's unsubscribe
|
||||
* token. Turns that digest off and answers its cadence.
|
||||
* - `POST /api/users/unsubscribe?token=` — the same, at its v3 path, which the
|
||||
* `List-Unsubscribe` header of every digest sent before v4 names. Those
|
||||
* tokens never expire, so this path stays for good.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router, type RouterMiddleware } from "@oak/oak";
|
||||
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { DIGEST_SETTING, type DigestCadence } from "../../shared/settings.ts";
|
||||
import { verifyUnsubscribeToken } from "../lib/jwt.ts";
|
||||
import { getUserById } from "../services/user-service.ts";
|
||||
import { updateSettings } from "../services/settings-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();
|
||||
|
||||
// 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.
|
||||
const unsubscribe: RouterMiddleware<string> = 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.post("/api/unsubscribe", unsubscribe);
|
||||
router.post("/api/users/unsubscribe", unsubscribe);
|
||||
|
||||
export default router;
|
||||
@@ -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;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Serves the titles and lengths of a YouTube playlist's entries, which the
|
||||
* global player lists beside the embed. On any failure it shows the embed
|
||||
* without a tracklist.
|
||||
*
|
||||
* - `GET /api/youtube/playlist?list=` — public. Answers the playlist's
|
||||
* tracklist.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
|
||||
import type { YouTubePlaylist } from "../../shared/player.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 youtubeRouter: Router = new Router();
|
||||
|
||||
// Public, like /api/bandcamp/tracks: it only reads a public page.
|
||||
youtubeRouter.get("/api/youtube/playlist", async (ctx) => {
|
||||
const listId = ctx.request.url.searchParams.get("list") ?? "";
|
||||
const responseBody: APIResponse<YouTubePlaylist> = {
|
||||
success: true,
|
||||
data: await resolveYouTubePlaylist(listId),
|
||||
};
|
||||
|
||||
ctx.response.headers.set("Cache-Control", "public, max-age=3600");
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default youtubeRouter;
|
||||
Reference in New Issue
Block a user