import { Router } from "@oak/oak"; import { APIErrorCode, APIException, type APIResponse, type Dump, isCreateUrlDumpRequest, isUpdateDumpRequest, type PaginatedData, } from "../model/interfaces.ts"; import { authMiddleware } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; import { parseOptionalAuth } from "../lib/auth.ts"; import { parsePagination } from "../lib/pagination.ts"; import { validateImageUpload } from "../lib/upload.ts"; import { THUMBNAILS_DIR } from "../config.ts"; import { createFileDump, createUrlDump, deleteDump, getDump, listDumps, refreshDumpMetadata, removeDumpThumbnail, replaceFileDump, setDumpThumbnail, updateDump, } from "../services/dump-service.ts"; import { getDumpVoters } from "../services/vote-service.ts"; import { getRelatedDumps } from "../services/backlink-service.ts"; const router = new Router({ prefix: "/api/dumps" }); router.post( "/", authMiddleware, async (ctx) => { const userId = ctx.state.user.userId; const contentType = ctx.request.headers.get("content-type") ?? ""; let dump: Dump; if (contentType.includes("multipart/form-data")) { const formData = await ctx.request.body.formData(); const file = formData.get("file"); const comment = formData.get("comment"); const title = formData.get("title"); const isPrivate = formData.get("isPrivate") === "true"; if (!(file instanceof File)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "A file is required", ); } dump = await createFileDump( file, typeof comment === "string" && comment ? comment : undefined, userId, isPrivate, typeof title === "string" && title ? title : undefined, ); } else { const body = await ctx.request.body.json(); if (!isCreateUrlDumpRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid dump data", ); } dump = await createUrlDump(body, userId); } const responseBody: APIResponse = { success: true, data: dump }; ctx.response.status = 201; ctx.response.body = responseBody; }, ); router.get("/:dumpId", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const dump = getDump(ctx.params.dumpId, requestingUserId); const responseBody: APIResponse = { success: true, data: dump }; ctx.response.body = responseBody; }); router.get("/:dumpId/voters", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const dump = getDump(ctx.params.dumpId, requestingUserId); const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = getDumpVoters(dump.id, page, limit); ctx.response.body = { success: true, data: { items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub), total, hasMore: page * limit < total, }, }; }); router.get("/:dumpId/related", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const dump = getDump(ctx.params.dumpId, requestingUserId); const related = getRelatedDumps(dump.id, requestingUserId); const responseBody: APIResponse = { success: true, data: related }; ctx.response.body = responseBody; }); router.get("/", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = listDumps(page, limit, requestingUserId); const responseBody: APIResponse> = { success: true, data: { items, total, hasMore: page * limit < total }, }; ctx.response.body = responseBody; }); router.put("/:dumpId/file", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user.userId; const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, "Not authorized to update dump", ); } const formData = await ctx.request.body.formData(); const file = formData.get("file"); const comment = formData.get("comment"); const title = formData.get("title"); if (!(file instanceof File)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "A file is required", ); } const updatedDump = await replaceFileDump( dumpId, file, typeof comment === "string" && comment ? comment : undefined, typeof title === "string" && title ? title : undefined, ); const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; }); router.put("/:dumpId", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user.userId; const body = await ctx.request.body.json(); if (!isUpdateDumpRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 422, "Erroneous user input", ); } const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, "Not authorized to update dump", ); } const updatedDump = await updateDump(dumpId, body); const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; }); router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user.userId; const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, "Not authorized to update dump", ); } const formData = await ctx.request.body.formData(); const file = formData.get("file"); if (!(file instanceof File)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Missing file field", ); } const data = new Uint8Array(await file.arrayBuffer()); const mime = validateImageUpload(data); // DB update first (resolves slug → id), then file write. const updatedDump = setDumpThumbnail(dump.id, mime); const filePath = `${THUMBNAILS_DIR}/${dump.id}-custom`; await Deno.mkdir(THUMBNAILS_DIR, { recursive: true }); try { await Deno.writeFile(filePath, data); } catch (err) { await Deno.remove(filePath).catch(() => {}); throw err; } const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; }); router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user.userId; const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, "Not authorized to update dump", ); } const updatedDump = await removeDumpThumbnail(dump.id); const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; }); router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user.userId; const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, "Not authorized to update dump", ); } const updatedDump = await refreshDumpMetadata(dumpId); const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; }); router.delete("/:dumpId", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user.userId; const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, "Not authorized to delete dump", ); } await deleteDump(dumpId); const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; }); export default router;