mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
145 lines
5.7 KiB
TypeScript
145 lines
5.7 KiB
TypeScript
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<string, unknown>;
|
||
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<string, unknown>;
|
||
} 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 <style>, or define @keyframes not namespaced as \"" + idForCss + "-…\"",
|
||
);
|
||
return { ok: false, errors };
|
||
}
|
||
|
||
if (errors.length) return { ok: false, errors };
|
||
|
||
return {
|
||
ok: true,
|
||
theme: {
|
||
id: id as string,
|
||
name: name as string,
|
||
author: author as string,
|
||
version: version as string,
|
||
description: description as string,
|
||
mode: mode as 'dark' | 'light',
|
||
...(Array.isArray(m.tags) ? { tags: m.tags as string[] } : {}),
|
||
css,
|
||
},
|
||
};
|
||
}
|