import { Router } from "@oak/oak"; import { parseOptionalAuth } from "../lib/auth.ts"; import { APIErrorCode, type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { isCreatePlaylistRequest, isReorderPlaylistRequest, isUpdatePlaylistRequest, } from "../validators/playlist.ts"; import type { Playlist, PlaylistMembership, PlaylistWithDumps, } from "../../shared/playlist.ts"; import { authMiddleware, type AuthState } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; import { addDumpToPlaylist, createPlaylist, deletePlaylist, getEditablePlaylist, getPlaylist, getPlaylistImageInfo, getPlaylistMembershipsForDump, removeDumpFromPlaylist, reorderPlaylist, setPlaylistImage, updatePlaylist, } from "../services/playlist-service.ts"; import { PLAYLIST_IMAGES_DIR, serveUploadedFile, validateImageUpload, } from "../lib/upload.ts"; const router = new Router({ prefix: "/api/playlists" }); // GET /api/playlists/by-dump/:dumpId/memberships — must be before /:playlistId router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => { const { dumpId } = ctx.params; const userId = ctx.state.user.userId; const responseBody: APIResponse = { success: true, data: getPlaylistMembershipsForDump(dumpId, userId), }; ctx.response.body = responseBody; }); // POST /api/playlists — create router.post("/", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isCreatePlaylistRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid request", ); } const responseBody: APIResponse = { success: true, data: createPlaylist(body, ctx.state.user.userId), }; ctx.response.status = 201; ctx.response.body = responseBody; }); // GET /api/playlists/:playlistId — optional auth router.get("/:playlistId", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx); const responseBody: APIResponse = { success: true, data: getPlaylist(ctx.params.playlistId, requestingUserId), }; ctx.response.body = responseBody; }); // PUT /api/playlists/:playlistId — update metadata router.put("/:playlistId", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isUpdatePlaylistRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid request", ); } const playlist = updatePlaylist( ctx.params.playlistId, body, ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); const responseBody: APIResponse = { success: true, data: playlist }; ctx.response.body = responseBody; }); // DELETE /api/playlists/:playlistId router.delete("/:playlistId", authMiddleware, (ctx) => { deletePlaylist( ctx.params.playlistId, ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); ctx.response.status = 204; }); // POST /api/playlists/:playlistId/dumps/:dumpId — add dump router.post("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => { addDumpToPlaylist( ctx.params.playlistId, ctx.params.dumpId, ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); ctx.response.status = 204; }); // DELETE /api/playlists/:playlistId/dumps/:dumpId — remove dump router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => { removeDumpFromPlaylist( ctx.params.playlistId, ctx.params.dumpId, ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); ctx.response.status = 204; }); // POST /api/playlists/:playlistId/image — upload playlist image router.post("/:playlistId/image", authMiddleware, async (ctx) => { const contentType = ctx.request.headers.get("content-type") ?? ""; if (!contentType.includes("multipart/form-data")) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "Expected multipart/form-data", ); } const body = await ctx.request.body.formData(); const file = body.get("file"); if (!(file instanceof File)) { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field"); } const data = new Uint8Array(await file.arrayBuffer()); const mime = validateImageUpload(data); // The file goes in before the row changes. Setting image_mime and bumping // updated_at announces the cover (the `?v=` on its URL, the socket event), // so the bytes must already be there: an earlier fetch would cache the old // cover under the new URL, and a failed write would leave a cover that 404s. // Written beside and renamed over, so the old cover is never half-replaced. const { id } = getEditablePlaylist( ctx.params.playlistId, ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); const filePath = `${PLAYLIST_IMAGES_DIR}/${id}`; const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`; await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true }); try { await Deno.writeFile(tmpPath, data); await Deno.rename(tmpPath, filePath); } catch (err) { await Deno.remove(tmpPath).catch(() => {}); throw err; } const playlist = setPlaylistImage( id, mime, ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); const responseBody: APIResponse = { success: true, data: playlist }; ctx.response.body = responseBody; }); // GET /api/playlists/:playlistId/image — serve playlist image router.get("/:playlistId/image", async (ctx) => { const info = getPlaylistImageInfo(ctx.params.playlistId); if (!info) { ctx.response.status = 404; return; } await serveUploadedFile( ctx, `${PLAYLIST_IMAGES_DIR}/${info.id}`, info.imageMime, ); }); // PUT /api/playlists/:playlistId/order — reorder router.put("/:playlistId/order", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isReorderPlaylistRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid request", ); } reorderPlaylist( ctx.params.playlistId, body.dumpIds, ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; }); export default router;