diff --git a/src/lib/cache.ts b/src/lib/cache.ts --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -11,6 +11,7 @@ 'gh-contrib': 60 * 60 * 12, // 12 hours lastfm: 60 * 60, // 1 hour (default, overridable per-put) npmx: 60 * 60 * 12, // 12 hours + og: 60 * 60 * 24 * 30, // 30 days profile: 60 * 60 * 24, // 24 hours ical: 60 * 60 * 2, // 2 hours events: 60 * 60, // 1 hour @@ -64,6 +65,22 @@ ttlSeconds?: number ): Promise { await this.put(namespace, key, JSON.stringify(value), ttlSeconds); + } + + // === ArrayBuffer convenience (for binary data like images) === + + async getArrayBuffer(namespace: CacheNamespace, key: string): Promise { + return this.kv.get(`${namespace}:${key}`, 'arrayBuffer'); + } + + async putArrayBuffer( + namespace: CacheNamespace, + key: string, + value: ArrayBuffer, + ttlSeconds?: number + ): Promise { + const ttl = ttlSeconds ?? NAMESPACE_TTL[namespace] ?? 0; + await this.kv.put(`${namespace}:${key}`, value, ttl > 0 ? { expirationTtl: ttl } : undefined); } // === blento data (keyed by DID, with handle↔did resolution) === diff --git a/src/routes/[[actor=actor]]/og.png/+server.ts b/src/routes/[[actor=actor]]/og.png/+server.ts --- a/src/routes/[[actor=actor]]/og.png/+server.ts +++ b/src/routes/[[actor=actor]]/og.png/+server.ts @@ -1,85 +1,88 @@ -import { getCDNImageBlobUrl } from '$lib/atproto/methods.js'; -import { createCache } from '$lib/cache'; -import { loadData } from '$lib/website/load'; import { env } from '$env/dynamic/private'; import { env as publicEnv } from '$env/dynamic/public'; - -import type { ActorIdentifier } from '@atcute/lexicons'; -import { ImageResponse } from '@ethercorps/sveltekit-og'; import { error } from '@sveltejs/kit'; - -function escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} +import { getActor } from '$lib/actor'; +import { createCache } from '$lib/cache'; export async function GET({ params, platform, request }) { - const cache = createCache(platform); - - const customDomain = request.headers.get('X-Custom-Domain')?.toLowerCase(); - - let actor: ActorIdentifier | undefined = params.actor; - - if (!actor) { - const kv = platform?.env?.CUSTOM_DOMAINS; - - if (kv && customDomain) { - try { - const did = await kv.get(customDomain); - - if (did) actor = did as ActorIdentifier; - } catch (error) { - console.error('failed to get custom domain kv', error); - } - } else { - actor = publicEnv.PUBLIC_HANDLE as ActorIdentifier; - } - } + const actor = await getActor({ + request, + paramActor: params.actor, + platform, + blockBoth: false + }); if (!actor) { throw error(404, 'Page not found'); } - const data = await loadData(actor, cache, false, 'self', env); + const cache = createCache(platform); + const cacheKey = actor; - let image: string | undefined = data.profile.avatar; - - if (data.publication.icon) { - image = - getCDNImageBlobUrl({ did: data.did, blob: data.publication.icon }) ?? data.profile.avatar; + // Check KV cache first + const cached = await cache?.getArrayBuffer('og', cacheKey); + if (cached) { + return new Response(cached, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=86400' + } + }); } - const name = data.publication?.name ?? data.profile.displayName ?? data.profile.handle; + const handle = params.actor ?? publicEnv.PUBLIC_HANDLE; + const siteUrl = `${publicEnv.PUBLIC_DOMAIN}/${handle}`; - const htmlString = ` -
-
- + const accountId = env.CLOUDFLARE_ACCOUNT_ID; + const apiToken = env.CLOUDFLARE_API_TOKEN; -

${escapeHtml(name)}

-
+ if (!accountId || !apiToken) { + throw error(500, 'Missing Cloudflare credentials'); + } -

Check out my blento

+ const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/browser-rendering/screenshot`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + url: siteUrl, + screenshotOptions: { + type: 'png', + clip: { + x: 0, + y: 0, + width: 1200, + height: 630 + } + }, + viewport: { + width: 1200, + height: 630, + deviceScaleFactor: 2 + }, + waitForTimeout: 1000 + }) + } + ); - - - - - - - - - - -
-`; + if (!response.ok) { + console.error('Cloudflare screenshot API error:', response.status, await response.text()); + throw error(502, 'Failed to generate OG image'); + } - return new ImageResponse(htmlString, { - width: 1200, - height: 630 + const imageBuffer = await response.arrayBuffer(); + + // Cache in KV (don't await — fire and forget) + cache?.putArrayBuffer('og', cacheKey, imageBuffer).catch(() => {}); + + return new Response(imageBuffer, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=86400' + } }); } diff --git a/src/routes/[[actor=actor]]/api/refresh/+server.ts b/src/routes/[[actor=actor]]/api/refresh/+server.ts --- a/src/routes/[[actor=actor]]/api/refresh/+server.ts +++ b/src/routes/[[actor=actor]]/api/refresh/+server.ts @@ -14,5 +14,8 @@ throw error(404, 'Page not found'); } + // Invalidate cached OG image so it gets regenerated + cache.delete('og', actor).catch(() => {}); + return json(await loadData(actor, cache, true, 'self', env)); }