521 lines
14 KiB
TypeScript
521 lines
14 KiB
TypeScript
import {
|
|
APIErrorCode,
|
|
APIException,
|
|
type CreateUrlDumpRequest,
|
|
type Dump,
|
|
type UpdateDumpRequest,
|
|
} from "../model/interfaces.ts";
|
|
import { db, dumpApiToRow, dumpRowToApi, isDumpRow } from "../model/db.ts";
|
|
import { fetchRichContent, isValidHttpUrl } 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";
|
|
|
|
const UPLOADS_DIR = "api/uploads";
|
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
|
|
const ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"];
|
|
const ALLOWED_MIME_TYPES = new Set([
|
|
"application/pdf",
|
|
"application/json",
|
|
"application/zip",
|
|
"application/x-zip-compressed",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
"application/msword",
|
|
"application/vnd.ms-excel",
|
|
"application/vnd.ms-powerpoint",
|
|
]);
|
|
|
|
function isAllowedMime(mime: string): boolean {
|
|
return ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
|
ALLOWED_MIME_TYPES.has(mime);
|
|
}
|
|
|
|
function titleFromUrl(url: string): string {
|
|
try {
|
|
return new URL(url).hostname.replace(/^www\./, "");
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
const BASE_COLS =
|
|
"id, kind, title, slug, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
|
|
|
|
const SELECT_COLS = `${BASE_COLS},
|
|
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
|
|
|
|
const SELECT_COLS_ALIASED =
|
|
"d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," +
|
|
" (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";
|
|
|
|
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);
|
|
const title = 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, rich_content, is_private)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
|
).run(
|
|
dumpId,
|
|
"url",
|
|
title,
|
|
slug,
|
|
request.comment ?? null,
|
|
userId,
|
|
createdAt.toISOString(),
|
|
request.url,
|
|
richContent ? JSON.stringify(richContent) : null,
|
|
isPrivate ? 1 : 0,
|
|
);
|
|
|
|
const dump: Dump = {
|
|
id: dumpId,
|
|
kind: "url",
|
|
title,
|
|
slug,
|
|
comment: request.comment,
|
|
userId,
|
|
createdAt,
|
|
url: request.url,
|
|
richContent,
|
|
voteCount: 0,
|
|
commentCount: 0,
|
|
isPrivate,
|
|
};
|
|
if (!isPrivate) {
|
|
broadcastNewDump(dump);
|
|
notifyUserFollowersNewDump(userId, dumpId, title);
|
|
}
|
|
if (request.comment) {
|
|
notifyMentions(userId, request.comment, "dump", dumpId, title);
|
|
}
|
|
return dump;
|
|
}
|
|
|
|
export async function createFileDump(
|
|
file: File,
|
|
comment: string | undefined,
|
|
userId: string,
|
|
isPrivate = false,
|
|
): Promise<Dump> {
|
|
if (!isAllowedMime(file.type)) {
|
|
throw new APIException(
|
|
APIErrorCode.BAD_REQUEST,
|
|
400,
|
|
`File type '${file.type}' is not allowed`,
|
|
);
|
|
}
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
throw new APIException(
|
|
APIErrorCode.BAD_REQUEST,
|
|
400,
|
|
"File too large (max 50 MB)",
|
|
);
|
|
}
|
|
|
|
const dumpId = crypto.randomUUID();
|
|
const createdAt = new Date();
|
|
const slug = makeSlug(file.name, dumpId);
|
|
|
|
await Deno.mkdir(UPLOADS_DIR, { recursive: true });
|
|
const data = new Uint8Array(await file.arrayBuffer());
|
|
|
|
try {
|
|
await Deno.writeFile(`${UPLOADS_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",
|
|
file.name,
|
|
slug,
|
|
comment ?? null,
|
|
userId,
|
|
createdAt.toISOString(),
|
|
file.name,
|
|
file.type,
|
|
file.size,
|
|
isPrivate ? 1 : 0,
|
|
);
|
|
} catch (err) {
|
|
// Roll back the file if DB insert fails
|
|
await Deno.remove(`${UPLOADS_DIR}/${dumpId}`).catch(() => {});
|
|
throw err;
|
|
}
|
|
|
|
const dump: Dump = {
|
|
id: dumpId,
|
|
kind: "file",
|
|
title: file.name,
|
|
slug,
|
|
comment,
|
|
userId,
|
|
createdAt,
|
|
fileName: file.name,
|
|
fileMime: file.type,
|
|
fileSize: file.size,
|
|
voteCount: 0,
|
|
commentCount: 0,
|
|
isPrivate,
|
|
};
|
|
if (!isPrivate) {
|
|
broadcastNewDump(dump);
|
|
notifyUserFollowersNewDump(userId, dumpId, file.name);
|
|
}
|
|
if (comment) notifyMentions(userId, comment, "dump", dumpId, file.name);
|
|
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");
|
|
}
|
|
return dump;
|
|
}
|
|
|
|
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",
|
|
);
|
|
}
|
|
|
|
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
|
}
|
|
|
|
export async function updateDump(
|
|
dumpId: string,
|
|
request: UpdateDumpRequest,
|
|
): Promise<Dump> {
|
|
const dump = fetchDump(dumpId);
|
|
|
|
const now = new Date();
|
|
|
|
// File dumps: only comment and isPrivate are editable
|
|
if (dump.kind === "file") {
|
|
const updatedDump: Dump = {
|
|
...dump,
|
|
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 comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
|
|
).run(
|
|
updatedDump.comment ?? null,
|
|
updatedDump.isPrivate ? 1 : 0,
|
|
now.toISOString(),
|
|
dumpId,
|
|
);
|
|
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
|
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
|
if (updatedDump.comment) {
|
|
notifyMentions(
|
|
dump.userId,
|
|
updatedDump.comment,
|
|
"dump",
|
|
dumpId,
|
|
updatedDump.title,
|
|
);
|
|
}
|
|
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);
|
|
}
|
|
|
|
const newSlug = makeSlug(title, dumpId);
|
|
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 = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
|
|
).run(
|
|
row.title,
|
|
row.slug,
|
|
row.comment,
|
|
row.url,
|
|
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 (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
|
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
|
if (updatedDump.comment) {
|
|
notifyMentions(
|
|
dump.userId,
|
|
updatedDump.comment,
|
|
"dump",
|
|
dumpId,
|
|
updatedDump.title,
|
|
);
|
|
}
|
|
return updatedDump;
|
|
}
|
|
|
|
export async function replaceFileDump(
|
|
dumpId: string,
|
|
file: File,
|
|
comment: string | undefined,
|
|
): Promise<Dump> {
|
|
if (!isAllowedMime(file.type)) {
|
|
throw new APIException(
|
|
APIErrorCode.BAD_REQUEST,
|
|
400,
|
|
`File type '${file.type}' is not allowed`,
|
|
);
|
|
}
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
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());
|
|
await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data);
|
|
|
|
const now = new Date();
|
|
const newSlug = makeSlug(file.name, dumpId);
|
|
db.prepare(
|
|
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`,
|
|
).run(
|
|
file.name,
|
|
newSlug,
|
|
file.name,
|
|
file.type,
|
|
file.size,
|
|
comment ?? null,
|
|
now.toISOString(),
|
|
dumpId,
|
|
);
|
|
|
|
if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name);
|
|
return {
|
|
...dump,
|
|
title: file.name,
|
|
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",
|
|
);
|
|
}
|
|
return { items: rows.map(dumpRowToApi), 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 rawRows: unknown[];
|
|
|
|
if (requestingUserId === userId) {
|
|
// Own profile: include private dumps the user themselves voted on and owns.
|
|
rawRows = 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 {
|
|
rawRows = 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;
|
|
}
|
|
|
|
const rows = rawRows as Parameters<typeof isDumpRow>[0][];
|
|
if (!rows.every(isDumpRow)) {
|
|
throw new APIException(
|
|
APIErrorCode.SERVER_ERROR,
|
|
500,
|
|
"Malformed dump data",
|
|
);
|
|
}
|
|
return { items: rows.map(dumpRowToApi), 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",
|
|
);
|
|
}
|
|
|
|
const richContent = await fetchRichContent(dump.url);
|
|
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);
|
|
|
|
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(`${UPLOADS_DIR}/${dumpId}`).catch(() => {});
|
|
}
|
|
|
|
broadcastDumpDeleted(dumpId);
|
|
}
|