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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user