127 lines
3 KiB
TypeScript
127 lines
3 KiB
TypeScript
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())
|
|
}
|