feat(upload): presigned URL — browser uploads directly to MinIO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3b4ee26a33
commit
0f611cf8cd
|
|
@ -3,11 +3,13 @@ import * as crypto from 'crypto';
|
||||||
import { Client as MinioClient } from 'minio';
|
import { Client as MinioClient } from 'minio';
|
||||||
|
|
||||||
const BUCKET = 'app-releases';
|
const BUCKET = 'app-releases';
|
||||||
|
const PRESIGN_EXPIRY = 3600; // 1 hour
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileStorageService {
|
export class FileStorageService {
|
||||||
private readonly logger = new Logger(FileStorageService.name);
|
private readonly logger = new Logger(FileStorageService.name);
|
||||||
private readonly minio: MinioClient;
|
private readonly minio: MinioClient;
|
||||||
|
private readonly ossBase: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.minio = new MinioClient({
|
this.minio = new MinioClient({
|
||||||
|
|
@ -17,43 +19,25 @@ export class FileStorageService {
|
||||||
accessKey: process.env.MINIO_ACCESS_KEY || 'genex-admin',
|
accessKey: process.env.MINIO_ACCESS_KEY || 'genex-admin',
|
||||||
secretKey: process.env.MINIO_SECRET_KEY || 'genex-minio-secret',
|
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 */
|
/** Generate object name for a new upload */
|
||||||
async uploadFile(
|
generateObjectName(originalName: string, platform: string, versionName: string): string {
|
||||||
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
|
|
||||||
const ext = originalName.split('.').pop() || 'bin';
|
const ext = originalName.split('.').pop() || 'bin';
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const random = crypto.randomBytes(4).toString('hex');
|
const random = crypto.randomBytes(4).toString('hex');
|
||||||
const objectName = `${platform}/${versionName}/${timestamp}-${random}.${ext}`;
|
return `${platform}/${versionName}/${timestamp}-${random}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Upload
|
/** Generate a presigned PUT URL so the browser can upload directly to MinIO */
|
||||||
await this.minio.putObject(BUCKET, objectName, buffer, buffer.length, {
|
async presignedPutUrl(objectName: string): Promise<string> {
|
||||||
'Content-Type':
|
return this.minio.presignedPutObject(BUCKET, objectName, PRESIGN_EXPIRY);
|
||||||
ext === 'apk'
|
}
|
||||||
? 'application/vnd.android.package-archive'
|
|
||||||
: 'application/octet-stream',
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
/** Public download URL for an object */
|
||||||
objectName,
|
downloadUrl(objectName: string): string {
|
||||||
fileSize: buffer.length.toString(),
|
return `${this.ossBase}/app-releases/${objectName}`;
|
||||||
sha256,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stream file from MinIO to a writable (e.g. Express Response) */
|
/** Stream file from MinIO to a writable (e.g. Express Response) */
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import {
|
import {
|
||||||
Controller, Get, Post, Put, Patch, Delete, Inject,
|
Controller, Get, Post, Put, Patch, Delete, Inject,
|
||||||
Param, Query, Body, UseGuards, UseInterceptors, UploadedFile, Req,
|
Param, Query, Body, UseGuards, Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiQuery } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common';
|
import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common';
|
||||||
import { AppVersionService } from '../../../application/services/app-version.service';
|
import { AppVersionService } from '../../../application/services/app-version.service';
|
||||||
|
|
@ -82,12 +81,24 @@ export class AdminVersionController {
|
||||||
return { code: 0, data: version };
|
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')
|
@Post('upload')
|
||||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 500 * 1024 * 1024 } }))
|
@ApiOperation({ summary: 'Save version metadata after direct MinIO upload' })
|
||||||
@ApiConsumes('multipart/form-data')
|
|
||||||
@ApiOperation({ summary: 'Upload APK/IPA and create version' })
|
|
||||||
async uploadVersion(
|
async uploadVersion(
|
||||||
@UploadedFile() file: Express.Multer.File,
|
|
||||||
@Body() body: {
|
@Body() body: {
|
||||||
appType?: string;
|
appType?: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
|
|
@ -98,57 +109,36 @@ export class AdminVersionController {
|
||||||
isForceUpdate?: string;
|
isForceUpdate?: string;
|
||||||
minOsVersion?: string;
|
minOsVersion?: string;
|
||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
|
objectName: string;
|
||||||
|
fileSize: string;
|
||||||
|
fileSha256?: string;
|
||||||
},
|
},
|
||||||
@Req() req: any,
|
@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 appType: AppType = (body.appType || 'GENEX_MOBILE').toUpperCase() as AppType;
|
||||||
const platform: Platform = body.platform
|
const platform: Platform = body.platform.toUpperCase() as Platform;
|
||||||
? (body.platform.toUpperCase() as Platform)
|
const versionCode = body.versionCode ? parseInt(body.versionCode, 10) : 1;
|
||||||
: (parsedInfo.platform as Platform);
|
const versionName = body.versionName || '1.0.0';
|
||||||
const versionCode = body.versionCode
|
|
||||||
? parseInt(body.versionCode, 10)
|
|
||||||
: parsedInfo.versionCode || 1;
|
|
||||||
const versionName = body.versionName || parsedInfo.versionName || '1.0.0';
|
|
||||||
const buildNumber = body.buildNumber || versionCode.toString();
|
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({
|
const version = await this.versionService.createVersion({
|
||||||
appType,
|
appType,
|
||||||
platform,
|
platform,
|
||||||
versionCode,
|
versionCode,
|
||||||
versionName,
|
versionName,
|
||||||
buildNumber,
|
buildNumber,
|
||||||
storageKey: uploadResult.objectName,
|
storageKey: body.objectName,
|
||||||
downloadUrl: '', // will be updated after id is known
|
downloadUrl: this.fileStorage.downloadUrl(body.objectName),
|
||||||
fileSize: uploadResult.fileSize,
|
fileSize: body.fileSize,
|
||||||
fileSha256: uploadResult.sha256,
|
fileSha256: body.fileSha256 || '',
|
||||||
changelog: body.changelog || '',
|
changelog: body.changelog || '',
|
||||||
isForceUpdate: body.isForceUpdate === 'true',
|
isForceUpdate: body.isForceUpdate === 'true',
|
||||||
minOsVersion: body.minOsVersion || parsedInfo.minSdkVersion,
|
minOsVersion: body.minOsVersion,
|
||||||
releaseDate: body.releaseDate ? new Date(body.releaseDate) : undefined,
|
releaseDate: body.releaseDate ? new Date(body.releaseDate) : undefined,
|
||||||
createdBy: req.user?.sub,
|
createdBy: req.user?.sub,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Direct download URL via MinIO (oss.gogenex.com/app-releases/<storageKey>)
|
return { code: 0, data: version };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('parse')
|
@Post('parse')
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,39 @@ class VersionRepository implements IVersionRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload(input: UploadVersionInput): Promise<AppVersion> {
|
async upload(input: UploadVersionInput): Promise<AppVersion> {
|
||||||
const fd = new FormData();
|
// Step 1: get presigned PUT URL from admin-service
|
||||||
fd.append('file', input.file);
|
const { uploadUrl, objectName, downloadUrl } = await httpClient.get<{
|
||||||
fd.append('appType', input.appType);
|
uploadUrl: string;
|
||||||
fd.append('platform', input.platform);
|
objectName: string;
|
||||||
if (input.versionName) fd.append('versionName', input.versionName);
|
downloadUrl: string;
|
||||||
if (input.buildNumber) fd.append('buildNumber', input.buildNumber);
|
}>('/api/v1/admin/versions/presigned-url', {
|
||||||
if (input.changelog) fd.append('changelog', input.changelog);
|
params: {
|
||||||
if (input.minOsVersion) fd.append('minOsVersion', input.minOsVersion);
|
filename: input.file.name,
|
||||||
fd.append('isForceUpdate', String(input.isForceUpdate));
|
platform: input.platform,
|
||||||
return httpClient.post<AppVersion>('/api/v1/admin/versions/upload', fd, { timeout: 300000 });
|
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<AppVersion>('/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<AppVersion> {
|
async update(id: string, input: UpdateVersionInput): Promise<AppVersion> {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue