3D 查看器给签名地址的兄弟资源补回鉴权 query
- loader 新增 resolveModel3dViewerSourceQuery / resolveModel3dViewerResourceUrl,只补模型目录下的资源地址 - glTF / FBX 解析改用带 URL 修饰器的专用 LoadingManager,不动全局 DefaultLoadingManager - 补测试:query 提取与片段剔除、目录内外的补与不补、多文件 glTF 兄弟资源解析
This commit is contained in:
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 走 GLTFLoader,fbx 走 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 }>(
|
||||
|
||||
Reference in New Issue
Block a user