this repo has no description
10
fork

Configure Feed

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

fix(og): handle-based project-og URL + exact JPEG bytes for link previews

Project pages now set og:image to `/api/registry/project-og/{handle}` instead
of og-banner with an encoded DID, so Cardyb’s nested URL encoding stays
simpler and matches patterns that unfurl reliably.

- Add `lib/og-banner-serve.ts` with shared `buildOgBannerResponse`, exact
ArrayBuffer slicing for Content-Length bodies, JPEG magic validation on
DB cache (invalid rows fall back to PDS resize + overwrite)
- Store tight `Uint8Array` copies (`.slice()`) before writing og_jpeg to Turso
- Keep `/api/registry/og-banner/{did}` as a thin wrapper for compatibility

Made-with: Cursor

+144 -104
+106
lib/og-banner-serve.ts
··· 1 + /** 2 + * Shared logic for project link-preview JPEGs (1200×630). 3 + * Used by `/api/registry/og-banner/{did}` and `/api/registry/project-og/{handle}`. 4 + */ 5 + import { Image } from "https://deno.land/x/imagescript@1.3.0/mod.ts"; 6 + import type { ProfileRow } from "./registry.ts"; 7 + import { getOgJpeg, storeOgJpeg } from "./registry.ts"; 8 + import { fetchBlobPublic } from "./pds.ts"; 9 + 10 + const OG_W = 1200; 11 + const OG_H = 630; 12 + const JPEG_QUALITY = 85; 13 + 14 + /** Copy only the bytes covered by `u` into a standalone ArrayBuffer for Response. */ 15 + function u8ToExactArrayBuffer(u: Uint8Array): ArrayBuffer { 16 + if (u.byteOffset === 0 && u.byteLength === u.buffer.byteLength) { 17 + return u.buffer as ArrayBuffer; 18 + } 19 + return u.buffer.slice( 20 + u.byteOffset, 21 + u.byteOffset + u.byteLength, 22 + ) as ArrayBuffer; 23 + } 24 + 25 + const CACHE_CONTROL = 26 + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400"; 27 + 28 + function looksLikeJpeg(u: Uint8Array): boolean { 29 + return u.byteLength > 3 && u[0] === 0xff && u[1] === 0xd8 && u[2] === 0xff; 30 + } 31 + 32 + const JPEG_HEADERS = { 33 + "content-type": "image/jpeg", 34 + "cache-control": CACHE_CONTROL, 35 + "content-disposition": "inline", 36 + "access-control-allow-origin": "*", 37 + "cross-origin-resource-policy": "cross-origin", 38 + } as const; 39 + 40 + function jpegResponse(jpeg: Uint8Array, etag: string): Response { 41 + return new Response(u8ToExactArrayBuffer(jpeg), { 42 + status: 200, 43 + headers: { 44 + ...JPEG_HEADERS, 45 + "content-length": String(jpeg.byteLength), 46 + etag, 47 + }, 48 + }); 49 + } 50 + 51 + /** 52 + * Build a 200 `Response` for the profile’s OG / link-card image, or `null` if 53 + * there is no banner or the upstream blob is missing. 54 + */ 55 + export async function buildOgBannerResponse( 56 + profile: ProfileRow, 57 + ): Promise<Response | null> { 58 + if (!profile.bannerCid) return null; 59 + 60 + const cached = await getOgJpeg(profile.did).catch(() => null); 61 + if (cached && cached.byteLength > 0 && looksLikeJpeg(cached)) { 62 + return jpegResponse(cached, `${profile.bannerCid}-og`); 63 + } 64 + 65 + let upstream: Response; 66 + try { 67 + upstream = await fetchBlobPublic( 68 + profile.pdsUrl, 69 + profile.did, 70 + profile.bannerCid, 71 + ); 72 + } catch (err) { 73 + console.warn("[og-banner] proxy error:", err); 74 + return null; 75 + } 76 + if (!upstream.ok) return null; 77 + 78 + const buf = new Uint8Array(await upstream.arrayBuffer()); 79 + try { 80 + const img = await Image.decode(buf); 81 + const jpegLoose = new Uint8Array( 82 + await img.cover(OG_W, OG_H).encodeJPEG(JPEG_QUALITY), 83 + ); 84 + const jpeg = jpegLoose.slice(); 85 + storeOgJpeg(profile.did, jpeg).catch((err) => 86 + console.warn("[og-banner] failed to cache og_jpeg:", err) 87 + ); 88 + return jpegResponse(jpeg, `${profile.bannerCid}-og`); 89 + } catch (err) { 90 + console.warn("[og-banner] resize failed, serving raw bytes:", err); 91 + const ct = upstream.headers.get("content-type") ?? 92 + profile.bannerMime ?? "application/octet-stream"; 93 + return new Response(u8ToExactArrayBuffer(buf), { 94 + status: 200, 95 + headers: { 96 + "content-type": ct, 97 + "content-length": String(buf.byteLength), 98 + "cache-control": CACHE_CONTROL, 99 + "content-disposition": "inline", 100 + "access-control-allow-origin": "*", 101 + "cross-origin-resource-policy": "cross-origin", 102 + "etag": profile.bannerCid, 103 + }, 104 + }); 105 + } 106 + }
+1 -1
lib/registry.ts
··· 568 568 * Read the pre-generated OG JPEG bytes for a project, or null if none 569 569 * has been stored yet (e.g. profile pre-dates this feature). 570 570 */ 571 - export async function getOgJpeg(did: string): Promise<Uint8Array | null> { 571 + export function getOgJpeg(did: string): Promise<Uint8Array | null> { 572 572 return withDb(async (c) => { 573 573 const r = await c.execute({ 574 574 sql: `SELECT og_jpeg FROM profile WHERE did = ?`,
+2 -1
routes/api/admin/backfill-og-jpegs.ts
··· 57 57 } 58 58 const buf = new Uint8Array(await upstream.arrayBuffer()); 59 59 const img = await Image.decode(buf); 60 - const jpeg = new Uint8Array( 60 + const jpegLoose = new Uint8Array( 61 61 await img.cover(OG_W, OG_H).encodeJPEG(JPEG_QUALITY), 62 62 ); 63 + const jpeg = jpegLoose.slice(); 63 64 await storeOgJpeg(row.did, jpeg); 64 65 processed++; 65 66 } catch (err) {
+2 -2
routes/api/registry/banner/[did].ts
··· 1 1 /** 2 2 * Proxies a project's banner blob from the owner's PDS for the in-page 3 3 * `<img>` (full resolution). Open Graph / Twitter meta images use 4 - * `/api/registry/og-banner/{did}` instead — a small 1200×630 JPEG for embed 5 - * pipelines (e.g. Bluesky composer) that struggle with large PNGs. 4 + * `/api/registry/project-og/{handle}` (or `og-banner/{did}`) — a small 5 + * 1200×630 JPEG for embed pipelines that struggle with large PNGs. 6 6 * 7 7 * The response is aggressively cached — the cache key includes the DID 8 8 * (stable) but not the CID, so cache-control is bounded the same way as
+8 -98
routes/api/registry/og-banner/[did].ts
··· 1 1 /** 2 - * Social / link-preview sized banner for `og:image` only (~1200×630 JPEG). 3 - * 4 - * Fast path: returns the pre-generated JPEG stored in the database at 5 - * profile-save time (< 10 ms, no PDS round-trip, no ImageScript). 6 - * 7 - * Slow path (fallback for older profiles that pre-date the og_jpeg cache): 8 - * fetches the full-resolution blob from the PDS, runs ImageScript 9 - * center-crop + JPEG re-encode, and stores the result back into the DB so 10 - * the next request hits the fast path. 11 - * 12 - * In-page banners keep using `/api/registry/banner/{did}`. 2 + * Social / link-preview sized banner for `og:image` (~1200×630 JPEG), keyed by DID. 3 + * Prefer `/api/registry/project-og/{handle}` in page meta (shorter URL, no encoded 4 + * colons). This route remains for bookmarks, API clients, and back-compat. 13 5 */ 14 - import { Image } from "https://deno.land/x/imagescript@1.3.0/mod.ts"; 15 6 import { define } from "../../../../utils.ts"; 16 - import { 17 - getOgJpeg, 18 - getProfileByDid, 19 - storeOgJpeg, 20 - } from "../../../../lib/registry.ts"; 21 - import { fetchBlobPublic } from "../../../../lib/pds.ts"; 7 + import { getProfileByDid } from "../../../../lib/registry.ts"; 8 + import { buildOgBannerResponse } from "../../../../lib/og-banner-serve.ts"; 22 9 import { withRateLimit } from "../../../../lib/rate-limit.ts"; 23 10 24 - const OG_W = 1200; 25 - const OG_H = 630; 26 - const JPEG_QUALITY = 85; 27 - 28 - const OG_HEADERS = { 29 - "content-type": "image/jpeg", 30 - "cache-control": 31 - "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 32 - "content-disposition": 'inline; filename="og-banner.jpg"', 33 - "access-control-allow-origin": "*", 34 - "cross-origin-resource-policy": "cross-origin", 35 - } as const; 36 - 37 11 export const handler = define.handlers({ 38 12 GET: withRateLimit(async (ctx) => { 39 13 const did = decodeURIComponent(ctx.params.did); ··· 41 15 if (!profile?.bannerCid) { 42 16 return new Response("not found", { status: 404 }); 43 17 } 44 - 45 - // ── Fast path: pre-generated JPEG already in the DB ────────────────── 46 - const cached = await getOgJpeg(did).catch(() => null); 47 - if (cached && cached.byteLength > 0) { 48 - return new Response(cached.buffer as ArrayBuffer, { 49 - status: 200, 50 - headers: { 51 - ...OG_HEADERS, 52 - "content-length": String(cached.byteLength), 53 - "etag": `${profile.bannerCid}-og`, 54 - }, 55 - }); 56 - } 57 - 58 - // ── Slow path: fetch from PDS, resize, store, return ───────────────── 59 - try { 60 - const upstream = await fetchBlobPublic( 61 - profile.pdsUrl, 62 - did, 63 - profile.bannerCid, 64 - ); 65 - if (!upstream.ok) { 66 - return new Response("not found", { status: 404 }); 67 - } 68 - const buf = new Uint8Array(await upstream.arrayBuffer()); 69 - try { 70 - const img = await Image.decode(buf); 71 - const jpeg = new Uint8Array( 72 - await img.cover(OG_W, OG_H).encodeJPEG(JPEG_QUALITY), 73 - ); 74 - // Store asynchronously so the response isn't blocked by the DB write. 75 - storeOgJpeg(did, jpeg).catch((err) => 76 - console.warn("[og-banner] failed to cache og_jpeg:", err) 77 - ); 78 - return new Response(jpeg.buffer as ArrayBuffer, { 79 - status: 200, 80 - headers: { 81 - ...OG_HEADERS, 82 - "content-length": String(jpeg.byteLength), 83 - "etag": `${profile.bannerCid}-og`, 84 - }, 85 - }); 86 - } catch (resizeErr) { 87 - console.warn( 88 - "[og-banner] resize failed, serving raw bytes:", 89 - resizeErr, 90 - ); 91 - const ct = upstream.headers.get("content-type") ?? 92 - profile.bannerMime ?? 93 - "application/octet-stream"; 94 - return new Response(buf.buffer as ArrayBuffer, { 95 - status: 200, 96 - headers: { 97 - "content-type": ct, 98 - "content-length": String(buf.byteLength), 99 - "cache-control": OG_HEADERS["cache-control"], 100 - "content-disposition": "inline", 101 - "access-control-allow-origin": "*", 102 - "cross-origin-resource-policy": "cross-origin", 103 - "etag": profile.bannerCid, 104 - }, 105 - }); 106 - } 107 - } catch (err) { 108 - console.warn("[og-banner] proxy error:", err); 109 - return new Response("upstream error", { status: 502 }); 110 - } 18 + const res = await buildOgBannerResponse(profile); 19 + if (!res) return new Response("not found", { status: 404 }); 20 + return res; 111 21 }), 112 22 });
+2 -1
routes/api/registry/profile.ts
··· 296 296 } 297 297 // Pre-generate the 1200×630 JPEG for the og:image cache. This runs 298 298 // after the PDS upload succeeds so a resize failure never blocks saving. 299 - ogJpeg = await generateOgJpeg(bytes); 299 + const rawOg = await generateOgJpeg(bytes); 300 + ogJpeg = rawOg ? rawOg.slice() : null; 300 301 } 301 302 302 303 /**
+22
routes/api/registry/project-og/[handle].ts
··· 1 + /** 2 + * Same bytes as `/api/registry/og-banner/{did}`, but keyed by registry handle. 3 + * Used in HTML `og:image` so unfurlers see a short URL without `%3A` / DID 4 + * encoding (Cardyb double-encodes the inner URL). 5 + */ 6 + import { define } from "../../../../utils.ts"; 7 + import { getProfileByHandle } from "../../../../lib/registry.ts"; 8 + import { buildOgBannerResponse } from "../../../../lib/og-banner-serve.ts"; 9 + import { withRateLimit } from "../../../../lib/rate-limit.ts"; 10 + 11 + export const handler = define.handlers({ 12 + GET: withRateLimit(async (ctx) => { 13 + const handle = decodeURIComponent(ctx.params.handle).toLowerCase(); 14 + const profile = await getProfileByHandle(handle).catch(() => null); 15 + if (!profile?.bannerCid) { 16 + return new Response("not found", { status: 404 }); 17 + } 18 + const res = await buildOgBannerResponse(profile); 19 + if (!res) return new Response("not found", { status: 404 }); 20 + return res; 21 + }), 22 + });
+1 -1
routes/explore/[handle].tsx
··· 91 91 messages.detail.missingProfile; 92 92 const ogImageUrl = profile.bannerCid 93 93 ? new URL( 94 - `/api/registry/og-banner/${encodeURIComponent(profile.did)}`, 94 + `/api/registry/project-og/${encodeURIComponent(profile.handle)}`, 95 95 ctx.url.origin, 96 96 ).href 97 97 : undefined;