Files
gerbeur/api/services/dump-service.ts
khannurien bfce3186f8
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
v3: the frontend stops trusting what it last saw — a review of src/ turned up counts that fell to zero, stale copies winning over fresh ones, sessions that tabs trampled on each other, and a player queue that outlived its listener, and this closes them. Voting or liking something with no live count yet started from 0, so a dump with five votes read 1 on the click and, with the socket down, 0 for good once the undo fired; the buttons now pass the count they show, which the optimistic update and its undo start from. After a reconnect the chat re-read its history as the truth, but the merge let the live copy of a message win, so an edit made while the socket was down never showed; the resync now replaces live copies with what it fetched. Every dump_updated added the dump to the live list if it wasn't there, so retitling a months-old dump put it at the top of New, Hot and Journal for everyone, while one that had arrived live kept its old title; an edit now refreshes a live entry in place and adds nothing, and the server announces a dump going public as a dump_created, which is the one case that is an arrival. The new-dump modal let a slow preview for the previous link land on the new one, title suggestion included; changing the link now aborts both lookups. Markdown found mentions by rewriting the source text with a rule that, unlike the server's, didn't skip an @ after a word character, so an email address linked its domain, a URL like mastodon.social/@bob was split, and @ in code came out as literal link syntax; mentions are now found by a remark step over text nodes only, by the server's rule. Changing the password in one tab stored fresh tokens that any other open tab wiped the moment its old token drew a 401; the session now lives behind utils/authStorage.ts, tabs follow each other through storage events (so a logout also signs out every tab), a 401 only forgets the stored session if it still holds that token, and in-place session updates go through a new updateSession() rather than re-reading localStorage. The persisted player queue carries media-token URLs yet survived logout for the next person to sign in; it is now stamped with its owner, not restored for anyone else, cleared on logout or account switch (a guest's queue survives signing in), and older unstamped queues holding a file are dropped. Playlist checkboxes in the new-dump and add-to-playlist modals flipped whether or not the server agreed and now wait for it. CommentThread and the Dump page's loads use authFetch like the rest, so a 401 there signs out consistently; isTokenExpired has one home; and the useScrollLock imports carry their extension so deno check passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:24:38 +00:00

755 lines
22 KiB
TypeScript

import {
APIErrorCode,
APIException,
type CreateUrlDumpRequest,
type Dump,
type DumpUrlMatch,
type UpdateDumpRequest,
} from "../model/interfaces.ts";
import {
db,
DUMP_SELECT_COLUMNS as SELECT_COLS,
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
dumpApiToRow,
dumpRowToApi,
isDumpRow,
} from "../model/db.ts";
import {
fetchRichContent,
isValidHttpUrl,
tryFetchRichContent,
} from "./rich-content-service.ts";
import {
broadcastDumpDeleted,
broadcastDumpUpdated,
broadcastNewDump,
} 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,
DUMP_MAX_FILE_SIZE_BYTES,
DUMPS_DIR,
THUMBNAILS_DIR,
VALIDATION,
} from "../config.ts";
import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts";
import { 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);
}
// The JSON routes validate these in isCreateUrlDumpRequest/isUpdateDumpRequest;
// the multipart ones (file create and replace) arrive here unchecked.
function assertFileDumpText(title?: string, comment?: string): void {
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Title must be at most ${VALIDATION.DUMP_TITLE_MAX} characters`,
);
}
if (comment && comment.length > VALIDATION.DUMP_COMMENT_MAX) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Comment must be at most ${VALIDATION.DUMP_COMMENT_MAX} characters`,
);
}
}
function titleFromUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
export async function createUrlDump(
request: CreateUrlDumpRequest,
userId: string,
): Promise<Dump> {
if (!isValidHttpUrl(request.url)) {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL");
}
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);
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,
);
const categoryIds = [...new Set(request.categoryIds ?? [])];
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,
};
if (!isPrivate) {
broadcastNewDump(dump);
notifyUserFollowersNewDump(userId, dumpId, title);
}
if (request.comment) {
notifyMentions(userId, request.comment, "dump", dumpId, title);
linkAttachments(request.comment, dumpId);
}
relinkBacklinks("dump_comment", dumpId, dumpId, request.comment ?? "");
return dump;
}
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(
APIErrorCode.BAD_REQUEST,
400,
`File type '${file.type}' is not allowed`,
);
}
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"File too large (max 50 MB)",
);
}
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 });
const data = new Uint8Array(await file.arrayBuffer());
try {
await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, data);
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) {
// Roll back the file if DB 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,
};
if (!isPrivate) {
broadcastNewDump(dump);
notifyUserFollowersNewDump(userId, dumpId, finalTitle);
}
if (comment) {
notifyMentions(userId, comment, "dump", dumpId, finalTitle);
linkAttachments(comment, dumpId);
}
relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? "");
return dump;
}
// Internal fetch — no privacy check. Use only when ownership is already enforced.
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(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
return dumpRowToApi(row);
}
// Public fetch — enforces visibility. Returns 404 for private dumps the requester doesn't own.
export function getDump(dumpId: string, requestingUserId?: string): Dump {
const dump = fetchDump(dumpId);
if (dump.isPrivate && dump.userId !== requestingUserId) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
attachCategoryIds([dump]);
return dump;
}
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 };
}
}
export function listDumps(
page: number,
limit: number,
requestingUserId?: string,
): { items: Dump[]; total: number } {
const offset = (page - 1) * limit;
// Show public dumps + the requesting user's own private dumps
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, offset)
: db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(limit, offset);
const totalRow = requestingUserId
? db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?);`,
).get(requestingUserId) as { count: number } | undefined
: db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0;`,
).get() as { count: number } | undefined;
if (!rows || !rows.every(isDumpRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed dump data",
);
}
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}
/**
* Dumps that already point at the same thing as `url`, newest first.
*
* Matched on the canonical key rather than the raw URL, so a link reposted with
* different tracking parameters, scheme, `www.`, or (for YouTube) in a
* different share shape still counts as the same dump. Private dumps are
* visible only to their owner, exactly as everywhere else.
*
* Returns a small display-only projection: this feeds a hint on the create
* form, not a feed.
*/
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,
}));
}
export async function updateDump(
dumpId: string,
request: UpdateDumpRequest,
): Promise<Dump> {
const dump = fetchDump(dumpId);
const now = new Date();
// File dumps: title, comment and isPrivate are editable
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]);
}
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
// Going public is an arrival for everyone who couldn't see it before.
if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump);
if (updatedDump.comment) {
notifyMentions(
dump.userId,
updatedDump.comment,
"dump",
dumpId,
updatedDump.title,
);
linkAttachments(updatedDump.comment, dumpId);
}
relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? "");
return updatedDump;
}
// URL dumps
const newUrl = request.url ?? dump.url!;
if (!isValidHttpUrl(newUrl)) {
throw new APIException(APIErrorCode.BAD_REQUEST, 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(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
if (request.categoryIds !== undefined) {
const ids = [...new Set(request.categoryIds)];
setDumpCategories(dumpId, ids);
updatedDump.categoryIds = ids;
} else {
attachCategoryIds([updatedDump]);
}
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
// Going public is an arrival for everyone who couldn't see it before.
if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump);
if (updatedDump.comment) {
notifyMentions(
dump.userId,
updatedDump.comment,
"dump",
dumpId,
updatedDump.title,
);
linkAttachments(updatedDump.comment, dumpId);
}
relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? "");
return updatedDump;
}
export async function replaceFileDump(
dumpId: string,
file: File,
comment: string | undefined,
title?: string,
categoryIds?: string[],
): Promise<Dump> {
assertFileDumpText(title, comment);
if (!isAllowedMime(file.type)) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
`File type '${file.type}' is not allowed`,
);
}
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"File too large (max 50 MB)",
);
}
const dump = fetchDump(dumpId);
if (dump.kind !== "file") {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump");
}
const data = new Uint8Array(await file.arrayBuffer());
const filePath = `${DUMPS_DIR}/${dumpId}`;
// Read old file contents so we can restore on DB failure
const oldData = await Deno.readFile(filePath).catch(() => null);
await Deno.writeFile(filePath, data);
const now = new Date();
const finalTitle = title?.trim() || dump.title;
const newSlug = finalTitle !== dump.title
? makeSlug(finalTitle, dumpId)
: dump.slug;
try {
db.prepare(
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`,
).run(
finalTitle,
newSlug ?? null,
file.name,
file.type,
file.size,
comment ?? null,
now.toISOString(),
dumpId,
);
} catch (err) {
// Roll back the file to its previous contents on DB failure
if (oldData) await Deno.writeFile(filePath, oldData).catch(() => {});
else await Deno.remove(filePath).catch(() => {});
throw err;
}
if (categoryIds !== undefined) {
const ids = [...new Set(categoryIds)];
setDumpCategories(dumpId, ids);
dump.categoryIds = ids;
} else {
attachCategoryIds([dump]);
}
if (comment) {
notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle);
linkAttachments(comment, dumpId);
}
relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? "");
return {
...dump,
title: finalTitle,
slug: newSlug,
fileName: file.name,
fileMime: file.type,
fileSize: file.size,
comment,
updatedAt: now,
};
}
export function getDumpsByUser(
userId: string,
page: number,
limit: number,
includePrivate: boolean,
): { items: Dump[]; total: number } {
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, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE user_id = ?${privacyFilter};`,
).get(userId) as { count: number } | undefined;
if (!rows.every(isDumpRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed dump data",
);
}
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}
export function getVotedDumpsByUser(
userId: string,
page: number,
limit: number,
requestingUserId: string | null,
): { items: Dump[]; total: number } {
const offset = (page - 1) * limit;
const dumpCols = SELECT_COLS_ALIASED;
let totalRow: { count: number } | undefined;
let rows: unknown[];
if (requestingUserId === userId) {
// Own profile: include private dumps the user themselves voted on and owns.
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, offset);
totalRow = db.prepare(
`SELECT COUNT(*) as count 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 = ?);`,
).get(userId, userId) as { count: number } | undefined;
} 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, offset);
totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps d
INNER JOIN votes v ON d.id = v.dump_id
WHERE v.user_id = ? AND d.is_private = 0;`,
).get(userId) as { count: number } | undefined;
}
if (!rows.every(isDumpRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed dump data",
);
}
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
const dump = fetchDump(dumpId);
if (dump.kind !== "url" || !dump.url) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"Only URL dumps support metadata refresh",
);
}
// A failed fetch yields a stub with no title and no thumbnail. Writing that
// over a dump that already has good metadata would silently destroy it and
// rename the dump to its bare hostname, so bail out instead — the caller
// surfaces the error and nothing is lost.
const { ok, content: richContent } = await tryFetchRichContent(dump.url);
if (!ok) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
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);
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
return updatedDump;
}
export function getDumpThumbnailInfo(
idOrSlug: string,
): { id: string; mime: string } | undefined {
const dump = fetchDump(idOrSlug);
return dump.thumbnailMime
? { id: dump.id, mime: dump.thumbnailMime }
: undefined;
}
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 };
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
return updatedDump;
}
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 };
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
return updatedDump;
}
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(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
if (dump.kind === "file") {
await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {});
}
broadcastDumpDeleted(dumpId);
}