Files
gerbeur/api/services/dump-service.ts

900 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 { announceLinksNowPublic, 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. A dump that turned public also joins the related
* dumps of those it is linked with.
*/
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));
if (after && !after.isPrivate) announceLinksNowPublic(after.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);
}