fix: add shared manual asset path normalization utility

This commit is contained in:
DMleadgen 2026-04-16 16:24:06 -06:00
parent 5d3ee2c4d7
commit 508a8bbe5e
Signed by: matt
GPG key ID: C2720CF8CD701894
2 changed files with 197 additions and 0 deletions

View file

@ -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)
})

127
lib/manuals-asset-paths.ts Normal file
View file

@ -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())
}