fix(org): favicon fallback, OG logo styling, and Discord embed CDN URLs (#714)
* fix(org): serve OG images from CDN for Discord embeds Discord times out on slow dynamic OG generation (~3-5s). Persist generated images to R2, point meta tags at the media CDN, and warm the API endpoint on org-site page loads so crawlers get a fast static PNG URL. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs: add org-site Open Graph and favicon guides Document user-facing sharing behavior and contributor architecture for dynamic OG images, favicon fallback, R2/CDN caching, and embed cache invalidation. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(org-site): address PR review on OG CDN fallback and docs - Fall back to API OG URL when CDN object is not yet available (HEAD check) - Cancel warm-fetch response body to avoid connection pool exhaustion - Align tenant-router OG edge cache TTL with one-hour API revalidation - Fix favicon docs navigation and add Custom Domain tab screenshot - Replace Chiberry Artistry example with AI Academy Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(org-site): accept docker hostnames for OG warm URL Use isValidHttpUrl for PRIVATE_SERVER_URL so internal values like http://api:3081 work for SSR warm fetches in Docker Compose. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
bd2585f8fd
commit
35f5216839
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Sharing Your Academy (Open Graph & Favicon)
|
||||
description: How favicons, link previews, and social embeds work when you share your academy URL on Discord, Slack, iMessage, and other platforms.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
When you share your academy URL — for example `https://ciodevs.myclassroomio.com` or your [custom domain](/docs/custom-domain) — chat apps and social networks build a **link preview** from two things:
|
||||
|
||||
1. **Meta tags** in the page HTML (title, description)
|
||||
2. An **Open Graph image** (`og:image`) — the large preview thumbnail
|
||||
|
||||
ClassroomIO generates both automatically for cloud academies. This guide explains what you control in the dashboard and what to expect when you change branding.
|
||||
|
||||
For the public homepage editor, see [Customize Your Academy Landing Page](/docs/org-landing-page). For org name, logo, and theme, see [Customize Your Organization](/docs/customize-organization).
|
||||
|
||||
## Favicon (browser tab icon)
|
||||
|
||||
The favicon is the small icon shown in the browser tab when someone opens your public academy site.
|
||||
|
||||
### Set a custom favicon
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Open Organization settings
|
||||
|
||||
Expand **Settings** in the sidebar, click **Organization**, then open the **Custom Domain** tab.
|
||||
|
||||

|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Upload under Custom favicon
|
||||
|
||||
Use the image picker in the **Custom favicon** section and click **Save favicon**.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Fallback when no favicon is set
|
||||
|
||||
If you have **not** uploaded a custom favicon, ClassroomIO uses your **organization logo** (the same image as in **Settings → Organization**). You do not need to upload the same file twice unless you want a different crop or format for the tab icon.
|
||||
|
||||
<Callout type="info" title="Where the favicon appears">
|
||||
The academy favicon is shown on **public org-site pages** (landing page, course catalog, public course pages). It is not applied to the admin dashboard, LMS, or login screens on `app.classroomio.com`.
|
||||
</Callout>
|
||||
|
||||
## Link previews (Open Graph)
|
||||
|
||||
When your academy homepage is shared, ClassroomIO provides:
|
||||
|
||||
| Meta field | Source |
|
||||
| --- | --- |
|
||||
| **Title** | Organization name + hero heading (from your landing page), e.g. `AI Academy \| Become a Certified AI Engineer` |
|
||||
| **Description** | Hero subheading, or footer tagline if no subheading |
|
||||
| **Preview image** | A generated **Academy** card with your logo, org name, theme colors, and (on paid plans) your tagline |
|
||||
|
||||
Free-plan academies include a small **Powered by ClassroomIO** watermark on the generated image. Paid plans show your tagline and no watermark.
|
||||
|
||||
### What the preview image looks like
|
||||
|
||||
The generated card includes:
|
||||
|
||||
- Your logo (top-left), or initials on a theme-colored background if you have no logo
|
||||
- An **Academy** label and org name
|
||||
- Theme-tinted background and accent colors from **Settings → Organization → brand color**
|
||||
- Tagline text on paid plans (from landing page footer tagline or hero subheading)
|
||||
|
||||
## When you change name, theme, or tagline
|
||||
|
||||
| Change | Page title & description | Preview image |
|
||||
| --- | --- | --- |
|
||||
| **Organization name** | Updates on next page load | Regenerated when the image is next requested |
|
||||
| **Brand / theme color** | Unchanged | Regenerated — colors on the card update |
|
||||
| **Hero subheading or footer tagline** | Description updates on next page load | Regenerated on paid plans (free plans do not show tagline on the image) |
|
||||
| **Organization logo** | Unchanged | Regenerated |
|
||||
|
||||
<Callout type="warn" title="Previews can lag behind saves">
|
||||
Text meta tags (title and description) update as soon as someone loads your site. The **thumbnail image** is cached for performance. After a branding change, visit your public academy URL once, then share the link again as a **new message** in Discord or Slack if the old preview still appears.
|
||||
</Callout>
|
||||
|
||||
### Refreshing Discord or Slack embeds
|
||||
|
||||
Chat apps cache link previews aggressively:
|
||||
|
||||
1. Save your changes in the dashboard.
|
||||
2. Open your public academy URL in a browser (this helps regenerate the preview image).
|
||||
3. Post the URL as a **new message** — editing an old message usually keeps the cached preview.
|
||||
|
||||
Discord in particular may cache failed previews for several minutes if the image was slow to generate on an earlier attempt.
|
||||
|
||||
## Course and lesson links
|
||||
|
||||
Public **course** pages use the course logo or banner for `og:image` when available. If a course has no banner, the preview may fall back to the course logo only. Org homepage links always use the dynamic Academy card described above.
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Customize Your Organization](/docs/customize-organization) — name, logo, brand color
|
||||
- [Customize Your Academy Landing Page](/docs/org-landing-page) — hero heading and subheading (used in title and description)
|
||||
- [Set Up a Custom Domain](/docs/custom-domain) — share links on your own hostname
|
||||
|
||||
For implementation details (API routes, CDN caching, contributor setup), see [Org-Site Open Graph & Favicon](/docs/contributor-guides/org-site-opengraph-and-favicon).
|
||||
@@ -75,6 +75,8 @@ If the dashboard uploads or downloads documents directly to/from R2 via presigne
|
||||
|
||||
Used for: serving images and video thumbnails (the dashboard loads these via the public URL from `CLOUDFLARE_IMAGE_BUCKET_DOMAIN`). If that URL is on a different origin than the dashboard, the bucket needs CORS for **GET** and **HEAD**.
|
||||
|
||||
Org-site **Open Graph preview images** are also stored in this bucket under `og/{siteName}.png` and served at `{CLOUDFLARE_IMAGE_BUCKET_DOMAIN}/og/{siteName}.png`. See [Org-Site Open Graph & Favicon](/docs/contributor-guides/org-site-opengraph-and-favicon).
|
||||
|
||||
1. In **R2** → select the **media** bucket.
|
||||
2. Open **Settings** → **CORS policy**.
|
||||
3. Add a policy that allows your app origins to load images:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"design": "Design Contribution Guide",
|
||||
"supabase-cloud": "Supabase Cloud Setup",
|
||||
"cloudflare-setup": "Cloudflare Setup",
|
||||
"org-site-opengraph-and-favicon": "Org-Site Open Graph & Favicon",
|
||||
"gitpod": "Gitpod Setup",
|
||||
"demo-accounts": "Login with Demo Accounts",
|
||||
"student-dashboard": "Student Dashboard Guide",
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
title: Org-Site Open Graph & Favicon
|
||||
description: Contributor reference for dynamic OG image generation, favicon fallback, CDN caching, and social embed behavior on public org-site routes.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
This document describes how ClassroomIO generates **Open Graph preview images** and **favicons** for public org-site routes (`*.myclassroomio.com`, BYOD custom domains, and self-hosted single-org mode). It covers caching layers, invalidation, and why crawlers such as Discord need a fast, stable image URL.
|
||||
|
||||
For admin-facing behavior, see [Sharing Your Academy (Open Graph & Favicon)](/docs/academy-sharing-and-branding).
|
||||
|
||||
## Overview
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph dashboard [Dashboard SSR]
|
||||
Layout["+layout.server.ts"]
|
||||
Meta["metaTags.server.ts"]
|
||||
Favicon["org-site-favicon.svelte"]
|
||||
end
|
||||
|
||||
subgraph api [API]
|
||||
OgRoute["GET /org-site/og/:siteName.png"]
|
||||
OgService["services/org/og.ts"]
|
||||
OgCache["services/org/og-cache.ts"]
|
||||
CFRender["Cloudflare Browser Rendering"]
|
||||
end
|
||||
|
||||
subgraph storage [Storage & edge]
|
||||
R2["R2 media bucket og/{siteName}.png"]
|
||||
CDN["img.cdn.clsrio.com"]
|
||||
Worker["tenant-router edge cache"]
|
||||
end
|
||||
|
||||
Layout -->|warm fetch| OgRoute
|
||||
Meta -->|og:image URL| CDN
|
||||
Favicon -->|icon href| CDN
|
||||
OgRoute --> OgService
|
||||
OgService --> OgCache
|
||||
OgCache -->|cache hit| R2
|
||||
OgService -->|cache miss| CFRender
|
||||
CFRender --> OgCache
|
||||
OgCache -->|persist| R2
|
||||
R2 --> CDN
|
||||
OgRoute --> Worker
|
||||
```
|
||||
|
||||
| Concern | Location |
|
||||
| --- | --- |
|
||||
| OG HTML template | `apps/api/src/utils/org-site-og.ts` |
|
||||
| OG generation service | `apps/api/src/services/org/og.ts` |
|
||||
| R2 read/write cache | `apps/api/src/services/org/og-cache.ts` |
|
||||
| API route | `apps/api/src/routes/org-site/og.ts` → mounted at `/org-site/og` in `apps/api/src/app.ts` |
|
||||
| Meta tags (SSR) | `apps/dashboard/src/lib/utils/functions/metaTags.server.ts` |
|
||||
| OG public URL resolution | `apps/dashboard/src/lib/utils/functions/org-site-og-url.ts` |
|
||||
| Shared URL helpers | `packages/utils/src/org-site/og-public-url.ts` |
|
||||
| Favicon component | `apps/dashboard/src/lib/features/app/org-site-favicon.svelte` |
|
||||
| Favicon href helper | `apps/dashboard/src/lib/utils/functions/org-branding.ts` |
|
||||
| OG warm on page load | `apps/dashboard/src/routes/+layout.server.ts` |
|
||||
| Proxy / edge cache | `apps/tenant-router/src/index.ts` |
|
||||
| Plan gating (watermark, tagline) | `packages/utils/src/plans/org-plan.ts` (`isOrgOnFreePlan`) |
|
||||
|
||||
## Open Graph image generation
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
GET /org-site/og/:siteName.png
|
||||
```
|
||||
|
||||
The `.png` suffix is optional; `normalizeOrgOgSiteName()` strips it before lookup.
|
||||
|
||||
The handler returns `image/png` with `ETag`, `Cache-Control: public, max-age=3600, stale-while-revalidate=86400`, and falls back to `https://brand.cdn.clsrio.com/og/classroomio-opengraph.jpg` on 404 or render errors.
|
||||
|
||||
### Render pipeline
|
||||
|
||||
1. Load org by `siteName` from the database.
|
||||
2. Resolve plan via `getActiveOrganizationPlan` + `isOrgOnFreePlan`.
|
||||
3. Build render input:
|
||||
- `orgName`, `logoUrl` (avatar), `themeColor`, `showWatermark` (free plan)
|
||||
- `tagline` — **paid plans only**; from `extractOrgTagline(landingpage)` (footer brand tagline, else hero subheading)
|
||||
4. Compute a **content etag** (16-char SHA-256 prefix) from `{ siteName, orgName, logoUrl, tagline, themeColor, showWatermark }`.
|
||||
5. Check R2 cache (see below). On miss, render HTML via `renderOrgSiteOg()` and screenshot with **Cloudflare Browser Rendering** (`getCloudflarePngBuffer`, viewport 1200×630).
|
||||
6. Persist PNG to R2 and return bytes.
|
||||
|
||||
### Template behavior
|
||||
|
||||
- **Logo present:** image fills the logo container; no theme-color background behind the logo (avoids washing out light logos).
|
||||
- **No logo:** initials fallback on a theme-colored rounded square.
|
||||
- **Free plan:** no tagline on image; **Powered by ClassroomIO** watermark.
|
||||
- **Paid plan:** tagline under org name; no watermark.
|
||||
|
||||
## Caching
|
||||
|
||||
OG images pass through several cache layers. Understanding them explains stale Discord previews and regeneration timing.
|
||||
|
||||
### 1. R2 object cache (source of truth for CDN URL)
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| **Bucket** | Media bucket (`CLOUDFLARE_IMAGE_BUCKET_DOMAIN` / `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`) |
|
||||
| **Key** | `og/{siteName}.png` |
|
||||
| **Invalidation** | Object metadata field `og-etag` compared to computed content etag; mismatch → regenerate and overwrite |
|
||||
| **Object Cache-Control** | `public, max-age=3600, stale-while-revalidate=86400` |
|
||||
|
||||
The public URL served in meta tags (cloud default):
|
||||
|
||||
```
|
||||
https://img.cdn.clsrio.com/og/{siteName}.png
|
||||
```
|
||||
|
||||
Same path on every request — no query-string cache bust. Updates rely on overwrite + CDN TTL expiry.
|
||||
|
||||
### 2. API in-memory / response cache
|
||||
|
||||
The API route sets `ETag` and honors `If-None-Match` (304). Rendered bytes may be served from R2 without calling Cloudflare Browser Rendering when the content etag matches.
|
||||
|
||||
**Cold generation** (Cloudflare screenshot) can take several seconds. Social crawlers (Discord ~3–5s timeout) fail if the first fetch is too slow — this motivated CDN-backed URLs and warming.
|
||||
|
||||
### 3. Dashboard OG warm
|
||||
|
||||
On every org-site `+layout.server.ts` load, a **fire-and-forget** `fetch()` hits the internal OG API URL (`resolveOrgSiteOgWarmUrl`) so the PNG is generated and written to R2 before crawlers request the CDN object.
|
||||
|
||||
This is best-effort (not awaited). It does **not** run at org-save time.
|
||||
|
||||
### 4. Tenant-router edge cache (cloud)
|
||||
|
||||
For `*.myclassroomio.com`, requests to `/proxy/org-site/og/*` are proxied to the API. The worker applies:
|
||||
|
||||
- `cf.cacheEverything: true`, `cacheTtl: 3600`
|
||||
- Response `Cache-Control: public, max-age=3600, stale-while-revalidate=86400`
|
||||
|
||||
Meta tags in cloud production prefer the **CDN URL**, not `/proxy/…`, so crawlers usually skip this hop. The edge cache remains a fallback for legacy or self-hosted proxy paths.
|
||||
|
||||
### 5. Crawler / chat app caches
|
||||
|
||||
Discord, Slack, iMessage, etc. cache **embed metadata** and **images** independently of ClassroomIO. Failed embeds may be cached for minutes. Users must often **re-post** a link to see updates even after R2 and CDN have fresh content.
|
||||
|
||||
## Meta tag resolution
|
||||
|
||||
`getBaseMetaTags()` in `metaTags.server.ts` sets title, description, and `og:image` for org sites.
|
||||
|
||||
**OG image URL priority** (`resolveOrgSiteOgImageUrl`):
|
||||
|
||||
1. `PUBLIC_OG_IMAGE_URL` — global override (self-hosted branding)
|
||||
2. **Cloud CDN** — `PUBLIC_MEDIA_CDN_URL` or default `https://img.cdn.clsrio.com` → `/og/{siteName}.png`, only when a `HEAD` request confirms the object exists
|
||||
3. `PUBLIC_SERVER_URL` — direct API URL `/org-site/og/{siteName}.png` (used until the CDN object is available)
|
||||
4. Tenant origin — `{origin}/proxy/org-site/og/{siteName}.png`
|
||||
|
||||
Title and description come from `org-site-meta.ts` (hero heading/subheading, org name). These update on every SSR response and do not depend on the PNG cache.
|
||||
|
||||
## Favicon
|
||||
|
||||
### Scope
|
||||
|
||||
Rendered from root `+layout.svelte` via `OrgSiteFavicon` when `data.isOrgSite` is true. Default ClassroomIO favicons in `app.html` are omitted for org sites; non-org routes inject `/favicon.ico` and `/logo-32.png` from the root layout.
|
||||
|
||||
### Resolution (`getOrgFaviconHref`)
|
||||
|
||||
1. Custom `org.favicon` if set (uploaded under **Settings → Organization → Custom Domain**)
|
||||
2. Else `org.avatarUrl`
|
||||
3. Relative URLs resolved against `page.url.origin`
|
||||
|
||||
## Invalidation matrix
|
||||
|
||||
| Org field changed | Content etag changes? | Meta text updates | PNG regen |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | Yes | Yes (title) | Yes |
|
||||
| `theme` | Yes | No | Yes |
|
||||
| `avatarUrl` | Yes | No | Yes |
|
||||
| Landing tagline / hero subheading | Yes (paid tagline on image) | Yes (description) | Yes (paid only on image) |
|
||||
| Plan (free ↔ paid) | Yes (`showWatermark`, tagline) | No | Yes |
|
||||
| `siteName` | N/A (different object key) | N/A | New key `og/{newSiteName}.png` |
|
||||
|
||||
Regeneration runs on the next OG API request or warm fetch after the etag mismatch.
|
||||
|
||||
<Callout type="info" title="No save-hook regen">
|
||||
There is no background job on organization `PUT`. Regen depends on a page visit (warm) or an external fetch of the OG endpoint.
|
||||
</Callout>
|
||||
|
||||
## Self-hosted notes
|
||||
|
||||
- Set `PUBLIC_IS_SELFHOSTED=true` in both dashboard and API for consistent behavior.
|
||||
- Without `CLOUDFLARE_IMAGE_BUCKET_DOMAIN` / `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`, OG images are not persisted to R2; meta tags fall back to `PUBLIC_SERVER_URL` or `/proxy/…`.
|
||||
- Cloudflare Browser Rendering credentials (`CLOUDFLARE_RENDERING_API_KEY`, account ID) are required for PNG generation.
|
||||
- Optional: `PUBLIC_MEDIA_CDN_URL` on the dashboard to override the CDN base for `og:image`.
|
||||
- BYOD custom domains hit the dashboard Render service directly (not tenant-router); `/proxy/*` forwarding in `hooks.server.ts` mirrors tenant-router behavior.
|
||||
|
||||
## Local development
|
||||
|
||||
- Org sites locally: `http://localhost:5173/?org={siteName}` (sets `_orgSiteName` cookie).
|
||||
- OG endpoint: `http://localhost:3002/org-site/og/{siteName}.png` or via dashboard proxy `http://localhost:5173/proxy/org-site/og/{siteName}.png`.
|
||||
- CDN URLs point at production `img.cdn.clsrio.com` when not self-hosted; local previews use proxy/API URLs unless you override env vars.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Sharing Your Academy (Open Graph & Favicon)](/docs/academy-sharing-and-branding) — admin-facing guide
|
||||
- [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) — R2 media bucket and `CLOUDFLARE_IMAGE_BUCKET_DOMAIN`
|
||||
- [Customize Your Organization](/docs/customize-organization) — logo, name, theme (user docs)
|
||||
@@ -95,6 +95,8 @@ For full custom domain setup (DNS, verification, which links use your domain), s
|
||||
|
||||
For the public homepage (hero, nav, footer), see [Customize Your Academy Landing Page](/docs/org-landing-page).
|
||||
|
||||
For favicon and link-preview behavior when sharing your academy URL, see [Sharing Your Academy (Open Graph & Favicon)](/docs/academy-sharing-and-branding).
|
||||
|
||||
## Set Brand Color
|
||||
|
||||
We understand that every organization has a preferred color that suits their brand, hence we offer organizations the flexibility to choose a theme for their brand from a variety of colors like **blue**, **green**, **orange**, and more. To select a brand color for your organization simply click on any of the provided color theme and this would be automatically applied across your entire application.
|
||||
@@ -104,4 +106,5 @@ We understand that every organization has a preferred color that suits their bra
|
||||
- [Organization](/docs/organization) — org settings overview
|
||||
- [Customize Your Academy Landing Page](/docs/org-landing-page) — public homepage
|
||||
- [Set Up a Custom Domain](/docs/custom-domain) — your own hostname
|
||||
- [Sharing Your Academy (Open Graph & Favicon)](/docs/academy-sharing-and-branding) — link previews and browser tab icon
|
||||
- [Roles and Permissions](/docs/roles-and-permissions) — team access
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"---Publish your academy---",
|
||||
"course-landingpage",
|
||||
"org-landing-page",
|
||||
"academy-sharing-and-branding",
|
||||
"custom-domain",
|
||||
|
||||
"---Organization & team---",
|
||||
|
||||
@@ -77,5 +77,6 @@ Always validate hero and footer links on the live site before sharing the URL ex
|
||||
|
||||
- [Customize Your Organization](/docs/customize-organization) — org name, logo, brand color
|
||||
- [Set Up a Custom Domain](/docs/custom-domain) — use your own domain on the landing page
|
||||
- [Sharing Your Academy (Open Graph & Favicon)](/docs/academy-sharing-and-branding) — how shared links pick up title, description, and preview image
|
||||
- [Create a Course Landing Page](/docs/course-landingpage) — per-course public pages (separate from the org homepage)
|
||||
- [Student Dashboard](/docs/student-dashboard) — where students go after signing in
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Reference in New Issue
Block a user