/** * Posts, reads, edits and deletes dumps, for the index feed, the dump page, its * edit page and the create form. `:dumpId` takes an id or a slug. A private * dump answers 404 to anyone but its poster, moderators included. * * - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file * (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`). * - `GET /api/dumps/by-url?url=` — optional sign-in. Answers the dumps already * pointing at the same thing as `url`, for the create form's duplicate hint. * - `GET /api/dumps/:dumpId` — optional sign-in. Answers a dump. * - `GET /api/dumps/:dumpId/voters?page=&limit=` — optional sign-in. Answers a * page of the users who upvoted it. * - `GET /api/dumps/:dumpId/related` — optional sign-in. Answers the dumps * linked to or from it. * - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the * playlists it appears in. * - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the * dumps the caller may see, newest first. * - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with * `dump:moderate`. Replaces a file dump's upload and comment, and any of its * title, categories and visibility (multipart). * - `PUT /api/dumps/:dumpId` — signed in, as its poster or with * `dump:moderate`. Updates its title, comment, visibility, categories, and a * link's URL. * - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with * `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5 * MB). * - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with * `dump:moderate`. Removes its custom thumbnail. * - `POST /api/dumps/:dumpId/refresh-metadata` — signed in, as its poster or * with `dump:moderate`. Fetches a link's preview again. * - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with * `dump:moderate`. Deletes it. * * @module */ import { Router } from "@oak/oak"; import { type APIResponse, type PaginatedData } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Dump, DumpUrlMatch } from "../../shared/dump.ts"; import { isCreateUrlDumpRequest, isUpdateDumpRequest, } from "../validators/dump.ts"; import type { Playlist } from "../../shared/playlist.ts"; import type { PublicUser } from "../../shared/user.ts"; import { toPublicUser } from "../model/user.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 { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts"; import { THUMBNAILS_DIR } from "../config.ts"; import { createFileDump, createUrlDump, deleteDump, findDumpsByUrl, 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"; import { getPlaylistsForDump } from "../services/playlist-service.ts"; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * An `APIException` thrown by a handler becomes the JSON error response there. */ const router: Router = new Router({ prefix: "/api/dumps" }); // Each handler resolves `:dumpId` with getDump, which checks privacy and takes // a slug, and passes `dump.id` on: the services' writes match on the id alone. /** * Parses a multipart form's `categoryIds`, a JSON array sent as a string. A * missing or malformed value counts as no category. */ function parseCategoryIds(value: FormDataEntryValue | null): string[] { if (typeof value !== "string" || !value) return []; try { const parsed: unknown = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : []; } catch { return []; } } 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(400, "A file is required"); } dump = await createFileDump( file, typeof comment === "string" && comment ? comment : undefined, userId, isPrivate, typeof title === "string" && title ? title : undefined, parseCategoryIds(formData.get("categoryIds")), ); } else { const body: unknown = await ctx.request.body.json(); if (!isCreateUrlDumpRequest(body)) { throw new APIException(400, "Invalid dump data"); } dump = await createUrlDump(body, userId); } const responseBody: APIResponse = { success: true, data: dump }; ctx.response.status = 201; ctx.response.body = responseBody; }, ); // Registered ahead of "/:dumpId" so the literal path wins over the parameter. router.get("/by-url", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const url = ctx.request.url.searchParams.get("url") ?? ""; const responseBody: APIResponse = { success: true, data: findDumpsByUrl(url, requestingUserId), }; 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, hasMore } = getDumpVoters(dump.id, page, limit); const responseBody: APIResponse> = { success: true, data: { items: items.map(toPublicUser), hasMore, }, }; ctx.response.body = responseBody; }); 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("/:dumpId/playlists", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const dump = getDump(ctx.params.dumpId, requestingUserId); const playlists = getPlaylistsForDump(dump.id, requestingUserId); const responseBody: APIResponse = { success: true, data: playlists, }; ctx.response.body = responseBody; }); router.get("/", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, hasMore } = listDumps(page, limit, requestingUserId); const responseBody: APIResponse> = { success: true, data: { items, hasMore }, }; 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(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(400, "A file is required"); } const updatedDump = await replaceFileDump( dump.id, file, typeof comment === "string" && comment ? comment : undefined, typeof title === "string" && title ? title : undefined, formData.has("categoryIds") ? parseCategoryIds(formData.get("categoryIds")) : undefined, formData.has("isPrivate") ? formData.get("isPrivate") === "true" : 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: unknown = await ctx.request.body.json(); if (!isUpdateDumpRequest(body)) { throw new APIException(400, "Erroneous user input"); } const dump = getDump(dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException(403, "Not authorized to update dump"); } const updatedDump = await updateDump(dump.id, 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(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(400, "Missing file field"); } const data = new Uint8Array(await file.arrayBuffer()); const mime = validateImageUpload(data); // Written before setDumpThumbnail announces it, or clients would fetch it // too early. await replaceUploadedFile(`${THUMBNAILS_DIR}/${dump.id}-custom`, data); const updatedDump = setDumpThumbnail(dump.id, mime); 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(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(403, "Not authorized to update dump"); } const updatedDump = await refreshDumpMetadata(dump.id); 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(403, "Not authorized to delete dump"); } await deleteDump(dump.id); const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; }); export default router;