diff --git a/backend/services/admin-service/src/application/services/file-storage.service.ts b/backend/services/admin-service/src/application/services/file-storage.service.ts index 62a0806..b9c40f5 100644 --- a/backend/services/admin-service/src/application/services/file-storage.service.ts +++ b/backend/services/admin-service/src/application/services/file-storage.service.ts @@ -3,11 +3,13 @@ import * as crypto from 'crypto'; import { Client as MinioClient } from 'minio'; const BUCKET = 'app-releases'; +const PRESIGN_EXPIRY = 3600; // 1 hour @Injectable() export class FileStorageService { private readonly logger = new Logger(FileStorageService.name); private readonly minio: MinioClient; + private readonly ossBase: string; constructor() { this.minio = new MinioClient({ @@ -17,43 +19,25 @@ export class FileStorageService { accessKey: process.env.MINIO_ACCESS_KEY || 'genex-admin', secretKey: process.env.MINIO_SECRET_KEY || 'genex-minio-secret', }); + this.ossBase = process.env.OSS_BASE_URL || 'https://oss.gogenex.com'; } - /** Upload file to MinIO and return metadata */ - async uploadFile( - buffer: Buffer, - originalName: string, - platform: string, - versionName: string, - ) { - // Ensure bucket exists - const exists = await this.minio.bucketExists(BUCKET); - if (!exists) { - await this.minio.makeBucket(BUCKET); - } - - // Compute SHA256 - const sha256 = crypto.createHash('sha256').update(buffer).digest('hex'); - - // Generate object name + /** Generate object name for a new upload */ + generateObjectName(originalName: string, platform: string, versionName: string): string { const ext = originalName.split('.').pop() || 'bin'; const timestamp = Date.now(); const random = crypto.randomBytes(4).toString('hex'); - const objectName = `${platform}/${versionName}/${timestamp}-${random}.${ext}`; + return `${platform}/${versionName}/${timestamp}-${random}.${ext}`; + } - // Upload - await this.minio.putObject(BUCKET, objectName, buffer, buffer.length, { - 'Content-Type': - ext === 'apk' - ? 'application/vnd.android.package-archive' - : 'application/octet-stream', - }); + /** Generate a presigned PUT URL so the browser can upload directly to MinIO */ + async presignedPutUrl(objectName: string): Promise { + return this.minio.presignedPutObject(BUCKET, objectName, PRESIGN_EXPIRY); + } - return { - objectName, - fileSize: buffer.length.toString(), - sha256, - }; + /** Public download URL for an object */ + downloadUrl(objectName: string): string { + return `${this.ossBase}/app-releases/${objectName}`; } /** Stream file from MinIO to a writable (e.g. Express Response) */ diff --git a/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts b/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts index 3696f70..6e777fe 100644 --- a/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts +++ b/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts @@ -1,8 +1,7 @@ import { Controller, Get, Post, Put, Patch, Delete, Inject, - Param, Query, Body, UseGuards, UseInterceptors, UploadedFile, Req, + Param, Query, Body, UseGuards, Req, } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiQuery } from '@nestjs/swagger'; import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common'; import { AppVersionService } from '../../../application/services/app-version.service'; @@ -82,12 +81,24 @@ export class AdminVersionController { return { code: 0, data: version }; } + @Get('presigned-url') + @ApiOperation({ summary: 'Get presigned PUT URL for direct browser-to-MinIO upload' }) + async getPresignedUrl( + @Query('filename') filename: string, + @Query('platform') platform: string, + @Query('versionName') versionName: string, + ) { + const objectName = this.fileStorage.generateObjectName(filename, platform.toUpperCase(), versionName); + const uploadUrl = await this.fileStorage.presignedPutUrl(objectName); + return { + code: 0, + data: { uploadUrl, objectName, downloadUrl: this.fileStorage.downloadUrl(objectName) }, + }; + } + @Post('upload') - @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 500 * 1024 * 1024 } })) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload APK/IPA and create version' }) + @ApiOperation({ summary: 'Save version metadata after direct MinIO upload' }) async uploadVersion( - @UploadedFile() file: Express.Multer.File, @Body() body: { appType?: string; platform: string; @@ -98,57 +109,36 @@ export class AdminVersionController { isForceUpdate?: string; minOsVersion?: string; releaseDate?: string; + objectName: string; + fileSize: string; + fileSha256?: string; }, @Req() req: any, ) { - if (!file) { - return { code: 400, message: 'No file uploaded — check Content-Type boundary' }; - } - // Parse package to extract metadata (auto-fill when not provided) - const parsedInfo = await this.packageParser.parse(file.buffer, file.originalname); - const appType: AppType = (body.appType || 'GENEX_MOBILE').toUpperCase() as AppType; - const platform: Platform = body.platform - ? (body.platform.toUpperCase() as Platform) - : (parsedInfo.platform as Platform); - const versionCode = body.versionCode - ? parseInt(body.versionCode, 10) - : parsedInfo.versionCode || 1; - const versionName = body.versionName || parsedInfo.versionName || '1.0.0'; + const platform: Platform = body.platform.toUpperCase() as Platform; + const versionCode = body.versionCode ? parseInt(body.versionCode, 10) : 1; + const versionName = body.versionName || '1.0.0'; const buildNumber = body.buildNumber || versionCode.toString(); - // Upload to MinIO - const uploadResult = await this.fileStorage.uploadFile( - file.buffer, - file.originalname, - platform, - versionName, - ); - const version = await this.versionService.createVersion({ appType, platform, versionCode, versionName, buildNumber, - storageKey: uploadResult.objectName, - downloadUrl: '', // will be updated after id is known - fileSize: uploadResult.fileSize, - fileSha256: uploadResult.sha256, + storageKey: body.objectName, + downloadUrl: this.fileStorage.downloadUrl(body.objectName), + fileSize: body.fileSize, + fileSha256: body.fileSha256 || '', changelog: body.changelog || '', isForceUpdate: body.isForceUpdate === 'true', - minOsVersion: body.minOsVersion || parsedInfo.minSdkVersion, + minOsVersion: body.minOsVersion, releaseDate: body.releaseDate ? new Date(body.releaseDate) : undefined, createdBy: req.user?.sub, }); - // Direct download URL via MinIO (oss.gogenex.com/app-releases/) - const ossBase = process.env.OSS_BASE_URL || 'https://oss.gogenex.com'; - const updated = await this.versionService.updateVersion(version.id, { - downloadUrl: `${ossBase}/app-releases/${uploadResult.objectName}`, - }); - - return { code: 0, data: updated }; + return { code: 0, data: version }; } @Post('parse') diff --git a/frontend/admin-web/src/infrastructure/repositories/version.repository.ts b/frontend/admin-web/src/infrastructure/repositories/version.repository.ts index 964d021..f6a0877 100644 --- a/frontend/admin-web/src/infrastructure/repositories/version.repository.ts +++ b/frontend/admin-web/src/infrastructure/repositories/version.repository.ts @@ -21,16 +21,39 @@ class VersionRepository implements IVersionRepository { } async upload(input: UploadVersionInput): Promise { - const fd = new FormData(); - fd.append('file', input.file); - fd.append('appType', input.appType); - fd.append('platform', input.platform); - if (input.versionName) fd.append('versionName', input.versionName); - if (input.buildNumber) fd.append('buildNumber', input.buildNumber); - if (input.changelog) fd.append('changelog', input.changelog); - if (input.minOsVersion) fd.append('minOsVersion', input.minOsVersion); - fd.append('isForceUpdate', String(input.isForceUpdate)); - return httpClient.post('/api/v1/admin/versions/upload', fd, { timeout: 300000 }); + // Step 1: get presigned PUT URL from admin-service + const { uploadUrl, objectName, downloadUrl } = await httpClient.get<{ + uploadUrl: string; + objectName: string; + downloadUrl: string; + }>('/api/v1/admin/versions/presigned-url', { + params: { + filename: input.file.name, + platform: input.platform, + versionName: input.versionName || '1.0.0', + }, + }); + + // Step 2: PUT file directly to MinIO (browser → MinIO, no admin-service relay) + const putRes = await fetch(uploadUrl, { + method: 'PUT', + body: input.file, + headers: { 'Content-Type': input.file.type || 'application/octet-stream' }, + }); + if (!putRes.ok) throw new Error(`MinIO upload failed: ${putRes.status}`); + + // Step 3: save metadata to admin-service + return httpClient.post('/api/v1/admin/versions/upload', { + appType: input.appType, + platform: input.platform, + versionName: input.versionName, + buildNumber: input.buildNumber, + changelog: input.changelog, + minOsVersion: input.minOsVersion, + isForceUpdate: String(input.isForceUpdate), + objectName, + fileSize: String(input.file.size), + }); } async update(id: string, input: UpdateVersionInput): Promise {