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