import { Router } from "@oak/oak"; import { fetchWithTimeout, isValidHttpUrl, tryFetchRichContent, } from "../services/rich-content-service.ts"; import { APIErrorCode, type APIFailure, type APIResponse, type LinkPreview, } from "../model/interfaces.ts"; import { authMiddleware } from "../middleware/auth.ts"; const previewRouter = new Router(); const PROXYABLE_IMAGE_TYPES = new Set([ "image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "image/bmp", "image/x-icon", "image/vnd.microsoft.icon", ]); // 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; }); // Proxied images, by URL: every viewer of a feed asks for the same // thumbnails, which were fetched from their hosts again for each one. Bounded // by total size; the least recently used go first. const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024; const imageCache = new Map< string, { bytes: Uint8Array; contentType: string; at: number } >(); let imageCacheBytes = 0; function readImageCache( url: string, ): { bytes: Uint8Array; contentType: string } | undefined { const hit = imageCache.get(url); if (!hit) return undefined; imageCache.delete(url); if (Date.now() - hit.at > IMAGE_CACHE_TTL_MS) { imageCacheBytes -= hit.bytes.byteLength; return undefined; } imageCache.set(url, hit); // most recently used return hit; } function writeImageCache( url: string, bytes: Uint8Array, contentType: string, ): void { const old = imageCache.get(url); if (old) { imageCache.delete(url); imageCacheBytes -= old.bytes.byteLength; } while ( imageCacheBytes + bytes.byteLength > IMAGE_CACHE_MAX_BYTES && imageCache.size > 0 ) { const [oldestUrl, oldest] = imageCache.entries().next().value!; imageCache.delete(oldestUrl); imageCacheBytes -= oldest.bytes.byteLength; } imageCache.set(url, { bytes, contentType, at: Date.now() }); imageCacheBytes += bytes.byteLength; } /** Forget every proxied image (tests). */ export function clearImageCache(): void { imageCache.clear(); imageCacheBytes = 0; } 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; } /** * Proxy 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; } const cached = readImageCache(url); if (cached) { sendImage(ctx, cached.bytes, cached.contentType); return; } try { const res = await fetchWithTimeout(url, 8000); // Raster formats only. The bytes go out under this site's origin, so an // SVG (which can carry script) or anything else a browser might execute // would run with the app's origin if opened directly. const contentType = (res.headers.get("content-type") ?? "") .split(";")[0].trim().toLowerCase(); if (!PROXYABLE_IMAGE_TYPES.has(contentType)) { await res.body?.cancel(); ctx.response.status = 400; return; } const MAX_SIZE = 5 * 1024 * 1024; // 5 MB const contentLength = Number(res.headers.get("content-length") ?? "0"); if (contentLength > MAX_SIZE) { await res.body?.cancel(); ctx.response.status = 400; return; } const bytes = new Uint8Array(await res.arrayBuffer()); if (bytes.length > MAX_SIZE) { ctx.response.status = 400; return; } writeImageCache(url, bytes, contentType); sendImage(ctx, bytes, contentType); } catch { ctx.response.status = 502; } }); export default previewRouter;