fix and update

This commit is contained in:
lendry
2026-07-07 10:14:37 +03:00
parent 29306eb2ec
commit bd6cd0d798
22 changed files with 1544 additions and 53 deletions

View File

@@ -23,6 +23,8 @@ import { NotificationsController } from './controllers/notifications.controller'
import { MediaController } from './controllers/media.controller';
import { BotController } from './controllers/bot.controller';
import { AdminBotController } from './controllers/admin-bot.controller';
import { AdminAppReleaseController } from './controllers/admin-app-release.controller';
import { AppReleaseController } from './controllers/app-release.controller';
import { TelegramBotApiController } from './controllers/telegram-bot-api.controller';
import { CoreGrpcService } from './core-grpc.service';
import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.guard';
@@ -40,7 +42,7 @@ import { FedcmCookieInterceptor } from './interceptors/fedcm-cookie.interceptor'
useFactory: (config: ConfigService) => ({
transport: Transport.GRPC,
options: {
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat', 'bot'],
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat', 'bot', 'apprelease'],
protoPath: [
join(__dirname, '../../../shared/proto/auth.proto'),
join(__dirname, '../../../shared/proto/admin.proto'),
@@ -53,7 +55,8 @@ import { FedcmCookieInterceptor } from './interceptors/fedcm-cookie.interceptor'
join(__dirname, '../../../shared/proto/media.proto'),
join(__dirname, '../../../shared/proto/notifications.proto'),
join(__dirname, '../../../shared/proto/chat.proto'),
join(__dirname, '../../../shared/proto/bot.proto')
join(__dirname, '../../../shared/proto/bot.proto'),
join(__dirname, '../../../shared/proto/app-release.proto')
],
url: config.get<string>('SSO_CORE_GRPC_URL', 'localhost:50051'),
channelOptions: {
@@ -67,7 +70,7 @@ import { FedcmCookieInterceptor } from './interceptors/fedcm-cookie.interceptor'
}
])
],
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, FedcmController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController, BotController, AdminBotController, TelegramBotApiController],
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, FedcmController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController, BotController, AdminBotController, AdminAppReleaseController, AppReleaseController, TelegramBotApiController],
providers: [CoreGrpcService, AdminGuard, RbacManageGuard, SuperAdminGuard, FedcmCookieInterceptor]
})
export class AppModule {}

View File

@@ -0,0 +1,185 @@
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 }));
}
}

View File

