Add proxy with file rewrite

This commit is contained in:
2024-06-26 16:51:03 -06:00
parent e0f7c29244
commit c25a5230d9
3 changed files with 199 additions and 100 deletions

View File

@@ -1,57 +1,90 @@
import { Controller, Get, Param, Redirect, Render, UploadedFile, UseInterceptors } from '@nestjs/common'; import {
Controller,
Get,
Param,
Redirect,
Render,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileService } from './file.service'; import { FileService } from './file.service';
import { Post } from '@nestjs/common'; import { Post } from '@nestjs/common';
import { UploadedObjectInfo } from 'minio'; import { UploadedObjectInfo } from 'minio';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { InjectMetric } from '@willsoto/nestjs-prometheus'; import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Response } from 'express';
@Controller('file') @Controller('file')
@ApiTags('file') @ApiTags('file')
export class FileController { export class FileController {
constructor(private readonly fileService: FileService) { } constructor(private readonly fileService: FileService) {}
@Get() @Get()
@Render('file/upload') @Render('file/upload')
generateUploadForm() { generateUploadForm() {
return {} return {};
} }
@Get(':key') @Get(':key/direct')
@Redirect('', 302) @Redirect('', 302)
async getFile(@Param('key') key: string) { async getFile(@Param('key') key: string) {
const url = await this.fileService.generatePresignedUrl(key) const url = await this.fileService.generatePresignedUrl(key);
return { url } return { url };
} }
@Post('upload') @Get(':key')
@UseInterceptors(FileInterceptor('file')) async getFileWithProxy(@Param('key') key: string, @Res() res: Response) {
@ApiConsumes('multipart/form-data') if (!(await this.fileService.fileExists(key))) {
@Render('file/upload-result') return res.status(404).send('File not found or expired');
async handleFileUpload(@UploadedFile() file: Express.Multer.File): Promise<any> {
const upload = await this.fileService.handleFileUpload(file);
return {
...upload,
expireTime: new Date(upload.expireAt * 1000).toLocaleString(),
}
} }
const buffer = await this.fileService.getFileFromS3(key);
const metadata = await this.fileService.getFileMetadataFromS3(key);
const { originalfilename, 'content-type': contentType } = metadata.metaData;
return res
.header('Content-Type', contentType)
.header(
'Content-Disposition',
`attachment; filename="${originalfilename ?? key}"`,
)
.send(buffer);
}
@Post('upload.txt') @Post('upload')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
async handleFileSilentUpload(@UploadedFile() file: Express.Multer.File): Promise<string> { @Render('file/upload-result')
const upload = await this.fileService.handleFileUpload(file); async handleFileUpload(
return `https://api.us.dev/file/${upload.key}` @UploadedFile() file: Express.Multer.File,
} ): Promise<any> {
const upload = await this.fileService.handleFileUpload(file);
return {
...upload,
expireTime: new Date(upload.expireAt * 1000).toLocaleString(),
};
}
@Post('upload.json') @Post('upload.txt')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
async handleFileUploadJson(@UploadedFile() file: Express.Multer.File): Promise<any> { async handleFileSilentUpload(
const upload = await this.fileService.handleFileUpload(file); @UploadedFile() file: Express.Multer.File,
return { ): Promise<string> {
...upload, const upload = await this.fileService.handleFileUpload(file);
expireTime: new Date(upload.expireAt * 1000).toLocaleString(), return `https://api.us.dev/file/${upload.key}`;
} }
}
@Post('upload.json')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
async handleFileUploadJson(
@UploadedFile() file: Express.Multer.File,
): Promise<any> {
const upload = await this.fileService.handleFileUpload(file);
return {
...upload,
expireTime: new Date(upload.expireAt * 1000).toLocaleString(),
};
}
} }

View File

