fix(asset-center): 修复生产缩略图渲染域名来源
This commit is contained in:
parent
3cd1102cd2
commit
b4f088d70a
@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCadImportService } from '@/server/services'
|
||||
import { resolveAssetThumbnailRenderBaseUrl } from '@/lib/asset-thumbnail-auth'
|
||||
import { assetCadImportPayloadSchema } from '../../platform/schemas'
|
||||
|
||||
function parseJsonStringArray(value: FormDataEntryValue | null): string[] | undefined {
|
||||
@ -105,7 +106,12 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const { familyMode: _familyMode, ...serviceInput } = payloadData
|
||||
const requestOrigin = new URL(request.url).origin
|
||||
const requestOrigin = resolveAssetThumbnailRenderBaseUrl({
|
||||
requestUrl: request.url,
|
||||
forwardedHost: request.headers.get('x-forwarded-host'),
|
||||
host: request.headers.get('host'),
|
||||
forwardedProto: request.headers.get('x-forwarded-proto'),
|
||||
})
|
||||
const result = await assetCadImportService.importAsset({
|
||||
...serviceInput,
|
||||
zipFile: file,
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCenterService } from '@/server/services'
|
||||
import { resolveAssetThumbnailRenderBaseUrl } from '@/lib/asset-thumbnail-auth'
|
||||
import { assetVariantUpdateSchema } from '../../../platform/schemas'
|
||||
|
||||
function shouldSkipThumbnail(request: NextRequest): boolean {
|
||||
@ -28,7 +29,12 @@ export async function PUT(
|
||||
}
|
||||
|
||||
try {
|
||||
const requestOrigin = new URL(request.url).origin
|
||||
const requestOrigin = resolveAssetThumbnailRenderBaseUrl({
|
||||
requestUrl: request.url,
|
||||
forwardedHost: request.headers.get('x-forwarded-host'),
|
||||
host: request.headers.get('host'),
|
||||
forwardedProto: request.headers.get('x-forwarded-proto'),
|
||||
})
|
||||
const variant = await assetCenterService.updateVariant(id, {
|
||||
...parsed.data,
|
||||
updatedBy: ctx.user.id,
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
|
||||
import { AppError } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCenterService } from '@/server/services'
|
||||
import { resolveAssetThumbnailRenderBaseUrl } from '@/lib/asset-thumbnail-auth'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
@ -13,7 +14,12 @@ export async function POST(
|
||||
const { id } = await context.params
|
||||
|
||||
try {
|
||||
const requestOrigin = new URL(request.url).origin
|
||||
const requestOrigin = resolveAssetThumbnailRenderBaseUrl({
|
||||
requestUrl: request.url,
|
||||
forwardedHost: request.headers.get('x-forwarded-host'),
|
||||
host: request.headers.get('host'),
|
||||
forwardedProto: request.headers.get('x-forwarded-proto'),
|
||||
})
|
||||
const variant = await assetCenterService.regenerateVariantThumbnail(id, {
|
||||
renderBaseUrl: requestOrigin,
|
||||
updatedBy: ctx.user.id,
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCenterService, assetOperationProgressService } from '@/server/services'
|
||||
import { resolveAssetThumbnailRenderBaseUrl } from '@/lib/asset-thumbnail-auth'
|
||||
import { assetVariantCreateSchema } from '../../platform/schemas'
|
||||
|
||||
function readOperationId(request: NextRequest): string | null {
|
||||
@ -65,7 +66,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const requestOrigin = new URL(request.url).origin
|
||||
const requestOrigin = resolveAssetThumbnailRenderBaseUrl({
|
||||
requestUrl: request.url,
|
||||
forwardedHost: request.headers.get('x-forwarded-host'),
|
||||
host: request.headers.get('host'),
|
||||
forwardedProto: request.headers.get('x-forwarded-proto'),
|
||||
})
|
||||
const result = await assetCenterService.createVariantWithSimilarity({
|
||||
...parsed.data,
|
||||
createdBy: ctx.user.id,
|
||||
|
||||
@ -6,10 +6,23 @@ const mockBuildPublicS3ObjectUrl = jest.fn()
|
||||
const mockDoesS3ObjectExist = jest.fn()
|
||||
const mockResolveObjectStorageUrlForServerAccess = jest.fn()
|
||||
const mockGenerateGenericThumbnail = jest.fn()
|
||||
const mockBuildObjectReadResponse = jest.fn()
|
||||
|
||||
const ENV_KEYS = [
|
||||
'ASSET_THUMBNAIL_RENDER_BASE_URL',
|
||||
'NEXT_PUBLIC_APP_URL',
|
||||
'NEXTAUTH_URL',
|
||||
] as const
|
||||
|
||||
const originalEnv = Object.fromEntries(
|
||||
ENV_KEYS.map((key) => [key, process.env[key]]),
|
||||
) as Record<(typeof ENV_KEYS)[number], string | undefined>
|
||||
|
||||
jest.mock('@/lib/asset-thumbnail-auth', () => ({
|
||||
buildAssetThumbnailImageStorageKey: (...args: unknown[]) =>
|
||||
mockBuildAssetThumbnailImageStorageKey(...args),
|
||||
resolveAssetThumbnailRenderBaseUrl:
|
||||
jest.requireActual('@/lib/asset-thumbnail-auth').resolveAssetThumbnailRenderBaseUrl,
|
||||
verifyAssetThumbnailRenderPayload: (...args: unknown[]) =>
|
||||
mockVerifyAssetThumbnailRenderPayload(...args),
|
||||
}))
|
||||
@ -21,6 +34,10 @@ jest.mock('@/lib/storage', () => ({
|
||||
mockResolveObjectStorageUrlForServerAccess(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/object-storage-read-response', () => ({
|
||||
buildObjectReadResponse: (...args: unknown[]) => mockBuildObjectReadResponse(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/asset-variant-thumbnail.service', () => ({
|
||||
assetVariantThumbnailService: {
|
||||
generateGenericThumbnail: (...args: unknown[]) =>
|
||||
@ -46,6 +63,9 @@ function buildRequest(): NextRequest {
|
||||
describe('GET /internal/asset-thumbnail-image', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
for (const key of ENV_KEYS) {
|
||||
delete process.env[key]
|
||||
}
|
||||
mockBuildAssetThumbnailImageStorageKey.mockReturnValue(
|
||||
'asset-center/runtime-thumbnails/component/thumb.png',
|
||||
)
|
||||
@ -58,6 +78,23 @@ describe('GET /internal/asset-thumbnail-image', () => {
|
||||
? 'https://cdn.example.com/asset-center/runtime-thumbnails/component/thumb.png?token=signed'
|
||||
: url,
|
||||
)
|
||||
mockBuildObjectReadResponse.mockImplementation((readUrl: string) =>
|
||||
new Response(null, {
|
||||
status: 307,
|
||||
headers: { location: readUrl },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = originalEnv[key]
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('redirects cached private thumbnails to a signed object URL', async () => {
|
||||
@ -72,6 +109,10 @@ describe('GET /internal/asset-thumbnail-image', () => {
|
||||
expect(mockResolveObjectStorageUrlForServerAccess).toHaveBeenCalledWith(
|
||||
'/api/storage/object?key=asset-center%2Fruntime-thumbnails%2Fcomponent%2Fthumb.png',
|
||||
)
|
||||
expect(mockBuildObjectReadResponse).toHaveBeenCalledWith(
|
||||
'https://cdn.example.com/asset-center/runtime-thumbnails/component/thumb.png?token=signed',
|
||||
expect.anything(),
|
||||
)
|
||||
expect(getRedirectLocation(response)).toBe(
|
||||
'https://cdn.example.com/asset-center/runtime-thumbnails/component/thumb.png?token=signed',
|
||||
)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
buildAssetThumbnailImageStorageKey,
|
||||
resolveAssetThumbnailRenderBaseUrl,
|
||||
verifyAssetThumbnailRenderPayload,
|
||||
type AssetThumbnailRenderAssetType,
|
||||
} from '@/lib/asset-thumbnail-auth'
|
||||
@ -19,24 +20,6 @@ function readStringParam(value: string | null): string | null {
|
||||
return value && value.trim().length > 0 ? value.trim() : null
|
||||
}
|
||||
|
||||
function resolveRequestOrigin(request: NextRequest): string {
|
||||
const configuredOrigin =
|
||||
process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.NEXTAUTH_URL?.trim()
|
||||
if (configuredOrigin) {
|
||||
return configuredOrigin.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
const forwardedHost = request.headers.get('x-forwarded-host')
|
||||
const host = forwardedHost ?? request.headers.get('host')
|
||||
const protocol = request.headers.get('x-forwarded-proto') ?? request.nextUrl.protocol.replace(/:$/, '')
|
||||
|
||||
if (!host) {
|
||||
return request.nextUrl.origin.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
return `${protocol}://${host}`.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const modelUrl = readStringParam(request.nextUrl.searchParams.get('modelUrl'))
|
||||
const assetType = readStringParam(
|
||||
@ -86,7 +69,12 @@ export async function GET(request: NextRequest) {
|
||||
identifier: storageKey,
|
||||
assetType,
|
||||
modelUrl,
|
||||
renderBaseUrl: resolveRequestOrigin(request),
|
||||
renderBaseUrl: resolveAssetThumbnailRenderBaseUrl({
|
||||
requestUrl: request.nextUrl,
|
||||
forwardedHost: request.headers.get('x-forwarded-host'),
|
||||
host: request.headers.get('host'),
|
||||
forwardedProto: request.headers.get('x-forwarded-proto'),
|
||||
}),
|
||||
storageKey,
|
||||
materialConfig,
|
||||
})
|
||||
|
||||
73
furniture-3d-system/src/lib/asset-thumbnail-auth.test.ts
Normal file
73
furniture-3d-system/src/lib/asset-thumbnail-auth.test.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { resolveAssetThumbnailRenderBaseUrl } from './asset-thumbnail-auth'
|
||||
|
||||
const ENV_KEYS = [
|
||||
'ASSET_THUMBNAIL_RENDER_BASE_URL',
|
||||
'NEXT_PUBLIC_APP_URL',
|
||||
'NEXTAUTH_URL',
|
||||
] as const
|
||||
|
||||
const originalEnv = Object.fromEntries(
|
||||
ENV_KEYS.map((key) => [key, process.env[key]]),
|
||||
) as Record<(typeof ENV_KEYS)[number], string | undefined>
|
||||
|
||||
function restoreEnv() {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = originalEnv[key]
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveAssetThumbnailRenderBaseUrl', () => {
|
||||
beforeEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
restoreEnv()
|
||||
})
|
||||
|
||||
it('prefers the dedicated thumbnail render base url over request origin', () => {
|
||||
process.env.ASSET_THUMBNAIL_RENDER_BASE_URL = 'https://cp.jiansun.vip/'
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://ignored.example.com'
|
||||
|
||||
expect(
|
||||
resolveAssetThumbnailRenderBaseUrl({
|
||||
requestUrl: 'https://0.0.0.0:3001/api/asset-center/variants/id/thumbnail',
|
||||
}),
|
||||
).toBe('https://cp.jiansun.vip')
|
||||
})
|
||||
|
||||
it('uses the public app url before falling back to the internal request origin', () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://cp.jiansun.vip'
|
||||
|
||||
expect(
|
||||
resolveAssetThumbnailRenderBaseUrl({
|
||||
requestUrl: 'https://0.0.0.0:3001/api/asset-center/variants/id/thumbnail',
|
||||
}),
|
||||
).toBe('https://cp.jiansun.vip')
|
||||
})
|
||||
|
||||
it('falls back to forwarded host headers when no public url is configured', () => {
|
||||
expect(
|
||||
resolveAssetThumbnailRenderBaseUrl({
|
||||
requestUrl: 'http://0.0.0.0:3001/api/asset-center/variants/id/thumbnail',
|
||||
forwardedHost: 'cp.jiansun.vip',
|
||||
forwardedProto: 'https',
|
||||
}),
|
||||
).toBe('https://cp.jiansun.vip')
|
||||
})
|
||||
|
||||
it('uses request origin only after configured and forwarded origins are absent', () => {
|
||||
expect(
|
||||
resolveAssetThumbnailRenderBaseUrl({
|
||||
requestUrl: 'http://localhost:3000/api/asset-center/variants/id/thumbnail',
|
||||
}),
|
||||
).toBe('http://localhost:3000')
|
||||
})
|
||||
})
|
||||
@ -15,6 +15,13 @@ export interface AssetThumbnailRenderPayload {
|
||||
materialConfig?: MaterialConfig | null
|
||||
}
|
||||
|
||||
export interface AssetThumbnailRenderBaseUrlInput {
|
||||
requestUrl?: string | URL | null
|
||||
forwardedHost?: string | null
|
||||
host?: string | null
|
||||
forwardedProto?: string | null
|
||||
}
|
||||
|
||||
export function buildAssetThumbnailExpiryTimestamp(
|
||||
ttlMs: number = 24 * 60 * 60 * 1000,
|
||||
nowMs: number = Date.now(),
|
||||
@ -22,6 +29,53 @@ export function buildAssetThumbnailExpiryTimestamp(
|
||||
return nowMs + ttlMs
|
||||
}
|
||||
|
||||
function readFirstHeaderValue(value: string | null | undefined): string | null {
|
||||
const next = value?.split(',')[0]?.trim()
|
||||
return next || null
|
||||
}
|
||||
|
||||
function normalizeOrigin(value: string | URL | null | undefined): string | null {
|
||||
const next = typeof value === 'string' ? value.trim() : value?.toString().trim()
|
||||
if (!next) return null
|
||||
|
||||
try {
|
||||
return new URL(next).origin.replace(/\/$/, '')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildForwardedOrigin(input: AssetThumbnailRenderBaseUrlInput): string | null {
|
||||
const host = readFirstHeaderValue(input.forwardedHost) || readFirstHeaderValue(input.host)
|
||||
if (!host) return null
|
||||
|
||||
const requestOrigin = normalizeOrigin(input.requestUrl)
|
||||
const protocol =
|
||||
readFirstHeaderValue(input.forwardedProto) ||
|
||||
(requestOrigin ? new URL(requestOrigin).protocol.replace(/:$/, '') : 'http')
|
||||
|
||||
return normalizeOrigin(`${protocol}://${host}`)
|
||||
}
|
||||
|
||||
export function resolveAssetThumbnailRenderBaseUrl(
|
||||
input: AssetThumbnailRenderBaseUrlInput = {},
|
||||
): string {
|
||||
const candidates = [
|
||||
process.env.ASSET_THUMBNAIL_RENDER_BASE_URL,
|
||||
process.env.NEXT_PUBLIC_APP_URL,
|
||||
process.env.NEXTAUTH_URL,
|
||||
buildForwardedOrigin(input),
|
||||
input.requestUrl,
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const origin = normalizeOrigin(candidate)
|
||||
if (origin) return origin
|
||||
}
|
||||
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
|
||||
function getThumbnailSigningSecret(): string {
|
||||
const secret =
|
||||
process.env.ASSET_THUMBNAIL_SECRET ||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user