diff --git a/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts b/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts index dd9c82ea..d50a02c9 100644 --- a/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts @@ -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, diff --git a/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts b/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts index db451d5d..30b9c82c 100644 --- a/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts @@ -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, diff --git a/furniture-3d-system/src/app/api/asset-center/variants/[id]/thumbnail/route.ts b/furniture-3d-system/src/app/api/asset-center/variants/[id]/thumbnail/route.ts index 34f5007c..d8649935 100644 --- a/furniture-3d-system/src/app/api/asset-center/variants/[id]/thumbnail/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/variants/[id]/thumbnail/route.ts @@ -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, diff --git a/furniture-3d-system/src/app/api/asset-center/variants/route.ts b/furniture-3d-system/src/app/api/asset-center/variants/route.ts index 911caffe..8fcf0d56 100644 --- a/furniture-3d-system/src/app/api/asset-center/variants/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/variants/route.ts @@ -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, diff --git a/furniture-3d-system/src/app/internal/asset-thumbnail-image/route.test.ts b/furniture-3d-system/src/app/internal/asset-thumbnail-image/route.test.ts index 092984eb..db24b6bd 100644 --- a/furniture-3d-system/src/app/internal/asset-thumbnail-image/route.test.ts +++ b/furniture-3d-system/src/app/internal/asset-thumbnail-image/route.test.ts @@ -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', ) diff --git a/furniture-3d-system/src/app/internal/asset-thumbnail-image/route.ts b/furniture-3d-system/src/app/internal/asset-thumbnail-image/route.ts index 909da3b6..2e059999 100644 --- a/furniture-3d-system/src/app/internal/asset-thumbnail-image/route.ts +++ b/furniture-3d-system/src/app/internal/asset-thumbnail-image/route.ts @@ -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, }) diff --git a/furniture-3d-system/src/lib/asset-thumbnail-auth.test.ts b/furniture-3d-system/src/lib/asset-thumbnail-auth.test.ts new file mode 100644 index 00000000..f81a2a14 --- /dev/null +++ b/furniture-3d-system/src/lib/asset-thumbnail-auth.test.ts @@ -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') + }) +}) diff --git a/furniture-3d-system/src/lib/asset-thumbnail-auth.ts b/furniture-3d-system/src/lib/asset-thumbnail-auth.ts index 2814e4b4..8bd93b6a 100644 --- a/furniture-3d-system/src/lib/asset-thumbnail-auth.ts +++ b/furniture-3d-system/src/lib/asset-thumbnail-auth.ts @@ -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 ||