import { Controller, Get, Post, Put, Delete, Patch, Param, Query, Body, UploadedFile, UseInterceptors, NotFoundException, Logger, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ConfigService } from '@nestjs/config'; import { diskStorage } from 'multer'; import * as fs from 'fs'; import * as path from 'path'; import { AppVersionRepository } from '../../../infrastructure/repositories/app-version.repository'; import { Platform } from '../../../domain/entities/app-version.entity'; import { CreateVersionDto } from '../../../application/dtos/create-version.dto'; import { UpdateVersionDto } from '../../../application/dtos/update-version.dto'; const UPLOAD_DIR = '/data/versions'; @Controller('api/v1/versions') export class VersionController { private readonly logger = new Logger(VersionController.name); private readonly downloadBaseUrl: string; constructor( private readonly versionRepo: AppVersionRepository, private readonly config: ConfigService, ) { this.downloadBaseUrl = this.config.get( 'DOWNLOAD_BASE_URL', 'https://it0api.szaiai.com/downloads/versions', ); // Ensure upload directories exist for (const p of [Platform.ANDROID, Platform.IOS]) { const dir = path.join(UPLOAD_DIR, p.toLowerCase()); fs.mkdirSync(dir, { recursive: true }); } } @Get() async list( @Query('platform') platform?: string, @Query('includeDisabled') includeDisabled?: string, ) { const filter: { platform?: Platform; includeDisabled?: boolean } = {}; if (platform) { filter.platform = platform.toUpperCase() as Platform; } if (includeDisabled === 'true') { filter.includeDisabled = true; } return this.versionRepo.findAll(filter); } @Get(':id') async getById(@Param('id') id: string) { const version = await this.versionRepo.findById(id); if (!version) throw new NotFoundException('Version not found'); return version; } @Post() async create(@Body() dto: CreateVersionDto) { return this.versionRepo.create({ ...dto, releaseDate: dto.releaseDate ? new Date(dto.releaseDate) : undefined, }); } @Put(':id') async update(@Param('id') id: string, @Body() dto: UpdateVersionDto) { const existing = await this.versionRepo.findById(id); if (!existing) throw new NotFoundException('Version not found'); return this.versionRepo.update(id, { ...dto, releaseDate: dto.releaseDate ? new Date(dto.releaseDate) : undefined, }); } @Delete(':id') async delete(@Param('id') id: string) { const version = await this.versionRepo.findById(id); if (!version) throw new NotFoundException('Version not found'); // Delete the associated file if it exists if (version.downloadUrl) { const platformDir = version.platform.toLowerCase(); const filename = path.basename(new URL(version.downloadUrl).pathname); const filePath = path.join(UPLOAD_DIR, platformDir, filename); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); this.logger.log(`Deleted file: ${filePath}`); } } await this.versionRepo.delete(id); return { deleted: true }; } @Patch(':id/toggle') async toggle(@Param('id') id: string, @Body('isEnabled') isEnabled: boolean) { const existing = await this.versionRepo.findById(id); if (!existing) throw new NotFoundException('Version not found'); return this.versionRepo.update(id, { isEnabled }); } @Post('upload') @UseInterceptors( FileInterceptor('file', { storage: diskStorage({ destination: (req, _file, cb) => { const platform = (req.body.platform || 'ANDROID').toLowerCase(); const dir = path.join(UPLOAD_DIR, platform); fs.mkdirSync(dir, { recursive: true }); cb(null, dir); }, filename: (_req, file, cb) => { // Preserve original filename with timestamp prefix to avoid collisions const timestamp = Date.now(); const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); cb(null, `${timestamp}_${safeName}`); }, }), limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB }), ) async upload( @UploadedFile() file: Express.Multer.File, @Body('platform') platform: string, @Body('versionName') versionName: string, @Body('buildNumber') buildNumber: string, @Body('changelog') changelog?: string, @Body('isForceUpdate') isForceUpdate?: string, @Body('minOsVersion') minOsVersion?: string, @Body('releaseDate') releaseDate?: string, ) { const platformEnum = (platform || 'ANDROID').toUpperCase() as Platform; const platformDir = platformEnum.toLowerCase(); const downloadUrl = `${this.downloadBaseUrl}/${platformDir}/${file.filename}`; return this.versionRepo.create({ platform: platformEnum, versionName, buildNumber, changelog: changelog || undefined, downloadUrl, fileSize: file.size, isForceUpdate: isForceUpdate === 'true', isEnabled: true, minOsVersion: minOsVersion || undefined, releaseDate: releaseDate ? new Date(releaseDate) : undefined, }); } @Post('parse') @UseInterceptors( FileInterceptor('file', { storage: diskStorage({ destination: '/tmp', filename: (_req, file, cb) => { cb(null, `parse_${Date.now()}_${file.originalname}`); }, }), limits: { fileSize: 500 * 1024 * 1024 }, }), ) async parsePackage( @UploadedFile() file: Express.Multer.File, @Body('platform') platform: string, ) { try { // Dynamic import for app-info-parser (CommonJS module) const AppInfoParser = (await import('app-info-parser')).default; const parser = new AppInfoParser(file.path); const info = await parser.parse(); let result: { versionName?: string; versionCode?: string; minSdkVersion?: string } = {}; if (platform?.toUpperCase() === 'IOS') { result = { versionName: info.CFBundleShortVersionString, versionCode: info.CFBundleVersion, minSdkVersion: info.MinimumOSVersion, }; } else { result = { versionName: info.versionName, versionCode: String(info.versionCode), minSdkVersion: info.usesSdk?.minSdkVersion ? String(info.usesSdk.minSdkVersion) : undefined, }; } return result; } catch (err) { this.logger.warn(`Failed to parse package: ${(err as Error).message}`); return { versionName: null, versionCode: null, minSdkVersion: null }; } finally { // Clean up temp file if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } } } }