v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The attachment routes, for images pasted into Markdown bodies.
|
||||
* Stores and serves the images pasted or dropped into the text editor, which
|
||||
* puts each one in the body as a link to its `GET` URL.
|
||||
*
|
||||
* - `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.
|
||||
* most 5 MB) and answers its id.
|
||||
* - `GET /api/attachments/:attachmentId` — public. Serves the image.
|
||||
*
|
||||
* @module
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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();
|
||||
|
||||
router.post("/api/attachments", authMiddleware, async (ctx) => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* The avatar routes.
|
||||
* Serves the avatar endpoints. The profile page uploads the avatar, 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
|
||||
* `file`, at most 5 MB), announces it, and answers the caller's user.
|
||||
* `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
|
||||
* one.
|
||||
*
|
||||
@@ -26,7 +29,10 @@ import {
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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();
|
||||
|
||||
router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
@@ -56,16 +62,15 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
try {
|
||||
updateUserAvatar(authPayload.userId, mime);
|
||||
} catch (err) {
|
||||
// DB write failed — clean up the orphaned file
|
||||
// The row doesn't describe the new file, so don't keep it.
|
||||
await Deno.remove(filePath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
updateClientAvatar(authPayload.userId, mime);
|
||||
|
||||
const user = getUserById(authPayload.userId);
|
||||
// Let every client refresh this user's avatar wherever it's shown (feeds,
|
||||
// comments, OP, profiles) — not just the presence row. updated_at, bumped on
|
||||
// the avatar write, doubles as the cache-busting version.
|
||||
// Presence alone would update only the online list; `user_updated` reaches
|
||||
// every avatar on screen, and its bumped `updatedAt` busts their cache.
|
||||
broadcastUserUpdated(toPublicUser(user));
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* The Bandcamp route, for the native player.
|
||||
* 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. Resolves a Bandcamp page
|
||||
* to its streamable tracks, at play time; `force=1` bypasses the cache.
|
||||
* - `GET /api/bandcamp/tracks?url=&force=` — public. Answers the page's
|
||||
* tracks and their stream URLs; `force=1` skips the server's cache.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,13 +13,14 @@ import type { APIResponse } from "../../shared/api.ts";
|
||||
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
|
||||
import type { Tralbum } from "../../shared/player.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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 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".
|
||||
// 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";
|
||||
@@ -28,8 +30,8 @@ bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
|
||||
data: await resolveBandcamp(url, { force }),
|
||||
};
|
||||
|
||||
// Private: the signed URLs in the body are per-fetch, so they must not be
|
||||
// held in a shared cache. Well inside the 24h signature life either way.
|
||||
// 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;
|
||||
});
|
||||
|
||||
@@ -41,7 +41,10 @@ import {
|
||||
updateCategory,
|
||||
} from "../services/category-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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/categories" });
|
||||
|
||||
router.get("/", (ctx) => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* The chat history route; messages are posted and edited over the socket (see
|
||||
* routes/ws.ts).
|
||||
* Serves the chat's history to the chatbox (`ChatModal`). Messages are sent,
|
||||
* edited and deleted over the socket instead (`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.
|
||||
* - `GET /api/chat?limit=&before=&beforeId=&after=&afterId=`, signed in.
|
||||
* Returns messages oldest first, at most `limit` (50 by default, 100 at
|
||||
* most). With no cursor, it returns the latest. With `before`, it returns
|
||||
* the page just older than that cursor, for "load older". With `after`, it
|
||||
* returns what follows it, for the resync after a reconnect. A cursor is a
|
||||
* message's ISO `created_at`, and its id breaks ties. Without `afterId`,
|
||||
* messages at the `after` timestamp itself are included.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -21,7 +22,10 @@ import {
|
||||
getRecentChatMessages,
|
||||
} from "../services/chat-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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/chat" });
|
||||
|
||||
router.get("/", authMiddleware, (ctx) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The comment routes. A private dump's comments exist only for its poster, and
|
||||
* changes to them aren't broadcast.
|
||||
* Serves the comments on dumps. The comments of a private dump 404 for anyone
|
||||
* but its poster, and changes to them aren't broadcast. Comments are otherwise
|
||||
* broadcast over the WebSocket as they are posted, edited and deleted.
|
||||
*
|
||||
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
|
||||
* on a dump, oldest first.
|
||||
@@ -49,7 +50,10 @@ import {
|
||||
broadcastCommentUpdated,
|
||||
} from "../services/ws-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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" });
|
||||
|
||||
router.get("/dumps/:dumpId/comments", async (ctx) => {
|
||||
@@ -111,7 +115,7 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||
router.get("/comments/:commentId/likers", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const dumpId = getCommentDumpId(ctx.params.commentId);
|
||||
getDump(dumpId, requestingUserId); // enforces privacy, 404s if not visible
|
||||
getDump(dumpId, requestingUserId); // 404s unless the caller may see the dump.
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = getCommentLikers(
|
||||
ctx.params.commentId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The dump routes. `:dumpId` takes an id or a slug; a private dump exists only
|
||||
* for its poster.
|
||||
* 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.
|
||||
*
|
||||
* - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file
|
||||
* (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`).
|
||||
@@ -14,9 +15,10 @@
|
||||
* - `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.
|
||||
* 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 (multipart).
|
||||
* `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.
|
||||
@@ -73,16 +75,18 @@ import { getDumpVoters } from "../services/vote-service.ts";
|
||||
import { getRelatedDumps } from "../services/backlink-service.ts";
|
||||
import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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/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.
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Reads the category ids that arrive on the multipart create path as a
|
||||
* JSON-encoded string field; empty when it is missing or malformed.
|
||||
* 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 [];
|
||||
@@ -191,7 +195,6 @@ router.get("/:dumpId/related", async (ctx) => {
|
||||
|
||||
router.get("/:dumpId/playlists", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
// Resolve through getDump so private dumps 404 for anyone but their owner.
|
||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||
const playlists = getPlaylistsForDump(dump.id, requestingUserId);
|
||||
const responseBody: APIResponse<Playlist[]> = {
|
||||
@@ -309,7 +312,8 @@ router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const mime = validateImageUpload(data);
|
||||
|
||||
// DB update first (resolves slug → id), then file write.
|
||||
// The record is written first. If the file write then fails, the thumbnail
|
||||
// route finds no custom file and falls back to the generated thumbnail.
|
||||
const updatedDump = setDumpThumbnail(dump.id, mime);
|
||||
const filePath = `${THUMBNAILS_DIR}/${dump.id}-custom`;
|
||||
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* The route serving file dumps' uploads.
|
||||
* Serves file dumps' uploads to the players, viewers and download links of the
|
||||
* frontend, which builds the URL with `dumpFileUrl`.
|
||||
*
|
||||
* - `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.
|
||||
* - `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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -16,16 +17,16 @@ import { authenticateMedia } from "../lib/auth.ts";
|
||||
import { serveFile } from "../lib/serve-file.ts";
|
||||
import { dumpFileVersion, getDump } from "../services/dump-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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" });
|
||||
|
||||
/**
|
||||
* Builds an RFC 6266 `Content-Disposition` value that is always a valid
|
||||
* ByteString. `headers.set` throws a TypeError if the value contains any
|
||||
* character > U+00FF, so a filename with an emoji / accent / CJK character
|
||||
* (e.g. an uploaded file) would otherwise crash the response. We provide an
|
||||
* ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the
|
||||
* full UTF-8 name for modern browsers.
|
||||
* Builds a `Content-Disposition` value for a file called `name`. `Headers.set`
|
||||
* throws on any character above U+00FF, so `filename` gets an ASCII copy of the
|
||||
* name and `filename*` (RFC 5987) the full one, percent-encoded.
|
||||
*/
|
||||
function contentDisposition(
|
||||
type: "inline" | "attachment",
|
||||
@@ -40,17 +41,16 @@ function contentDisposition(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Matches the types served inline. An upload's type is whatever the uploader's
|
||||
* browser claimed, and the file comes from the app's own origin, where the
|
||||
* session token lives. So only types a browser shows without running code in
|
||||
* them are inline, and anything else (HTML, SVG, XML…) downloads. `<img>`,
|
||||
* `<audio>` and `fetch()` ignore `Content-Disposition`, so the players and
|
||||
* previews 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());
|
||||
}
|
||||
@@ -58,14 +58,13 @@ function isInlineSafe(mime: string): boolean {
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
// Guard against path traversal (UUIDs are safe, but be explicit)
|
||||
// The id names a file on disk: nothing but an id's characters gets through.
|
||||
if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
|
||||
}
|
||||
|
||||
// Media elements can't send an Authorization header, so the viewer's token
|
||||
// arrives as a query param (same as the WebSocket route). Needed to authorize
|
||||
// access to private dumps the requester owns.
|
||||
// Media elements can't send an Authorization header, so the viewer's media
|
||||
// token comes in the query string.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
@@ -98,10 +97,9 @@ router.get("/:dumpId", async (ctx) => {
|
||||
// links.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
|
||||
// The app's URLs carry the file's version (see dumpFileVersion), so one
|
||||
// naming the current version never changes and is cached for good; any
|
||||
// other URL is revalidated against the ETag. A private dump's bytes must not
|
||||
// sit in a shared cache.
|
||||
// A URL naming the file's current version (dumpFileVersion) always serves the
|
||||
// same bytes, so it is cached for good; any other is revalidated against the
|
||||
// ETag. A private dump's bytes must not sit in a shared cache.
|
||||
const scope = dump.isPrivate ? "private" : "public";
|
||||
const current = ctx.request.url.searchParams.get("v") ===
|
||||
dumpFileVersion(dump);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The follow routes, all for what the caller follows.
|
||||
* 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`.
|
||||
*
|
||||
* - `GET /api/follows/status` — signed in. Answers the ids of the users and
|
||||
* playlists they follow.
|
||||
@@ -37,10 +38,9 @@ import {
|
||||
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.
|
||||
* Returns the id of the category the `?category=` slug names, undefined without
|
||||
* one, or "missing" when it names none, so that the feed comes back empty
|
||||
* rather than unfiltered.
|
||||
*/
|
||||
function resolveCategoryFilter(
|
||||
params: URLSearchParams,
|
||||
@@ -50,14 +50,14 @@ function resolveCategoryFilter(
|
||||
return getCategoryIdBySlug(slug) ?? "missing";
|
||||
}
|
||||
|
||||
/** The feed a filter naming no category yields. */
|
||||
const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false };
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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" });
|
||||
|
||||
// Static routes first to prevent Oak matching "status"/"feed" as a :param
|
||||
|
||||
router.get("/status", authMiddleware, (ctx) => {
|
||||
const status = getFollowStatus(ctx.state.user.userId);
|
||||
const body: APIResponse<FollowStatus> = { success: true, data: status };
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* The invite routes.
|
||||
* Serves the invite endpoints. Any signed-in user can invite someone, from
|
||||
* their profile page, and the invite link opens the `/register` page.
|
||||
*
|
||||
* - `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.
|
||||
* - `GET /api/invites/:token` — public. Checks that an invite still works,
|
||||
* before the register page shows its form; 404 when it doesn't.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -15,7 +16,10 @@ import type { InviteResponse } from "../../shared/user.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import { createInvite, validateInvite } from "../services/invite-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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/invites" });
|
||||
|
||||
router.post("/", authMiddleware, (ctx: AuthContext) => {
|
||||
@@ -31,6 +35,7 @@ router.post("/", authMiddleware, (ctx: AuthContext) => {
|
||||
});
|
||||
|
||||
router.get("/:token", (ctx) => {
|
||||
// A used invite answers 404 too: the page only needs to know it won't work.
|
||||
try {
|
||||
validateInvite(ctx.params.token);
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* The notification routes, all for the caller's own notifications.
|
||||
* Serves the caller's own notifications to the client's `/notifications`
|
||||
* 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.
|
||||
* - `PATCH /api/notifications/:id/read` — signed in. Marks one read.
|
||||
* - `PATCH /api/notifications/:id/read` — signed in. Marks one of them read.
|
||||
* The client doesn't use it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -25,7 +28,10 @@ import {
|
||||
markOneRead,
|
||||
} from "../services/notification-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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" });
|
||||
|
||||
router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* The playlist routes. `:playlistId` takes an id or a slug.
|
||||
* Serves playlists, their dumps and their covers. `: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.
|
||||
* 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.
|
||||
* - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with
|
||||
@@ -16,8 +18,8 @@
|
||||
* 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
|
||||
* - `GET /api/playlists/:playlistId/image?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.
|
||||
@@ -60,7 +62,10 @@ import {
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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<AuthState> = new Router<AuthState>({
|
||||
prefix: "/api/playlists",
|
||||
});
|
||||
@@ -170,11 +175,10 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const mime = validateImageUpload(data);
|
||||
|
||||
// The file goes in before the row changes. Setting image_mime and bumping
|
||||
// updated_at announces the cover (the `?v=` on its URL, the socket event),
|
||||
// so the bytes must already be there: an earlier fetch would cache the old
|
||||
// cover under the new URL, and a failed write would leave a cover that 404s.
|
||||
// Written beside and renamed over, so the old cover is never half-replaced.
|
||||
// The file is written before setPlaylistImage announces it, or clients would
|
||||
// fetch the new `?v=` URL too early and cache the old cover under it. It is
|
||||
// written beside the old one and renamed over it, so a failed write leaves
|
||||
// the old cover whole.
|
||||
const { id } = getEditablePlaylist(
|
||||
ctx.params.playlistId,
|
||||
ctx.state.user.userId,
|
||||
@@ -210,7 +214,7 @@ router.get("/:playlistId/image", async (ctx) => {
|
||||
ctx.response.status = 404;
|
||||
return;
|
||||
}
|
||||
// The URL may carry a media token; keep it out of anyone else's logs.
|
||||
// The URL may carry a media token, which a Referer would leak.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
await serveUploadedFile(
|
||||
ctx,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The link preview routes.
|
||||
* Fetches link previews for the create form, and proxies the remote images
|
||||
* that the frontend's thumbnails can't load directly.
|
||||
*
|
||||
* - `GET /api/preview?url=` — signed in. Answers the preview of a link, and
|
||||
* whether its page could be reached.
|
||||
@@ -21,11 +22,13 @@ import {
|
||||
import type { LinkPreview } from "../../shared/dump.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// Signed-in only: it's the create form's preview, and an open one lets anyone
|
||||
// use the server to fetch pages for them.
|
||||
// Signed-in only: an open preview would let anyone make the server fetch pages.
|
||||
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
@@ -37,10 +40,6 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
return;
|
||||
}
|
||||
// `reached` is reported separately because a failed fetch still yields a
|
||||
// usable stub (hostname only). Without the flag the create form cannot tell
|
||||
// "this page has no preview" from "this link is dead", and shows the same
|
||||
// bare card for both.
|
||||
const { ok, content } = await tryFetchRichContent(url);
|
||||
const responseBody: APIResponse<LinkPreview> = {
|
||||
success: true,
|
||||
@@ -49,7 +48,10 @@ 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. */
|
||||
/**
|
||||
* Answers with a proxied image. It is sent from this site's origin, so it is
|
||||
* sandboxed in case a browser opens it as a document.
|
||||
*/
|
||||
function sendImage(
|
||||
ctx: { response: { headers: Headers; body?: unknown } },
|
||||
bytes: Uint8Array,
|
||||
@@ -65,8 +67,8 @@ function sendImage(
|
||||
ctx.response.body = bytes;
|
||||
}
|
||||
|
||||
// Proxies an external image through the server so HTTP thumbnail URLs don't
|
||||
// trigger mixed-content blocks when the frontend is served over HTTPS.
|
||||
// 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) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* The search route.
|
||||
* Searches dumps, users and playlists at once, for the client's `Search` page.
|
||||
* Only the dumps are paged. Private dumps and playlists are found only by
|
||||
* their owner.
|
||||
*
|
||||
* - `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.
|
||||
* matching dumps with their total, and up to 20 matching users and
|
||||
* playlists. A blank `q` finds nothing.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,7 +19,10 @@ import { searchPlaylists } from "../services/playlist-service.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type { SearchResults } from "../../shared/search.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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/search" });
|
||||
|
||||
router.get("/", async (ctx) => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* The route serving dumps' thumbnails, generated with ffmpeg where needed.
|
||||
* 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`.
|
||||
*
|
||||
* - `GET /api/thumbnails/:dumpId?token=&v=` — public; a private dump's
|
||||
* - `GET /api/thumbnails/:dumpId?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
|
||||
@@ -23,16 +25,18 @@ import { serveUploadedFile } from "../lib/upload.ts";
|
||||
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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" });
|
||||
|
||||
/**
|
||||
* Whether ffmpeg runs, probed once per process: spawning `ffmpeg -version` on
|
||||
* every uncached thumbnail request cost a process per card.
|
||||
* Holds whether ffmpeg runs, probed once per process: probing 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 {
|
||||
@@ -50,21 +54,23 @@ function ffmpegAvailable(): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Lists the container formats ffmpeg may read a video as. The upload's type is
|
||||
* only what the uploader claimed, and ffmpeg guesses the format from the bytes,
|
||||
* so a "video" could be an HLS or concat playlist pointing it at local files or
|
||||
* internal URLs. With this list, and `-protocol_whitelist file`, ffmpeg reads
|
||||
* nothing but the file, and only as a video.
|
||||
*/
|
||||
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}). */
|
||||
/**
|
||||
* Lists the formats ffmpeg may read an image as, for the reason given at
|
||||
* {@link VIDEO_FORMATS}.
|
||||
*/
|
||||
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Maps the image types that get a scaled-down preview to the preview's format.
|
||||
* A GIF is served as it is, to keep its animation.
|
||||
*/
|
||||
const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
|
||||
"image/jpeg": "jpg",
|
||||
@@ -75,13 +81,13 @@ const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Caps an image preview's width. It covers the widest feed card on a
|
||||
* high-density screen, at a fraction of a phone photo's bytes.
|
||||
*/
|
||||
const PREVIEW_WIDTH = 960;
|
||||
|
||||
/**
|
||||
* Runs ffmpeg with `args` into `outPath`.
|
||||
* Runs ffmpeg with `args`, writing to `outPath`.
|
||||
*
|
||||
* @throws {Error} When ffmpeg fails.
|
||||
*/
|
||||
@@ -121,7 +127,7 @@ function videoStill(srcPath: string, outPath: string): Promise<void> {
|
||||
], outPath);
|
||||
}
|
||||
|
||||
/** Writes a copy of an image scaled down to {@link PREVIEW_WIDTH}. */
|
||||
/** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */
|
||||
function imagePreview(srcPath: string, outPath: string): Promise<void> {
|
||||
return runFfmpeg([
|
||||
"-format_whitelist",
|
||||
@@ -141,15 +147,14 @@ function imagePreview(srcPath: string, outPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Holds the generations under way, by output path. A feed full of cards asking
|
||||
* for the same new still at once then waits on a single ffmpeg run.
|
||||
*/
|
||||
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).
|
||||
* Makes `outPath` with `generate` unless it exists. Resolves false when it
|
||||
* can't be made, because ffmpeg is missing or failed.
|
||||
*/
|
||||
async function ensureGenerated(
|
||||
outPath: string,
|
||||
@@ -168,20 +173,22 @@ async function ensureGenerated(
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
// The id names files on disk: nothing but an id's characters gets through.
|
||||
if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
|
||||
}
|
||||
|
||||
// Token via query param (media elements can't send headers) — authorizes the
|
||||
// owner's private dumps and gates the custom-thumbnail branch below too.
|
||||
// Media elements can't send an Authorization header, so the viewer's media
|
||||
// token comes in the query string. This privacy check covers the custom
|
||||
// thumbnail too, since getDumpThumbnailInfo makes none.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
// The URL may carry a media token; keep it out of anyone else's logs.
|
||||
// The URL may carry a media token, which must not leak through a Referer.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
// A private dump's thumbnail must not sit in a shared cache. The app's URLs
|
||||
// carry the file's version (see dumpFileVersion): what's generated from the
|
||||
// file is cached for good under the current one, and revalidated otherwise.
|
||||
// What is generated from the file is cached for good under a URL naming its
|
||||
// current version (dumpFileVersion), and revalidated under any other. A
|
||||
// private dump's thumbnail must not sit in a shared cache.
|
||||
const scope = dump.isPrivate ? "private" : "public";
|
||||
const generatedCache =
|
||||
ctx.request.url.searchParams.get("v") === dumpFileVersion(dump)
|
||||
@@ -190,8 +197,8 @@ router.get("/:dumpId", async (ctx) => {
|
||||
|
||||
const customThumb = getDumpThumbnailInfo(dumpId);
|
||||
if (customThumb) {
|
||||
// Replaceable without the URL changing: always revalidated (a 304 when
|
||||
// unchanged).
|
||||
// Replacing it doesn't change the file's version, so it is always
|
||||
// revalidated.
|
||||
const served = await serveUploadedFile(
|
||||
ctx,
|
||||
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The user and account routes. Public profiles never carry the password hash or
|
||||
* the email.
|
||||
* 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.
|
||||
*
|
||||
* - `POST /api/users/register` — public, with a valid invite. Creates an
|
||||
* account, sends the welcome email, and answers the user and their tokens.
|
||||
@@ -106,15 +107,18 @@ import {
|
||||
getFollowedUsersByUser,
|
||||
} from "../services/follow-service.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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" });
|
||||
|
||||
/** Login attempts per account: a burst of 10, then one every 30s. */
|
||||
/** Allows a burst of 10 login attempts per account, then one every 30s. */
|
||||
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
|
||||
|
||||
/**
|
||||
* Issues `user` a session token, plus the media-only token that goes in file
|
||||
* and thumbnail URLs (see {@link createMediaToken}).
|
||||
* 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 {
|
||||
@@ -138,8 +142,8 @@ router.post("/register", async (ctx) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Claim the invite up front (throws 404/409 if bad) and hand it back if the
|
||||
// account can't be created, so one invite can't open two accounts.
|
||||
// Claimed before the async account creation, so that one invite can't open
|
||||
// two accounts, and handed back if the creation fails.
|
||||
const inviterId = claimInvite(body.inviteToken);
|
||||
|
||||
let user;
|
||||
@@ -150,7 +154,7 @@ router.post("/register", async (ctx) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Send welcome email (fire-and-forget)
|
||||
// Not awaited: a slow or failing SMTP server mustn't hold up the sign-up.
|
||||
if (isEmailEnabled()) {
|
||||
const emailMarkdown = WELCOME_EMAIL_BODY
|
||||
.replaceAll("{{username}}", user.username)
|
||||
@@ -255,6 +259,8 @@ router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
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");
|
||||
@@ -339,10 +345,9 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
"Invalid request",
|
||||
);
|
||||
}
|
||||
// Only the profile fields. The role is assigned by an admin, the password
|
||||
// changes through /me/change-password (which asks for the current one), and
|
||||
// the username is fixed — a token alone must not be able to change any of
|
||||
// them.
|
||||
// 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,
|
||||
@@ -370,8 +375,7 @@ router.patch(
|
||||
"Invalid role",
|
||||
);
|
||||
}
|
||||
// Disallow changing your own role: prevents an admin from accidentally
|
||||
// locking themselves out, and self-promotion is meaningless anyway.
|
||||
// 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(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
|
||||
123
api/routes/ws.ts
123
api/routes/ws.ts
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* The WebSocket route, and the handling of what clients send over it.
|
||||
* Opens the WebSocket that `WSProvider` keeps for each tab, and handles what
|
||||
* the client sends over it. Voting, comment likes and every chat write go
|
||||
* through here, not through HTTP. The messages are typed in `shared/ws.ts`,
|
||||
* and `services/ws-service.ts` sends the broadcasts.
|
||||
*
|
||||
* - `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}.
|
||||
* - `GET /ws?token=`, public. A valid session `token` signs the socket in, and
|
||||
* an invalid one leaves it anonymous. Answers 403 to an origin not in
|
||||
* {@link ALLOWED_ORIGINS}, and 426 to a request that isn't an upgrade.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -62,10 +63,17 @@ import type {
|
||||
} from "../../shared/ws.ts";
|
||||
import type { User } from "../model/user.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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();
|
||||
|
||||
/** Checks whether a socket may be opened from `origin`. */
|
||||
/**
|
||||
* Checks whether a socket may be opened from `origin`. Browsers don't apply
|
||||
* CORS to WebSockets, so this is what stops another site from opening one with
|
||||
* its visitors' tokens.
|
||||
*/
|
||||
function isAllowedOrigin(origin: string): boolean {
|
||||
return ALLOWED_ORIGINS.includes(origin);
|
||||
}
|
||||
@@ -82,9 +90,8 @@ router.get("/ws", async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// authenticate() refuses a token whose user is gone or which predates a
|
||||
// password change, and reads the live role from the DB. Such a socket is
|
||||
// simply anonymous; the client's next API call gets the 401.
|
||||
// A revoked token (deleted user, changed password) opens an anonymous
|
||||
// socket. The client signs out on its next API call, which gets a 401.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const authPayload = token ? await authenticate(token) : null;
|
||||
let user: User | undefined;
|
||||
@@ -104,9 +111,11 @@ router.get("/ws", async (ctx) => {
|
||||
avatarMime: user?.avatarMime,
|
||||
};
|
||||
|
||||
// Use addEventListener — more reliable than onopen= with Deno.serve
|
||||
// addEventListener, since `onopen =` proved unreliable under Deno.serve.
|
||||
socket.addEventListener("open", () => {
|
||||
register(client);
|
||||
// The new socket is registered, so it gets this presence_update too,
|
||||
// before its welcome.
|
||||
broadcastPresence();
|
||||
|
||||
try {
|
||||
@@ -141,12 +150,16 @@ router.get("/ws", async (ctx) => {
|
||||
socket.addEventListener("close", () => {
|
||||
unregister(client);
|
||||
broadcastPresence();
|
||||
// If they were mid-compose, drop them from the typing set right away.
|
||||
// The sweep only sees registered clients, so it would never clear this one.
|
||||
if (client.typingAt) broadcastTyping();
|
||||
});
|
||||
});
|
||||
|
||||
/** Parses one message from a client and dispatches it by `type`. */
|
||||
/**
|
||||
* Parses one message from a client and dispatches it by `type`. A message that
|
||||
* isn't JSON, or whose `type` is unknown, is ignored. Its fields are still
|
||||
* untrusted, so each handler checks their types.
|
||||
*/
|
||||
function handleMessage(client: WsClient, data: unknown): void {
|
||||
if (typeof data !== "string") return;
|
||||
let parsed: unknown;
|
||||
@@ -155,8 +168,6 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Anything but an object with a string `type` (`null`, a number, an array)
|
||||
// is ignored rather than read from.
|
||||
if (
|
||||
typeof parsed !== "object" || parsed === null ||
|
||||
typeof (parsed as { type?: unknown }).type !== "string"
|
||||
@@ -187,7 +198,7 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
handleChatSend(client, msg.body, msg.replyToId, msg.nonce);
|
||||
break;
|
||||
case "chat_typing":
|
||||
// Typing is cosmetic: past the limit it's just dropped.
|
||||
// Typing is cosmetic: past the limit it's dropped, with no error.
|
||||
if (
|
||||
client.userId &&
|
||||
takeToken(`typing:${client.userId}`, TYPING_LIMIT)
|
||||
@@ -210,14 +221,15 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a message only while the socket is open: `send` on a closing socket throws. */
|
||||
/** Sends `data` unless the socket is closing, where `send` would throw. */
|
||||
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.
|
||||
* Handles `vote_cast` and `vote_remove`. It acks the voter with `vote_ack` and
|
||||
* broadcasts `votes_update`. A failure goes back as an `error` naming the dump
|
||||
* and the action, so the client can undo its optimistic vote at once.
|
||||
*/
|
||||
function handleVote(
|
||||
client: WsClient,
|
||||
@@ -244,8 +256,7 @@ function handleVote(
|
||||
voteCount,
|
||||
});
|
||||
|
||||
// Only a private dump's owner can vote on it, so they're its whole
|
||||
// audience.
|
||||
// Only a private dump's owner can see it, and so vote on it.
|
||||
broadcastVoteUpdate(
|
||||
dumpId,
|
||||
voteCount,
|
||||
@@ -260,8 +271,8 @@ function handleVote(
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts or removes the client's like on a comment, acknowledges it, and
|
||||
* broadcasts the new count.
|
||||
* Handles `comment_like_cast` and `comment_like_remove`, as
|
||||
* {@link handleVote} does votes.
|
||||
*/
|
||||
function handleCommentLike(
|
||||
client: WsClient,
|
||||
@@ -302,20 +313,23 @@ function handleCommentLike(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Limits each user's chat writes, sends, edits and deletes together, to a burst
|
||||
* of 8, then one every 1.5 s. The buckets are per user, so more tabs don't buy
|
||||
* more.
|
||||
*/
|
||||
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 per user. They are cheap for the client to send often; past
|
||||
* this they're dropped, and the next one catches up.
|
||||
* Limits each user's `chat_read`. One over the limit is dropped, and the next
|
||||
* one catches up, since a marker only moves forward.
|
||||
*/
|
||||
const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 };
|
||||
|
||||
/** Reports a failed chat write to the client, with the nonce of its pending copy. */
|
||||
/**
|
||||
* Sends a chat-scoped `error`. With the `nonce` of a `chat_send`, the chatbox
|
||||
* marks that pending message failed. Without one, it shows `message` as a
|
||||
* notice.
|
||||
*/
|
||||
function sendChatError(
|
||||
client: WsClient,
|
||||
message: string,
|
||||
@@ -325,8 +339,8 @@ function sendChatError(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the id of the user behind a chat write, or undefined after sending
|
||||
* the error when they are signed out or over {@link CHAT_WRITE_LIMIT}.
|
||||
*/
|
||||
function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
||||
if (!client.userId) {
|
||||
@@ -344,7 +358,9 @@ function chatWriter(client: WsClient, nonce?: string): string | undefined {
|
||||
return client.userId;
|
||||
}
|
||||
|
||||
/** Returns `value` when it is a string of at most `maxLength`, else undefined. */
|
||||
/**
|
||||
* 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
|
||||
@@ -352,8 +368,8 @@ function optionalString(value: unknown, maxLength: number): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a chat message, broadcasts it, and notifies the @mentioned users not
|
||||
* already reading the chat.
|
||||
* Handles `chat_send`. It stores the message, broadcasts it, and notifies the
|
||||
* @mentioned users who aren't reading the chat.
|
||||
*/
|
||||
function handleChatSend(
|
||||
client: WsClient,
|
||||
@@ -375,14 +391,10 @@ function handleChatSend(
|
||||
body,
|
||||
optionalString(replyToId, 64),
|
||||
);
|
||||
// Sending a message ends composition — clear the typing flag before the
|
||||
// message broadcast so the indicator doesn't linger next to it.
|
||||
// Before the broadcast, so the typing indicator doesn't linger beside the
|
||||
// message.
|
||||
setClientTyping(client, false);
|
||||
// Broadcast to everyone including the sender, whose pending copy the nonce
|
||||
// retires.
|
||||
broadcastChatMessage(message, nonce);
|
||||
// Skip mention notifications for users who already have the chat open —
|
||||
// they've seen the message live.
|
||||
notifyMentions(
|
||||
userId,
|
||||
message.body,
|
||||
@@ -402,10 +414,9 @@ function handleChatSend(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Checks whether the client's user may edit and delete others' chat messages.
|
||||
* The role is read from the database, not from the socket's connect, so that a
|
||||
* demoted moderator loses the right at once.
|
||||
*/
|
||||
function canModerateChat(client: WsClient): boolean {
|
||||
if (!client.userId) return false;
|
||||
@@ -417,7 +428,10 @@ function canModerateChat(client: WsClient): boolean {
|
||||
return !!client.role && hasPermission(client.role, "chat:moderate");
|
||||
}
|
||||
|
||||
/** Edits a chat message, broadcasts it, and notifies newly @mentioned users. */
|
||||
/**
|
||||
* Handles `chat_edit`. It stores the new body, broadcasts it, and notifies the
|
||||
* users the edit newly @mentions.
|
||||
*/
|
||||
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
@@ -434,8 +448,9 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
canModerateChat(client),
|
||||
);
|
||||
broadcastChatMessageUpdated(message);
|
||||
// A newly added @mention in an edit should still notify (deduped per
|
||||
// message+user), skipping anyone already reading the chat.
|
||||
// Mentions already notified for this message are deduplicated, so only
|
||||
// the new ones notify. After a moderator's edit, they come from the
|
||||
// moderator.
|
||||
notifyMentions(
|
||||
userId,
|
||||
message.body,
|
||||
@@ -453,7 +468,7 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a chat message and broadcasts it. */
|
||||
/** Handles `chat_delete`. It deletes the message and broadcasts its id. */
|
||||
function handleChatDelete(client: WsClient, id: unknown): void {
|
||||
const userId = chatWriter(client);
|
||||
if (!userId) return;
|
||||
@@ -474,8 +489,9 @@ 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.
|
||||
* Handles `chat_read`. It moves the user's read marker and sends
|
||||
* `chat_read_update` to all their sockets, so every tab and device updates its
|
||||
* badge together.
|
||||
*/
|
||||
function handleChatRead(client: WsClient, at: unknown): void {
|
||||
if (!client.userId || typeof at !== "string") return;
|
||||
@@ -488,7 +504,8 @@ function handleChatRead(client: WsClient, at: unknown): void {
|
||||
unread: state.unread,
|
||||
});
|
||||
} catch {
|
||||
// A malformed marker changes nothing; there's nothing to report.
|
||||
// A malformed marker changes nothing, and the client doesn't wait for an
|
||||
// answer.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* The YouTube route, for the player's tracklist.
|
||||
* 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. Resolves a YouTube playlist id
|
||||
* to its tracklist (titles and lengths).
|
||||
* - `GET /api/youtube/playlist?list=` — public. Answers the playlist's
|
||||
* tracklist.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,11 +14,13 @@ import type { APIResponse } from "../../shared/api.ts";
|
||||
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
|
||||
import type { YouTubePlaylist } from "../../shared/player.ts";
|
||||
|
||||
/** The router serving the endpoints the module doc lists. */
|
||||
/**
|
||||
* 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. The client
|
||||
// treats any failure as "show the embed without a tracklist".
|
||||
// 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> = {
|
||||
|
||||
Reference in New Issue
Block a user