fix file size limit

This commit is contained in:
lendry
2026-07-07 17:36:01 +03:00
parent b90017aad0
commit f36a8d7456
12 changed files with 482 additions and 159 deletions

View File

@@ -4,7 +4,16 @@ import { AppReleasePlatform, Prisma } from '../generated/prisma/client';
import { PrismaService } from '../infra/prisma.service';
import { MinioService } from '../infra/minio.service';
type PlatformInput = 'ANDROID' | 'WINDOWS';
const VARIANT_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const FILENAME_VARIANT_RULES: Array<{ pattern: RegExp; variant: string }> = [
{ pattern: /arm64[-_]?v8a/i, variant: 'arm64-v8a' },
{ pattern: /armeabi[-_]?v7a/i, variant: 'armeabi-v7a' },
{ pattern: /x86[-_]?64/i, variant: 'x86_64' },
{ pattern: /(?<![a-z0-9])x86(?![a-z0-9])/i, variant: 'x86' },
{ pattern: /universal/i, variant: 'universal' },
{ pattern: /aab/i, variant: 'aab' }
];
@Injectable()
export class AppReleaseService {
@@ -13,6 +22,36 @@ export class AppReleaseService {
private readonly minio: MinioService
) {}
detectVariantFromFileName(fileName: string) {
const baseName = fileName.trim().replace(/\.apk$/i, '');
for (const rule of FILENAME_VARIANT_RULES) {
if (rule.pattern.test(baseName)) {
return rule.variant;
}
}
const normalized = baseName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
return normalized || 'universal';
}
normalizeVariant(raw?: string, fileName?: string) {
const trimmed = raw?.trim().toLowerCase();
if (trimmed) {
const normalized = trimmed.replace(/\s+/g, '-').replace(/[^a-z0-9._-]/g, '');
if (!normalized || !VARIANT_PATTERN.test(normalized)) {
throw new BadRequestException('Вариант сборки должен содержать только латиницу, цифры, точки и дефисы');
}
return normalized;
}
if (fileName?.trim()) {
return this.detectVariantFromFileName(fileName);
}
return 'universal';
}
private normalizePlatform(platform: string): AppReleasePlatform {
const normalized = platform.trim().toUpperCase();
if (normalized === 'ANDROID' || normalized === 'WINDOWS') {
@@ -26,6 +65,7 @@ export class AppReleaseService {
platform: AppReleasePlatform;
version: string;
versionCode: number;
variant: string;
fileName: string;
storageKey: string;
fileSize: bigint;
@@ -40,6 +80,7 @@ export class AppReleaseService {
platform: release.platform,
version: release.version,
versionCode: release.versionCode,
variant: release.variant,
fileName: release.fileName,
storageKey: release.storageKey,
fileSize: release.fileSize.toString(),
@@ -72,10 +113,33 @@ export class AppReleaseService {
}
}
buildStorageKey(platform: AppReleasePlatform, version: string, fileName: string) {
buildStorageKey(platform: AppReleasePlatform, version: string, variant: string, fileName: string) {
const safeVersion = version.replace(/[^a-zA-Z0-9._-]+/g, '_');
const safeVariant = variant.replace(/[^a-zA-Z0-9._-]+/g, '_');
const safeName = fileName.replace(/[^a-zA-Z0-9._-]+/g, '_');
return `releases/${platform.toLowerCase()}/${safeVersion}/${randomUUID()}-${safeName}`;
return `releases/${platform.toLowerCase()}/${safeVersion}/${safeVariant}/${randomUUID()}-${safeName}`;
}
private preferVariant<T extends { variant: string }>(releases: T[]) {
if (!releases.length) return null;
return releases.find((item) => item.variant === 'universal') ?? releases[0];
}
private async getLatestPublishedVersionCode(platform: AppReleasePlatform) {
const latest = await this.prisma.appRelease.findFirst({
where: { platform, isPublished: true },
orderBy: [{ versionCode: 'desc' }, { createdAt: 'desc' }],
select: { versionCode: true }
});
return latest?.versionCode ?? null;
}
private async listPublishedVariants(platform: AppReleasePlatform, versionCode: number) {
const releases = await this.prisma.appRelease.findMany({
where: { platform, isPublished: true, versionCode },
orderBy: [{ variant: 'asc' }, { createdAt: 'desc' }]
});
return releases;
}
async listReleases(platform?: string) {
@@ -84,7 +148,7 @@ export class AppReleaseService {
: {};
const releases = await this.prisma.appRelease.findMany({
where,
orderBy: [{ platform: 'asc' }, { versionCode: 'desc' }, { createdAt: 'desc' }]
orderBy: [{ platform: 'asc' }, { versionCode: 'desc' }, { variant: 'asc' }, { createdAt: 'desc' }]
});
return { releases: releases.map((release) => this.toResponse(release)) };
}
@@ -96,7 +160,7 @@ export class AppReleaseService {
};
const releases = await this.prisma.appRelease.findMany({
where,
orderBy: [{ platform: 'asc' }, { versionCode: 'desc' }, { createdAt: 'desc' }]
orderBy: [{ platform: 'asc' }, { versionCode: 'desc' }, { variant: 'asc' }, { createdAt: 'desc' }]
});
return { releases: releases.map((release) => this.toResponse(release)) };
}
@@ -105,6 +169,7 @@ export class AppReleaseService {
platform: string;
version: string;
versionCode: number;
variant?: string;
fileName: string;
storageKey: string;
fileSize: string | number | bigint;
@@ -114,6 +179,7 @@ export class AppReleaseService {
}) {
const platform = this.normalizePlatform(input.platform);
const version = input.version.trim();
const variant = this.normalizeVariant(input.variant, input.fileName);
if (!/^\d+(?:\.\d+){0,3}(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
throw new BadRequestException('Версия должна быть в формате semver, например 1.2.3');
}
@@ -142,6 +208,7 @@ export class AppReleaseService {
platform,
version,
versionCode: input.versionCode,
variant,
fileName: input.fileName.trim(),
storageKey: input.storageKey,
fileSize: BigInt(input.fileSize),
@@ -153,7 +220,9 @@ export class AppReleaseService {
return this.toResponse(release);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new BadRequestException('Релиз с такой версией или кодом версии уже существует для этой платформы');
throw new BadRequestException(
`Сборка «${variant}» для версии ${version} (код ${input.versionCode}) уже загружена. Выберите другой вариант или удалите существующий файл.`
);
}
throw error;
}
@@ -183,10 +252,13 @@ export class AppReleaseService {
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' }]
});
const versionCode = await this.getLatestPublishedVersionCode(normalized);
if (versionCode === null) {
throw new NotFoundException('Опубликованный релиз для этой платформы не найден');
}
const variants = await this.listPublishedVariants(normalized, versionCode);
const release = this.preferVariant(variants);
if (!release) {
throw new NotFoundException('Опубликованный релиз для этой платформы не найден');
}
@@ -194,29 +266,75 @@ export class AppReleaseService {
}
async checkUpdate(platform: string, versionCode: number) {
const latest = await this.getLatestRelease(platform);
const updateAvailable = versionCode < latest.versionCode;
const normalized = this.normalizePlatform(platform);
const latestVersionCode = await this.getLatestPublishedVersionCode(normalized);
if (latestVersionCode === null || versionCode >= latestVersionCode) {
return { updateAvailable: false, variants: [] };
}
const variants = await this.listPublishedVariants(normalized, latestVersionCode);
const preferred = this.preferVariant(variants);
if (!preferred) {
return { updateAvailable: false, variants: [] };
}
return {
updateAvailable,
latest: updateAvailable ? latest : undefined
updateAvailable: true,
latest: this.toResponse(preferred),
variants: variants.map((item) => this.toResponse(item))
};
}
async resolveDownload(platform: string, releaseId?: string) {
async resolveDownload(platform: string, releaseId?: string, variant?: 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 (releaseId) {
const release = await this.prisma.appRelease.findFirst({
where: { id: releaseId, platform: normalized, isPublished: true }
});
if (!release) {
throw new NotFoundException('Файл релиза не найден');
}
return this.toDownloadResponse(release);
}
const latestVersionCode = await this.getLatestPublishedVersionCode(normalized);
if (latestVersionCode === null) {
throw new NotFoundException('Файл релиза не найден');
}
const normalizedVariant = variant?.trim() ? this.normalizeVariant(variant) : 'universal';
let release = await this.prisma.appRelease.findFirst({
where: {
platform: normalized,
isPublished: true,
versionCode: latestVersionCode,
variant: normalizedVariant
}
});
if (!release) {
const variants = await this.listPublishedVariants(normalized, latestVersionCode);
release = this.preferVariant(variants);
}
if (!release) {
throw new NotFoundException('Файл релиза не найден');
}
return this.toDownloadResponse(release);
}
private toDownloadResponse(release: {
storageKey: string;
fileName: string;
platform: AppReleasePlatform;
fileSize: bigint;
sha256: string;
version: string;
versionCode: number;
variant: string;
}) {
return {
storageKey: release.storageKey,
fileName: release.fileName,
@@ -224,7 +342,8 @@ export class AppReleaseService {
fileSize: release.fileSize.toString(),
sha256: release.sha256,
version: release.version,
versionCode: release.versionCode
versionCode: release.versionCode,
variant: release.variant
};
}
}

View File

@@ -1411,6 +1411,7 @@ export class AuthGrpcController {
platform: string;
version: string;
versionCode: number;
variant?: string;
fileName: string;
storageKey: string;
fileSize: string;
@@ -1445,7 +1446,7 @@ export class AuthGrpcController {
}
@GrpcMethod('AppReleaseService', 'ResolveAppReleaseDownload')
resolveAppReleaseDownload(command: { platform: string; releaseId?: string }) {
return this.appRelease.resolveDownload(command.platform, command.releaseId);
resolveAppReleaseDownload(command: { platform: string; releaseId?: string; variant?: string }) {
return this.appRelease.resolveDownload(command.platform, command.releaseId, command.variant);
}
}