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()
}