fix(storage): 通过同源代理加载对象存储模型
This commit is contained in:
parent
73655af744
commit
8fd40e0846
@ -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) {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
40
furniture-3d-system/tests/next-config-security.test.js
Normal file
40
furniture-3d-system/tests/next-config-security.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user