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"; import { DUMPS_DIR } from "../lib/upload.ts"; 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, updated_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.updated_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 { 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 { 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(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", 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(`${DUMPS_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 { 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 { 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()); 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 newSlug = makeSlug(file.name, dumpId); try { 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, ); } 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 (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 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", ); } return { items: rows.map(dumpRowToApi), 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 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); }