feat(org): dynamic open graph images for school sites (#709)
* feat(org): add dynamic open graph images for school sites Generate branded 1200x630 OG images for org public sites via GET /org/og/:siteName.png. Paying cloud orgs include their landing-page tagline; free plans show a Powered by ClassroomIO watermark. Dashboard org-site meta tags now point at the dynamic image. Co-authored-by: Rotimi Best <[email protected]> * feat(org): enable custom favicon upload and org-site rendering Allow org admins to upload a favicon from domain settings, persist it via PUT /organization, and render it on public org sites with logo fallback. Co-authored-by: Rotimi Best <[email protected]> * fix(org): scope custom favicon to public org-site routes only Co-authored-by: Rotimi Best <[email protected]> * refactor(api): inline org-site OG rendering, remove @cio/og-images package Move template and render helpers into apps/api/src/utils/org-site-og.ts. Certificates stay in a shared package because they support multiple templates; org OG is a single API-only concern. Co-authored-by: Rotimi Best <[email protected]> * chore: remove packages/og-images directory Co-authored-by: Rotimi Best <[email protected]> * refactor(api): mount org OG image under /org-site/og Align with existing org-site public routes (/org-site/course) instead of a top-level /org prefix. Co-authored-by: Rotimi Best <[email protected]> * refactor(plans): consolidate isOrgOnFreePlan into options-based utility Move orgId guard and self-hosted/plan checks into @cio/utils/plans. Dashboard and API callers pass plans, isSelfHosted, and optional orgId directly; remove the dashboard store wrapper. Co-authored-by: Rotimi Best <[email protected]> * fix(org): require orgId for free-plan check and translate favicon strings Treat missing orgId as not on free plan so nav gating matches prior behavior when currentOrg is unset. Pass organization.id from OG service. Add favicon settings translations for all non-English locales. Co-authored-by: Rotimi Best <[email protected]> * refactor(dashboard): move meta tag helpers to metaTags.server.ts Keep PRIVATE_SERVER_URL resolution in a server-only module so dynamic private env imports cannot leak into universal or client bundles. 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
af95c6280c
commit
b8fca607aa
+2
-1
@@ -32,7 +32,7 @@ import { organizationSsoRouter } from '@api/routes/organization/sso';
|
||||
import { organizationTokenAuthRouter } from '@api/routes/organization/token-auth';
|
||||
import { prettyJSON } from 'hono/pretty-json';
|
||||
import { cohortRouter } from '@api/routes/cohort';
|
||||
import { publicCourseRouter } from '@api/routes/org-site';
|
||||
import { publicCourseRouter, orgSiteOgRouter } from '@api/routes/org-site';
|
||||
import { publicWidgetsRouter } from '@api/routes/widgets';
|
||||
import rateLimiter from '@api/middlewares/rate-limiter';
|
||||
import { secureHeaders } from 'hono/secure-headers';
|
||||
@@ -231,6 +231,7 @@ export const app = new Hono()
|
||||
.route('/community', communityRouter)
|
||||
.route('/invite', inviteRouter)
|
||||
.route('/org-site/course', publicCourseRouter)
|
||||
.route('/org-site/og', orgSiteOgRouter)
|
||||
.route('/public-api/v1', v1Router)
|
||||
.route('/cohort', cohortRouter)
|
||||
.route('/unsplash', unsplashRouter)
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './public-course';
|
||||
export * from './og';
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { generateOrgSiteOgImage, getOrgSiteOgFallbackImageUrl, normalizeOrgOgSiteName } from '@api/services/org/og';
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
|
||||
export const orgSiteOgRouter = new Hono().get('/:siteName', async (c) => {
|
||||
try {
|
||||
const siteName = normalizeOrgOgSiteName(c.req.param('siteName'));
|
||||
const { buffer, etag } = await generateOrgSiteOgImage(siteName);
|
||||
const ifNoneMatch = c.req.header('if-none-match');
|
||||
|
||||
if (ifNoneMatch === `"${etag}"`) {
|
||||
return c.body(null, 304);
|
||||
}
|
||||
|
||||
c.header('Content-Type', 'image/png');
|
||||
c.header('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
|
||||
c.header('ETag', `"${etag}"`);
|
||||
|
||||
return c.body(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(buffer));
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.statusCode === 404) {
|
||||
return c.redirect(getOrgSiteOgFallbackImageUrl(), 302);
|
||||
}
|
||||
|
||||
console.error('Failed to generate org OG image:', error);
|
||||
|
||||
return c.redirect(getOrgSiteOgFallbackImageUrl(), 302);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getCloudflarePngBuffer } from '@api/utils/cloudflare';
|
||||
import { resolveThemeColor } from '@cio/email';
|
||||
import { getActiveOrganizationPlan, getOrganizationBySiteName } from '@cio/db/queries/organization';
|
||||
import { extractOrgTagline, renderOrgSiteOg } from '@api/utils/org-site-og';
|
||||
import { isOrgOnFreePlan } from '@cio/utils/plans';
|
||||
import { env } from '@cio/core/config/env';
|
||||
|
||||
const DEFAULT_OG_IMAGE = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.jpg';
|
||||
const OG_VIEWPORT = { width: 1200, height: 630, deviceScaleFactor: 2 } as const;
|
||||
const isSelfHosted = env.PUBLIC_IS_SELFHOSTED === 'true';
|
||||
|
||||
export function normalizeOrgOgSiteName(rawSiteName: string): string {
|
||||
return rawSiteName.endsWith('.png') ? rawSiteName.slice(0, -4) : rawSiteName;
|
||||
}
|
||||
|
||||
export function getOrgSiteOgFallbackImageUrl(): string {
|
||||
return DEFAULT_OG_IMAGE;
|
||||
}
|
||||
|
||||
function buildOrgSiteOgEtag(payload: {
|
||||
siteName: string;
|
||||
orgName: string;
|
||||
logoUrl?: string;
|
||||
tagline?: string;
|
||||
themeColor: string;
|
||||
showWatermark: boolean;
|
||||
}): string {
|
||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
export async function generateOrgSiteOgImage(siteName: string): Promise<{ buffer: Buffer; etag: string }> {
|
||||
const organization = await getOrganizationBySiteName(siteName);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const activePlan = await getActiveOrganizationPlan(organization.id);
|
||||
const isFreePlan = isOrgOnFreePlan({
|
||||
plans: activePlan ? [{ planName: activePlan.planName, isActive: activePlan.isActive }] : [],
|
||||
isSelfHosted,
|
||||
orgId: organization.id
|
||||
});
|
||||
|
||||
const tagline = isFreePlan ? undefined : extractOrgTagline(organization.landingpage);
|
||||
const themeColor = resolveThemeColor(organization.theme) ?? '#1d4ed8';
|
||||
const renderInput = {
|
||||
orgName: organization.name,
|
||||
logoUrl: organization.avatarUrl?.trim() || undefined,
|
||||
tagline,
|
||||
themeColor,
|
||||
showWatermark: isFreePlan
|
||||
};
|
||||
|
||||
const etag = buildOrgSiteOgEtag({
|
||||
siteName,
|
||||
...renderInput
|
||||
});
|
||||
|
||||
const { html, styles } = renderOrgSiteOg(renderInput);
|
||||
const buffer = await getCloudflarePngBuffer(html, styles, OG_VIEWPORT);
|
||||
|
||||
return { buffer, etag };
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { CLOUDFLARE } from '@api/constants';
|
||||
|
||||
export type ScreenshotViewport = {
|
||||
width: number;
|
||||
height: number;
|
||||
deviceScaleFactor?: number;
|
||||
};
|
||||
|
||||
export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
|
||||
console.log('Generating PDF with Cloudflare API...');
|
||||
try {
|
||||
@@ -29,9 +35,13 @@ export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
|
||||
|
||||
/**
|
||||
* Renders HTML to a PNG via Cloudflare Browser Rendering's `/screenshot` endpoint.
|
||||
* The viewport is fixed to 1100x780 to match the certificate canvas.
|
||||
* Defaults to 1100x780 for certificate canvases; pass a custom viewport for other assets.
|
||||
*/
|
||||
export const getCloudflarePngBuffer = async (html: string, styles?: string) => {
|
||||
export const getCloudflarePngBuffer = async (
|
||||
html: string,
|
||||
styles?: string,
|
||||
viewport: ScreenshotViewport = { width: 1100, height: 780, deviceScaleFactor: 2 }
|
||||
) => {
|
||||
console.log('Generating PNG with Cloudflare API...');
|
||||
try {
|
||||
const response = await fetch(
|
||||
@@ -45,7 +55,11 @@ export const getCloudflarePngBuffer = async (html: string, styles?: string) => {
|
||||
body: JSON.stringify({
|
||||
html,
|
||||
addStyleTag: styles ? [{ content: styles }] : undefined,
|
||||
viewport: { width: 1100, height: 780, deviceScaleFactor: 2 },
|
||||
viewport: {
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
deviceScaleFactor: viewport.deviceScaleFactor ?? 2
|
||||
},
|
||||
screenshotOptions: { type: 'png', omitBackground: false, fullPage: false }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
export type OrgSiteOgInput = {
|
||||
orgName: string;
|
||||
logoUrl?: string;
|
||||
tagline?: string;
|
||||
themeColor: string;
|
||||
showWatermark: boolean;
|
||||
};
|
||||
|
||||
export type OrgSiteOgRenderResult = {
|
||||
html: string;
|
||||
styles: string;
|
||||
};
|
||||
|
||||
const FONTS_LINK_HREF = 'https://fonts.googleapis.com/css2?family=Inter:wght@500;600;700&display=swap';
|
||||
|
||||
function escapeHtml(input: unknown): string {
|
||||
return String(input ?? '').replace(/[&<>"']/g, (char) => {
|
||||
switch (char) {
|
||||
case '&':
|
||||
return '&';
|
||||
case '<':
|
||||
return '<';
|
||||
case '>':
|
||||
return '>';
|
||||
case '"':
|
||||
return '"';
|
||||
default:
|
||||
return ''';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return `${trimmed.slice(0, maxLength - 1).trim()}…`;
|
||||
}
|
||||
|
||||
function orgInitials(orgName: string): string {
|
||||
const words = orgName.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length >= 2) {
|
||||
return `${words[0]![0] ?? ''}${words[1]![0] ?? ''}`.toUpperCase();
|
||||
}
|
||||
|
||||
return orgName.trim().slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function shadeColor(hex: string, percent: number): string {
|
||||
const normalized = hex.startsWith('#') ? hex.slice(1) : hex;
|
||||
if (normalized.length !== 6) {
|
||||
return hex;
|
||||
}
|
||||
|
||||
const numeric = parseInt(normalized, 16);
|
||||
if (Number.isNaN(numeric)) {
|
||||
return hex;
|
||||
}
|
||||
|
||||
const offset = Math.round((percent / 100) * 255);
|
||||
const clamp = (value: number) => Math.max(0, Math.min(255, value));
|
||||
const red = clamp(((numeric >> 16) & 0xff) + offset);
|
||||
const green = clamp(((numeric >> 8) & 0xff) + offset);
|
||||
const blue = clamp((numeric & 0xff) + offset);
|
||||
const next = (red << 16) | (green << 8) | blue;
|
||||
|
||||
return `#${next.toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a public-facing tagline from raw org `landingpage` JSON without
|
||||
* depending on dashboard normalization helpers.
|
||||
*/
|
||||
export function extractOrgTagline(landingpage: unknown): string | undefined {
|
||||
if (!isRecord(landingpage)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const footer = landingpage.footer;
|
||||
if (isRecord(footer)) {
|
||||
const brand = footer.brand;
|
||||
if (isRecord(brand) && typeof brand.tagline === 'string') {
|
||||
const footerTagline = brand.tagline.trim();
|
||||
if (footerTagline) {
|
||||
return footerTagline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hero = landingpage.hero;
|
||||
if (isRecord(hero) && typeof hero.subheading === 'string') {
|
||||
const subheading = hero.subheading.trim();
|
||||
if (subheading) {
|
||||
return subheading;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function renderOrgSiteOgTemplate(input: OrgSiteOgInput): { body: string; styles: string } {
|
||||
const orgName = truncateText(input.orgName, 48);
|
||||
const tagline = input.tagline ? truncateText(input.tagline, 90) : '';
|
||||
const themeColor = input.themeColor || '#1d4ed8';
|
||||
const gradientEnd = shadeColor(themeColor, -35);
|
||||
const initials = orgInitials(orgName);
|
||||
|
||||
const logoMarkup = input.logoUrl
|
||||
? `<img class="logo-image" src="${escapeHtml(input.logoUrl)}" alt="" />`
|
||||
: `<div class="logo-fallback" aria-hidden="true">${escapeHtml(initials)}</div>`;
|
||||
|
||||
const taglineMarkup = tagline ? `<p class="tagline">${escapeHtml(tagline)}</p>` : '';
|
||||
|
||||
const watermarkMarkup = input.showWatermark
|
||||
? `<div class="watermark">Powered by <strong>ClassroomIO</strong></div>`
|
||||
: '';
|
||||
|
||||
const body = `
|
||||
<div class="canvas">
|
||||
<div class="card">
|
||||
<div class="glow"></div>
|
||||
<div class="content">
|
||||
<div class="logo-wrap">${logoMarkup}</div>
|
||||
<div class="copy">
|
||||
<h1 class="org-name">${escapeHtml(orgName)}</h1>
|
||||
${taglineMarkup}
|
||||
</div>
|
||||
</div>
|
||||
${watermarkMarkup}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const styles = `
|
||||
@import url('${FONTS_LINK_HREF}');
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
background: linear-gradient(135deg, ${themeColor} 0%, ${gradientEnd} 100%);
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
padding: 72px 80px;
|
||||
color: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
top: -120px;
|
||||
right: -80px;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
max-width: 1040px;
|
||||
}
|
||||
|
||||
.logo-wrap {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.logo-image,
|
||||
.logo-fallback {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
border-radius: 28px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 2px solid rgba(255, 255, 255, 0.28);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.logo-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.org-name {
|
||||
font-size: 64px;
|
||||
line-height: 1.05;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin-top: 18px;
|
||||
max-width: 760px;
|
||||
font-size: 30px;
|
||||
line-height: 1.35;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.watermark {
|
||||
position: absolute;
|
||||
right: 48px;
|
||||
bottom: 36px;
|
||||
z-index: 2;
|
||||
padding: 10px 16px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.watermark strong {
|
||||
font-weight: 700;
|
||||
}`;
|
||||
|
||||
return { body, styles };
|
||||
}
|
||||
|
||||
export function renderOrgSiteOg(input: OrgSiteOgInput): OrgSiteOgRenderResult {
|
||||
const { body, styles } = renderOrgSiteOgTemplate(input);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=1200,initial-scale=1.0">
|
||||
<title>${input.orgName} Open Graph</title>
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { html, styles };
|
||||
}
|
||||
Reference in New Issue
Block a user