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:
co-authored by
Rotimi Best
Cursor Agent
parent
bd2585f8fd
commit
35f5216839
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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<string> {
|
||||
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<MetaTagsProps> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<LoadOutpu
|
||||
const orgSiteInfo = await getOrgSiteInfo(url, cookies);
|
||||
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 = {
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Sharing Your Academy (Open Graph & Favicon)
|
||||
description: How favicons, link previews, and social embeds work when you share your academy URL on Discord, Slack, iMessage, and other platforms.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
When you share your academy URL — for example `https://ciodevs.myclassroomio.com` or your [custom domain](/docs/custom-domain) — chat apps and social networks build a **link preview** from two things:
|
||||
|
||||
1. **Meta tags** in the page HTML (title, description)
|
||||
2. An **Open Graph image** (`og:image`) — the large preview thumbnail
|
||||
|
||||
ClassroomIO generates both automatically for cloud academies. This guide explains what you control in the dashboard and what to expect when you change branding.
|
||||
|
||||
For the public homepage editor, see [Customize Your Academy Landing Page](/docs/org-landing-page). For org name, logo, and theme, see [Customize Your Organization](/docs/customize-organization).
|
||||
|
||||
## Favicon (browser tab icon)
|
||||
|
||||
The favicon is the small icon shown in the browser tab when someone opens your public academy site.
|
||||
|
||||
### Set a custom favicon
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Open Organization settings
|
||||
|
||||
Expand **Settings** in the sidebar, click **Organization**, then open the **Custom Domain** tab.
|
||||
|
||||

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