feat: 增加Blog Live2D资源防盗链网关
This commit is contained in:
parent
ed7d15f5ad
commit
453d95ef35
@ -11,6 +11,9 @@ MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=kt-template-online
|
||||
BLOG_LIVE2D_ALLOWED_ORIGINS=https://blog.kwitsukasa.top,http://localhost:5999
|
||||
BLOG_LIVE2D_BUCKET=kt-template-online
|
||||
BLOG_LIVE2D_PREFIX=blog/live2d/pio
|
||||
|
||||
WORDPRESS_BASE_URL=http://localhost
|
||||
WORDPRESS_HOST_HEADER=
|
||||
|
||||
5
API.md
5
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` |
|
||||
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`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,9 +331,12 @@ WordPress rewrite 未开启导致 `/wp-json/*` 返回 404 时,后端会回退
|
||||
| `GET` | `/minio/resource-proxy` | 代理读取资源 |
|
||||
| `GET` | `/minio/download` | 下载文件流 |
|
||||
| `DELETE` | `/minio/remove` | 删除文件 |
|
||||
| `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、反斜杠、`.`/`..` 和多重编码后的路径逃逸。
|
||||
|
||||
## QQBot 管理
|
||||
|
||||
QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 事件总线、账号能力绑定、在线命令、自动回复规则、权限名单、发送/接收日志和插件生态。
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
| `qqbot/plugins/bilibili-card` | 解析 QQ/NapCat Bilibili 卡片和短链,按账号事件绑定回复封面图和视频文字摘要 |
|
||||
| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
||||
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
||||
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
|
||||
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除,以及 Blog Live2D 运行包受控读取入口 |
|
||||
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
|
||||
|
||||
## 目录结构
|
||||
@ -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` |
|
||||
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`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,6 +71,8 @@ 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 前拒绝。
|
||||
|
||||
QQBot 插件 worker 使用 BullMQ 队列串行执行同一插件安装实例的请求。K8s 生产清单包含内部服务 `kt-qqbot-plugin-redis`,生产 env 可将 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` 配为该服务名。`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS` 控制排队等待窗口,插件 `operation.timeoutMs` 仍表示单次执行预算。
|
||||
|
||||
QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qqbot_plugin_task` / `qqbot_plugin_task_run`,通过 BullMQ Job Scheduler 调度并经插件 worker 的 `executeTask` 边界执行。`sql/qqbot-init.sql` 可为既有环境增量创建任务表和 Admin 菜单。Admin 页面路径为 `/qqbot/plugin-task`。定时任务队列可用 `QQBOT_PLUGIN_TASK_QUEUE_REDIS_*` 单独配置;留空时复用插件 worker 队列的 Redis 连接。BangDream Bestdori 主数据缓存使用 `BANGDREAM_TSUGU_CACHE_ROOT`,生产清单挂载到容器内 `/data/qqbot/plugins/bangdream/cache`,对应 k3d 节点可写 hostPath `/var/lib/rancher/k3s/kt-template-online-api/qqbot-plugins`。
|
||||
|
||||
190
src/modules/asset/application/blog-live2d-asset.service.ts
Normal file
190
src/modules/asset/application/blog-live2d-asset.service.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MinioClientService } from './asset-minio.service';
|
||||
import type {
|
||||
BlogLive2DAssetResult,
|
||||
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_PREFIX = 'blog/live2d/pio';
|
||||
const MAX_DECODE_DEPTH = 6;
|
||||
|
||||
@Injectable()
|
||||
export class BlogLive2DAssetService {
|
||||
/**
|
||||
* Creates the guarded Blog Live2D asset service.
|
||||
* @param minioClientService - Existing MinIO helper used to stream versioned Pio runtime files.
|
||||
* @param configService - Runtime config source for bucket, object prefix, and allowed browser origins.
|
||||
*/
|
||||
constructor(
|
||||
private readonly minioClientService: MinioClientService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Rejects browser asset requests that do not come from the configured Blog origins.
|
||||
* @param referer - Browser Referer header; used for normal `<script>/<img>/<canvas>` asset loads.
|
||||
* @param origin - Browser Origin header; used by CORS-capable requests when Referer is absent.
|
||||
*/
|
||||
assertAllowedRequest(referer?: string, origin?: string): void {
|
||||
const candidates = [referer, origin].filter(Boolean) as string[];
|
||||
if (!candidates.length) {
|
||||
throw new BadRequestException('Live2D asset referer is required');
|
||||
}
|
||||
|
||||
const allowed = this.getAllowedOrigins();
|
||||
const allSourcesAllowed = candidates.every((candidate) =>
|
||||
allowed.includes(this.toOrigin(candidate)),
|
||||
);
|
||||
|
||||
if (!allSourcesAllowed) {
|
||||
throw new BadRequestException('Live2D asset request is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* @returns MinIO stream and stat metadata for the requested object.
|
||||
*/
|
||||
async getRuntimeObject(
|
||||
version: string,
|
||||
objectPath: BlogLive2DRuntimeAssetPath,
|
||||
): Promise<BlogLive2DAssetResult> {
|
||||
return this.minioClientService.getObject(
|
||||
this.resolveRuntimeObjectPath(version, objectPath),
|
||||
this.getBucketName(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the MinIO object key for a versioned Pio runtime file.
|
||||
* @param version - Runtime release 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,
|
||||
objectPath: BlogLive2DRuntimeAssetPath,
|
||||
): string {
|
||||
const prefix = this.getPrefixSegments();
|
||||
const versionSegment = this.normalizeRouteSegments(version, 'version');
|
||||
const assetSegments = this.normalizeRouteSegments(objectPath, 'asset path');
|
||||
|
||||
return [...prefix, ...versionSegment, ...assetSegments].join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Configured MinIO bucket for Blog Live2D runtime files.
|
||||
*/
|
||||
private getBucketName(): string {
|
||||
return (
|
||||
this.configService.get<string>('BLOG_LIVE2D_BUCKET') ||
|
||||
this.minioClientService.getDefaultBucket?.() ||
|
||||
DEFAULT_LIVE2D_BUCKET
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Sanitized MinIO object-key prefix for Pio runtime files.
|
||||
*/
|
||||
private getPrefixSegments(): string[] {
|
||||
return this.normalizeRouteSegments(
|
||||
this.configService.get<string>('BLOG_LIVE2D_PREFIX') ||
|
||||
DEFAULT_LIVE2D_PREFIX,
|
||||
'prefix',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Allowed absolute origins for Blog Live2D asset requests.
|
||||
*/
|
||||
private getAllowedOrigins(): string[] {
|
||||
return (
|
||||
this.configService.get<string>('BLOG_LIVE2D_ALLOWED_ORIGINS') ||
|
||||
DEFAULT_ALLOWED_ORIGINS
|
||||
)
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => this.toOrigin(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a header value or configured URL to an absolute origin string.
|
||||
* @param value - Referer, Origin, or allowlist entry that must parse as an HTTP(S) URL.
|
||||
* @returns Protocol and host pair used for allowlist comparison.
|
||||
*/
|
||||
private toOrigin(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error('unsupported protocol');
|
||||
}
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch {
|
||||
throw new BadRequestException('Live2D asset request source is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 label - Human-readable source name used in the rejection message.
|
||||
* @returns Decoded path segments with traversal, absolute URL, and backslash escapes rejected.
|
||||
*/
|
||||
private normalizeRouteSegments(
|
||||
input: BlogLive2DRuntimeAssetPath,
|
||||
label: string,
|
||||
): string[] {
|
||||
const raw = Array.isArray(input) ? input.join('/') : String(input || '');
|
||||
const decoded = this.decodeRepeated(raw.trim(), label);
|
||||
|
||||
if (
|
||||
!decoded ||
|
||||
decoded.includes('\\') ||
|
||||
decoded.startsWith('/') ||
|
||||
decoded.startsWith('//') ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(decoded)
|
||||
) {
|
||||
throw new BadRequestException(`Invalid Live2D ${label}`);
|
||||
}
|
||||
|
||||
const segments = decoded.split('/').filter(Boolean);
|
||||
if (
|
||||
!segments.length ||
|
||||
segments.some((segment) => segment === '.' || segment === '..')
|
||||
) {
|
||||
throw new BadRequestException(`Invalid Live2D ${label}`);
|
||||
}
|
||||
|
||||
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.
|
||||
* @param label - Human-readable source name used in the rejection message.
|
||||
* @returns Fully decoded path text when decoding converges within the bounded depth.
|
||||
*/
|
||||
private decodeRepeated(value: string, label: string): string {
|
||||
try {
|
||||
let decoded = value;
|
||||
for (let index = 0; index < MAX_DECODE_DEPTH; index += 1) {
|
||||
const next = decodeURIComponent(decoded);
|
||||
if (next === decoded) {
|
||||
return next;
|
||||
}
|
||||
decoded = next;
|
||||
}
|
||||
} catch {
|
||||
throw new BadRequestException(`Invalid Live2D ${label}`);
|
||||
}
|
||||
|
||||
throw new BadRequestException(`Invalid Live2D ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,16 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminAuthGuardModule } from '@/modules/admin/identity/auth/admin-auth-guard.module';
|
||||
import { MinioClientService } from './application/asset-minio.service';
|
||||
import { BlogLive2DAssetService } from './application/blog-live2d-asset.service';
|
||||
import { BlogLive2DAssetController } from './contract/blog-live2d-asset.controller';
|
||||
import { MinioClientController } from './contract/asset-minio.controller';
|
||||
|
||||
export const ASSET_CONTROLLERS = [MinioClientController];
|
||||
export const ASSET_CONTROLLERS = [
|
||||
MinioClientController,
|
||||
BlogLive2DAssetController,
|
||||
];
|
||||
|
||||
export const ASSET_PROVIDERS = [MinioClientService];
|
||||
export const ASSET_PROVIDERS = [MinioClientService, BlogLive2DAssetService];
|
||||
|
||||
export const ASSET_DOMAIN_CONTRACT = {
|
||||
tables: [
|
||||
|
||||
62
src/modules/asset/contract/blog-live2d-asset.controller.ts
Normal file
62
src/modules/asset/contract/blog-live2d-asset.controller.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { Controller, Get, Headers, Param, Res } from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { ApiFileDownloadResponse, Public } from '@/common';
|
||||
import { BlogLive2DAssetService } from '../application/blog-live2d-asset.service';
|
||||
import type { BlogLive2DRuntimeAssetPath } from '../domain/blog-live2d-asset.types';
|
||||
|
||||
@Controller('blog/live2d')
|
||||
@ApiTags('Blog - Live2D')
|
||||
export class BlogLive2DAssetController {
|
||||
/**
|
||||
* Creates the public Blog Live2D asset controller.
|
||||
* @param blogLive2DAssetService - Service that validates browser source headers and streams MinIO objects.
|
||||
*/
|
||||
constructor(private readonly blogLive2DAssetService: BlogLive2DAssetService) {}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @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')
|
||||
@ApiOperation({ summary: '获取 Pio Live2D 运行时资源' })
|
||||
@ApiParam({ name: 'version', example: 'v1' })
|
||||
@ApiParam({ name: 'assetPath', example: 'textures/texture_00.png' })
|
||||
@ApiFileDownloadResponse('Pio Live2D runtime asset stream')
|
||||
@Public()
|
||||
async getPioAsset(
|
||||
@Param('version') version: string,
|
||||
@Param('assetPath') assetPath: BlogLive2DRuntimeAssetPath,
|
||||
@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.getRuntimeObject(version, assetPath);
|
||||
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
stat.metaData?.['content-type'] || 'application/octet-stream',
|
||||
);
|
||||
res.setHeader('Cache-Control', this.getCacheControl(objectName));
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private getCacheControl(objectName: string): string {
|
||||
return objectName.endsWith('manifest.json') ||
|
||||
objectName.endsWith('.model3.json')
|
||||
? 'public, max-age=60'
|
||||
: 'public, max-age=31536000, immutable';
|
||||
}
|
||||
}
|
||||
18
src/modules/asset/contract/blog-live2d-asset.dto.ts
Normal file
18
src/modules/asset/contract/blog-live2d-asset.dto.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class BlogLive2DManifestDto {
|
||||
@ApiProperty({ example: 'pio' })
|
||||
character: string;
|
||||
|
||||
@ApiProperty({ example: 'v1' })
|
||||
version: string;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
desktopOnly: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: '/api/blog/live2d/pio/v1/pio.model3.json',
|
||||
})
|
||||
model3: string;
|
||||
}
|
||||
|
||||
13
src/modules/asset/domain/blog-live2d-asset.types.ts
Normal file
13
src/modules/asset/domain/blog-live2d-asset.types.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { MinioObjectResult } from './asset-minio.types';
|
||||
|
||||
export type BlogLive2DRuntimeAssetPath = string | string[];
|
||||
|
||||
export type BlogLive2DAssetRequest = {
|
||||
objectPath: BlogLive2DRuntimeAssetPath;
|
||||
origin?: string;
|
||||
referer?: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type BlogLive2DAssetResult = MinioObjectResult;
|
||||
|
||||
225
test/asset/blog-live2d-asset.service.spec.ts
Normal file
225
test/asset/blog-live2d-asset.service.spec.ts
Normal file
@ -0,0 +1,225 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { BadRequestException, HttpStatus } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as request from 'supertest';
|
||||
import { MinioClientService } from '../../src/modules/asset/application/asset-minio.service';
|
||||
import { BlogLive2DAssetService } from '../../src/modules/asset/application/blog-live2d-asset.service';
|
||||
import { BlogLive2DAssetController } from '../../src/modules/asset/contract/blog-live2d-asset.controller';
|
||||
|
||||
/**
|
||||
* Creates the config facade used by Blog Live2D asset tests.
|
||||
* @param overrides - Runtime config values that should replace the default fixture.
|
||||
* @returns ConfigService-compatible object for direct service construction.
|
||||
*/
|
||||
function createConfig(overrides: Record<string, string | undefined> = {}) {
|
||||
const values: Record<string, string | undefined> = {
|
||||
BLOG_LIVE2D_ALLOWED_ORIGINS:
|
||||
'https://blog.kwitsukasa.top,http://localhost:5999',
|
||||
BLOG_LIVE2D_BUCKET: 'kt-template-online',
|
||||
BLOG_LIVE2D_PREFIX: 'blog/live2d/pio',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
get: jest.fn((key: string) => values[key]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a MinIO client facade that records requested object names.
|
||||
* @returns Mocked MinIO client service used by the service and controller tests.
|
||||
*/
|
||||
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',
|
||||
},
|
||||
size: 2,
|
||||
},
|
||||
stream: Readable.from(['ok']),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('BlogLive2DAssetService', () => {
|
||||
it('allows configured blog referer', () => {
|
||||
const service = new BlogLive2DAssetService(
|
||||
createMinio() as never,
|
||||
createConfig() as never,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
service.assertAllowedRequest(
|
||||
'https://blog.kwitsukasa.top/post/1',
|
||||
undefined,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows configured origin when referer is absent', () => {
|
||||
const service = new BlogLive2DAssetService(
|
||||
createMinio() as never,
|
||||
createConfig() as never,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
service.assertAllowedRequest(undefined, 'http://localhost:5999'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects external referer', () => {
|
||||
const service = new BlogLive2DAssetService(
|
||||
createMinio() as never,
|
||||
createConfig() as never,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
service.assertAllowedRequest('https://example.com/hotlink', undefined),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects missing or malformed request source', () => {
|
||||
const service = new BlogLive2DAssetService(
|
||||
createMinio() as never,
|
||||
createConfig() as never,
|
||||
);
|
||||
|
||||
expect(() => service.assertAllowedRequest(undefined, undefined)).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(() => service.assertAllowedRequest('not-a-url', undefined)).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps nested 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', ['textures', 'texture_00.png']);
|
||||
|
||||
expect(minio.getObject).toHaveBeenCalledWith(
|
||||
'blog/live2d/pio/v1/textures/texture_00.png',
|
||||
'kt-template-online',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects runtime path traversal before touching MinIO', async () => {
|
||||
const minio = createMinio();
|
||||
const service = new BlogLive2DAssetService(
|
||||
minio as never,
|
||||
createConfig() as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getRuntimeObject('v1', ['%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']],
|
||||
])(
|
||||
'rejects unsafe %s runtime paths before touching MinIO',
|
||||
async (_name, version, objectPath) => {
|
||||
const minio = createMinio();
|
||||
const service = new BlogLive2DAssetService(
|
||||
minio as never,
|
||||
createConfig() as never,
|
||||
);
|
||||
|
||||
await expect(service.getRuntimeObject(version, objectPath)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(minio.getObject).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('BlogLive2DAssetController', () => {
|
||||
it('streams nested Pio runtime 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(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/blog/live2d/pio/v1/textures/texture_00.png')
|
||||
.set('Referer', 'https://blog.kwitsukasa.top/post/1')
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
expect(Buffer.from(response.body).toString()).toBe('ok');
|
||||
expect(response.headers['content-type']).toContain('image/png');
|
||||
expect(response.headers['cache-control']).toBe(
|
||||
'public, max-age=31536000, immutable',
|
||||
);
|
||||
expect(minio.getObject).toHaveBeenCalledWith(
|
||||
'blog/live2d/pio/v1/textures/texture_00.png',
|
||||
'kt-template-online',
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects external hotlink 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(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
try {
|
||||
await request(app.getHttpServer())
|
||||
.get('/blog/live2d/pio/v1/pio.model3.json')
|
||||
.set('Referer', 'https://example.com/post/1')
|
||||
.expect(HttpStatus.BAD_REQUEST);
|
||||
expect(minio.getObject).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -4,6 +4,7 @@ jest.mock('../../../src/modules/qqbot/core/qqbot-core.module', () => ({
|
||||
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { IS_PUBLIC_KEY } from '../../../src/common';
|
||||
import { AppModule } from '../../../src/app.module';
|
||||
import { AdminAuthGuardModule } from '../../../src/modules/admin/identity/auth/admin-auth-guard.module';
|
||||
import {
|
||||
@ -13,6 +14,8 @@ import {
|
||||
AssetModule,
|
||||
} from '../../../src/modules/asset/asset.module';
|
||||
import { MinioClientService } from '../../../src/modules/asset/application/asset-minio.service';
|
||||
import { BlogLive2DAssetService } from '../../../src/modules/asset/application/blog-live2d-asset.service';
|
||||
import { BlogLive2DAssetController } from '../../../src/modules/asset/contract/blog-live2d-asset.controller';
|
||||
import { MinioClientController } from '../../../src/modules/asset/contract/asset-minio.controller';
|
||||
import { AdminPlatformConfigModule } from '../../../src/modules/admin/platform-config/admin-platform-config.module';
|
||||
import {
|
||||
@ -61,6 +64,7 @@ describe('Asset module contract', () => {
|
||||
'GET /minio/resource-proxy',
|
||||
'GET /minio/download',
|
||||
'DELETE /minio/remove',
|
||||
'GET /blog/live2d/pio/:version/*assetPath',
|
||||
]),
|
||||
);
|
||||
});
|
||||
@ -97,20 +101,37 @@ describe('Asset module contract', () => {
|
||||
);
|
||||
|
||||
expect(getModuleMetadata(AssetModule, MODULE_METADATA.CONTROLLERS)).toEqual(
|
||||
expect.arrayContaining([MinioClientController]),
|
||||
expect.arrayContaining([
|
||||
MinioClientController,
|
||||
BlogLive2DAssetController,
|
||||
]),
|
||||
);
|
||||
expect(getModuleMetadata(AssetModule, MODULE_METADATA.PROVIDERS)).toEqual(
|
||||
expect.arrayContaining([MinioClientService]),
|
||||
expect.arrayContaining([MinioClientService, BlogLive2DAssetService]),
|
||||
);
|
||||
expect(assetExports).toEqual(
|
||||
expect.arrayContaining([MinioClientService, BlogLive2DAssetService]),
|
||||
);
|
||||
expect(assetExports).toEqual(expect.arrayContaining([MinioClientService]));
|
||||
expect(ASSET_CONTROLLERS).toEqual(
|
||||
expect.arrayContaining([MinioClientController]),
|
||||
expect.arrayContaining([
|
||||
MinioClientController,
|
||||
BlogLive2DAssetController,
|
||||
]),
|
||||
);
|
||||
expect(ASSET_PROVIDERS).toEqual(
|
||||
expect.arrayContaining([MinioClientService]),
|
||||
expect.arrayContaining([MinioClientService, BlogLive2DAssetService]),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the Blog Live2D runtime stream as an explicit public asset route', () => {
|
||||
expect(
|
||||
Reflect.getMetadata(
|
||||
IS_PUBLIC_KEY,
|
||||
BlogLive2DAssetController.prototype.getPioAsset,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the real Batch 3 Asset SQL schema and boundary contract', () => {
|
||||
expect(ASSET_DOMAIN_CONTRACT.tables).toEqual([
|
||||
'asset_bucket',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user