Files
classroomio/apps/api/src/services/widget-payload.ts
T
Rotimi Best 367ddcddd7 feat(embeds): add embed.classroomio.com public widget host
Serve widgets from short public URLs via tenant-router with R2-backed
assets and runtime API_URL injection, so copy-paste snippets hide infra
paths. Update widget payloads, CSP, upload purge URLs, and dev-only API
inject; document format/stage workflow in AGENTS.md and fix ui-prefix-check
YAML syntax.
2026-06-12 02:06:37 +01:00

276 lines
8.1 KiB
TypeScript

import { getOrgCourses } from '@cio/db/queries/course';
import { getActiveOrganizationPlan, getOrganizationById } from '@cio/db/queries/organization';
import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@cio/db/queries/tag';
import { listWidgetCourses } from '@cio/db/queries/widget';
import { PLAN } from '@cio/utils/plans';
import { EMBED_PUBLIC_BASE_URL, getEmbedBaseUrl, getEmbedPublicUrl, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
import {
buildWidgetPayload as buildWidgetPayloadShared,
normalizeWidgetConfig,
resolveWidgetPlanGatedFields,
type BuildWidgetPayloadCourse,
type TWidgetPayload
} from '@cio/utils/validation/widget';
import type { TWidget } from '@db/types';
import * as csstree from 'css-tree';
import { env } from '@cio/core/config/env';
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const DEFAULT_EMBED_CDN_BASE_URL = EMBED_PUBLIC_BASE_URL;
const DEFAULT_LOCAL_EMBED_BASE_URL = 'http://localhost:5180';
const DEFAULT_WIDGET_APP_BASE_URL = 'https://classroomio.com';
const ALLOWED_CUSTOM_CSS_PROPERTIES = new Set([
'align-items',
'background',
'background-color',
'border',
'border-color',
'border-radius',
'border-style',
'border-width',
'box-shadow',
'color',
'display',
'fill',
'flex',
'flex-basis',
'flex-direction',
'flex-grow',
'flex-shrink',
'font',
'font-family',
'font-size',
'font-style',
'font-weight',
'gap',
'grid',
'grid-template-columns',
'grid-template-rows',
'height',
'justify-content',
'letter-spacing',
'line-height',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'max-height',
'max-width',
'min-height',
'min-width',
'object-fit',
'opacity',
'overflow',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'place-items',
'stroke',
'text-align',
'text-decoration',
'text-transform',
'transform',
'transition',
'width',
'white-space'
]);
function encodeBase58(bytes: Uint8Array): string {
let value = 0n;
for (const byte of bytes) {
value = (value << 8n) + BigInt(byte);
}
if (value === 0n) {
return BASE58_ALPHABET[0];
}
let encoded = '';
while (value > 0n) {
const remainder = Number(value % 58n);
encoded = BASE58_ALPHABET[remainder] + encoded;
value /= 58n;
}
return encoded;
}
export function generateWidgetPublicKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return `wgt_${encodeBase58(bytes).slice(0, 16)}`;
}
export function sanitizeCustomCssValue(customCss: string, planName?: string | null): string {
if (!customCss.trim() || !planName || planName === PLAN.BASIC) {
return '';
}
if (customCss.length > 5_000) {
throw new Error('Custom CSS exceeds 5KB limit');
}
const normalizedCss = customCss.toLowerCase();
const bannedSnippets = ['@import', 'javascript:', 'expression(', 'behavior:', 'binding:', '-moz-binding'];
if (bannedSnippets.some((snippet) => normalizedCss.includes(snippet))) {
throw new Error('Custom CSS contains unsupported rules');
}
const ast = csstree.parse(customCss, { context: 'stylesheet' });
csstree.walk(ast, (node: any) => {
if (node.type === 'Atrule') {
throw new Error('At-rules are not allowed in custom CSS');
}
if (node.type === 'Declaration') {
const propertyName = node.property.toLowerCase();
const propertyValue = csstree.generate(node.value).toLowerCase();
if (!ALLOWED_CUSTOM_CSS_PROPERTIES.has(propertyName)) {
throw new Error(`Custom CSS property "${propertyName}" is not allowed`);
}
if (propertyName === 'position' && propertyValue.includes('fixed')) {
throw new Error('position: fixed is not allowed in custom CSS');
}
}
});
return csstree.generate(ast);
}
export function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
if (customDomain) {
return `https://${customDomain}`;
}
return siteName ? `https://${siteName}.${TENANT_ROOT_DOMAIN}` : DEFAULT_WIDGET_APP_BASE_URL;
}
function resolveEmbedBaseUrl(): string {
return getEmbedBaseUrl(
env.PUBLIC_EMBED_BASE_URL ||
(env.NODE_ENV === 'development' ? DEFAULT_LOCAL_EMBED_BASE_URL : DEFAULT_EMBED_CDN_BASE_URL)
);
}
export function getCourseWidgetScriptUrl(): string {
return getEmbedPublicUrl('course-widget', undefined, resolveEmbedBaseUrl());
}
export function getWidgetEmbedCode(publicKey: string): string {
const scriptUrl = getCourseWidgetScriptUrl();
return `<div data-cio-widget="course-widget" data-widget-key="${publicKey}"></div>\n<script async type="module" src="${scriptUrl}"></script>`;
}
export function getWidgetHostedEmbedUrl(publicKey: string): string {
return getEmbedPublicUrl('course-widget', { key: publicKey }, resolveEmbedBaseUrl());
}
type OrgCourse = Awaited<ReturnType<typeof getOrgCourses>>['items'][number];
export function formatCourseForWidget(
course: OrgCourse,
tags: BuildWidgetPayloadCourse['tags']
): BuildWidgetPayloadCourse {
return {
id: course.id,
slug: course.slug ?? course.id,
title: course.title,
description: course.description,
imageUrl: course.bannerImage || course.logo || null,
isPublished: course.isPublished,
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
lessonCount: course.lessonCount,
exerciseCount: course.exerciseCount ?? 0,
courseType: course.type,
createdAt: course.createdAt ?? new Date().toISOString(),
tags
};
}
export async function buildWidgetPayload(
widget: TWidget,
overrideSelectedCourseIds?: string[]
): Promise<TWidgetPayload> {
const organization = await getOrganizationById(widget.organizationId);
if (!organization) {
throw new Error('Organization not found for widget');
}
const activePlan = await getActiveOrganizationPlan(widget.organizationId);
const planName = activePlan?.planName ?? null;
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, planName);
const selectedCourseIds =
overrideSelectedCourseIds ?? (await listWidgetCourses(widget.id)).map((course) => course.courseId);
const allCoursesResult = await getOrgCourses({ orgId: widget.organizationId, limit: 200 });
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
widget.organizationId,
allCoursesResult.items.map((course) => course.id)
);
const allCourses = allCoursesResult.items.map((course) =>
formatCourseForWidget(course, tagsByCourseId[course.id] ?? [])
);
const planGatedFields = resolveWidgetPlanGatedFields(planName, normalizedConfig.themePreset);
const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, planName);
const orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain);
return buildWidgetPayloadShared({
widget: {
id: widget.id,
publicKey: widget.publicKey,
layoutType: widget.layoutType,
selectionMode: widget.selectionMode,
config: normalizedConfig
},
organization: {
id: organization.id,
name: organization.name,
siteName: organization.siteName ?? '',
customDomain: organization.customDomain
},
orgBaseUrl,
allCourses,
selectedCourseIds,
planGatedFields,
planName,
sanitizedCustomCss
});
}
export async function listWidgetAvailableEditorData(orgId: string) {
const [coursesResult, tagGroups, activePlan] = await Promise.all([
getOrgCourses({ orgId, limit: 200 }),
getTagGroupsWithTags(orgId),
getActiveOrganizationPlan(orgId)
]);
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
orgId,
coursesResult.items.map((course) => course.id)
);
return {
availableCourses: coursesResult.items.map((course) =>
formatCourseForWidget(course, tagsByCourseId[course.id] ?? [])
),
availableTags: tagGroups.flatMap((group) =>
group.tags.map((tag) => ({
id: tag.id,
name: tag.name,
slug: tag.slug,
color: tag.color,
groupId: group.id
}))
),
planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio'),
planName: activePlan?.planName ?? null
};
}