/** * Fetches link previews for the create form, and proxies the remote images * that the frontend's thumbnails can't load directly. * * - `GET /api/preview?url=` — signed in. Answers the preview of a link, and * whether its page could be reached. * - `GET /api/proxy-image?url=` — public. Serves a remote raster image through * this site; 400 when it isn't one, 502 when its host can't be reached. * * @module */ import { Router } from "@oak/oak"; import { isValidHttpUrl } from "../remote/fetch.ts"; import { fetchProxiedImage } from "../remote/image-proxy.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 { 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 previewRouter: Router = new Router(); // Signed-in only: an open preview would let anyone make the server fetch pages. previewRouter.get("/api/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. previewRouter.get("/api/proxy-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); if (!image) { ctx.response.status = 400; return; } sendImage(ctx, image.bytes, image.contentType); } catch { ctx.response.status = 502; } }); export default previewRouter;