flnxi-Wu/lib/url-rewrite.ts
2026-05-25 15:36:51 +08:00

191 lines
5.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 存储 URL 重写工具
*
* 目标:即使数据库里存的是 http://localhost:9000/... 这样的地址,
* 也要在「返回给前端之前」自动替换为实际可访问的 MinIO 地址。
*/
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
// Docker 内部网络 IP 段(需要重写为宿主机 IP)
const DOCKER_INTERNAL_PREFIXES = ['172.17.', '172.18.', '172.19.', '172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.', '172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.']
function isDockerInternalIP(hostname: string): boolean {
return DOCKER_INTERNAL_PREFIXES.some(prefix => hostname.startsWith(prefix))
}
function isLocalOrDockerIP(hostname: string): boolean {
return LOCAL_HOSTS.has(hostname) || isDockerInternalIP(hostname)
}
function getSafeMinioBase(): { protocol: string; host: string; port: string } | null {
const publicDomain = (process.env.S3_PUBLIC_DOMAIN || '').trim()
const serverIp = (process.env.SERVER_IP || '').trim()
const minioPort = (process.env.MINIO_API_PORT || '9000').trim()
const appUrl = (process.env.APP_PUBLIC_URL || process.env.NEXTAUTH_URL || '').trim()
let host: string | null = null
let port: string = minioPort || '9000'
// 1) 端口优先取 S3_PUBLIC_DOMAIN 上的端口(如果有)
if (publicDomain) {
try {
const url = new URL(publicDomain)
if (url.port) {
port = url.port
}
} catch {
// ignore
}
}
// 2) 主机优先使用 SERVER_IP
if (serverIp && !LOCAL_HOSTS.has(serverIp)) {
host = serverIp
}
// 3) 如果没有 SERVER_IP,再从 APP_PUBLIC_URL / NEXTAUTH_URL 推断
if (!host && appUrl) {
try {
const url = new URL(appUrl)
if (!LOCAL_HOSTS.has(url.hostname)) {
host = url.hostname
}
} catch {
// ignore
}
}
// 4) 如果 publicDomain 本身就是非 localhost 的,也可以作为兜底 host
if (!host && publicDomain) {
try {
const url = new URL(publicDomain)
if (!LOCAL_HOSTS.has(url.hostname)) {
host = url.hostname
}
} catch {
// ignore
}
}
if (!host) {
return null
}
return {
protocol: 'http:',
host,
port: port || '9000',
}
}
function shouldProxyMaterialAssets() {
const value = (process.env.MATERIAL_ASSET_PROXY_ENABLED ?? '1').trim().toLowerCase()
return value !== '0' && value !== 'false' && value !== 'no'
}
export function toMaterialAssetProxyUrl(url?: string | null): string | null {
if (!url || typeof url !== 'string') return url ?? null
const trimmed = url.trim()
if (!trimmed) return null
if (trimmed.startsWith('/api/materials/asset?') || trimmed.startsWith('data:')) return trimmed
if (!/^https?:\/\//i.test(trimmed)) return trimmed
return `/api/materials/asset?url=${encodeURIComponent(trimmed)}`
}
export function toSharedMaterialAssetProxyUrl(url: string | null | undefined, token: string): string | null {
const proxiedUrl = toMaterialAssetProxyUrl(url)
if (!proxiedUrl || !proxiedUrl.startsWith('/api/materials/asset?')) {
return proxiedUrl
}
return `${proxiedUrl}&shareToken=${encodeURIComponent(token)}`
}
/**
* 将存储相关的 URL 中的 localhost/127.0.0.1 替换为 SERVER_IP:MINIO_API_PORT
* - 仅在 URL 为 http/https 且 host 为本机时生效
* - 其他情况(如相对路径、第三方 CDN)保持不变
*/
export function rewriteStorageUrlForClient(url?: string | null): string | null {
if (!url || typeof url !== 'string') return url ?? null
const trimmed = url.trim()
if (!trimmed) return null
// 非 http(s) 直接返回(可能是相对路径或 data: URL)
if (!/^https?:\/\//i.test(trimmed)) {
return trimmed
}
const minioBase = getSafeMinioBase()
if (!minioBase) {
// 没有 SERVER_IP 配置时,不做任何修改,避免返回错误地址
return trimmed
}
try {
const parsed = new URL(trimmed)
if (!isLocalOrDockerIP(parsed.hostname)) {
// 不是 localhost / 127.0.0.1 / Docker 内部 IP,直接返回
return trimmed
}
// 将 localhost:9000 或 Docker 内部 IP 替换为 SERVER_IP:MINIO_API_PORT
parsed.protocol = minioBase.protocol
parsed.hostname = minioBase.host
parsed.port = minioBase.port
return parsed.toString()
} catch {
// URL 解析失败时保持原样,避免引入新的错误
return trimmed
}
}
function rewriteMaterialAssetUrlForClient(url?: string | null): string | null {
if (shouldProxyMaterialAssets()) {
return toMaterialAssetProxyUrl(url)
}
return rewriteStorageUrlForClient(url)
}
/**
* 批量重写素材对象中的所有存储 URL 字段
*/
export function rewriteMaterialStorageUrlsForClient<T extends Record<string, any>>(material: T): T {
if (!material || typeof material !== 'object') return material
const cloned: any = { ...material }
const urlFields = [
'previewUrl',
'videoCoverUrl',
'videoPreview540Url',
'videoPreviewClipUrl',
'audioUrl',
'videoSourceUrl',
'zipFilePath',
] as const
urlFields.forEach((field) => {
if (field in cloned) {
cloned[field] = rewriteMaterialAssetUrlForClient(cloned[field])
}
})
// displayImages: string[]
if (Array.isArray(cloned.displayImages)) {
cloned.displayImages = cloned.displayImages.map((item: any) =>
typeof item === 'string' ? rewriteMaterialAssetUrlForClient(item) : item
)
}
return cloned as T
}