diff --git a/apps/api-gateway/src/app.module.ts b/apps/api-gateway/src/app.module.ts index 90f2b07..6588878 100644 --- a/apps/api-gateway/src/app.module.ts +++ b/apps/api-gateway/src/app.module.ts @@ -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('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 {} diff --git a/apps/api-gateway/src/controllers/admin-app-release.controller.ts b/apps/api-gateway/src/controllers/admin-app-release.controller.ts new file mode 100644 index 0000000..2725a3e --- /dev/null +++ b/apps/api-gateway/src/controllers/admin-app-release.controller.ts @@ -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 })); + } +} diff --git a/apps/api-gateway/src/controllers/app-release.controller.ts b/apps/api-gateway/src/controllers/app-release.controller.ts new file mode 100644 index 0000000..ad0801b --- /dev/null +++ b/apps/api-gateway/src/controllers/app-release.controller.ts @@ -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); + } +} diff --git a/apps/api-gateway/src/core-grpc.service.ts b/apps/api-gateway/src/core-grpc.service.ts index 5b01eb0..422ef51 100644 --- a/apps/api-gateway/src/core-grpc.service.ts +++ b/apps/api-gateway/src/core-grpc.service.ts @@ -23,6 +23,7 @@ export class CoreGrpcService implements OnModuleInit { family!: Record; media!: Record; bot!: Record; + appRelease!: Record; constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {} @@ -44,5 +45,6 @@ export class CoreGrpcService implements OnModuleInit { this.chat = this.client.getService>('ChatService'); this.media = this.client.getService>('MediaService'); this.bot = this.client.getService>('BotService'); + this.appRelease = this.client.getService>('AppReleaseService'); } } diff --git a/apps/api-gateway/src/dto/app-release.dto.ts b/apps/api-gateway/src/dto/app-release.dto.ts new file mode 100644 index 0000000..4c23019 --- /dev/null +++ b/apps/api-gateway/src/dto/app-release.dto.ts @@ -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; +} diff --git a/apps/frontend/app/admin/releases/page.tsx b/apps/frontend/app/admin/releases/page.tsx new file mode 100644 index 0000000..f62058f --- /dev/null +++ b/apps/frontend/app/admin/releases/page.tsx @@ -0,0 +1,319 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Download, Loader2, Monitor, Smartphone, Trash2, Upload } from 'lucide-react'; +import { useAuth } from '@/components/id/auth-provider'; +import { AdminShell } from '@/components/id/admin-shell'; +import { useToast } from '@/components/id/toast-provider'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + AppRelease, + AppReleasePlatform, + deleteAdminAppRelease, + fetchAdminAppReleases, + getApiErrorMessage, + updateAdminAppRelease, + uploadAdminAppRelease +} from '@/lib/api'; +import { cn } from '@/lib/utils'; + +function formatBytes(value: string) { + const size = Number(value); + if (!Number.isFinite(size) || size <= 0) return '—'; + const units = ['Б', 'КБ', 'МБ', 'ГБ']; + let amount = size; + let unit = 0; + while (amount >= 1024 && unit < units.length - 1) { + amount /= 1024; + unit += 1; + } + return `${amount.toFixed(amount >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(new Date(value)); +} + +const platformMeta: Record = { + ANDROID: { label: 'Android', icon: Smartphone, accept: '.apk,application/vnd.android.package-archive' }, + WINDOWS: { label: 'Windows', icon: Monitor, accept: '.exe,application/vnd.microsoft.portable-executable' } +}; + +export default function AdminReleasesPage() { + const { token, user } = useAuth(); + const { showToast } = useToast(); + const [releases, setReleases] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [platform, setPlatform] = useState('ANDROID'); + const [version, setVersion] = useState(''); + const [versionCode, setVersionCode] = useState(''); + const [releaseNotes, setReleaseNotes] = useState(''); + const [file, setFile] = useState(null); + + const canManage = Boolean(user?.canManageSettings); + + const loadReleases = useCallback(async () => { + if (!token || !canManage) return; + setLoading(true); + try { + const response = await fetchAdminAppReleases(token); + setReleases(response.releases ?? []); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось загрузить релизы') ?? 'Ошибка'); + } finally { + setLoading(false); + } + }, [canManage, showToast, token]); + + useEffect(() => { + void loadReleases(); + }, [loadReleases]); + + const grouped = useMemo(() => { + const android = releases.filter((item) => item.platform === 'ANDROID'); + const windows = releases.filter((item) => item.platform === 'WINDOWS'); + return { android, windows }; + }, [releases]); + + async function handleUpload(event: React.FormEvent) { + event.preventDefault(); + if (!token || !file) { + showToast('Выберите файл релиза'); + return; + } + const parsedVersionCode = Number(versionCode); + if (!version.trim() || !Number.isInteger(parsedVersionCode) || parsedVersionCode < 1) { + showToast('Укажите версию и положительный код версии'); + return; + } + + setUploading(true); + try { + const created = await uploadAdminAppRelease(token, { + platform, + version: version.trim(), + versionCode: parsedVersionCode, + releaseNotes: releaseNotes.trim() || undefined, + file + }); + setReleases((current) => [created, ...current]); + setVersion(''); + setVersionCode(''); + setReleaseNotes(''); + setFile(null); + showToast('Релиз загружен и опубликован'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось загрузить релиз') ?? 'Ошибка'); + } finally { + setUploading(false); + } + } + + async function togglePublished(release: AppRelease) { + if (!token) return; + try { + const updated = await updateAdminAppRelease(token, release.id, { isPublished: !release.isPublished }); + setReleases((current) => current.map((item) => (item.id === release.id ? updated : item))); + showToast(updated.isPublished ? 'Релиз опубликован' : 'Релиз скрыт'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось обновить релиз') ?? 'Ошибка'); + } + } + + async function removeRelease(release: AppRelease) { + if (!token) return; + if (!window.confirm(`Удалить версию ${release.version} (${platformMeta[release.platform].label})?`)) return; + try { + await deleteAdminAppRelease(token, release.id); + setReleases((current) => current.filter((item) => item.id !== release.id)); + showToast('Релиз удалён'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось удалить релиз') ?? 'Ошибка'); + } + } + + function renderReleaseList(items: AppRelease[], emptyLabel: string) { + if (loading) { + return
Загружаем релизы...
; + } + if (!items.length) { + return
{emptyLabel}
; + } + + return ( +
+ {items.map((release, index) => { + const Icon = platformMeta[release.platform].icon; + const isLatest = index === 0 && release.isPublished; + return ( +
+
+
+
+ +
+
+
+

v{release.version}

+ + build {release.versionCode} + + {isLatest ? ( + + Последняя + + ) : null} + {!release.isPublished ? ( + + Скрыта + + ) : null} +
+

+ {release.fileName} · {formatBytes(release.fileSize)} · {formatDate(release.createdAt)} +

+

SHA-256: {release.sha256}

+ {release.releaseNotes ? ( +

{release.releaseNotes}

+ ) : null} +
+
+
+ + + +
+
+
+ ); + })} +
+ ); + } + + if (!canManage) { + return ( + +
Недостаточно прав для управления релизами.
+
+ ); + } + + const PlatformIcon = platformMeta[platform].icon; + + return ( + +
+
+
+
+ +
+
+

Новая версия

+

Загрузите APK или EXE с подписью SHA-256

+
+
+ +
void handleUpload(event)}> +
+ {(['ANDROID', 'WINDOWS'] as AppReleasePlatform[]).map((item) => { + const meta = platformMeta[item]; + const Icon = meta.icon; + return ( + + ); + })} +
+ + setVersion(event.target.value)} + required + /> + setVersionCode(event.target.value)} + required + /> +