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:
khannurien
2026-09-29 14:31:14 +00:00
parent 42e0356d21
commit 0d4e344d77
287 changed files with 5679 additions and 5135 deletions

View File

@@ -1,9 +1,10 @@
/**
* Dumps: posting links and files, editing and deleting them, and reading them
* as each viewer may see them. A private dump exists only for its poster.
* Posts, edits and deletes dumps, and reads them as each viewer may see them.
* `routes/dumps.ts` calls it, and so do the search, user, file and thumbnail
* routes to read dumps. It broadcasts the changes to public dumps over the
* WebSocket.
*
* The functions that change a dump don't check who asks: the routes do, before
* calling them.
* The functions that change a dump don't check who asks: the routes do first.
*
* @module
*/
@@ -59,16 +60,14 @@ import {
setDumpCategories,
} from "./category-service.ts";
/** Checks whether a file dump may have type `mime`. */
function isAllowedMime(mime: string): boolean {
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
DUMP_ALLOWED_MIME_TYPES.has(mime);
}
/**
* Throws a 400 when a file dump's title or comment is too long. The JSON routes
* validate these in isCreateUrlDumpRequest/isUpdateDumpRequest; the multipart
* ones (file create and replace) arrive here unchecked.
* Throws a 400 when a file dump's title or comment is too long. The multipart
* routes that post and replace a file don't check them, unlike the JSON ones.
*/
function assertFileDumpText(title?: string, comment?: string): void {
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
@@ -87,7 +86,7 @@ function assertFileDumpText(title?: string, comment?: string): void {
}
}
/** Returns the title a link gets without a better one: its host. */
/** Returns a link's host name, the title it gets when its page has none. */
function titleFromUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
@@ -97,8 +96,10 @@ function titleFromUrl(url: string): string {
}
/**
* Posts a link as `userId`, with the preview fetched from it; a public one is
* announced and notified to the poster's followers.
* Posts a link as `userId`, for `POST /api/dumps`, with its preview fetched
* from the page. A public dump is broadcast as `dump_created` and notified to
* the poster's followers. Anyone @mentioned in the note is notified even when
* the dump is private.
*
* @throws {APIException} 400 when the URL isn't a public http(s) URL or a
* category doesn't exist.
@@ -171,8 +172,9 @@ export async function createUrlDump(
}
/**
* Posts an uploaded file as `userId`, stored under {@link DUMPS_DIR}; a public
* one is announced and notified to the poster's followers.
* Posts an uploaded file as `userId`, for `POST /api/dumps`, and stores it in
* `DUMPS_DIR` under the dump's id. It is announced and notified as
* {@link createUrlDump} does.
*
* @throws {APIException} 400 when the title or comment is too long, the file's
* type isn't allowed, it exceeds 50 MB, or a category doesn't exist.
@@ -234,7 +236,7 @@ export async function createFileDump(
setDumpCategories(dumpId, dedupedCategoryIds);
});
} catch (err) {
// Roll back the file if DB insert fails
// Leave no file behind when the write or the insert fails.
await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {});
throw err;
}
@@ -268,9 +270,10 @@ export async function createFileDump(
}
/**
* Returns a file dump's version as the app puts it in file and thumbnail URLs
* (`?v=`): it changes whenever the file can have. Mirrored by
* `dumpFileVersion` in src/utils/shared/urls.ts.
* Returns a file dump's version, which the app's file and thumbnail URLs carry
* as `?v=`. It changes whenever the file may have, so the file and thumbnail
* routes cache a URL naming the current one for good. The frontend's
* `dumpFileVersion` must compute the same.
*/
export function dumpFileVersion(
dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">,
@@ -281,9 +284,9 @@ export function dumpFileVersion(
}
/**
* Removes the files generated from a dump's upload (see
* api/routes/thumbnails.ts) when it's replaced or gone, so they're made again
* from the new one.
* Deletes the thumbnails that `routes/thumbnails.ts` generated from a dump's
* upload, once the upload is replaced or deleted. The route makes them again
* from a new one.
*/
async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
await Promise.all(
@@ -294,8 +297,8 @@ async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
}
/**
* Returns the dump with id or slug `idOrSlug`, or throws a 404. No privacy
* check: use only when ownership is already enforced.
* Returns the dump with id or slug `idOrSlug`, without its categories, or
* throws a 404. It doesn't check privacy: the routes do, with {@link getDump}.
*/
function fetchDump(idOrSlug: string): Dump {
const row = UUID_RE.test(idOrSlug)
@@ -310,9 +313,11 @@ function fetchDump(idOrSlug: string): Dump {
}
/**
* Returns a dump, by id or slug, as `requestingUserId` may see it.
* Returns a dump, by id or slug, as `requestingUserId` may see it. The routes
* resolve every `:dumpId` with it before acting on the dump.
*
* @throws {APIException} 404 when there is no such dump, or it is private and not theirs.
* @throws {APIException} 404 when there is no such dump, or it is private and
* not theirs.
*/
export function getDump(dumpId: string, requestingUserId?: string): Dump {
const dump = fetchDump(dumpId);
@@ -324,9 +329,9 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
}
/**
* Returns a page of the dumps whose title, comment, or previewed title or
* description contains `query`, newest first, and how many match in all: the
* public ones, and the requesting user's own.
* Returns a page of the dumps `requestingUserId` may see whose title, note, or
* previewed title or description contains `query`, newest first, with how many
* match in all. `GET /api/search` calls it.
*/
export function searchDumps(
query: string,
@@ -366,8 +371,10 @@ export function searchDumps(
}
/**
* Returns a page of the dumps, newest first: the public ones, and the
* requesting user's own.
* Returns a page of the dumps `requestingUserId` may see, newest first, for the
* index feed (`GET /api/dumps`).
*
* @throws {APIException} 500 when a row doesn't parse as a dump.
*/
export function listDumps(
page: number,
@@ -375,7 +382,6 @@ export function listDumps(
requestingUserId?: string,
): Page<Dump> {
const offset = (page - 1) * limit;
// Show public dumps + the requesting user's own private dumps
const rows = requestingUserId
? db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
@@ -398,16 +404,11 @@ export function listDumps(
}
/**
* Returns the dumps that already point at the same thing as `url`, newest
* first.
*
* Matched on the canonical key rather than the raw URL, so a link reposted with
* different tracking parameters, scheme, `www.`, or (for YouTube) in a
* different share shape still counts as the same dump. Private dumps are
* visible only to their owner, exactly as everywhere else.
*
* Returns a small display-only projection: this feeds a hint on the create
* form, not a feed.
* Returns the dumps `requestingUserId` may see that were posted with the same
* link as `url`, newest first, for the create form's duplicate notice
* (`GET /api/dumps/by-url`). Links are compared in their canonical form, so
* tracking parameters, the scheme, `www.` or a YouTube share format don't hide
* a repost.
*/
export function findDumpsByUrl(
url: string,
@@ -447,12 +448,14 @@ export function findDumpsByUrl(
}
/**
* Applies `request` to a dump: its title, comment, visibility and categories,
* and a URL dump's link (fetching the new preview). Announces the change as the
* visibility requires, and notifies anyone newly @mentioned.
* Applies `request` to a dump, for `PUT /api/dumps/:dumpId`. A new link has its
* preview fetched and gives the dump its page's title, unless `request` sets
* one. A file dump ignores `url`. Clients get the dump as `dump_updated`, or a
* `dump_deleted` when it turns private and a `dump_created` when it turns
* public. Anyone @mentioned in the note is notified.
*
* @throws {APIException} 404 when there is no such dump, 400 when a new URL isn't a public http(s) URL
* or a category doesn't exist.
* @throws {APIException} 404 when there is no such dump, 400 when a new URL
* isn't a public http(s) URL or a category doesn't exist.
*/
export async function updateDump(
dumpId: string,
@@ -465,7 +468,6 @@ export async function updateDump(
const now = new Date();
// File dumps: title, comment and isPrivate are editable
if (dump.kind === "file") {
const title = request.title?.trim() || dump.title;
const slug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug;
@@ -516,7 +518,6 @@ export async function updateDump(
return updatedDump;
}
// URL dumps
const newUrl = request.url ?? dump.url!;
if (!isValidHttpUrl(newUrl)) {
@@ -596,15 +597,15 @@ export async function updateDump(
}
/**
* Replaces a file dump's upload, and with it its comment and optionally its
* title, categories and visibility. `isPrivate` left out, visibility stays as
* it is; it is taken here rather than by a second request so a new file and a
* change of visibility land together. The old file is restored if the update
* fails.
* Replaces a file dump's upload and its comment, for
* `PUT /api/dumps/:dumpId/file`. A `title`, `categoryIds` or `isPrivate` left
* out stays as it is. Visibility is taken here so that a new file and a change
* of visibility land together. The old file is put back if the database update
* fails, and clients are told as {@link updateDump} tells them.
*
* @throws {APIException} 404 when there is no such dump; 400 when it isn't a file dump, the title or
* comment is too long, the file's type isn't allowed, it exceeds 50 MB, or a
* category doesn't exist.
* @throws {APIException} 404 when there is no such dump, 400 when it isn't a
* file dump, the title or comment is too long, the file's type isn't allowed,
* it exceeds 50 MB, or a category doesn't exist.
*/
export async function replaceFileDump(
dumpId: string,
@@ -681,7 +682,6 @@ export async function replaceFileDump(
}
});
} catch (err) {
// Put the previous file back on DB failure
if (hadOld) await Deno.rename(backupPath, filePath).catch(() => {});
else await Deno.remove(filePath).catch(() => {});
throw err;
@@ -718,8 +718,11 @@ export async function replaceFileDump(
}
/**
* Returns a page of `userId`'s dumps, newest first, the private ones only with
* `includePrivate`.
* Returns a page of `userId`'s dumps, newest first, for
* `GET /api/users/:username/dumps`. The route sets `includePrivate` when users
* ask for their own.
*
* @throws {APIException} 500 when a row doesn't parse as a dump.
*/
export function getDumpsByUser(
userId: string,
@@ -745,8 +748,11 @@ export function getDumpsByUser(
}
/**
* Returns a page of the dumps `userId` upvoted, latest vote first — their own
* private ones only when they are the one asking.
* Returns a page of the dumps `userId` upvoted, latest vote first, for
* `GET /api/users/:username/votes`. Their own private dumps are included only
* when they are the one asking.
*
* @throws {APIException} 500 when a row doesn't parse as a dump.
*/
export function getVotedDumpsByUser(
userId: string,
@@ -759,7 +765,6 @@ export function getVotedDumpsByUser(
let rows: unknown[];
if (requestingUserId === userId) {
// Own profile: include private dumps the user themselves voted on and owns.
rows = db.prepare(
`SELECT ${dumpCols}
FROM dumps d
@@ -790,10 +795,11 @@ export function getVotedDumpsByUser(
}
/**
* Fetches a URL dump's preview again and replaces its title and preview.
* Fetches a URL dump's preview again, bypassing the cache, and replaces its
* title and preview, for `POST /api/dumps/:dumpId/refresh-metadata`.
*
* @throws {APIException} 404 when there is no such dump, 400 when it isn't a URL dump, 502 when the
* page can't be reached.
* @throws {APIException} 404 when there is no such dump, 400 when it isn't a
* URL dump, 502 when the page can't be reached.
*/
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
const dump = fetchDump(dumpId);
@@ -806,10 +812,8 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
);
}
// A failed fetch yields a stub with no title and no thumbnail. Writing that
// over a dump that already has good metadata would silently destroy it and
// rename the dump to its bare hostname, so bail out instead — the caller
// surfaces the error and nothing is lost.
// A failed fetch yields a stub with no title or thumbnail. Written over the
// dump's metadata, it would rename the dump to its host name.
const { ok, content: richContent } = await tryFetchRichContent(dump.url, {
fresh: true,
});
@@ -835,7 +839,9 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
}
/**
* Returns a dump's id and custom thumbnail type, or undefined without one.
* Returns a dump's id and custom thumbnail type, or undefined without one, for
* the thumbnail route. It doesn't check privacy: the route calls
* {@link getDump} first.
*
* @throws {APIException} 404 when there is no such dump.
*/
@@ -849,7 +855,9 @@ export function getDumpThumbnailInfo(
}
/**
* Records a dump's new custom thumbnail, once its file is in place.
* Records that a dump has a custom thumbnail of type `mime`, for
* `PUT /api/dumps/:dumpId/thumbnail`. The route writes the file after this
* call returns.
*
* @throws {APIException} 404 when there is no such dump.
*/
@@ -865,7 +873,8 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
}
/**
* Removes a dump's custom thumbnail, and its file.
* Removes a dump's custom thumbnail and its file, for
* `DELETE /api/dumps/:dumpId/thumbnail`.
*
* @throws {APIException} 404 when there is no such dump.
*/
@@ -880,7 +889,8 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
}
/**
* Deletes a dump, its files and thumbnails, and announces it.
* Deletes a dump with its files and thumbnails, for
* `DELETE /api/dumps/:dumpId`, and broadcasts `dump_deleted`.
*
* @throws {APIException} 404 when there is no such dump.
*/