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