@@ -1,73 +1,123 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { UploadedObjectInfo } from 'minio'; import { ItemBucketMetadata, UploadedObjectInfo } from 'minio';
import { MinioService } from 'src/minio/minio.service'; import { MinioService } from 'src/minio/minio.service';
@Injectable() @Injectable()
export class FileService { export class FileService {
private readonly logger = new Logger(FileService.name); private readonly logger = new Logger(FileService.name);
private readonly bucketName; private readonly bucketName;
private readonly filePrefix = 'file'; private readonly filePrefix = 'file';
private expirationTime: number; private expirationTime: number;
constructor( constructor(
private readonly minioService: MinioService, private readonly minioService: MinioService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) { ) {
this.expirationTime = this.configService.get('file.defaultTtl', 30 * 24 * 60 * 60); this.expirationTime = this.configService.get(
this.bucketName = this.configService.get('file.bucketName', 'api.us.dev-files'); 'file.defaultTtl',
try { 30 * 24 * 60 * 60,
this.minioService.listBucketObjects(this.bucketName); );
} catch (e) { this.bucketName = this.configService.get(
this.logger.log(`Error with bucket ${this.bucketName}: ${e.message}`); 'file.bucketName',
} 'api.us.dev-files',
this.deleteExpiredFiles() );
try {
this.minioService.listBucketObjects(this.bucketName);
} catch (e) {
this.logger.log(`Error with bucket ${this.bucketName}: ${e.message}`);
} }
this.deleteExpiredFiles();
}
private generateRandomKey(): string { private generateRandomKey(): string {
return Math.random().toString(36).substring(2); return Math.random().toString(36).substring(2);
}
private pathFromKey(key: string): string {
return [this.filePrefix, key].join('/');
}
async handleFileUpload(file: Express.Multer.File): Promise<{
uploadResult: UploadedObjectInfo;
expireAt: number;
key: string;
originalFilename: string;
}> {
const expireAt = Date.now() / 1000 + this.expirationTime;
const key = this.generateRandomKey();
const uploadResult = await this.minioService.uploadBuffer(
this.bucketName,
this.pathFromKey(key),
file.buffer,
{
expireAt,
originalFilename: file.originalname,
'content-type': file.mimetype,
},
);
return {
uploadResult,
key,
expireAt,
originalFilename: file.originalname,
};
}
async getFileFromS3(key: string): Promise<Buffer> {
return this.minioService.getBuffer(this.bucketName, this.pathFromKey(key));
}
async getFileMetadataFromS3(key: string): Promise<ItemBucketMetadata> {
return this.minioService.getObjectMetadata(
this.bucketName,
this.pathFromKey(key),
);
}
async fileExists(key: string): Promise<boolean> {
return this.minioService.objectExists(
this.bucketName,
this.pathFromKey(key),
);
}
@Cron('0 0 * * *')
private async deleteExpiredFiles(): Promise<void> {
this.logger.debug('Running cron job to delete expired files');
const now = Date.now() / 1000;
const objectNames = await this.minioService.listBucketObjects(
this.bucketName,
this.filePrefix,
true,
);
for (const objectName of objectNames) {
this.logger.debug(`Checking object ${objectName}`);
const objectInfo = await this.minioService.getObjectMetadata(
this.bucketName,
objectName,
);
if (objectInfo.metaData.expireat < now) {
this.logger.debug(`Deleting object ${objectName}`);
await this.minioService.deleteObject(this.bucketName, objectName);
}
} }
}
async handleFileUpload(file: Express.Multer.File): Promise<{ uploadResult: UploadedObjectInfo, expireAt: number, key: string, originalFilename: string }> { async generatePresignedUrl(key: string): Promise<string> {
const expireAt = (Date.now() / 1000) + this.expirationTime; const objectPath = [this.filePrefix, key].join('/');
const key = this.generateRandomKey(); const metadata = await this.minioService.getObjectMetadata(
const uploadResult = await this.minioService.uploadBuffer(this.bucketName, [this.filePrefix, key].join('/'), file.buffer, { this.bucketName,
expireAt, objectPath,
originalFilename: file.originalname, );
'content-type': file.mimetype, if (metadata.expireAt < Date.now() / 1000) {
}); throw new Error('Object has expired');
return {
uploadResult,
key,
expireAt,
originalFilename: file.originalname,
}
} }
return await this.minioService.generatePresignedUrl(
@Cron('0 0 * * *') this.bucketName,
private async deleteExpiredFiles(): Promise<void> { objectPath,
this.logger.debug('Running cron job to delete expired files'); 10,
const now = Date.now() / 1000; );
const objectNames = await this.minioService.listBucketObjects(this.bucketName, this.filePrefix, true); }
for (const objectName of objectNames) {
this.logger.debug(`Checking object ${objectName}`)
const objectInfo = await this.minioService.getObjectMetadata(this.bucketName, objectName);
if (objectInfo.metaData.expireat < now) {
this.logger.debug(`Deleting object ${objectName}`);
await this.minioService.deleteObject(this.bucketName, objectName);
}
}
}
async generatePresignedUrl(key: string): Promise<string> {
const objectPath = [this.filePrefix, key].join('/');
const metadata = await this.minioService.getObjectMetadata(this.bucketName, objectPath);
if (metadata.expireAt < Date.now() / 1000) {
throw new Error('Object has expired');
}
return await this.minioService.generatePresignedUrl(this.bucketName, objectPath, 10);
}
} }

View File

@@ -91,7 +91,11 @@ export class MinioService {
prefix?: string, prefix?: string,
recursive: boolean = false, recursive: boolean = false,
): Promise<string[]> { ): Promise<string[]> {
const objectStream = await this.client.listObjectsV2(bucketName, prefix, recursive); const objectStream = await this.client.listObjectsV2(
bucketName,
prefix,
recursive,
);
const objects = await new Promise<ItemBucketMetadata[]>( const objects = await new Promise<ItemBucketMetadata[]>(
(resolve, reject) => { (resolve, reject) => {
const objects: ItemBucketMetadata[] = []; const objects: ItemBucketMetadata[] = [];
@@ -128,4 +132,16 @@ export class MinioService {
): Promise<string> { ): Promise<string> {
return await this.client.presignedGetObject(bucketName, objectName, expiry); return await this.client.presignedGetObject(bucketName, objectName, expiry);
} }
public async objectExists(
bucketName: string,
objectName: string,
): Promise<boolean> {
try {
await this.client.statObject(bucketName, objectName);
return true;
} catch (e) {
return false;
}
}
} }