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, broadcastNewDump } from "./ws-service.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 SELECT_COLS = "id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_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); db.prepare( `INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, ).run( dumpId, "url", title, request.comment ?? null, userId, createdAt.toISOString(), request.url, richContent ? JSON.stringify(richContent) : null, ); const dump: Dump = { id: dumpId, kind: "url", title, comment: request.comment, userId, createdAt, url: request.url, richContent, voteCount: 0 }; broadcastNewDump(dump); return dump; } export async function createFileDump( file: File, comment: string | undefined, userId: string, ): 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(); 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, comment, user_id, created_at, file_name, file_mime, file_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`, ).run( dumpId, "file", file.name, comment ?? null, userId, createdAt.toISOString(), file.name, file.type, file.size, ); } 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, comment, userId, createdAt, fileName: file.name, fileMime: file.type, fileSize: file.size, voteCount: 0, }; broadcastNewDump(dump); return dump; } export function getDump(dumpId: string): Dump { const row = db.prepare( `SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`, ).get(dumpId); if (!row || !isDumpRow(row)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } return dumpRowToApi(row); } export function listDumps(): Dump[] { const rows = db.prepare( `SELECT ${SELECT_COLS} FROM dumps;`, ).all(); if (!rows || !rows.every(isDumpRow)) { throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data"); } return rows.map(dumpRowToApi); } export async function updateDump( dumpId: string, request: UpdateDumpRequest, ): Promise { const dump = getDump(dumpId); // File dumps: only comment is editable if (dump.kind === "file") { const updatedDump = { ...dump, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment }; db.prepare(`UPDATE dumps SET comment = ? WHERE id = ?;`) .run(updatedDump.comment ?? null, dumpId); 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 updatedDump: Dump = { ...dump, title, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment, url: newUrl, richContent, }; const row = dumpApiToRow(updatedDump); const result = db.prepare( `UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ? WHERE id = ?;`, ).run(row.title, row.comment, row.url, row.rich_content, row.id); if (result.changes === 0) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } 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 = getDump(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); db.prepare( `UPDATE dumps SET title = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ? WHERE id = ?;`, ).run(file.name, file.name, file.type, file.size, comment ?? null, dumpId); return { ...dump, title: file.name, fileName: file.name, fileMime: file.type, fileSize: file.size, comment }; } export function getDumpsByUser(userId: string): Dump[] { const rows = db.prepare( `SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ? ORDER BY created_at DESC;`, ).all(userId); if (!rows.every(isDumpRow)) { throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data"); } return rows.map(dumpRowToApi); } export function getVotedDumpsByUser(userId: string): Dump[] { const rows = db.prepare( `SELECT ${SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ")} FROM dumps d INNER JOIN votes v ON d.id = v.dump_id WHERE v.user_id = ? ORDER BY v.created_at DESC;`, ).all(userId); if (!rows.every(isDumpRow)) { throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data"); } return rows.map(dumpRowToApi); } export async function deleteDump(dumpId: string): Promise { const dump = getDump(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); }