feat: add tenant theme templates

This commit is contained in:
Codex
2026-06-29 18:05:30 +08:00
parent a68b4ab9e3
commit ee578af1f6
16 changed files with 1239 additions and 24 deletions

View File

@@ -24,6 +24,48 @@ const ROLE_TEMPLATE_STATUSES = ['active', 'disabled', 'archived'];
const BADGE_CATEGORIES = ['learning', 'practice', 'vocabulary', 'mock_exam', 'activity', 'feedback', 'sales', 'system', 'custom'];
const BADGE_UNLOCK_TYPES = ['manual', 'auto', 'score', 'check_in', 'practice_count', 'vocabulary_mastered', 'mock_exam_score', 'feedback_resolved', 'custom'];
const BADGE_OPERATORS = ['gte', 'lte', 'eq', 'gt', 'lt'];
const THEME_MODES = ['light', 'dark', 'auto'];
const THEME_DENSITIES = ['compact', 'comfortable', 'dense'];
const THEME_PUBLIC_ASSET_KEYS = new Set([
'logoUrl',
'faviconUrl',
'appIconUrl',
'shareImageUrl',
'loginPosterUrl',
'splashImageUrl',
'iconSet',
'shareCardStyle',
]);
const THEME_TOKEN_KEYS = new Set([
'mode',
'primaryColor',
'accentColor',
'backgroundColor',
'surfaceColor',
'textColor',
'mutedColor',
'borderColor',
'successColor',
'warningColor',
'dangerColor',
'borderRadius',
'buttonRadius',
'fontFamily',
'layoutDensity',
'customCssVars',
'icons',
]);
const THEME_URL_ASSET_KEYS = new Set([
'logoUrl',
'faviconUrl',
'appIconUrl',
'shareImageUrl',
'loginPosterUrl',
'splashImageUrl',
]);
const SAFE_THEME_TOKEN_PATTERN = /^[a-z0-9][a-z0-9_-]{0,31}$/i;
const SAFE_THEME_CSS_VAR_KEY_PATTERN = /^--tiku-[a-z0-9-]{1,48}$/i;
const UNSAFE_THEME_STRING_PATTERN = /(app_private\.tenant_secrets|-----BEGIN|<script|javascript:|data:text\/html|expression\s*\(|@import|url\s*\()/i;
function jsonBodyValue(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
@@ -140,6 +182,185 @@ function publicJsonValue(value: unknown) {
return JSON.stringify(publicConfig);
}
function assertThemePublicString(value: unknown, path: string) {
if (value === undefined || value === null || value === '') return;
if (typeof value !== 'string') {
throw new HttpError(400, `${path} must be a string`, 'INVALID_THEME_STRING');
}
if (UNSAFE_THEME_STRING_PATTERN.test(value)) {
throw new HttpError(400, `${path} contains unsafe public theme content`, 'PUBLIC_CONFIG_SECRET_REJECTED');
}
}
function assertHexColor(value: unknown, key: string) {
if (value === undefined || value === null || value === '') return;
if (typeof value !== 'string' || !/^#[0-9a-f]{6}$/i.test(value.trim())) {
throw new HttpError(400, `${key} must be a #RRGGBB color`, 'INVALID_THEME_COLOR');
}
}
function themeNumber(value: unknown, key: string) {
if (value === undefined || value === null || value === '') return undefined;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 32) {
throw new HttpError(400, `${key} must be a number between 0 and 32`, 'INVALID_THEME_NUMBER');
}
return Math.trunc(parsed);
}
function sanitizeThemeFontFamily(value: unknown) {
const candidate = nullableString(value);
if (!candidate) return 'system';
assertThemePublicString(candidate, 'theme.fontFamily');
if (!/^[\u4e00-\u9fa5a-zA-Z0-9\s,"'-]{1,80}$/.test(candidate)) {
throw new HttpError(400, 'fontFamily contains unsupported characters', 'INVALID_THEME_FONT');
}
return candidate;
}
function sanitizeThemeCssVars(value: unknown) {
const source = objectValue(value);
const result: Record<string, string> = {};
for (const [key, raw] of Object.entries(source)) {
if (!SAFE_THEME_CSS_VAR_KEY_PATTERN.test(key)) {
throw new HttpError(400, `Invalid CSS variable key: ${key}`, 'INVALID_THEME_CSS_VAR');
}
assertThemePublicString(raw, `theme.customCssVars.${key}`);
const text = String(raw).trim();
if (text.length > 96 || /[{};]/.test(text)) {
throw new HttpError(400, `Invalid CSS variable value: ${key}`, 'INVALID_THEME_CSS_VAR');
}
result[key] = text;
}
return result;
}
function sanitizeThemeIcons(value: unknown) {
const source = objectValue(value);
const result: Record<string, string> = {};
for (const [key, raw] of Object.entries(source)) {
if (!SAFE_THEME_TOKEN_PATTERN.test(key)) {
throw new HttpError(400, `Invalid icon key: ${key}`, 'INVALID_THEME_ICON');
}
assertThemePublicString(raw, `theme.icons.${key}`);
const text = String(raw).trim();
if (text.startsWith('/') || /^https?:\/\//i.test(text)) {
result[key] = sanitizeThemePublicUrl(text, `theme.icons.${key}`);
continue;
}
if (!SAFE_THEME_TOKEN_PATTERN.test(text)) {
throw new HttpError(400, `Invalid icon value: ${key}`, 'INVALID_THEME_ICON');
}
result[key] = text;
}
return result;
}
function sanitizeThemePublicUrl(value: unknown, path: string) {
if (value === undefined || value === null || value === '') return '';
assertThemePublicString(value, path);
const text = String(value).trim();
if (text.startsWith('/')) {
if (text.startsWith('//') || text.includes('\\')) {
throw new HttpError(400, `${path} must be an HTTPS URL or absolute public path`, 'INVALID_THEME_ASSET_URL');
}
return text;
}
let parsed: URL;
try {
parsed = new URL(text);
} catch {
throw new HttpError(400, `${path} must be an HTTPS URL or absolute public path`, 'INVALID_THEME_ASSET_URL');
}
const isLocalDev = parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
if (parsed.protocol !== 'https:' && !isLocalDev) {
throw new HttpError(400, `${path} must use HTTPS`, 'INVALID_THEME_ASSET_URL');
}
return parsed.toString();
}
function sanitizeThemePublicToken(value: unknown, path: string) {
if (value === undefined || value === null || value === '') return '';
assertThemePublicString(value, path);
const text = String(value).trim();
if (!SAFE_THEME_TOKEN_PATTERN.test(text)) {
throw new HttpError(400, `${path} must be a safe token`, 'INVALID_THEME_ASSET_TOKEN');
}
return text;
}
function sanitizeThemeTokens(value: unknown) {
const source = objectValue(value);
assertPublicConfigHasNoSecrets(source, 'theme');
const theme: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(source)) {
if (!THEME_TOKEN_KEYS.has(key)) {
throw new HttpError(400, `Invalid theme key: ${key}`, 'INVALID_THEME_KEY');
}
if (key.endsWith('Color')) {
assertHexColor(raw, key);
theme[key] = typeof raw === 'string' ? raw.trim().toLowerCase() : raw;
continue;
}
if (key === 'mode') {
theme.mode = optionalChoice(raw, THEME_MODES, 'light');
continue;
}
if (key === 'layoutDensity') {
theme.layoutDensity = optionalChoice(raw, THEME_DENSITIES, 'comfortable');
continue;
}
if (key === 'borderRadius' || key === 'buttonRadius') {
const parsed = themeNumber(raw, key);
if (parsed !== undefined) theme[key] = parsed;
continue;
}
if (key === 'fontFamily') {
theme.fontFamily = sanitizeThemeFontFamily(raw);
continue;
}
if (key === 'customCssVars') {
theme.customCssVars = sanitizeThemeCssVars(raw);
continue;
}
if (key === 'icons') {
theme.icons = sanitizeThemeIcons(raw);
continue;
}
theme[key] = raw;
}
return theme;
}
function sanitizeThemePublicAssets(value: unknown) {
const source = objectValue(value);
assertPublicConfigHasNoSecrets(source, 'publicAssets');
const assets: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(source)) {
if (!THEME_PUBLIC_ASSET_KEYS.has(key)) {
throw new HttpError(400, `Invalid theme asset key: ${key}`, 'INVALID_THEME_ASSET_KEY');
}
assets[key] = THEME_URL_ASSET_KEYS.has(key)
? sanitizeThemePublicUrl(raw, `publicAssets.${key}`)
: sanitizeThemePublicToken(raw, `publicAssets.${key}`);
}
return assets;
}
function mergeTheme(templateTheme: unknown, overrides: unknown) {
return {
...objectValue(templateTheme),
...sanitizeThemeTokens(overrides),
};
}
function mergeThemeAssets(templateAssets: unknown, overrides: unknown) {
return {
...objectValue(templateAssets),
...sanitizeThemePublicAssets(overrides),
};
}
function secretRef(scope: SecretScope, secretKey: string) {
return `app_private.tenant_secrets:${scope}:${secretKey}`;
}
@@ -718,6 +939,303 @@ export async function updateTenantSettingsRoute(ctx: RequestContext) {
return { item };
}
export async function tenantThemeTemplatesRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:theme:read');
const items = await query(
`
select code, name, description, preview_image_url as "previewImageUrl",
theme, public_assets as "publicAssets", sort_order as "sortOrder",
status, updated_at as "updatedAt"
from public.tenant_theme_templates
where status = 'active'
order by sort_order asc, code asc
`,
);
return { items };
}
export async function tenantThemeRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:theme:read');
const item = await queryOne(
`
select c.tenant_id as "tenantId",
c.active_template_code as "activeTemplateCode",
c.active_theme as "activeTheme",
c.active_public_assets as "activePublicAssets",
c.draft_template_code as "draftTemplateCode",
c.draft_theme as "draftTheme",
c.draft_public_assets as "draftPublicAssets",
c.status,
c.published_at as "publishedAt",
c.published_by as "publishedBy",
c.draft_updated_by as "draftUpdatedBy",
c.updated_at as "updatedAt",
at.name as "activeTemplateName",
dt.name as "draftTemplateName",
coalesce(b.theme, '{}'::jsonb) as "brandingTheme",
coalesce(b.public_assets, '{}'::jsonb) as "brandingPublicAssets"
from public.tenant_theme_configs c
left join public.tenant_theme_templates at on at.code = c.active_template_code
left join public.tenant_theme_templates dt on dt.code = c.draft_template_code
left join public.tenant_branding b on b.tenant_id = c.tenant_id
where c.tenant_id = $1
limit 1
`,
[auth.tenantId],
);
if (item) return { item };
const fallback = await queryOne(
`
select b.tenant_id as "tenantId",
null::text as "activeTemplateCode",
coalesce(b.theme, '{}'::jsonb) as "activeTheme",
coalesce(b.public_assets, '{}'::jsonb) as "activePublicAssets",
null::text as "draftTemplateCode",
'{}'::jsonb as "draftTheme",
'{}'::jsonb as "draftPublicAssets",
'published'::text as status,
null::timestamptz as "publishedAt",
null::uuid as "publishedBy",
null::uuid as "draftUpdatedBy",
b.updated_at as "updatedAt",
null::text as "activeTemplateName",
null::text as "draftTemplateName",
coalesce(b.theme, '{}'::jsonb) as "brandingTheme",
coalesce(b.public_assets, '{}'::jsonb) as "brandingPublicAssets"
from public.tenant_branding b
where b.tenant_id = $1
limit 1
`,
[auth.tenantId],
);
return { item: fallback || {
tenantId: auth.tenantId,
activeTemplateCode: null,
activeTheme: {},
activePublicAssets: {},
draftTemplateCode: null,
draftTheme: {},
draftPublicAssets: {},
status: 'published',
publishedAt: null,
updatedAt: null,
} };
}
export async function previewTenantThemeRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:theme:write');
const body = await readJsonBody(ctx);
const templateCode = requiredString(body, 'templateCode');
const themeOverrides = body.theme || body.themeOverrides || {};
const publicAssetOverrides = body.publicAssets || body.publicAssetOverrides || {};
const item = await transaction(async client => {
const template = await client.query<{
code: string;
name: string;
theme: Record<string, unknown>;
publicAssets: Record<string, unknown>;
}>(
`
select code, name, theme, public_assets as "publicAssets"
from public.tenant_theme_templates
where code = $1 and status = 'active'
limit 1
`,
[templateCode],
);
const selected = template.rows[0];
if (!selected) throw new HttpError(404, 'Theme template not found', 'THEME_TEMPLATE_NOT_FOUND');
const draftTheme = mergeTheme(selected.theme, themeOverrides);
const draftPublicAssets = mergeThemeAssets(selected.publicAssets, publicAssetOverrides);
const result = await client.query(
`
insert into public.tenant_theme_configs (
tenant_id, draft_template_code, draft_theme, draft_public_assets,
status, draft_updated_by
)
values ($1, $2, $3::jsonb, $4::jsonb, 'draft', $5)
on conflict (tenant_id)
do update set draft_template_code = excluded.draft_template_code,
draft_theme = excluded.draft_theme,
draft_public_assets = excluded.draft_public_assets,
status = 'draft',
draft_updated_by = excluded.draft_updated_by,
updated_at = now()
returning tenant_id as "tenantId",
draft_template_code as "draftTemplateCode",
draft_theme as "draftTheme",
draft_public_assets as "draftPublicAssets",
status, updated_at as "updatedAt"
`,
[
auth.tenantId,
selected.code,
JSON.stringify(draftTheme),
JSON.stringify(draftPublicAssets),
auth.userId,
],
);
await recordAudit(client, auth, 'tenant.theme.previewed', 'tenant_theme_configs', auth.tenantId, {
templateCode: selected.code,
themeKeys: Object.keys(draftTheme),
publicAssetKeys: Object.keys(draftPublicAssets),
});
return {
...result.rows[0],
templateName: selected.name,
};
});
return { item };
}
export async function publishTenantThemeRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:theme:write');
const body = await readJsonBody(ctx);
const useDraft = boolValue(body.useDraft, true);
const templateCode = nullableString(body.templateCode);
const item = await transaction(async client => {
let sourceTheme: Record<string, unknown>;
let sourcePublicAssets: Record<string, unknown>;
let sourceTemplateCode: string | null;
let templateName: string | null = null;
if (useDraft) {
const draft = await client.query<{
draftTemplateCode: string | null;
draftTheme: Record<string, unknown>;
draftPublicAssets: Record<string, unknown>;
templateName: string | null;
}>(
`
select c.draft_template_code as "draftTemplateCode",
c.draft_theme as "draftTheme",
c.draft_public_assets as "draftPublicAssets",
t.name as "templateName"
from public.tenant_theme_configs c
left join public.tenant_theme_templates t on t.code = c.draft_template_code
where c.tenant_id = $1
limit 1
`,
[auth.tenantId],
);
const row = draft.rows[0];
if (!row || !row.draftTemplateCode) {
throw new HttpError(400, 'No draft theme to publish', 'THEME_DRAFT_NOT_FOUND');
}
sourceTheme = sanitizeThemeTokens(row.draftTheme);
sourcePublicAssets = sanitizeThemePublicAssets(row.draftPublicAssets);
sourceTemplateCode = row.draftTemplateCode;
templateName = row.templateName;
} else {
const selectedCode = templateCode || requiredString(body, 'templateCode');
const template = await client.query<{
code: string;
name: string;
theme: Record<string, unknown>;
publicAssets: Record<string, unknown>;
}>(
`
select code, name, theme, public_assets as "publicAssets"
from public.tenant_theme_templates
where code = $1 and status = 'active'
limit 1
`,
[selectedCode],
);
const selected = template.rows[0];
if (!selected) throw new HttpError(404, 'Theme template not found', 'THEME_TEMPLATE_NOT_FOUND');
sourceTheme = mergeTheme(selected.theme, body.theme || body.themeOverrides || {});
sourcePublicAssets = mergeThemeAssets(selected.publicAssets, body.publicAssets || body.publicAssetOverrides || {});
sourceTemplateCode = selected.code;
templateName = selected.name;
}
await client.query(
`
insert into public.tenant_branding (tenant_id, brand_name, theme, public_assets)
values (
$1,
coalesce((select brand_name from public.tenant_branding where tenant_id = $1), (select name from public.tenants where id = $1), '租户题库'),
$2::jsonb,
$3::jsonb
)
on conflict (tenant_id)
do update set theme = excluded.theme,
public_assets = coalesce(public.tenant_branding.public_assets, '{}'::jsonb) || excluded.public_assets,
updated_at = now()
`,
[auth.tenantId, JSON.stringify(sourceTheme), JSON.stringify(sourcePublicAssets)],
);
const result = await client.query(
`
insert into public.tenant_theme_configs (
tenant_id, active_template_code, active_theme, active_public_assets,
draft_template_code, draft_theme, draft_public_assets,
status, published_at, published_by, draft_updated_by
)
values ($1, $2, $3::jsonb, $4::jsonb, null, '{}'::jsonb, '{}'::jsonb, 'published', now(), $5, $5)
on conflict (tenant_id)
do update set active_template_code = excluded.active_template_code,
active_theme = excluded.active_theme,
active_public_assets = excluded.active_public_assets,
draft_template_code = null,
draft_theme = '{}'::jsonb,
draft_public_assets = '{}'::jsonb,
status = 'published',
published_at = now(),
published_by = excluded.published_by,
updated_at = now()
returning tenant_id as "tenantId",
active_template_code as "activeTemplateCode",
active_theme as "activeTheme",
active_public_assets as "activePublicAssets",
status,
published_at as "publishedAt",
published_by as "publishedBy",
updated_at as "updatedAt"
`,
[
auth.tenantId,
sourceTemplateCode,
JSON.stringify(sourceTheme),
JSON.stringify(sourcePublicAssets),
auth.userId,
],
);
await recordAudit(client, auth, 'tenant.theme.published', 'tenant_theme_configs', auth.tenantId, {
templateCode: sourceTemplateCode,
themeKeys: Object.keys(sourceTheme),
publicAssetKeys: Object.keys(sourcePublicAssets),
});
return {
...result.rows[0],
templateName,
};
});
return { item };
}
export async function tenantDomainsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:domains:read');