fix: 收敛Blog Live2D资源family
This commit is contained in:
parent
53a590b09d
commit
b7b335ca5d
4
API.md
4
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 管理
|
||||
|
||||
|
||||
@ -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` 仍表示单次执行预算。
|
||||
|
||||
|
||||
@ -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<BlogLive2DAssetResult> {
|
||||
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.
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user