3D 查看器给签名地址的兄弟资源补回鉴权 query

- loader 新增 resolveModel3dViewerSourceQuery / resolveModel3dViewerResourceUrl,只补模型目录下的资源地址
- glTF / FBX 解析改用带 URL 修饰器的专用 LoadingManager,不动全局 DefaultLoadingManager
- 补测试:query 提取与片段剔除、目录内外的补与不补、多文件 glTF 兄弟资源解析
This commit is contained in:
2026-09-23 11:48:48 +08:00
parent c5a718ac0e
commit f4083dcb6d
2 changed files with 184 additions and 3 deletions
+113 -1
View File
@@ -1,14 +1,17 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
isModel3dViewerFormatSupported,
isModel3dViewerOverSize,
loadModel3dViewerObject,
MODEL3D_VIEWER_MAX_MODEL_BYTES,
Model3dViewerAbortedError,
readModel3dViewerSource,
resolveModel3dViewerFormat,
resolveModel3dViewerFormatFromBytes,
resolveModel3dViewerFormatFromMimeType,
resolveModel3dViewerResourceUrl,
resolveModel3dViewerSourceQuery,
} from './loader';
describe('模型格式承诺范围', () => {
@@ -150,3 +153,112 @@ describe('模型下载可中止', () => {
]);
});
});
/**
* 签名模型地址的兄弟资源补鉴权。
*
* 宿主给的是私有对象的签名地址,token 在 query 里;`GLTFLoader` / `FBXLoader` 用
* `path + uri` 拼多文件模型的兄弟 buffer 与贴图,那一层只拼目录、不带 query。
*/
describe('签名模型地址的兄弟资源补鉴权', () => {
it('从模型地址的 query 里取出鉴权参数,丢掉片段', () => {
expect(
resolveModel3dViewerSourceQuery(
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/model.glb?token=abc&expires=1',
),
).toBe('?token=abc&expires=1');
expect(
resolveModel3dViewerSourceQuery(
'https://bucket.oss-cn-hangzhou.aliyuncs.com/m.glb?token=abc#frag',
),
).toBe('?token=abc');
expect(resolveModel3dViewerSourceQuery('https://x/m.glb?')).toBeNull();
expect(resolveModel3dViewerSourceQuery('https://x/m.glb')).toBeNull();
expect(resolveModel3dViewerSourceQuery(null)).toBeNull();
});
it('只补模型目录下的资源地址,并保留资源自己的 query', () => {
const basePath =
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/';
const query = '?token=abc';
expect(
resolveModel3dViewerResourceUrl({
url: `${basePath}buffer.bin`,
basePath,
query,
}),
).toBe(`${basePath}buffer.bin?token=abc`);
expect(
resolveModel3dViewerResourceUrl({
url: `${basePath}textures/tex.png`,
basePath,
query,
}),
).toBe(`${basePath}textures/tex.png?token=abc`);
expect(
resolveModel3dViewerResourceUrl({
url: `${basePath}buffer.bin?v=2`,
basePath,
query,
}),
).toBe(`${basePath}buffer.bin?v=2&token=abc`);
});
it('第三方 CDN、同源其它对象、无目录与空地址都原样返回', () => {
const basePath = 'https://bucket/editor/model3d/t-1/';
const query = '?token=abc';
for (const input of [
{ url: 'https://cdn.example.com/tex.png', basePath, query },
{ url: 'https://bucket/other/tex.png', basePath, query },
{
url: 'https://bucket/editor/model3d/t-1/buffer.bin',
basePath: '',
query,
},
{ url: '', basePath, query },
]) {
expect(resolveModel3dViewerResourceUrl(input)).toBe(input.url);
}
});
it('多文件 glTF 加载时把 query 补到兄弟资源地址上', async () => {
const modelUrl =
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/model.gltf?token=abc';
const resolvedUrls: string[] = [];
const originalFetch = globalThis.fetch;
// 动态 import 的替身只记录一次兄弟资源解析,不真的解析 glTF。
vi.doMock('three/examples/jsm/loaders/GLTFLoader.js', () => ({
GLTFLoader: class {
private readonly manager: { resolveURL: (url: string) => string };
constructor(manager: { resolveURL: (url: string) => string }) {
this.manager = manager;
}
parse(
_payload: unknown,
path: string,
onLoad: (gltf: { scene: object }) => void,
) {
resolvedUrls.push(this.manager.resolveURL(`${path}buffer.bin`));
onLoad({ scene: {} });
}
},
}));
globalThis.fetch = (async () =>
new Response(new TextEncoder().encode('{"asset":{"version":"2.0"}}'), {
headers: { 'content-type': 'model/gltf+json' },
})) as typeof fetch;
try {
await loadModel3dViewerObject({ kind: 'url', url: modelUrl });
} finally {
globalThis.fetch = originalFetch;
vi.doUnmock('three/examples/jsm/loaders/GLTFLoader.js');
}
expect(resolvedUrls).toEqual([
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/buffer.bin?token=abc',
]);
});
});
+71 -2
View File
@@ -266,6 +266,50 @@ function resolveModel3dViewerBasePath(source: Model3DViewerSource): string {
return separatorIndex >= 0 ? source.url.slice(0, separatorIndex + 1) : '';
}
/**
* 模型地址 query 里的鉴权参数(含 `?`,丢掉 `#` 之后的部分);没有 query 返回 null。
*
* 宿主给的多半是私有对象的签名地址(见 `getSignedAssetReadUrl`),token 就在 query 里。
*/
export function resolveModel3dViewerSourceQuery(
url: string | null | undefined,
): string | null {
const value = url?.trim() ?? '';
const queryIndex = value.indexOf('?');
if (queryIndex < 0) {
return null;
}
const query = value.slice(queryIndex + 1).split('#')[0] ?? '';
return query ? `?${query}` : null;
}
/**
* 给模型目录下的兄弟资源地址补回模型地址的鉴权 query。
*
* `GLTFLoader` / `FBXLoader` 用 `path + uri` 拼多文件模型的兄弟 buffer 与贴图地址,
* 地址落在模型目录里,但签名 token 在这一步丢掉了(目录本身不带 query),私有对象
* 于是 401/403。这里只补模型目录下的地址:既不把 token 外发到第三方 CDN,也不把
* 「为某个对象签出的 token」贴到同源的另一个对象上(OSS 会以 SignatureDoesNotMatch
* 拒绝),资源地址自己带的 query 也保留。
*/
export function resolveModel3dViewerResourceUrl(input: {
url: string;
/** 模型地址所在目录,由 `resolveModel3dViewerBasePath` 得到;空串表示没有目录可补。 */
basePath: string;
/** 模型地址的鉴权 query,由 `resolveModel3dViewerSourceQuery` 得到。 */
query: string;
}): string {
const { url, basePath, query } = input;
if (!url || !basePath || !url.startsWith(basePath)) {
return url;
}
const hashIndex = url.indexOf('#');
const withoutHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
const hash = hashIndex >= 0 ? url.slice(hashIndex) : '';
const separator = withoutHash.includes('?') ? '&' : '?';
return `${withoutHash}${separator}${query.slice(1)}${hash}`;
}
/**
* 解析模型字节为 three 对象。glb / 单文件 gltf 走 GLTFLoaderfbx 走 FBXLoader
* 多文件 gltf 的兄弟资源按模型地址目录解析,取不到时按加载失败处理。
@@ -296,18 +340,43 @@ export async function loadModel3dViewerObject(
}
const basePath = resolveModel3dViewerBasePath(source);
const resourceQuery =
source.kind === 'url' ? resolveModel3dViewerSourceQuery(source.url) : null;
/**
* 兄弟资源(buffer / 贴图)的加载管理器:模型地址带 query 时才建,把同一份鉴权
* query 补到模型目录下的资源地址上。用专用管理器而不是默认的那个,避免改动全局
* `DefaultLoadingManager` 影响宿主其它加载器。
*/
async function createResourceLoadingManager() {
if (!resourceQuery || !basePath) {
return undefined;
}
const { LoadingManager } = await import('three');
const manager = new LoadingManager();
manager.setURLModifier((url: string) =>
resolveModel3dViewerResourceUrl({
url,
basePath,
query: resourceQuery,
}),
);
return manager;
}
try {
if (format === 'fbx') {
const { FBXLoader } = await import(
'three/examples/jsm/loaders/FBXLoader.js'
);
return new FBXLoader().parse(data, basePath);
const manager = await createResourceLoadingManager();
return new FBXLoader(manager).parse(data, basePath);
}
const { GLTFLoader } = await import(
'three/examples/jsm/loaders/GLTFLoader.js'
);
const loader = new GLTFLoader();
const loader = new GLTFLoader(await createResourceLoadingManager());
const payload: string | ArrayBuffer =
format === 'gltf' ? new TextDecoder().decode(new Uint8Array(data)) : data;
const gltf = await new Promise<{ scene: ThreeTypes.Object3D }>(