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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-09 13:38:49 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent bd2585f8fd
commit 35f5216839
17 changed files with 557 additions and 39 deletions
+86
View File
@@ -0,0 +1,86 @@
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getStorageConfig, getS3Client } from '@cio/core/config/storage';
import { uploadToS3 } from '@cio/core/utils/s3';
import { getOrgSiteOgObjectKey } from '@cio/utils/org-site/og-public-url';
const OG_ETAG_METADATA_KEY = 'og-etag';
export function getOrgSiteOgPublicUrl(siteName: string): string | null {
const mediaPublicBaseUrl = getStorageConfig().mediaPublicBaseUrl;
if (!mediaPublicBaseUrl) {
return null;
}
return `${mediaPublicBaseUrl.replace(/\/$/, '')}/${getOrgSiteOgObjectKey(siteName)}`;
}
async function readCachedOrgSiteOgBuffer(siteName: string, etag: string): Promise<Buffer | null> {
const config = getStorageConfig();
const key = getOrgSiteOgObjectKey(siteName);
try {
const head = await getS3Client().send(
new HeadObjectCommand({
Bucket: config.bucketMedia,
Key: key
})
);
if (head.Metadata?.[OG_ETAG_METADATA_KEY] !== etag) {
return null;
}
const object = await getS3Client().send(
new GetObjectCommand({
Bucket: config.bucketMedia,
Key: key
})
);
const body = object.Body;
if (!body) {
return null;
}
const bytes = await body.transformToByteArray();
return Buffer.from(bytes);
} catch {
return null;
}
}
export async function persistOrgSiteOgImage(siteName: string, buffer: Buffer, etag: string): Promise<string | null> {
const config = getStorageConfig();
const publicUrl = getOrgSiteOgPublicUrl(siteName);
if (!publicUrl) {
return null;
}
const uploadResult = await uploadToS3({
Bucket: config.bucketMedia,
Key: getOrgSiteOgObjectKey(siteName),
Body: buffer,
ContentType: 'image/png',
CacheControl: 'public, max-age=3600, stale-while-revalidate=86400',
Metadata: {
[OG_ETAG_METADATA_KEY]: etag
}
});
if (!uploadResult.success) {
console.error('persistOrgSiteOgImage error:', uploadResult.error);
return null;
}
return publicUrl;
}
export async function getCachedOrgSiteOgBuffer(siteName: string, etag: string): Promise<Buffer | null> {
try {
return await readCachedOrgSiteOgBuffer(siteName, etag);
} catch (error) {
console.error('getCachedOrgSiteOgBuffer error:', error);
return null;
}
}
+10
View File
@@ -7,6 +7,7 @@ import { getActiveOrganizationPlan, getOrganizationBySiteName } from '@cio/db/qu
import { extractOrgTagline, renderOrgSiteOg } from '@api/utils/org-site-og'; import { extractOrgTagline, renderOrgSiteOg } from '@api/utils/org-site-og';
import { isOrgOnFreePlan } from '@cio/utils/plans'; import { isOrgOnFreePlan } from '@cio/utils/plans';
import { env } from '@cio/core/config/env'; import { env } from '@cio/core/config/env';
import { getCachedOrgSiteOgBuffer, getOrgSiteOgPublicUrl, persistOrgSiteOgImage } from '@api/services/org/og-cache';
const DEFAULT_OG_IMAGE = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.jpg'; const DEFAULT_OG_IMAGE = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.jpg';
const OG_VIEWPORT = { width: 1200, height: 630, deviceScaleFactor: 2 } as const; const OG_VIEWPORT = { width: 1200, height: 630, deviceScaleFactor: 2 } as const;
@@ -20,6 +21,8 @@ export function getOrgSiteOgFallbackImageUrl(): string {
return DEFAULT_OG_IMAGE; return DEFAULT_OG_IMAGE;
} }
export { getOrgSiteOgPublicUrl };
function buildOrgSiteOgEtag(payload: { function buildOrgSiteOgEtag(payload: {
siteName: string; siteName: string;
orgName: string; orgName: string;
@@ -59,8 +62,15 @@ export async function generateOrgSiteOgImage(siteName: string): Promise<{ buffer
...renderInput ...renderInput
}); });
const cachedBuffer = await getCachedOrgSiteOgBuffer(siteName, etag);
if (cachedBuffer) {
return { buffer: cachedBuffer, etag };
}
const { html, styles } = renderOrgSiteOg(renderInput); const { html, styles } = renderOrgSiteOg(renderInput);
const buffer = await getCloudflarePngBuffer(html, styles, OG_VIEWPORT); const buffer = await getCloudflarePngBuffer(html, styles, OG_VIEWPORT);
await persistOrgSiteOgImage(siteName, buffer, etag);
return { buffer, etag }; return { buffer, etag };
} }
+2
View File
@@ -32,6 +32,8 @@ UNSPLASH_API_KEY=
# Sever URL - Optional if you want to use video upload and pdf downloads # Sever URL - Optional if you want to use video upload and pdf downloads
# This is the deployment URL of apps/api # This is the deployment URL of apps/api
PUBLIC_SERVER_URL= PUBLIC_SERVER_URL=
# Public CDN base for uploaded media (org OG images are served from /og/{siteName}.png)
# PUBLIC_MEDIA_CDN_URL=https://img.cdn.clsrio.com
# Optional server-only override used during SSR (for Docker/internal network) # Optional server-only override used during SSR (for Docker/internal network)
# Example in compose: http://api:3081 # Example in compose: http://api:3081
PRIVATE_SERVER_URL= PRIVATE_SERVER_URL=
@@ -3,6 +3,7 @@ import type { OrgSiteInfo } from '$features/app/layout-setup';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { env as publicEnv } from '$env/dynamic/public'; import { env as publicEnv } from '$env/dynamic/public';
import { buildOrgSiteTitle, extractOrgSiteMetaCopy } from '$lib/utils/functions/org-site-meta'; import { buildOrgSiteTitle, extractOrgSiteMetaCopy } from '$lib/utils/functions/org-site-meta';
import { resolveOrgSiteOgImageUrl } from '$lib/utils/functions/org-site-og-url';
const isSelfHosted = PUBLIC_IS_SELFHOSTED === 'true'; const isSelfHosted = PUBLIC_IS_SELFHOSTED === 'true';
@@ -13,52 +14,25 @@ const CLOUD_OG_IMAGE = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.jp
const ORG_OG_WIDTH = 1200; const ORG_OG_WIDTH = 1200;
const ORG_OG_HEIGHT = 630; const ORG_OG_HEIGHT = 630;
function isPublicHttpOrigin(value: string): boolean { async function resolveOgImageUrl(url: URL, orgSiteInfo: OrgSiteInfo): Promise<string> {
try {
const parsed = new URL(value);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return false;
}
const host = parsed.hostname;
if (host === 'localhost' || host === '127.0.0.1') {
return true;
}
return host.includes('.');
} catch {
return false;
}
}
function getOrgSiteOgImageUrl(siteName: string, pageUrl: URL): string | null {
if (!siteName) {
return null;
}
const ogPath = `/org-site/og/${encodeURIComponent(siteName)}.png`;
return `${pageUrl.origin}/proxy${ogPath}`;
}
function resolveOgImageUrl(url: URL, orgSiteInfo: OrgSiteInfo): string {
const envUrl = publicEnv.PUBLIC_OG_IMAGE_URL?.trim(); const envUrl = publicEnv.PUBLIC_OG_IMAGE_URL?.trim();
if (envUrl) { if (envUrl) {
return envUrl; return envUrl;
} }
if (orgSiteInfo.isOrgSite && orgSiteInfo.org?.siteName) { if (orgSiteInfo.isOrgSite && orgSiteInfo.org?.siteName) {
const dynamicOgUrl = getOrgSiteOgImageUrl(orgSiteInfo.org.siteName, url); const dynamicOgUrl = await resolveOrgSiteOgImageUrl({
siteName: orgSiteInfo.org.siteName,
pageOrigin: url.origin,
isSelfHosted,
mediaCdnUrl: publicEnv.PUBLIC_MEDIA_CDN_URL,
publicServerUrl: publicEnv.PUBLIC_SERVER_URL
});
if (dynamicOgUrl) { if (dynamicOgUrl) {
return dynamicOgUrl; return dynamicOgUrl;
} }
} }
const publicServerUrl = publicEnv.PUBLIC_SERVER_URL?.trim();
if (publicServerUrl && isPublicHttpOrigin(publicServerUrl) && orgSiteInfo.org?.siteName) {
return `${publicServerUrl.replace(/\/$/, '')}/org-site/og/${encodeURIComponent(orgSiteInfo.org.siteName)}.png`;
}
if (isSelfHosted) { if (isSelfHosted) {
const org = orgSiteInfo.org; const org = orgSiteInfo.org;
if (!org) { if (!org) {
@@ -117,7 +91,7 @@ function resolveOrgSiteMeta(orgSiteInfo: OrgSiteInfo): {
}; };
} }
export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsProps { export async function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): Promise<MetaTagsProps> {
const orgMeta = resolveOrgSiteMeta(orgSiteInfo); const orgMeta = resolveOrgSiteMeta(orgSiteInfo);
const title = const title =
@@ -133,7 +107,7 @@ export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsPro
(isSelfHosted && orgSiteInfo.org?.name ? orgSiteInfo.org.name : null) || (isSelfHosted && orgSiteInfo.org?.name ? orgSiteInfo.org.name : null) ||
'ClassroomIO'; 'ClassroomIO';
const ogImageUrl = resolveOgImageUrl(url, orgSiteInfo); const ogImageUrl = await resolveOgImageUrl(url, orgSiteInfo);
const usesDynamicOrgOg = const usesDynamicOrgOg =
orgSiteInfo.isOrgSite && Boolean(orgSiteInfo.org?.siteName) && !publicEnv.PUBLIC_OG_IMAGE_URL?.trim(); orgSiteInfo.isOrgSite && Boolean(orgSiteInfo.org?.siteName) && !publicEnv.PUBLIC_OG_IMAGE_URL?.trim();
@@ -0,0 +1,96 @@
import { buildOrgSiteOgApiUrl, buildOrgSiteOgCdnUrl } from '@cio/utils/org-site/og-public-url';
const DEFAULT_CLOUD_MEDIA_CDN_URL = 'https://img.cdn.clsrio.com';
function isValidHttpUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
}
function isPublicHttpOrigin(value: string): boolean {
if (!isValidHttpUrl(value)) {
return false;
}
const host = new URL(value).hostname;
if (host === 'localhost' || host === '127.0.0.1') {
return true;
}
return host.includes('.');
}
const CDN_HEAD_TIMEOUT_MS = 2000;
async function isOrgSiteOgCdnObjectAvailable(cdnUrl: string): Promise<boolean> {
try {
const response = await fetch(cdnUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(CDN_HEAD_TIMEOUT_MS)
});
return response.ok;
} catch {
return false;
}
}
export async function resolveOrgSiteOgImageUrl(options: {
siteName: string;
pageOrigin: string;
isSelfHosted: boolean;
mediaCdnUrl?: string | null;
publicServerUrl?: string | null;
}): Promise<string | null> {
const siteName = options.siteName.trim();
if (!siteName) {
return null;
}
const mediaCdnUrl = options.mediaCdnUrl?.trim() || (!options.isSelfHosted ? DEFAULT_CLOUD_MEDIA_CDN_URL : '');
if (mediaCdnUrl && isPublicHttpOrigin(mediaCdnUrl)) {
const cdnUrl = buildOrgSiteOgCdnUrl(siteName, mediaCdnUrl);
if (await isOrgSiteOgCdnObjectAvailable(cdnUrl)) {
return cdnUrl;
}
}
const publicServerUrl = options.publicServerUrl?.trim();
if (publicServerUrl && isPublicHttpOrigin(publicServerUrl)) {
return buildOrgSiteOgApiUrl(siteName, publicServerUrl);
}
return `${options.pageOrigin}/proxy/org-site/og/${encodeURIComponent(siteName)}.png`;
}
export function resolveOrgSiteOgWarmUrl(options: {
siteName: string;
isSelfHosted: boolean;
privateServerUrl?: string | null;
publicServerUrl?: string | null;
}): string | null {
const siteName = options.siteName.trim();
if (!siteName) {
return null;
}
const privateServerUrl = options.privateServerUrl?.trim();
if (privateServerUrl && isValidHttpUrl(privateServerUrl)) {
return buildOrgSiteOgApiUrl(siteName, privateServerUrl);
}
const publicServerUrl = options.publicServerUrl?.trim();
if (publicServerUrl && isPublicHttpOrigin(publicServerUrl)) {
return buildOrgSiteOgApiUrl(siteName, publicServerUrl);
}
if (!options.isSelfHosted) {
return buildOrgSiteOgApiUrl(siteName, 'https://api.classroomio.com');
}
return null;
}
+19 -1
View File
@@ -2,9 +2,12 @@ import type { AccountOrg } from '$features/app/types';
import type { MetaTagsProps } from 'svelte-meta-tags'; import type { MetaTagsProps } from 'svelte-meta-tags';
import type { UploadLimits } from '$lib/utils/config/upload-limits'; import type { UploadLimits } from '$lib/utils/config/upload-limits';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
import { getBaseMetaTags } from '$lib/utils/functions/metaTags.server'; import { getBaseMetaTags } from '$lib/utils/functions/metaTags.server';
import { getOrgSiteInfo } from '$features/app/layout-setup'; import { getOrgSiteInfo } from '$features/app/layout-setup';
import { getUploadLimits } from '$lib/utils/config/upload-limits'; import { getUploadLimits } from '$lib/utils/config/upload-limits';
import { resolveOrgSiteOgWarmUrl } from '$lib/utils/functions/org-site-og-url';
export const ssr = true; export const ssr = true;
@@ -28,12 +31,27 @@ export const load = async ({ url, cookies, request, locals }): Promise<LoadOutpu
const orgSiteInfo = await getOrgSiteInfo(url, cookies); const orgSiteInfo = await getOrgSiteInfo(url, cookies);
const orgSiteInfoMs = Math.round((performance.now() - orgSiteInfoStart) * 100) / 100; const orgSiteInfoMs = Math.round((performance.now() - orgSiteInfoStart) * 100) / 100;
if (orgSiteInfo.isOrgSite && orgSiteInfo.org?.siteName) {
const warmUrl = resolveOrgSiteOgWarmUrl({
siteName: orgSiteInfo.org.siteName,
isSelfHosted: PUBLIC_IS_SELFHOSTED === 'true',
privateServerUrl: env.PRIVATE_SERVER_URL,
publicServerUrl: publicEnv.PUBLIC_SERVER_URL
});
if (warmUrl) {
void fetch(warmUrl)
.then((response) => response.body?.cancel())
.catch(() => {});
}
}
const response: LoadOutput = { const response: LoadOutput = {
orgSiteName: orgSiteInfo.orgSiteName, orgSiteName: orgSiteInfo.orgSiteName,
isOrgSite: orgSiteInfo.isOrgSite, isOrgSite: orgSiteInfo.isOrgSite,
skipAuth: orgSiteInfo.subdomain === 'play' || debugPlay === 'true', skipAuth: orgSiteInfo.subdomain === 'play' || debugPlay === 'true',
org: orgSiteInfo.org, org: orgSiteInfo.org,
baseMetaTags: getBaseMetaTags(url, orgSiteInfo), baseMetaTags: await getBaseMetaTags(url, orgSiteInfo),
serverLang: request.headers?.get('accept-language') || '', serverLang: request.headers?.get('accept-language') || '',
localeCookie: cookies.get('classroomio_locale') || '', localeCookie: cookies.get('classroomio_locale') || '',
locals, locals,
@@ -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.
![Custom favicon settings](/custom-favicon-setup.webp)
</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**. 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. 1. In **R2** → select the **media** bucket.
2. Open **Settings** → **CORS policy**. 2. Open **Settings** → **CORS policy**.
3. Add a policy that allows your app origins to load images: 3. Add a policy that allows your app origins to load images:
@@ -3,6 +3,7 @@
"design": "Design Contribution Guide", "design": "Design Contribution Guide",
"supabase-cloud": "Supabase Cloud Setup", "supabase-cloud": "Supabase Cloud Setup",
"cloudflare-setup": "Cloudflare Setup", "cloudflare-setup": "Cloudflare Setup",
"org-site-opengraph-and-favicon": "Org-Site Open Graph & Favicon",
"gitpod": "Gitpod Setup", "gitpod": "Gitpod Setup",
"demo-accounts": "Login with Demo Accounts", "demo-accounts": "Login with Demo Accounts",
"student-dashboard": "Student Dashboard Guide", "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 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 ## 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. 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 - [Organization](/docs/organization) — org settings overview
- [Customize Your Academy Landing Page](/docs/org-landing-page) — public homepage - [Customize Your Academy Landing Page](/docs/org-landing-page) — public homepage
- [Set Up a Custom Domain](/docs/custom-domain) — your own hostname - [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 - [Roles and Permissions](/docs/roles-and-permissions) — team access
+1
View File
@@ -41,6 +41,7 @@
"---Publish your academy---", "---Publish your academy---",
"course-landingpage", "course-landingpage",
"org-landing-page", "org-landing-page",
"academy-sharing-and-branding",
"custom-domain", "custom-domain",
"---Organization & team---", "---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 - [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 - [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) - [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 - [Student Dashboard](/docs/student-dashboard) — where students go after signing in
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+9 -1
View File
@@ -323,6 +323,7 @@ export default {
const upstreamHost = isApiCall ? env.API_UPSTREAM_HOST : env.DASHBOARD_UPSTREAM_HOST; const upstreamHost = isApiCall ? env.API_UPSTREAM_HOST : env.DASHBOARD_UPSTREAM_HOST;
const upstreamPath = isProxiedApiCall ? url.pathname.slice(PROXY_PREFIX.length) || '/' : url.pathname; const upstreamPath = isProxiedApiCall ? url.pathname.slice(PROXY_PREFIX.length) || '/' : url.pathname;
const isOrgSiteOgImage = isProxiedApiCall && upstreamPath.startsWith('/org-site/og/');
const upstreamUrl = new URL(request.url); const upstreamUrl = new URL(request.url);
upstreamUrl.protocol = 'https:'; upstreamUrl.protocol = 'https:';
@@ -346,13 +347,14 @@ export default {
// cold browser cache (new device, or post-deploy hash change) stampedes the // cold browser cache (new device, or post-deploy hash change) stampedes the
// origin for every chunk. // origin for every chunk.
const isImmutableAsset = url.pathname.startsWith('/_app/immutable/'); const isImmutableAsset = url.pathname.startsWith('/_app/immutable/');
const edgeCacheTtl = isImmutableAsset ? 31536000 : isOrgSiteOgImage ? 3600 : undefined;
const upstreamResponse = await fetch(upstreamUrl.toString(), { const upstreamResponse = await fetch(upstreamUrl.toString(), {
method: request.method, method: request.method,
headers: upstreamHeaders, headers: upstreamHeaders,
body: request.body, body: request.body,
redirect: 'manual', redirect: 'manual',
cf: isImmutableAsset ? { cacheEverything: true, cacheTtl: 31536000 } : undefined cf: edgeCacheTtl ? { cacheEverything: true, cacheTtl: edgeCacheTtl } : undefined
}); });
if (isImmutableAsset) { if (isImmutableAsset) {
@@ -361,6 +363,12 @@ export default {
return response; return response;
} }
if (isOrgSiteOgImage && upstreamResponse.ok) {
const response = new Response(upstreamResponse.body, upstreamResponse);
response.headers.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
return response;
}
return upstreamResponse; return upstreamResponse;
} }
} satisfies ExportedHandler<Env>; } satisfies ExportedHandler<Env>;
+4
View File
@@ -60,6 +60,10 @@
"./openapi/public-api": { "./openapi/public-api": {
"types": "./dist/openapi/public-api.d.ts", "types": "./dist/openapi/public-api.d.ts",
"default": "./dist/openapi/public-api.js" "default": "./dist/openapi/public-api.js"
},
"./org-site/og-public-url": {
"types": "./dist/org-site/og-public-url.d.ts",
"default": "./dist/org-site/og-public-url.js"
} }
}, },
"scripts": { "scripts": {
@@ -0,0 +1,11 @@
export function getOrgSiteOgObjectKey(siteName: string): string {
return `og/${encodeURIComponent(siteName)}.png`;
}
export function buildOrgSiteOgCdnUrl(siteName: string, cdnBaseUrl: string): string {
return `${cdnBaseUrl.replace(/\/$/, '')}/${getOrgSiteOgObjectKey(siteName)}`;
}
export function buildOrgSiteOgApiUrl(siteName: string, apiBaseUrl: string): string {
return `${apiBaseUrl.replace(/\/$/, '')}/org-site/og/${encodeURIComponent(siteName)}.png`;
}