import { validateThemeCss } from './themeInjection'; import { FIXED_THEMES } from '@/utils/themes/fixedThemes'; /** * Validation for a locally imported theme package (a .zip holding manifest.json * + theme.css). Community themes are free-form, so this enforces two things: * * 1. the **manifest** is well-formed (the same field rules as the repo schema), * and its id doesn't collide with a built-in theme, and * 2. the CSS passes the in-app **security floor** (`validateThemeCss`) — no * network/scripts/breakout, data:-only `url()`, id-namespaced `@keyframes`. * * Quality, structure, animations and taste are deliberately NOT checked here: * store themes are vetted by maintainers, and sideloaded themes are installed * at the user's own risk (the import UI says so). The floor is the safety line. */ // Field patterns copied verbatim from the repo's schema/manifest.schema.json. const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; const AUTHOR_RE = /^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$/; const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; const APP_VER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; const TAG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; const MANIFEST_KEYS = new Set([ 'id', 'name', 'author', 'version', 'description', 'mode', 'tags', 'minAppVersion', ]); const BUILTIN_IDS = new Set(FIXED_THEMES.map((f) => f.id)); export interface ValidatedTheme { id: string; name: string; author: string; version: string; description: string; mode: 'dark' | 'light'; tags?: string[]; css: string; } export type ValidateResult = | { ok: true; theme: ValidatedTheme } | { ok: false; errors: string[] }; export function validateThemePackage(manifestText: string, css: string): ValidateResult { const errors: string[] = []; // ---- manifest ---- let m: Record; try { const parsed = JSON.parse(manifestText); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { return { ok: false, errors: ['manifest.json must be a JSON object'] }; } m = parsed as Record; } catch (e) { return { ok: false, errors: [`manifest.json is not valid JSON: ${(e as Error).message}`] }; } for (const k of Object.keys(m)) { if (!MANIFEST_KEYS.has(k)) errors.push(`manifest has an unknown property "${k}"`); } const str = (k: string): string | null => (typeof m[k] === 'string' ? (m[k] as string) : null); const id = str('id'); if (id === null) errors.push('manifest.id is required and must be a string'); else { if (!ID_RE.test(id) || id.length < 2 || id.length > 48) { errors.push('manifest.id must be lowercase kebab-case, 2–48 chars'); } if (BUILTIN_IDS.has(id)) errors.push(`manifest.id "${id}" collides with a built-in theme`); } const name = str('name'); if (name === null) errors.push('manifest.name is required and must be a string'); else if (name.length < 1 || name.length > 50) errors.push('manifest.name must be 1–50 chars'); const author = str('author'); if (author === null) errors.push('manifest.author is required and must be a string'); else if (!AUTHOR_RE.test(author)) errors.push('manifest.author must be a GitHub handle (no leading @)'); const version = str('version'); if (version === null) errors.push('manifest.version is required and must be a string'); else if (!SEMVER_RE.test(version)) errors.push('manifest.version must be a SemVer string (e.g. 1.0.0)'); const description = str('description'); if (description === null) errors.push('manifest.description is required and must be a string'); else if (description.length < 1 || description.length > 200) errors.push('manifest.description must be 1–200 chars'); const mode = str('mode'); if (mode === null) errors.push('manifest.mode is required and must be a string'); else if (mode !== 'dark' && mode !== 'light') errors.push('manifest.mode must be "dark" or "light"'); if (m.tags !== undefined) { const tags = m.tags; if (!Array.isArray(tags)) errors.push('manifest.tags must be an array'); else { if (tags.length > 8) errors.push('manifest.tags allows at most 8 items'); if (new Set(tags).size !== tags.length) errors.push('manifest.tags must be unique'); for (const tag of tags) { if (typeof tag !== 'string' || !TAG_RE.test(tag) || tag.length > 24) { errors.push(`manifest.tags has an invalid tag: ${JSON.stringify(tag)}`); } } } } if (m.minAppVersion !== undefined) { if (typeof m.minAppVersion !== 'string' || !APP_VER_RE.test(m.minAppVersion)) { errors.push('manifest.minAppVersion must be a version like 1.2.3'); } } // ---- css security floor ---- // Needs a valid id (the @keyframes namespace check is keyed on it). const idForCss = id && ID_RE.test(id) ? id : null; if (idForCss === null) { errors.push('theme.css cannot be validated until manifest.id is valid'); return { ok: false, errors }; } if (validateThemeCss(css, idForCss) == null) { errors.push( "theme.css failed the safety check — it may exceed the size limit, reach the network (only data: url() is allowed), use @import / @property / scripts, break out of its