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;
}

View File

@@ -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<AppReleasePlatform, { label: string; icon: typeof Smartphone; accept: string }> = {
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<AppRelease[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [platform, setPlatform] = useState<AppReleasePlatform>('ANDROID');
const [version, setVersion] = useState('');
const [versionCode, setVersionCode] = useState('');
const [releaseNotes, setReleaseNotes] = useState('');
const [file, setFile] = useState<File | null>(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<HTMLFormElement>) {
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 <div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем релизы...</div>;
}
if (!items.length) {
return <div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">{emptyLabel}</div>;
}
return (
<div className="space-y-3">
{items.map((release, index) => {
const Icon = platformMeta[release.platform].icon;
const isLatest = index === 0 && release.isPublished;
return (
<div key={release.id} className="rounded-[20px] border border-[#eceef4] bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[#f4f5f8]">
<Icon className="h-5 w-5 text-[#3390ec]" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold">v{release.version}</h3>
<span className="rounded-full bg-[#eef4ff] px-2 py-0.5 text-xs font-medium text-[#3390ec]">
build {release.versionCode}
</span>
{isLatest ? (
<span className="rounded-full bg-[#e8f8ee] px-2 py-0.5 text-xs font-medium text-[#1a7f37]">
Последняя
</span>
) : null}
{!release.isPublished ? (
<span className="rounded-full bg-[#fff4e5] px-2 py-0.5 text-xs font-medium text-[#b54708]">
Скрыта
</span>
) : null}
</div>
<p className="mt-1 text-sm text-[#667085]">
{release.fileName} · {formatBytes(release.fileSize)} · {formatDate(release.createdAt)}
</p>
<p className="mt-1 break-all font-mono text-xs text-[#8f92a0]">SHA-256: {release.sha256}</p>
{release.releaseNotes ? (
<p className="mt-3 whitespace-pre-wrap text-sm leading-relaxed text-[#1f2430]">{release.releaseNotes}</p>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button variant="outline" size="sm" className="rounded-xl" asChild>
<a href={`/downloads/${release.platform === 'ANDROID' ? 'android' : 'windows'}`} target="_blank" rel="noreferrer">
<Download className="mr-2 h-4 w-4" />
Скачать
</a>
</Button>
<Button variant="outline" size="sm" className="rounded-xl" onClick={() => void togglePublished(release)}>
{release.isPublished ? 'Скрыть' : 'Опубликовать'}
</Button>
<Button
variant="ghost"
size="sm"
className="rounded-xl text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => void removeRelease(release)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
);
}
if (!canManage) {
return (
<AdminShell active="/admin/releases">
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-6 text-[#667085]">Недостаточно прав для управления релизами.</div>
</AdminShell>
);
}
const PlatformIcon = platformMeta[platform].icon;
return (
<AdminShell active="/admin/releases">
<div className="grid gap-8 xl:grid-cols-[380px_minmax(0,1fr)]">
<section className="rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
<div className="mb-5 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
<Upload className="h-6 w-6" />
</div>
<div>
<h2 className="text-xl font-semibold">Новая версия</h2>
<p className="text-sm text-[#667085]">Загрузите APK или EXE с подписью SHA-256</p>
</div>
</div>
<form className="space-y-4" onSubmit={(event) => void handleUpload(event)}>
<div className="grid grid-cols-2 gap-2">
{(['ANDROID', 'WINDOWS'] as AppReleasePlatform[]).map((item) => {
const meta = platformMeta[item];
const Icon = meta.icon;
return (
<button
key={item}
type="button"
className={cn(
'flex items-center justify-center gap-2 rounded-2xl border px-3 py-3 text-sm font-medium transition',
platform === item
? 'border-[#3390ec] bg-[#eef4ff] text-[#1f2430]'
: 'border-[#eceef4] bg-[#f8f9fb] text-[#667085] hover:bg-[#f1f2f6]'
)}
onClick={() => setPlatform(item)}
>
<Icon className="h-4 w-4" />
{meta.label}
</button>
);
})}
</div>
<Input
placeholder="Версия, например 1.2.0"
value={version}
onChange={(event) => setVersion(event.target.value)}
required
/>
<Input
type="number"
min={1}
placeholder="Код версии (versionCode), например 120"
value={versionCode}
onChange={(event) => setVersionCode(event.target.value)}
required
/>
<textarea
className="min-h-[110px] w-full rounded-2xl border border-[#eceef4] bg-[#f8f9fb] px-4 py-3 text-sm outline-none transition focus:border-[#3390ec]"
placeholder="Что нового в этой версии"
value={releaseNotes}
onChange={(event) => setReleaseNotes(event.target.value)}
/>
<label className="flex cursor-pointer flex-col items-center justify-center rounded-[20px] border border-dashed border-[#c7d2fe] bg-[#f8faff] px-4 py-8 text-center transition hover:bg-[#eef4ff]">
<PlatformIcon className="mb-3 h-8 w-8 text-[#3390ec]" />
<span className="text-sm font-medium text-[#1f2430]">
{file ? file.name : `Выберите файл ${platform === 'ANDROID' ? '.apk' : '.exe'}`}
</span>
<span className="mt-1 text-xs text-[#667085]">До 350 МБ</span>
<input
type="file"
className="hidden"
accept={platformMeta[platform].accept}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
</label>
<Button type="submit" className="w-full rounded-xl" disabled={uploading || !file}>
{uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
{uploading ? 'Загружаем...' : 'Загрузить релиз'}
</Button>
</form>
</section>
<div className="space-y-8">
<section>
<h2 className="mb-4 text-xl font-semibold">Android</h2>
{renderReleaseList(grouped.android, 'Релизы Android пока не загружены')}
</section>
<section>
<h2 className="mb-4 text-xl font-semibold">Windows</h2>
{renderReleaseList(grouped.windows, 'Релизы Windows пока не загружены')}
</section>
</div>
</div>
</AdminShell>
);
}

View File

@@ -1521,7 +1521,7 @@ function LoginPageContent() {
<ProjectTagline className="mt-9 text-center text-sm font-semibold" />
<a
href="/download/android"
href="/downloads/android"
className="mx-auto mt-3 flex w-fit items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium text-[#b9bdc9] transition hover:bg-white/10 hover:text-white"
>
<Download className="h-3.5 w-3.5" />

View File

@@ -1,49 +1,8 @@
import { createReadStream, existsSync, statSync } from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
const APK_CANDIDATES = [
process.env.TAURI_ANDROID_APK_PATH,
path.resolve(process.cwd(), 'public/downloads/lendry-id.apk'),
path.resolve(process.cwd(), 'apps/frontend/public/downloads/lendry-id.apk'),
'/app/apps/frontend/public/downloads/lendry-id.apk',
'/app/public/downloads/lendry-id.apk',
path.resolve(process.cwd(), '../../tauri_app/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk'),
path.resolve(process.cwd(), '../../tauri_app/src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk'),
path.resolve(process.cwd(), '../../tauri_app/src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release.apk'),
path.resolve(process.cwd(), '../../tauri_app/src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk')
].filter((candidate): candidate is string => Boolean(candidate));
function resolveApkPath() {
return APK_CANDIDATES.find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
}
export async function GET() {
const apkPath = resolveApkPath();
if (!apkPath) {
return new Response(
'APK ещё не собран. Запустите: ./install.sh --build-apk (или пункт 12 в меню). После сборки файл появится как apps/frontend/public/downloads/lendry-id.apk.',
{
status: 404,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store'
}
},
);
}
const stat = statSync(apkPath);
const stream = Readable.toWeb(createReadStream(apkPath)) as ReadableStream;
return new Response(stream, {
headers: {
'Content-Type': 'application/vnd.android.package-archive',
'Content-Length': String(stat.size),
'Content-Disposition': 'attachment; filename="lendry-id.apk"',
'Cache-Control': 'no-store'
}
});
export async function GET(request: NextRequest) {
const target = new URL('/downloads/android', request.nextUrl.origin);
return NextResponse.redirect(target, 302);
}

View File

@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
function resolveApiBase(request: NextRequest) {
const internal = process.env.INTERNAL_API_URL?.replace(/\/$/, '');
if (internal) return internal;
const origin = request.nextUrl.origin;
return `${origin}/idp-api`;
}
export async function GET(request: NextRequest) {
const target = `${resolveApiBase(request)}/releases/download/android`;
return NextResponse.redirect(target, 302);
}

View File

@@ -0,0 +1,254 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { ArrowRight, Download, Monitor, ShieldCheck, Smartphone, Sparkles } from 'lucide-react';
import { BrandLogo } from '@/components/id/brand-logo';
import { usePublicSettings } from '@/components/id/public-settings-provider';
import { Button } from '@/components/ui/button';
import {
AppRelease,
AppReleasePlatform,
buildAppReleaseDownloadUrl,
fetchPublicAppReleases
} from '@/lib/api';
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: 'long',
year: 'numeric'
}).format(new Date(value));
}
const platformCards: Array<{
platform: AppReleasePlatform;
slug: 'android' | 'windows';
title: string;
subtitle: string;
icon: typeof Smartphone;
gradient: string;
}> = [
{
platform: 'ANDROID',
slug: 'android',
title: 'Android',
subtitle: 'Скачайте APK для телефона или планшета',
icon: Smartphone,
gradient: 'from-[#34c759] to-[#0f9d58]'
},
{
platform: 'WINDOWS',
slug: 'windows',
title: 'Windows',
subtitle: 'Установщик для компьютера',
icon: Monitor,
gradient: 'from-[#3390ec] to-[#1d4ed8]'
}
];
export default function DownloadsPage() {
const { projectName } = usePublicSettings();
const [releases, setReleases] = useState<AppRelease[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
void (async () => {
try {
const response = await fetchPublicAppReleases();
setReleases(response.releases ?? []);
} finally {
setLoading(false);
}
})();
}, []);
const latestByPlatform = useMemo(() => {
const map = new Map<AppReleasePlatform, AppRelease>();
for (const release of releases) {
if (!map.has(release.platform)) {
map.set(release.platform, release);
}
}
return map;
}, [releases]);
const historyByPlatform = useMemo(() => ({
ANDROID: releases.filter((item) => item.platform === 'ANDROID'),
WINDOWS: releases.filter((item) => item.platform === 'WINDOWS')
}), [releases]);
return (
<main className="min-h-screen bg-[linear-gradient(180deg,#f7f9fc_0%,#ffffff_42%,#f4f7fb_100%)]">
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:py-14">
<div className="mb-10 flex flex-wrap items-center justify-between gap-4">
<BrandLogo />
<Button variant="outline" className="rounded-full" asChild>
<Link href="/auth/login">Войти в аккаунт</Link>
</Button>
</div>
<section className="relative overflow-hidden rounded-[32px] border border-[#e8edf5] bg-white px-6 py-10 shadow-[0_24px_80px_rgba(31,36,48,0.08)] sm:px-10 sm:py-12">
<div className="absolute -right-16 -top-16 h-56 w-56 rounded-full bg-[#eef4ff] blur-3xl" />
<div className="absolute -bottom-20 left-10 h-48 w-48 rounded-full bg-[#e8f8ee] blur-3xl" />
<div className="relative max-w-3xl">
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-[#eef4ff] px-3 py-1 text-sm font-medium text-[#3390ec]">
<Sparkles className="h-4 w-4" />
Официальные сборки {projectName}
</div>
<h1 className="text-4xl font-semibold tracking-tight text-[#1f2430] sm:text-5xl">
Скачайте приложение
</h1>
<p className="mt-4 max-w-2xl text-base leading-relaxed text-[#667085] sm:text-lg">
Актуальные версии для Android и Windows. Каждый релиз подписан SHA-256 мобильное приложение может
автоматически проверять обновления по коду версии.
</p>
</div>
</section>
<section className="mt-8 grid gap-5 lg:grid-cols-2">
{platformCards.map((card) => {
const latest = latestByPlatform.get(card.platform);
const Icon = card.icon;
return (
<div
key={card.platform}
className="overflow-hidden rounded-[28px] border border-[#eceef4] bg-white shadow-[0_18px_50px_rgba(31,36,48,0.06)]"
>
<div className={`bg-gradient-to-br ${card.gradient} px-6 py-8 text-white`}>
<div className="flex items-center gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/15 backdrop-blur">
<Icon className="h-7 w-7" />
</div>
<div>
<h2 className="text-2xl font-semibold">{card.title}</h2>
<p className="mt-1 text-sm text-white/85">{card.subtitle}</p>
</div>
</div>
</div>
<div className="space-y-5 px-6 py-6">
{loading ? (
<p className="text-sm text-[#667085]">Проверяем доступные версии...</p>
) : latest ? (
<>
<div>
<p className="text-sm text-[#667085]">Последняя версия</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="text-2xl font-semibold text-[#1f2430]">v{latest.version}</span>
<span className="rounded-full bg-[#eef4ff] px-2.5 py-1 text-xs font-medium text-[#3390ec]">
build {latest.versionCode}
</span>
</div>
<p className="mt-2 text-sm text-[#667085]">
{formatBytes(latest.fileSize)} · {formatDate(latest.createdAt)}
</p>
</div>
{latest.releaseNotes ? (
<p className="rounded-2xl bg-[#f8f9fb] px-4 py-3 text-sm leading-relaxed text-[#1f2430]">
{latest.releaseNotes}
</p>
) : null}
<div className="flex flex-col gap-3 sm:flex-row">
<Button className="flex-1 rounded-xl" asChild>
<a href={`/downloads/${card.slug}`}>
<Download className="mr-2 h-4 w-4" />
Скачать последнюю
</a>
</Button>
<Button variant="outline" className="flex-1 rounded-xl" asChild>
<a href={buildAppReleaseDownloadUrl(card.platform, latest.id)}>
Прямая ссылка
<ArrowRight className="ml-2 h-4 w-4" />
</a>
</Button>
</div>
</>
) : (
<p className="text-sm text-[#667085]">Сборка для этой платформы пока не опубликована.</p>
)}
</div>
</div>
);
})}
</section>
<section className="mt-10 grid gap-6 lg:grid-cols-2">
{platformCards.map((card) => {
const history = historyByPlatform[card.platform];
return (
<div key={`history-${card.platform}`} className="rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
<h3 className="text-xl font-semibold">История версий · {card.title}</h3>
<div className="mt-4 space-y-3">
{loading ? (
<p className="text-sm text-[#667085]">Загружаем список версий...</p>
) : history.length ? (
history.map((release, index) => (
<div
key={release.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-[#f8f9fb] px-4 py-3"
>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-[#1f2430]">v{release.version}</span>
<span className="text-xs text-[#667085]">build {release.versionCode}</span>
{index === 0 ? (
<span className="rounded-full bg-[#e8f8ee] px-2 py-0.5 text-[11px] font-medium text-[#1a7f37]">
Актуальная
</span>
) : null}
</div>
<p className="mt-1 text-xs text-[#667085]">
{formatDate(release.createdAt)} · {formatBytes(release.fileSize)}
</p>
</div>
<Button variant="outline" size="sm" className="rounded-xl" asChild>
<a href={buildAppReleaseDownloadUrl(card.platform, release.id)}>Скачать</a>
</Button>
</div>
))
) : (
<p className="text-sm text-[#667085]">Версии пока не опубликованы.</p>
)}
</div>
</div>
);
})}
</section>
<section className="mt-10 rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
<div className="flex items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
<ShieldCheck className="h-5 w-5" />
</div>
<div>
<h3 className="text-lg font-semibold">Проверка обновлений в приложении</h3>
<p className="mt-2 text-sm leading-relaxed text-[#667085]">
Мобильный клиент может запрашивать{' '}
<code className="rounded bg-[#f4f5f8] px-1.5 py-0.5">GET /idp-api/releases/check?platform=ANDROID&amp;versionCode=...</code>{' '}
и сравнивать SHA-256 перед установкой. Постоянные ссылки на последнюю версию:
<span className="mt-2 block font-medium text-[#1f2430]">/downloads/android</span>
<span className="block font-medium text-[#1f2430]">/downloads/windows</span>
</p>
</div>
</div>
</section>
</div>
</main>
);
}

View File

@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
function resolveApiBase(request: NextRequest) {
const internal = process.env.INTERNAL_API_URL?.replace(/\/$/, '');
if (internal) return internal;
const origin = request.nextUrl.origin;
return `${origin}/idp-api`;
}
export async function GET(request: NextRequest) {
const target = `${resolveApiBase(request)}/releases/download/windows`;
return NextResponse.redirect(target, 302);
}

View File

@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { Bot, Boxes, Settings2, ShieldCheck, Users } from 'lucide-react';
import { Bot, Boxes, Download, Settings2, ShieldCheck, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PublicUser } from '@/lib/api';
@@ -36,6 +36,12 @@ const links = [
label: 'Настройки',
icon: Settings2,
permissions: ['canManageSettings'] as const
},
{
href: '/admin/releases',
label: 'Приложения',
icon: Download,
permissions: ['canManageSettings'] as const
}
];

View File

@@ -26,7 +26,7 @@ export function getAdminLandingPath(user: PublicUser): string {
return '/admin/oauth';
}
export function canAccessAdminSection(user: PublicUser, section: 'users' | 'oauth' | 'settings' | 'rbac' | 'bots') {
export function canAccessAdminSection(user: PublicUser, section: 'users' | 'oauth' | 'settings' | 'rbac' | 'bots' | 'releases') {
switch (section) {
case 'users':
return Boolean(user.canViewUsers || user.canManageUsers);
@@ -35,6 +35,7 @@ export function canAccessAdminSection(user: PublicUser, section: 'users' | 'oaut
case 'oauth':
return Boolean(user.canViewOAuth || user.canManageOAuth);
case 'settings':
case 'releases':
return Boolean(user.canManageSettings);
case 'rbac':
return Boolean(user.canManageRoles);

View File

@@ -1727,5 +1727,111 @@ export async function fetchFamilyPresence(groupId: string, token?: string | null
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, accessToken, true, { silent: true });
}
export type AppReleasePlatform = 'ANDROID' | 'WINDOWS';
export interface AppRelease {
id: string;
platform: AppReleasePlatform;
version: string;
versionCode: number;
fileName: string;
storageKey: string;
fileSize: string;
sha256: string;
releaseNotes?: string;
isPublished: boolean;
createdAt: string;
updatedAt: string;
}
export interface AppUpdateCheckResponse {
updateAvailable: boolean;
latest?: AppRelease;
}
export async function fetchPublicAppReleases(platform?: AppReleasePlatform) {
const query = platform ? `?platform=${platform}` : '';
return apiFetch<{ releases: AppRelease[] }>(`/releases${query}`, {}, null, true, { silent: true });
}
export async function fetchLatestAppRelease(platform: AppReleasePlatform) {
return apiFetch<AppRelease>(`/releases/latest/${platform.toLowerCase()}`, {}, null, true, { silent: true });
}
export async function checkAppUpdate(platform: AppReleasePlatform, versionCode: number) {
return apiFetch<AppUpdateCheckResponse>(
`/releases/check?platform=${platform}&versionCode=${versionCode}`,
{},
null,
true,
{ silent: true }
);
}
export function buildAppReleaseDownloadUrl(platform: AppReleasePlatform, releaseId?: string) {
const base = getApiUrl().replace(/\/$/, '');
const slug = platform.toLowerCase();
return releaseId ? `${base}/releases/download/${slug}/${releaseId}` : `${base}/releases/download/${slug}`;
}
export async function fetchAdminAppReleases(token: string, platform?: AppReleasePlatform) {
const query = platform ? `?platform=${platform}` : '';
return apiFetch<{ releases: AppRelease[] }>(`/admin/releases${query}`, {}, token);
}
export async function uploadAdminAppRelease(
token: string,
payload: {
platform: AppReleasePlatform;
version: string;
versionCode: number;
releaseNotes?: string;
file: File;
},
onProgress?: (percent: number) => void
) {
const formData = new FormData();
formData.append('platform', payload.platform);
formData.append('version', payload.version.trim());
formData.append('versionCode', String(payload.versionCode));
if (payload.releaseNotes?.trim()) {
formData.append('releaseNotes', payload.releaseNotes.trim());
}
formData.append('file', payload.file, payload.file.name);
const apiBase = getApiUrl().replace(/\/$/, '');
const response = await platformFetch(`${apiBase}/admin/releases/upload`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`
},
body: formData
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const message = typeof body?.message === 'string' ? body.message : 'Не удалось загрузить релиз';
throw new ApiError(message, response.status, 'UPLOAD_FAILED');
}
onProgress?.(100);
return (await response.json()) as AppRelease;
}
export async function updateAdminAppRelease(
token: string,
releaseId: string,
patch: { isPublished?: boolean; releaseNotes?: string }
) {
return apiFetch<AppRelease>(`/admin/releases/${releaseId}`, {
method: 'PATCH',
body: JSON.stringify(patch)
}, token);
}
export async function deleteAdminAppRelease(token: string, releaseId: string) {
return apiFetch<{ deleted: boolean }>(`/admin/releases/${releaseId}`, { method: 'DELETE' }, token);
}
/** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */
export const WS_URL = configuredWsUrl;

View File

@@ -0,0 +1,33 @@
-- CreateEnum
CREATE TYPE "AppReleasePlatform" AS ENUM ('ANDROID', 'WINDOWS');
-- CreateTable
CREATE TABLE "AppRelease" (
"id" TEXT NOT NULL,
"platform" "AppReleasePlatform" NOT NULL,
"version" TEXT NOT NULL,
"versionCode" INTEGER NOT NULL,
"fileName" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"fileSize" BIGINT NOT NULL,
"sha256" TEXT NOT NULL,
"releaseNotes" TEXT,
"isPublished" BOOLEAN NOT NULL DEFAULT true,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppRelease_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AppRelease_storageKey_key" ON "AppRelease"("storageKey");
-- CreateIndex
CREATE UNIQUE INDEX "AppRelease_platform_version_key" ON "AppRelease"("platform", "version");
-- CreateIndex
CREATE UNIQUE INDEX "AppRelease_platform_versionCode_key" ON "AppRelease"("platform", "versionCode");
-- CreateIndex
CREATE INDEX "AppRelease_platform_isPublished_createdAt_idx" ON "AppRelease"("platform", "isPublished", "createdAt");

View File

@@ -35,6 +35,31 @@ enum OAuthClientType {
PUBLIC
}
enum AppReleasePlatform {
ANDROID
WINDOWS
}
model AppRelease {
id String @id @default(uuid())
platform AppReleasePlatform
version String
versionCode Int
fileName String
storageKey String @unique
fileSize BigInt
sha256 String
releaseNotes String?
isPublished Boolean @default(true)
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([platform, version])
@@unique([platform, versionCode])
@@index([platform, isPublished, createdAt])
}
model User {
id String @id @default(uuid())
email String? @unique

View File

@@ -28,6 +28,7 @@ import { FedcmService } from './domain/fedcm.service';
import { OAuthIssuerService } from './domain/oauth-issuer.service';
import { OtpService } from './domain/otp.service';
import { AdvancedAuthService } from './domain/advanced-auth.service';
import { AppReleaseService } from './domain/app-release.service';
import { FamilyService } from './domain/family.service';
import { MediaService } from './domain/media.service';
import { NotificationsService } from './domain/notifications.service';
@@ -87,6 +88,7 @@ import { BotFatherAssistantService } from './domain/bot/bot-father-assistant.ser
OAuthIssuerService,
OtpService,
AdvancedAuthService,
AppReleaseService,
TotpService,
FamilyService,
MediaService,

View File

@@ -0,0 +1,230 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { AppReleasePlatform, Prisma } from '../generated/prisma/client';
import { PrismaService } from '../infra/prisma.service';
import { MinioService } from '../infra/minio.service';
type PlatformInput = 'ANDROID' | 'WINDOWS';
@Injectable()
export class AppReleaseService {
constructor(
private readonly prisma: PrismaService,
private readonly minio: MinioService
) {}
private normalizePlatform(platform: string): AppReleasePlatform {
const normalized = platform.trim().toUpperCase();
if (normalized === 'ANDROID' || normalized === 'WINDOWS') {
return normalized;
}
throw new BadRequestException('Платформа должна быть ANDROID или WINDOWS');
}
private toResponse(release: {
id: string;
platform: AppReleasePlatform;
version: string;
versionCode: number;
fileName: string;
storageKey: string;
fileSize: bigint;
sha256: string;
releaseNotes: string | null;
isPublished: boolean;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: release.id,
platform: release.platform,
version: release.version,
versionCode: release.versionCode,
fileName: release.fileName,
storageKey: release.storageKey,
fileSize: release.fileSize.toString(),
sha256: release.sha256,
releaseNotes: release.releaseNotes ?? undefined,
isPublished: release.isPublished,
createdAt: release.createdAt.toISOString(),
updatedAt: release.updatedAt.toISOString()
};
}
private contentTypeForPlatform(platform: AppReleasePlatform, fileName: string) {
const lower = fileName.toLowerCase();
if (platform === AppReleasePlatform.ANDROID || lower.endsWith('.apk')) {
return 'application/vnd.android.package-archive';
}
if (platform === AppReleasePlatform.WINDOWS || lower.endsWith('.exe')) {
return 'application/vnd.microsoft.portable-executable';
}
return 'application/octet-stream';
}
assertReleaseFile(platform: AppReleasePlatform, fileName: string) {
const lower = fileName.trim().toLowerCase();
if (platform === AppReleasePlatform.ANDROID && !lower.endsWith('.apk')) {
throw new BadRequestException('Для Android загрузите файл .apk');
}
if (platform === AppReleasePlatform.WINDOWS && !lower.endsWith('.exe')) {
throw new BadRequestException('Для Windows загрузите файл .exe');
}
}
buildStorageKey(platform: AppReleasePlatform, version: string, fileName: string) {
const safeVersion = version.replace(/[^a-zA-Z0-9._-]+/g, '_');
const safeName = fileName.replace(/[^a-zA-Z0-9._-]+/g, '_');
return `releases/${platform.toLowerCase()}/${safeVersion}/${randomUUID()}-${safeName}`;
}
async listReleases(platform?: string) {
const where: Prisma.AppReleaseWhereInput = platform
? { platform: this.normalizePlatform(platform) }
: {};
const releases = await this.prisma.appRelease.findMany({
where,
orderBy: [{ platform: 'asc' }, { versionCode: 'desc' }, { createdAt: 'desc' }]
});
return { releases: releases.map((release) => this.toResponse(release)) };
}
async listPublicReleases(platform?: string) {
const where: Prisma.AppReleaseWhereInput = {
isPublished: true,
...(platform ? { platform: this.normalizePlatform(platform) } : {})
};
const releases = await this.prisma.appRelease.findMany({
where,
orderBy: [{ platform: 'asc' }, { versionCode: 'desc' }, { createdAt: 'desc' }]
});
return { releases: releases.map((release) => this.toResponse(release)) };
}
async createRelease(input: {
platform: string;
version: string;
versionCode: number;
fileName: string;
storageKey: string;
fileSize: string | number | bigint;
sha256: string;
releaseNotes?: string;
createdById?: string;
}) {
const platform = this.normalizePlatform(input.platform);
const version = input.version.trim();
if (!/^\d+(?:\.\d+){0,3}(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
throw new BadRequestException('Версия должна быть в формате semver, например 1.2.3');
}
if (!Number.isInteger(input.versionCode) || input.versionCode < 1) {
throw new BadRequestException('Код версии должен быть положительным целым числом');
}
const sha256 = input.sha256.trim().toLowerCase();
if (!/^[a-f0-9]{64}$/.test(sha256)) {
throw new BadRequestException('Некорректная SHA-256 подпись файла');
}
this.assertReleaseFile(platform, input.fileName);
if (!input.storageKey.startsWith(`releases/${platform.toLowerCase()}/`)) {
throw new BadRequestException('Некорректный ключ хранения релиза');
}
const exists = await this.minio.objectExists(input.storageKey);
if (!exists) {
throw new BadRequestException('Файл релиза не найден в хранилище. Повторите загрузку.');
}
try {
const release = await this.prisma.appRelease.create({
data: {
platform,
version,
versionCode: input.versionCode,
fileName: input.fileName.trim(),
storageKey: input.storageKey,
fileSize: BigInt(input.fileSize),
sha256,
releaseNotes: input.releaseNotes?.trim() || null,
createdById: input.createdById ?? null
}
});
return this.toResponse(release);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new BadRequestException('Релиз с такой версией или кодом версии уже существует для этой платформы');
}
throw error;
}
}
async updateRelease(releaseId: string, patch: { isPublished?: boolean; releaseNotes?: string }) {
const release = await this.prisma.appRelease.update({
where: { id: releaseId },
data: {
...(patch.isPublished !== undefined ? { isPublished: patch.isPublished } : {}),
...(patch.releaseNotes !== undefined ? { releaseNotes: patch.releaseNotes.trim() || null } : {})
}
});
return this.toResponse(release);
}
async deleteRelease(releaseId: string) {
const release = await this.prisma.appRelease.findUnique({ where: { id: releaseId } });
if (!release) {
throw new NotFoundException('Релиз не найден');
}
await this.prisma.appRelease.delete({ where: { id: releaseId } });
await this.minio.deleteObject(release.storageKey).catch(() => undefined);
return { deleted: true };
}
async getLatestRelease(platform: string) {
const normalized = this.normalizePlatform(platform);
const release = await this.prisma.appRelease.findFirst({
where: { platform: normalized, isPublished: true },
orderBy: [{ versionCode: 'desc' }, { createdAt: 'desc' }]
});
if (!release) {
throw new NotFoundException('Опубликованный релиз для этой платформы не найден');
}
return this.toResponse(release);
}
async checkUpdate(platform: string, versionCode: number) {
const latest = await this.getLatestRelease(platform);
const updateAvailable = versionCode < latest.versionCode;
return {
updateAvailable,
latest: updateAvailable ? latest : undefined
};
}
async resolveDownload(platform: string, releaseId?: string) {
const normalized = this.normalizePlatform(platform);
const release = releaseId
? await this.prisma.appRelease.findFirst({
where: { id: releaseId, platform: normalized, isPublished: true }
})
: await this.prisma.appRelease.findFirst({
where: { platform: normalized, isPublished: true },
orderBy: [{ versionCode: 'desc' }, { createdAt: 'desc' }]
});
if (!release) {
throw new NotFoundException('Файл релиза не найден');
}
return {
storageKey: release.storageKey,
fileName: release.fileName,
contentType: this.contentTypeForPlatform(release.platform, release.fileName),
fileSize: release.fileSize.toString(),
sha256: release.sha256,
version: release.version,
versionCode: release.versionCode
};
}
}

View File

@@ -18,6 +18,7 @@ import { OAuthCoreService } from './oauth-core.service';
import { FedcmService } from './fedcm.service';
import { OtpService } from './otp.service';
import { AdvancedAuthService } from './advanced-auth.service';
import { AppReleaseService } from './app-release.service';
import { FamilyService } from './family.service';
import { MediaService } from './media.service';
import { NotificationsService } from './notifications.service';
@@ -48,6 +49,7 @@ export class AuthGrpcController {
private readonly fedcm: FedcmService,
private readonly otp: OtpService,
private readonly advancedAuth: AdvancedAuthService,
private readonly appRelease: AppReleaseService,
private readonly family: FamilyService,
private readonly media: MediaService,
private readonly notifications: NotificationsService,
@@ -1371,4 +1373,57 @@ export class AuthGrpcController {
updatedAt: provider.updatedAt.toISOString()
};
}
@GrpcMethod('AppReleaseService', 'ListAppReleases')
listAppReleases(command: { platform?: string }) {
return this.appRelease.listReleases(command.platform);
}
@GrpcMethod('AppReleaseService', 'ListPublicAppReleases')
listPublicAppReleases(command: { platform?: string }) {
return this.appRelease.listPublicReleases(command.platform);
}
@GrpcMethod('AppReleaseService', 'CreateAppRelease')
createAppRelease(command: {
platform: string;
version: string;
versionCode: number;
fileName: string;
storageKey: string;
fileSize: string;
sha256: string;
releaseNotes?: string;
createdById?: string;
}) {
return this.appRelease.createRelease(command);
}
@GrpcMethod('AppReleaseService', 'UpdateAppRelease')
updateAppRelease(command: { releaseId: string; isPublished?: boolean; releaseNotes?: string }) {
return this.appRelease.updateRelease(command.releaseId, {
isPublished: command.isPublished,
releaseNotes: command.releaseNotes
});
}
@GrpcMethod('AppReleaseService', 'DeleteAppRelease')
deleteAppRelease(command: { releaseId: string }) {
return this.appRelease.deleteRelease(command.releaseId);
}
@GrpcMethod('AppReleaseService', 'GetLatestAppRelease')
getLatestAppRelease(command: { platform: string }) {
return this.appRelease.getLatestRelease(command.platform);
}
@GrpcMethod('AppReleaseService', 'CheckAppUpdate')
checkAppUpdate(command: { platform: string; versionCode: number }) {
return this.appRelease.checkUpdate(command.platform, command.versionCode);
}
@GrpcMethod('AppReleaseService', 'ResolveAppReleaseDownload')
resolveAppReleaseDownload(command: { platform: string; releaseId?: string }) {
return this.appRelease.resolveDownload(command.platform, command.releaseId);
}
}

View File

@@ -162,9 +162,30 @@ export class MinioService implements OnModuleInit {
if (storageKey.endsWith('.ogg')) return 'audio/ogg';
if (storageKey.endsWith('.webm')) return 'audio/webm';
if (storageKey.endsWith('.wav')) return 'audio/wav';
if (storageKey.endsWith('.apk')) return 'application/vnd.android.package-archive';
if (storageKey.endsWith('.exe')) return 'application/vnd.microsoft.portable-executable';
return 'image/jpeg';
}
async objectExists(storageKey: string) {
try {
const { HeadObjectCommand } = await import('@aws-sdk/client-s3');
await this.internalClient.send(
new HeadObjectCommand({
Bucket: this.bucket,
Key: storageKey
})
);
return true;
} catch {
return false;
}
}
async deleteObject(storageKey: string) {
await this.deleteObjects([storageKey]);
}
async createPresignedUploadUrl(storageKey: string, contentType: string, expiresInSeconds = 300) {
const command = new PutObjectCommand({
Bucket: this.bucket,

View File

@@ -20,7 +20,7 @@ async function bootstrap() {
app.connectMicroservice<MicroserviceOptions>({
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'),
@@ -33,7 +33,8 @@ async function bootstrap() {
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.getOrThrow<string>('GRPC_URL')
}

View File

@@ -0,0 +1,94 @@
syntax = "proto3";
package apprelease;
service AppReleaseService {
rpc ListAppReleases (ListAppReleasesRequest) returns (ListAppReleasesResponse);
rpc ListPublicAppReleases (ListPublicAppReleasesRequest) returns (ListAppReleasesResponse);
rpc CreateAppRelease (CreateAppReleaseRequest) returns (AppRelease);
rpc UpdateAppRelease (UpdateAppReleaseRequest) returns (AppRelease);
rpc DeleteAppRelease (DeleteAppReleaseRequest) returns (Empty);
rpc GetLatestAppRelease (GetLatestAppReleaseRequest) returns (AppRelease);
rpc CheckAppUpdate (CheckAppUpdateRequest) returns (CheckAppUpdateResponse);
rpc ResolveAppReleaseDownload (ResolveAppReleaseDownloadRequest) returns (AppReleaseDownloadResponse);
}
message Empty {}
message AppRelease {
string id = 1;
string platform = 2;
string version = 3;
int32 versionCode = 4;
string fileName = 5;
string storageKey = 6;
string fileSize = 7;
string sha256 = 8;
optional string releaseNotes = 9;
bool isPublished = 10;
string createdAt = 11;
string updatedAt = 12;
}
message ListAppReleasesRequest {
optional string platform = 1;
}
message ListPublicAppReleasesRequest {
optional string platform = 1;
}
message ListAppReleasesResponse {
repeated AppRelease releases = 1;
}
message CreateAppReleaseRequest {
string platform = 1;
string version = 2;
int32 versionCode = 3;
string fileName = 4;
string storageKey = 5;
string fileSize = 6;
string sha256 = 7;
optional string releaseNotes = 8;
optional string createdById = 9;
}
message UpdateAppReleaseRequest {
string releaseId = 1;
optional bool isPublished = 2;
optional string releaseNotes = 3;
}
message DeleteAppReleaseRequest {
string releaseId = 1;
}
message GetLatestAppReleaseRequest {
string platform = 1;
}
message CheckAppUpdateRequest {
string platform = 1;
int32 versionCode = 2;
}
message CheckAppUpdateResponse {
bool updateAvailable = 1;
optional AppRelease latest = 2;
}
message ResolveAppReleaseDownloadRequest {
string platform = 1;
optional string releaseId = 2;
}
message AppReleaseDownloadResponse {
string storageKey = 1;
string fileName = 2;
string contentType = 3;
string fileSize = 4;
string sha256 = 5;
string version = 6;
int32 versionCode = 7;
}