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