fix: install Theme API v2 themes and their skins from the admin page and the marketplace

This commit is contained in:
Linus Rath
2026-10-14 01:47:58 +02:00
parent e57b7a2c18
commit e9f87c7970
13 changed files with 394 additions and 322 deletions
+14 -37
View File
@@ -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<string> {
@@ -151,59 +149,38 @@ export async function POST(request: NextRequest) {
const now = new Date().toISOString();
if (type === 'theme') {
let pkg;
let manifest: Record<string, unknown>;
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',
{
+5 -38
View File
@@ -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');
}
+13
View File
@@ -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');
}
+24 -1
View File
@@ -19,9 +19,11 @@ vi.mock('@/lib/admin/config-manager', () => ({
setPolicy: vi.fn(async (p: Record<string, unknown>) => { policy = p; }),
},
}));
const saved: Array<{ theme: Record<string, unknown>; 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<string, unknown>, 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');
});
});
+15 -62
View File
@@ -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<string, unknown>;
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 });
+2
View File
@@ -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(
+35 -1
View File
@@ -1,9 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const serverThemes: { themes: unknown[] } = { themes: [] };
const assets: Record<string, string> = {};
vi.mock('@/lib/browser-navigation', async (orig) => ({
...(await orig<typeof import('@/lib/browser-navigation')>()),
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');
});
});
+47 -3
View File
@@ -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<ServerTheme | null> {
export async function saveTheme(
theme: ServerTheme,
css: string,
skin: string | null = null,
): Promise<void> {
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<boolean> {
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<string | null> {
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<string, string>();
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<string | null> {
const cssPath = path.join(getThemesDir(), `${id}.css`);
try {
+39
View File
@@ -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<NextResponse> {
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 });
}
}
+158
View File
@@ -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, unknown>): 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<string, unknown>): { 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<ThemePackageResult> {
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<string, unknown>;
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 };
}
+2
View File
@@ -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;
+11 -174
View File
@@ -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, unknown>): 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<string, unknown>): { 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<ThemeExtractionResult> {
const errors: string[] = [];
const warnings: string[] = [];
let pkg: OpenedPackage;
let manifestData: Record<string, unknown>;
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 };
}
/**
+29 -6
View File
@@ -6,6 +6,7 @@ import {
injectThemeCSS,
removeThemeCSS,
sanitizeThemeCSS,
sanitizeSkinCSS,
injectThemeSkinCSS,
removeThemeSkinCSS,
} from '@/lib/theme-loader';
@@ -353,10 +354,12 @@ export const useThemeStore = create<ThemeState>()(
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<ThemeState>()(
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<ThemeState>()(
} 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<ThemeState>()(
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<string | null> {
async function downloadThemeAsset(theme: ServerThemeInfo, kind: 'css' | 'skin'): Promise<string | null> {
// 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;
}
}