diff --git a/API.md b/API.md index d51de69..2ecf13d 100644 --- a/API.md +++ b/API.md @@ -93,7 +93,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 | 分组 | 关键变量 | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | -| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_PREFIX` | +| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` | | Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` | | Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` | @@ -331,12 +331,12 @@ WordPress rewrite 未开启导致 `/wp-json/*` 返回 404 时,后端会回退 | `GET` | `/minio/resource-proxy` | 代理读取资源 | | `GET` | `/minio/download` | 下载文件流 | | `DELETE` | `/minio/remove` | 删除文件 | -| `GET` | `/blog/live2d/pio/catalog.json` | 公开读取 Pio Live2D 公共目录索引,按 Referer/Origin 白名单防盗链 | -| `GET` | `/blog/live2d/pio/:family/*assetPath` | 公开读取 Pio Live2D 运行包资源,`family` 只允许 `moc`/`moc3`,按 Referer/Origin 白名单防盗链 | +| `GET` | `/blog/live2d/:character/catalog.json` | 公开读取 Pio/Tia Live2D 公共目录索引,按 Referer/Origin 白名单防盗链 | +| `GET` | `/blog/live2d/:character/:family/*assetPath` | 公开读取 Pio/Tia Live2D 运行包资源,`character` 只允许 `pio`/`tia`,`family` 只允许 `moc`/`moc3`,按 Referer/Origin 白名单防盗链 | `bucketName` 不传时使用 `MINIO_BUCKET`。 -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。 +Blog Live2D 运行包读取入口不使用 Vben 响应包装,直接返回 MinIO 文件流。根 `catalog.json` 暴露当前角色 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` 决定 bucket,`BLOG_LIVE2D_ROOT_PREFIX` 决定角色根目录(默认 `blog/live2d`),旧 `BLOG_LIVE2D_PREFIX=blog/live2d/pio` 会自动派生到同一根前缀以兼容现有环境。路由支持嵌套资源路径,如 `textures/default-costume.png` 和 `assets/model/motions/breath1.motion3.json`,并拒绝绝对 URL、反斜杠、`.`/`..`、多重编码后的路径逃逸、未知角色和 `v1/v2` 这类自定义版本 family。 ## QQBot 管理 diff --git a/README.md b/README.md index bfc8261..a234d2e 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ ci/ Jenkins Agent/Docker 辅助文件 | 分组 | 变量 | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | -| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_PREFIX` | +| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` | | Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` | | Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` | @@ -71,7 +71,7 @@ ci/ Jenkins Agent/Docker 辅助文件 `DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。 -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 前拒绝。 +Blog Live2D 运行包存放在 MinIO,公开读取入口为 `/blog/live2d/:character/catalog.json` 和 `/blog/live2d/:character/:family/*assetPath`。`character` 只允许 `pio`、`tia`,family 只允许 `moc` 和 `moc3`:`moc/` 提供旧 WordPress 同款 Cubism2 `index.json`、`model.moc`、`.mtn` 动作和贴图,`moc3/` 保留当前重建 Cubism3 包(Tia 当前只发布 `moc/`,不会在 catalog 声明不存在的 MOC3)。`BLOG_LIVE2D_ALLOWED_ORIGINS` 是允许加载 Live2D runtime 的 Blog 域名白名单;`BLOG_LIVE2D_ROOT_PREFIX` 指向角色根目录(默认 `blog/live2d`),旧 `BLOG_LIVE2D_PREFIX=blog/live2d/pio` 会自动派生到同一根前缀以兼容现有环境;未通过 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 ab2b87a..227fcfb 100644 --- a/src/modules/asset/application/blog-live2d-asset.service.ts +++ b/src/modules/asset/application/blog-live2d-asset.service.ts @@ -7,13 +7,16 @@ import { ConfigService } from '@nestjs/config'; import { MinioClientService } from './asset-minio.service'; import type { BlogLive2DAssetResult, + BlogLive2DCharacter, BlogLive2DRuntimeAssetPath, } from '../domain/blog-live2d-asset.types'; const DEFAULT_ALLOWED_ORIGINS = 'https://blog.kwitsukasa.top'; const DEFAULT_LIVE2D_BUCKET = 'kt-template-online'; +const DEFAULT_LIVE2D_ROOT_PREFIX = 'blog/live2d'; const DEFAULT_LIVE2D_PREFIX = 'blog/live2d/pio'; const MAX_DECODE_DEPTH = 6; +const ALLOWED_LIVE2D_CHARACTERS = new Set(['pio', 'tia']); const ALLOWED_RUNTIME_FAMILIES = new Set(['moc', 'moc3']); /** @@ -68,18 +71,20 @@ export class BlogLive2DAssetService { } /** - * Streams one Pio runtime asset from the configured MinIO bucket and prefix. + * Streams one Blog Live2D runtime asset from the configured MinIO bucket. + * @param character - Public character segment; only `pio` and `tia` are exposed so arbitrary MinIO prefixes cannot be streamed. * @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( + character: string, family: string, objectPath: BlogLive2DRuntimeAssetPath, ): Promise { try { return await this.minioClientService.getObject( - this.resolveRuntimeObjectPath(family, objectPath), + this.resolveRuntimeObjectPath(character, family, objectPath), this.getBucketName(), ); } catch (error) { @@ -91,13 +96,18 @@ export class BlogLive2DAssetService { } /** - * Streams the Pio root catalog from the configured MinIO prefix. + * Streams one character root catalog from the configured MinIO root prefix. + * @param character - Public character segment; keeps catalog streaming inside the `pio|tia` allowlist. * @returns MinIO stream and stat metadata for `catalog.json`. */ - async getCatalogObject(): Promise { + async getCatalogObject(character: string): Promise { try { return await this.minioClientService.getObject( - [...this.getPrefixSegments(), 'catalog.json'].join('/'), + [ + ...this.getRootPrefixSegments(), + this.normalizeCharacter(character), + 'catalog.json', + ].join('/'), this.getBucketName(), ); } catch (error) { @@ -109,20 +119,25 @@ export class BlogLive2DAssetService { } /** - * Builds the MinIO object key for a fixed-family Pio runtime file. + * Builds the MinIO object key for a fixed-family character runtime file. + * @param character - Public character segment placed between root prefix and family. * @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. + * @returns Full MinIO object key under the configured Blog Live2D root prefix. */ resolveRuntimeObjectPath( + character: string, family: string, objectPath: BlogLive2DRuntimeAssetPath, ): string { - const prefix = this.getPrefixSegments(); + const prefix = this.getRootPrefixSegments(); + const characterSegment = this.normalizeCharacter(character); const familySegment = this.normalizeRuntimeFamily(family); const assetSegments = this.normalizeRouteSegments(objectPath, 'asset path'); - return [...prefix, ...familySegment, ...assetSegments].join('/'); + return [...prefix, characterSegment, ...familySegment, ...assetSegments].join( + '/', + ); } /** @@ -137,13 +152,25 @@ export class BlogLive2DAssetService { } /** - * @returns Sanitized MinIO object-key prefix for Pio runtime files. + * @returns Sanitized MinIO object-key root prefix before the character segment. */ - private getPrefixSegments(): string[] { - return this.normalizeRouteSegments( + private getRootPrefixSegments(): string[] { + const rootPrefix = this.configService.get('BLOG_LIVE2D_ROOT_PREFIX'); + if (rootPrefix) { + return this.normalizeRouteSegments(rootPrefix, 'root prefix'); + } + + const legacyPrefix = this.configService.get('BLOG_LIVE2D_PREFIX') || - DEFAULT_LIVE2D_PREFIX, - 'prefix', + DEFAULT_LIVE2D_PREFIX; + const segments = this.normalizeRouteSegments(legacyPrefix, 'prefix'); + if (segments.at(-1) === 'pio') { + return segments.slice(0, -1); + } + + return this.normalizeRouteSegments( + DEFAULT_LIVE2D_ROOT_PREFIX, + 'root prefix', ); } @@ -178,6 +205,23 @@ export class BlogLive2DAssetService { } } + /** + * Normalizes and validates the public Blog Live2D character segment. + * @param character - Route character segment requested by Blog Web. + * @returns Safe character key used as the first public child below the root prefix. + */ + private normalizeCharacter(character: string): BlogLive2DCharacter { + const segments = this.normalizeRouteSegments(character, 'character'); + if ( + segments.length !== 1 || + !ALLOWED_LIVE2D_CHARACTERS.has(segments[0]) + ) { + throw new BadRequestException('Invalid Live2D character'); + } + + return segments[0] as BlogLive2DCharacter; + } + /** * Normalizes route and config path values into safe object-key segments. * @param input - String or wildcard array supplied by Nest/path-to-regexp or runtime config. diff --git a/src/modules/asset/contract/blog-live2d-asset.controller.ts b/src/modules/asset/contract/blog-live2d-asset.controller.ts index 6906aed..0bdf257 100644 --- a/src/modules/asset/contract/blog-live2d-asset.controller.ts +++ b/src/modules/asset/contract/blog-live2d-asset.controller.ts @@ -15,24 +15,27 @@ export class BlogLive2DAssetController { constructor(private readonly blogLive2DAssetService: BlogLive2DAssetService) {} /** - * Streams the public Pio root catalog after Origin/Referer validation. + * Streams a public Blog Live2D character root catalog after Origin/Referer validation. + * @param character - Public character segment, currently `pio` or `tia`. * @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/catalog.json') - @ApiOperation({ summary: '获取 Pio Live2D 目录规范索引' }) - @ApiFileDownloadResponse('Pio Live2D root catalog stream') + @Get(':character/catalog.json') + @ApiOperation({ summary: '获取 Blog Live2D 角色目录规范索引' }) + @ApiParam({ name: 'character', enum: ['pio', 'tia'], example: 'pio' }) + @ApiFileDownloadResponse('Blog Live2D character root catalog stream') @Public() - async getPioCatalog( + async getCharacterCatalog( + @Param('character') character: string, @Headers('referer') referer: string | undefined, @Headers('origin') origin: string | undefined, @Res() res: Response, ) { this.blogLive2DAssetService.assertAllowedRequest(referer, origin); const { stream, stat, objectName } = - await this.blogLive2DAssetService.getCatalogObject(); + await this.blogLive2DAssetService.getCatalogObject(character); res.setHeader( 'Content-Type', @@ -43,7 +46,8 @@ export class BlogLive2DAssetController { } /** - * Streams a fixed-family Pio runtime asset after Origin/Referer validation. + * Streams a fixed-family Blog Live2D runtime asset after Origin/Referer validation. + * @param character - Public character segment, currently `pio` or `tia`. * @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. @@ -51,13 +55,15 @@ export class BlogLive2DAssetController { * @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/:family/*assetPath') - @ApiOperation({ summary: '获取 Pio Live2D 运行时资源' }) + @Get(':character/:family/*assetPath') + @ApiOperation({ summary: '获取 Blog Live2D 角色运行时资源' }) + @ApiParam({ name: 'character', enum: ['pio', 'tia'], example: 'tia' }) @ApiParam({ name: 'family', example: 'moc' }) @ApiParam({ name: 'assetPath', example: 'textures/default-costume.png' }) - @ApiFileDownloadResponse('Pio Live2D runtime asset stream') + @ApiFileDownloadResponse('Blog Live2D runtime asset stream') @Public() - async getPioAsset( + async getCharacterAsset( + @Param('character') character: string, @Param('family') family: string, @Param('assetPath') assetPath: BlogLive2DRuntimeAssetPath, @Headers('referer') referer: string | undefined, @@ -66,7 +72,11 @@ export class BlogLive2DAssetController { ) { this.blogLive2DAssetService.assertAllowedRequest(referer, origin); const { stream, stat, objectName } = - await this.blogLive2DAssetService.getRuntimeObject(family, assetPath); + await this.blogLive2DAssetService.getRuntimeObject( + character, + family, + assetPath, + ); res.setHeader( 'Content-Type', diff --git a/src/modules/asset/contract/blog-live2d-asset.dto.ts b/src/modules/asset/contract/blog-live2d-asset.dto.ts index 5883872..c4d053e 100644 --- a/src/modules/asset/contract/blog-live2d-asset.dto.ts +++ b/src/modules/asset/contract/blog-live2d-asset.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; export class BlogLive2DManifestDto { - @ApiProperty({ example: 'pio' }) + @ApiProperty({ enum: ['pio', 'tia'], example: 'tia' }) character: string; @ApiProperty({ example: 'moc' }) @@ -11,7 +11,7 @@ export class BlogLive2DManifestDto { desktopOnly: boolean; @ApiProperty({ - example: '/api/blog/live2d/pio/moc/index.json', + example: '/api/blog/live2d/tia/moc/index.json', }) 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 7226a17..99e95a6 100644 --- a/src/modules/asset/domain/blog-live2d-asset.types.ts +++ b/src/modules/asset/domain/blog-live2d-asset.types.ts @@ -1,8 +1,10 @@ import type { MinioObjectResult } from './asset-minio.types'; export type BlogLive2DRuntimeAssetPath = string | string[]; +export type BlogLive2DCharacter = 'pio' | 'tia'; export type BlogLive2DAssetRequest = { + character: BlogLive2DCharacter; family: string; objectPath: BlogLive2DRuntimeAssetPath; origin?: string; diff --git a/test/asset/blog-live2d-asset.service.spec.ts b/test/asset/blog-live2d-asset.service.spec.ts index cdef14e..67c77d1 100644 --- a/test/asset/blog-live2d-asset.service.spec.ts +++ b/test/asset/blog-live2d-asset.service.spec.ts @@ -110,7 +110,7 @@ describe('BlogLive2DAssetService', () => { createConfig() as never, ); - await service.getCatalogObject(); + await service.getCatalogObject('pio'); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/catalog.json', @@ -125,7 +125,7 @@ describe('BlogLive2DAssetService', () => { createConfig() as never, ); - await service.getRuntimeObject('moc3', [ + await service.getRuntimeObject('pio', 'moc3', [ 'assets', 'model', 'motions', @@ -145,7 +145,7 @@ describe('BlogLive2DAssetService', () => { createConfig() as never, ); - await service.getRuntimeObject('moc', ['index.json']); + await service.getRuntimeObject('pio', 'moc', ['index.json']); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/moc/index.json', @@ -160,7 +160,10 @@ describe('BlogLive2DAssetService', () => { createConfig() as never, ); - await service.getRuntimeObject('moc', ['textures', 'manifest.json']); + await service.getRuntimeObject('pio', 'moc', [ + 'textures', + 'manifest.json', + ]); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/moc/textures/manifest.json', @@ -179,7 +182,7 @@ describe('BlogLive2DAssetService', () => { ); await expect( - service.getRuntimeObject('moc', ['manifest.json']), + service.getRuntimeObject('pio', 'moc', ['manifest.json']), ).rejects.toMatchObject({ status: HttpStatus.NOT_FOUND, message: 'Live2D runtime asset not found', @@ -198,7 +201,7 @@ describe('BlogLive2DAssetService', () => { ); await expect( - service.getRuntimeObject('moc', ['%252e%252e', 'secret.env']), + service.getRuntimeObject('pio', 'moc', ['%252e%252e', 'secret.env']), ).rejects.toThrow(BadRequestException); expect(minio.getObject).not.toHaveBeenCalled(); }); @@ -219,12 +222,58 @@ describe('BlogLive2DAssetService', () => { createConfig() as never, ); - await expect(service.getRuntimeObject(family, objectPath)).rejects.toThrow( - BadRequestException, - ); + await expect( + service.getRuntimeObject('pio', family, objectPath), + ).rejects.toThrow(BadRequestException); expect(minio.getObject).not.toHaveBeenCalled(); }, ); + + it('maps the Tia root catalog below the shared Blog Live2D root prefix', async () => { + const minio = createMinio(); + const service = new BlogLive2DAssetService( + minio as never, + createConfig({ BLOG_LIVE2D_ROOT_PREFIX: 'blog/live2d' }) as never, + ); + + await service.getCatalogObject('tia'); + + expect(minio.getObject).toHaveBeenCalledWith( + 'blog/live2d/tia/catalog.json', + 'kt-template-online', + ); + }); + + it('maps Tia MOC runtime files below the Tia public root', async () => { + const minio = createMinio(); + const service = new BlogLive2DAssetService( + minio as never, + createConfig({ BLOG_LIVE2D_ROOT_PREFIX: 'blog/live2d' }) as never, + ); + + await service.getRuntimeObject('tia', 'moc', [ + 'textures', + 'default-costume.png', + ]); + + expect(minio.getObject).toHaveBeenCalledWith( + 'blog/live2d/tia/moc/textures/default-costume.png', + 'kt-template-online', + ); + }); + + it('rejects unsupported Live2D characters before touching MinIO', async () => { + const minio = createMinio(); + const service = new BlogLive2DAssetService( + minio as never, + createConfig({ BLOG_LIVE2D_ROOT_PREFIX: 'blog/live2d' }) as never, + ); + + await expect( + service.getRuntimeObject('evil', 'moc', ['index.json']), + ).rejects.toThrow(BadRequestException); + expect(minio.getObject).not.toHaveBeenCalled(); + }); }); describe('BlogLive2DAssetController', () => { @@ -378,6 +427,77 @@ describe('BlogLive2DAssetController', () => { } }); + it('streams Tia MOC assets for allowed blog requests', async () => { + const minio = createMinio(); + const moduleRef = await Test.createTestingModule({ + controllers: [BlogLive2DAssetController], + providers: [ + BlogLive2DAssetService, + { + provide: MinioClientService, + useValue: minio, + }, + { + provide: ConfigService, + useValue: createConfig({ BLOG_LIVE2D_ROOT_PREFIX: 'blog/live2d' }), + }, + ], + }).compile(); + const app = moduleRef.createNestApplication(); + await app.init(); + + try { + const response = await request(app.getHttpServer()) + .get('/blog/live2d/tia/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/tia/moc/index.json', + 'kt-template-online', + ); + } finally { + await app.close(); + } + }); + + it('rejects unsupported character requests before streaming assets', async () => { + const minio = createMinio(); + const moduleRef = await Test.createTestingModule({ + controllers: [BlogLive2DAssetController], + providers: [ + BlogLive2DAssetService, + { + provide: MinioClientService, + useValue: minio, + }, + { + provide: ConfigService, + useValue: createConfig({ BLOG_LIVE2D_ROOT_PREFIX: 'blog/live2d' }), + }, + ], + }).compile(); + const app = moduleRef.createNestApplication(); + await app.init(); + + try { + await request(app.getHttpServer()) + .get('/blog/live2d/evil/catalog.json') + .set('Referer', 'https://blog.kwitsukasa.top/post/1') + .expect(HttpStatus.BAD_REQUEST); + await request(app.getHttpServer()) + .get('/blog/live2d/evil/moc/index.json') + .set('Referer', 'https://blog.kwitsukasa.top/post/1') + .expect(HttpStatus.BAD_REQUEST); + expect(minio.getObject).not.toHaveBeenCalled(); + } finally { + await app.close(); + } + }); + it('rejects external hotlink requests before streaming assets', async () => { const minio = createMinio(); const moduleRef = await Test.createTestingModule({ diff --git a/test/modules/asset/asset-module-contract.spec.ts b/test/modules/asset/asset-module-contract.spec.ts index 57ccad5..3a9b9fa 100644 --- a/test/modules/asset/asset-module-contract.spec.ts +++ b/test/modules/asset/asset-module-contract.spec.ts @@ -64,8 +64,8 @@ describe('Asset module contract', () => { 'GET /minio/resource-proxy', 'GET /minio/download', 'DELETE /minio/remove', - 'GET /blog/live2d/pio/catalog.json', - 'GET /blog/live2d/pio/:family/*assetPath', + 'GET /blog/live2d/:character/catalog.json', + 'GET /blog/live2d/:character/:family/*assetPath', ]), ); }); @@ -128,13 +128,13 @@ describe('Asset module contract', () => { expect( Reflect.getMetadata( IS_PUBLIC_KEY, - BlogLive2DAssetController.prototype.getPioCatalog, + BlogLive2DAssetController.prototype.getCharacterCatalog, ), ).toBe(true); expect( Reflect.getMetadata( IS_PUBLIC_KEY, - BlogLive2DAssetController.prototype.getPioAsset, + BlogLive2DAssetController.prototype.getCharacterAsset, ), ).toBe(true); });