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 00000000..a157c9be Binary files /dev/null and b/apps/docs/public/custom-favicon-setup.webp differ 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`; +}