your personal website on atproto - mirror blento.app
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #263 from flo-bit/feat/add-better-og-images

add better og images

authored by

Florian and committed by
GitHub
0aade5f5 8a3814f5

+86 -63
+17
src/lib/cache.ts
··· 11 11 'gh-contrib': 60 * 60 * 12, // 12 hours 12 12 lastfm: 60 * 60, // 1 hour (default, overridable per-put) 13 13 npmx: 60 * 60 * 12, // 12 hours 14 + og: 60 * 60 * 24 * 30, // 30 days 14 15 profile: 60 * 60 * 24, // 24 hours 15 16 ical: 60 * 60 * 2, // 2 hours 16 17 events: 60 * 60, // 1 hour ··· 64 65 ttlSeconds?: number 65 66 ): Promise<void> { 66 67 await this.put(namespace, key, JSON.stringify(value), ttlSeconds); 68 + } 69 + 70 + // === ArrayBuffer convenience (for binary data like images) === 71 + 72 + async getArrayBuffer(namespace: CacheNamespace, key: string): Promise<ArrayBuffer | null> { 73 + return this.kv.get(`${namespace}:${key}`, 'arrayBuffer'); 74 + } 75 + 76 + async putArrayBuffer( 77 + namespace: CacheNamespace, 78 + key: string, 79 + value: ArrayBuffer, 80 + ttlSeconds?: number 81 + ): Promise<void> { 82 + const ttl = ttlSeconds ?? NAMESPACE_TTL[namespace] ?? 0; 83 + await this.kv.put(`${namespace}:${key}`, value, ttl > 0 ? { expirationTtl: ttl } : undefined); 67 84 } 68 85 69 86 // === blento data (keyed by DID, with handle↔did resolution) ===
+3
src/routes/[[actor=actor]]/api/refresh/+server.ts
··· 14 14 throw error(404, 'Page not found'); 15 15 } 16 16 17 + // Invalidate cached OG image so it gets regenerated 18 + cache.delete('og', actor).catch(() => {}); 19 + 17 20 return json(await loadData(actor, cache, true, 'self', env)); 18 21 }
+66 -63
src/routes/[[actor=actor]]/og.png/+server.ts
··· 1 - import { getCDNImageBlobUrl } from '$lib/atproto/methods.js'; 2 - import { createCache } from '$lib/cache'; 3 - import { loadData } from '$lib/website/load'; 4 1 import { env } from '$env/dynamic/private'; 5 2 import { env as publicEnv } from '$env/dynamic/public'; 6 - 7 - import type { ActorIdentifier } from '@atcute/lexicons'; 8 - import { ImageResponse } from '@ethercorps/sveltekit-og'; 9 3 import { error } from '@sveltejs/kit'; 10 - 11 - function escapeHtml(str: string): string { 12 - return str 13 - .replace(/&/g, '&amp;') 14 - .replace(/</g, '&lt;') 15 - .replace(/>/g, '&gt;') 16 - .replace(/"/g, '&quot;') 17 - .replace(/'/g, '&#39;'); 18 - } 4 + import { getActor } from '$lib/actor'; 5 + import { createCache } from '$lib/cache'; 19 6 20 7 export async function GET({ params, platform, request }) { 21 - const cache = createCache(platform); 22 - 23 - const customDomain = request.headers.get('X-Custom-Domain')?.toLowerCase(); 24 - 25 - let actor: ActorIdentifier | undefined = params.actor; 8 + const actor = await getActor({ 9 + request, 10 + paramActor: params.actor, 11 + platform, 12 + blockBoth: false 13 + }); 26 14 27 15 if (!actor) { 28 - const kv = platform?.env?.CUSTOM_DOMAINS; 16 + throw error(404, 'Page not found'); 17 + } 29 18 30 - if (kv && customDomain) { 31 - try { 32 - const did = await kv.get(customDomain); 19 + const cache = createCache(platform); 20 + const cacheKey = actor; 33 21 34 - if (did) actor = did as ActorIdentifier; 35 - } catch (error) { 36 - console.error('failed to get custom domain kv', error); 22 + // Check KV cache first 23 + const cached = await cache?.getArrayBuffer('og', cacheKey); 24 + if (cached) { 25 + return new Response(cached, { 26 + headers: { 27 + 'Content-Type': 'image/png', 28 + 'Cache-Control': 'public, max-age=86400' 37 29 } 38 - } else { 39 - actor = publicEnv.PUBLIC_HANDLE as ActorIdentifier; 40 - } 41 - } 42 - 43 - if (!actor) { 44 - throw error(404, 'Page not found'); 30 + }); 45 31 } 46 32 47 - const data = await loadData(actor, cache, false, 'self', env); 33 + const handle = params.actor ?? publicEnv.PUBLIC_HANDLE; 34 + const siteUrl = `${publicEnv.PUBLIC_DOMAIN}/${handle}`; 48 35 49 - let image: string | undefined = data.profile.avatar; 36 + const accountId = env.CLOUDFLARE_ACCOUNT_ID; 37 + const apiToken = env.CLOUDFLARE_API_TOKEN; 50 38 51 - if (data.publication.icon) { 52 - image = 53 - getCDNImageBlobUrl({ did: data.did, blob: data.publication.icon }) ?? data.profile.avatar; 39 + if (!accountId || !apiToken) { 40 + throw error(500, 'Missing Cloudflare credentials'); 54 41 } 55 42 56 - const name = data.publication?.name ?? data.profile.displayName ?? data.profile.handle; 57 - 58 - const htmlString = ` 59 - <div class="flex flex-col p-8 w-full h-full bg-neutral-900"> 60 - <div class="flex items-center mb-8 mt-16"> 61 - <img src="${escapeHtml(image ?? '')}" width="128" height="128" class="rounded-full" /> 43 + const response = await fetch( 44 + `https://api.cloudflare.com/client/v4/accounts/${accountId}/browser-rendering/screenshot`, 45 + { 46 + method: 'POST', 47 + headers: { 48 + Authorization: `Bearer ${apiToken}`, 49 + 'Content-Type': 'application/json' 50 + }, 51 + body: JSON.stringify({ 52 + url: siteUrl, 53 + screenshotOptions: { 54 + type: 'png', 55 + clip: { 56 + x: 0, 57 + y: 0, 58 + width: 1200, 59 + height: 630 60 + } 61 + }, 62 + viewport: { 63 + width: 1200, 64 + height: 630, 65 + deviceScaleFactor: 2 66 + }, 67 + waitForTimeout: 1000 68 + }) 69 + } 70 + ); 62 71 63 - <h1 class="text-neutral-50 text-7xl ml-4">${escapeHtml(name)}</h1> 64 - </div> 72 + if (!response.ok) { 73 + console.error('Cloudflare screenshot API error:', response.status, await response.text()); 74 + throw error(502, 'Failed to generate OG image'); 75 + } 65 76 66 - <p class="mt-8 text-4xl text-neutral-300">Check out my blento</p> 77 + const imageBuffer = await response.arrayBuffer(); 67 78 68 - <svg class="absolute w-130 h-130 top-50 right-0" viewBox="0 0 900 900" fill="none" xmlns="http://www.w3.org/2000/svg"> 69 - <rect x="100" y="100" width="160" height="340" rx="23" fill="#EF4444"/> 70 - <rect x="640" y="280" width="160" height="340" rx="23" fill="#22C55E"/> 71 - <rect x="280" y="100" width="340" height="340" rx="23" fill="#F59E0B"/> 72 - <rect x="100" y="460" width="340" height="160" rx="23" fill="#0EA5E9"/> 73 - <rect x="640" y="100" width="160" height="160" rx="23" fill="#EAB308"/> 74 - <rect x="100" y="640" width="160" height="160" rx="23" fill="#6366F1"/> 75 - <rect x="460" y="460" width="160" height="160" rx="23" fill="#14B8A6"/> 76 - <rect x="280" y="640" width="520" height="160" rx="23" fill="#A855F7"/> 77 - </svg> 78 - </div> 79 - `; 79 + // Cache in KV (don't await — fire and forget) 80 + cache?.putArrayBuffer('og', cacheKey, imageBuffer).catch(() => {}); 80 81 81 - return new ImageResponse(htmlString, { 82 - width: 1200, 83 - height: 630 82 + return new Response(imageBuffer, { 83 + headers: { 84 + 'Content-Type': 'image/png', 85 + 'Cache-Control': 'public, max-age=86400' 86 + } 84 87 }); 85 88 }