diff --git a/API.md b/API.md index fc72d18..d51de69 100644 --- a/API.md +++ b/API.md @@ -332,11 +332,11 @@ WordPress rewrite 未开启导致 `/wp-json/*` 返回 404 时,后端会回退 | `GET` | `/minio/download` | 下载文件流 | | `DELETE` | `/minio/remove` | 删除文件 | | `GET` | `/blog/live2d/pio/catalog.json` | 公开读取 Pio Live2D 公共目录索引,按 Referer/Origin 白名单防盗链 | -| `GET` | `/blog/live2d/pio/:version/*assetPath` | 公开读取 Pio Live2D 运行包资源,按 Referer/Origin 白名单防盗链 | +| `GET` | `/blog/live2d/pio/:family/*assetPath` | 公开读取 Pio Live2D 运行包资源,`family` 只允许 `moc`/`moc3`,按 Referer/Origin 白名单防盗链 | `bucketName` 不传时使用 `MINIO_BUCKET`。 -Blog Live2D 运行包读取入口不使用 Vben 响应包装,直接返回 MinIO 文件流。根 `catalog.json` 暴露当前 Pio 版本、目录规范和动作/贴图计数,versioned asset 路由读取 `manifest.json`、runtime、model、motion、shader 和 source texture 文件。`BLOG_LIVE2D_ALLOWED_ORIGINS` 配置允许的 Blog 源,例如 `https://blog.kwitsukasa.top,http://localhost:5999`;`BLOG_LIVE2D_BUCKET` 和 `BLOG_LIVE2D_PREFIX` 决定对象位置。路由支持嵌套资源路径,如 `assets/textures/manifest.json` 和 `assets/model/motions/breath1.motion3.json`,并拒绝绝对 URL、反斜杠、`.`/`..` 和多重编码后的路径逃逸。 +Blog Live2D 运行包读取入口不使用 Vben 响应包装,直接返回 MinIO 文件流。根 `catalog.json` 暴露当前 Pio family、目录规范和动作/贴图计数;asset 路由只读取 `moc/` 或 `moc3/` 下的 `index.json`、`manifest.json`、runtime、model、motion、shader 和 source texture 文件。`BLOG_LIVE2D_ALLOWED_ORIGINS` 配置允许的 Blog 源,例如 `https://blog.kwitsukasa.top,http://localhost:5999`;`BLOG_LIVE2D_BUCKET` 和 `BLOG_LIVE2D_PREFIX` 决定对象位置。路由支持嵌套资源路径,如 `textures/default-costume.png` 和 `assets/model/motions/breath1.motion3.json`,并拒绝绝对 URL、反斜杠、`.`/`..`、多重编码后的路径逃逸和 `v1/v2` 这类自定义版本 family。 ## QQBot 管理 diff --git a/README.md b/README.md index 6f6c268..bfc8261 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ ci/ Jenkins Agent/Docker 辅助文件 `DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。 -Blog Live2D 运行包存放在 MinIO,公开读取入口为 `/blog/live2d/pio/catalog.json` 和 `/blog/live2d/pio/:version/*assetPath`。`catalog.json` 是 Pio 公共目录索引,versioned asset 路由读取 `v1/manifest.json`、runtime、model、motion、shader、source texture 等文件。`BLOG_LIVE2D_ALLOWED_ORIGINS` 是允许加载 Pio runtime 的 Blog 域名白名单,`BLOG_LIVE2D_BUCKET` 和 `BLOG_LIVE2D_PREFIX` 指向运行包对象位置;未通过 Referer/Origin 白名单的请求会在读取 MinIO 前拒绝。 +Blog Live2D 运行包存放在 MinIO,公开读取入口为 `/blog/live2d/pio/catalog.json` 和 `/blog/live2d/pio/:family/*assetPath`。`catalog.json` 是 Pio 公共目录索引,family 只允许 `moc` 和 `moc3`:`moc/` 提供旧 WordPress 同款 Cubism2 `index.json`、`model.moc`、`.mtn` 动作和贴图,`moc3/` 保留当前重建 Cubism3 包。`BLOG_LIVE2D_ALLOWED_ORIGINS` 是允许加载 Pio runtime 的 Blog 域名白名单,`BLOG_LIVE2D_BUCKET` 和 `BLOG_LIVE2D_PREFIX` 指向运行包对象位置;未通过 Referer/Origin 白名单的请求会在读取 MinIO 前拒绝。 QQBot 插件 worker 使用 BullMQ 队列串行执行同一插件安装实例的请求。K8s 生产清单包含内部服务 `kt-qqbot-plugin-redis`,生产 env 可将 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` 配为该服务名。`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS` 控制排队等待窗口,插件 `operation.timeoutMs` 仍表示单次执行预算。 diff --git a/src/modules/asset/application/blog-live2d-asset.service.ts b/src/modules/asset/application/blog-live2d-asset.service.ts index f1f391d..ab2b87a 100644 --- a/src/modules/asset/application/blog-live2d-asset.service.ts +++ b/src/modules/asset/application/blog-live2d-asset.service.ts @@ -14,6 +14,7 @@ const DEFAULT_ALLOWED_ORIGINS = 'https://blog.kwitsukasa.top'; const DEFAULT_LIVE2D_BUCKET = 'kt-template-online'; const DEFAULT_LIVE2D_PREFIX = 'blog/live2d/pio'; const MAX_DECODE_DEPTH = 6; +const ALLOWED_RUNTIME_FAMILIES = new Set(['moc', 'moc3']); /** * Detects MinIO/S3 object-missing errors from `statObject` and `getObject`. @@ -37,7 +38,7 @@ function isMinioObjectNotFound(error: unknown): boolean { export class BlogLive2DAssetService { /** * Creates the guarded Blog Live2D asset service. - * @param minioClientService - Existing MinIO helper used to stream versioned Pio runtime files. + * @param minioClientService - Existing MinIO helper used to stream Pio runtime family files. * @param configService - Runtime config source for bucket, object prefix, and allowed browser origins. */ constructor( @@ -68,17 +69,17 @@ export class BlogLive2DAssetService { /** * Streams one Pio runtime asset from the configured MinIO bucket and prefix. - * @param version - Runtime release segment such as `v1`; kept separate from object path to prevent route traversal. - * @param objectPath - Route tail below the version, including nested paths such as `textures/texture_00.png`. + * @param family - Runtime family segment; only `moc` and `moc3` are allowed under the public Pio root. + * @param objectPath - Route tail below the family, including nested paths such as `textures/default-costume.png`. * @returns MinIO stream and stat metadata for the requested object. */ async getRuntimeObject( - version: string, + family: string, objectPath: BlogLive2DRuntimeAssetPath, ): Promise { try { return await this.minioClientService.getObject( - this.resolveRuntimeObjectPath(version, objectPath), + this.resolveRuntimeObjectPath(family, objectPath), this.getBucketName(), ); } catch (error) { @@ -108,20 +109,20 @@ export class BlogLive2DAssetService { } /** - * Builds the MinIO object key for a versioned Pio runtime file. - * @param version - Runtime release segment supplied by the route. + * Builds the MinIO object key for a fixed-family Pio runtime file. + * @param family - Runtime family segment supplied by the route. * @param objectPath - Route tail supplied by the wildcard parameter. * @returns Full MinIO object key under the configured Pio prefix. */ resolveRuntimeObjectPath( - version: string, + family: string, objectPath: BlogLive2DRuntimeAssetPath, ): string { const prefix = this.getPrefixSegments(); - const versionSegment = this.normalizeRouteSegments(version, 'version'); + const familySegment = this.normalizeRuntimeFamily(family); const assetSegments = this.normalizeRouteSegments(objectPath, 'asset path'); - return [...prefix, ...versionSegment, ...assetSegments].join('/'); + return [...prefix, ...familySegment, ...assetSegments].join('/'); } /** @@ -211,6 +212,20 @@ export class BlogLive2DAssetService { return segments; } + /** + * Normalizes and validates the public Pio runtime family segment. + * @param family - Public runtime family requested by Blog Web; custom version directories are not allowed. + * @returns Single safe family segment, either `moc` or `moc3`. + */ + private normalizeRuntimeFamily(family: string): string[] { + const segments = this.normalizeRouteSegments(family, 'family'); + if (segments.length !== 1 || !ALLOWED_RUNTIME_FAMILIES.has(segments[0])) { + throw new BadRequestException('Invalid Live2D family'); + } + + return segments; + } + /** * Decodes route path text until stable so encoded traversal cannot pass through. * @param value - Raw path text from a route segment or config value. diff --git a/src/modules/asset/contract/blog-live2d-asset.controller.ts b/src/modules/asset/contract/blog-live2d-asset.controller.ts index d10f909..6906aed 100644 --- a/src/modules/asset/contract/blog-live2d-asset.controller.ts +++ b/src/modules/asset/contract/blog-live2d-asset.controller.ts @@ -43,22 +43,22 @@ export class BlogLive2DAssetController { } /** - * Streams a versioned Pio runtime asset after Origin/Referer validation. - * @param version - Runtime version segment such as `v1`. - * @param assetPath - Wildcard asset path under the version, including nested texture or motion folders. + * Streams a fixed-family Pio runtime asset after Origin/Referer validation. + * @param family - Runtime family segment, either `moc` for WordPress Cubism2 parity or `moc3` for reconstructed Cubism3 assets. + * @param assetPath - Wildcard asset path under the family, including nested texture or motion folders. * @param referer - Browser Referer header used by the hotlink protection policy. * @param origin - Browser Origin header used when the request type supplies it. * @param res - Express response that receives MinIO content headers and stream bytes. * @returns Promise resolved after the stream is attached to the Express response; no Vben body is returned. */ - @Get('pio/:version/*assetPath') + @Get('pio/:family/*assetPath') @ApiOperation({ summary: '获取 Pio Live2D 运行时资源' }) - @ApiParam({ name: 'version', example: 'v1' }) - @ApiParam({ name: 'assetPath', example: 'textures/texture_00.png' }) + @ApiParam({ name: 'family', example: 'moc' }) + @ApiParam({ name: 'assetPath', example: 'textures/default-costume.png' }) @ApiFileDownloadResponse('Pio Live2D runtime asset stream') @Public() async getPioAsset( - @Param('version') version: string, + @Param('family') family: string, @Param('assetPath') assetPath: BlogLive2DRuntimeAssetPath, @Headers('referer') referer: string | undefined, @Headers('origin') origin: string | undefined, @@ -66,7 +66,7 @@ export class BlogLive2DAssetController { ) { this.blogLive2DAssetService.assertAllowedRequest(referer, origin); const { stream, stat, objectName } = - await this.blogLive2DAssetService.getRuntimeObject(version, assetPath); + await this.blogLive2DAssetService.getRuntimeObject(family, assetPath); res.setHeader( 'Content-Type', @@ -79,12 +79,10 @@ export class BlogLive2DAssetController { /** * Chooses browser cache policy based on the streamed Live2D object name. * @param objectName - MinIO object key returned by the asset service. - * @returns Short cache for manifests and immutable cache for hashed or versioned runtime assets. + * @returns Short cache for mutable JSON entries and immutable cache for binary model, motion, and texture files. */ private getCacheControl(objectName: string): string { - return objectName.endsWith('manifest.json') || - objectName.endsWith('catalog.json') || - objectName.endsWith('.model3.json') + return objectName.endsWith('.json') ? 'public, max-age=60' : 'public, max-age=31536000, immutable'; } diff --git a/src/modules/asset/contract/blog-live2d-asset.dto.ts b/src/modules/asset/contract/blog-live2d-asset.dto.ts index ef93e67..5883872 100644 --- a/src/modules/asset/contract/blog-live2d-asset.dto.ts +++ b/src/modules/asset/contract/blog-live2d-asset.dto.ts @@ -4,16 +4,15 @@ export class BlogLive2DManifestDto { @ApiProperty({ example: 'pio' }) character: string; - @ApiProperty({ example: 'v1' }) - version: string; + @ApiProperty({ example: 'moc' }) + family: string; @ApiProperty({ example: true }) desktopOnly: boolean; @ApiProperty({ - example: - '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json', + example: '/api/blog/live2d/pio/moc/index.json', }) - model3: string; + entry: string; } diff --git a/src/modules/asset/domain/blog-live2d-asset.types.ts b/src/modules/asset/domain/blog-live2d-asset.types.ts index 2312cd7..7226a17 100644 --- a/src/modules/asset/domain/blog-live2d-asset.types.ts +++ b/src/modules/asset/domain/blog-live2d-asset.types.ts @@ -3,10 +3,10 @@ import type { MinioObjectResult } from './asset-minio.types'; export type BlogLive2DRuntimeAssetPath = string | string[]; export type BlogLive2DAssetRequest = { + family: string; objectPath: BlogLive2DRuntimeAssetPath; origin?: string; referer?: string; - version: string; }; export type BlogLive2DAssetResult = MinioObjectResult; diff --git a/test/asset/blog-live2d-asset.service.spec.ts b/test/asset/blog-live2d-asset.service.spec.ts index 8b2a47b..ffb1c6c 100644 --- a/test/asset/blog-live2d-asset.service.spec.ts +++ b/test/asset/blog-live2d-asset.service.spec.ts @@ -118,14 +118,14 @@ describe('BlogLive2DAssetService', () => { ); }); - it('maps nested runtime files below the configured MinIO prefix', async () => { + it('maps nested MOC3 runtime files below the configured MinIO prefix', async () => { const minio = createMinio(); const service = new BlogLive2DAssetService( minio as never, createConfig() as never, ); - await service.getRuntimeObject('v1', [ + await service.getRuntimeObject('moc3', [ 'assets', 'model', 'motions', @@ -133,7 +133,22 @@ describe('BlogLive2DAssetService', () => { ]); expect(minio.getObject).toHaveBeenCalledWith( - 'blog/live2d/pio/v1/assets/model/motions/breath1.motion3.json', + 'blog/live2d/pio/moc3/assets/model/motions/breath1.motion3.json', + 'kt-template-online', + ); + }); + + it('maps the fixed MOC family entry below the configured MinIO prefix', async () => { + const minio = createMinio(); + const service = new BlogLive2DAssetService( + minio as never, + createConfig() as never, + ); + + await service.getRuntimeObject('moc', ['index.json']); + + expect(minio.getObject).toHaveBeenCalledWith( + 'blog/live2d/pio/moc/index.json', 'kt-template-online', ); }); @@ -149,13 +164,13 @@ describe('BlogLive2DAssetService', () => { ); await expect( - service.getRuntimeObject('v1', ['manifest.json']), + service.getRuntimeObject('moc', ['manifest.json']), ).rejects.toMatchObject({ status: HttpStatus.NOT_FOUND, message: 'Live2D runtime asset not found', }); expect(minio.getObject).toHaveBeenCalledWith( - 'blog/live2d/pio/v1/manifest.json', + 'blog/live2d/pio/moc/manifest.json', 'kt-template-online', ); }); @@ -168,27 +183,28 @@ describe('BlogLive2DAssetService', () => { ); await expect( - service.getRuntimeObject('v1', ['%252e%252e', 'secret.env']), + service.getRuntimeObject('moc', ['%252e%252e', 'secret.env']), ).rejects.toThrow(BadRequestException); expect(minio.getObject).not.toHaveBeenCalled(); }); it.each([ - ['absolute URL', 'v1', ['https://evil.test', 'texture.png']], - ['absolute path', 'v1', ['/textures', 'texture.png']], - ['backslash path', 'v1', ['textures\\texture.png']], - ['dot segment', 'v1', ['.', 'texture.png']], - ['version traversal', '../v1', ['texture.png']], + ['absolute URL', 'moc', ['https://evil.test', 'texture.png']], + ['absolute path', 'moc', ['/textures', 'texture.png']], + ['backslash path', 'moc', ['textures\\texture.png']], + ['dot segment', 'moc', ['.', 'texture.png']], + ['family traversal', '../moc', ['texture.png']], + ['custom version family', 'v1', ['manifest.json']], ])( 'rejects unsafe %s runtime paths before touching MinIO', - async (_name, version, objectPath) => { + async (_name, family, objectPath) => { const minio = createMinio(); const service = new BlogLive2DAssetService( minio as never, createConfig() as never, ); - await expect(service.getRuntimeObject(version, objectPath)).rejects.toThrow( + await expect(service.getRuntimeObject(family, objectPath)).rejects.toThrow( BadRequestException, ); expect(minio.getObject).not.toHaveBeenCalled(); @@ -255,7 +271,7 @@ describe('BlogLive2DAssetController', () => { try { const response = await request(app.getHttpServer()) - .get('/blog/live2d/pio/v1/assets/textures/default-costume.png') + .get('/blog/live2d/pio/moc3/assets/textures/default-costume.png') .set('Referer', 'https://blog.kwitsukasa.top/post/1') .expect(HttpStatus.OK); @@ -265,7 +281,44 @@ describe('BlogLive2DAssetController', () => { 'public, max-age=31536000, immutable', ); expect(minio.getObject).toHaveBeenCalledWith( - 'blog/live2d/pio/v1/assets/textures/default-costume.png', + 'blog/live2d/pio/moc3/assets/textures/default-costume.png', + 'kt-template-online', + ); + } finally { + await app.close(); + } + }); + + it('streams fixed Pio MOC JSON assets with a short cache policy', async () => { + const minio = createMinio(); + const moduleRef = await Test.createTestingModule({ + controllers: [BlogLive2DAssetController], + providers: [ + BlogLive2DAssetService, + { + provide: MinioClientService, + useValue: minio, + }, + { + provide: ConfigService, + useValue: createConfig(), + }, + ], + }).compile(); + const app = moduleRef.createNestApplication(); + await app.init(); + + try { + const response = await request(app.getHttpServer()) + .get('/blog/live2d/pio/moc/index.json') + .set('Referer', 'https://blog.kwitsukasa.top/post/1') + .expect(HttpStatus.OK); + + expect(response.body).toEqual({ ok: true }); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.headers['cache-control']).toBe('public, max-age=60'); + expect(minio.getObject).toHaveBeenCalledWith( + 'blog/live2d/pio/moc/index.json', 'kt-template-online', ); } finally { @@ -294,7 +347,7 @@ describe('BlogLive2DAssetController', () => { try { await request(app.getHttpServer()) - .get('/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json') + .get('/blog/live2d/pio/moc3/assets/model/pio.moc-reconstructed.model3.json') .set('Referer', 'https://example.com/post/1') .expect(HttpStatus.BAD_REQUEST); expect(minio.getObject).not.toHaveBeenCalled(); diff --git a/test/modules/asset/asset-module-contract.spec.ts b/test/modules/asset/asset-module-contract.spec.ts index b19a5fe..57ccad5 100644 --- a/test/modules/asset/asset-module-contract.spec.ts +++ b/test/modules/asset/asset-module-contract.spec.ts @@ -65,7 +65,7 @@ describe('Asset module contract', () => { 'GET /minio/download', 'DELETE /minio/remove', 'GET /blog/live2d/pio/catalog.json', - 'GET /blog/live2d/pio/:version/*assetPath', + 'GET /blog/live2d/pio/:family/*assetPath', ]), ); });