fix(storage): 通过同源代理加载对象存储模型

This commit is contained in:
WuYongJie 2026-06-02 22:28:21 +08:00
parent 73655af744
commit 8fd40e0846
7 changed files with 239 additions and 30 deletions

View File

@ -1,6 +1,61 @@
/** @type {import('next').NextConfig} */
const isProduction = process.env.NODE_ENV === 'production'
function trimToNull(value) {
const normalized = value?.trim()
return normalized ? normalized : null
}
function buildUrlOrigin(rawUrl) {
const normalized = trimToNull(rawUrl)
if (!normalized) {
return null
}
const withProtocol = /^https?:\/\//i.test(normalized)
? normalized
: `https://${normalized}`
try {
const parsed = new URL(withProtocol)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null
}
return parsed.origin
} catch {
return null
}
}
function collectStorageConnectSources() {
const origins = new Set()
for (const candidate of [
process.env.S3_PUBLIC_DOMAIN,
process.env.S3_PUBLIC_ENDPOINT,
process.env.S3_PUBLIC_URL,
process.env.S3_ENDPOINT,
process.env.QINIU_PUBLIC_DOMAIN,
process.env.QINIU_PUBLIC_ENDPOINT,
process.env.QINIU_PUBLIC_URL,
]) {
const origin = buildUrlOrigin(candidate)
if (origin) {
origins.add(origin)
}
}
return Array.from(origins)
}
const connectSources = [
"'self'",
'https://login.dingtalk.com',
'https://api.dingtalk.com',
'https://oapi.dingtalk.com',
...collectStorageConnectSources(),
...(!isProduction ? ['ws:', 'http://localhost:*', 'http://127.0.0.1:*'] : []),
]
const contentSecurityPolicy = [
"default-src 'self'",
"base-uri 'self'",
@ -11,7 +66,7 @@ const contentSecurityPolicy = [
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
`script-src 'self' 'unsafe-inline'${isProduction ? '' : " 'unsafe-eval'"}`,
`connect-src 'self' https://login.dingtalk.com https://api.dingtalk.com https://oapi.dingtalk.com${isProduction ? '' : ' ws: http://localhost:* http://127.0.0.1:*'}`,
`connect-src ${connectSources.join(' ')}`,
"worker-src 'self' blob:",
"media-src 'self' blob:",
...(isProduction ? ["upgrade-insecure-requests"] : []),
@ -56,11 +111,6 @@ const baseSecurityHeaders = [
: []),
]
function trimToNull(value) {
const normalized = value?.trim()
return normalized ? normalized : null
}
function buildRemotePatternFromUrl(rawUrl) {
const normalized = trimToNull(rawUrl)
if (!normalized) {

View File

@ -55,9 +55,25 @@ describe('GET /api/storage/object', () => {
)
})
it('redirects a stable object URL to a signed storage URL', async () => {
it('proxies a signed HTTPS storage URL instead of redirecting the browser cross-origin', async () => {
const upstreamBody = new Response('obj-bytes').body
mockFetch.mockResolvedValue({
ok: true,
status: 200,
body: upstreamBody,
headers: {
get: jest.fn((name: string) => {
const headers: Record<string, string> = {
'content-type': 'model/obj',
'content-length': '9',
}
return headers[name.toLowerCase()] ?? null
}),
},
})
const request = {
nextUrl: new URL('http://localhost/api/storage/object?key=models/table.glb'),
nextUrl: new URL('http://localhost/api/storage/object?key=models/table.obj'),
headers: { get: jest.fn(() => null) },
} as NextRequest
const response = await GET(request)
@ -67,12 +83,17 @@ describe('GET /api/storage/object', () => {
expect.any(Function),
{ requireAuth: true },
)
expect(mockCreateObjectReadUrl).toHaveBeenCalledWith('models/table.glb')
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'https://cdn.example.com/models/table.glb?token=signed',
expect(mockCreateObjectReadUrl).toHaveBeenCalledWith('models/table.obj')
expect(mockFetch).toHaveBeenCalledWith(
'https://cdn.example.com/models/table.obj?token=signed',
expect.objectContaining({
cache: 'no-store',
}),
)
expect(mockFetch).not.toHaveBeenCalled()
expect(response.status).toBe(200)
expect(getResponseHeader(response, 'content-type')).toBe('model/obj')
expect(getResponseHeader(response, 'content-length')).toBe('9')
await expect(response.text()).resolves.toBe('obj-bytes')
})
it('proxies a signed HTTP storage URL instead of redirecting the browser to HTTP', async () => {

View File

@ -23,7 +23,8 @@ export class ObjectReadProxyError extends Error {
function shouldProxyObjectReadUrl(readUrl: string): boolean {
try {
return new URL(readUrl).protocol === 'http:'
const protocol = new URL(readUrl).protocol
return protocol === 'http:' || protocol === 'https:'
} catch {
return false
}

View File

@ -111,6 +111,18 @@ describe('storage object provider', () => {
expect(storage.buildPublicS3ObjectUrl('prod-assets/models/table.glb')).toBe(
'https://cdn.example.com/furniture/prod-assets/models/table.glb',
)
expect(
storage.resolveObjectStorageUrlForClientAccess(
'https://cdn.example.com/furniture/prod-assets/models/table.glb',
),
).toBe('/api/storage/object?key=prod-assets%2Fmodels%2Ftable.glb')
expect(
storage.resolveObjectStorageUrlForClientAccess(
'https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf',
),
).toBe(
'https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf',
)
})
it('rejects unsafe object keys at the storage boundary', async () => {

View File

@ -132,6 +132,76 @@ function readPrivateObjectAccessKey(value: string): string | null {
}
}
function getPublicObjectUrlBaseCandidates(): string[] {
const storageAdapter = getStorageAdapter()
const bases = new Set<string>()
const publicEndpoint = storageAdapter.publicEndpoint.replace(/\/+$/, '')
if (publicEndpoint) {
bases.add(`${publicEndpoint}/${storageAdapter.bucketName}`)
bases.add(publicEndpoint)
}
return Array.from(bases)
}
function readPublicObjectAccessKey(value: string): string | null {
const normalized = trimToNull(value)
if (!normalized) return null
let parsed: URL
try {
parsed = new URL(normalized)
} catch {
return null
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null
}
for (const base of getPublicObjectUrlBaseCandidates()) {
let baseUrl: URL
try {
baseUrl = new URL(base)
} catch {
continue
}
if (parsed.origin !== baseUrl.origin) {
continue
}
const basePath = baseUrl.pathname.replace(/\/+$/, '')
if (
basePath &&
parsed.pathname !== basePath &&
!parsed.pathname.startsWith(`${basePath}/`)
) {
continue
}
const rawKey = parsed.pathname.slice(basePath.length).replace(/^\/+/, '')
if (!rawKey) {
continue
}
let decodedKey = rawKey
try {
decodedKey = decodeURIComponent(rawKey)
} catch {
// 保留原值,由 normalizeObjectKey 继续做安全校验。
}
try {
return normalizeObjectKey(decodedKey)
} catch {
continue
}
}
return null
}
function buildS3PublicBaseUrl(): string {
const configured =
trimToNull(process.env.S3_PUBLIC_DOMAIN) ||
@ -466,7 +536,15 @@ function createObjectStorageAdapter(): ObjectStorageAdapter {
return createS3StorageAdapter()
}
const storageAdapter = createObjectStorageAdapter()
let storageAdapter: ObjectStorageAdapter | null = null
function getStorageAdapter(): ObjectStorageAdapter {
if (!storageAdapter) {
storageAdapter = createObjectStorageAdapter()
}
return storageAdapter
}
/**
* 上传文件到对象存储
@ -490,19 +568,19 @@ export async function uploadBufferToObjectStorage(
key: string,
contentType: string,
): Promise<string> {
return storageAdapter.uploadBuffer(buffer, key, contentType)
return getStorageAdapter().uploadBuffer(buffer, key, contentType)
}
export function buildPublicObjectUrl(key: string): string {
return storageAdapter.buildPublicUrl(key)
return getStorageAdapter().buildPublicUrl(key)
}
export function buildStableObjectAccessUrl(key: string): string {
return storageAdapter.buildPublicUrl(key)
return getStorageAdapter().buildPublicUrl(key)
}
export function createObjectReadUrl(key: string): string {
return storageAdapter.buildDirectReadUrl(key)
return getStorageAdapter().buildDirectReadUrl(key)
}
export function resolveObjectStorageUrlForServerAccess(url: string): string {
@ -510,8 +588,14 @@ export function resolveObjectStorageUrlForServerAccess(url: string): string {
return objectKey ? createObjectReadUrl(objectKey) : url
}
export function resolveObjectStorageUrlForClientAccess(url: string): string {
const objectKey =
readPrivateObjectAccessKey(url) ?? readPublicObjectAccessKey(url)
return objectKey ? buildPrivateObjectAccessUrl(objectKey) : url
}
export async function doesObjectExist(key: string): Promise<boolean> {
return storageAdapter.objectExists(key)
return getStorageAdapter().objectExists(key)
}
/**
@ -519,7 +603,7 @@ export async function doesObjectExist(key: string): Promise<boolean> {
*/
export async function deleteObject(key: string): Promise<void> {
try {
await storageAdapter.deleteObject(key)
await getStorageAdapter().deleteObject(key)
} catch (error) {
console.error('删除文件失败:', error)
}
@ -533,27 +617,27 @@ export const doesS3ObjectExist = doesObjectExist
export const deleteFromS3 = deleteObject
export function getStorageProvider(): StorageProvider {
return storageAdapter.provider
return getStorageAdapter().provider
}
export function getStorageAccessMode(): StorageAccessMode {
return storageAdapter.accessMode
return getStorageAdapter().accessMode
}
export function getStorageBucketName(): string {
return storageAdapter.bucketName
return getStorageAdapter().bucketName
}
export function getStorageInternalEndpoint(): string {
return storageAdapter.internalEndpoint
return getStorageAdapter().internalEndpoint
}
export function getStoragePublicEndpoint(): string {
return storageAdapter.publicEndpoint
return getStorageAdapter().publicEndpoint
}
export function getStorageFilePathPrefix(): string {
return storageAdapter.filePathPrefix
return getStorageAdapter().filePathPrefix
}
export function getStorageAllowedPublicOrigins(): string[] {
@ -570,7 +654,7 @@ export function getStorageAllowedPublicOrigins(): string[] {
}
}
pushOrigin(storageAdapter.publicEndpoint)
pushOrigin(getStorageAdapter().publicEndpoint)
pushOrigin(process.env.S3_PUBLIC_DOMAIN)
pushOrigin(process.env.S3_PUBLIC_ENDPOINT)
pushOrigin(process.env.S3_PUBLIC_URL)

View File

@ -114,6 +114,7 @@ import {
import { assetSkuSyncService, type AssetSkuSyncReason } from './asset-sku-sync.service'
import { materialService } from './material.service'
import type { AssetOperationProgressStep } from './asset-operation-progress.service'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
interface VariantThumbnailRenderOptions {
renderBaseUrl?: string | null
@ -588,7 +589,7 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
finishName: record.finish?.name ?? null,
finish: record.finish ? toMaterialLikeDTO(record.finish) : null,
dimensions: isRecord(record.dimensions) ? (record.dimensions as Record<string, number>) : {},
modelUrl: record.modelUrl,
modelUrl: resolveObjectStorageUrlForClientAccess(record.modelUrl),
thumbnailUrl: record.thumbnailUrl,
barcode: record.barcode,
supplierName: record.supplierName,
@ -656,7 +657,7 @@ function toDeletedAssetVariantDTO(record: AssetVariantWithRelations): DeletedAss
finishName: record.finish?.name ?? null,
dimensions: isRecord(record.dimensions) ? (record.dimensions as Record<string, number>) : {},
thumbnailUrl: record.thumbnailUrl,
modelUrl: record.modelUrl,
modelUrl: resolveObjectStorageUrlForClientAccess(record.modelUrl),
createdBy: record.createdBy,
creatorName: record.creator?.name ?? null,
deletedAt: record.deletedAt.toISOString(),

View File

@ -0,0 +1,40 @@
describe('next.config security headers', () => {
const originalEnv = process.env
afterEach(() => {
jest.resetModules()
process.env = originalEnv
})
function loadConfigWithEnv(env) {
jest.resetModules()
process.env = {
...originalEnv,
...env,
}
return require('../next.config.js')
}
async function readContentSecurityPolicy(config) {
const headers = await config.headers()
const rootHeaders = headers.find((entry) => entry.source === '/(.*)')?.headers ?? []
return rootHeaders.find((header) => header.key === 'Content-Security-Policy')?.value ?? ''
}
it('allows configured object storage origins for 3D model fetches', async () => {
const config = loadConfigWithEnv({
NODE_ENV: 'production',
S3_PUBLIC_DOMAIN:
'https://s3.jiansun.vip/gongxiangpan/3danzhuang/furniture-3d-system',
QINIU_PUBLIC_DOMAIN: 'http://tevepd6od.hd-bkt.clouddn.com',
})
const csp = await readContentSecurityPolicy(config)
expect(csp).toContain(
'connect-src',
)
expect(csp).toContain('https://s3.jiansun.vip')
expect(csp).toContain('http://tevepd6od.hd-bkt.clouddn.com')
})
})