Files
gerbeur/api/model/dump.ts
khannurien a36a4cac6b v3: fix the backend's correctness findings — each by a rule shared across the code rather than one more special case. Link previews: readTextCapped decoded every page as UTF-8, so a Latin-1 page came out garbled unless it went through the curl fallback, which re-encoded it on its own; pages are now decoded in the charset their Content-Type or <meta> declares, and the curl fallback hands the bytes over as they are. The image proxy served and cached for a day an error answer that claimed an image type, and read a body without Content-Length whole before checking its size; it now refuses an error status and reads through the same capped reader. The icons task's Twemoji fetch gives up after 15 s instead of holding the start up, and extractLinkHref, which only its tests used, is gone. Backlinks matched a linked URL against the raw url column, so https://x.com/ missed a dump posted as https://x.com; they now compare canonical URLs, as reposts do. The four LIKE searches put the query in raw, so % and _ were wildcards and the mention search for "a_b" found "axb"; lib/like.ts escapes them. Every list query now treats a malformed row the same way, a 500 through model/rows.ts's parseRows, where ten of them silently dropped it; the playlist dumps query uses the model's column list rather than a hand-written copy, and the aliased list is derived from the plain one. The vote and like services commit through transaction() instead of their own BEGIN and ROLLBACK, which would have rolled back a caller's transaction.
Validation: the dump validators refuse the nulls their shared types don't allow (a null title or note on creation, a null URL on edit); a playlist title of spaces is refused and titles are stored trimmed; usernames and emails are checked by the same USERNAME_RE and EMAIL_RE in shared/validation.ts on sign-up, on PATCH /api/users/me (which checked other limits) and in the sign-up form. Mentions are read by shared/mention.ts's mentionedUsernames, which skips code and URLs as Markdown shows them: https://mastodon.social/@bob or a `@bob` in code notified bob; the editor's suggestions derive their trigger from the same pattern instead of a copy. Following an unknown user is a 404 rather than a foreign-key 500, and following a private playlist a 404 rather than a 403 that confirmed it exists. `ws` and `emojibase` are reserved category slugs, since the server answers them before the app. GET /api/users/me no longer turns every error into a 500.

Dead code goes: adminOnlyMiddleware, verifyEmailTransport, listUsers, deleteUser with disconnectUser and the force_logout message only it sent, PATCH /api/notifications/:id/read (with markOneRead), isSlugAvailable, the unreachable 500 in fetchComment, and the `if (!ctx.state.user)` checks behind authMiddleware. 214 backend and 627 frontend tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:35:53 +00:00

154 lines
5.5 KiB
TypeScript

/**
* Maps the `dumps` table to the API's {@link Dump}. Every service that reads
* dumps selects them with the column lists here, checks each row with
* {@link isDumpRow} and converts it with {@link dumpRowToApi}.
*
* @module
*/
import type { SQLOutputValue } from "node:sqlite";
import type { Dump, RichContent } from "../../shared/dump.ts";
/**
* Read from the `dumps` table with {@link DUMP_SELECT_COLUMNS}, or written back
* from a {@link Dump} by {@link dumpApiToRow}. It lacks `url_canonical`, which
* only the duplicate lookup reads.
*/
export interface DumpRow {
id: string;
kind: string;
title: string;
comment: string | null;
user_id: string;
created_at: string;
updated_at: string | null;
slug: string | null;
/** The link as the poster submitted it. */
url: string | null;
/** {@link RichContent} as JSON. */
rich_content: string | null;
file_name: string | null;
file_mime: string | null;
file_size: number | null;
vote_count: number;
/** Computed by a subquery, which counts the comments that aren't deleted. */
comment_count: number;
is_private: number;
custom_thumbnail_mime: string | null;
/** Lets a query select more columns, such as a join's. */
[key: string]: SQLOutputValue;
}
/**
* Lists the stored columns of {@link DumpRow}. A column added to the table must
* be added here, to {@link DumpRow} and to {@link isDumpRow}.
*/
export const DUMP_COLUMNS =
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime";
// The same columns, for a query that aliases `dumps` as `d`.
const DUMP_COLUMNS_ALIASED = DUMP_COLUMNS.split(", ").map((c) => `d.${c}`)
.join(", ");
/**
* Selects a whole {@link DumpRow}, the stored columns and the computed
* `comment_count`.
*/
export const DUMP_SELECT_COLUMNS: string = `${DUMP_COLUMNS},
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
/**
* Selects a whole {@link DumpRow} in a query that aliases `dumps` as `d` to
* join it with other tables.
*/
export const DUMP_SELECT_COLUMNS_ALIASED: string = `${DUMP_COLUMNS_ALIASED},
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count`;
/**
* Checks whether `obj`, a row from a query, is a {@link DumpRow}. It checks
* each column's type, not that `kind` is `url` or `file`.
*/
export function isDumpRow(obj: unknown): obj is DumpRow {
return !!obj &&
typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"kind" in obj && typeof obj.kind === "string" &&
"title" in obj && typeof obj.title === "string" &&
"comment" in obj &&
(typeof obj.comment === "string" || obj.comment === null) &&
"user_id" in obj && typeof obj.user_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
"url" in obj && (typeof obj.url === "string" || obj.url === null) &&
"rich_content" in obj &&
(typeof obj.rich_content === "string" || obj.rich_content === null) &&
"file_name" in obj &&
(typeof obj.file_name === "string" || obj.file_name === null) &&
"file_mime" in obj &&
(typeof obj.file_mime === "string" || obj.file_mime === null) &&
"file_size" in obj &&
(typeof obj.file_size === "number" || obj.file_size === null) &&
"vote_count" in obj && typeof obj.vote_count === "number" &&
"comment_count" in obj && typeof obj.comment_count === "number" &&
"is_private" in obj && typeof obj.is_private === "number" &&
"custom_thumbnail_mime" in obj &&
(typeof obj.custom_thumbnail_mime === "string" ||
obj.custom_thumbnail_mime === null);
}
/**
* Converts a row to the API's {@link Dump}, parsing its preview's JSON. It
* leaves `categoryIds` out: the services add them with `attachCategoryIds`.
*/
export function dumpRowToApi(row: DumpRow): Dump {
return {
id: row.id,
kind: row.kind as "url" | "file",
title: row.title,
slug: row.slug ?? undefined,
url: row.url ?? undefined,
comment: row.comment ?? undefined,
userId: row.user_id,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
richContent: row.rich_content
? (JSON.parse(row.rich_content) as RichContent)
: undefined,
fileName: row.file_name ?? undefined,
fileMime: row.file_mime ?? undefined,
fileSize: row.file_size ?? undefined,
voteCount: row.vote_count,
commentCount: row.comment_count,
isPrivate: Boolean(row.is_private),
thumbnailMime: row.custom_thumbnail_mime ?? undefined,
};
}
/**
* Converts a {@link Dump} back to a row, for a service that writes an edited
* dump's columns.
*/
export function dumpApiToRow(dump: Dump): DumpRow {
return {
id: dump.id,
kind: dump.kind,
title: dump.title,
slug: dump.slug ?? null,
comment: dump.comment ?? null,
user_id: dump.userId,
created_at: dump.createdAt.toISOString(),
updated_at: dump.updatedAt?.toISOString() ?? null,
url: dump.url ?? null,
rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null,
file_name: dump.fileName ?? null,
file_mime: dump.fileMime ?? null,
file_size: dump.fileSize ?? null,
vote_count: dump.voteCount,
comment_count: dump.commentCount,
is_private: dump.isPrivate ? 1 : 0,
custom_thumbnail_mime: dump.thumbnailMime ?? null,
};
}