mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
151 lines
4.7 KiB
TypeScript
151 lines
4.7 KiB
TypeScript
/**
|
|
* Checks a theme's CSS before core serves it.
|
|
*
|
|
* A theme is colors and styles only, so its file may hold nothing but rules
|
|
* whose every selector starts with the theme's own class (`.nord`,
|
|
* `:root.nord`, `html.nord ...`), optionally inside @media or @supports.
|
|
* Anything that could load from elsewhere or run code is refused: @import,
|
|
* url() to another host, other at-rules, expression() and javascript:.
|
|
*/
|
|
|
|
const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]);
|
|
|
|
function stripComments(css: string): string {
|
|
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
}
|
|
|
|
/** Splits on commas that are not inside parentheses or brackets. */
|
|
function splitSelectors(prelude: string): string[] {
|
|
const parts: string[] = [];
|
|
let depth = 0;
|
|
let current = "";
|
|
for (const ch of prelude) {
|
|
if (ch === "(" || ch === "[") depth += 1;
|
|
if (ch === ")" || ch === "]") depth -= 1;
|
|
if (ch === "," && depth === 0) {
|
|
parts.push(current);
|
|
current = "";
|
|
continue;
|
|
}
|
|
current += ch;
|
|
}
|
|
parts.push(current);
|
|
return parts.map((part) => part.trim());
|
|
}
|
|
|
|
function withoutStrings(css: string): string {
|
|
return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""');
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
/** Index of the brace closing the one at `open`, skipping strings. */
|
|
function matchingBrace(css: string, open: number): number {
|
|
let depth = 0;
|
|
let quote: string | null = null;
|
|
for (let i = open; i < css.length; i += 1) {
|
|
const ch = css[i];
|
|
if (quote) {
|
|
if (ch === "\\") i += 1;
|
|
else if (ch === quote) quote = null;
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'") quote = ch;
|
|
else if (ch === "{") depth += 1;
|
|
else if (ch === "}") {
|
|
depth -= 1;
|
|
if (depth === 0) return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function checkUrls(css: string, errors: string[]): void {
|
|
const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi);
|
|
for (const match of urls) {
|
|
const target = match[2].trim();
|
|
if (target.startsWith("data:")) continue;
|
|
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) {
|
|
errors.push(`url(${target}) points outside the plugin`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkRules(
|
|
css: string,
|
|
scope: RegExp,
|
|
themeId: string,
|
|
errors: string[],
|
|
): void {
|
|
let index = 0;
|
|
while (index < css.length) {
|
|
const rest = css.slice(index);
|
|
if (rest.trim() === "") return;
|
|
const open = css.indexOf("{", index);
|
|
const semicolon = css.indexOf(";", index);
|
|
if (semicolon !== -1 && (open === -1 || semicolon < open)) {
|
|
const statement = css.slice(index, semicolon).trim();
|
|
errors.push(
|
|
statement.startsWith("@")
|
|
? `${statement.split(/\s/)[0]} is not allowed in a theme`
|
|
: `Unexpected "${statement}" outside a rule`,
|
|
);
|
|
index = semicolon + 1;
|
|
continue;
|
|
}
|
|
if (open === -1) {
|
|
errors.push(`Unexpected "${rest.trim()}" at the end`);
|
|
return;
|
|
}
|
|
const close = matchingBrace(css, open);
|
|
if (close === -1) {
|
|
errors.push("A rule is missing its closing brace");
|
|
return;
|
|
}
|
|
const prelude = css.slice(index, open).trim();
|
|
const body = css.slice(open + 1, close);
|
|
|
|
if (prelude.startsWith("@")) {
|
|
const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
|
|
if (ALLOWED_BLOCK_AT_RULES.has(name)) {
|
|
checkRules(body, scope, themeId, errors);
|
|
} else {
|
|
errors.push(`@${name} is not allowed in a theme`);
|
|
}
|
|
} else {
|
|
for (const selector of splitSelectors(prelude)) {
|
|
if (!scope.test(selector)) {
|
|
errors.push(
|
|
`Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`,
|
|
);
|
|
}
|
|
}
|
|
if (withoutStrings(body).includes("{")) {
|
|
errors.push(`Nested rules are not allowed in "${prelude}"`);
|
|
}
|
|
}
|
|
index = close + 1;
|
|
}
|
|
}
|
|
|
|
/** Every problem with a theme's CSS, empty when it is safe to serve. */
|
|
export function validateThemeCss(css: string, themeId: string): string[] {
|
|
const errors: string[] = [];
|
|
const clean = stripComments(css);
|
|
if (/@import\b/i.test(clean))
|
|
errors.push("@import is not allowed in a theme");
|
|
if (/expression\s*\(/i.test(clean))
|
|
errors.push("expression() is not allowed in a theme");
|
|
if (/javascript:/i.test(clean))
|
|
errors.push("javascript: is not allowed in a theme");
|
|
if (/-moz-binding|behavior\s*:/i.test(clean))
|
|
errors.push("Script bindings are not allowed in a theme");
|
|
checkUrls(clean, errors);
|
|
const id = escapeRegExp(themeId);
|
|
const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`);
|
|
checkRules(clean, scope, themeId, errors);
|
|
return [...new Set(errors)];
|
|
}
|