118 lines
3.5 KiB
TypeScript
118 lines
3.5 KiB
TypeScript
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
|
import type { MaterialConfig } from '@/lib/3d/material-manager'
|
|
import { serializeMaterialConfig } from '@/lib/material-config'
|
|
|
|
export type AssetThumbnailRenderAssetType =
|
|
| 'BOARD'
|
|
| 'COMPONENT'
|
|
| 'HARDWARE'
|
|
| 'PACKAGING'
|
|
|
|
export interface AssetThumbnailRenderPayload {
|
|
modelUrl: string
|
|
assetType: AssetThumbnailRenderAssetType
|
|
expiresAt: number
|
|
materialConfig?: MaterialConfig | null
|
|
}
|
|
|
|
function getThumbnailSigningSecret(): string {
|
|
const secret =
|
|
process.env.ASSET_THUMBNAIL_SECRET ||
|
|
process.env.NEXTAUTH_SECRET ||
|
|
process.env.JWT_SECRET
|
|
|
|
if (secret && secret.trim().length > 0) {
|
|
return secret.trim()
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'test') {
|
|
return 'asset-thumbnail-test-secret'
|
|
}
|
|
|
|
throw new Error('缺少缩略图签名密钥,无法生成内部渲染令牌')
|
|
}
|
|
|
|
function buildSignableMessage(payload: AssetThumbnailRenderPayload): string {
|
|
return [
|
|
payload.assetType,
|
|
payload.modelUrl,
|
|
String(payload.expiresAt),
|
|
serializeMaterialConfig(payload.materialConfig),
|
|
].join('|')
|
|
}
|
|
|
|
export function signAssetThumbnailRenderPayload(
|
|
payload: AssetThumbnailRenderPayload,
|
|
): string {
|
|
return createHmac('sha256', getThumbnailSigningSecret())
|
|
.update(buildSignableMessage(payload))
|
|
.digest('hex')
|
|
}
|
|
|
|
export function verifyAssetThumbnailRenderPayload(
|
|
payload: AssetThumbnailRenderPayload,
|
|
token: string,
|
|
): boolean {
|
|
if (!token || payload.expiresAt <= Date.now()) {
|
|
return false
|
|
}
|
|
|
|
const expected = signAssetThumbnailRenderPayload(payload)
|
|
const actualBuffer = Buffer.from(token)
|
|
const expectedBuffer = Buffer.from(expected)
|
|
|
|
if (actualBuffer.length !== expectedBuffer.length) {
|
|
return false
|
|
}
|
|
|
|
return timingSafeEqual(actualBuffer, expectedBuffer)
|
|
}
|
|
|
|
export function buildAssetThumbnailRenderUrl(
|
|
baseUrl: string,
|
|
payload: AssetThumbnailRenderPayload,
|
|
): string {
|
|
const url = new URL('/internal/asset-thumbnail', baseUrl)
|
|
url.searchParams.set('modelUrl', payload.modelUrl)
|
|
url.searchParams.set('assetType', payload.assetType)
|
|
url.searchParams.set('expiresAt', String(payload.expiresAt))
|
|
const serializedMaterialConfig = serializeMaterialConfig(payload.materialConfig)
|
|
if (serializedMaterialConfig) {
|
|
url.searchParams.set('materialConfig', serializedMaterialConfig)
|
|
}
|
|
url.searchParams.set('token', signAssetThumbnailRenderPayload(payload))
|
|
return url.toString()
|
|
}
|
|
|
|
export function buildAssetThumbnailImageStorageKey(
|
|
payload: Pick<AssetThumbnailRenderPayload, 'modelUrl' | 'assetType' | 'materialConfig'>,
|
|
): string {
|
|
const digest = createHash('sha256')
|
|
.update(
|
|
[
|
|
payload.assetType,
|
|
payload.modelUrl,
|
|
serializeMaterialConfig(payload.materialConfig),
|
|
].join('|'),
|
|
)
|
|
.digest('hex')
|
|
|
|
return `asset-center/runtime-thumbnails/${payload.assetType.toLowerCase()}/${digest}.png`
|
|
}
|
|
|
|
export function buildAssetThumbnailImageUrl(
|
|
baseUrl: string,
|
|
payload: AssetThumbnailRenderPayload,
|
|
): string {
|
|
const url = new URL('/internal/asset-thumbnail-image', baseUrl)
|
|
url.searchParams.set('modelUrl', payload.modelUrl)
|
|
url.searchParams.set('assetType', payload.assetType)
|
|
url.searchParams.set('expiresAt', String(payload.expiresAt))
|
|
const serializedMaterialConfig = serializeMaterialConfig(payload.materialConfig)
|
|
if (serializedMaterialConfig) {
|
|
url.searchParams.set('materialConfig', serializedMaterialConfig)
|
|
}
|
|
url.searchParams.set('token', signAssetThumbnailRenderPayload(payload))
|
|
return url.toString()
|
|
}
|