fix: add shared manual asset path normalization utility
This commit is contained in:
parent
5d3ee2c4d7
commit
508a8bbe5e
2 changed files with 197 additions and 0 deletions
70
lib/manuals-asset-paths.test.ts
Normal file
70
lib/manuals-asset-paths.test.ts
Normal 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
127
lib/manuals-asset-paths.ts
Normal 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())
|
||||
}
|
||||
Loading…
Reference in a new issue