diff --git a/system/netlify/functions/og-image.mjs b/system/netlify/functions/og-image.mjs index ff0792c486..92125f5a2c 100644 --- a/system/netlify/functions/og-image.mjs +++ b/system/netlify/functions/og-image.mjs @@ -6,16 +6,25 @@ export const config = { path: "/api/og-image" }; const MAX_BYTES = 5 * 1024 * 1024; // 5 MB const FETCH_TIMEOUT_MS = 8000; +const UNAVAILABLE_TTL = 5 * 60 * 1000; +const unavailable = new Map(); -const corsJson = (status, body) => +const corsJson = (status, body, headers = {}) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", + ...headers, }, }); +function unavailableImage(entry) { + return corsJson(404, { error: "Image unavailable", upstreamStatus: entry.status }, { + "Cache-Control": `public, max-age=${Math.max(0, Math.floor((entry.until - Date.now()) / 1000))}`, + }); +} + export default async function handler(req) { if (req.method === "OPTIONS") { return new Response(null, { @@ -41,6 +50,10 @@ export default async function handler(req) { return corsJson(400, { error: "Invalid URL" }); } + const cached = unavailable.get(targetUrl); + if (cached?.until > Date.now()) return unavailableImage(cached); + unavailable.delete(targetUrl); + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); @@ -55,7 +68,18 @@ export default async function handler(req) { }); clearTimeout(timeout); - if (!upstream.ok) return corsJson(502, { error: `HTTP ${upstream.status}` }); + if (!upstream.ok) { + await upstream.body?.cancel().catch(() => {}); + // A missing or denied remote image is an unavailable chat decoration, + // not an origin outage. Keep the same short retry window as og-preview. + if ([401, 403, 404, 410].includes(upstream.status)) { + const entry = { status: upstream.status, until: Date.now() + UNAVAILABLE_TTL }; + if (unavailable.size >= 1000) unavailable.delete(unavailable.keys().next().value); + unavailable.set(targetUrl, entry); + return unavailableImage(entry); + } + return corsJson(502, { error: `HTTP ${upstream.status}` }); + } const contentType = upstream.headers.get("content-type") || "application/octet-stream"; if (!/^image\//i.test(contentType)) { diff --git a/system/tests/og-image.test.mjs b/system/tests/og-image.test.mjs new file mode 100644 index 0000000000..f18425fc91 --- /dev/null +++ b/system/tests/og-image.test.mjs @@ -0,0 +1,45 @@ +// node --experimental-vm-modules --test system/tests/og-image.test.mjs +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { readFile } from "node:fs/promises"; + +const source = await readFile(new URL("../netlify/functions/og-image.mjs", import.meta.url), "utf8"); +async function fixture(fetch) { + let now = 1000000; + const context = vm.createContext({ fetch, Response, URL, AbortController, + Date: { now: () => now }, setTimeout, clearTimeout }); + const module = new vm.SourceTextModule(source, { context }); + await module.link(() => { throw Error("Unexpected import"); }); await module.evaluate(); + return { advance: ms => { now += ms; }, request: (target = "https://example.org/cover.png") => + module.namespace.default(new Request("https://aesthetic.computer/api/og-image?url=" + encodeURIComponent(target))) }; +} + +test("denied or missing preview images stay unavailable and briefly cache the upstream result", async () => { + for (const status of [401, 403, 404, 410]) { + let calls = 0, cancelled = 0; + const f = await fixture(async () => { calls++; return { + ok: false, status, body: { cancel: async () => { cancelled++; } }, + }; }); + const response = await f.request(); + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { error: "Image unavailable", upstreamStatus: status }); + assert.equal(response.headers.get("Cache-Control"), "public, max-age=300"); + assert.equal(response.headers.get("Access-Control-Allow-Origin"), "*"); + f.advance(120000); + assert.equal((await f.request()).headers.get("Cache-Control"), "public, max-age=180"); + assert.equal(calls, 1); assert.equal(cancelled, 1); + f.advance(180000); await f.request(); assert.equal(calls, 2); + } +}); + +test("upstream server errors remain uncached 502s and successful images retain their bytes", async () => { + let calls = 0; + const failed = await fixture(async () => { calls++; return new Response("unavailable", { status: 503 }); }); + assert.equal((await failed.request()).status, 502); + assert.equal((await failed.request()).status, 502); assert.equal(calls, 2); + const f = await fixture(async () => new Response(new Uint8Array([1, 2, 3]), { headers: { "Content-Type": "image/png" } })); + const image = await f.request(); + assert.equal(image.status, 200); assert.equal(image.headers.get("Content-Type"), "image/png"); + assert.deepEqual(new Uint8Array(await image.arrayBuffer()), new Uint8Array([1, 2, 3])); +});