diff --git a/.gitignore b/.gitignore
index 81ab24e..39a7a37 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,10 @@ seeds/
# OS
.DS_Store
+
+# wrangler files
+.wrangler
+.dev.vars*
+!.dev.vars.example
+.env*
+!.env.example
diff --git a/opengraph/README.md b/opengraph/README.md
new file mode 100644
index 0000000..1776968
--- /dev/null
+++ b/opengraph/README.md
@@ -0,0 +1,19 @@
+# OpenGraph Worker
+
+Cloudflare Worker that injects dynamic `og:title`, `og:description`, `og:image`, and `og:url` into the atbbs SPA for link previews on social platforms.
+
+Handles two route patterns:
+
+- `/bbs/*` — rewrites the HTML `` tags with BBS/board/thread metadata from Slingshot
+- `/og/bbs/*.png` — generates a branded 1200x630 preview image on the fly
+
+## Deploy
+
+```bash
+cd opengraph
+npm install
+npx wrangler login
+npx wrangler deploy -c wrangler.toml
+```
+
+Requires `atbbs.xyz` to be proxied through Cloudflare.
diff --git a/opengraph/hero-light.svg b/opengraph/hero-light.svg
new file mode 100644
index 0000000..bc12127
--- /dev/null
+++ b/opengraph/hero-light.svg
@@ -0,0 +1,66 @@
+
diff --git a/opengraph/index.ts b/opengraph/index.ts
new file mode 100644
index 0000000..d1b36a2
--- /dev/null
+++ b/opengraph/index.ts
@@ -0,0 +1,340 @@
+import { ImageResponse, loadGoogleFont } from "workers-og";
+import heroLogoSvg from "./hero-light.svg";
+
+const SLINGSHOT_URL = "https://slingshot.microcosm.blue/xrpc";
+
+const DEFAULT_TITLE = "atbbs";
+const DEFAULT_DESCRIPTION = "Decentralized forums on the AT Protocol.";
+
+// Tailwind neutral palette — matches the site's light theme.
+const COLORS = {
+ background: "#fafafa", // neutral-50
+ title: "#171717", // neutral-900
+ subtitle: "#525252", // neutral-600
+ description: "#525252", // neutral-600
+};
+
+// Types
+
+interface Route {
+ type: "bbs" | "board" | "thread" | "news";
+ handle: string;
+ slug?: string;
+ did?: string;
+ rkey?: string;
+}
+
+interface Metadata {
+ title: string;
+ subtitle: string;
+ description: string;
+}
+
+interface SlingshotIdentity {
+ did: string;
+ handle: string;
+ pds?: string;
+}
+
+interface SlingshotRecord {
+ uri: string;
+ cid: string;
+ value: Record;
+}
+
+// Utils
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function truncate(text: string, maxLength: number): string {
+ if (text.length <= maxLength) return text;
+ return text.substring(0, maxLength - 3) + "...";
+}
+
+// Slingshot
+
+async function resolveIdentity(
+ handle: string,
+): Promise {
+ const response = await fetch(
+ `${SLINGSHOT_URL}/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}`,
+ );
+ if (!response.ok) return null;
+ return (await response.json()) as SlingshotIdentity;
+}
+
+async function fetchRecord(
+ did: string,
+ collection: string,
+ recordKey: string,
+): Promise {
+ const response = await fetch(
+ `${SLINGSHOT_URL}/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(recordKey)}`,
+ );
+ if (!response.ok) return null;
+ return (await response.json()) as SlingshotRecord;
+}
+
+async function fetchSiteName(did: string, fallback: string): Promise {
+ const siteRecord = await fetchRecord(did, "xyz.atbbs.site", "self");
+ return siteRecord ? siteRecord.value.name : fallback;
+}
+
+// --- Route parsing ---
+
+function parseRoute(path: string): Route | null {
+ // Strip /og prefix and .png suffix so this works for both HTML and image routes.
+ const normalizedPath = path.replace(/^\/og/, "").replace(/\.png$/, "");
+
+ const bbsMatch = normalizedPath.match(/^\/bbs\/([^/]+)$/);
+ if (bbsMatch) return { type: "bbs", handle: bbsMatch[1] };
+
+ const boardMatch = normalizedPath.match(/^\/bbs\/([^/]+)\/board\/([^/]+)$/);
+ if (boardMatch)
+ return { type: "board", handle: boardMatch[1], slug: boardMatch[2] };
+
+ const threadMatch = normalizedPath.match(
+ /^\/bbs\/([^/]+)\/thread\/([^/]+)\/([^/]+)$/,
+ );
+ if (threadMatch)
+ return {
+ type: "thread",
+ handle: threadMatch[1],
+ did: threadMatch[2],
+ rkey: threadMatch[3],
+ };
+
+ const newsMatch = normalizedPath.match(/^\/bbs\/([^/]+)\/news\/([^/]+)$/);
+ if (newsMatch)
+ return { type: "news", handle: newsMatch[1], rkey: newsMatch[2] };
+
+ return null;
+}
+
+// Metadata
+
+async function fetchMetadata(route: Route): Promise {
+ const identity = await resolveIdentity(route.handle);
+ if (!identity) return null;
+
+ if (route.type === "bbs") {
+ const siteRecord = await fetchRecord(
+ identity.did,
+ "xyz.atbbs.site",
+ "self",
+ );
+ if (siteRecord) {
+ return {
+ title: siteRecord.value.name,
+ subtitle: "",
+ description: siteRecord.value.description || "",
+ };
+ }
+ } else if (route.type === "board") {
+ const siteName = await fetchSiteName(identity.did, route.handle);
+ const boardRecord = await fetchRecord(
+ identity.did,
+ "xyz.atbbs.board",
+ route.slug!,
+ );
+ if (boardRecord) {
+ return {
+ title: boardRecord.value.name,
+ subtitle: siteName,
+ description: boardRecord.value.description || "",
+ };
+ }
+ } else if (route.type === "thread") {
+ const siteName = await fetchSiteName(identity.did, route.handle);
+ const postRecord = await fetchRecord(
+ route.did!,
+ "xyz.atbbs.post",
+ route.rkey!,
+ );
+ if (postRecord) {
+ return {
+ title: postRecord.value.title || "Thread",
+ subtitle: siteName,
+ description: postRecord.value.body || "",
+ };
+ }
+ } else if (route.type === "news") {
+ const siteName = await fetchSiteName(identity.did, route.handle);
+ const postRecord = await fetchRecord(
+ identity.did,
+ "xyz.atbbs.post",
+ route.rkey!,
+ );
+ if (postRecord) {
+ return {
+ title: postRecord.value.title || "News",
+ subtitle: siteName,
+ description: postRecord.value.body || "",
+ };
+ }
+ }
+
+ return null;
+}
+
+// Generate OG Images
+
+const HERO_LOGO_DATA_URI =
+ "data:image/svg+xml," + encodeURIComponent(heroLogoSvg);
+
+async function renderOgImage(
+ title: string,
+ subtitle: string,
+ description: string,
+): Promise {
+ const displayTitle = escapeHtml(truncate(title, 40));
+ const displaySubtitle = escapeHtml(subtitle);
+ const displayDescription = escapeHtml(truncate(description, 120));
+
+ const fontData = await loadGoogleFont({
+ family: "Geist Mono",
+ weight: 400,
+ });
+
+ const subtitleHtml = displaySubtitle
+ ? `${displaySubtitle}
`
+ : "";
+
+ const html = `
+
+

+
+ ${subtitleHtml}
+
${displayTitle}
+
${displayDescription}
+
+
+ `;
+
+ return new ImageResponse(html, {
+ width: 1200,
+ height: 630,
+ fonts: [
+ {
+ name: "Geist Mono",
+ data: fontData,
+ style: "normal",
+ weight: 400,
+ },
+ ],
+ });
+}
+
+// Inject HTML
+
+function injectMetadata(
+ html: string,
+ title: string,
+ description: string,
+ pageUrl: string,
+ imageUrl: string,
+): string {
+ const safeTitle = escapeHtml(title);
+ const safeDescription = escapeHtml(description.substring(0, 200));
+ const safePageUrl = escapeHtml(pageUrl);
+ const safeImageUrl = escapeHtml(imageUrl);
+
+ html = html.replace(
+ "atbbs",
+ `${safeTitle}`,
+ );
+ html = html.replace(
+ '',
+ ``,
+ );
+ html = html.replace(
+ '',
+ ``,
+ );
+ html = html.replace(
+ '',
+ ``,
+ );
+
+ if (!html.includes("og:url")) {
+ html = html.replace(
+ '\n {
+ const url = new URL(request.url);
+ const path = url.pathname;
+
+ // Dynamic og:image at /og/bbs/...
+ if (path.startsWith("/og/bbs/")) {
+ const route = parseRoute(path);
+ if (!route) {
+ return renderOgImage(DEFAULT_TITLE, "", DEFAULT_DESCRIPTION);
+ }
+
+ try {
+ const metadata = await fetchMetadata(route);
+ if (metadata) {
+ return renderOgImage(
+ metadata.title,
+ metadata.subtitle,
+ metadata.description,
+ );
+ }
+ } catch {
+ // Fall through to default image.
+ }
+
+ return renderOgImage(DEFAULT_TITLE, "", DEFAULT_DESCRIPTION);
+ }
+
+ // Inject metadata into HTML for /bbs/... routes.
+ const route = parseRoute(path);
+ const originResponse = await fetch(request);
+ const contentType = originResponse.headers.get("content-type") || "";
+
+ if (!route || !contentType.includes("text/html")) {
+ return originResponse;
+ }
+
+ let html = await originResponse.text();
+
+ try {
+ const metadata = await fetchMetadata(route);
+ if (metadata) {
+ const fullTitle = metadata.subtitle
+ ? `${metadata.title} \u2014 ${metadata.subtitle}`
+ : metadata.title;
+ const imageUrl = `${url.origin}/og${path}.png`;
+ html = injectMetadata(
+ html,
+ fullTitle,
+ metadata.description,
+ url.toString(),
+ imageUrl,
+ );
+ }
+ } catch {
+ // On any error, serve the original HTML unmodified.
+ }
+
+ return new Response(html, {
+ status: originResponse.status,
+ headers: originResponse.headers,
+ });
+ },
+};
diff --git a/opengraph/package-lock.json b/opengraph/package-lock.json
new file mode 100644
index 0000000..26c68b5
--- /dev/null
+++ b/opengraph/package-lock.json
@@ -0,0 +1,237 @@
+{
+ "name": "atbbs-opengraph",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "atbbs-opengraph",
+ "version": "1.0.0",
+ "dependencies": {
+ "workers-og": "^0.0.27"
+ }
+ },
+ "node_modules/@resvg/resvg-wasm": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.4.0.tgz",
+ "integrity": "sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==",
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@shuding/opentype.js": {
+ "version": "1.4.0-beta.0",
+ "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz",
+ "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==",
+ "license": "MIT",
+ "dependencies": {
+ "fflate": "^0.7.3",
+ "string.prototype.codepointat": "^0.2.1"
+ },
+ "bin": {
+ "ot": "bin/ot"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
+ "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/camelize": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
+ "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/css-background-parser": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz",
+ "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==",
+ "license": "MIT"
+ },
+ "node_modules/css-box-shadow": {
+ "version": "1.0.0-3",
+ "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz",
+ "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==",
+ "license": "MIT"
+ },
+ "node_modules/css-color-keywords": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
+ "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/css-to-react-native": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
+ "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "camelize": "^1.0.0",
+ "css-color-keywords": "^1.0.0",
+ "postcss-value-parser": "^4.0.2"
+ }
+ },
+ "node_modules/emoji-regex-xs": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz",
+ "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/fflate": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz",
+ "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==",
+ "license": "MIT"
+ },
+ "node_modules/hex-rgb": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz",
+ "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/just-camel-case": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/just-camel-case/-/just-camel-case-6.2.0.tgz",
+ "integrity": "sha512-ICenRLXwkQYLk3UyvLQZ+uKuwFVJ3JHFYFn7F2782G2Mv2hW8WPePqgdhpnjGaqkYtSVWnyCESZhGXUmY3/bEg==",
+ "license": "MIT"
+ },
+ "node_modules/linebreak": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
+ "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "0.0.8",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+ "license": "MIT"
+ },
+ "node_modules/parse-css-color": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz",
+ "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.1.4",
+ "hex-rgb": "^4.1.0"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "license": "MIT"
+ },
+ "node_modules/string.prototype.codepointat": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
+ "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==",
+ "license": "MIT"
+ },
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "license": "MIT"
+ },
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
+ "node_modules/workers-og": {
+ "version": "0.0.27",
+ "resolved": "https://registry.npmjs.org/workers-og/-/workers-og-0.0.27.tgz",
+ "integrity": "sha512-QvwptQ0twmouQHiITUi3kYxEPCLdueC/U4msQ2xMz2iktd+iseSs7zlREw3T1dAsPxPw73FQlw8cXFsfANZPlw==",
+ "dependencies": {
+ "@resvg/resvg-wasm": "2.4.0",
+ "just-camel-case": "^6.2.0",
+ "satori": "^0.15.2",
+ "yoga-wasm-web": "0.3.3"
+ }
+ },
+ "node_modules/workers-og/node_modules/css-gradient-parser": {
+ "version": "0.0.16",
+ "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.16.tgz",
+ "integrity": "sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/workers-og/node_modules/satori": {
+ "version": "0.15.2",
+ "resolved": "https://registry.npmjs.org/satori/-/satori-0.15.2.tgz",
+ "integrity": "sha512-vu/49vdc8MzV5jUchs3TIRDCOkOvMc1iJ11MrZvhg9tE4ziKIEIBjBZvies6a9sfM2vQ2gc3dXeu6rCK7AztHA==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@shuding/opentype.js": "1.4.0-beta.0",
+ "css-background-parser": "^0.1.0",
+ "css-box-shadow": "1.0.0-3",
+ "css-gradient-parser": "^0.0.16",
+ "css-to-react-native": "^3.0.0",
+ "emoji-regex-xs": "^2.0.1",
+ "escape-html": "^1.0.3",
+ "linebreak": "^1.1.0",
+ "parse-css-color": "^0.2.1",
+ "postcss-value-parser": "^4.2.0",
+ "yoga-wasm-web": "^0.3.3"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/yoga-wasm-web": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz",
+ "integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/opengraph/package.json b/opengraph/package.json
new file mode 100644
index 0000000..8f6a7f5
--- /dev/null
+++ b/opengraph/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "atbbs-opengraph",
+ "version": "1.0.0",
+ "private": true,
+ "dependencies": {
+ "workers-og": "^0.0.27"
+ }
+}
diff --git a/opengraph/wrangler.toml b/opengraph/wrangler.toml
new file mode 100644
index 0000000..070aa2d
--- /dev/null
+++ b/opengraph/wrangler.toml
@@ -0,0 +1,15 @@
+name = "atbbs-opengraph"
+main = "index.ts"
+compatibility_date = "2024-01-01"
+
+rules = [
+ { type = "Text", globs = ["**/*.svg"] }
+]
+
+[[routes]]
+pattern = "*atbbs.xyz/bbs/*"
+zone_name = "atbbs.xyz"
+
+[[routes]]
+pattern = "*atbbs.xyz/og/*"
+zone_name = "atbbs.xyz"
diff --git a/web/public/og.png b/web/public/og.png
index cbe46ede8805bd1f0d98984e1d7e8155fe61135c..eaad66e555faa2342558c8c888e1b029e452ba32 100644
GIT binary patch
delta 402
zcmbO(H(zdonf(<{7srr_Id5-n%zbRY(01|k!KKp+&WpxYx_*m0G<8+9f`tl)j@z$$
z(GB|*%;bM;%)Kqoz`#)TZ=Dmf
zwrrP3ep7aBPVx557GmE88j3$SO}uzm;!5gQhQ242``?vad;9U$+cld%?Pp;4VXr^g
zk@1LjY#4vT*LmMx=cV73TYvZ5qvyvYR;$b0n^XL?r5-*fJRIFMWZ_O`Y6Kq18xw^@*B
h5h$o)8O8oGE2ji`#Hn51!oa}5;OXk;vd$@?2>^8X#`ORI
delta 390
zcmbO)H(hRmnZp@R7srr_Id5+s%zbPi;BfJ*lls?#OffT77bfa8to^Zpm!+{;;gQ$>
zy=)dt?(O__Z+`z{XJBZUw|_M|0|P?>?`A{p8_bgfm{S$Eu9ai3`@Uy?Sbss)_T#4Y
zA1t3IruQ21S$7w2Y-fmR7N6L7L~Pqt<^r|zzh7_IoX3CqXYf02hKBW(ijyBQ9oEc>
zwq;oJeb0VA>(}2tT%LP!PVrZc<>tpCY!AJ>d3gTjcKdYx^GDBpw$q;+$aq98cQt#%
zwC8`fR9-Hf%rQ45
z9&gzEY)<>!(opw%;(N|>Pi|y7B6a%}W5V9shx-{A;w6*U$UXc5Qt@u|*_+@70(fq}u()z4*}Q$iB}XkNO>