import { lookup } from "node:dns/promises"; import { readFile, realpath, stat } from "node:fs/promises"; import { isIP } from "node:net"; import path from "node:path"; import { extractMarkdownImageSources } from "@md-to-pdf/renderer"; export const MAXIMUM_IMAGE_COUNT = 50; export const MAXIMUM_IMAGE_BYTES = 8 * 1024 * 1024; export const MAXIMUM_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024; export const REMOTE_IMAGE_TIMEOUT_MS = 10_000; export const MAXIMUM_REMOTE_REDIRECTS = 3; const failedImageDataUrl = `data:image/svg+xml;base64,${Buffer.from( `图片加载失败` ).toString("base64")}`; export interface MarkdownImageResource { path: string; contentType?: string; data: string; } export interface ImageResolutionContext { localRoot?: string; } export interface ResolvedMarkdownImages { sources: ReadonlyMap; warnings: string[]; } export interface ImageResourceResolverOptions { remoteLoader?: (url: string) => Promise; } export interface LoadedImage { content: Buffer; contentType: string; } function isPrivateIpv4( address: string, allowProxyBenchmarkRange = false ) { const parts = address.split(".").map(Number); const [first, second] = parts; if ( parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) || first === undefined || second === undefined ) { return true; } return ( first === 0 || first === 10 || first === 127 || (first === 100 && second >= 64 && second <= 127) || (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) || (!allowProxyBenchmarkRange && first === 198 && (second === 18 || second === 19)) || first >= 224 ); } function isPrivateIpAddress( address: string, allowProxyBenchmarkRange = false ) { const normalized = address.toLowerCase(); if (isIP(normalized) === 4) { return isPrivateIpv4(normalized, allowProxyBenchmarkRange); } if (isIP(normalized) !== 6) { return true; } if ( normalized === "::" || normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || /^fe[89ab]/u.test(normalized) ) { return true; } const mapped = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/u)?.[1]; return mapped ? isPrivateIpv4(mapped, allowProxyBenchmarkRange) : false; } async function assertPublicRemoteUrl(url: URL) { if (!["http:", "https:"].includes(url.protocol)) { throw new Error("仅支持 HTTP/HTTPS 图片"); } if ( url.username || url.password || url.hostname.toLowerCase() === "localhost" ) { throw new Error("图片地址包含不允许的认证或本地主机"); } if (isIP(url.hostname)) { if (isPrivateIpAddress(url.hostname)) { throw new Error("图片地址指向非公网 IP"); } return; } const addresses = await lookup(url.hostname, { all: true, verbatim: true }); if ( addresses.length === 0 || addresses.some(({ address }) => isPrivateIpAddress(address, true)) ) { throw new Error("图片域名解析到非公网 IP"); } } function detectImageContentType(content: Buffer) { if ( content.length >= 8 && content.subarray(0, 8).equals( Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) ) ) { return "image/png"; } if ( content.length >= 3 && content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff ) { return "image/jpeg"; } const prefix = content.subarray(0, 12).toString("ascii"); if (prefix.startsWith("GIF87a") || prefix.startsWith("GIF89a")) { return "image/gif"; } if (prefix.startsWith("RIFF") && prefix.endsWith("WEBP")) { return "image/webp"; } if ( content.length >= 12 && content.subarray(4, 12).toString("ascii").startsWith("ftyp") && /(?:avif|avis)/u.test(content.subarray(8, 32).toString("ascii")) ) { return "image/avif"; } const textPrefix = content.subarray(0, Math.min(content.length, 4096)) .toString("utf8") .replace(/^\uFEFF/u, "") .trimStart(); if ( /^(?:<\?xml[\s\S]*?\?>\s*)?]/iu.test(textPrefix) ) { const svg = content.toString("utf8"); if ( / MAXIMUM_IMAGE_BYTES ) { throw new Error("远程图片超过单文件大小限制"); } if (!response.body) { throw new Error("远程图片响应为空"); } const chunks: Buffer[] = []; let total = 0; const reader = response.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) { break; } total += value.byteLength; if (total > MAXIMUM_IMAGE_BYTES) { await reader.cancel(); throw new Error("远程图片超过单文件大小限制"); } chunks.push(Buffer.from(value)); } return Buffer.concat(chunks, total); } export async function downloadRemoteImage( source: string, fetcher: typeof fetch = fetch ): Promise { const controller = new AbortController(); const timeout = setTimeout( () => controller.abort(), REMOTE_IMAGE_TIMEOUT_MS ); try { let current = new URL(source); for ( let redirectCount = 0; redirectCount <= MAXIMUM_REMOTE_REDIRECTS; redirectCount += 1 ) { await assertPublicRemoteUrl(current); const response = await fetcher(current, { redirect: "manual", signal: controller.signal, headers: { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml", "user-agent": "md-to-pdf/0.4.1" } }); if ( response.status >= 300 && response.status < 400 && response.headers.has("location") ) { if (redirectCount === MAXIMUM_REMOTE_REDIRECTS) { throw new Error("远程图片重定向次数过多"); } current = new URL(response.headers.get("location")!, current); continue; } if (!response.ok) { throw new Error(`远程图片返回 HTTP ${response.status}`); } const content = await readResponseBody(response); return { content, contentType: detectImageContentType(content) }; } throw new Error("远程图片重定向次数过多"); } finally { clearTimeout(timeout); } } export function normalizeDocumentAssetPath(source: string) { const pathOnly = source.split(/[?#]/u, 1)[0] ?? ""; let decoded: string; try { decoded = decodeURIComponent(pathOnly); } catch { throw new Error("图片路径 URL 编码无效"); } const normalizedSeparators = decoded.replace(/\\/gu, "/"); if ( normalizedSeparators.startsWith("/") || /^[a-z]:\//iu.test(normalizedSeparators) || normalizedSeparators.includes("\0") ) { throw new Error("不允许绝对图片路径"); } const segments = normalizedSeparators .split("/") .filter((segment) => segment && segment !== "."); if (segments.length === 0 || segments.includes("..")) { throw new Error("图片路径为空或越过文档目录"); } return segments.join("/"); } function isContainedPath(root: string, target: string) { const relative = path.relative(root, target); return ( relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ); } async function loadLocalImage(root: string, source: string) { const normalized = normalizeDocumentAssetPath(source); const realRoot = await realpath(root); const target = path.resolve(realRoot, ...normalized.split("/")); const realTarget = await realpath(target); if (!isContainedPath(realRoot, realTarget)) { throw new Error("图片路径越过文档目录"); } const targetStat = await stat(realTarget); if (!targetStat.isFile()) { throw new Error("图片资源不是文件"); } if (targetStat.size > MAXIMUM_IMAGE_BYTES) { throw new Error("本地图片超过单文件大小限制"); } const content = await readFile(realTarget); return { content, contentType: detectImageContentType(content) }; } function parseUploadedResources(value: unknown) { if (value === undefined) { return new Map(); } if (!Array.isArray(value)) { throw new Error("图片素材必须是数组"); } if (value.length > MAXIMUM_IMAGE_COUNT) { throw new Error(`图片数量不能超过 ${MAXIMUM_IMAGE_COUNT}`); } const result = new Map(); let totalBytes = 0; for (const item of value) { if ( typeof item !== "object" || item === null || !("path" in item) || !("data" in item) || typeof item.path !== "string" || typeof item.data !== "string" || item.data.length > Math.ceil(MAXIMUM_IMAGE_BYTES * 4 / 3) + 16 ) { throw new Error("图片素材参数无效"); } const normalized = normalizeDocumentAssetPath(item.path); if (result.has(normalized)) { throw new Error(`图片素材路径重复:${normalized}`); } const content = Buffer.from(item.data, "base64"); if (content.length === 0 || content.length > MAXIMUM_IMAGE_BYTES) { throw new Error(`图片素材大小无效:${normalized}`); } totalBytes += content.length; if (totalBytes > MAXIMUM_TOTAL_IMAGE_BYTES) { throw new Error("图片素材总大小超过限制"); } result.set(normalized, { content, contentType: detectImageContentType(content) }); } return result; } export function createImageResourceResolver( options: ImageResourceResolverOptions = {} ) { const remoteLoader = options.remoteLoader ?? downloadRemoteImage; const remoteCache = new Map< string, { expiresAt: number; promise: Promise } >(); function loadRemote(source: string) { const cached = remoteCache.get(source); if (cached && cached.expiresAt > Date.now()) { return cached.promise; } const promise = remoteLoader(source); remoteCache.set(source, { expiresAt: Date.now() + 5 * 60_000, promise }); if (remoteCache.size > 100) { const oldest = remoteCache.keys().next().value as string | undefined; if (oldest) { remoteCache.delete(oldest); } } void promise.catch(() => { if (remoteCache.get(source)?.promise === promise) { remoteCache.delete(source); } }); return promise; } return async function resolve( markdown: string, resources: unknown, context: ImageResolutionContext = {} ): Promise { const references = extractMarkdownImageSources(markdown); if (references.length > MAXIMUM_IMAGE_COUNT) { throw new Error(`图片引用数量不能超过 ${MAXIMUM_IMAGE_COUNT}`); } const uploaded = parseUploadedResources(resources); const sources = new Map(); const warnings: string[] = []; let resolvedBytes = 0; for (const source of references) { if (source.startsWith("data:")) { continue; } try { let image: LoadedImage; if (/^https?:\/\//iu.test(source)) { image = await loadRemote(source); } else { const normalized = normalizeDocumentAssetPath(source); const uploadedImage = uploaded.get(normalized); if (uploadedImage) { image = uploadedImage; } else if (context.localRoot) { image = await loadLocalImage(context.localRoot, source); } else { throw new Error("未提供对应素材目录"); } } resolvedBytes += image.content.length; if (resolvedBytes > MAXIMUM_TOTAL_IMAGE_BYTES) { throw new Error("文档图片总大小超过限制"); } sources.set(source, imageToDataUrl(image)); } catch (error) { const message = error instanceof Error ? error.message : "未知错误"; warnings.push(`图片 ${source} 加载失败:${message}`); sources.set(source, failedImageDataUrl); } } return { sources, warnings }; }; }