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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The functions that change a dump don't check who asks: the routes do, before
|
||||
* calling them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type {
|
||||
@@ -49,13 +59,17 @@ 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);
|
||||
}
|
||||
|
||||
// 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 JSON routes
|
||||
* validate these in isCreateUrlDumpRequest/isUpdateDumpRequest; the multipart
|
||||
* ones (file create and replace) arrive here unchecked.
|
||||
*/
|
||||
function assertFileDumpText(title?: string, comment?: string): void {
|
||||
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
|
||||
throw new APIException(
|
||||
@@ -73,6 +87,7 @@ function assertFileDumpText(title?: string, comment?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the title a link gets without a better one: its host. */
|
||||
function titleFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
@@ -81,6 +96,13 @@ 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.
|
||||
*
|
||||
* @throws {APIException} 400 when the URL isn't a public http(s) URL or a
|
||||
* category doesn't exist.
|
||||
*/
|
||||
export async function createUrlDump(
|
||||
request: CreateUrlDumpRequest,
|
||||
userId: string,
|
||||
@@ -148,6 +170,13 @@ export async function createUrlDump(
|
||||
return dump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts an uploaded file as `userId`, stored under {@link DUMPS_DIR}; a public
|
||||
* one is announced and notified to the poster's followers.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
export async function createFileDump(
|
||||
file: File,
|
||||
comment: string | undefined,
|
||||
@@ -239,7 +268,7 @@ export async function createFileDump(
|
||||
}
|
||||
|
||||
/**
|
||||
* A file dump's version as the app puts it in file and thumbnail URLs
|
||||
* 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.
|
||||
*/
|
||||
@@ -251,8 +280,11 @@ export function dumpFileVersion(
|
||||
}`;
|
||||
}
|
||||
|
||||
/** Files generated from a dump's upload (see api/routes/thumbnails.ts):
|
||||
* dropped when it's replaced or gone, so they're made again from the new one. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
|
||||
await Promise.all(
|
||||
[`${dumpId}.jpg`, `${dumpId}.preview.jpg`, `${dumpId}.preview.png`].map(
|
||||
@@ -261,7 +293,10 @@ async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Internal fetch — no privacy check. Use only when ownership is already enforced.
|
||||
/**
|
||||
* Returns the dump with id or slug `idOrSlug`, or throws a 404. No privacy
|
||||
* check: use only when ownership is already enforced.
|
||||
*/
|
||||
function fetchDump(idOrSlug: string): Dump {
|
||||
const row = UUID_RE.test(idOrSlug)
|
||||
? db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`).get(idOrSlug)
|
||||
@@ -274,7 +309,11 @@ function fetchDump(idOrSlug: string): Dump {
|
||||
return dumpRowToApi(row);
|
||||
}
|
||||
|
||||
// Public fetch — enforces visibility. Returns 404 for private dumps the requester doesn't own.
|
||||
/**
|
||||
* Returns a dump, by id or slug, as `requestingUserId` may see it.
|
||||
*
|
||||
* @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);
|
||||
if (dump.isPrivate && dump.userId !== requestingUserId) {
|
||||
@@ -284,6 +323,11 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
|
||||
return 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.
|
||||
*/
|
||||
export function searchDumps(
|
||||
query: string,
|
||||
page: number,
|
||||
@@ -321,6 +365,10 @@ export function searchDumps(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps, newest first: the public ones, and the
|
||||
* requesting user's own.
|
||||
*/
|
||||
export function listDumps(
|
||||
page: number,
|
||||
limit: number,
|
||||
@@ -350,7 +398,8 @@ export function listDumps(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps that already point at the same thing as `url`, newest first.
|
||||
* 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
|
||||
@@ -397,6 +446,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.
|
||||
*
|
||||
* @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,
|
||||
request: UpdateDumpRequest,
|
||||
@@ -538,14 +595,23 @@ export async function updateDump(
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @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,
|
||||
file: File,
|
||||
comment: string | undefined,
|
||||
title?: string,
|
||||
categoryIds?: string[],
|
||||
// Left out, visibility stays as it is. Taken here rather than by a second
|
||||
// request so a new file and a change of visibility land together.
|
||||
isPrivate?: boolean,
|
||||
): Promise<Dump> {
|
||||
assertFileDumpText(title, comment);
|
||||
@@ -651,6 +717,10 @@ export async function replaceFileDump(
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of `userId`'s dumps, newest first, the private ones only with
|
||||
* `includePrivate`.
|
||||
*/
|
||||
export function getDumpsByUser(
|
||||
userId: string,
|
||||
page: number,
|
||||
@@ -674,6 +744,10 @@ export function getDumpsByUser(
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps `userId` upvoted, latest vote first — their own
|
||||
* private ones only when they are the one asking.
|
||||
*/
|
||||
export function getVotedDumpsByUser(
|
||||
userId: string,
|
||||
page: number,
|
||||
@@ -715,6 +789,12 @@ export function getVotedDumpsByUser(
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a URL dump's preview again and replaces its title and preview.
|
||||
*
|
||||
* @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);
|
||||
|
||||
@@ -754,6 +834,11 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a dump's id and custom thumbnail type, or undefined without one.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
export function getDumpThumbnailInfo(
|
||||
idOrSlug: string,
|
||||
): { id: string; mime: string } | undefined {
|
||||
@@ -763,6 +848,11 @@ export function getDumpThumbnailInfo(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a dump's new custom thumbnail, once its file is in place.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
export function setDumpThumbnail(dumpId: string, mime: string): Dump {
|
||||
const dump = fetchDump(dumpId);
|
||||
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = ? WHERE id = ?;`).run(
|
||||
@@ -774,6 +864,11 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a dump's custom thumbnail, and its file.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
|
||||
const dump = fetchDump(dumpId);
|
||||
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
|
||||
@@ -784,6 +879,11 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a dump, its files and thumbnails, and announces it.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
export async function deleteDump(dumpId: string): Promise<void> {
|
||||
const dump = fetchDump(dumpId);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user