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