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

@@ -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')
}