kt-template-online-api/src/minio/minio.controller.ts

316 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
);
}
}