/** * The link preview routes. * * @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"; /** * The link preview routes: * * - `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. */ const previewRouter: Router = new Router(); // Signed-in only: it's the create form's preview, and an open one lets anyone // use the server to fetch pages for them. 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.VALIDATION_ERROR, message: "Invalid URL" }, }; ctx.response.status = 400; ctx.response.body = responseBody; return; } // `reached` is reported separately because a failed fetch still yields a // usable stub (hostname only). Without the flag the create form cannot tell // "this page has no preview" from "this link is dead", and shows the same // bare card for both. 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, sandboxed so it can't run 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; } // Proxies an external image through the server so HTTP thumbnail URLs don't // trigger mixed-content blocks when the frontend is served over HTTPS. 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;