fix and update
This commit is contained in:
@@ -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,
|
||||
|
||||
230
apps/sso-core/src/domain/app-release.service.ts
Normal file
230
apps/sso-core/src/domain/app-release.service.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user