Files
IdP/apps/api-gateway/src/controllers/admin-app-release.controller.ts
2026-07-07 10:14:37 +03:00

186 lines
6.8 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,
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 }));
}
}