import { BadRequestException, Body, Controller, Delete, Get, Param, Patch, Post, Query, Res, UploadedFile, UseGuards, UseInterceptors, UsePipes, ValidationPipe } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { createHash, randomUUID } from 'node:crypto'; import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { UpdateAppReleaseDto } from '../dto/app-release.dto'; import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard'; import { CurrentAdmin } from '../decorators/current-admin.decorator'; import { buildContentDisposition } from '../media-content-disposition'; type StreamResponse = { setHeader: (key: string, value: string) => void; status: (code: number) => { json: (body: unknown) => void }; } & NodeJS.WritableStream; const releaseWritePipe = new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: false }); @ApiTags('Релизы приложений (админ)') @ApiBearerAuth() @UseGuards(AdminGuard) @Controller('admin/releases') export class AdminAppReleaseController { private s3Client: S3Client | null = null; constructor(private readonly core: CoreGrpcService) {} private getS3Client() { if (!this.s3Client) { const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost:9000'; const useSsl = process.env.MINIO_USE_SSL === 'true'; this.s3Client = new S3Client({ endpoint: `${useSsl ? 'https' : 'http'}://${endpoint}`, region: process.env.MINIO_REGION ?? 'us-east-1', credentials: { accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin', secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin' }, forcePathStyle: true }); } return this.s3Client; } @Get() @ApiOperation({ summary: 'Список релизов приложений', description: 'Возвращает все загруженные версии Android и Windows.' }) list(@CurrentAdmin() admin: AdminRequestUser, @Query('platform') platform?: string) { assertAdminPermission(admin, 'canManageSettings'); return firstValueFrom(this.core.appRelease.ListAppReleases({ platform })); } @Post('upload') @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Загрузить новый релиз', description: 'Загружает APK или EXE в MinIO и создаёт запись релиза с SHA-256 подписью.' }) @ApiBody({ schema: { type: 'object', properties: { platform: { type: 'string', enum: ['ANDROID', 'WINDOWS'] }, version: { type: 'string', example: '1.2.0' }, versionCode: { type: 'integer', example: 120 }, releaseNotes: { type: 'string' }, file: { type: 'string', format: 'binary' } }, required: ['platform', 'version', 'versionCode', 'file'] } }) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 350 * 1024 * 1024 } })) async upload( @CurrentAdmin() admin: AdminRequestUser, @UploadedFile() file: { buffer: Buffer; originalname: string; mimetype: string; size: number } | undefined, @Body('platform') platform: string, @Body('version') version: string, @Body('versionCode') versionCodeRaw: string, @Body('releaseNotes') releaseNotes?: string ) { assertAdminPermission(admin, 'canManageSettings'); if (!file?.buffer?.length) { throw new BadRequestException('Файл релиза не передан'); } if (!platform?.trim() || !version?.trim()) { throw new BadRequestException('Укажите платформу и версию'); } const versionCode = Number(versionCodeRaw); if (!Number.isInteger(versionCode) || versionCode < 1) { throw new BadRequestException('Код версии должен быть положительным целым числом'); } const normalizedPlatform = platform.trim().toUpperCase(); const fileName = file.originalname?.trim() || 'release.bin'; const lowerName = fileName.toLowerCase(); if (normalizedPlatform === 'ANDROID' && !lowerName.endsWith('.apk')) { throw new BadRequestException('Для Android загрузите файл .apk'); } if (normalizedPlatform === 'WINDOWS' && !lowerName.endsWith('.exe')) { throw new BadRequestException('Для Windows загрузите файл .exe'); } const safeVersion = version.trim().replace(/[^a-zA-Z0-9._-]+/g, '_'); const safeName = fileName.replace(/[^a-zA-Z0-9._-]+/g, '_'); const storageKey = `releases/${normalizedPlatform.toLowerCase()}/${safeVersion}/${randomUUID()}-${safeName}`; const sha256 = createHash('sha256').update(file.buffer).digest('hex'); const contentType = normalizedPlatform === 'ANDROID' ? 'application/vnd.android.package-archive' : 'application/vnd.microsoft.portable-executable'; const bucket = process.env.MINIO_BUCKET ?? 'lendry-id'; await this.getS3Client().send( new PutObjectCommand({ Bucket: bucket, Key: storageKey, Body: file.buffer, ContentType: contentType }) ); return firstValueFrom( this.core.appRelease.CreateAppRelease({ platform: normalizedPlatform, version: version.trim(), versionCode, fileName, storageKey, fileSize: String(file.size), sha256, releaseNotes: releaseNotes?.trim() || undefined, createdById: admin.id }) ); } @Patch(':releaseId') @UsePipes(releaseWritePipe) @ApiOperation({ summary: 'Обновить релиз', description: 'Публикация/снятие с публикации и заметки к релизу.' }) @ApiParam({ name: 'releaseId', description: 'ID релиза' }) update( @CurrentAdmin() admin: AdminRequestUser, @Param('releaseId') releaseId: string, @Body() dto: UpdateAppReleaseDto ) { assertAdminPermission(admin, 'canManageSettings'); return firstValueFrom( this.core.appRelease.UpdateAppRelease({ releaseId, isPublished: dto.isPublished, releaseNotes: dto.releaseNotes }) ); } @Delete(':releaseId') @ApiOperation({ summary: 'Удалить релиз', description: 'Удаляет запись релиза и файл из хранилища.' }) @ApiParam({ name: 'releaseId', description: 'ID релиза' }) remove(@CurrentAdmin() admin: AdminRequestUser, @Param('releaseId') releaseId: string) { assertAdminPermission(admin, 'canManageSettings'); return firstValueFrom(this.core.appRelease.DeleteAppRelease({ releaseId })); } }