From 35f5216839f6f608a99f682f3ca8045dfe05af32 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Thu, 9 Jul 2026 13:38:49 +0100 Subject: [PATCH] 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 * 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 * 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 * 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 --------- Co-authored-by: Cursor Agent Co-authored-by: Rotimi Best --- apps/api/src/services/org/og-cache.ts | 86 ++++++++ apps/api/src/services/org/og.ts | 10 + apps/dashboard/.env.example | 2 + .../lib/utils/functions/metaTags.server.ts | 48 +---- .../lib/utils/functions/org-site-og-url.ts | 96 +++++++++ apps/dashboard/src/routes/+layout.server.ts | 20 +- .../docs/academy-sharing-and-branding.mdx | 102 +++++++++ .../contributor-guides/cloudflare-setup.mdx | 2 + .../content/docs/contributor-guides/meta.json | 1 + .../org-site-opengraph-and-favicon.mdx | 199 ++++++++++++++++++ .../content/docs/customize-organization.mdx | 3 + apps/docs/content/docs/meta.json | 1 + apps/docs/content/docs/org-landing-page.mdx | 1 + apps/docs/public/custom-favicon-setup.webp | Bin 0 -> 17810 bytes apps/tenant-router/src/index.ts | 10 +- packages/utils/package.json | 4 + packages/utils/src/org-site/og-public-url.ts | 11 + 17 files changed, 557 insertions(+), 39 deletions(-) create mode 100644 apps/api/src/services/org/og-cache.ts create mode 100644 apps/dashboard/src/lib/utils/functions/org-site-og-url.ts create mode 100644 apps/docs/content/docs/academy-sharing-and-branding.mdx create mode 100644 apps/docs/content/docs/contributor-guides/org-site-opengraph-and-favicon.mdx create mode 100644 apps/docs/public/custom-favicon-setup.webp create mode 100644 packages/utils/src/org-site/og-public-url.ts diff --git a/apps/api/src/services/org/og-cache.ts b/apps/api/src/services/org/og-cache.ts new file mode 100644 index 00000000..b1e592f5 --- /dev/null +++ b/apps/api/src/services/org/og-cache.ts @@ -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 { + 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 { + 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 { + try { + return await readCachedOrgSiteOgBuffer(siteName, etag); + } catch (error) { + console.error('getCachedOrgSiteOgBuffer error:', error); + return null; + } +} diff --git a/apps/api/src/services/org/og.ts b/apps/api/src/services/org/og.ts index 79970668..b618ead5 100644 --- a/apps/api/src/services/org/og.ts +++ b/apps/api/src/services/org/og.ts @@ -7,6 +7,7 @@ import { getActiveOrganizationPlan, getOrganizationBySiteName } from '@cio/db/qu import { extractOrgTagline, renderOrgSiteOg } from '@api/utils/org-site-og'; import { isOrgOnFreePlan } from '@cio/utils/plans'; 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 OG_VIEWPORT = { width: 1200, height: 630, deviceScaleFactor: 2 } as const; @@ -20,6 +21,8 @@ export function getOrgSiteOgFallbackImageUrl(): string { return DEFAULT_OG_IMAGE; } +export { getOrgSiteOgPublicUrl }; + function buildOrgSiteOgEtag(payload: { siteName: string; orgName: string; @@ -59,8 +62,15 @@ export async function generateOrgSiteOgImage(siteName: string): Promise<{ buffer ...renderInput }); + const cachedBuffer = await getCachedOrgSiteOgBuffer(siteName, etag); + if (cachedBuffer) { + return { buffer: cachedBuffer, etag }; + } + const { html, styles } = renderOrgSiteOg(renderInput); const buffer = await getCloudflarePngBuffer(html, styles, OG_VIEWPORT); + await persistOrgSiteOgImage(siteName, buffer, etag); + return { buffer, etag }; } diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index c5ed85f0..33f2bec9 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -32,6 +32,8 @@ UNSPLASH_API_KEY= # Sever URL - Optional if you want to use video upload and pdf downloads # This is the deployment URL of apps/api 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) # Example in compose: http://api:3081 PRIVATE_SERVER_URL= diff --git a/apps/dashboard/src/lib/utils/functions/metaTags.server.ts b/apps/dashboard/src/lib/utils/functions/metaTags.server.ts index db4dcd4e..5c7154ab 100644 --- a/apps/dashboard/src/lib/utils/functions/metaTags.server.ts +++ b/apps/dashboard/src/lib/utils/functions/metaTags.server.ts @@ -3,6 +3,7 @@ import type { OrgSiteInfo } from '$features/app/layout-setup'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; import { env as publicEnv } from '$env/dynamic/public'; 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'; @@ -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_HEIGHT = 630; -function isPublicHttpOrigin(value: string): boolean { - 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 { +async function resolveOgImageUrl(url: URL, orgSiteInfo: OrgSiteInfo): Promise { const envUrl = publicEnv.PUBLIC_OG_IMAGE_URL?.trim(); if (envUrl) { return envUrl; } 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) { 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) { const org = orgSiteInfo.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 { const orgMeta = resolveOrgSiteMeta(orgSiteInfo); const title = @@ -133,7 +107,7 @@ export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsPro (isSelfHosted && orgSiteInfo.org?.name ? orgSiteInfo.org.name : null) || 'ClassroomIO'; - const ogImageUrl = resolveOgImageUrl(url, orgSiteInfo); + const ogImageUrl = await resolveOgImageUrl(url, orgSiteInfo); const usesDynamicOrgOg = orgSiteInfo.isOrgSite && Boolean(orgSiteInfo.org?.siteName) && !publicEnv.PUBLIC_OG_IMAGE_URL?.trim(); diff --git a/apps/dashboard/src/lib/utils/functions/org-site-og-url.ts b/apps/dashboard/src/lib/utils/functions/org-site-og-url.ts new file mode 100644 index 00000000..2aa13246 --- /dev/null +++ b/apps/dashboard/src/lib/utils/functions/org-site-og-url.ts @@ -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 { + 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 { + 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; +} diff --git a/apps/dashboard/src/routes/+layout.server.ts b/apps/dashboard/src/routes/+layout.server.ts index 45257d9c..9fc5dace 100644 --- a/apps/dashboard/src/routes/+layout.server.ts +++ b/apps/dashboard/src/routes/+layout.server.ts @@ -2,9 +2,12 @@ import type { AccountOrg } from '$features/app/types'; import type { MetaTagsProps } from 'svelte-meta-tags'; import type { UploadLimits } from '$lib/utils/config/upload-limits'; 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 { getOrgSiteInfo } from '$features/app/layout-setup'; import { getUploadLimits } from '$lib/utils/config/upload-limits'; +import { resolveOrgSiteOgWarmUrl } from '$lib/utils/functions/org-site-og-url'; export const ssr = true; @@ -28,12 +31,27 @@ export const load = async ({ url, cookies, request, locals }): Promise response.body?.cancel()) + .catch(() => {}); + } + } + const response: LoadOutput = { orgSiteName: orgSiteInfo.orgSiteName, isOrgSite: orgSiteInfo.isOrgSite, skipAuth: orgSiteInfo.subdomain === 'play' || debugPlay === 'true', org: orgSiteInfo.org, - baseMetaTags: getBaseMetaTags(url, orgSiteInfo), + baseMetaTags: await getBaseMetaTags(url, orgSiteInfo), serverLang: request.headers?.get('accept-language') || '', localeCookie: cookies.get('classroomio_locale') || '', locals, diff --git a/apps/docs/content/docs/academy-sharing-and-branding.mdx b/apps/docs/content/docs/academy-sharing-and-branding.mdx new file mode 100644 index 00000000..fe40a5b9 --- /dev/null +++ b/apps/docs/content/docs/academy-sharing-and-branding.mdx @@ -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 + + + +### Open Organization settings + +Expand **Settings** in the sidebar, click **Organization**, then open the **Custom Domain** tab. + +![Custom favicon settings](/custom-favicon-setup.webp) + + + +### Upload under Custom favicon + +Use the image picker in the **Custom favicon** section and click **Save favicon**. + + + +### 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. + + + 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`. + + +## 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 | + + + 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. + + +### 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). diff --git a/apps/docs/content/docs/contributor-guides/cloudflare-setup.mdx b/apps/docs/content/docs/contributor-guides/cloudflare-setup.mdx index 8402c347..ebef15fd 100644 --- a/apps/docs/content/docs/contributor-guides/cloudflare-setup.mdx +++ b/apps/docs/content/docs/contributor-guides/cloudflare-setup.mdx @@ -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: diff --git a/apps/docs/content/docs/contributor-guides/meta.json b/apps/docs/content/docs/contributor-guides/meta.json index fa13b293..9175bdf1 100644 --- a/apps/docs/content/docs/contributor-guides/meta.json +++ b/apps/docs/content/docs/contributor-guides/meta.json @@ -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", diff --git a/apps/docs/content/docs/contributor-guides/org-site-opengraph-and-favicon.mdx b/apps/docs/content/docs/contributor-guides/org-site-opengraph-and-favicon.mdx new file mode 100644 index 00000000..d3142e01 --- /dev/null +++ b/apps/docs/content/docs/contributor-guides/org-site-opengraph-and-favicon.mdx @@ -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. + + + There is no background job on organization `PUT`. Regen depends on a page visit (warm) or an external fetch of the OG endpoint. + + +## 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) diff --git a/apps/docs/content/docs/customize-organization.mdx b/apps/docs/content/docs/customize-organization.mdx index 63d3bc3b..8f0475d1 100644 --- a/apps/docs/content/docs/customize-organization.mdx +++ b/apps/docs/content/docs/customize-organization.mdx @@ -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 diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 9b21063d..909f9d4b 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -41,6 +41,7 @@ "---Publish your academy---", "course-landingpage", "org-landing-page", + "academy-sharing-and-branding", "custom-domain", "---Organization & team---", diff --git a/apps/docs/content/docs/org-landing-page.mdx b/apps/docs/content/docs/org-landing-page.mdx index c8ae9284..98cdc09c 100644 --- a/apps/docs/content/docs/org-landing-page.mdx +++ b/apps/docs/content/docs/org-landing-page.mdx @@ -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 diff --git a/apps/docs/public/custom-favicon-setup.webp b/apps/docs/public/custom-favicon-setup.webp new file mode 100644 index 0000000000000000000000000000000000000000..a157c9be7f54eb23250cf258336b2a64b29313f6 GIT binary patch literal 17810 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 literal 0 HcmV?d00001 diff --git a/apps/tenant-router/src/index.ts b/apps/tenant-router/src/index.ts index 2cdae88a..ddff613a 100644 --- a/apps/tenant-router/src/index.ts +++ b/apps/tenant-router/src/index.ts @@ -323,6 +323,7 @@ export default { const upstreamHost = isApiCall ? env.API_UPSTREAM_HOST : env.DASHBOARD_UPSTREAM_HOST; 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); upstreamUrl.protocol = 'https:'; @@ -346,13 +347,14 @@ export default { // cold browser cache (new device, or post-deploy hash change) stampedes the // origin for every chunk. const isImmutableAsset = url.pathname.startsWith('/_app/immutable/'); + const edgeCacheTtl = isImmutableAsset ? 31536000 : isOrgSiteOgImage ? 3600 : undefined; const upstreamResponse = await fetch(upstreamUrl.toString(), { method: request.method, headers: upstreamHeaders, body: request.body, redirect: 'manual', - cf: isImmutableAsset ? { cacheEverything: true, cacheTtl: 31536000 } : undefined + cf: edgeCacheTtl ? { cacheEverything: true, cacheTtl: edgeCacheTtl } : undefined }); if (isImmutableAsset) { @@ -361,6 +363,12 @@ export default { 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; } } satisfies ExportedHandler; diff --git a/packages/utils/package.json b/packages/utils/package.json index b1d6842f..95c888e3 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -60,6 +60,10 @@ "./openapi/public-api": { "types": "./dist/openapi/public-api.d.ts", "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": { diff --git a/packages/utils/src/org-site/og-public-url.ts b/packages/utils/src/org-site/og-public-url.ts new file mode 100644 index 00000000..9c5c132f --- /dev/null +++ b/packages/utils/src/org-site/og-public-url.ts @@ -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`; +}