feat: 支持Pio Live2D根目录索引
This commit is contained in:
parent
d6135a2012
commit
53a590b09d
3
API.md
3
API.md
@ -331,11 +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/:version/*assetPath` | 公开读取 Pio Live2D 运行包资源,按 Referer/Origin 白名单防盗链 |
|
||||
|
||||
`bucketName` 不传时使用 `MINIO_BUCKET`。
|
||||
|
||||
Blog Live2D 运行包读取入口不使用 Vben 响应包装,直接返回 MinIO 文件流。`BLOG_LIVE2D_ALLOWED_ORIGINS` 配置允许的 Blog 源,例如 `https://blog.kwitsukasa.top,http://localhost:5999`;`BLOG_LIVE2D_BUCKET` 和 `BLOG_LIVE2D_PREFIX` 决定对象位置。路由支持嵌套资源路径,如 `textures/texture_00.png` 和 `motions/idle.motion3.json`,并拒绝绝对 URL、反斜杠、`.`/`..` 和多重编码后的路径逃逸。
|
||||
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、反斜杠、`.`/`..` 和多重编码后的路径逃逸。
|
||||
|
||||
## QQBot 管理
|
||||
|
||||
|
||||
@ -71,7 +71,7 @@ ci/ Jenkins Agent/Docker 辅助文件
|
||||
|
||||
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
|
||||
|
||||
Blog Live2D 运行包存放在 MinIO,公开读取入口为 `/blog/live2d/pio/:version/*assetPath`。`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/: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 前拒绝。
|
||||
|
||||
QQBot 插件 worker 使用 BullMQ 队列串行执行同一插件安装实例的请求。K8s 生产清单包含内部服务 `kt-qqbot-plugin-redis`,生产 env 可将 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` 配为该服务名。`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS` 控制排队等待窗口,插件 `operation.timeoutMs` 仍表示单次执行预算。
|
||||
|
||||
|
||||
@ -89,6 +89,24 @@ export class BlogLive2DAssetService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the Pio root catalog from the configured MinIO prefix.
|
||||
* @returns MinIO stream and stat metadata for `catalog.json`.
|
||||
*/
|
||||
async getCatalogObject(): Promise<BlogLive2DAssetResult> {
|
||||
try {
|
||||
return await this.minioClientService.getObject(
|
||||
[...this.getPrefixSegments(), 'catalog.json'].join('/'),
|
||||
this.getBucketName(),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isMinioObjectNotFound(error)) {
|
||||
throw new NotFoundException('Live2D runtime asset not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the MinIO object key for a versioned Pio runtime file.
|
||||
* @param version - Runtime release segment supplied by the route.
|
||||
|
||||
@ -14,6 +14,34 @@ export class BlogLive2DAssetController {
|
||||
*/
|
||||
constructor(private readonly blogLive2DAssetService: BlogLive2DAssetService) {}
|
||||
|
||||
/**
|
||||
* Streams the public Pio root catalog after Origin/Referer validation.
|
||||
* @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')
|
||||
@Public()
|
||||
async getPioCatalog(
|
||||
@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();
|
||||
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
stat.metaData?.['content-type'] || 'application/json',
|
||||
);
|
||||
res.setHeader('Cache-Control', this.getCacheControl(objectName));
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a versioned Pio runtime asset after Origin/Referer validation.
|
||||
* @param version - Runtime version segment such as `v1`.
|
||||
@ -55,6 +83,7 @@ export class BlogLive2DAssetController {
|
||||
*/
|
||||
private getCacheControl(objectName: string): string {
|
||||
return objectName.endsWith('manifest.json') ||
|
||||
objectName.endsWith('catalog.json') ||
|
||||
objectName.endsWith('.model3.json')
|
||||
? 'public, max-age=60'
|
||||
: 'public, max-age=31536000, immutable';
|
||||
|
||||
@ -11,7 +11,8 @@ export class BlogLive2DManifestDto {
|
||||
desktopOnly: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: '/api/blog/live2d/pio/v1/pio.model3.json',
|
||||
example:
|
||||
'/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
|
||||
})
|
||||
model3: string;
|
||||
}
|
||||
|
||||
@ -33,21 +33,22 @@ function createConfig(overrides: Record<string, string | undefined> = {}) {
|
||||
function createMinio() {
|
||||
return {
|
||||
getDefaultBucket: jest.fn(() => 'kt-template-online'),
|
||||
getObject: jest.fn(async (objectName: string, bucketName?: string) => ({
|
||||
bucketName,
|
||||
objectName,
|
||||
stat: {
|
||||
etag: 'etag-1',
|
||||
lastModified: new Date('2026-07-04T00:00:00.000Z'),
|
||||
metaData: {
|
||||
'content-type': objectName.endsWith('.json')
|
||||
? 'application/json'
|
||||
: 'image/png',
|
||||
getObject: jest.fn(async (objectName: string, bucketName?: string) => {
|
||||
const isJson = objectName.endsWith('.json');
|
||||
return {
|
||||
bucketName,
|
||||
objectName,
|
||||
stat: {
|
||||
etag: 'etag-1',
|
||||
lastModified: new Date('2026-07-04T00:00:00.000Z'),
|
||||
metaData: {
|
||||
'content-type': isJson ? 'application/json' : 'image/png',
|
||||
},
|
||||
size: 2,
|
||||
},
|
||||
size: 2,
|
||||
},
|
||||
stream: Readable.from(['ok']),
|
||||
})),
|
||||
stream: Readable.from([isJson ? '{"ok":true}' : 'ok']),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -102,6 +103,21 @@ describe('BlogLive2DAssetService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('maps the root catalog below the configured MinIO prefix', async () => {
|
||||
const minio = createMinio();
|
||||
const service = new BlogLive2DAssetService(
|
||||
minio as never,
|
||||
createConfig() as never,
|
||||
);
|
||||
|
||||
await service.getCatalogObject();
|
||||
|
||||
expect(minio.getObject).toHaveBeenCalledWith(
|
||||
'blog/live2d/pio/catalog.json',
|
||||
'kt-template-online',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps nested runtime files below the configured MinIO prefix', async () => {
|
||||
const minio = createMinio();
|
||||
const service = new BlogLive2DAssetService(
|
||||
@ -109,10 +125,15 @@ describe('BlogLive2DAssetService', () => {
|
||||
createConfig() as never,
|
||||
);
|
||||
|
||||
await service.getRuntimeObject('v1', ['textures', 'texture_00.png']);
|
||||
await service.getRuntimeObject('v1', [
|
||||
'assets',
|
||||
'model',
|
||||
'motions',
|
||||
'breath1.motion3.json',
|
||||
]);
|
||||
|
||||
expect(minio.getObject).toHaveBeenCalledWith(
|
||||
'blog/live2d/pio/v1/textures/texture_00.png',
|
||||
'blog/live2d/pio/v1/assets/model/motions/breath1.motion3.json',
|
||||
'kt-template-online',
|
||||
);
|
||||
});
|
||||
@ -176,6 +197,43 @@ describe('BlogLive2DAssetService', () => {
|
||||
});
|
||||
|
||||
describe('BlogLive2DAssetController', () => {
|
||||
it('streams the Pio root catalog 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(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/blog/live2d/pio/catalog.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/catalog.json',
|
||||
'kt-template-online',
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('streams nested Pio runtime assets for allowed blog requests', async () => {
|
||||
const minio = createMinio();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
@ -197,7 +255,7 @@ describe('BlogLive2DAssetController', () => {
|
||||
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/blog/live2d/pio/v1/textures/texture_00.png')
|
||||
.get('/blog/live2d/pio/v1/assets/textures/default-costume.png')
|
||||
.set('Referer', 'https://blog.kwitsukasa.top/post/1')
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
@ -207,7 +265,7 @@ describe('BlogLive2DAssetController', () => {
|
||||
'public, max-age=31536000, immutable',
|
||||
);
|
||||
expect(minio.getObject).toHaveBeenCalledWith(
|
||||
'blog/live2d/pio/v1/textures/texture_00.png',
|
||||
'blog/live2d/pio/v1/assets/textures/default-costume.png',
|
||||
'kt-template-online',
|
||||
);
|
||||
} finally {
|
||||
@ -236,7 +294,7 @@ describe('BlogLive2DAssetController', () => {
|
||||
|
||||
try {
|
||||
await request(app.getHttpServer())
|
||||
.get('/blog/live2d/pio/v1/pio.model3.json')
|
||||
.get('/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json')
|
||||
.set('Referer', 'https://example.com/post/1')
|
||||
.expect(HttpStatus.BAD_REQUEST);
|
||||
expect(minio.getObject).not.toHaveBeenCalled();
|
||||
|
||||
@ -64,6 +64,7 @@ describe('Asset module contract', () => {
|
||||
'GET /minio/resource-proxy',
|
||||
'GET /minio/download',
|
||||
'DELETE /minio/remove',
|
||||
'GET /blog/live2d/pio/catalog.json',
|
||||
'GET /blog/live2d/pio/:version/*assetPath',
|
||||
]),
|
||||
);
|
||||
@ -124,6 +125,12 @@ describe('Asset module contract', () => {
|
||||
});
|
||||
|
||||
it('marks the Blog Live2D runtime stream as an explicit public asset route', () => {
|
||||
expect(
|
||||
Reflect.getMetadata(
|
||||
IS_PUBLIC_KEY,
|
||||
BlogLive2DAssetController.prototype.getPioCatalog,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Reflect.getMetadata(
|
||||
IS_PUBLIC_KEY,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user