65 lines
1.9 KiB
TypeScript
65 lines
1.9 KiB
TypeScript
import { readFile, realpath } from "node:fs/promises";
|
|
import { resolve, sep } from "node:path";
|
|
|
|
const LOCAL_UPLOAD_URL_PREFIX = "/uploads/local/";
|
|
|
|
function isLoopbackHostname(hostname: string) {
|
|
const normalized = hostname.toLowerCase();
|
|
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
|
}
|
|
|
|
export function resolveTrustedLocalImageCandidate(
|
|
imageInput: string,
|
|
workspaceRoot = process.cwd(),
|
|
) {
|
|
const raw = String(imageInput || "").trim();
|
|
if (!raw) return null;
|
|
|
|
let pathname = "";
|
|
if (raw.startsWith("/")) {
|
|
pathname = new URL(raw, "http://localhost").pathname;
|
|
} else {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!["http:", "https:"].includes(url.protocol) || !isLoopbackHostname(url.hostname)) {
|
|
return null;
|
|
}
|
|
pathname = url.pathname;
|
|
}
|
|
|
|
let decodedPathname: string;
|
|
try {
|
|
decodedPathname = decodeURIComponent(pathname);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!decodedPathname.startsWith(LOCAL_UPLOAD_URL_PREFIX)) return null;
|
|
|
|
const uploadRoot = resolve(workspaceRoot, "public", "uploads", "local");
|
|
const relativePath = decodedPathname.slice(LOCAL_UPLOAD_URL_PREFIX.length);
|
|
const candidatePath = resolve(uploadRoot, relativePath);
|
|
if (!relativePath || !candidatePath.startsWith(`${uploadRoot}${sep}`)) return null;
|
|
return { candidatePath, uploadRoot };
|
|
}
|
|
|
|
export async function readTrustedLocalImage(
|
|
imageInput: string,
|
|
workspaceRoot = process.cwd(),
|
|
) {
|
|
const candidate = resolveTrustedLocalImageCandidate(imageInput, workspaceRoot);
|
|
if (!candidate) return null;
|
|
|
|
const [realUploadRoot, realCandidatePath] = await Promise.all([
|
|
realpath(candidate.uploadRoot),
|
|
realpath(candidate.candidatePath),
|
|
]);
|
|
if (!realCandidatePath.startsWith(`${realUploadRoot}${sep}`)) {
|
|
throw new Error("本地图片路径不允许访问");
|
|
}
|
|
return readFile(realCandidatePath);
|
|
}
|