/playlists is the new page of every public playlist, newest first (GET /api/playlists), and /playlists/<slug> a playlist category's, with a menu of the categories, linked from the user menu as "All playlists"; `playlists` is reserved as a dump category's slug, and one that had it is renamed. A profile's Playlists tab narrows to a playlist category with the same menu as its Dumps tab (?category=), and so do the Dumps and Playlists tabs of /search, each with its own kind (category and playlistCategory for GET /api/search), another tab dropping it. The category service takes the kind throughout, so a dump can't be filed under a playlist category nor the other way round, and categoryJoin is the one filter join the dump, follow and playlist services share. useCategories(kind) gives one kind's set from the one fetch, CategoryChips replaces the chips the dump card and page each drew, useCategoryFilter keeps a list to its category as the socket brings more, and useCategorySelection holds a form's picked categories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
874 lines
28 KiB
TypeScript
874 lines
28 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 {
|
|
notifyMentions,
|
|
notifyPlaylistFollowersNowPublic,
|
|
notifyUserFollowersNewDump,
|
|
} from "./notification-service.ts";
|
|
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
|
|
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
|
import { removeGeneratedMedia } from "../lib/ffmpeg.ts";
|
|
import {
|
|
DUMP_ALLOWED_MIME_PREFIXES,
|
|
DUMP_ALLOWED_MIME_TYPES,
|
|
DUMPS_DIR,
|
|
THUMBNAILS_DIR,
|
|
} from "../config.ts";
|
|
import {
|
|
DUMP_MAX_FILE_SIZE_BYTES,
|
|
VALIDATION,
|
|
} from "../../shared/validation.ts";
|
|
import { linkAttachments } from "./attachment-service.ts";
|
|
import { relinkBacklinks } from "./backlink-service.ts";
|
|
import {
|
|
assertCategoriesExist,
|
|
attachCategoryIds,
|
|
categoryJoin,
|
|
setCategories,
|
|
} from "./category-service.ts";
|
|
import { LIKE, likePattern } from "../lib/like.ts";
|
|
import { parseRows } from "../model/rows.ts";
|
|
|
|
function isAllowedMime(mime: string): boolean {
|
|
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
|
DUMP_ALLOWED_MIME_TYPES.has(mime);
|
|
}
|
|
|
|
/**
|
|
* Throws a 400 when a file dump's title or comment is too long. The multipart
|
|
* routes that post and replace a file don't check them, unlike the JSON ones.
|
|
*/
|
|
function assertFileDumpText(title?: string, comment?: string): void {
|
|
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
|
|
throw new APIException(
|
|
400,
|
|
`Title must be at most ${VALIDATION.DUMP_TITLE_MAX} characters`,
|
|
);
|
|
}
|
|
if (comment && comment.length > VALIDATION.DUMP_COMMENT_MAX) {
|
|
throw new APIException(
|
|
400,
|
|
`Comment must be at most ${VALIDATION.DUMP_COMMENT_MAX} characters`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
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<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 };
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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]);
|
|
}
|
|
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)];
|
|
setCategories("dump", dumpId, ids);
|
|
updatedDump.categoryIds = ids;
|
|
} else {
|
|
attachCategoryIds("dump", [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<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,
|
|
};
|
|
broadcastDumpChange(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);
|
|
|
|
broadcastDumpChange(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 };
|
|
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<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 };
|
|
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<void> {
|
|
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 removeGeneratedMedia(dump.id);
|
|
}
|
|
if (dump.thumbnailMime) {
|
|
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
|
|
}
|
|
|
|
broadcastDumpChange(dump, null);
|
|
}
|