import { APIErrorCode, APIException, type CreateUrlDumpRequest, type Dump, 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 } 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 { DUMP_ALLOWED_MIME_PREFIXES, DUMP_ALLOWED_MIME_TYPES, DUMP_MAX_FILE_SIZE_BYTES, DUMPS_DIR, THUMBNAILS_DIR, } 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); } 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 { 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 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 { 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 }; } export async function updateDump( dumpId: string, request: UpdateDumpRequest, ): Promise { 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); 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 = ?, 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 (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); 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 { 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 { 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 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 { 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 { 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); }