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 = { ".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 { 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(); 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 }; }