/** * 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 { notifyMentions, notifyPlaylistFollowersNowPublic, notifyUserFollowersNewDump, } from "./notification-service.ts"; import { changeSlug, newSlug, resolveSlug } from "./slug-service.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`, ); } } /** * 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 { 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 || (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, ); setDumpCategories(dumpId, categoryIds); }); const dump: Dump = { ...fetchDump(dumpId), 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 { 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 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, ); 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 = { ...fetchDump(dumpId), 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; } /** * 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 { await Promise.all( [`${dumpId}.jpg`, `${dumpId}.preview.jpg`, `${dumpId}.preview.png`].map( (name) => Deno.remove(`${THUMBNAILS_DIR}/${name}`).catch(() => {}), ), ); } /** * 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]); 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. `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 { 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 `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(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 { const dump = fetchDump(dumpId); if (request.categoryIds !== undefined) { assertCategoriesExist(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)]; setDumpCategories(dumpId, ids); updatedDump.categoryIds = ids; } else { attachCategoryIds([updatedDump]); } broadcastDumpChange(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)]; setDumpCategories(dumpId, ids); updatedDump.categoryIds = ids; } else { attachCategoryIds([updatedDump]); } broadcastDumpChange(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 { 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); 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) { 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: slug ?? dump.slug, fileName: file.name, fileMime: file.type, fileSize: file.size, comment, isPrivate: finalPrivate, updatedAt: now, }; broadcastDumpChange(dump, updatedDump); notifyIfWentPublic(dump, updatedDump); return updatedDump; } /** * Returns a page of `userId`'s dumps, newest first, for * `GET /api/users/:key/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 { 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/:key/upvoted`. 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 { 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/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 { 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); 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( dumpId: string, ): { id: string; mime: string } | undefined { const dump = fetchDump(dumpId); 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 { 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 { 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); }