feat: 支持Tia Live2D资源路由
This commit is contained in:
parent
0294bb93b2
commit
a2ad3b38a5
8
API.md
8
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` |
|
| 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` |
|
| 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 | `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` |
|
| 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/resource-proxy` | 代理读取资源 |
|
||||||
| `GET` | `/minio/download` | 下载文件流 |
|
| `GET` | `/minio/download` | 下载文件流 |
|
||||||
| `DELETE` | `/minio/remove` | 删除文件 |
|
| `DELETE` | `/minio/remove` | 删除文件 |
|
||||||
| `GET` | `/blog/live2d/pio/catalog.json` | 公开读取 Pio Live2D 公共目录索引,按 Referer/Origin 白名单防盗链 |
|
| `GET` | `/blog/live2d/:character/catalog.json` | 公开读取 Pio/Tia Live2D 公共目录索引,按 Referer/Origin 白名单防盗链 |
|
||||||
| `GET` | `/blog/live2d/pio/:family/*assetPath` | 公开读取 Pio Live2D 运行包资源,`family` 只允许 `moc`/`moc3`,按 Referer/Origin 白名单防盗链 |
|
| `GET` | `/blog/live2d/:character/:family/*assetPath` | 公开读取 Pio/Tia Live2D 运行包资源,`character` 只允许 `pio`/`tia`,`family` 只允许 `moc`/`moc3`,按 Referer/Origin 白名单防盗链 |
|
||||||
|
|
||||||
`bucketName` 不传时使用 `MINIO_BUCKET`。
|
`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 管理
|
## QQBot 管理
|
||||||
|
|
||||||
|
|||||||
@ -59,7 +59,7 @@ ci/ Jenkins Agent/Docker 辅助文件
|
|||||||
| 分组 | 变量 |
|
| 分组 | 变量 |
|
||||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
|
| 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` |
|
| 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` |
|
| 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_*` |
|
| 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/迁移脚本。
|
`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` 仍表示单次执行预算。
|
QQBot 插件 worker 使用 BullMQ 队列串行执行同一插件安装实例的请求。K8s 生产清单包含内部服务 `kt-qqbot-plugin-redis`,生产 env 可将 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` 配为该服务名。`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS` 控制排队等待窗口,插件 `operation.timeoutMs` 仍表示单次执行预算。
|
||||||
|
|
||||||
|
|||||||
@ -7,13 +7,16 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { MinioClientService } from './asset-minio.service';
|
import { MinioClientService } from './asset-minio.service';
|
||||||
import type {
|
import type {
|
||||||
BlogLive2DAssetResult,
|
BlogLive2DAssetResult,
|
||||||
|
BlogLive2DCharacter,
|
||||||
BlogLive2DRuntimeAssetPath,
|
BlogLive2DRuntimeAssetPath,
|
||||||
} from '../domain/blog-live2d-asset.types';
|
} from '../domain/blog-live2d-asset.types';
|
||||||
|
|
||||||
const DEFAULT_ALLOWED_ORIGINS = 'https://blog.kwitsukasa.top';
|
const DEFAULT_ALLOWED_ORIGINS = 'https://blog.kwitsukasa.top';
|
||||||
const DEFAULT_LIVE2D_BUCKET = 'kt-template-online';
|
const DEFAULT_LIVE2D_BUCKET = 'kt-template-online';
|
||||||
|
const DEFAULT_LIVE2D_ROOT_PREFIX = 'blog/live2d';
|
||||||
const DEFAULT_LIVE2D_PREFIX = 'blog/live2d/pio';
|
const DEFAULT_LIVE2D_PREFIX = 'blog/live2d/pio';
|
||||||
const MAX_DECODE_DEPTH = 6;
|
const MAX_DECODE_DEPTH = 6;
|
||||||
|
const ALLOWED_LIVE2D_CHARACTERS = new Set(['pio', 'tia']);
|
||||||
const ALLOWED_RUNTIME_FAMILIES = new Set(['moc', 'moc3']);
|
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 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`.
|
* @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.
|
* @returns MinIO stream and stat metadata for the requested object.
|
||||||
*/
|
*/
|
||||||
async getRuntimeObject(
|
async getRuntimeObject(
|
||||||
|
character: string,
|
||||||
family: string,
|
family: string,
|
||||||
objectPath: BlogLive2DRuntimeAssetPath,
|
objectPath: BlogLive2DRuntimeAssetPath,
|
||||||
): Promise<BlogLive2DAssetResult> {
|
): Promise<BlogLive2DAssetResult> {
|
||||||
try {
|
try {
|
||||||
return await this.minioClientService.getObject(
|
return await this.minioClientService.getObject(
|
||||||
this.resolveRuntimeObjectPath(family, objectPath),
|
this.resolveRuntimeObjectPath(character, family, objectPath),
|
||||||
this.getBucketName(),
|
this.getBucketName(),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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`.
|
* @returns MinIO stream and stat metadata for `catalog.json`.
|
||||||
*/
|
*/
|
||||||
async getCatalogObject(): Promise<BlogLive2DAssetResult> {
|
async getCatalogObject(character: string): Promise<BlogLive2DAssetResult> {
|
||||||
try {
|
try {
|
||||||
return await this.minioClientService.getObject(
|
return await this.minioClientService.getObject(
|
||||||
[...this.getPrefixSegments(), 'catalog.json'].join('/'),
|
[
|
||||||
|
...this.getRootPrefixSegments(),
|
||||||
|
this.normalizeCharacter(character),
|
||||||
|
'catalog.json',
|
||||||
|
].join('/'),
|
||||||
this.getBucketName(),
|
this.getBucketName(),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 family - Runtime family segment supplied by the route.
|
||||||
* @param objectPath - Route tail supplied by the wildcard parameter.
|
* @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(
|
resolveRuntimeObjectPath(
|
||||||
|
character: string,
|
||||||
family: string,
|
family: string,
|
||||||
objectPath: BlogLive2DRuntimeAssetPath,
|
objectPath: BlogLive2DRuntimeAssetPath,
|
||||||
): string {
|
): string {
|
||||||
const prefix = this.getPrefixSegments();
|
const prefix = this.getRootPrefixSegments();
|
||||||
|
const characterSegment = this.normalizeCharacter(character);
|
||||||
const familySegment = this.normalizeRuntimeFamily(family);
|
const familySegment = this.normalizeRuntimeFamily(family);
|
||||||
const assetSegments = this.normalizeRouteSegments(objectPath, 'asset path');
|
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[] {
|
private getRootPrefixSegments(): string[] {
|
||||||
return this.normalizeRouteSegments(
|
const rootPrefix = this.configService.get<string>('BLOG_LIVE2D_ROOT_PREFIX');
|
||||||
|
if (rootPrefix) {
|
||||||
|
return this.normalizeRouteSegments(rootPrefix, 'root prefix');
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyPrefix =
|
||||||
this.configService.get<string>('BLOG_LIVE2D_PREFIX') ||
|
this.configService.get<string>('BLOG_LIVE2D_PREFIX') ||
|
||||||
DEFAULT_LIVE2D_PREFIX,
|
DEFAULT_LIVE2D_PREFIX;
|
||||||
'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.
|
* 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.
|
* @param input - String or wildcard array supplied by Nest/path-to-regexp or runtime config.
|
||||||
|
|||||||
@ -15,24 +15,27 @@ export class BlogLive2DAssetController {
|
|||||||
constructor(private readonly blogLive2DAssetService: BlogLive2DAssetService) {}
|
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 referer - Browser Referer header used by the hotlink protection policy.
|
||||||
* @param origin - Browser Origin header used when the request type supplies it.
|
* @param origin - Browser Origin header used when the request type supplies it.
|
||||||
* @param res - Express response that receives MinIO content headers and stream bytes.
|
* @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.
|
* @returns Promise resolved after the stream is attached to the Express response; no Vben body is returned.
|
||||||
*/
|
*/
|
||||||
@Get('pio/catalog.json')
|
@Get(':character/catalog.json')
|
||||||
@ApiOperation({ summary: '获取 Pio Live2D 目录规范索引' })
|
@ApiOperation({ summary: '获取 Blog Live2D 角色目录规范索引' })
|
||||||
@ApiFileDownloadResponse('Pio Live2D root catalog stream')
|
@ApiParam({ name: 'character', enum: ['pio', 'tia'], example: 'pio' })
|
||||||
|
@ApiFileDownloadResponse('Blog Live2D character root catalog stream')
|
||||||
@Public()
|
@Public()
|
||||||
async getPioCatalog(
|
async getCharacterCatalog(
|
||||||
|
@Param('character') character: string,
|
||||||
@Headers('referer') referer: string | undefined,
|
@Headers('referer') referer: string | undefined,
|
||||||
@Headers('origin') origin: string | undefined,
|
@Headers('origin') origin: string | undefined,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
this.blogLive2DAssetService.assertAllowedRequest(referer, origin);
|
this.blogLive2DAssetService.assertAllowedRequest(referer, origin);
|
||||||
const { stream, stat, objectName } =
|
const { stream, stat, objectName } =
|
||||||
await this.blogLive2DAssetService.getCatalogObject();
|
await this.blogLive2DAssetService.getCatalogObject(character);
|
||||||
|
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Type',
|
'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 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 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 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.
|
* @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.
|
* @returns Promise resolved after the stream is attached to the Express response; no Vben body is returned.
|
||||||
*/
|
*/
|
||||||
@Get('pio/:family/*assetPath')
|
@Get(':character/:family/*assetPath')
|
||||||
@ApiOperation({ summary: '获取 Pio Live2D 运行时资源' })
|
@ApiOperation({ summary: '获取 Blog Live2D 角色运行时资源' })
|
||||||
|
@ApiParam({ name: 'character', enum: ['pio', 'tia'], example: 'tia' })
|
||||||
@ApiParam({ name: 'family', example: 'moc' })
|
@ApiParam({ name: 'family', example: 'moc' })
|
||||||
@ApiParam({ name: 'assetPath', example: 'textures/default-costume.png' })
|
@ApiParam({ name: 'assetPath', example: 'textures/default-costume.png' })
|
||||||
@ApiFileDownloadResponse('Pio Live2D runtime asset stream')
|
@ApiFileDownloadResponse('Blog Live2D runtime asset stream')
|
||||||
@Public()
|
@Public()
|
||||||
async getPioAsset(
|
async getCharacterAsset(
|
||||||
|
@Param('character') character: string,
|
||||||
@Param('family') family: string,
|
@Param('family') family: string,
|
||||||
@Param('assetPath') assetPath: BlogLive2DRuntimeAssetPath,
|
@Param('assetPath') assetPath: BlogLive2DRuntimeAssetPath,
|
||||||
@Headers('referer') referer: string | undefined,
|
@Headers('referer') referer: string | undefined,
|
||||||
@ -66,7 +72,11 @@ export class BlogLive2DAssetController {
|
|||||||
) {
|
) {
|
||||||
this.blogLive2DAssetService.assertAllowedRequest(referer, origin);
|
this.blogLive2DAssetService.assertAllowedRequest(referer, origin);
|
||||||
const { stream, stat, objectName } =
|
const { stream, stat, objectName } =
|
||||||
await this.blogLive2DAssetService.getRuntimeObject(family, assetPath);
|
await this.blogLive2DAssetService.getRuntimeObject(
|
||||||
|
character,
|
||||||
|
family,
|
||||||
|
assetPath,
|
||||||
|
);
|
||||||
|
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Type',
|
'Content-Type',
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class BlogLive2DManifestDto {
|
export class BlogLive2DManifestDto {
|
||||||
@ApiProperty({ example: 'pio' })
|
@ApiProperty({ enum: ['pio', 'tia'], example: 'tia' })
|
||||||
character: string;
|
character: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'moc' })
|
@ApiProperty({ example: 'moc' })
|
||||||
@ -11,7 +11,7 @@ export class BlogLive2DManifestDto {
|
|||||||
desktopOnly: boolean;
|
desktopOnly: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '/api/blog/live2d/pio/moc/index.json',
|
example: '/api/blog/live2d/tia/moc/index.json',
|
||||||
})
|
})
|
||||||
entry: string;
|
entry: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import type { MinioObjectResult } from './asset-minio.types';
|
import type { MinioObjectResult } from './asset-minio.types';
|
||||||
|
|
||||||
export type BlogLive2DRuntimeAssetPath = string | string[];
|
export type BlogLive2DRuntimeAssetPath = string | string[];
|
||||||
|
export type BlogLive2DCharacter = 'pio' | 'tia';
|
||||||
|
|
||||||
export type BlogLive2DAssetRequest = {
|
export type BlogLive2DAssetRequest = {
|
||||||
|
character: BlogLive2DCharacter;
|
||||||
family: string;
|
family: string;
|
||||||
objectPath: BlogLive2DRuntimeAssetPath;
|
objectPath: BlogLive2DRuntimeAssetPath;
|
||||||
origin?: string;
|
origin?: string;
|
||||||
|
|||||||
@ -110,7 +110,7 @@ describe('BlogLive2DAssetService', () => {
|
|||||||
createConfig() as never,
|
createConfig() as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.getCatalogObject();
|
await service.getCatalogObject('pio');
|
||||||
|
|
||||||
expect(minio.getObject).toHaveBeenCalledWith(
|
expect(minio.getObject).toHaveBeenCalledWith(
|
||||||
'blog/live2d/pio/catalog.json',
|
'blog/live2d/pio/catalog.json',
|
||||||
@ -125,7 +125,7 @@ describe('BlogLive2DAssetService', () => {
|
|||||||
createConfig() as never,
|
createConfig() as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.getRuntimeObject('moc3', [
|
await service.getRuntimeObject('pio', 'moc3', [
|
||||||
'assets',
|
'assets',
|
||||||
'model',
|
'model',
|
||||||
'motions',
|
'motions',
|
||||||
@ -145,7 +145,7 @@ describe('BlogLive2DAssetService', () => {
|
|||||||
createConfig() as never,
|
createConfig() as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.getRuntimeObject('moc', ['index.json']);
|
await service.getRuntimeObject('pio', 'moc', ['index.json']);
|
||||||
|
|
||||||
expect(minio.getObject).toHaveBeenCalledWith(
|
expect(minio.getObject).toHaveBeenCalledWith(
|
||||||
'blog/live2d/pio/moc/index.json',
|
'blog/live2d/pio/moc/index.json',
|
||||||
@ -160,7 +160,10 @@ describe('BlogLive2DAssetService', () => {
|
|||||||
createConfig() as never,
|
createConfig() as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.getRuntimeObject('moc', ['textures', 'manifest.json']);
|
await service.getRuntimeObject('pio', 'moc', [
|
||||||
|
'textures',
|
||||||
|
'manifest.json',
|
||||||
|
]);
|
||||||
|
|
||||||
expect(minio.getObject).toHaveBeenCalledWith(
|
expect(minio.getObject).toHaveBeenCalledWith(
|
||||||
'blog/live2d/pio/moc/textures/manifest.json',
|
'blog/live2d/pio/moc/textures/manifest.json',
|
||||||
@ -179,7 +182,7 @@ describe('BlogLive2DAssetService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getRuntimeObject('moc', ['manifest.json']),
|
service.getRuntimeObject('pio', 'moc', ['manifest.json']),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
status: HttpStatus.NOT_FOUND,
|
status: HttpStatus.NOT_FOUND,
|
||||||
message: 'Live2D runtime asset not found',
|
message: 'Live2D runtime asset not found',
|
||||||
@ -198,7 +201,7 @@ describe('BlogLive2DAssetService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.getRuntimeObject('moc', ['%252e%252e', 'secret.env']),
|
service.getRuntimeObject('pio', 'moc', ['%252e%252e', 'secret.env']),
|
||||||
).rejects.toThrow(BadRequestException);
|
).rejects.toThrow(BadRequestException);
|
||||||
expect(minio.getObject).not.toHaveBeenCalled();
|
expect(minio.getObject).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -219,12 +222,58 @@ describe('BlogLive2DAssetService', () => {
|
|||||||
createConfig() as never,
|
createConfig() as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(service.getRuntimeObject(family, objectPath)).rejects.toThrow(
|
await expect(
|
||||||
BadRequestException,
|
service.getRuntimeObject('pio', family, objectPath),
|
||||||
);
|
).rejects.toThrow(BadRequestException);
|
||||||
expect(minio.getObject).not.toHaveBeenCalled();
|
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', () => {
|
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 () => {
|
it('rejects external hotlink requests before streaming assets', async () => {
|
||||||
const minio = createMinio();
|
const minio = createMinio();
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
|||||||
@ -64,8 +64,8 @@ describe('Asset module contract', () => {
|
|||||||
'GET /minio/resource-proxy',
|
'GET /minio/resource-proxy',
|
||||||
'GET /minio/download',
|
'GET /minio/download',
|
||||||
'DELETE /minio/remove',
|
'DELETE /minio/remove',
|
||||||
'GET /blog/live2d/pio/catalog.json',
|
'GET /blog/live2d/:character/catalog.json',
|
||||||
'GET /blog/live2d/pio/:family/*assetPath',
|
'GET /blog/live2d/:character/:family/*assetPath',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -128,13 +128,13 @@ describe('Asset module contract', () => {
|
|||||||
expect(
|
expect(
|
||||||
Reflect.getMetadata(
|
Reflect.getMetadata(
|
||||||
IS_PUBLIC_KEY,
|
IS_PUBLIC_KEY,
|
||||||
BlogLive2DAssetController.prototype.getPioCatalog,
|
BlogLive2DAssetController.prototype.getCharacterCatalog,
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
Reflect.getMetadata(
|
Reflect.getMetadata(
|
||||||
IS_PUBLIC_KEY,
|
IS_PUBLIC_KEY,
|
||||||
BlogLive2DAssetController.prototype.getPioAsset,
|
BlogLive2DAssetController.prototype.getCharacterAsset,
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user