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

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