diff --git a/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/og.png/+server.ts b/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/og.png/+server.ts index ae25e51..ef81903 100644 --- a/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/og.png/+server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/og.png/+server.ts @@ -7,6 +7,66 @@ import { flattenEventRecord, getEventRecordFromContrail, getServerClient } from import { formatInTz, partsInTz } from '@atmo-dev/events-ui'; import { render } from 'svelte/server'; +// Short, REVALIDATING cache policy for the OG image. Overrides the library's +// year-long, non-revalidating default that froze social cards at CDNs and +// scrapers, so event edits (renamed event, new date, new thumbnail) never +// surfaced (flo-bit/atmo-events#41). 5m browser / 1h CDN / 1d stale-while- +// revalidate lets edits propagate while keeping unchanged cards cheap. +const CACHE_CONTROL = 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400'; + +/** FNV-1a 32-bit hash to 8-char hex. Deterministic and header-safe. */ +function fnv1a(input: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16).padStart(8, '0'); +} + +/** + * Weak ETag for the OG image. The ATProto record CID rolls whenever the event + * record's content changes, making it an ideal validator. contrail can hand back + * a null CID (see flattenEventRecord), so fall back to a hash of every field the + * rendered card depends on: name, start time, timezone (it sets the date line), + * and media (the thumbnail). A conditional GET then keeps returning 304 for an + * unchanged card, while any edit that changes the image also changes the ETag. + */ +function ogEtag(event: { + cid?: string | null; + rkey: string; + startsAt: string; + name: string; + timezone?: string | null; + media?: unknown; +}): string { + const basis = + event.cid ?? + `f${fnv1a( + JSON.stringify([ + event.rkey, + event.startsAt, + event.name, + event.timezone ?? '', + event.media ?? null + ]) + )}`; + return `W/"${basis}"`; +} + +/** + * RFC 7232 weak If-None-Match evaluation: matches when the client presents `*` + * or any tag equal to our ETag under weak comparison (the `W/` prefix is ignored + * on both sides). Used to short-circuit an unchanged card to a bare 304. + */ +function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { + if (!header) return false; + if (header.trim() === '*') return true; + const stripWeak = (v: string) => v.trim().replace(/^W\//, ''); + const target = stripWeak(etag); + return header.split(',').some((tag) => stripWeak(tag) === target); +} + function formatDate(dateStr: string, tz: string | undefined): string { // Render in the event's authored timezone when known so OG images match // what the event page shows, regardless of the edge server's local zone. @@ -16,7 +76,7 @@ function formatDate(dateStr: string, tz: string | undefined): string { return `${weekday}, ${month} ${day}`; } -export async function GET({ params, platform }) { +export async function GET({ params, platform, request }) { const { rkey } = params; const did = await getActor(params.actor); @@ -40,6 +100,16 @@ export async function GET({ params, platform }) { throw error(404, 'Event not found'); } + // Serve the validator before doing any rendering work: a matching conditional + // GET returns a bare 304 (no body, no render) so unchanged cards stay cheap. + const etag = ogEtag(eventData); + if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { + return new Response(null, { + status: 304, + headers: { 'Cache-Control': CACHE_CONTROL, ETag: etag } + }); + } + const dateStr = formatDate(eventData.startsAt, eventData.timezone); let thumbnailUrl: string | null = null; @@ -62,8 +132,14 @@ export async function GET({ params, platform }) { .replace(/"/g, '"') .replace(/'/g, "'"); - return new ImageResponse( - decoded, - { width: 1200, height: 630, debug: false, format: 'png' } - ); + // The `headers` override is spread LAST by @ethercorps/sveltekit-og, so it + // wins over the library's baked-in year-long default and serves our + // revalidating policy + validator instead. + return new ImageResponse(decoded, { + width: 1200, + height: 630, + debug: false, + format: 'png', + headers: { 'Cache-Control': CACHE_CONTROL, ETag: etag } + }); } -- 2.51.2 From 5aa762b750a4c0a54036f22cefcefa00f5fa56a6 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Sat, 11 Jul 2026 13:50:25 -0400 Subject: [PATCH 2/2] test(og): cover cache headers, conditional 304, and ETag rolling --- .../p/[actor]/e/[rkey]/og.png/server.test.ts | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 apps/web/src/routes/(app)/p/[actor]/e/[rkey]/og.png/server.test.ts diff --git a/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/og.png/server.test.ts b/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/og.png/server.test.ts new file mode 100644 index 0000000..e378995 --- /dev/null +++ b/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/og.png/server.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Regression net for flo-bit/atmo-events#41: the OG image used to be +// served with the library default `public, immutable, no-transform, +// max-age=31536000`, freezing social cards for a year so event edits never +// surfaced. The route now overrides that with a short, revalidating policy plus +// a CID-derived weak ETag, and short-circuits a matching conditional GET to a +// bare 304 with no render. +// +// Mock at the I/O boundary (contrail D1 client + actor resolution), NOT the +// ImageResponse renderer -- the 200 path builds a real PNG response so we're +// asserting the library's actual header-spread behaviour. Precedent: +// src/routes/(app)/topics/[slug]/page.server.test.ts. +vi.mock('$lib/contrail', () => ({ + getServerClient: vi.fn(() => ({})), + getEventRecordFromContrail: vi.fn(), + flattenEventRecord: vi.fn() +})); + +vi.mock('$lib/actor', () => ({ + getActor: vi.fn() +})); + +// +server.ts's only runtime import from the UI package is the date helpers; +// stubbing the barrel keeps plyr's CSS (which Node's ESM loader rejects) out of +// the test graph. Same pattern as src/lib/search/server/query.test.ts. These are +// pure date formatters, NOT the ImageResponse renderer, so the 200 path still +// builds a real PNG. +vi.mock('@atmo-dev/events-ui', () => ({ + formatInTz: (dateStr: string, _tz: string | undefined, opts: Intl.DateTimeFormatOptions) => + new Intl.DateTimeFormat('en-US', { ...opts, timeZone: 'UTC' }).format(new Date(dateStr)), + partsInTz: (dateStr: string, _tz: string | undefined, opts: Intl.DateTimeFormatOptions) => + Object.fromEntries( + new Intl.DateTimeFormat('en-US', { ...opts, timeZone: 'UTC' }) + .formatToParts(new Date(dateStr)) + .map((p) => [p.type, p.value]) + ) +})); + +import { GET } from './+server'; +import { getActor } from '$lib/actor'; +import { getEventRecordFromContrail, flattenEventRecord } from '$lib/contrail'; + +const mockGetActor = vi.mocked(getActor); +const mockGetEventRecord = vi.mocked(getEventRecordFromContrail); +const mockFlatten = vi.mocked(flattenEventRecord); + +type Flat = ReturnType; + +function flat(overrides: Record = {}): Flat { + return { + name: 'Rust Meetup', + startsAt: '2026-08-01T18:00:00.000Z', + timezone: 'America/New_York', + rkey: 'rk-123', + did: 'did:plc:alice', + uri: 'at://did:plc:alice/community.lexicon.calendar.event/rk-123', + cid: 'bafyreiabc123', + media: [], + ...overrides + } as unknown as Flat; +} + +function evt(opts: { actor?: string; rkey?: string; ifNoneMatch?: string } = {}) { + const actor = opts.actor ?? 'alice.test'; + const rkey = opts.rkey ?? 'rk-123'; + const url = new URL(`https://atmo.test/p/${actor}/e/${rkey}/og.png`); + const headers = new Headers(); + if (opts.ifNoneMatch) headers.set('If-None-Match', opts.ifNoneMatch); + return { + params: { actor, rkey }, + url, + platform: { env: {} }, + request: new Request(url, { headers }) + } as unknown as Parameters[0]; +} + +beforeEach(() => { + mockGetActor.mockResolvedValue('did:plc:alice'); + // A truthy record; flattenEventRecord is what actually shapes eventData below. + mockGetEventRecord.mockResolvedValue( + {} as Awaited> + ); + mockFlatten.mockReturnValue(flat()); +}); + +afterEach(() => vi.clearAllMocks()); + +describe('og.png cache headers + conditional GET', () => { + it('serves a short, revalidating Cache-Control -- no immutable, no year-long max-age', async () => { + const res = await GET(evt()); + expect(res.status).toBe(200); + const cc = res.headers.get('cache-control'); + expect(cc).not.toMatch(/immutable/); + expect(cc).not.toContain('max-age=31536000'); + expect(cc).toContain('max-age=300'); + expect(cc).toContain('s-maxage=3600'); + expect(cc).toContain('stale-while-revalidate=86400'); + }); + + it('renders a PNG on a cache miss and carries an ETag', async () => { + const res = await GET(evt()); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('image/png'); + expect(res.headers.get('etag')).toBeTruthy(); + // A real ImageResponse body stream (the renderer WAS invoked on the miss). + expect(res.body).not.toBeNull(); + }); + + it('304s an unchanged record on matching If-None-Match, with no body and no render', async () => { + const first = await GET(evt()); + const etag = first.headers.get('etag'); + expect(etag).toBeTruthy(); + + const second = await GET(evt({ ifNoneMatch: etag! })); + expect(second.status).toBe(304); + // No body stream: the renderer (ImageResponse) was never constructed. + expect(second.body).toBeNull(); + expect(second.headers.get('etag')).toBe(etag); + // The 304 still carries the revalidating policy, not the library default. + expect(second.headers.get('cache-control')).not.toMatch(/immutable/); + expect(second.headers.get('cache-control')).toContain('max-age=300'); + }); + + it('rolls the ETag when the record CID changes; a stale validator does NOT 304', async () => { + mockFlatten.mockReturnValue(flat({ cid: 'bafyreiOLD' })); + const before = await GET(evt()); + const oldEtag = before.headers.get('etag'); + expect(oldEtag).toBe('W/"bafyreiOLD"'); + + mockFlatten.mockReturnValue(flat({ cid: 'bafyreiNEW' })); + const after = await GET(evt()); + expect(after.headers.get('etag')).toBe('W/"bafyreiNEW"'); + expect(after.headers.get('etag')).not.toBe(oldEtag); + + // A scraper holding the OLD etag re-requests the (now-changed) card: + // weak comparison misses, so it gets fresh bytes, not a 304. + const stale = await GET(evt({ ifNoneMatch: oldEtag! })); + expect(stale.status).toBe(200); + expect(stale.headers.get('content-type')).toBe('image/png'); + }); + + it('falls back to a content validator when CID is null, and still 304s', async () => { + mockFlatten.mockReturnValue(flat({ cid: null })); + const first = await GET(evt()); + const etag = first.headers.get('etag'); + expect(etag).toBeTruthy(); + expect(etag).not.toMatch(/null/); + + // Unchanged null-CID record still short-circuits. + const same = await GET(evt({ ifNoneMatch: etag! })); + expect(same.status).toBe(304); + + // Editing the name rolls the fallback validator. + mockFlatten.mockReturnValue(flat({ cid: null, name: 'Totally Different Event' })); + const renamed = await GET(evt()); + expect(renamed.headers.get('etag')).not.toBe(etag); + }); + + it('rolls the null-CID fallback validator when the thumbnail or timezone changes', async () => { + const thumb = (link: string) => [ + { role: 'thumbnail', content: { $type: 'blob', ref: { $link: link } } } + ]; + mockFlatten.mockReturnValue(flat({ cid: null, media: thumb('blobA') })); + const base = await GET(evt()); + const baseEtag = base.headers.get('etag'); + expect(baseEtag).toBeTruthy(); + + // A new thumbnail changes the rendered card, so the validator must roll. + mockFlatten.mockReturnValue(flat({ cid: null, media: thumb('blobB') })); + const rethumbed = await GET(evt()); + expect(rethumbed.headers.get('etag')).not.toBe(baseEtag); + + // Timezone sets the date line, so a timezone-only edit must roll it too. + mockFlatten.mockReturnValue(flat({ cid: null, media: thumb('blobA'), timezone: 'Asia/Tokyo' })); + const retz = await GET(evt()); + expect(retz.headers.get('etag')).not.toBe(baseEtag); + }); + + it('404s for an unknown actor before touching contrail', async () => { + mockGetActor.mockResolvedValue(null); + await expect(GET(evt())).rejects.toThrow(); + expect(mockGetEventRecord).not.toHaveBeenCalled(); + }); + + it('404s when the event record is missing', async () => { + mockGetEventRecord.mockResolvedValue( + null as Awaited> + ); + await expect(GET(evt())).rejects.toThrow(); + }); +});