fix(org): favicon fallback, OG logo styling, and Discord embed CDN URLs (#714)

* fix(org): serve OG images from CDN for Discord embeds

Discord times out on slow dynamic OG generation (~3-5s). Persist generated
images to R2, point meta tags at the media CDN, and warm the API endpoint
on org-site page loads so crawlers get a fast static PNG URL.

Co-authored-by: Rotimi Best <[email protected]>

* docs: add org-site Open Graph and favicon guides

Document user-facing sharing behavior and contributor architecture
for dynamic OG images, favicon fallback, R2/CDN caching, and embed
cache invalidation.

Co-authored-by: Rotimi Best <[email protected]>

* fix(org-site): address PR review on OG CDN fallback and docs

- Fall back to API OG URL when CDN object is not yet available (HEAD check)
- Cancel warm-fetch response body to avoid connection pool exhaustion
- Align tenant-router OG edge cache TTL with one-hour API revalidation
- Fix favicon docs navigation and add Custom Domain tab screenshot
- Replace Chiberry Artistry example with AI Academy

Co-authored-by: Rotimi Best <[email protected]>

* fix(org-site): accept docker hostnames for OG warm URL

Use isValidHttpUrl for PRIVATE_SERVER_URL so internal values like
http://api:3081 work for SSR warm fetches in Docker Compose.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-09 13:38:49 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent bd2585f8fd
commit 35f5216839
17 changed files with 557 additions and 39 deletions
@@ -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;
}