316 lines
8.4 KiB
TypeScript
316 lines
8.4 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
Controller,
|
||
Body,
|
||
Delete,
|
||
Get,
|
||
HttpStatus,
|
||
Post,
|
||
Query,
|
||
Res,
|
||
UploadedFile,
|
||
UseGuards,
|
||
UseInterceptors,
|
||
} from '@nestjs/common';
|
||
import { FileInterceptor } from '@nestjs/platform-express';
|
||
import {
|
||
ApiBody,
|
||
ApiConsumes,
|
||
ApiOperation,
|
||
ApiQuery,
|
||
ApiTags,
|
||
} from '@nestjs/swagger';
|
||
import { Response } from 'express';
|
||
import { MinioClientService } from './minio.service';
|
||
import type { MinioUploadFile } from './minio.service';
|
||
import {
|
||
ApiFileDownloadResponse,
|
||
ApiArrayResponse,
|
||
ApiModelResponse,
|
||
ApiSuccessResponse,
|
||
ToolsService,
|
||
} from '@/common';
|
||
import {
|
||
MinioBucketStatusDto,
|
||
MinioObjectDto,
|
||
MinioUploadResultDto,
|
||
} from './minio.dto';
|
||
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
||
|
||
const PROXY_RESOURCE_TIMEOUT = 1000 * 15;
|
||
const PROXY_RESOURCE_CONTENT_TYPES = [
|
||
'image/',
|
||
'font/',
|
||
'text/css',
|
||
'application/font',
|
||
'application/x-font',
|
||
'application/vnd.ms-fontobject',
|
||
];
|
||
const PROXY_RESOURCE_EXTENSION_RE =
|
||
/\.(avif|bmp|css|eot|gif|ico|jpe?g|otf|png|svg|ttf|webp|woff2?)(?:[?#].*)?$/i;
|
||
|
||
@Controller('minio')
|
||
@ApiTags('基础能力 - MinIO')
|
||
@UseGuards(JwtAuthGuard)
|
||
export class MinioClientController {
|
||
constructor(
|
||
private readonly toolsService: ToolsService,
|
||
private readonly minioClientService: MinioClientService,
|
||
) {}
|
||
|
||
@Get('check')
|
||
@ApiOperation({ summary: '检查MinIO连接和Bucket状态' })
|
||
@ApiQuery({ name: 'bucketName', required: false })
|
||
@ApiModelResponse(MinioBucketStatusDto, {
|
||
bucketName: 'kt-template-online',
|
||
exists: true,
|
||
})
|
||
async check(@Res() res, @Query('bucketName') bucketName?: string) {
|
||
const result = await this.minioClientService.checkConnection(bucketName);
|
||
|
||
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||
}
|
||
|
||
@Post('bucket')
|
||
@ApiOperation({ summary: '创建Bucket(存在则跳过)' })
|
||
@ApiQuery({ name: 'bucketName', required: false })
|
||
@ApiSuccessResponse({
|
||
schema: {
|
||
type: 'string',
|
||
description: 'Bucket名称',
|
||
},
|
||
example: 'kt-template-online',
|
||
})
|
||
async createBucket(@Res() res, @Query('bucketName') bucketName?: string) {
|
||
const result = await this.minioClientService.ensureBucket(bucketName);
|
||
|
||
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||
}
|
||
|
||
@Post('upload')
|
||
@UseInterceptors(FileInterceptor('file'))
|
||
@ApiOperation({ summary: '上传文件到MinIO' })
|
||
@ApiConsumes('multipart/form-data')
|
||
@ApiModelResponse(MinioUploadResultDto, {
|
||
bucketName: 'kt-template-online',
|
||
objectName: 'uploads/1715580000000-a1b2c3-demo.png',
|
||
etag: '9b2cf535f27731c974343645a3985328',
|
||
size: 2048,
|
||
mimeType: 'image/png',
|
||
url: 'http://127.0.0.1:9000/kt-template-online/uploads/demo.png',
|
||
})
|
||
@ApiBody({
|
||
schema: {
|
||
type: 'object',
|
||
properties: {
|
||
file: {
|
||
type: 'string',
|
||
format: 'binary',
|
||
},
|
||
bucketName: {
|
||
type: 'string',
|
||
},
|
||
objectName: {
|
||
type: 'string',
|
||
},
|
||
},
|
||
required: ['file'],
|
||
},
|
||
})
|
||
async upload(
|
||
@Res() res,
|
||
@UploadedFile() file: MinioUploadFile,
|
||
@Body('bucketName') bucketName?: string,
|
||
@Body('objectName') objectName?: string,
|
||
) {
|
||
const result = await this.minioClientService.uploadObject({
|
||
bucketName,
|
||
objectName,
|
||
file,
|
||
});
|
||
|
||
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||
}
|
||
|
||
@Get('list')
|
||
@ApiOperation({ summary: '获取MinIO文件列表' })
|
||
@ApiQuery({ name: 'bucketName', required: false })
|
||
@ApiQuery({ name: 'prefix', required: false })
|
||
@ApiQuery({ name: 'recursive', required: false })
|
||
@ApiArrayResponse(MinioObjectDto, [
|
||
{
|
||
name: 'uploads/demo.png',
|
||
size: 2048,
|
||
etag: '9b2cf535f27731c974343645a3985328',
|
||
lastModified: '2026-05-13T02:30:00.000Z',
|
||
},
|
||
])
|
||
async list(
|
||
@Res() res,
|
||
@Query('bucketName') bucketName?: string,
|
||
@Query('prefix') prefix?: string,
|
||
@Query('recursive') recursive?: string,
|
||
) {
|
||
const result = await this.minioClientService.listObjects({
|
||
bucketName,
|
||
prefix,
|
||
recursive: recursive !== 'false',
|
||
});
|
||
|
||
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||
}
|
||
|
||
@Get('url')
|
||
@ApiOperation({ summary: '获取文件临时访问地址' })
|
||
@ApiQuery({ name: 'objectName' })
|
||
@ApiQuery({ name: 'bucketName', required: false })
|
||
@ApiQuery({ name: 'expiry', required: false })
|
||
@ApiSuccessResponse({
|
||
schema: {
|
||
type: 'string',
|
||
description: '文件临时访问地址',
|
||
},
|
||
example:
|
||
'http://127.0.0.1:9000/kt-template-online/uploads/demo.png?X-Amz-Algorithm=AWS4-HMAC-SHA256',
|
||
})
|
||
async getUrl(
|
||
@Res() res,
|
||
@Query('objectName') objectName: string,
|
||
@Query('bucketName') bucketName?: string,
|
||
@Query('expiry') expiry?: string,
|
||
) {
|
||
const result = await this.minioClientService.getPresignedUrl(
|
||
objectName,
|
||
bucketName,
|
||
expiry ? Number(expiry) : undefined,
|
||
);
|
||
|
||
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||
}
|
||
|
||
@Get('resource-proxy')
|
||
@ApiOperation({ summary: '代理截图所需的图片/CSS/字体资源' })
|
||
@ApiQuery({ name: 'url' })
|
||
async proxyResource(@Res() res: Response, @Query('url') url: string) {
|
||
const target = this.getProxyResourceUrl(url);
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), PROXY_RESOURCE_TIMEOUT);
|
||
|
||
try {
|
||
const response = await fetch(target, {
|
||
redirect: 'follow',
|
||
signal: controller.signal,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new BadRequestException(`资源代理失败:${response.status}`);
|
||
}
|
||
|
||
const contentType =
|
||
response.headers.get('content-type') || 'application/octet-stream';
|
||
|
||
if (!this.isAllowedProxyResource(contentType, target)) {
|
||
throw new BadRequestException('仅支持代理图片、CSS 和字体资源');
|
||
}
|
||
|
||
const data = Buffer.from(await response.arrayBuffer());
|
||
|
||
res.setHeader('Content-Type', contentType);
|
||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||
res.send(data);
|
||
} catch (err) {
|
||
if (err instanceof BadRequestException) {
|
||
throw err;
|
||
}
|
||
|
||
throw new BadRequestException('资源代理失败');
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
@Get('download')
|
||
@ApiOperation({ summary: '下载MinIO文件' })
|
||
@ApiQuery({ name: 'objectName' })
|
||
@ApiQuery({ name: 'bucketName', required: false })
|
||
@ApiFileDownloadResponse()
|
||
async download(
|
||
@Res() res: Response,
|
||
@Query('objectName') objectName: string,
|
||
@Query('bucketName') bucketName?: string,
|
||
) {
|
||
const { stream, stat } = await this.minioClientService.getObject(
|
||
objectName,
|
||
bucketName,
|
||
);
|
||
|
||
res.setHeader(
|
||
'Content-Type',
|
||
stat.metaData?.['content-type'] || 'application/octet-stream',
|
||
);
|
||
res.setHeader(
|
||
'Content-Disposition',
|
||
`attachment; filename="${encodeURIComponent(
|
||
objectName.split('/').pop(),
|
||
)}"`,
|
||
);
|
||
|
||
stream.pipe(res);
|
||
}
|
||
|
||
@Delete('remove')
|
||
@ApiOperation({ summary: '删除MinIO文件' })
|
||
@ApiQuery({ name: 'objectName' })
|
||
@ApiQuery({ name: 'bucketName', required: false })
|
||
@ApiSuccessResponse({
|
||
schema: {
|
||
type: 'boolean',
|
||
},
|
||
example: true,
|
||
})
|
||
async remove(
|
||
@Res() res,
|
||
@Query('objectName') objectName: string,
|
||
@Query('bucketName') bucketName?: string,
|
||
) {
|
||
const result = await this.minioClientService.removeObject(
|
||
objectName,
|
||
bucketName,
|
||
);
|
||
|
||
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||
}
|
||
|
||
private getProxyResourceUrl(url: string) {
|
||
if (!url) {
|
||
throw new BadRequestException('资源地址不能为空');
|
||
}
|
||
|
||
try {
|
||
const target = new URL(url);
|
||
|
||
if (!['http:', 'https:'].includes(target.protocol)) {
|
||
throw new BadRequestException('仅支持 http/https 资源');
|
||
}
|
||
|
||
return target.toString();
|
||
} catch (err) {
|
||
if (err instanceof BadRequestException) {
|
||
throw err;
|
||
}
|
||
|
||
throw new BadRequestException('资源地址不合法');
|
||
}
|
||
}
|
||
|
||
private isAllowedProxyResource(contentType: string, target: string) {
|
||
const normalizedType = contentType.split(';')[0].trim().toLowerCase();
|
||
|
||
return (
|
||
PROXY_RESOURCE_CONTENT_TYPES.some((type) =>
|
||
normalizedType.startsWith(type),
|
||
) || PROXY_RESOURCE_EXTENSION_RE.test(target)
|
||
);
|
||
}
|
||
}
|