906 lines
27 KiB
TypeScript
906 lines
27 KiB
TypeScript
/**
|
|
* Dumps: posting links and files, editing and deleting them, and reading them
|
|
* as each viewer may see them. A private dump exists only for its poster.
|
|
*
|
|
* The functions that change a dump don't check who asks: the routes do, before
|
|
* calling them.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { APIErrorCode } from "../../shared/api.ts";
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import type {
|
|
CreateUrlDumpRequest,
|
|
Dump,
|
|
DumpUrlMatch,
|
|
UpdateDumpRequest,
|
|
} from "../../shared/dump.ts";
|
|
import { db, transaction } from "../db/connection.ts";
|
|
import {
|
|
DUMP_SELECT_COLUMNS as SELECT_COLS,
|
|
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
|
|
dumpApiToRow,
|
|
dumpRowToApi,
|
|
isDumpRow,
|
|
} from "../model/dump.ts";
|
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
|
import {
|
|
fetchRichContent,
|
|
tryFetchRichContent,
|
|
} from "./rich-content-service.ts";
|
|
import { isValidHttpUrl } from "../remote/fetch.ts";
|
|
import {
|
|
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,
|
|
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";
|
|
|
|
/** Checks whether a file dump may have type `mime`. */
|
|
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 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`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Returns the title a link gets without a better one: its host. */
|
|
function titleFromUrl(url: string): string {
|
|
try {
|
|
return new URL(url).hostname.replace(/^www\./, "");
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Posts a link as `userId`, with the preview fetched from it; a public one is
|
|
* announced and notified to the poster's followers.
|
|
*
|
|
* @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(APIErrorCode.BAD_REQUEST, 400, "Invalid URL");
|
|
}
|
|
assertCategoriesExist(request.categoryIds ?? []);
|
|
|
|
const dumpId = crypto.randomUUID();
|
|
const createdAt = new Date();
|
|
const richContent = await fetchRichContent(request.url);
|
|
// A title typed on the create form wins over the one scraped from the page:
|
|
// the poster has seen the preview and is correcting it.
|
|
const title = request.title?.trim() || richContent?.title ||
|
|
titleFromUrl(request.url);
|
|
const isPrivate = request.isPrivate ?? false;
|
|
const slug = makeSlug(title, dumpId);
|
|
|
|
const categoryIds = [...new Set(request.categoryIds ?? [])];
|
|
transaction(() => {
|
|
db.prepare(
|
|
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
|
).run(
|
|
dumpId,
|
|
"url",
|
|
title,
|
|
slug,
|
|
request.comment ?? null,
|
|
userId,
|
|
createdAt.toISOString(),
|
|
request.url,
|
|
canonicalizeUrl(request.url),
|
|
richContent ? JSON.stringify(richContent) : null,
|
|
isPrivate ? 1 : 0,
|
|
);
|
|
setDumpCategories(dumpId, categoryIds);
|
|
});
|
|
|
|
const dump: Dump = {
|
|
id: dumpId,
|
|
kind: "url",
|
|
title,
|
|
slug,
|
|
comment: request.comment,
|
|
userId,
|
|
createdAt,
|
|
url: request.url,
|
|
richContent,
|
|
voteCount: 0,
|
|
commentCount: 0,
|
|
isPrivate,
|
|
categoryIds,
|
|
};
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Posts an uploaded file as `userId`, stored under {@link DUMPS_DIR}; a public
|
|
* one is announced and notified to the poster's followers.
|
|
*
|
|
* @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(
|
|
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)",
|
|
);
|
|
}
|
|
assertCategoriesExist(categoryIds);
|
|
|
|
const dumpId = crypto.randomUUID();
|
|
const createdAt = new Date();
|
|
const finalTitle = title?.trim() || file.name;
|
|
const slug = makeSlug(finalTitle, dumpId);
|
|
const dedupedCategoryIds = [...new Set(categoryIds)];
|
|
|
|
await Deno.mkdir(DUMPS_DIR, { recursive: true });
|
|
|
|
try {
|
|
// Streamed to disk rather than copied into one more 50 MB buffer first.
|
|
await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, file.stream());
|
|
|
|
transaction(() => {
|
|
db.prepare(
|
|
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
|
).run(
|
|
dumpId,
|
|
"file",
|
|
finalTitle,
|
|
slug,
|
|
comment ?? null,
|
|
userId,
|
|
createdAt.toISOString(),
|
|
file.name,
|
|
file.type,
|
|
file.size,
|
|
isPrivate ? 1 : 0,
|
|
);
|
|
setDumpCategories(dumpId, dedupedCategoryIds);
|
|
});
|
|
} catch (err) {
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Returns a file dump's version as the app puts it in file and thumbnail URLs
|
|
* (`?v=`): it changes whenever the file can have. Mirrored by
|
|
* `dumpFileVersion` in src/utils/shared/urls.ts.
|
|
*/
|
|
export function dumpFileVersion(
|
|
dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">,
|
|
): string {
|
|
return `${dump.fileSize ?? 0}-${
|
|
(dump.updatedAt ?? dump.createdAt).getTime()
|
|
}`;
|
|
}
|
|
|
|
/**
|
|
* Removes the files generated from a dump's upload (see
|
|
* api/routes/thumbnails.ts) when it's replaced or gone, so they're made again
|
|
* from the new one.
|
|
*/
|
|
async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
|
|
await Promise.all(
|
|
[`${dumpId}.jpg`, `${dumpId}.preview.jpg`, `${dumpId}.preview.png`].map(
|
|
(name) => Deno.remove(`${THUMBNAILS_DIR}/${name}`).catch(() => {}),
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns the dump with id or slug `idOrSlug`, or throws a 404. 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);
|
|
}
|
|
|
|
/**
|
|
* Returns a dump, by id or slug, as `requestingUserId` may see it.
|
|
*
|
|
* @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(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
|
}
|
|
attachCategoryIds([dump]);
|
|
return dump;
|
|
}
|
|
|
|
/**
|
|
* Returns a page of the dumps whose title, comment, or previewed title or
|
|
* description contains `query`, newest first, and how many match in all: the
|
|
* public ones, and the requesting user's own.
|
|
*/
|
|
export function searchDumps(
|
|
query: string,
|
|
page: number,
|
|
limit: number,
|
|
requestingUserId?: string,
|
|
): { items: Dump[]; total: number } {
|
|
if (!query.trim()) return { items: [], total: 0 };
|
|
const pattern = `%${query}%`;
|
|
const offset = (page - 1) * limit;
|
|
|
|
const searchClause =
|
|
`(title LIKE ? OR comment LIKE ? OR json_extract(rich_content,'$.title') LIKE ? OR json_extract(rich_content,'$.description') LIKE ?)`;
|
|
const searchParams = [pattern, pattern, pattern, pattern] as const;
|
|
|
|
if (requestingUserId) {
|
|
const rows = db.prepare(
|
|
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
|
).all(requestingUserId, ...searchParams, limit, offset);
|
|
const totalRow = db.prepare(
|
|
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
|
|
).get(requestingUserId, ...searchParams) as { count: number } | undefined;
|
|
const items = rows.filter(isDumpRow).map(dumpRowToApi);
|
|
attachCategoryIds(items);
|
|
return { items, total: totalRow?.count ?? 0 };
|
|
} else {
|
|
const rows = db.prepare(
|
|
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
|
).all(...searchParams, limit, offset);
|
|
const totalRow = db.prepare(
|
|
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
|
|
).get(...searchParams) as { count: number } | undefined;
|
|
const items = rows.filter(isDumpRow).map(dumpRowToApi);
|
|
attachCategoryIds(items);
|
|
return { items, total: totalRow?.count ?? 0 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a page of the dumps, newest first: the public ones, and the
|
|
* requesting user's own.
|
|
*/
|
|
export function listDumps(
|
|
page: number,
|
|
limit: number,
|
|
requestingUserId?: string,
|
|
): Page<Dump> {
|
|
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 + 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(
|
|
APIErrorCode.SERVER_ERROR,
|
|
500,
|
|
"Malformed dump data",
|
|
);
|
|
}
|
|
|
|
const items = rows.map(dumpRowToApi);
|
|
attachCategoryIds(items);
|
|
return pageOf(items, limit);
|
|
}
|
|
|
|
/**
|
|
* Returns the 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,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Applies `request` to a dump: its title, comment, visibility and categories,
|
|
* and a URL dump's link (fetching the new preview). Announces the change as the
|
|
* visibility requires, and notifies anyone newly @mentioned.
|
|
*
|
|
* @throws {APIException} 404 when there is no such dump, 400 when a new URL isn't a public http(s) URL
|
|
* or a category doesn't exist.
|
|
*/
|
|
export async function updateDump(
|
|
dumpId: string,
|
|
request: UpdateDumpRequest,
|
|
): Promise<Dump> {
|
|
const dump = fetchDump(dumpId);
|
|
if (request.categoryIds !== undefined) {
|
|
assertCategoriesExist(request.categoryIds);
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Replaces a file dump's upload, and with it its comment and optionally its
|
|
* title, categories and visibility. `isPrivate` left out, visibility stays as
|
|
* it is; it is taken here rather than by a second request so a new file and a
|
|
* change of visibility land together. The old file is restored if the update
|
|
* fails.
|
|
*
|
|
* @throws {APIException} 404 when there is no such dump; 400 when it isn't a file dump, the title or
|
|
* comment is too long, the file's type isn't allowed, it exceeds 50 MB, or a
|
|
* category doesn't exist.
|
|
*/
|
|
export async function replaceFileDump(
|
|
dumpId: string,
|
|
file: File,
|
|
comment: string | undefined,
|
|
title?: string,
|
|
categoryIds?: string[],
|
|
isPrivate?: boolean,
|
|
): Promise<Dump> {
|
|
assertFileDumpText(title, comment);
|
|
if (!isAllowedMime(file.type)) {
|
|
throw new APIException(
|
|
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");
|
|
}
|
|
if (categoryIds !== undefined) assertCategoriesExist(categoryIds);
|
|
|
|
// The new file is written beside the old one and renamed over it (atomic: a
|
|
// reader sees one or the other). A hard link keeps the old one reachable so
|
|
// a failed update can put it back, without holding either in memory.
|
|
const filePath = `${DUMPS_DIR}/${dumpId}`;
|
|
const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`;
|
|
const backupPath = `${tmpPath}.old`;
|
|
await Deno.mkdir(DUMPS_DIR, { recursive: true });
|
|
try {
|
|
await Deno.writeFile(tmpPath, file.stream());
|
|
} catch (err) {
|
|
await Deno.remove(tmpPath).catch(() => {});
|
|
throw err;
|
|
}
|
|
const hadOld = await Deno.link(filePath, backupPath).then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
await Deno.rename(tmpPath, filePath);
|
|
|
|
const now = new Date();
|
|
const finalTitle = title?.trim() || dump.title;
|
|
const newSlug = finalTitle !== dump.title
|
|
? makeSlug(finalTitle, dumpId)
|
|
: dump.slug;
|
|
const finalPrivate = isPrivate ?? dump.isPrivate;
|
|
try {
|
|
transaction(() => {
|
|
db.prepare(
|
|
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
|
|
).run(
|
|
finalTitle,
|
|
newSlug ?? null,
|
|
file.name,
|
|
file.type,
|
|
file.size,
|
|
comment ?? null,
|
|
finalPrivate ? 1 : 0,
|
|
now.toISOString(),
|
|
dumpId,
|
|
);
|
|
if (categoryIds !== undefined) {
|
|
setDumpCategories(dumpId, [...new Set(categoryIds)]);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
// Put the previous file back on DB failure
|
|
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) {
|
|
notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle);
|
|
linkAttachments(comment, dumpId);
|
|
}
|
|
relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? "");
|
|
const updatedDump: Dump = {
|
|
...dump,
|
|
title: finalTitle,
|
|
slug: newSlug,
|
|
fileName: file.name,
|
|
fileMime: file.type,
|
|
fileSize: file.size,
|
|
comment,
|
|
isPrivate: finalPrivate,
|
|
updatedAt: now,
|
|
};
|
|
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
|
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
|
if (dump.isPrivate && !updatedDump.isPrivate) broadcastNewDump(updatedDump);
|
|
return updatedDump;
|
|
}
|
|
|
|
/**
|
|
* Returns a page of `userId`'s dumps, newest first, the private ones only with
|
|
* `includePrivate`.
|
|
*/
|
|
export function getDumpsByUser(
|
|
userId: string,
|
|
page: number,
|
|
limit: number,
|
|
includePrivate: boolean,
|
|
): Page<Dump> {
|
|
const offset = (page - 1) * limit;
|
|
const privacyFilter = includePrivate ? "" : " AND is_private = 0";
|
|
const rows = db.prepare(
|
|
`SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ?${privacyFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
|
).all(userId, limit + 1, offset);
|
|
if (!rows.every(isDumpRow)) {
|
|
throw new APIException(
|
|
APIErrorCode.SERVER_ERROR,
|
|
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 — their own
|
|
* private ones only when they are the one asking.
|
|
*/
|
|
export function getVotedDumpsByUser(
|
|
userId: string,
|
|
page: number,
|
|
limit: number,
|
|
requestingUserId: string | null,
|
|
): Page<Dump> {
|
|
const offset = (page - 1) * limit;
|
|
const dumpCols = SELECT_COLS_ALIASED;
|
|
let rows: unknown[];
|
|
|
|
if (requestingUserId === userId) {
|
|
// 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 + 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(
|
|
APIErrorCode.SERVER_ERROR,
|
|
500,
|
|
"Malformed dump data",
|
|
);
|
|
}
|
|
const items = rows.map(dumpRowToApi);
|
|
attachCategoryIds(items);
|
|
return pageOf(items, limit);
|
|
}
|
|
|
|
/**
|
|
* Fetches a URL dump's preview again and replaces its title and preview.
|
|
*
|
|
* @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(
|
|
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, {
|
|
fresh: true,
|
|
});
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Returns a dump's id and custom thumbnail type, or undefined without one.
|
|
*
|
|
* @throws {APIException} 404 when there is no such dump.
|
|
*/
|
|
export function getDumpThumbnailInfo(
|
|
idOrSlug: string,
|
|
): { id: string; mime: string } | undefined {
|
|
const dump = fetchDump(idOrSlug);
|
|
return dump.thumbnailMime
|
|
? { id: dump.id, mime: dump.thumbnailMime }
|
|
: undefined;
|
|
}
|
|
|
|
/**
|
|
* Records a dump's new custom thumbnail, once its file is in place.
|
|
*
|
|
* @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 };
|
|
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
|
return updatedDump;
|
|
}
|
|
|
|
/**
|
|
* Removes a dump's custom thumbnail, and its file.
|
|
*
|
|
* @throws {APIException} 404 when there is no such dump.
|
|
*/
|
|
export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
|
|
const dump = fetchDump(dumpId);
|
|
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
|
|
.run(dump.id);
|
|
await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {});
|
|
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
|
|
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
|
return updatedDump;
|
|
}
|
|
|
|
/**
|
|
* Deletes a dump, its files and thumbnails, and announces it.
|
|
*
|
|
* @throws {APIException} 404 when there is no such dump.
|
|
*/
|
|
export async function deleteDump(dumpId: string): Promise<void> {
|
|
const dump = fetchDump(dumpId);
|
|
|
|
const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
|
|
|
|
if (result.changes === 0) {
|
|
throw new APIException(APIErrorCode.NOT_FOUND, 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(() => {});
|
|
}
|
|
|
|
broadcastDumpDeleted(dumpId);
|
|
}
|