Files
gerbeur/api/services/dump-service.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

877 lines
27 KiB
TypeScript

/**
* 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 first.
*
* @module
*/
import { APIException } from "../lib/api-exception.ts";
import type {
CreateUrlDumpRequest,
Dump,
DumpUrlMatch,
UpdateDumpRequest,
} from "../../shared/dump.ts";
import { db, transaction } from "../db/connection.ts";
import {
DUMP_SELECT_COLUMNS as SELECT_COLS,
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
dumpApiToRow,
dumpRowToApi,
isDumpRow,
} from "../model/dump.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import {
fetchRichContent,
tryFetchRichContent,
} from "./rich-content-service.ts";
import { isValidHttpUrl } from "../remote/fetch.ts";
import { broadcastDumpChange } from "./ws-service.ts";
import {
notifyMentions,
notifyUserFollowersNewDump,
} from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import {
DUMP_ALLOWED_MIME_PREFIXES,
DUMP_ALLOWED_MIME_TYPES,
DUMPS_DIR,
THUMBNAILS_DIR,
} from "../config.ts";
import {
DUMP_MAX_FILE_SIZE_BYTES,
VALIDATION,
} from "../../shared/validation.ts";
import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts";
import {
assertCategoriesExist,
attachCategoryIds,
setDumpCategories,
} from "./category-service.ts";
import { LIKE, likePattern } from "../lib/like.ts";
import { parseRows } from "../model/rows.ts";
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 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) {
throw new APIException(
400,
`Title must be at most ${VALIDATION.DUMP_TITLE_MAX} characters`,
);
}
if (comment && comment.length > VALIDATION.DUMP_COMMENT_MAX) {
throw new APIException(
400,
`Comment must be at most ${VALIDATION.DUMP_COMMENT_MAX} characters`,
);
}
}
/** 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\./, "");
} catch {
return url;
}
}
/**
* 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.
*/
export async function createUrlDump(
request: CreateUrlDumpRequest,
userId: string,
): Promise<Dump> {
if (!isValidHttpUrl(request.url)) {
throw new APIException(400, "Invalid URL");
}
assertCategoriesExist(request.categoryIds ?? []);
const dumpId = crypto.randomUUID();
const createdAt = new Date();
const richContent = await fetchRichContent(request.url);
// A title typed on the create form wins over the one scraped from the page:
// the poster has seen the preview and is correcting it.
const title = request.title?.trim() || richContent?.title ||
titleFromUrl(request.url);
const isPrivate = request.isPrivate ?? false;
const slug = makeSlug(title, dumpId);
const categoryIds = [...new Set(request.categoryIds ?? [])];
transaction(() => {
db.prepare(
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
).run(
dumpId,
"url",
title,
slug,
request.comment ?? null,
userId,
createdAt.toISOString(),
request.url,
canonicalizeUrl(request.url),
richContent ? JSON.stringify(richContent) : null,
isPrivate ? 1 : 0,
);
setDumpCategories(dumpId, categoryIds);
});
const dump: Dump = {
id: dumpId,
kind: "url",
title,
slug,
comment: request.comment,
userId,
createdAt,
url: request.url,
richContent,
voteCount: 0,
commentCount: 0,
isPrivate,
categoryIds,
};
broadcastDumpChange(null, dump);
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, title);
if (request.comment) {
// A private dump's mentions are notified if it goes public.
if (!isPrivate) {
notifyMentions(userId, request.comment, "dump", dumpId, title);
}
linkAttachments(request.comment, "dump", dumpId);
}
relinkBacklinks("dump_comment", dumpId, dumpId, request.comment ?? "");
return dump;
}
/**
* 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.
*/
export async function createFileDump(
file: File,
comment: string | undefined,
userId: string,
isPrivate = false,
title?: string,
categoryIds: string[] = [],
): Promise<Dump> {
assertFileDumpText(title, comment);
if (!isAllowedMime(file.type)) {
throw new APIException(400, `File type '${file.type}' is not allowed`);
}
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
throw new APIException(400, "File too large (max 50 MB)");
}
assertCategoriesExist(categoryIds);
const dumpId = crypto.randomUUID();
const createdAt = new Date();
const finalTitle = title?.trim() || file.name;
const slug = makeSlug(finalTitle, dumpId);
const dedupedCategoryIds = [...new Set(categoryIds)];
await Deno.mkdir(DUMPS_DIR, { recursive: true });
try {
// Streamed to disk rather than copied into one more 50 MB buffer first.
await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, file.stream());
transaction(() => {
db.prepare(
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
).run(
dumpId,
"file",
finalTitle,
slug,
comment ?? null,
userId,
createdAt.toISOString(),
file.name,
file.type,
file.size,
isPrivate ? 1 : 0,
);
setDumpCategories(dumpId, dedupedCategoryIds);
});
} catch (err) {
// Leave no file behind when the write or the insert fails.
await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {});
throw err;
}
const dump: Dump = {
id: dumpId,
kind: "file",
title: finalTitle,
slug,
comment,
userId,
createdAt,
fileName: file.name,
fileMime: file.type,
fileSize: file.size,
voteCount: 0,
commentCount: 0,
isPrivate,
categoryIds: dedupedCategoryIds,
};
broadcastDumpChange(null, dump);
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, finalTitle);
if (comment) {
// A private dump's mentions are notified if it goes public.
if (!isPrivate) {
notifyMentions(userId, comment, "dump", dumpId, finalTitle);
}
linkAttachments(comment, "dump", dumpId);
}
relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? "");
return dump;
}
/**
* 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">,
): string {
return `${dump.fileSize ?? 0}-${
(dump.updatedAt ?? dump.createdAt).getTime()
}`;
}
/**
* 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(
[`${dumpId}.jpg`, `${dumpId}.preview.jpg`, `${dumpId}.preview.png`].map(
(name) => Deno.remove(`${THUMBNAILS_DIR}/${name}`).catch(() => {}),
),
);
}
/**
* 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)
? db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`).get(idOrSlug)
: db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE slug = ?;`).get(
idOrSlug,
);
if (!row || !isDumpRow(row)) {
throw new APIException(404, "Dump not found");
}
return dumpRowToApi(row);
}
/**
* 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.
*/
export function getDump(dumpId: string, requestingUserId?: string): Dump {
const dump = fetchDump(dumpId);
if (dump.isPrivate && dump.userId !== requestingUserId) {
throw new APIException(404, "Dump not found");
}
attachCategoryIds([dump]);
return dump;
}
/**
* 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,
page: number,
limit: number,
requestingUserId?: string,
): { items: Dump[]; total: number } {
if (!query.trim()) return { items: [], total: 0 };
const pattern = likePattern(query);
const offset = (page - 1) * limit;
const searchClause =
`(title ${LIKE} OR comment ${LIKE} OR json_extract(rich_content,'$.title') ${LIKE} OR json_extract(rich_content,'$.description') ${LIKE})`;
const searchParams = [pattern, pattern, pattern, pattern] as const;
if (requestingUserId) {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(requestingUserId, ...searchParams, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
).get(requestingUserId, ...searchParams) as { count: number } | undefined;
const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
} else {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(...searchParams, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
).get(...searchParams) as { count: number } | undefined;
const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}
}
/**
* 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,
limit: number,
requestingUserId?: string,
): Page<Dump> {
const offset = (page - 1) * limit;
const rows = requestingUserId
? db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(requestingUserId, limit + 1, offset)
: db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(limit + 1, offset);
if (!rows || !rows.every(isDumpRow)) {
throw new APIException(500, "Malformed dump data");
}
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return pageOf(items, limit);
}
/**
* 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,
requestingUserId?: string,
limit = 3,
): DumpUrlMatch[] {
const canonical = canonicalizeUrl(url);
if (!canonical) return [];
const rows = db.prepare(
`SELECT d.id, d.slug, d.title, d.created_at, d.vote_count, u.username,
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count
FROM dumps d
JOIN users u ON u.id = d.user_id
WHERE d.url_canonical = ? AND (d.is_private = 0 OR d.user_id = ?)
ORDER BY d.created_at DESC
LIMIT ?;`,
).all(canonical, requestingUserId ?? null, limit) as {
id: string;
slug: string | null;
title: string;
created_at: string;
vote_count: number;
username: string;
comment_count: number;
}[];
return rows.map((row) => ({
id: row.id,
slug: row.slug ?? undefined,
title: row.title,
username: row.username,
createdAt: new Date(row.created_at),
voteCount: row.vote_count,
commentCount: row.comment_count,
}));
}
/**
* 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.
*/
export async function updateDump(
dumpId: string,
request: UpdateDumpRequest,
): Promise<Dump> {
const dump = fetchDump(dumpId);
if (request.categoryIds !== undefined) {
assertCategoriesExist(request.categoryIds);
}
const now = new Date();
if (dump.kind === "file") {
const title = request.title?.trim() || dump.title;
const slug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug;
const updatedDump: Dump = {
...dump,
title,
slug,
comment: "comment" in request
? (request.comment ?? undefined)
: dump.comment,
isPrivate: "isPrivate" in request
? (request.isPrivate ?? false)
: dump.isPrivate,
updatedAt: now,
};
db.prepare(
`UPDATE dumps SET title = ?, slug = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run(
updatedDump.title,
updatedDump.slug ?? null,
updatedDump.comment ?? null,
updatedDump.isPrivate ? 1 : 0,
now.toISOString(),
dumpId,
);
if (request.categoryIds !== undefined) {
const ids = [...new Set(request.categoryIds)];
setDumpCategories(dumpId, ids);
updatedDump.categoryIds = ids;
} else {
attachCategoryIds([updatedDump]);
}
broadcastDumpChange(dump, updatedDump);
if (updatedDump.comment) {
if (!updatedDump.isPrivate) {
notifyMentions(
dump.userId,
updatedDump.comment,
"dump",
dumpId,
updatedDump.title,
);
}
linkAttachments(updatedDump.comment, "dump", dumpId);
}
relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? "");
return updatedDump;
}
const newUrl = request.url ?? dump.url!;
if (!isValidHttpUrl(newUrl)) {
throw new APIException(400, "Invalid URL");
}
let { richContent, title } = dump;
if (newUrl !== dump.url) {
richContent = await fetchRichContent(newUrl);
title = richContent?.title ?? titleFromUrl(newUrl);
}
if (request.title?.trim()) {
title = request.title.trim();
}
const newSlug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug;
const updatedDump: Dump = {
...dump,
title,
slug: newSlug,
comment: "comment" in request
? (request.comment ?? undefined)
: dump.comment,
url: newUrl,
richContent,
isPrivate: "isPrivate" in request
? (request.isPrivate ?? false)
: dump.isPrivate,
updatedAt: now,
};
const row = dumpApiToRow(updatedDump);
const result = db.prepare(
`UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, url_canonical = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run(
row.title,
row.slug,
row.comment,
row.url,
row.url ? canonicalizeUrl(row.url) : null,
row.rich_content,
row.is_private,
now.toISOString(),
row.id,
);
if (result.changes === 0) {
throw new APIException(404, "Dump not found");
}
if (request.categoryIds !== undefined) {
const ids = [...new Set(request.categoryIds)];
setDumpCategories(dumpId, ids);
updatedDump.categoryIds = ids;
} else {
attachCategoryIds([updatedDump]);
}
broadcastDumpChange(dump, updatedDump);
if (updatedDump.comment) {
if (!updatedDump.isPrivate) {
notifyMentions(
dump.userId,
updatedDump.comment,
"dump",
dumpId,
updatedDump.title,
);
}
linkAttachments(updatedDump.comment, "dump", dumpId);
}
relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? "");
return updatedDump;
}
/**
* 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.
*/
export async function replaceFileDump(
dumpId: string,
file: File,
comment: string | undefined,
title?: string,
categoryIds?: string[],
isPrivate?: boolean,
): Promise<Dump> {
assertFileDumpText(title, comment);
if (!isAllowedMime(file.type)) {
throw new APIException(400, `File type '${file.type}' is not allowed`);
}
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
throw new APIException(400, "File too large (max 50 MB)");
}
const dump = fetchDump(dumpId);
if (dump.kind !== "file") {
throw new APIException(400, "Not a file dump");
}
if (categoryIds !== undefined) assertCategoriesExist(categoryIds);
// The new file is written beside the old one and renamed over it (atomic: a
// reader sees one or the other). A hard link keeps the old one reachable so
// a failed update can put it back, without holding either in memory.
const filePath = `${DUMPS_DIR}/${dumpId}`;
const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`;
const backupPath = `${tmpPath}.old`;
await Deno.mkdir(DUMPS_DIR, { recursive: true });
try {
await Deno.writeFile(tmpPath, file.stream());
} catch (err) {
await Deno.remove(tmpPath).catch(() => {});
throw err;
}
const hadOld = await Deno.link(filePath, backupPath).then(
() => true,
() => false,
);
await Deno.rename(tmpPath, filePath);
const now = new Date();
const finalTitle = title?.trim() || dump.title;
const newSlug = finalTitle !== dump.title
? makeSlug(finalTitle, dumpId)
: dump.slug;
const finalPrivate = isPrivate ?? dump.isPrivate;
try {
transaction(() => {
db.prepare(
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run(
finalTitle,
newSlug ?? null,
file.name,
file.type,
file.size,
comment ?? null,
finalPrivate ? 1 : 0,
now.toISOString(),
dumpId,
);
if (categoryIds !== undefined) {
setDumpCategories(dumpId, [...new Set(categoryIds)]);
}
});
} catch (err) {
if (hadOld) await Deno.rename(backupPath, filePath).catch(() => {});
else await Deno.remove(filePath).catch(() => {});
throw err;
}
if (hadOld) await Deno.remove(backupPath).catch(() => {});
await removeGeneratedThumbnails(dumpId);
if (categoryIds !== undefined) {
dump.categoryIds = [...new Set(categoryIds)];
} else {
attachCategoryIds([dump]);
}
if (comment) {
if (!finalPrivate) {
notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle);
}
linkAttachments(comment, "dump", dumpId);
}
relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? "");
const updatedDump: Dump = {
...dump,
title: finalTitle,
slug: newSlug,
fileName: file.name,
fileMime: file.type,
fileSize: file.size,
comment,
isPrivate: finalPrivate,
updatedAt: now,
};
broadcastDumpChange(dump, updatedDump);
return updatedDump;
}
/**
* 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,
page: number,
limit: number,
includePrivate: boolean,
): Page<Dump> {
const offset = (page - 1) * limit;
const privacyFilter = includePrivate ? "" : " AND is_private = 0";
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ?${privacyFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(userId, limit + 1, offset);
if (!rows.every(isDumpRow)) {
throw new APIException(500, "Malformed dump data");
}
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return pageOf(items, limit);
}
/**
* 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,
page: number,
limit: number,
requestingUserId: string | null,
): Page<Dump> {
const offset = (page - 1) * limit;
const dumpCols = SELECT_COLS_ALIASED;
let rows: unknown[];
if (requestingUserId === userId) {
rows = db.prepare(
`SELECT ${dumpCols}
FROM dumps d
INNER JOIN votes v ON d.id = v.dump_id
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?)
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
).all(userId, userId, limit + 1, offset);
} else {
rows = db.prepare(
`SELECT ${dumpCols}
FROM dumps d
INNER JOIN votes v ON d.id = v.dump_id
WHERE v.user_id = ? AND d.is_private = 0
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
).all(userId, limit + 1, offset);
}
if (!rows.every(isDumpRow)) {
throw new APIException(500, "Malformed dump data");
}
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return pageOf(items, limit);
}
/**
* 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.
*/
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
const dump = fetchDump(dumpId);
if (dump.kind !== "url" || !dump.url) {
throw new APIException(400, "Only URL dumps support metadata refresh");
}
// 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,
});
if (!ok) {
throw new APIException(
502,
"Could not reach the page to refresh its metadata",
);
}
const title = richContent?.title ?? titleFromUrl(dump.url);
const updatedDump: Dump = { ...dump, title, richContent };
const row = dumpApiToRow(updatedDump);
db.prepare(
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
).run(row.title, row.rich_content, row.id);
broadcastDumpChange(dump, updatedDump);
return updatedDump;
}
/**
* 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.
*/
export function getDumpThumbnailInfo(
idOrSlug: string,
): { id: string; mime: string } | undefined {
const dump = fetchDump(idOrSlug);
return dump.thumbnailMime
? { id: dump.id, mime: dump.thumbnailMime }
: undefined;
}
/**
* Records that a dump has a custom thumbnail of type `mime`, for
* `PUT /api/dumps/:dumpId/thumbnail`, once the route has written the file.
*
* @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(
mime,
dump.id,
);
const updatedDump: Dump = { ...dump, thumbnailMime: mime };
broadcastDumpChange(dump, updatedDump);
return updatedDump;
}
/**
* Removes a dump's custom thumbnail and its file, for
* `DELETE /api/dumps/:dumpId/thumbnail`.
*
* @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 = ?;`)
.run(dump.id);
await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {});
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
broadcastDumpChange(dump, updatedDump);
return updatedDump;
}
/**
* 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.
*/
export async function deleteDump(dumpId: string): Promise<void> {
const dump = fetchDump(dumpId);
const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
if (result.changes === 0) {
throw new APIException(404, "Dump not found");
}
if (dump.kind === "file") {
await Deno.remove(`${DUMPS_DIR}/${dump.id}`).catch(() => {});
await removeGeneratedThumbnails(dump.id);
}
if (dump.thumbnailMime) {
await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {});
}
broadcastDumpChange(dump, null);
}