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 `
\n`; } export function getWidgetHostedEmbedUrl(publicKey: string): string { return getEmbedPublicUrl('course-widget', { key: publicKey }, resolveEmbedBaseUrl()); } type OrgCourse = Awaited