Files
gerbeur/api/services/dump-service.ts
khannurien 14e3ab125b v3: fix the privacy and security findings of the comment pass — private dumps' broadcasts, mentions and attachments reached people who can't see them, and a few smaller holes. A dump's every write now goes through ws-service's broadcastDumpChange(before, after), which decides the audience: a private dump's creation, edits and deletion reach its owner's sockets alone (its deletion id went to everyone before), going private is dump_deleted for everyone else and dump_updated for the owner (whose other tabs dropped it), going public dump_created for everyone else. Mentions in a private dump, a comment on one, or a private playlist's description notify no one until it goes public (mentions are deduplicated per context, so going public notifies them once), and a moderator's edit of a comment, chat message or playlist mentions in the author's name. A link preview of a private playlist is refused, as one of a private dump already was: the preview is stored in the linking dump, which others may see. Attachments record the type of the resource that links them (migration 0018 backfills it); one linked by a private dump or playlist, or a comment on a private dump, is served only with its owner's media token, which Markdown now adds to attachment URLs, and one whose resource is gone answers 404 and is purged at startup. The Bandcamp provider claimed bandcamp.com.evil.example (the pattern wasn't anchored) and now reuses bandcamp-tralbum's anchored one; the YouTube provider took any site's ?list= for a YouTube playlist and missed m., music., youtube-nocookie and /live/ links, and now shares canonical-url's rules. Sign-in does the same scrypt work for an unknown username as for a wrong password, so timing no longer tells which exist. Usernames and emails are unique regardless of case (migration 0017, which refuses to run over existing clashes and names them): "Bob" and "bob" could both sign up and a mention picked one, emails had no constraint, and a reset request in another case silently sent nothing. The Open Graph tags escape the site name and build their URLs on PUBLIC_URL and FRONTEND_URL instead of the request's origin, which a proxy may rewrite.
Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email.

Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass.

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

875 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";
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 = `%${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 = rows.filter(isDumpRow).map(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 = rows.filter(isDumpRow).map(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);
}