/** * Reads other sites for the client, as `api/remote/` does for the server: the * previews of links on the create form, the images its thumbnails can't load * directly, and the tracks the players list. On any failure of the last two, * the player falls back to the embed. * * - `GET /api/remote/preview?url=` — signed in. Answers the preview of a link, * and whether its page could be reached. * - `GET /api/remote/image?url=&w=` — public. Serves a remote raster image * through this site, scaled down to `w` (`shared/image.ts`) when it names * one; 400 when it isn't an image, 502 when its host can't be reached. * - `GET /api/remote/bandcamp?url=&force=` — public. Answers a Bandcamp * page's tracks and their stream URLs, for the native player; `force=1` * skips the server's cache. * - `GET /api/remote/youtube?list=` — public. Answers a YouTube playlist's * tracklist, which the global player lists beside the embed. * * @module */ import { Router } from "@oak/oak"; import { parseImageWidth } from "../../shared/image.ts"; import { isValidHttpUrl } from "../remote/fetch.ts"; import { fetchProxiedImage } from "../remote/image-proxy.ts"; import { resolveBandcamp } from "../remote/bandcamp-streams.ts"; import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts"; import { tryFetchRichContent } from "../services/rich-content-service.ts"; import { APIErrorCode, type APIFailure, type APIResponse, } from "../../shared/api.ts"; import type { LinkPreview } from "../../shared/dump.ts"; import type { Tralbum, YouTubePlaylist } from "../../shared/player.ts"; import { authMiddleware } from "../middleware/auth.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/remote" }); // Signed-in only: an open preview would let anyone make the server fetch pages. router.get("/preview", authMiddleware, async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; if (!isValidHttpUrl(url)) { const responseBody: APIFailure = { success: false, error: { code: APIErrorCode.BAD_REQUEST, message: "Invalid URL" }, }; ctx.response.status = 400; ctx.response.body = responseBody; return; } const { ok, content } = await tryFetchRichContent(url); const responseBody: APIResponse = { success: true, data: { reached: ok, richContent: content ?? null }, }; ctx.response.body = responseBody; }); /** * Answers with a proxied image. It is sent from this site's origin, so it is * sandboxed in case a browser opens it as a document. */ function sendImage( ctx: { response: { headers: Headers; body?: unknown } }, bytes: Uint8Array, contentType: string, ): void { ctx.response.headers.set("Content-Type", contentType); ctx.response.headers.set("X-Content-Type-Options", "nosniff"); ctx.response.headers.set( "Content-Security-Policy", "sandbox; default-src 'none'", ); ctx.response.headers.set("Cache-Control", "public, max-age=86400"); ctx.response.body = bytes; } // The frontend sends a thumbnail here when it is plain HTTP, which an HTTPS // page won't load, or when its host's hotlink protection refused the browser. router.get("/image", async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; if (!isValidHttpUrl(url)) { ctx.response.status = 400; return; } try { const image = await fetchProxiedImage( url, parseImageWidth(ctx.request.url.searchParams.get("w")), ); if (!image) { ctx.response.status = 400; return; } sendImage(ctx, image.bytes, image.contentType); } catch { ctx.response.status = 502; } }); // Public: it reads a public page and answers the URLs that page gives any // visitor. router.get("/bandcamp", async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; const force = ctx.request.url.searchParams.get("force") === "1"; const responseBody: APIResponse = { success: true, data: await resolveBandcamp(url, { force }), }; // Not in a shared cache: the signed URLs belong to one page fetch. An hour // is well inside their 24 hours. ctx.response.headers.set("Cache-Control", "private, max-age=3600"); ctx.response.body = responseBody; }); // Public, like /bandcamp: it only reads a public page. router.get("/youtube", async (ctx) => { const listId = ctx.request.url.searchParams.get("list") ?? ""; const responseBody: APIResponse = { success: true, data: await resolveYouTubePlaylist(listId), }; ctx.response.headers.set("Cache-Control", "public, max-age=3600"); ctx.response.body = responseBody; }); export default router;