Files
gerbeur/api/services/dump-service.ts
khannurien 308a8299f3 v4: every live update reaches every page that shows what it changed — the client kept each kind of event in one "latest" slot, so two arriving before a render lost the first (two notifications of one dump going public showed one); they now go through a subscription, useWSEvent, and each reaches every subscriber. A playlist made private sent its new title and description to everyone, and kept them on a visitor's page; playlists now follow the dumps' rule, broadcastChange: others get playlist_deleted, and playlist_created when it goes public again. A dump deleted or turned private sends its playlists' dumps again, so their counts and pages follow. Comments on a private dump reach its poster's other tabs. Reading the notifications clears the bell in every tab (notifications_read), and a category change makes every client fetch the categories again (categories_changed). The dump page gives way to a notice when its dump is deleted or made private. A profile gains the user's new dumps, the followed feed those of the users followed and the playlists followed, the search results follow edits and deletions, the index's category feed drops a dump edited out of it, and a renamed user's name follows on cards and comments. After a reconnection, every live list fetches the pages it holds again (usePagedList's resync), and the playlist, the profile, the dump, the categories and the follows are fetched again. The position memory of usePositionAwareSync is now inside the list sync hooks, so a returning item is put back without depending on the order the hooks are called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:34:49 +00:00

898 lines
29 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. A dump's slug is made from its title when it is
* posted, and changes only when an edit sends a new one (`slug-service.ts`). 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 { siteHost } from "../../shared/url.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 {
broadcastHoldersDumps,
type PlaylistHolder,
playlistsHolding,
} from "./playlist-service.ts";
import {
notifyMentions,
notifyPlaylistFollowersNowPublic,
notifyUserFollowersNewDump,
} from "./notification-service.ts";
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { removeGeneratedMedia } from "../lib/ffmpeg.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,
categoryJoin,
setCategories,
} 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`,
);
}
}
/**
* Tells clients how a dump changed, for every write of this service:
* `broadcastDumpChange` for the dump itself, and, when it was deleted or
* changed visibility, the dumps of each playlist holding it, which moved for
* their viewers. A deletion passes the `holders` it read before the cascade
* took the dump out of them.
*/
function announceDumpChange(
before: Dump | null,
after: Dump | null,
holders?: PlaylistHolder[],
): void {
broadcastDumpChange(before, after);
if (!before || (after && after.isPrivate === before.isPrivate)) return;
broadcastHoldersDumps(holders ?? playlistsHolding(before.id));
}
/**
* Notifies the followers of a dump that went public, `before` being private
* and `after` public: the poster's, as if it were just posted, and those of
* each public playlist holding it, as if it were just added. A private dump's
* followers were never told of it, so an edit that makes it public is when
* they learn of it.
*/
function notifyIfWentPublic(before: Dump, after: Dump): void {
if (!before.isPrivate || after.isPrivate) return;
notifyUserFollowersNewDump(after.userId, after.id, after.title);
notifyPlaylistFollowersNowPublic({ dumpId: after.id });
}
/**
* 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("dump", 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 ||
(siteHost(request.url) ?? request.url);
const isPrivate = request.isPrivate ?? false;
const categoryIds = [...new Set(request.categoryIds ?? [])];
transaction(() => {
const slug = newSlug("dump", userId, title);
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,
);
setCategories("dump", dumpId, categoryIds);
});
const dump: Dump = { ...fetchDump(dumpId), categoryIds };
announceDumpChange(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("dump", categoryIds);
const dumpId = crypto.randomUUID();
const createdAt = new Date();
const finalTitle = title?.trim() || file.name;
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(() => {
const slug = newSlug("dump", userId, finalTitle);
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,
);
setCategories("dump", 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 = { ...fetchDump(dumpId), categoryIds: dedupedCategoryIds };
announceDumpChange(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 dump `dumpId`, without its categories, or throws a 404. It doesn't
* check privacy: the routes do, with {@link getDump}.
*/
function fetchDump(dumpId: string): Dump {
const row = db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`)
.get(dumpId);
if (!row || !isDumpRow(row)) {
throw new APIException(404, "Dump not found");
}
return dumpRowToApi(row);
}
/**
* Returns dump `dumpId` 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", [dump]);
return dump;
}
/**
* Returns the dump of `userId`'s whose slug is or was `slug`, as
* `requestingUserId` may see it, for `GET /api/users/:key/dumps/:slug`, which
* reads a dump by its page's path. Reached by a former slug, it comes with
* its current one, which the client puts in its URL.
*
* @throws {APIException} 404 when there is no such dump, or it is private and
* not theirs.
*/
export function getDumpByPath(
userId: string,
slug: string,
requestingUserId?: string,
): Dump {
const dumpId = resolveSlug("dump", userId, slug);
if (!dumpId) throw new APIException(404, "Dump not found");
return getDump(dumpId, requestingUserId);
}
/**
* 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, optionally only those in the dump category `categoryId`.
* `GET /api/search` calls it.
*/
export function searchDumps(
query: string,
page: number,
limit: number,
requestingUserId?: string,
categoryId?: 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 privacyClause = requestingUserId
? "(is_private = 0 OR user_id = ?)"
: "is_private = 0";
const [catJoin, catParams] = categoryJoin("dump", "dumps", categoryId);
const params = [
...catParams,
...(requestingUserId ? [requestingUserId] : []),
pattern,
pattern,
pattern,
pattern,
];
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps ${catJoin} WHERE ${privacyClause} AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(...params, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps ${catJoin} WHERE ${privacyClause} AND ${searchClause};`,
).get(...params) as { count: number } | undefined;
const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds("dump", 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("dump", items);
return pageOf(items, limit);
}
/**
* Returns the `limit` most upvoted public dumps posted from `from` to `to`,
* the newer first on a tie, leaving out those no one upvoted. The community
* service gathers them for the `/community` page and the digests.
*
* @throws {APIException} 500 when a row doesn't parse as a dump.
*/
export function listTopDumps(from: Date, to: Date, limit: number): Dump[] {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps
WHERE is_private = 0 AND vote_count > 0
AND created_at >= ? AND created_at < ?
ORDER BY vote_count DESC, created_at DESC
LIMIT ?;`,
).all(from.toISOString(), to.toISOString(), limit);
const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds("dump", items);
return items;
}
/**
* 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?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;
title: string;
created_at: string;
vote_count: number;
username: string;
comment_count: number;
}[];
return rows.map((row) => ({
id: row.id,
slug: row.slug,
title: row.title,
ownerUsername: 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`. A new `slug` goes through `changeSlug`,
* which `exempt` (a moderator) frees from its rate limit; a new title leaves
* the slug as it is. 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, and when it turns public,
* its followers are as when it is posted ({@link notifyIfWentPublic}).
*
* @throws {APIException} 404 when there is no such dump, 400 when a new URL
* isn't a public http(s) URL, a category doesn't exist or the slug isn't
* valid, 409 when the slug is taken, 429 when it changed too often lately.
*/
export async function updateDump(
dumpId: string,
request: UpdateDumpRequest,
exempt = false,
): Promise<Dump> {
const dump = fetchDump(dumpId);
if (request.categoryIds !== undefined) {
assertCategoriesExist("dump", request.categoryIds);
}
if (dump.kind === "url" && !isValidHttpUrl(request.url ?? dump.url!)) {
throw new APIException(400, "Invalid URL");
}
// First, so that a refused slug leaves the rest of the dump unchanged.
if (request.slug !== undefined) {
changeSlug("dump", dump, request.slug, exempt);
}
const slug = request.slug ?? dump.slug;
const now = new Date();
if (dump.kind === "file") {
const title = request.title?.trim() || dump.title;
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 = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run(
updatedDump.title,
updatedDump.comment ?? null,
updatedDump.isPrivate ? 1 : 0,
now.toISOString(),
dumpId,
);
if (request.categoryIds !== undefined) {
const ids = [...new Set(request.categoryIds)];
setCategories("dump", dumpId, ids);
updatedDump.categoryIds = ids;
} else {
attachCategoryIds("dump", [updatedDump]);
}
announceDumpChange(dump, updatedDump);
notifyIfWentPublic(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!;
let { richContent, title } = dump;
if (newUrl !== dump.url) {
richContent = await fetchRichContent(newUrl);
title = richContent?.title ?? siteHost(newUrl) ?? newUrl;
}
if (request.title?.trim()) {
title = request.title.trim();
}
const updatedDump: Dump = {
...dump,
title,
slug,
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 = ?, comment = ?, url = ?, url_canonical = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run(
row.title,
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)];
setCategories("dump", dumpId, ids);
updatedDump.categoryIds = ids;
} else {
attachCategoryIds("dump", [updatedDump]);
}
announceDumpChange(dump, updatedDump);
notifyIfWentPublic(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`, `slug`, `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 slug changes as in
* {@link updateDump}, before the file is written. The old file is put back if
* the database update fails, and clients and followers 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, a category doesn't exist or the slug isn't valid, 409 when
* the slug is taken, 429 when it changed too often lately.
*/
export async function replaceFileDump(
dumpId: string,
file: File,
comment: string | undefined,
title?: string,
categoryIds?: string[],
isPrivate?: boolean,
slug?: string,
exempt = false,
): 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("dump", categoryIds);
if (slug !== undefined) changeSlug("dump", dump, slug, exempt);
// 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 finalPrivate = isPrivate ?? dump.isPrivate;
try {
transaction(() => {
db.prepare(
`UPDATE dumps SET title = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run(
finalTitle,
file.name,
file.type,
file.size,
comment ?? null,
finalPrivate ? 1 : 0,
now.toISOString(),
dumpId,
);
if (categoryIds !== undefined) {
setCategories("dump", 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 removeGeneratedMedia(dumpId);
if (categoryIds !== undefined) {
dump.categoryIds = [...new Set(categoryIds)];
} else {
attachCategoryIds("dump", [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: slug ?? dump.slug,
fileName: file.name,
fileMime: file.type,
fileSize: file.size,
comment,
isPrivate: finalPrivate,
updatedAt: now,
};
announceDumpChange(dump, updatedDump);
notifyIfWentPublic(dump, updatedDump);
return updatedDump;
}
/**
* Returns a page of `userId`'s dumps, newest first, for
* `GET /api/users/:key/dumps`, optionally only those in `categoryId`. 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,
categoryId?: string,
): Page<Dump> {
const offset = (page - 1) * limit;
const privacyFilter = includePrivate ? "" : " AND d.is_private = 0";
const [catJoin, catParams] = categoryJoin("dump", "d", categoryId);
const rows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED} FROM dumps d ${catJoin}
WHERE d.user_id = ?${privacyFilter}
ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`,
).all(...catParams, userId, limit + 1, offset);
if (!rows.every(isDumpRow)) {
throw new APIException(500, "Malformed dump data");
}
const items = rows.map(dumpRowToApi);
attachCategoryIds("dump", items);
return pageOf(items, limit);
}
/**
* Returns a page of the dumps `userId` upvoted, latest vote first, for
* `GET /api/users/:key/upvoted`, optionally only those in `categoryId`.
* 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,
categoryId?: string,
): Page<Dump> {
const offset = (page - 1) * limit;
const isOwn = requestingUserId === userId;
const privacyFilter = isOwn
? "(d.is_private = 0 OR d.user_id = ?)"
: "d.is_private = 0";
const [catJoin, catParams] = categoryJoin("dump", "d", categoryId);
const rows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED}
FROM dumps d
INNER JOIN votes v ON d.id = v.dump_id
${catJoin}
WHERE v.user_id = ? AND ${privacyFilter}
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
).all(
...catParams,
userId,
...(isOwn ? [userId] : []),
limit + 1,
offset,
);
if (!rows.every(isDumpRow)) {
throw new APIException(500, "Malformed dump data");
}
const items = rows.map(dumpRowToApi);
attachCategoryIds("dump", 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/metadata`. The slug stays.
*
* @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 ?? siteHost(dump.url) ?? 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);
announceDumpChange(dump, updatedDump);
return updatedDump;
}
/**
* Returns where dump `dumpId`'s custom thumbnail is kept: the thumbnail route
* writes and serves it, and this service deletes it with the thumbnail or the
* dump.
*/
export function customThumbnailPath(dumpId: string): string {
return `${THUMBNAILS_DIR}/${dumpId}-custom`;
}
/**
* 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 };
announceDumpChange(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(customThumbnailPath(dump.id)).catch(() => {});
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
announceDumpChange(dump, updatedDump);
return updatedDump;
}
/**
* Deletes a dump with its files and thumbnails, for
* `DELETE /api/dumps/:dumpId`, and broadcasts `dump_deleted` and the new
* dumps of the playlists that held it.
*
* @throws {APIException} 404 when there is no such dump.
*/
export async function deleteDump(dumpId: string): Promise<void> {
const dump = fetchDump(dumpId);
const holders = playlistsHolding(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 removeGeneratedMedia(dump.id);
}
if (dump.thumbnailMime) {
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
}
announceDumpChange(dump, null, holders);
}