diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index f5659edff..d83e156b8 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -9,6 +9,7 @@ import { getTheme, getPluginRegistry, getThemeRegistry, + themeCSSResolver, type ServerPlugin, type ServerTheme, } from '@/lib/admin/plugin-registry'; @@ -16,13 +17,10 @@ import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; import { EXTENSION_ID_RE, ExtensionPackageError, - openPackage, - readManifest, readPluginPackage, - readText, + readThemePackage, suspiciousCode, } from '@/lib/extension-package'; -import { sanitizeThemeCSS } from '@/lib/theme-loader'; import { configManager } from '@/lib/admin/config-manager'; async function getDirectoryUrl(): Promise { @@ -151,59 +149,38 @@ export async function POST(request: NextRequest) { const now = new Date().toISOString(); if (type === 'theme') { - let pkg; - let manifest: Record; - try { - pkg = await openPackage(buffer, 'theme'); - manifest = await readManifest(pkg); - } catch (err) { - if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); - throw err; + const read = await readThemePackage(buffer, { resolveExtends: await themeCSSResolver() }); + if (!read.valid || !read.manifest) { + return NextResponse.json({ error: read.errors.join('; ') }, { status: 400 }); } + const manifest = read.manifest; // The slug is what the admin picked and what install status is keyed // on. A bundle that names another id would install over whatever // already uses that id, keeping its enabled and force-enabled state. - if (manifest.id !== undefined && manifest.id !== slug) { + if (manifest.id !== slug) { return NextResponse.json( { error: `Bundle id "${String(manifest.id)}" does not match the directory entry "${slug}"` }, { status: 400 }, ); } const resolvedId = slug; - - let css: string | null; - try { - css = await readText(pkg, 'theme.css'); - } catch (err) { - if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); - throw err; - } - if (css === null) { - return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 }); - } - - // Sanitize unconditionally: the safety check is advisory, and the - // sanitizer is also what drops rules outside :root and .dark. - const warnings: string[] = []; - const sanitized = sanitizeThemeCSS(css); - css = sanitized.css; - warnings.push(...sanitized.warnings); + const { css, skin, warnings } = read; const existingTheme = await getTheme(resolvedId); const isUpdate = existingTheme !== null; const theme: ServerTheme = { id: resolvedId, - name: (manifest.name as string) || slug, + name: manifest.name || slug, // Prefer the directory-published version (what we requested) over // manifest.version. Publishers sometimes forget to bump the version // inside the bundle's manifest.json; trusting it would make the // update never appear to "stick" — the registry would keep showing // the older version even after a successful update. - version: version || (manifest.version as string), - author: (manifest.author as string) || 'Unknown', - description: (manifest.description as string) || '', - variants: (manifest.variants as string[]) || ['light', 'dark'], + version: version || manifest.version, + author: manifest.author || 'Unknown', + description: manifest.description || '', + variants: manifest.variants, enabled: existingTheme?.enabled ?? true, ...(existingTheme?.forceEnabled !== undefined ? { forceEnabled: existingTheme.forceEnabled } @@ -212,7 +189,7 @@ export async function POST(request: NextRequest) { updatedAt: now, }; - await saveTheme(theme, css); + await saveTheme(theme, css, skin); await auditLog( isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme', { diff --git a/app/api/admin/themes/[id]/css/route.ts b/app/api/admin/themes/[id]/css/route.ts index 347ccfaad..ad154a470 100644 --- a/app/api/admin/themes/[id]/css/route.ts +++ b/app/api/admin/themes/[id]/css/route.ts @@ -1,46 +1,13 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry'; -import { logger } from '@/lib/logger'; +import { NextRequest } from 'next/server'; +import { serveThemeAsset } from '@/lib/admin/theme-asset'; /** - * GET /api/admin/themes/[id]/css - Serve theme CSS to clients + * GET /api/admin/themes/[id]/css - Serve a theme's CSS to clients */ export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const { id } = await params; - - // Validate ID format - if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) { - return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 }); - } - - // Verify theme exists and is enabled - const registry = await getThemeRegistry(); - const theme = registry.themes.find(t => t.id === id); - if (!theme) { - return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); - } - if (!theme.enabled) { - return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 }); - } - - const css = await getThemeCSS(id); - if (!css) { - return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 }); - } - - return new NextResponse(css, { - headers: { - 'Content-Type': 'text/css; charset=utf-8', - 'Cache-Control': 'public, max-age=3600', - 'X-Content-Type-Options': 'nosniff', - }, - }); - } catch (error) { - logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } + const { id } = await params; + return serveThemeAsset(id, 'css'); } diff --git a/app/api/admin/themes/[id]/skin/route.ts b/app/api/admin/themes/[id]/skin/route.ts new file mode 100644 index 000000000..c292e44e5 --- /dev/null +++ b/app/api/admin/themes/[id]/skin/route.ts @@ -0,0 +1,13 @@ +import { NextRequest } from 'next/server'; +import { serveThemeAsset } from '@/lib/admin/theme-asset'; + +/** + * GET /api/admin/themes/[id]/skin - Serve a theme's skin (Theme API v2 component CSS) to clients + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + return serveThemeAsset(id, 'skin'); +} diff --git a/app/api/admin/themes/__tests__/route.test.ts b/app/api/admin/themes/__tests__/route.test.ts index 3a092fba2..89a60618a 100644 --- a/app/api/admin/themes/__tests__/route.test.ts +++ b/app/api/admin/themes/__tests__/route.test.ts @@ -19,9 +19,11 @@ vi.mock('@/lib/admin/config-manager', () => ({ setPolicy: vi.fn(async (p: Record) => { policy = p; }), }, })); +const saved: Array<{ theme: Record; css: string; skin: string | null }> = []; vi.mock('@/lib/admin/plugin-registry', () => ({ deleteTheme: vi.fn(async () => true), - saveTheme: vi.fn(), + themeCSSResolver: vi.fn(async () => () => null), + saveTheme: vi.fn(async (theme: Record, css: string, skin: string | null) => { saved.push({ theme, css, skin }); }), getTheme: vi.fn(async () => null), getThemeRegistry: vi.fn(async () => ({ themes: [] })), })); @@ -38,6 +40,7 @@ function del(id: string) { } beforeEach(() => { + saved.length = 0; policy = { forceEnabledThemes: ['corp', 'other'], forceEnabledPlugins: [] }; }); @@ -65,3 +68,23 @@ describe('POST /api/admin/themes', () => { expect((await res.json()).error).toMatch(/once unpacked/); }); }); + +describe('POST /api/admin/themes with Theme API v2', () => { + it('installs a tokens-only theme with a skin', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'ocean', name: 'Ocean', version: '1.0.0', author: 'a', type: 'theme', variants: ['light', 'dark'], + apiVersion: 2, tokens: { light: { '--color-primary': '#0066cc' }, dark: { '--color-primary': '#66aaff' } }, + })); + zip.file('skin.css', '.message-list { border-radius: 8px; }'); + const bytes = await zip.generateAsync({ type: 'uint8array' }); + const form = new FormData(); + form.append('file', new File([bytes as BlobPart], 'ocean.zip')); + + const res = await POST(new NextRequest('http://localhost/api/admin/themes', { method: 'POST', body: form })); + + expect(res.status).toBe(200); + expect(saved[0].css).toContain('#0066cc'); + expect(saved[0].skin).toContain('border-radius'); + }); +}); diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index 8f4cfbec9..9f07bf34f 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -6,12 +6,12 @@ import { getThemeRegistry, saveTheme, deleteTheme as removeTheme, + themeCSSResolver, type ServerTheme, } from '@/lib/admin/plugin-registry'; import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup'; -import { ExtensionPackageError, openPackage, readManifest, readText, type OpenedPackage } from '@/lib/extension-package'; -import { sanitizeThemeCSS } from '@/lib/theme-loader'; +import { readThemePackage } from '@/lib/extension-package'; /** * GET /api/admin/themes - List all admin-managed themes @@ -47,75 +47,28 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing file' }, { status: 400 }); } - let pkg: OpenedPackage; - let manifest: Record; - try { - pkg = await openPackage(await file.arrayBuffer(), 'theme'); - manifest = await readManifest(pkg); - } catch (err) { - if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); - throw err; + const read = await readThemePackage(await file.arrayBuffer(), { + resolveExtends: await themeCSSResolver(), + }); + if (!read.valid || !read.manifest) { + return NextResponse.json({ error: read.errors.join('; ') }, { status: 400 }); } - - // Validate manifest - const errors: string[] = []; - if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); - if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); - if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); - if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); - - if (manifest.type !== 'theme') { - errors.push(`Expected type "theme", got "${manifest.type}"`); - } - - if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { - errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); - } - - if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) { - errors.push('Missing or empty "variants" array'); - } else { - const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark'); - if (!valid) errors.push('Variants must be "light" or "dark"'); - } - - if (errors.length > 0) { - return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); - } - - // Read theme.css - let css: string | null; - try { - css = await readText(pkg, 'theme.css'); - } catch (err) { - if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); - throw err; - } - if (css === null) { - return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 }); - } - - // Sanitize unconditionally: the safety check is advisory, and the - // sanitizer is also what drops rules outside :root and .dark. - const warnings: string[] = []; - const sanitized = sanitizeThemeCSS(css); - css = sanitized.css; - warnings.push(...sanitized.warnings); + const { manifest, css, skin, warnings } = read; const now = new Date().toISOString(); const theme: ServerTheme = { - id: manifest.id as string, - name: manifest.name as string, - version: manifest.version as string, - author: manifest.author as string, - description: (manifest.description as string) || '', - variants: manifest.variants as string[], + id: manifest.id, + name: manifest.name, + version: manifest.version, + author: manifest.author, + description: manifest.description || '', + variants: manifest.variants, enabled: true, installedAt: now, updatedAt: now, }; - await saveTheme(theme, css); + await saveTheme(theme, css, skin); await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip); return NextResponse.json({ theme, warnings }); diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 8332d1da9..197117906 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -82,6 +82,8 @@ export async function GET(request: NextRequest) { description: t.description, variants: t.variants, forceEnabled: t.forceEnabled || false, + hasSkin: t.hasSkin || false, + contentHash: t.contentHash, })); return NextResponse.json( diff --git a/lib/__tests__/theme-store-sync.test.ts b/lib/__tests__/theme-store-sync.test.ts index 824fc059f..4486c6409 100644 --- a/lib/__tests__/theme-store-sync.test.ts +++ b/lib/__tests__/theme-store-sync.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const serverThemes: { themes: unknown[] } = { themes: [] }; +const assets: Record = {}; vi.mock('@/lib/browser-navigation', async (orig) => ({ ...(await orig()), - apiFetch: vi.fn(async () => new Response(JSON.stringify({ plugins: [], ...serverThemes }))), + apiFetch: vi.fn(async (url: string) => { + const path = url.split('?')[0]; + if (path === '/api/plugins') return new Response(JSON.stringify({ plugins: [], ...serverThemes })); + return path in assets ? new Response(assets[path]) : new Response('missing', { status: 404 }); + }), })); vi.mock('@/lib/plugin-storage', () => ({ pluginStorage: { @@ -13,6 +18,7 @@ vi.mock('@/lib/plugin-storage', () => ({ deleteThemeSkin: vi.fn(async () => {}), deletePreview: vi.fn(async () => {}), getThemeSkin: vi.fn(async () => null), + saveThemeSkin: vi.fn(async () => {}), }, })); @@ -60,3 +66,31 @@ describe('server theme sync', () => { expect(useThemeStore.getState().installedThemes.some((t) => t.id === 'corp')).toBe(true); }); }); + +describe('server theme sync with skins', () => { + it('downloads the skin of a v2 theme', async () => { + useThemeStore.setState((s) => ({ installedThemes: s.installedThemes.filter((t) => t.builtIn), activeThemeId: null })); + serverThemes.themes = [{ id: 'ocean', name: 'Ocean', version: '1.0.0', author: 'a', description: '', variants: ['light'], forceEnabled: false, hasSkin: true, contentHash: 'h1' }]; + assets['/api/admin/themes/ocean/css'] = ':root{--color-primary:#0066cc}'; + assets['/api/admin/themes/ocean/skin'] = '.message-list{border-radius:8px}'; + + await useThemeStore.getState().syncServerThemes(); + + const ocean = useThemeStore.getState().installedThemes.find((t) => t.id === 'ocean'); + expect(ocean?.skin).toContain('border-radius'); + expect(pluginStorage.saveThemeSkin).toHaveBeenCalledWith('ocean', expect.stringContaining('border-radius')); + }); + + it('re-downloads a re-upload of the same version', async () => { + useThemeStore.setState((s) => ({ + installedThemes: [...s.installedThemes.filter((t) => t.builtIn), { ...CORP, id: 'ocean', forceEnabled: false, version: '1.0.0', contentHash: 'h1', css: ':root{--color-primary:red}' }], + activeThemeId: null, + })); + serverThemes.themes = [{ id: 'ocean', name: 'Ocean', version: '1.0.0', author: 'a', description: '', variants: ['light'], forceEnabled: false, hasSkin: false, contentHash: 'h2' }]; + assets['/api/admin/themes/ocean/css'] = ':root{--color-primary:blue}'; + + await useThemeStore.getState().syncServerThemes(); + + expect(useThemeStore.getState().installedThemes.find((t) => t.id === 'ocean')?.css).toContain('blue'); + }); +}); diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 1ec02385a..49bc072d7 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -102,6 +102,13 @@ export interface ServerTheme { forceEnabled?: boolean; installedAt: string; updatedAt: string; + /** True when a Theme API v2 skin.css is stored next to the CSS. */ + hasSkin?: boolean; + /** + * SHA-256 of the CSS and skin. Clients use it to notice a re-upload of + * the same version and as a cache key on the CSS and skin URLs. + */ + contentHash?: string; } interface PluginRegistry { @@ -274,6 +281,7 @@ export async function getTheme(id: string): Promise { export async function saveTheme( theme: ServerTheme, css: string, + skin: string | null = null, ): Promise { assertWritable('install theme'); if (!SAFE_ID_RE.test(theme.id)) { @@ -285,6 +293,17 @@ export async function saveTheme( // Save CSS file const cssPath = path.join(dir, `${theme.id}.css`); await writeFile(cssPath, css, 'utf-8'); + const skinPath = path.join(dir, `${theme.id}.skin.css`); + if (skin) { + await writeFile(skinPath, skin, 'utf-8'); + } else { + try { await unlink(skinPath); } catch { /* none stored */ } + } + theme = { + ...theme, + hasSkin: !!skin, + contentHash: createHash('sha256').update(css).update('\0').update(skin ?? '').digest('hex'), + }; // Update registry const registry = await getThemeRegistry(); @@ -317,13 +336,38 @@ export async function deleteTheme(id: string): Promise { registry.themes.splice(idx, 1); await writeJsonFile(themeRegistryPath(), registry); - // Remove CSS file - const cssPath = path.join(getThemesDir(), `${id}.css`); - try { await unlink(cssPath); } catch { /* ok if missing */ } + // Remove CSS and skin files + for (const file of [`${id}.css`, `${id}.skin.css`]) { + try { await unlink(path.join(getThemesDir(), file)); } catch { /* ok if missing */ } + } return true; } +export async function getThemeSkin(id: string): Promise { + if (!SAFE_ID_RE.test(id)) return null; + try { + return await readFile(path.join(getThemesDir(), `${id}.skin.css`), 'utf-8'); + } catch { + return null; + } +} + +/** + * CSS of every installed theme and every built-in theme, keyed by id, for + * resolving a new theme's `extends`. + */ +export async function themeCSSResolver(): Promise<(id: string) => string | null> { + const { BUILTIN_THEMES } = await import('@/lib/builtin-themes'); + const map = new Map(); + for (const t of BUILTIN_THEMES) if (t.css) map.set(t.id, t.css); + for (const t of (await getThemeRegistry()).themes) { + const css = await getThemeCSS(t.id); + if (css) map.set(t.id, css); + } + return (id) => map.get(id) ?? null; +} + export async function getThemeCSS(id: string): Promise { const cssPath = path.join(getThemesDir(), `${id}.css`); try { diff --git a/lib/admin/theme-asset.ts b/lib/admin/theme-asset.ts new file mode 100644 index 000000000..9dc700840 --- /dev/null +++ b/lib/admin/theme-asset.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { getThemeCSS, getThemeRegistry, getThemeSkin } from './plugin-registry'; +import { logger } from '@/lib/logger'; + +/** + * Serve an enabled theme's CSS or skin. Clients put the theme's content hash + * in the URL, so the response may be revalidated freely but never served + * stale after an update (it used to be cached publicly for an hour). + */ +export async function serveThemeAsset(id: string, kind: 'css' | 'skin'): Promise { + try { + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) { + return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 }); + } + const registry = await getThemeRegistry(); + const theme = registry.themes.find(t => t.id === id); + if (!theme) { + return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); + } + if (!theme.enabled) { + return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 }); + } + const body = kind === 'css' ? await getThemeCSS(id) : await getThemeSkin(id); + if (!body) { + return NextResponse.json({ error: kind === 'css' ? 'Theme CSS not found' : 'Theme has no skin' }, { status: 404 }); + } + return new NextResponse(body, { + headers: { + 'Content-Type': 'text/css; charset=utf-8', + 'Cache-Control': 'private, no-cache', + ...(theme.contentHash ? { ETag: `"${theme.contentHash}"` } : {}), + 'X-Content-Type-Options': 'nosniff', + }, + }); + } catch (error) { + logger.error('Theme asset serve error', { kind, error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/lib/extension-package.ts b/lib/extension-package.ts index c2c577771..895abafc2 100644 --- a/lib/extension-package.ts +++ b/lib/extension-package.ts @@ -16,7 +16,11 @@ import { type PluginTier, type PluginType, type SettingFieldSchema, + type ThemeManifest, + MAX_THEME_SKIN_BYTES, } from './plugin-types'; +import { sanitizeSkinCSS, sanitizeThemeCSS } from './theme-loader'; +import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler'; import { sanitizeApiPostPaths, sanitizeFrameOrigins, sanitizeHttpOrigins } from './extension-origins'; export const EXTENSION_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; @@ -506,3 +510,157 @@ export async function readPluginPackage( const warnings = [...check.warnings, ...suspiciousCode(code).map((l) => `Contains ${l} - review for security`)]; return { manifest: check.manifest, code, warnings, pkg }; } + +// ─── Themes ────────────────────────────────────────────────── + +function validateBaseManifest(manifest: Record): string[] { + const errors: string[] = []; + + if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); + if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); + if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); + if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); + if (!manifest.type || typeof manifest.type !== 'string') errors.push('Missing or invalid "type"'); + + // Validate ID format (alphanumeric + hyphens) + if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { + errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); + } + + return errors; +} + +export function checkThemeManifest(manifest: Record): { result: ThemeManifest | null; errors: string[] } { + const errors = validateBaseManifest(manifest); + + if (manifest.type !== 'theme') { + errors.push(`Expected type "theme", got "${manifest.type}"`); + } + + if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) { + errors.push('Missing or empty "variants" array (must be ["light"], ["dark"], or ["light","dark"])'); + } else { + const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark'); + if (!valid) errors.push('Variants must be "light" or "dark"'); + } + + // ── Theme API v2 fields (all optional) ── + if (manifest.apiVersion !== undefined && manifest.apiVersion !== 1 && manifest.apiVersion !== 2) { + errors.push('"apiVersion" must be 1 or 2 if present'); + } + if (manifest.extends !== undefined && typeof manifest.extends !== 'string') { + errors.push('"extends" must be a string (the parent theme id)'); + } + if (manifest.tokens !== undefined && (typeof manifest.tokens !== 'object' || manifest.tokens === null)) { + errors.push('"tokens" must be an object with optional "common"/"light"/"dark" maps'); + } + if (manifest.density !== undefined && !['compact', 'normal', 'touch'].includes(manifest.density as string)) { + errors.push('"density" must be "compact", "normal", or "touch"'); + } + if (manifest.derive !== undefined && typeof manifest.derive !== 'boolean') { + errors.push('"derive" must be a boolean'); + } + if (manifest.radii !== undefined && (typeof manifest.radii !== 'object' || manifest.radii === null)) { + errors.push('"radii" must be an object'); + } + if (manifest.typography !== undefined && (typeof manifest.typography !== 'object' || manifest.typography === null)) { + errors.push('"typography" must be an object'); + } + + if (errors.length > 0) return { result: null, errors }; + + return { + result: manifest as unknown as ThemeManifest, + errors: [], + }; +} + +export interface ThemePackageResult { + valid: boolean; + errors: string[]; + warnings: string[]; + manifest: ThemeManifest | null; + /** Compiled (Theme API v2) and sanitized CSS. */ + css: string; + /** Sanitized skin.css for v2 themes, else null. */ + skin: string | null; + pkg: OpenedPackage | null; +} + +/** + * Open, validate and compile a theme package. v1 themes need theme.css; v2 + * themes may be tokens only and may ship a skin.css. `resolveExtends` returns + * the CSS of an installed theme for `extends`. + */ +export async function readThemePackage( + data: ArrayBuffer | Uint8Array, + opts: { resolveExtends?: (id: string) => string | null } = {}, +): Promise { + const fail = (errors: string[], warnings: string[] = [], manifest: ThemeManifest | null = null, pkg: OpenedPackage | null = null): ThemePackageResult => + ({ valid: false, errors, warnings, manifest, css: '', skin: null, pkg }); + const errors: string[] = []; + const warnings: string[] = []; + + let pkg: OpenedPackage; + let manifestData: Record; + try { + pkg = await openPackage(data, 'theme'); + manifestData = await readManifest(pkg); + } catch (err) { + return fail([err instanceof ExtensionPackageError ? err.message : 'Invalid ZIP file']); + } + + const { result: manifest, errors: manifestErrors } = checkThemeManifest(manifestData); + if (!manifest) return fail(manifestErrors, warnings, null, pkg); + + // theme.css is required for v1 themes, optional for v2 ones, whose CSS the + // compiler can produce from the manifest alone. + const isAdvanced = isAdvancedManifest(manifest); + let cssText: string | null; + let skinText: string | null; + try { + cssText = await readText(pkg, 'theme.css'); + skinText = await readText(pkg, 'skin.css', MAX_THEME_SKIN_BYTES * 4); + } catch (err) { + return fail([err instanceof ExtensionPackageError ? err.message : 'Could not read theme files'], warnings, manifest, pkg); + } + + let userCSS = ''; + if (cssText !== null) { + // Sanitize instead of rejecting, and always: the sanitizer is also what + // drops rules outside :root and .dark. + const sanitized = sanitizeThemeCSS(cssText); + userCSS = sanitized.css; + warnings.push(...sanitized.warnings); + } else if (!isAdvanced) { + return fail(['Missing theme.css'], warnings, manifest, pkg); + } + + let css = userCSS; + if (isAdvanced) { + const compiled = compileAdvancedTheme(manifest, { userCSS, resolveExtends: opts.resolveExtends }); + if (compiled.errors.length > 0) return fail([...errors, ...compiled.errors], warnings, manifest, pkg); + warnings.push(...compiled.warnings); + // Defence in depth: sanitize the compiled output too. + const sanitized = sanitizeThemeCSS(compiled.css); + css = sanitized.css; + warnings.push(...sanitized.warnings); + } + + // Skins target real component selectors and skip the :root/.dark selector + // check; they still go through the dangerous-pattern sanitizer. + let skin: string | null = null; + if (skinText !== null) { + if (!isAdvanced) { + warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin'); + } else if (skinText.length > MAX_THEME_SKIN_BYTES) { + warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`); + } else { + const sanitized = sanitizeSkinCSS(skinText); + skin = sanitized.css; + warnings.push(...sanitized.warnings); + } + } + + return { valid: true, errors: [], warnings, manifest, css, skin, pkg }; +} diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index f46a40f0e..39695db07 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -209,6 +209,8 @@ export interface InstalledTheme { builtIn: boolean; managed?: boolean; forceEnabled?: boolean; + /** Server content hash of a managed theme's CSS + skin; see ServerTheme. */ + contentHash?: string; // ─── Advanced (Theme API v2) ─ carried over from the manifest ─ apiVersion?: 1 | 2; diff --git a/lib/plugin-validator.ts b/lib/plugin-validator.ts index 83bb7d06c..4082a04d2 100644 --- a/lib/plugin-validator.ts +++ b/lib/plugin-validator.ts @@ -1,20 +1,11 @@ // Plugin/Theme ZIP upload validation, extraction, and manifest parsing -import { - type ThemeManifest, - type PluginManifest, - MAX_THEME_SKIN_BYTES, -} from './plugin-types'; -import { sanitizeThemeCSS, sanitizeSkinCSS } from './theme-loader'; -import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler'; +import type { ThemeManifest, PluginManifest } from './plugin-types'; import { ExtensionPackageError, - openPackage, readEntry, - readManifest, readPluginPackage, - readText, - type OpenedPackage, + readThemePackage, } from './extension-package'; export interface ValidationResult { @@ -40,179 +31,25 @@ export interface PluginExtractionResult extends ValidationResult { preview: string | null; } -// ─── Manifest Validation ───────────────────────────────────── - -function validateBaseManifest(manifest: Record): string[] { - const errors: string[] = []; - - if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); - if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); - if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); - if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); - if (!manifest.type || typeof manifest.type !== 'string') errors.push('Missing or invalid "type"'); - - // Validate ID format (alphanumeric + hyphens) - if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { - errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); - } - - return errors; -} - -function validateThemeManifest(manifest: Record): { result: ThemeManifest | null; errors: string[] } { - const errors = validateBaseManifest(manifest); - - if (manifest.type !== 'theme') { - errors.push(`Expected type "theme", got "${manifest.type}"`); - } - - if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) { - errors.push('Missing or empty "variants" array (must be ["light"], ["dark"], or ["light","dark"])'); - } else { - const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark'); - if (!valid) errors.push('Variants must be "light" or "dark"'); - } - - // ── Theme API v2 fields (all optional) ── - if (manifest.apiVersion !== undefined && manifest.apiVersion !== 1 && manifest.apiVersion !== 2) { - errors.push('"apiVersion" must be 1 or 2 if present'); - } - if (manifest.extends !== undefined && typeof manifest.extends !== 'string') { - errors.push('"extends" must be a string (the parent theme id)'); - } - if (manifest.tokens !== undefined && (typeof manifest.tokens !== 'object' || manifest.tokens === null)) { - errors.push('"tokens" must be an object with optional "common"/"light"/"dark" maps'); - } - if (manifest.density !== undefined && !['compact', 'normal', 'touch'].includes(manifest.density as string)) { - errors.push('"density" must be "compact", "normal", or "touch"'); - } - if (manifest.derive !== undefined && typeof manifest.derive !== 'boolean') { - errors.push('"derive" must be a boolean'); - } - if (manifest.radii !== undefined && (typeof manifest.radii !== 'object' || manifest.radii === null)) { - errors.push('"radii" must be an object'); - } - if (manifest.typography !== undefined && (typeof manifest.typography !== 'object' || manifest.typography === null)) { - errors.push('"typography" must be an object'); - } - - if (errors.length > 0) return { result: null, errors }; - - return { - result: manifest as unknown as ThemeManifest, - errors: [], - }; -} - // ─── ZIP Extraction ────────────────────────────────────────── /** - * Extract and validate a theme ZIP file. + * Extract and validate a theme ZIP file. Same checks as the admin and + * marketplace installs (see extension-package.ts). */ export async function extractTheme(file: File): Promise { - const errors: string[] = []; - const warnings: string[] = []; - - let pkg: OpenedPackage; - let manifestData: Record; - try { - pkg = await openPackage(await file.arrayBuffer(), 'theme'); - manifestData = await readManifest(pkg); - } catch (err) { - const message = err instanceof ExtensionPackageError ? err.message : 'Invalid ZIP file'; - return { valid: false, errors: [message], warnings: [], manifest: null, css: '', skin: null, preview: null }; - } - - const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData); - errors.push(...manifestErrors); - if (!manifest) { - return { valid: false, errors, warnings, manifest: null, css: '', skin: null, preview: null }; - } - - // Read theme.css - required for v1 themes, optional when the manifest - // declares Theme API v2 fields (tokens/extends/derive/density/radii/typography), - // since the compiler can produce CSS purely from the manifest. - const isAdvanced = isAdvancedManifest(manifest); - let cssText: string | null; - let skinText: string | null; - try { - cssText = await readText(pkg, 'theme.css'); - skinText = await readText(pkg, 'skin.css', MAX_THEME_SKIN_BYTES * 4); - } catch (err) { - const message = err instanceof ExtensionPackageError ? err.message : 'Could not read theme files'; - return { valid: false, errors: [message], warnings, manifest, css: '', skin: null, preview: null }; - } - - let userCSS = ''; - if (cssText !== null) { - userCSS = cssText; - // Sanitize instead of rejecting, and always: the sanitizer is also what - // drops rules outside :root and .dark. - const sanitized = sanitizeThemeCSS(userCSS); - userCSS = sanitized.css; - warnings.push(...sanitized.warnings); - } else if (!isAdvanced) { - errors.push('Missing theme.css'); - return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null }; - } - - // Compile advanced tokens into CSS (for v2 manifests). The compiled output - // is concatenated with any user-supplied theme.css for fine-grained overrides. - let rawCSS = userCSS; - if (isAdvanced) { - const compiled = compileAdvancedTheme(manifest, { userCSS }); - if (compiled.errors.length > 0) { - errors.push(...compiled.errors); - return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null }; - } - warnings.push(...compiled.warnings); - rawCSS = compiled.css; - - // Run sanitizer over the final compiled output as a defence-in-depth check. - const sanitized = sanitizeThemeCSS(rawCSS); - rawCSS = sanitized.css; - warnings.push(...sanitized.warnings); - } - - // Read preview image if present + const result = await readThemePackage(await file.arrayBuffer()); + const { pkg, ...rest } = result; let preview: string | null = null; - if (manifest.preview) { + if (pkg && result.manifest?.preview) { try { - const bytes = await readEntry(pkg, manifest.preview, 512 * 1024); - if (bytes) preview = await blobToDataUri(new Blob([bytes as BlobPart], { type: previewType(manifest.preview) })); + const bytes = await readEntry(pkg, result.manifest.preview, 512 * 1024); + if (bytes) preview = await blobToDataUri(new Blob([bytes as BlobPart], { type: previewType(result.manifest.preview) })); } catch { - warnings.push('Could not read preview image'); + rest.warnings.push('Could not read preview image'); } } - - // Read skin.css if present (Theme API v2 only). Skins target real - // component selectors and bypass the strict :root/.dark selector check - - // they still go through the dangerous-pattern sanitizer. - let skin: string | null = null; - if (skinText !== null) { - if (!isAdvanced) { - warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin'); - } else { - const rawSkin = skinText; - if (rawSkin.length > MAX_THEME_SKIN_BYTES) { - warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`); - } else { - const sanitized = sanitizeSkinCSS(rawSkin); - skin = sanitized.css; - warnings.push(...sanitized.warnings); - } - } - } - - return { - valid: errors.length === 0, - errors, - warnings, - manifest, - css: rawCSS, - skin, - preview, - }; + return { ...rest, preview }; } /** diff --git a/stores/theme-store.ts b/stores/theme-store.ts index 3427dc5ea..d06df2822 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -6,6 +6,7 @@ import { injectThemeCSS, removeThemeCSS, sanitizeThemeCSS, + sanitizeSkinCSS, injectThemeSkinCSS, removeThemeSkinCSS, } from '@/lib/theme-loader'; @@ -353,10 +354,12 @@ export const useThemeStore = create()( if (!local) { // Download and install new server theme - const css = await downloadThemeCSS(st.id); + const css = await downloadThemeAsset(st, 'css'); if (!css) continue; + const skin = st.hasSkin ? await downloadThemeAsset(st, 'skin') : null; const sanitized = sanitizeThemeCSS(css); + const sanitizedSkin = skin ? sanitizeSkinCSS(skin).css : undefined; const theme: InstalledTheme = { id: st.id, name: st.name, @@ -369,9 +372,12 @@ export const useThemeStore = create()( builtIn: false, managed: true, forceEnabled: st.forceEnabled, + contentHash: st.contentHash, + ...(sanitizedSkin ? { skin: sanitizedSkin } : {}), }; await pluginStorage.saveThemeCSS(st.id, sanitized.css); + if (sanitizedSkin) await pluginStorage.saveThemeSkin(st.id, sanitizedSkin); set(state => { if (state.installedThemes.some(t => t.id === st.id)) { return {}; @@ -383,15 +389,25 @@ export const useThemeStore = create()( } else if (!local.builtIn) { let css = local.css; + let skin = local.skin; + // The hash catches a re-upload of the same version. + const changed = local.version !== st.version + || (!!st.contentHash && local.contentHash !== st.contentHash); - if (local.version !== st.version || !css) { - const downloadedCss = await downloadThemeCSS(st.id); + if (changed || !css) { + const downloadedCss = await downloadThemeAsset(st, 'css'); if (!downloadedCss) continue; const sanitized = sanitizeThemeCSS(downloadedCss); css = sanitized.css; await pluginStorage.saveThemeCSS(st.id, sanitized.css); } + if (changed) { + const downloadedSkin = st.hasSkin ? await downloadThemeAsset(st, 'skin') : null; + skin = downloadedSkin ? sanitizeSkinCSS(downloadedSkin).css : undefined; + if (skin) await pluginStorage.saveThemeSkin(st.id, skin); + else await pluginStorage.deleteThemeSkin(st.id); + } const updatedTheme = { ...local, @@ -400,9 +416,11 @@ export const useThemeStore = create()( author: st.author, description: st.description || '', css, + skin, variants: st.variants as ThemeVariant[], managed: true, forceEnabled: st.forceEnabled, + contentHash: st.contentHash, }; set(state => ({ @@ -587,6 +605,8 @@ interface ServerThemeInfo { description: string; variants: string[]; forceEnabled: boolean; + hasSkin?: boolean; + contentHash?: string; } function dedupeInstalledThemes(themes: InstalledTheme[]): InstalledTheme[] { @@ -612,13 +632,16 @@ function dedupeInstalledThemes(themes: InstalledTheme[]): InstalledTheme[] { return [...byId.values()]; } -async function downloadThemeCSS(themeId: string): Promise { +async function downloadThemeAsset(theme: ServerThemeInfo, kind: 'css' | 'skin'): Promise { + // The content hash in the URL keeps a browser cache from serving the + // previous upload. + const version = theme.contentHash ? `?v=${encodeURIComponent(theme.contentHash)}` : ''; try { - const res = await apiFetch(`/api/admin/themes/${encodeURIComponent(themeId)}/css`); + const res = await apiFetch(`/api/admin/themes/${encodeURIComponent(theme.id)}/${kind}${version}`); if (!res.ok) return null; return await res.text(); } catch { - console.warn(`[theme-store] Failed to download CSS for theme "${themeId}"`); + console.warn(`[theme-store] Failed to download ${kind} for theme "${theme.id}"`); return null; } } \ No newline at end of file