/** * Serves playlists, their dumps and their covers. `:playlistId` is a * playlist's id; its page reads it by its path, through * `GET /api/users/:key/playlists/:slug`. * * - `POST /api/playlists` — signed in. Creates a playlist. * - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist * with the dumps the caller may see in it. A private one 404s but for its * owner. * - `PUT /api/playlists/:playlistId` — signed in, as its owner or with * `playlist:moderate`. Updates its title, slug, description and visibility. * A new slug keeps the old one as an alias, 3 times a day at most but for * those with `playlist:moderate`. * - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with * `playlist:moderate`. Deletes it. * - `POST /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner * or with `playlist:moderate`. Adds a dump at the top. * - `DELETE /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner * or with `playlist:moderate`. Removes a dump. * - `PUT /api/playlists/:playlistId/cover` — signed in, as its owner or with * `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB). * - `GET /api/playlists/:playlistId/cover?token=` — public, but a private * playlist's cover needs its owner's media `token`. Serves its cover, or 404s * without one. * - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with * `playlist:moderate`. Reorders the dumps the caller can see in it. * * Which of the caller's playlists hold a dump is `GET /api/me/playlists`'s. * * @module */ import { Router } from "@oak/oak"; import { authenticateMedia, parseOptionalAuth } from "../lib/auth.ts"; import { type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { isCreatePlaylistRequest, isReorderPlaylistRequest, isUpdatePlaylistRequest, } from "../validators/playlist.ts"; import type { Playlist, 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, removeDumpFromPlaylist, reorderPlaylist, setPlaylistImage, updatePlaylist, } from "../services/playlist-service.ts"; import { replaceUploadedFile, serveUploadedFile, validateImageUpload, } from "../lib/upload.ts"; import { PLAYLIST_IMAGES_DIR } from "../config.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/playlists", }); router.post("/", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isCreatePlaylistRequest(body)) { throw new APIException(400, "Invalid request"); } const responseBody: APIResponse = { success: true, data: createPlaylist(body, ctx.state.user.userId), }; ctx.response.status = 201; ctx.response.body = responseBody; }); 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; }); router.put("/:playlistId", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isUpdatePlaylistRequest(body)) { throw new APIException(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; }); router.delete("/:playlistId", authMiddleware, (ctx) => { deletePlaylist( ctx.params.playlistId, ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); ctx.response.status = 204; }); 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; }); 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; }); router.put("/:playlistId/cover", authMiddleware, async (ctx) => { const contentType = ctx.request.headers.get("content-type") ?? ""; if (!contentType.includes("multipart/form-data")) { throw new APIException(400, "Expected multipart/form-data"); } const body = await ctx.request.body.formData(); const file = body.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 setPlaylistImage announces it, or clients would fetch the // new `?v=` URL too early and cache the old cover under it. const { id } = getEditablePlaylist( ctx.params.playlistId, ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); await replaceUploadedFile(`${PLAYLIST_IMAGES_DIR}/${id}`, data); 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; }); router.get("/:playlistId/cover", async (ctx) => { // An can't send an Authorization header, so a private playlist's owner // is known by the media token in the query string, as for dump files. const token = ctx.request.url.searchParams.get("token"); const viewerId = token ? await authenticateMedia(token) : null; const info = getPlaylistImageInfo(ctx.params.playlistId, viewerId); if (!info) { ctx.response.status = 404; return; } // The URL may carry a media token, which a Referer would leak. ctx.response.headers.set("Referrer-Policy", "no-referrer"); await serveUploadedFile( ctx, `${PLAYLIST_IMAGES_DIR}/${info.id}`, info.imageMime, 3600, !info.isPublic, ); }); router.put("/:playlistId/order", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isReorderPlaylistRequest(body)) { throw new APIException(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;