import { Router } from "@oak/oak"; import { verifyJWT } from "../lib/jwt.ts"; import { APIErrorCode, APIException, isCreatePlaylistRequest, isReorderPlaylistRequest, isUpdatePlaylistRequest, } from "../model/interfaces.ts"; import { authMiddleware, type AuthState } from "../middleware/auth.ts"; import { addDumpToPlaylist, createPlaylist, deletePlaylist, getPlaylist, getPlaylistImageMime, getPlaylistMembershipsForDump, removeDumpFromPlaylist, reorderPlaylist, setPlaylistImage, updatePlaylist, } from "../services/playlist-service.ts"; const PLAYLIST_IMAGES_DIR = "api/uploads/playlist-images"; const MAX_IMAGE_SIZE = 5 * 1024 * 1024; const ALLOWED_IMAGE_MIMES = new Set([ "image/jpeg", "image/png", "image/gif", "image/webp", ]); function checkImageMagicBytes(data: Uint8Array, mime: string): boolean { if (mime === "image/webp") { return data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50; } const magic: Record = { "image/jpeg": [0xFF, 0xD8, 0xFF], "image/png": [0x89, 0x50, 0x4E, 0x47], "image/gif": [0x47, 0x49, 0x46, 0x38], }; return (magic[mime] ?? []).every((b, i) => data[i] === b); } 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 memberships = getPlaylistMembershipsForDump(dumpId, userId); ctx.response.body = { success: true, data: memberships }; }); // POST /api/playlists — create router.post("/", authMiddleware, async (ctx) => { const body = await ctx.request.body.json(); if (!isCreatePlaylistRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid request", ); } const playlist = createPlaylist(body, ctx.state.user.userId); ctx.response.status = 201; ctx.response.body = { success: true, data: playlist }; }); // GET /api/playlists/:playlistId — optional auth router.get("/:playlistId", async (ctx) => { let requestingUserId: string | null = null; const authHeader = ctx.request.headers.get("Authorization"); if (authHeader?.startsWith("Bearer ")) { const payload = await verifyJWT(authHeader.substring(7)); if (payload) requestingUserId = payload.userId; } const playlist = getPlaylist(ctx.params.playlistId, requestingUserId); ctx.response.body = { success: true, data: playlist }; }); // PUT /api/playlists/:playlistId — update metadata router.put("/:playlistId", authMiddleware, async (ctx) => { const body = 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, ); ctx.response.body = { success: true, data: playlist }; }); // DELETE /api/playlists/:playlistId router.delete("/:playlistId", authMiddleware, (ctx) => { deletePlaylist(ctx.params.playlistId, ctx.state.user.userId); 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, ); 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, ); 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"); } if (!ALLOWED_IMAGE_MIMES.has(file.type)) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "Only JPEG, PNG, GIF, WebP images are allowed", ); } if (file.size > MAX_IMAGE_SIZE) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "File too large (max 5 MB)", ); } const data = new Uint8Array(await file.arrayBuffer()); if (!checkImageMagicBytes(data, file.type)) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "File content does not match declared type", ); } await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true }); await Deno.writeFile(`${PLAYLIST_IMAGES_DIR}/${ctx.params.playlistId}`, data); const playlist = setPlaylistImage( ctx.params.playlistId, file.type, ctx.state.user.userId, ); ctx.response.body = { success: true, data: playlist }; }); // GET /api/playlists/:playlistId/image — serve playlist image router.get("/:playlistId/image", async (ctx) => { const imageMime = getPlaylistImageMime(ctx.params.playlistId); if (!imageMime) { ctx.response.status = 404; return; } let data: Uint8Array; try { data = await Deno.readFile( `${PLAYLIST_IMAGES_DIR}/${ctx.params.playlistId}`, ); } catch { ctx.response.status = 404; return; } ctx.response.headers.set("Content-Type", imageMime); ctx.response.headers.set("Content-Disposition", "inline"); ctx.response.headers.set("Cache-Control", "public, max-age=3600"); ctx.response.body = data; }); // PUT /api/playlists/:playlistId/order — reorder router.put("/:playlistId/order", authMiddleware, async (ctx) => { const body = 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); ctx.response.body = { success: true, data: null }; }); export default router;