Files
MorphDoc/packages/application/src/image-resources.ts
T

441 lines
13 KiB
TypeScript

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(
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="120" viewBox="0 0 640 120"><rect width="640" height="120" fill="#f8fafc"/><rect x="1" y="1" width="638" height="118" fill="none" stroke="#cbd5e1"/><text x="320" y="67" text-anchor="middle" font-family="sans-serif" font-size="18" fill="#64748b">图片加载失败</text></svg>`
).toString("base64")}`;
export interface MarkdownImageResource {
path: string;
contentType?: string;
data: string;
}
export interface ImageResolutionContext {
localRoot?: string;
}
export interface ResolvedMarkdownImages {
sources: ReadonlyMap<string, string>;
warnings: string[];
}
export interface ImageResourceResolverOptions {
remoteLoader?: (url: string) => Promise<LoadedImage>;
}
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*)?<svg[\s>]/iu.test(textPrefix)
) {
const svg = content.toString("utf8");
if (
/<script\b|<foreignObject\b|\son\w+\s*=|(?:href|src)\s*=\s*["'](?:https?:|\/\/)/iu.test(
svg
)
) {
throw new Error("SVG 包含不安全的脚本或外部资源");
}
return "image/svg+xml";
}
throw new Error("资源不是受支持的图片格式");
}
function imageToDataUrl(image: LoadedImage) {
return `data:${image.contentType};base64,${image.content.toString("base64")}`;
}
async function readResponseBody(response: Response) {
const declaredLength = Number(response.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > 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<LoadedImage> {
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<string, LoadedImage>();
}
if (!Array.isArray(value)) {
throw new Error("图片素材必须是数组");
}
if (value.length > MAXIMUM_IMAGE_COUNT) {
throw new Error(`图片数量不能超过 ${MAXIMUM_IMAGE_COUNT}`);
}
const result = new Map<string, LoadedImage>();
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<LoadedImage> }
>();
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<ResolvedMarkdownImages> {
const references = extractMarkdownImageSources(markdown);
if (references.length > MAXIMUM_IMAGE_COUNT) {
throw new Error(`图片引用数量不能超过 ${MAXIMUM_IMAGE_COUNT}`);
}
const uploaded = parseUploadedResources(resources);
const sources = new Map<string, string>();
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 };
};
}