diff --git a/workers/README.md b/workers/README.md index 98efcba0c..e9fbcd341 100644 --- a/workers/README.md +++ b/workers/README.md @@ -1,84 +1,58 @@ -# OG Image Generation +# Open Graph -Two components work together to serve Open Graph preview images for -social media embeds (Twitter/X, Slack, Discord, etc.). +Worker on `open-graph.radicle.network` that serves OG meta tags and +preview card images for social media embeds. -## Components +## How it works -**og-injector** (`og-injector.js`) runs as middleware on the app itself -(Cloudflare Workers). It injects ``, ``, -and related Open Graph tags into every HTML response, pointing to the -OG image worker. +`app.radicle.xyz` is a plain static SPA. A Cloudflare Redirect Rule +302s social media crawlers to `open-graph.radicle.network`, which +returns HTML with OG tags. The `og:image` points to `/cards/...` on +the same domain, where the worker renders PNG cards via Satori + resvg-wasm. -**open-graph** (`open-graph/index.js`) is a standalone Cloudflare -Worker that generates PNG cards on the fly using Satori (HTML/CSS to -SVG) and resvg-wasm (SVG to PNG). It fetches data from the radicle-httpd -API, generates procedural avatars, and caches results at the edge. - -Request flow: request hits app -> og-injector injects og:image URL -> -social crawler fetches image from open-graph worker -> worker calls -httpd API, renders PNG, caches and returns it. +Browsers and search crawlers hit the SPA directly — no worker cost. ## Routes -| URL pattern | Card type | -|-------------|-----------| -| `/` | Home (forest image + wordmark) | -| `/nodes/:host` | Node (avatar + hostname + stats) | -| `/nodes/:host/users/:did` | User (avatar + alias + DID) | -| `/nodes/:host/:rid` | Repository (avatar + name + stats + delegates) | -| `/nodes/:host/:rid/issues` | Issues listing (open + closed counts) | -| `/nodes/:host/:rid/patches` | Patches listing (open/draft/archived/merged counts) | -| `/nodes/:host/:rid/history` | Commit history (branch + commit count) | -| `/nodes/:host/:rid/remotes/:peer/history/:branch?` | Commit history (peer branch) | -| `/nodes/:host/:rid/issues/:id` | Single issue (state pill + title + author) | -| `/nodes/:host/:rid/patches/:id` | Single patch (state pill + title + author) | -| `/nodes/:host/:rid/commits/:sha` | Single commit (title + author/committer gravatars) | +- `/*` — OG HTML with meta tags (cached 2 hours) +- `/cards/*` — PNG card images (cached 2 hours) -## Fallback chain +Card routes mirror app routes: `/cards/nodes/:host/:rid`, +`/cards/nodes/:host/:rid/issues/:id`, etc. When API fetches fail, +cards degrade: specific item -> repo -> node -> home. -When data fetching fails, cards degrade gracefully: +## Cloudflare Redirect Rule - single issue/patch/commit -> repo card -> node card - issues/patches/history listing -> repo card -> node card - repo card -> node card - node/user/home -> always renders +**Rules > Redirect Rules** on the `radicle.xyz` zone: -## Fonts - -Two weights of Booton are bundled as worker data assets: -- `Booton-Regular.ttf` (400) for body text -- `Booton-SemiBold.ttf` (600) for headings and labels - -## Caching - -Two layers of caching: - -- **open-graph worker**: Uses Cloudflare's Cache API (`caches.default`) - at the edge. Responses carry `Cache-Control: public, max-age=120` - (2 minutes). -- **og-injector**: Injected HTML responses carry - `Cache-Control: public, max-age=300` (5 minutes). - -## Deployment - -### og-injector - -Deployed as part of the Cloudflare Pages project via `npm run deploy`. -The `OG_IMAGE_BASE` env var is set to the open-graph worker origin -(`https://open-graph.radicle.network`). - -### open-graph worker - -```sh -npm run deploy:open-graph +``` +(http.host eq "app.radicle.xyz") and ( + (http.user_agent contains "Twitterbot") or + (http.user_agent contains "facebookexternalhit") or + (http.user_agent contains "Facebot") or + (http.user_agent contains "LinkedInBot") or + (http.user_agent contains "Slackbot") or + (http.user_agent contains "Discordbot") or + (http.user_agent contains "TelegramBot") or + (http.user_agent contains "WhatsApp") or + (http.user_agent contains "Pinterestbot") or + (http.user_agent contains "ZulipURLPreview") or + (http.user_agent contains "Bluesky Cardyb") +) ``` -For local development: +Dynamic 302 to `concat("https://open-graph.radicle.network", http.request.uri.path)` + +## Rate Limiting + +**Security > WAF > Rate limiting rules** on the `radicle.network` zone: + +Verified bots are rate limited to 50 requests per 10 seconds across +all of `radicle.network`, with a 1 minute block. + +## Deploy ```sh -npm run start:open-graph +npm run deploy:open-graph # worker +npm run start:open-graph # local dev ``` - -The worker bundles fonts (`.ttf`), the forest image (`.jpg`), and the -resvg WASM module as data assets configured in `wrangler.toml`. diff --git a/workers/og-injector.js b/workers/og-injector.js deleted file mode 100644 index cf4362503..000000000 --- a/workers/og-injector.js +++ /dev/null @@ -1,166 +0,0 @@ -function parseRoute(pathname) { - const segments = pathname.replace(/^\//, "").split("/"); - const first = segments[0]; - - // Home - if (first === "" || first === undefined) { - return { type: "home" }; - } - - if (first !== "nodes" && first !== "seeds") return null; - - const host = segments[1]; - if (!host) return { type: "home" }; - - const rid = segments[2]; - - // /nodes/:host - if (!rid) return { type: "node", host }; - - // /nodes/:host/users/:did - if (rid === "users") { - const did = segments[3]; - return did ? { type: "user", host, did } : { type: "node", host }; - } - - // /nodes/:host/:rid/issues/:id - if (segments[3] === "issues" && segments[4]) { - return { type: "issue", host, rid, id: segments[4] }; - } - // /nodes/:host/:rid/issues - if (segments[3] === "issues") { - return { type: "issues", host, rid }; - } - // /nodes/:host/:rid/patches/:id - if (segments[3] === "patches" && segments[4]) { - return { type: "patch", host, rid, id: segments[4] }; - } - // /nodes/:host/:rid/patches - if (segments[3] === "patches") { - return { type: "patches", host, rid }; - } - // /nodes/:host/:rid/commits/:sha - if (segments[3] === "commits" && segments[4]) { - return { type: "commit", host, rid, sha: segments[4] }; - } - - // /nodes/:host/:rid/history/:branch - if (segments[3] === "history") { - return { type: "history", host, rid }; - } - - // /nodes/:host/:rid/remotes/:peer/history/:branch - if (segments[3] === "remotes" && segments[4] && segments[5] === "history") { - return { type: "history", host, rid }; - } - - // /nodes/:host/:rid — source, tree, etc. share the repo card - return { type: "repo", host, rid }; -} - -function shortenDid(did) { - const match = /^did:key:(z[a-zA-Z0-9]+)$/.exec(did); - if (!match) return did; - const pubkey = match[1]; - return `${pubkey.substring(0, 8)}…${pubkey.slice(-8)}`; -} - -function titleForRoute(route) { - switch (route.type) { - case "home": - return "Radicle Explorer · Decentralized Code Collaboration"; - case "node": - return `Radicle Seed Node · ${route.host}`; - case "user": - return `Radicle User · ${shortenDid(route.did)} · ${route.host}`; - case "repo": - return `Radicle Repo · ${route.rid} · ${route.host}`; - case "issues": - return `Issues · ${route.rid} · ${route.host}`; - case "patches": - return `Patches · ${route.rid} · ${route.host}`; - case "issue": - return `Issue ${route.id.slice(0, 7)} · ${route.rid} · ${route.host}`; - case "patch": - return `Patch ${route.id.slice(0, 7)} · ${route.rid} · ${route.host}`; - case "commit": - return `Commit ${route.sha.slice(0, 7)} · ${route.rid} · ${route.host}`; - case "history": - return `Commit History · ${route.rid} · ${route.host}`; - default: - return "Radicle Explorer · Decentralized Code Collaboration"; - } -} - -function descriptionForRoute(route) { - const h = route.host; - switch (route.type) { - case "home": - return "Explore open-source repositories, issues, and patches on the Radicle peer-to-peer code collaboration network. Sovereign hosting without central servers."; - case "node": - return `Browse repositories hosted on the ${h} Radicle seed node. Explore source code, issues, patches, and contributor activity.`; - case "user": - return `Radicle user profile on ${h}. Browse repositories, patches, and contributions across the peer-to-peer network.`; - case "repo": - return `Radicle repository hosted on ${h}. Browse source code, issues, patches, commit history, and contributor activity.`; - case "issues": - return `Browse issues for this repository on ${h}. View bug reports, feature requests, and ongoing discussions on the Radicle network.`; - case "patches": - return `Browse patches for this repository on ${h}. View proposed changes, code reviews, and merge status on the Radicle network.`; - case "issue": - return `View this issue and its discussion in a Radicle repository on ${h}. Track progress, comments, and resolution.`; - case "patch": - return `View this patch and its review in a Radicle repository on ${h}. Browse revisions, review comments, and merge status.`; - case "commit": - return `View this commit and its diff in a Radicle repository on ${h}. Inspect changed files, author details, and additions.`; - case "history": - return `Browse the commit history of this Radicle repository on ${h}. View commits, contributors, and development activity.`; - default: - return "Explore open-source repositories, issues, and patches on the Radicle peer-to-peer code collaboration network."; - } -} - -export default { - async fetch(request, env) { - const url = new URL(request.url); - const route = parseRoute(url.pathname); - - if (!route) { - return env.ASSETS.fetch(request); - } - - const hostParam = route.type === "home" ? `?host=${url.hostname}` : ""; - const ogImageUrl = `${env.OG_IMAGE_BASE}${url.pathname}${hostParam}`; - const title = titleForRoute(route); - const description = descriptionForRoute(route); - - const indexResponse = await env.ASSETS.fetch( - new Request(new URL("/", url)), - ); - const html = await indexResponse.text(); - - const tags = [ - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ].join("\n "); - - const injected = html.replace("", ` ${tags}\n`); - - return new Response(injected, { - status: indexResponse.status, - headers: { - "content-type": "text/html;charset=UTF-8", - "cache-control": "public, max-age=300", - }, - }); - }, -}; diff --git a/workers/open-graph/index.js b/workers/open-graph/index.js index 0ac9842bb..41f349f44 100644 --- a/workers/open-graph/index.js +++ b/workers/open-graph/index.js @@ -38,8 +38,7 @@ function ensureInit() { } function parseRoute(pathname) { - const path = pathname; - const segments = path.replace(/^\//, "").split("/"); + const segments = pathname.replace(/^\//, "").split("/"); const first = segments[0]; if (first === "" || first === undefined) return { type: "home" }; @@ -2112,13 +2111,134 @@ async function renderPng(template) { return resvg.render().asPng(); } +function titleForRoute(route) { + switch (route.type) { + case "home": + return "Radicle Explorer \u00b7 Decentralized Code Collaboration"; + case "node": + return `Radicle Seed Node \u00b7 ${route.host}`; + case "user": + return `Radicle User \u00b7 ${shortDid(route.did)} \u00b7 ${route.host}`; + case "repo": + return `Radicle Repo \u00b7 ${route.rid} \u00b7 ${route.host}`; + case "issues": + return `Issues \u00b7 ${route.rid} \u00b7 ${route.host}`; + case "patches": + return `Patches \u00b7 ${route.rid} \u00b7 ${route.host}`; + case "issue": + return `Issue ${route.id.slice(0, 7)} \u00b7 ${route.rid} \u00b7 ${route.host}`; + case "patch": + return `Patch ${route.id.slice(0, 7)} \u00b7 ${route.rid} \u00b7 ${route.host}`; + case "commit": + return `Commit ${route.sha.slice(0, 7)} \u00b7 ${route.rid} \u00b7 ${route.host}`; + case "history": + return `Commit History \u00b7 ${route.rid} \u00b7 ${route.host}`; + default: + return "Radicle Explorer \u00b7 Decentralized Code Collaboration"; + } +} + +function descriptionForRoute(route) { + const h = route.host; + switch (route.type) { + case "home": + return "Explore open-source repositories, issues, and patches on the Radicle peer-to-peer code collaboration network. Sovereign hosting without central servers."; + case "node": + return `Browse repositories hosted on the ${h} Radicle seed node. Explore source code, issues, patches, and contributor activity.`; + case "user": + return `Radicle user profile on ${h}. Browse repositories, patches, and contributions across the peer-to-peer network.`; + case "repo": + return `Radicle repository hosted on ${h}. Browse source code, issues, patches, commit history, and contributor activity.`; + case "issues": + return `Browse issues for this repository on ${h}. View bug reports, feature requests, and ongoing discussions on the Radicle network.`; + case "patches": + return `Browse patches for this repository on ${h}. View proposed changes, code reviews, and merge status on the Radicle network.`; + case "issue": + return `View this issue and its discussion in a Radicle repository on ${h}. Track progress, comments, and resolution.`; + case "patch": + return `View this patch and its review in a Radicle repository on ${h}. Browse revisions, review comments, and merge status.`; + case "commit": + return `View this commit and its diff in a Radicle repository on ${h}. Inspect changed files, author details, and additions.`; + case "history": + return `Browse the commit history of this Radicle repository on ${h}. View commits, contributors, and development activity.`; + default: + return "Explore open-source repositories, issues, and patches on the Radicle peer-to-peer code collaboration network."; + } +} + +function escapeHtml(str) { + return str + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +async function handleOgHtml(request, url, env) { + const cache = caches.default; + const cached = await cache.match(request); + if (cached) return cached; + + const route = parseRoute(url.pathname); + if (!route) { + return new Response("Not found", { status: 404 }); + } + + const appBase = env.APP_BASE || "https://app.radicle.xyz"; + const appHost = new URL(appBase).hostname; + const hostParam = route.type === "home" ? `?host=${appHost}` : ""; + const ogImageUrl = escapeHtml( + `https://${url.hostname}/cards${url.pathname}${hostParam}`, + ); + const canonicalUrl = escapeHtml(`${appBase}${url.pathname}`); + const title = escapeHtml(titleForRoute(route)); + const description = escapeHtml(descriptionForRoute(route)); + + const html = ` + +
+ +