diff --git a/lib/manuals-asset-paths.test.ts b/lib/manuals-asset-paths.test.ts new file mode 100644 index 00000000..6d0f4cc4 --- /dev/null +++ b/lib/manuals-asset-paths.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + isPrivateOrLocalHost, + normalizeManualAssetValue, +} from "@/lib/manuals-asset-paths" + +test("normalizeManualAssetValue keeps relative paths relative", () => { + assert.equal( + normalizeManualAssetValue("Royal-Vendors/foo-manual.pdf", "manual"), + "Royal-Vendors/foo-manual.pdf" + ) + assert.equal( + normalizeManualAssetValue("Royal-Vendors/foo-thumb.jpg", "thumbnail"), + "Royal-Vendors/foo-thumb.jpg" + ) +}) + +test("normalizeManualAssetValue normalizes site-proxy absolute URLs to relative paths", () => { + assert.equal( + normalizeManualAssetValue( + "https://cdn.example.com/manuals/vendor/foo-manual.pdf", + "manual" + ), + "vendor/foo-manual.pdf" + ) + + assert.equal( + normalizeManualAssetValue( + "https://files.example.com/vendor/foo-manual.pdf", + "manual" + ), + "https://files.example.com/vendor/foo-manual.pdf" + ) +}) + +test("normalizeManualAssetValue rewrites localhost and private hosts to relative paths", () => { + assert.equal( + normalizeManualAssetValue( + "http://localhost:3000/api/thumbnails/Royal-Vendors/foo-thumb.jpg", + "thumbnail" + ), + "Royal-Vendors/foo-thumb.jpg" + ) + + assert.equal( + normalizeManualAssetValue( + "http://127.0.0.1:3000/api/manuals/Royal-Vendors/foo-manual.pdf", + "manual" + ), + "Royal-Vendors/foo-manual.pdf" + ) + + assert.equal( + normalizeManualAssetValue( + "http://10.1.2.3:3000/api/thumbnails/Royal-Vendors/foo-thumb.jpg", + "thumbnail" + ), + "Royal-Vendors/foo-thumb.jpg" + ) +}) + +test("isPrivateOrLocalHost identifies local/private hosts", () => { + assert.equal(isPrivateOrLocalHost("localhost"), true) + assert.equal(isPrivateOrLocalHost("127.0.0.1"), true) + assert.equal(isPrivateOrLocalHost("10.0.0.9"), true) + assert.equal(isPrivateOrLocalHost("192.168.1.20"), true) + assert.equal(isPrivateOrLocalHost("172.18.5.4"), true) + assert.equal(isPrivateOrLocalHost("cdn.example.com"), false) +}) diff --git a/lib/manuals-asset-paths.ts b/lib/manuals-asset-paths.ts new file mode 100644 index 00000000..516e8811 --- /dev/null +++ b/lib/manuals-asset-paths.ts @@ -0,0 +1,127 @@ +export type ManualAssetKind = "manual" | "thumbnail" + +function safeDecodeSegment(value: string) { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function decodePath(pathname: string) { + return pathname + .split("/") + .map((segment) => safeDecodeSegment(segment)) + .join("/") +} + +function stripLeadingSlash(value: string) { + return value.replace(/^\/+/, "") +} + +function stripAssetPrefix(value: string, kind: ManualAssetKind) { + if (kind === "manual") { + return value + .replace(/^api\/manuals\//i, "") + .replace(/^manuals\//i, "") + .replace(/^\/api\/manuals\//i, "") + .replace(/^\/manuals\//i, "") + } + + return value + .replace(/^api\/thumbnails\//i, "") + .replace(/^thumbnails\//i, "") + .replace(/^\/api\/thumbnails\//i, "") + .replace(/^\/thumbnails\//i, "") +} + +function looksLikePrivateHost(hostname: string) { + const host = hostname.trim().toLowerCase() + if (!host) { + return true + } + + if (host === "localhost" || host === "127.0.0.1" || host === "::1") { + return true + } + + if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host)) { + return true + } + + const match172 = host.match(/^172\.(\d{1,3})\./) + if (match172) { + const octet = Number.parseInt(match172[1] || "", 10) + if (Number.isFinite(octet) && octet >= 16 && octet <= 31) { + return true + } + } + + return false +} + +function normalizeRelativePath(value: string, kind: ManualAssetKind) { + const decoded = decodePath(stripLeadingSlash(value.trim())) + const withoutPrefix = stripAssetPrefix(decoded, kind).trim() + if (!withoutPrefix) { + return null + } + + if (withoutPrefix.includes("..")) { + return null + } + + return withoutPrefix +} + +function extractRelativePathFromAbsolute(url: URL, kind: ManualAssetKind) { + const decodedPath = decodePath(url.pathname) + return normalizeRelativePath(decodedPath, kind) +} + +export function isPrivateOrLocalHost(hostname: string) { + return looksLikePrivateHost(hostname) +} + +export function normalizeManualAssetValue( + value: string | null | undefined, + kind: ManualAssetKind +): string | null { + const raw = String(value || "").trim() + if (!raw) { + return null + } + + if (!/^https?:\/\//i.test(raw)) { + return normalizeRelativePath(raw, kind) + } + + try { + const parsed = new URL(raw) + const extractedRelativePath = extractRelativePathFromAbsolute(parsed, kind) + if (looksLikePrivateHost(parsed.hostname)) { + return extractedRelativePath + } + + // Normalize site-proxy URLs to relative paths to keep persisted values tenant-safe. + if (extractedRelativePath) { + const lowerPath = parsed.pathname.toLowerCase() + if ( + lowerPath.includes("/api/manuals/") || + lowerPath.includes("/api/thumbnails/") || + lowerPath.includes("/manuals/") || + lowerPath.includes("/thumbnails/") + ) { + return extractedRelativePath + } + } + + return raw + } catch { + return null + } +} + +export function isAbsoluteHttpUrl(value: string) { + return /^https?:\/\//i.test(value.trim()) +}