Files
MorphDoc/apps/server/src/theme-registry.ts
T

247 lines
6.1 KiB
TypeScript

import {
readdir,
readFile,
realpath,
stat
} from "node:fs/promises";
import {
extname,
isAbsolute,
relative,
resolve,
sep
} from "node:path";
import {
themeManifestSchema,
type ThemeManifest
} from "@md-to-pdf/core";
export interface ThemeRecord {
manifest: ThemeManifest;
directory: string;
source: "bundled" | "local";
}
export interface ThemeRegistryOptions {
bundledRoot: string;
localRoot: string;
}
const assetContentTypes: Record<string, string> = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".ttf": "font/ttf",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2"
};
function isMissingFileError(error: unknown) {
return (
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "ENOENT"
);
}
function isInsideDirectory(directory: string, path: string) {
const pathFromDirectory = relative(directory, path);
return (
pathFromDirectory === "" ||
(!pathFromDirectory.startsWith(`..${sep}`) &&
pathFromDirectory !== ".." &&
!isAbsolute(pathFromDirectory))
);
}
async function resolveThemeFile(directory: string, relativePath: string) {
if (
isAbsolute(relativePath) ||
relativePath.includes("\0") ||
relativePath.split(/[\\/]/).includes("..")
) {
throw new Error("主题资源路径不安全");
}
const realDirectory = await realpath(directory);
const candidate = resolve(realDirectory, relativePath);
if (!isInsideDirectory(realDirectory, candidate)) {
throw new Error("主题资源路径越界");
}
const realCandidate = await realpath(candidate);
if (!isInsideDirectory(realDirectory, realCandidate)) {
throw new Error("主题资源符号链接越界");
}
const fileStat = await stat(realCandidate);
if (!fileStat.isFile()) {
throw new Error("主题资源不是文件");
}
return realCandidate;
}
async function readThemeRoot(
root: string,
source: ThemeRecord["source"]
): Promise<ThemeRecord[]> {
let entries;
try {
entries = await readdir(root, { withFileTypes: true });
} catch (error) {
if (isMissingFileError(error) && source === "local") {
return [];
}
throw error;
}
const records: ThemeRecord[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const directory = resolve(root, entry.name);
try {
const manifestPath = await resolveThemeFile(directory, "theme.json");
const manifest = themeManifestSchema.parse(
JSON.parse(await readFile(manifestPath, "utf8"))
);
if (manifest.id !== entry.name) {
throw new Error("主题目录名必须与主题 ID 一致");
}
if (manifest.bundled !== (source === "bundled")) {
throw new Error("主题 bundled 标记与来源不一致");
}
await resolveThemeFile(directory, manifest.entry);
if (manifest.print) {
await resolveThemeFile(directory, manifest.print);
}
records.push({
manifest,
directory,
source
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`主题 ${entry.name} 无效:${message}`);
}
}
return records;
}
function encodeAssetPath(path: string) {
return path
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
}
function prepareThemeCss(css: string, themeId: string) {
const withoutTyporaExportIncludes = css.replace(
/^\s*@include-when-export\s+url\([^;\r\n]+;\s*$/gim,
""
);
return withoutTyporaExportIncludes.replace(
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
(match, _quote: string, rawValue: string) => {
const value = rawValue.trim();
if (value.startsWith("data:") || value.startsWith("#")) {
return match;
}
if (
value.startsWith("/") ||
value.startsWith("//") ||
/^[a-z][a-z0-9+.-]*:/i.test(value)
) {
throw new Error(`主题包含不允许的外部资源:${value}`);
}
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
if (normalized.split("/").includes("..")) {
throw new Error(`主题资源路径不安全:${value}`);
}
return `url("/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(normalized)}")`;
}
);
}
export function createThemeRegistry(options: ThemeRegistryOptions) {
async function list() {
const [bundledThemes, localThemes] = await Promise.all([
readThemeRoot(options.bundledRoot, "bundled"),
readThemeRoot(options.localRoot, "local")
]);
const themes = [...bundledThemes, ...localThemes];
const themeIds = new Set<string>();
for (const theme of themes) {
if (themeIds.has(theme.manifest.id)) {
throw new Error(`主题 ID 重复:${theme.manifest.id}`);
}
themeIds.add(theme.manifest.id);
}
return themes;
}
async function get(themeId: string) {
return (await list()).find((theme) => theme.manifest.id === themeId);
}
async function getCss(themeId: string) {
const theme = await get(themeId);
if (!theme) {
return undefined;
}
const cssFiles = [
await resolveThemeFile(theme.directory, theme.manifest.entry),
...(theme.manifest.print
? [await resolveThemeFile(theme.directory, theme.manifest.print)]
: [])
];
const css = (
await Promise.all(cssFiles.map((path) => readFile(path, "utf8")))
).join("\n");
return prepareThemeCss(css, themeId);
}
async function getAsset(themeId: string, assetPath: string) {
const theme = await get(themeId);
if (!theme) {
return undefined;
}
const extension = extname(assetPath).toLowerCase();
const contentType = assetContentTypes[extension];
if (!contentType) {
throw new Error("不支持的主题资源类型");
}
const path = await resolveThemeFile(theme.directory, assetPath);
return {
contentType,
content: await readFile(path)
};
}
return {
get,
getAsset,
getCss,
list
};
}