@@ -0,0 +1,145 @@
import { Controller, Get, Param, Query, Res } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { buildContentDisposition } from '../media-content-disposition';
type StreamResponse = {
setHeader: (key: string, value: string) => void;
status: (code: number) => { json: (body: unknown) => void };
} & NodeJS.WritableStream;
@ApiTags('Скачивание приложений')
@Controller('releases')
export class AppReleaseController {
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;
}
private normalizePlatform(platform: string) {
const normalized = platform.trim().toUpperCase();
if (normalized === 'ANDROID' || normalized === 'WINDOWS') {
return normalized;
}
return null;
}
@Get()
@ApiOperation({ summary: 'Публичный список релизов', description: 'Возвращает опубликованные версии приложений для страницы /downloads.' })
@ApiQuery({ name: 'platform', required: false, enum: ['ANDROID', 'WINDOWS'] })
list(@Query('platform') platform?: string) {
return firstValueFrom(this.core.appRelease.ListPublicAppReleases({ platform }));
}
@Get('latest/:platform')
@ApiOperation({ summary: 'Последний релиз платформы', description: 'Метаданные последней опубликованной версии.' })
@ApiParam({ name: 'platform', enum: ['android', 'windows', 'ANDROID', 'WINDOWS'] })
latest(@Param('platform') platform: string) {
const normalized = this.normalizePlatform(platform);
if (!normalized) {
return { message: 'Некорректная платформа' };
}
return firstValueFrom(this.core.appRelease.GetLatestAppRelease({ platform: normalized }));
}
@Get('check')
@ApiOperation({
summary: 'Проверка обновления',
description: 'Для мобильного приложения: сравнивает versionCode с последним релизом.'
})
@ApiQuery({ name: 'platform', enum: ['ANDROID', 'WINDOWS'] })
@ApiQuery({ name: 'versionCode', type: Number })
check(@Query('platform') platform: string, @Query('versionCode') versionCodeRaw: string) {
const normalized = this.normalizePlatform(platform);
const versionCode = Number(versionCodeRaw);
if (!normalized || !Number.isInteger(versionCode) || versionCode < 1) {
return { updateAvailable: false };
}
return firstValueFrom(this.core.appRelease.CheckAppUpdate({ platform: normalized, versionCode }));
}
@Get('download/:platform')
@ApiOperation({ summary: 'Скачать последний релиз', description: 'Отдаёт файл последней опубликованной версии.' })
@ApiParam({ name: 'platform', enum: ['android', 'windows', 'ANDROID', 'WINDOWS'] })
@ApiResponse({ status: 200, description: 'Файл релиза' })
async downloadLatest(@Param('platform') platform: string, @Res({ passthrough: false }) response: StreamResponse) {
const normalized = this.normalizePlatform(platform);
if (!normalized) {
response.status(400).json({ message: 'Некорректная платформа' });
return;
}
await this.streamRelease(response, normalized);
}
@Get('download/:platform/:releaseId')
@ApiOperation({ summary: 'Скачать конкретный релиз', description: 'Отдаёт файл выбранной опубликованной версии.' })
async downloadById(
@Param('platform') platform: string,
@Param('releaseId') releaseId: string,
@Res({ passthrough: false }) response: StreamResponse
) {
const normalized = this.normalizePlatform(platform);
if (!normalized) {
response.status(400).json({ message: 'Некорректная платформа' });
return;
}
await this.streamRelease(response, normalized, releaseId);
}
private async streamRelease(response: StreamResponse, platform: string, releaseId?: string) {
const resolved = (await firstValueFrom(
this.core.appRelease.ResolveAppReleaseDownload({ platform, releaseId })
)) as {
storageKey: string;
fileName: string;
contentType: string;
fileSize: string;
sha256: string;
version: string;
versionCode: number;
};
const bucket = process.env.MINIO_BUCKET ?? 'lendry-id';
const object = await this.getS3Client().send(
new GetObjectCommand({
Bucket: bucket,
Key: resolved.storageKey
})
);
response.setHeader('Content-Type', resolved.contentType);
response.setHeader('Content-Length', resolved.fileSize);
response.setHeader('X-Release-Version', resolved.version);
response.setHeader('X-Release-Version-Code', String(resolved.versionCode));
response.setHeader('X-Release-Sha256', resolved.sha256);
response.setHeader('Cache-Control', 'public, max-age=300');
response.setHeader('Content-Disposition', buildContentDisposition('attachment', resolved.fileName));
const body = object.Body;
if (!body) {
response.status(404).json({ message: 'Файл релиза не найден' });
return;
}
const stream = body as NodeJS.ReadableStream;
stream.pipe(response);
}
}

View File

@@ -23,6 +23,7 @@ export class CoreGrpcService implements OnModuleInit {
family!: Record<string, GrpcMethod>;
media!: Record<string, GrpcMethod>;
bot!: Record<string, GrpcMethod>;
appRelease!: Record<string, GrpcMethod>;
constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {}
@@ -44,5 +45,6 @@ export class CoreGrpcService implements OnModuleInit {
this.chat = this.client.getService<Record<string, GrpcMethod>>('ChatService');
this.media = this.client.getService<Record<string, GrpcMethod>>('MediaService');
this.bot = this.client.getService<Record<string, GrpcMethod>>('BotService');
this.appRelease = this.client.getService<Record<string, GrpcMethod>>('AppReleaseService');
}
}

View File

@@ -0,0 +1,20 @@
import { IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class UpdateAppReleaseDto {
@IsOptional()
isPublished?: boolean;
@IsOptional()
@IsString()
@MaxLength(4000)
releaseNotes?: string;
}
export class CheckAppUpdateQueryDto {
@IsIn(['ANDROID', 'WINDOWS', 'android', 'windows'])
platform!: string;
@IsInt()
@Min(1)
versionCode!: number;
}