v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 21:07:34 +00:00
parent 1702370447
commit d62e4a9883
119 changed files with 2967 additions and 611 deletions

View File

@@ -1,3 +1,9 @@
/**
* The attachment routes, for images pasted into Markdown bodies.
*
* @module
*/
import { Router } from "@oak/oak";
import { authMiddleware } from "../middleware/auth.ts";
import {
@@ -13,7 +19,15 @@ import {
validateImageUpload,
} from "../lib/upload.ts";
const router = new Router();
/**
* The attachment routes, for images pasted into Markdown bodies:
*
* - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at
* most 5 MB) and answers its id; it is bound to a resource once a saved body
* links it.
* - `GET /api/attachments/:attachmentId` — public. Serves the image.
*/
const router: Router = new Router();
router.post("/api/attachments", authMiddleware, async (ctx) => {
const contentType = ctx.request.headers.get("content-type") ?? "";

View File

@@ -1,3 +1,9 @@
/**
* The avatar routes.
*
* @module
*/
import { Router } from "@oak/oak";
import { authMiddleware } from "../middleware/auth.ts";
import { getUserById, updateUserAvatar } from "../services/user-service.ts";
@@ -15,7 +21,15 @@ import {
validateImageUpload,
} from "../lib/upload.ts";
const router = new Router();
/**
* The avatar routes:
*
* - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart
* `file`, at most 5 MB), announces it, and answers the caller's user.
* - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without
* one.
*/
const router: Router = new Router();
router.post("/api/avatars/me", authMiddleware, async (ctx) => {
const authPayload = ctx.state.user;

View File

@@ -1,18 +1,26 @@
/**
* The Bandcamp route.
*
* @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";
const bandcampRouter = new Router();
/**
* Resolve a Bandcamp page to its streamable tracks.
* The Bandcamp route, for the native player:
*
* Public, like /api/preview: it only reads a public page and returns URLs the
* same page already hands any visitor. Errors are thrown as APIException and
* rendered by errorMiddleware; the client treats any failure as "fall back to
* the iframe embed".
* - `GET /api/bandcamp/tracks?url=&force=` — public. Resolves a Bandcamp page
* to its streamable tracks, at play time; `force=1` bypasses the cache.
*/
const bandcampRouter: Router = new Router();
// Public: it only reads a public page and returns URLs the same page already
// hands any visitor. Errors are thrown as APIException and rendered by
// errorMiddleware; the client treats any failure as "fall back to the iframe
// embed".
bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? "";
const force = ctx.request.url.searchParams.get("force") === "1";

View File

@@ -1,3 +1,9 @@
/**
* The category routes.
*
* @module
*/
import { Router } from "@oak/oak";
import {
@@ -25,7 +31,20 @@ import {
updateCategory,
} from "../services/category-service.ts";
const router = new Router({ prefix: "/api/categories" });
/**
* The category routes:
*
* - `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.
* - `DELETE /api/categories/:id` — permission `category:manage`. Deletes a
* category; its dumps stay.
*/
const router: Router = new Router({ prefix: "/api/categories" });
router.get("/", (ctx) => {
const responseBody: APIResponse<Category[]> = {

View File

@@ -1,3 +1,9 @@
/**
* The chat history route.
*
* @module
*/
import { Router } from "@oak/oak";
import type { APIResponse } from "../../shared/api.ts";
import type { ChatMessage } from "../../shared/chat.ts";
@@ -7,13 +13,19 @@ import {
getRecentChatMessages,
} from "../services/chat-service.ts";
const router = new Router({ prefix: "/api/chat" });
/**
* The chat history route; messages are posted and edited over the socket (see
* routes/ws.ts):
*
* - `GET /api/chat?limit=&before=&beforeId=&after=&afterId=` — signed in.
* Answers messages in ascending order: the most recent by default; with
* `before`, the page of older messages preceding that cursor ("load older");
* with `after`, the messages following it (resync after a reconnect). A
* cursor is an ISO `created_at` plus the message id to break ties; without
* `afterId` the `after` timestamp itself is included.
*/
const router: Router = new Router({ prefix: "/api/chat" });
// GET /api/chat — recent messages (ascending). `limit` caps the page size.
// Cursors (ISO created_at, plus the message id to break ties):
// before/beforeId — the page of older messages preceding it ("load older");
// after/afterId — the messages following it (resync after a reconnect);
// without afterId the timestamp itself is included.
router.get("/", authMiddleware, (ctx) => {
const params = ctx.request.url.searchParams;
const limitParam = params.get("limit");

View File

@@ -1,3 +1,9 @@
/**
* The comment routes.
*
* @module
*/
import { Router } from "@oak/oak";
import {
APIErrorCode,
@@ -31,9 +37,23 @@ import {
broadcastCommentUpdated,
} from "../services/ws-service.ts";
const router = new Router({ prefix: "/api" });
/**
* The comment routes. A private dump's comments exist only for its poster, and
* changes to them aren't broadcast:
*
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
* on a dump, oldest first.
* - `POST /api/dumps/:dumpId/comments` — signed in. Posts a comment, replying
* to `parentId` if given.
* - `PATCH /api/comments/:commentId` — signed in, as its author or with
* `comment:moderate`. Edits a comment.
* - `GET /api/comments/:commentId/likers?page=&limit=` — optional sign-in.
* Answers a page of the users who like a comment.
* - `DELETE /api/comments/:commentId` — signed in, as its author or with
* `comment:moderate`. Deletes a comment, leaving its place in the thread.
*/
const router: Router = new Router({ prefix: "/api" });
// GET /api/dumps/:dumpId/comments — optional auth (to access private dump comments)
router.get("/dumps/:dumpId/comments", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dump = getDump(ctx.params.dumpId, requestingUserId);
@@ -45,7 +65,6 @@ router.get("/dumps/:dumpId/comments", async (ctx) => {
ctx.response.body = responseBody;
});
// POST /api/dumps/:dumpId/comments — auth required
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId;
const dump = getDump(ctx.params.dumpId, userId);
@@ -69,7 +88,6 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
ctx.response.body = responseBody;
});
// PATCH /api/comments/:commentId — auth required
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId;
const canModerate = can(ctx.state.user, "comment:moderate");
@@ -92,7 +110,6 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
ctx.response.body = responseBody;
});
// GET /api/comments/:commentId/likers — optional auth (to access private dump's comment likers)
router.get("/comments/:commentId/likers", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dumpId = getCommentDumpId(ctx.params.commentId);
@@ -113,7 +130,6 @@ router.get("/comments/:commentId/likers", async (ctx) => {
ctx.response.body = responseBody;
});
// DELETE /api/comments/:commentId — auth required
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
const userId = ctx.state.user.userId;
const canModerate = can(ctx.state.user, "comment:moderate");

View File

@@ -1,3 +1,9 @@
/**
* The dump routes.
*
* @module
*/
import { Router } from "@oak/oak";
import {
@@ -38,13 +44,48 @@ import { getDumpVoters } from "../services/vote-service.ts";
import { getRelatedDumps } from "../services/backlink-service.ts";
import { getPlaylistsForDump } from "../services/playlist-service.ts";
const router = new Router({ prefix: "/api/dumps" });
/**
* The dump routes. `:dumpId` takes an id or a slug; a private dump exists only
* for its poster:
*
* - `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
* 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
* page of the users who upvoted it.
* - `GET /api/dumps/:dumpId/related` — optional sign-in. Answers the dumps
* 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, newest first.
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
* `dump:moderate`. Replaces a file dump's upload (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.
* - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with
* `dump:moderate`. Deletes it.
*/
const router: Router = new Router({ prefix: "/api/dumps" });
// Every route here takes a slug or an id; the services below them key on the
// id, so mutations always pass `dump.id` from the resolved dump, never the raw
// parameter.
// Categories arrive on the multipart create path as a JSON-encoded string field.
/**
* Reads the category ids that arrive on the multipart create path as a
* JSON-encoded string field; empty when it is missing or malformed.
*/
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
if (typeof value !== "string" || !value) return [];
try {

View File

@@ -1,3 +1,9 @@
/**
* The route serving file dumps' uploads.
*
* @module
*/
import { Router } from "@oak/oak";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
@@ -6,7 +12,14 @@ import { authenticateMedia } from "../lib/auth.ts";
import { serveFile } from "../lib/serve-file.ts";
import { dumpFileVersion, getDump } from "../services/dump-service.ts";
const router = new Router({ prefix: "/api/files" });
/**
* The route serving file dumps' uploads:
*
* - `GET /api/files/:dumpId?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.
*/
const router: Router = new Router({ prefix: "/api/files" });
/**
* Builds an RFC 6266 `Content-Disposition` value that is always a valid
@@ -28,15 +41,18 @@ function contentDisposition(
}`;
}
// The MIME type of an upload is whatever the uploader's browser claimed, and
// these responses come from the app's own origin — where the session token
// lives. Only types a browser renders without running the file's own code are
// served inline; anything else (text/html, image/svg+xml, XML, …) downloads.
// <img>, <audio> and fetch() ignore Content-Disposition, so previews that embed
// or read the file still work.
/**
* The types served inline. The MIME type of an upload is whatever the
* uploader's browser claimed, and these responses come from the app's own
* origin — where the session token lives. Only types a browser renders without
* running the file's own code are served inline; anything else (text/html,
* image/svg+xml, XML, …) downloads. <img>, <audio> and fetch() ignore
* Content-Disposition, so previews that embed or read the file still work.
*/
const INLINE_MIME_RE =
/^(image\/(png|jpeg|gif|webp|avif|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf|text\/plain)$/;
/** Checks whether a file of type `mime` may be served inline. */
function isInlineSafe(mime: string): boolean {
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase());
}

View File

@@ -1,3 +1,9 @@
/**
* The follow routes.
*
* @module
*/
import { Router } from "@oak/oak";
import { authMiddleware } from "../middleware/auth.ts";
import type { APIResponse, PaginatedData } from "../../shared/api.ts";
@@ -15,9 +21,12 @@ import {
} from "../services/follow-service.ts";
import { getCategoryIdBySlug } from "../services/category-service.ts";
// Resolves the optional ?category=<slug> filter. Returns the matching category
// id, or "missing" when a slug was given but matches nothing (so the feed comes
// back empty rather than unfiltered), or undefined when no filter was requested.
/**
* Resolves the optional ?category=<slug> filter. Returns the matching category
* id, or "missing" when a slug was given but matches nothing (so the feed comes
* back empty rather than unfiltered), or undefined when no filter was
* requested.
*/
function resolveCategoryFilter(
params: URLSearchParams,
): string | "missing" | undefined {
@@ -26,20 +35,37 @@ function resolveCategoryFilter(
return getCategoryIdBySlug(slug) ?? "missing";
}
/** The feed a filter naming no category yields. */
const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false };
const router = new Router({ prefix: "/api/follows" });
/**
* The follow routes, all for what the caller follows:
*
* - `GET /api/follows/status` — 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.
* 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.
*/
const router: Router = new Router({ prefix: "/api/follows" });
// Static routes first to prevent Oak matching "status"/"feed" as a :param
// GET /api/follows/status
router.get("/status", authMiddleware, (ctx) => {
const status = getFollowStatus(ctx.state.user.userId);
const body: APIResponse<FollowStatus> = { success: true, data: status };
ctx.response.body = body;
});
// GET /api/follows/feed/users?page=&limit=
router.get("/feed/users", authMiddleware, (ctx) => {
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
@@ -54,7 +80,6 @@ router.get("/feed/users", authMiddleware, (ctx) => {
ctx.response.body = body;
});
// GET /api/follows/feed/playlists?page=&limit=
router.get("/feed/playlists", authMiddleware, (ctx) => {
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
@@ -74,25 +99,21 @@ router.get("/feed/playlists", authMiddleware, (ctx) => {
ctx.response.body = body;
});
// POST /api/follows/users/:userId
router.post("/users/:userId", authMiddleware, (ctx) => {
followUser(ctx.state.user.userId, ctx.params.userId);
ctx.response.status = 204;
});
// DELETE /api/follows/users/:userId
router.delete("/users/:userId", authMiddleware, (ctx) => {
unfollowUser(ctx.state.user.userId, ctx.params.userId);
ctx.response.status = 204;
});
// POST /api/follows/playlists/:playlistId
router.post("/playlists/:playlistId", authMiddleware, (ctx) => {
followPlaylist(ctx.state.user.userId, ctx.params.playlistId);
ctx.response.status = 204;
});
// DELETE /api/follows/playlists/:playlistId
router.delete("/playlists/:playlistId", authMiddleware, (ctx) => {
unfollowPlaylist(ctx.state.user.userId, ctx.params.playlistId);
ctx.response.status = 204;

View File

@@ -1,3 +1,9 @@
/**
* The invite routes.
*
* @module
*/
import { Router } from "@oak/oak";
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
@@ -5,9 +11,15 @@ import type { InviteResponse } from "../../shared/user.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import { createInvite, validateInvite } from "../services/invite-service.ts";
const router = new Router({ prefix: "/api/invites" });
/**
* The invite routes:
*
* - `POST /api/invites` — signed in. Makes an invite and answers its token.
* - `GET /api/invites/:token` — public. Checks an invite is valid, before the
* register page shows its form; 404 when it isn't.
*/
const router: Router = new Router({ prefix: "/api/invites" });
// Create a new invite link (any authenticated user)
router.post("/", authMiddleware, (ctx: AuthContext) => {
if (!ctx.state.user) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
@@ -20,7 +32,6 @@ router.post("/", authMiddleware, (ctx: AuthContext) => {
ctx.response.body = responseBody;
});
// Validate an invite token (used by the register page before showing the form)
router.get("/:token", (ctx) => {
try {
validateInvite(ctx.params.token);

View File

@@ -1,3 +1,9 @@
/**
* The notification routes.
*
* @module
*/
import { Router } from "@oak/oak";
import {
APIErrorCode,
@@ -14,9 +20,16 @@ import {
markOneRead,
} from "../services/notification-service.ts";
const router = new Router({ prefix: "/api/notifications" });
/**
* The notification routes, all for the caller's own notifications:
*
* - `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.
* - `PATCH /api/notifications/:id/read` — signed in. Marks one read.
*/
const router: Router = new Router({ prefix: "/api/notifications" });
// GET /api/notifications?page=N&limit=N
router.get("/", authMiddleware, (ctx: AuthContext) => {
if (!ctx.state.user) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
@@ -34,7 +47,6 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
ctx.response.body = responseBody;
});
// POST /api/notifications/read-all
router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
if (!ctx.state.user) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
@@ -43,7 +55,6 @@ router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
ctx.response.status = 204;
});
// PATCH /api/notifications/:id/read
router.patch("/:id/read", authMiddleware, (ctx) => {
const user = ctx.state.user;
if (!user) {

View File

@@ -1,5 +1,11 @@
/**
* The playlist routes.
*
* @module
*/
import { Router } from "@oak/oak";
import { parseOptionalAuth } from "../lib/auth.ts";
import { authenticateMedia, parseOptionalAuth } from "../lib/auth.ts";
import { APIErrorCode, type APIResponse } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import {
@@ -33,9 +39,35 @@ import {
validateImageUpload,
} from "../lib/upload.ts";
const router = new Router<AuthState>({ prefix: "/api/playlists" });
/**
* The playlist routes. `:playlistId` takes an id or a 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 only to its owner.
* - `PUT /api/playlists/:playlistId` — signed in, as its owner or with
* `playlist:moderate`. Updates its title, description and visibility.
* - `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
* `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB).
* - `GET /api/playlists/:playlistId/image?token=` — public; a private
* playlist's cover needs its owner's media `token`. Serves its cover; 404
* 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.
*/
const router: Router<AuthState> = new Router<AuthState>({
prefix: "/api/playlists",
});
// GET /api/playlists/by-dump/:dumpId/memberships — must be before /:playlistId
// 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;
@@ -46,7 +78,6 @@ router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => {
ctx.response.body = responseBody;
});
// POST /api/playlists — create
router.post("/", authMiddleware, async (ctx) => {
const body: unknown = await ctx.request.body.json();
if (!isCreatePlaylistRequest(body)) {
@@ -64,7 +95,6 @@ router.post("/", authMiddleware, async (ctx) => {
ctx.response.body = responseBody;
});
// GET /api/playlists/:playlistId — optional auth
router.get("/:playlistId", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx);
const responseBody: APIResponse<PlaylistWithDumps> = {
@@ -74,7 +104,6 @@ router.get("/:playlistId", async (ctx) => {
ctx.response.body = responseBody;
});
// PUT /api/playlists/:playlistId — update metadata
router.put("/:playlistId", authMiddleware, async (ctx) => {
const body: unknown = await ctx.request.body.json();
if (!isUpdatePlaylistRequest(body)) {
@@ -94,7 +123,6 @@ router.put("/:playlistId", authMiddleware, async (ctx) => {
ctx.response.body = responseBody;
});
// DELETE /api/playlists/:playlistId
router.delete("/:playlistId", authMiddleware, (ctx) => {
deletePlaylist(
ctx.params.playlistId,
@@ -104,7 +132,6 @@ router.delete("/:playlistId", authMiddleware, (ctx) => {
ctx.response.status = 204;
});
// POST /api/playlists/:playlistId/dumps/:dumpId — add dump
router.post("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
addDumpToPlaylist(
ctx.params.playlistId,
@@ -115,7 +142,6 @@ router.post("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
ctx.response.status = 204;
});
// DELETE /api/playlists/:playlistId/dumps/:dumpId — remove dump
router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
removeDumpFromPlaylist(
ctx.params.playlistId,
@@ -126,7 +152,6 @@ router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => {
ctx.response.status = 204;
});
// POST /api/playlists/:playlistId/image — upload playlist image
router.post("/:playlistId/image", authMiddleware, async (ctx) => {
const contentType = ctx.request.headers.get("content-type") ?? "";
if (!contentType.includes("multipart/form-data")) {
@@ -177,21 +202,27 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
ctx.response.body = responseBody;
});
// GET /api/playlists/:playlistId/image — serve playlist image
router.get("/:playlistId/image", async (ctx) => {
const info = getPlaylistImageInfo(ctx.params.playlistId);
// 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");
const viewerId = token ? await authenticateMedia(token) : null;
const info = getPlaylistImageInfo(ctx.params.playlistId, viewerId);
if (!info) {
ctx.response.status = 404;
return;
}
// The URL may carry a media token; keep it out of anyone else's logs.
ctx.response.headers.set("Referrer-Policy", "no-referrer");
await serveUploadedFile(
ctx,
`${PLAYLIST_IMAGES_DIR}/${info.id}`,
info.imageMime,
3600,
!info.isPublic,
);
});
// PUT /api/playlists/:playlistId/order — reorder
router.put("/:playlistId/order", authMiddleware, async (ctx) => {
const body: unknown = await ctx.request.body.json();
if (!isReorderPlaylistRequest(body)) {

View File

@@ -1,3 +1,9 @@
/**
* The link preview routes.
*
* @module
*/
import { Router } from "@oak/oak";
import { isValidHttpUrl } from "../remote/fetch.ts";
import { fetchProxiedImage } from "../remote/image-proxy.ts";
@@ -10,7 +16,15 @@ import {
import type { LinkPreview } from "../../shared/dump.ts";
import { authMiddleware } from "../middleware/auth.ts";
const previewRouter = new Router();
/**
* The link preview routes:
*
* - `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.
*/
const previewRouter: Router = new Router();
// Signed-in only: it's the create form's preview, and an open one lets anyone
// use the server to fetch pages for them.
@@ -37,6 +51,7 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
ctx.response.body = responseBody;
});
/** Answers with a proxied image, sandboxed so it can't run as a document. */
function sendImage(
ctx: { response: { headers: Headers; body?: unknown } },
bytes: Uint8Array,
@@ -52,10 +67,8 @@ function sendImage(
ctx.response.body = bytes;
}
/**
* Proxy an external image through the server so HTTP thumbnail URLs don't
* trigger mixed-content blocks when the frontend is served over HTTPS.
*/
// Proxies an external image through the server so HTTP thumbnail URLs don't
// trigger mixed-content blocks when the frontend is served over HTTPS.
previewRouter.get("/api/proxy-image", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) {

View File

@@ -1,3 +1,9 @@
/**
* The search route.
*
* @module
*/
import { Router } from "@oak/oak";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
@@ -7,7 +13,14 @@ import { searchPlaylists } from "../services/playlist-service.ts";
import type { APIResponse } from "../../shared/api.ts";
import type { SearchResults } from "../../shared/search.ts";
const router = new Router({ prefix: "/api/search" });
/**
* The search route:
*
* - `GET /api/search?q=&page=&limit=` — optional sign-in. Answers a page of the
* matching dumps with their total, and up to 20 matching users and playlists;
* private dumps and playlists only the caller's own.
*/
const router: Router = new Router({ prefix: "/api/search" });
router.get("/", async (ctx) => {
const params = ctx.request.url.searchParams;

View File

@@ -1,3 +1,9 @@
/**
* The route serving dumps' thumbnails, generated with ffmpeg where needed.
*
* @module
*/
import { Router } from "@oak/oak";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
@@ -11,12 +17,24 @@ import { serveUploadedFile } from "../lib/upload.ts";
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
import { authenticateMedia } from "../lib/auth.ts";
const router = new Router({ prefix: "/api/thumbnails" });
/**
* The route serving dumps' thumbnails:
*
* - `GET /api/thumbnails/:dumpId?token=&v=` — 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.
*/
const router: Router = new Router({ prefix: "/api/thumbnails" });
// Probed once per process: spawning `ffmpeg -version` on every uncached
// thumbnail request cost a process per card.
/**
* Whether ffmpeg runs, probed once per process: spawning `ffmpeg -version` on
* every uncached thumbnail request cost a process per card.
*/
let ffmpegProbe: Promise<boolean> | undefined;
/** Checks whether ffmpeg is available. */
function ffmpegAvailable(): Promise<boolean> {
ffmpegProbe ??= (async () => {
try {
@@ -33,16 +51,23 @@ function ffmpegAvailable(): Promise<boolean> {
return ffmpegProbe;
}
// The upload's type is only what the uploader claimed, and ffmpeg probes the
// real format from the bytes — a "video" could be an HLS or concat playlist
// pointing ffmpeg at local files or internal URLs. Read nothing but the file
// itself, and only as the kind of container it's meant to be.
/**
* The container formats ffmpeg may read a video as. The upload's type is only
* what the uploader claimed, and ffmpeg probes the real format from the bytes —
* a "video" could be an HLS or concat playlist pointing ffmpeg at local files
* or internal URLs. Read nothing but the file itself, and only as the kind of
* container it's meant to be.
*/
const VIDEO_FORMATS =
"mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc";
/** The formats ffmpeg may read an image as (see {@link VIDEO_FORMATS}). */
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
/** Images the feed shows through a scaled-down preview. GIFs keep their
* animation (served as they are); anything else has no preview to make. */
/**
* The image types the feed shows through a scaled-down preview, and the
* preview's format. GIFs keep their animation (served as they are); anything
* else has no preview to make.
*/
const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
"image/jpeg": "jpg",
"image/bmp": "jpg",
@@ -51,10 +76,17 @@ const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
"image/webp": "png",
};
/** Width of an image preview: comfortably covers the widest feed card on a
* high-density screen, a fraction of a phone photo's bytes. */
/**
* The width of an image preview: comfortably covers the widest feed card on a
* high-density screen, a fraction of a phone photo's bytes.
*/
const PREVIEW_WIDTH = 960;
/**
* Runs ffmpeg with `args` into `outPath`.
*
* @throws {Error} When ffmpeg fails.
*/
async function runFfmpeg(args: string[], outPath: string): Promise<void> {
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
// Written beside and renamed over, so a concurrent reader never gets half.
@@ -71,6 +103,7 @@ async function runFfmpeg(args: string[], outPath: string): Promise<void> {
await Deno.rename(tmpPath, outPath);
}
/** Writes a 320-pixel-wide JPEG still of a video, one second in. */
function videoStill(srcPath: string, outPath: string): Promise<void> {
return runFfmpeg([
"-format_whitelist",
@@ -90,6 +123,7 @@ function videoStill(srcPath: string, outPath: string): Promise<void> {
], outPath);
}
/** Writes a copy of an image scaled down to {@link PREVIEW_WIDTH}. */
function imagePreview(srcPath: string, outPath: string): Promise<void> {
return runFfmpeg([
"-format_whitelist",
@@ -108,10 +142,17 @@ function imagePreview(srcPath: string, outPath: string): Promise<void> {
], outPath);
}
// One ffmpeg run per output: a feed full of cards asking for the same new
// still at once waits on a single generation.
/**
* The generations under way, by output path: one ffmpeg run per output, so a
* feed full of cards asking for the same new still at once waits on a single
* generation.
*/
const inFlight = new Map<string, Promise<void>>();
/**
* Makes `outPath` with `generate` unless it exists; false when it can't be
* made (no ffmpeg, or ffmpeg failed).
*/
async function ensureGenerated(
outPath: string,
generate: () => Promise<void>,

View File

@@ -1,3 +1,9 @@
/**
* The user and account routes.
*
* @module
*/
import { Router } from "@oak/oak";
import {
@@ -64,14 +70,54 @@ import {
getFollowedUsersByUser,
} from "../services/follow-service.ts";
// Users router
const router = new Router({ prefix: "/api/users" });
/**
* The user and account routes. Public profiles never carry the password hash or
* the email:
*
* - `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.
* - `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
* page of their dumps, the private ones only to themselves.
* - `GET /api/users/:username/votes?page=&limit=` — optional sign-in. Answers a
* page of the dumps they upvoted, private ones only to themselves.
*/
const router: Router = new Router({ prefix: "/api/users" });
// A burst of 10 attempts, then one every 30s.
/** Login attempts per account: a burst of 10, then one every 30s. */
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
// The session token, plus the media-only token that goes in file and
// thumbnail URLs (see createMediaToken).
/**
* Issues `user` a session token, plus the media-only token that goes in file
* and thumbnail URLs (see {@link createMediaToken}).
*/
async function issueTokens(user: User): Promise<AuthTokens> {
return {
token: await createJWT({
@@ -83,7 +129,6 @@ async function issueTokens(user: User): Promise<AuthTokens> {
};
}
// Register a new user (requires a valid invite token)
router.post("/register", async (ctx) => {
const body: unknown = await ctx.request.body.json();
@@ -129,7 +174,6 @@ router.post("/register", async (ctx) => {
ctx.response.body = responseBody;
});
// Login
router.post("/login", async (ctx) => {
const body: unknown = await ctx.request.body.json();
@@ -179,7 +223,6 @@ router.post("/login", async (ctx) => {
ctx.response.body = responseBody;
});
// Get current user profile
router.get("/me", authMiddleware, (ctx: AuthContext) => {
try {
if (!ctx.state.user) {
@@ -206,7 +249,6 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
}
});
// A media token for a session that predates them (see createMediaToken).
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
const responseBody: APIResponse<MediaTokenResponse> = {
success: true,
@@ -215,7 +257,6 @@ router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
ctx.response.body = responseBody;
});
// Request a password reset email (unauthenticated; always returns 200)
router.post("/request-password-reset", async (ctx) => {
const body: unknown = await ctx.request.body.json();
const email = fieldOf(body, "email");
@@ -229,7 +270,6 @@ router.post("/request-password-reset", async (ctx) => {
ctx.response.body = responseBody;
});
// Consume a reset token and set a new password (unauthenticated)
router.post("/reset-password", async (ctx) => {
const body: unknown = await ctx.request.body.json();
const token = fieldOf(body, "token");
@@ -246,7 +286,6 @@ router.post("/reset-password", async (ctx) => {
ctx.response.body = responseBody;
});
// Change current user's password (requires current password verification)
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
const body: unknown = await ctx.request.body.json();
const currentPassword = fieldOf(body, "currentPassword");
@@ -293,7 +332,6 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
ctx.response.body = responseBody;
});
// Update current user profile (description, etc.)
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
const body: unknown = await ctx.request.body.json();
if (!isUpdateUserRequest(body)) {
@@ -320,7 +358,6 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
ctx.response.body = responseBody;
});
// Set another user's role. Requires the user:manage permission.
router.patch(
"/:userId/role",
authMiddleware,
@@ -355,7 +392,6 @@ router.patch(
},
);
// User search for @mention autocomplete
router.get("/search", (ctx) => {
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
const responseBody: APIResponse<UserSuggestion[]> = {
@@ -365,7 +401,6 @@ router.get("/search", (ctx) => {
ctx.response.body = responseBody;
});
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
router.get("/by-id/:userId", (ctx) => {
const responseBody: APIResponse<PublicUser> = {
success: true,
@@ -374,7 +409,6 @@ router.get("/by-id/:userId", (ctx) => {
ctx.response.body = responseBody;
});
// Followed users for a user (public)
router.get("/:username/followed-users", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
@@ -389,7 +423,6 @@ router.get("/:username/followed-users", (ctx) => {
ctx.response.body = responseBody;
});
// Followed playlists for a user (public only)
router.get("/:username/followed-playlists", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
@@ -401,7 +434,6 @@ router.get("/:username/followed-playlists", (ctx) => {
ctx.response.body = responseBody;
});
// Playlists by user (optional auth: include private only if requester === owner)
router.get("/:username/playlists", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
const requestingUserId = await parseOptionalAuth(ctx);
@@ -419,7 +451,6 @@ router.get("/:username/playlists", async (ctx) => {
ctx.response.body = responseBody;
});
// Invite tree for a user (public)
router.get("/:username/invitees", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const responseBody: APIResponse<Invitee[]> = {
@@ -429,7 +460,6 @@ router.get("/:username/invitees", (ctx) => {
ctx.response.body = responseBody;
});
// Public user profile by username (no passwordHash)
router.get("/:username", (ctx) => {
const responseBody: APIResponse<PublicUser> = {
success: true,
@@ -438,7 +468,6 @@ router.get("/:username", (ctx) => {
ctx.response.body = responseBody;
});
// Dumps posted by user (optional auth: owner sees their private dumps)
router.get("/:username/dumps", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
const requestingUserId = await parseOptionalAuth(ctx);
@@ -457,7 +486,6 @@ router.get("/:username/dumps", async (ctx) => {
ctx.response.body = responseBody;
});
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
router.get("/:username/votes", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
const requestingUserId = await parseOptionalAuth(ctx);

View File

@@ -1,3 +1,9 @@
/**
* The WebSocket route, and the handling of what clients send over it.
*
* @module
*/
import { Router } from "@oak/oak";
import { ALLOWED_ORIGINS } from "../config.ts";
import { authenticate } from "../lib/auth.ts";
@@ -50,8 +56,18 @@ import type {
} from "../../shared/ws.ts";
import type { User } from "../model/user.ts";
const router = new Router();
/**
* The WebSocket route:
*
* - `GET /ws?token=` — public; a session `token` signs the socket in, and an
* invalid one leaves it anonymous. Opens the socket that carries presence,
* live updates, votes, comment likes and the chat (see
* `ClientToServerMessage` and `ServerToClientMessage` in shared/ws.ts); 403
* from an origin not in {@link ALLOWED_ORIGINS}.
*/
const router: Router = new Router();
/** Checks whether a socket may be opened from `origin`. */
function isAllowedOrigin(origin: string): boolean {
return ALLOWED_ORIGINS.includes(origin);
}
@@ -132,6 +148,7 @@ router.get("/ws", async (ctx) => {
});
});
/** Parses one message from a client and dispatches it by `type`. */
function handleMessage(client: WsClient, data: unknown): void {
if (typeof data !== "string") return;
let parsed: unknown;
@@ -195,11 +212,15 @@ function handleMessage(client: WsClient, data: unknown): void {
}
}
// Sends only while the socket is open: `send` on a closing socket throws.
/** Sends a message only while the socket is open: `send` on a closing socket throws. */
function send(socket: WebSocket, data: ServerToClientMessage): void {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
}
/**
* Casts or removes the client's upvote, acknowledges it, and broadcasts the new
* count.
*/
function handleVote(
client: WsClient,
dumpId: unknown,
@@ -240,6 +261,10 @@ function handleVote(
}
}
/**
* Casts or removes the client's like on a comment, acknowledges it, and
* broadcasts the new count.
*/
function handleCommentLike(
client: WsClient,
commentId: unknown,
@@ -278,14 +303,21 @@ function handleCommentLike(
}
}
// Sending, editing and deleting share one bucket: a burst of 8, then one every
// 1.5s — room for a quick back-and-forth, not for a flood.
/**
* Chat writes per user. Sending, editing and deleting share one bucket: a
* burst of 8, then one every 1.5s — room for a quick back-and-forth, not for a
* flood.
*/
const CHAT_WRITE_LIMIT: RateLimit = { capacity: 8, refill: 1 / 1.5 };
/** Typing signals per user. */
const TYPING_LIMIT: RateLimit = { capacity: 10, refill: 1 };
// Read markers are cheap for the client to send often; past this they're
// dropped, and the next one catches up.
/**
* Read markers per user. They are cheap for the client to send often; past
* this they're dropped, and the next one catches up.
*/
const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 };
/** Reports a failed chat write to the client, with the nonce of its pending copy. */
function sendChatError(
client: WsClient,
message: string,
@@ -294,8 +326,10 @@ function sendChatError(
send(client.socket, { type: "error", scope: "chat", message, nonce });
}
// Validates the common preconditions of a chat write: signed in and under the
// rate limit. Returns the user id, or undefined after reporting why not.
/**
* Validates the common preconditions of a chat write: signed in and under the
* rate limit. Returns the user id, or undefined after reporting why not.
*/
function chatWriter(client: WsClient, nonce?: string): string | undefined {
if (!client.userId) {
sendChatError(client, "Authentication required", nonce);
@@ -312,12 +346,17 @@ function chatWriter(client: WsClient, nonce?: string): string | undefined {
return client.userId;
}
/** Returns `value` when it is a string of at most `maxLength`, else undefined. */
function optionalString(value: unknown, maxLength: number): string | undefined {
return typeof value === "string" && value.length <= maxLength
? value
: undefined;
}
/**
* Posts a chat message, broadcasts it, and notifies the @mentioned users not
* already reading the chat.
*/
function handleChatSend(
client: WsClient,
body: unknown,
@@ -364,9 +403,12 @@ function handleChatSend(
}
}
// Reads the role from the DB on every moderation check rather than trusting the
// one captured at connect: a demoted moderator loses the power right away, not
// whenever their socket next reconnects.
/**
* Checks whether the client's user may moderate the chat. Reads the role from
* the DB on every check rather than trusting the one captured at connect: a
* demoted moderator loses the power right away, not whenever their socket next
* reconnects.
*/
function canModerateChat(client: WsClient): boolean {
if (!client.userId) return false;
try {
@@ -377,6 +419,7 @@ function canModerateChat(client: WsClient): boolean {
return !!client.role && hasPermission(client.role, "chat:moderate");
}
/** Edits a chat message, broadcasts it, and notifies newly @mentioned users. */
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
const userId = chatWriter(client);
if (!userId) return;
@@ -412,6 +455,7 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
}
}
/** Deletes a chat message and broadcasts it. */
function handleChatDelete(client: WsClient, id: unknown): void {
const userId = chatWriter(client);
if (!userId) return;
@@ -431,8 +475,10 @@ function handleChatDelete(client: WsClient, id: unknown): void {
}
}
// Moves the user's read marker and tells all their sockets, so every tab and
// device drops its badge together.
/**
* Moves the user's read marker and tells all their sockets, so every tab and
* device drops its badge together.
*/
function handleChatRead(client: WsClient, at: unknown): void {
if (!client.userId || typeof at !== "string") return;
if (!takeToken(`read:${client.userId}`, CHAT_READ_LIMIT)) return;

View File

@@ -1,16 +1,24 @@
/**
* The YouTube route.
*
* @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";
const youtubeRouter = new Router();
/**
* Resolve a YouTube playlist ID to its tracklist (titles and lengths).
* The YouTube route, for the player's tracklist:
*
* Public, like /api/bandcamp/tracks: it only reads a public page. The client
* treats any failure as "show the embed without a tracklist".
* - `GET /api/youtube/playlist?list=` — public. Resolves a YouTube playlist id
* to its tracklist (titles and lengths).
*/
const youtubeRouter: Router = new Router();
// Public, like /api/bandcamp/tracks: it only reads a public page. The client
// treats any failure as "show the embed without a tracklist".
youtubeRouter.get("/api/youtube/playlist", async (ctx) => {
const listId = ctx.request.url.searchParams.get("list") ?? "";
const responseBody: APIResponse<YouTubePlaylist> = {