diff --git a/src/app.module.ts b/src/app.module.ts index dc7a8b6..ce55841 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,7 @@ import { ScheduleModule } from '@nestjs/schedule'; import { RedisClientOptions } from 'redis'; import { redisStore } from 'cache-manager-redis-yet'; import { RedirectsModule } from './redirects/redirects.module'; +import { FileModule } from './file/file.module'; @Module({ imports: [ @@ -74,6 +75,7 @@ import { RedirectsModule } from './redirects/redirects.module'; MinioModule, KvModule, RedirectsModule, + FileModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 5891938..180f0ae 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -24,4 +24,9 @@ export default () => ({ password: process.env.REDIS_PASS ?? '', db: parseInt(process.env.REDIS_DB ?? '1'), }, + file: { + bucketName: process.env.FILE_BUCKET_NAME ?? process.env.S3_BUCKET ?? 'api.us.dev-files', + // default file ttl in seconds + defaultTtl: parseInt(process.env.FILE_DEFAULT_TTL ?? (30 * 24 * 60 * 60).toString()), + } }); diff --git a/src/file/file.controller.ts b/src/file/file.controller.ts new file mode 100644 index 0000000..056f9df --- /dev/null +++ b/src/file/file.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Param, Redirect, Render, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileService } from './file.service'; +import { Post } from '@nestjs/common'; +import { UploadedObjectInfo } from 'minio'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiConsumes } from '@nestjs/swagger'; + +@Controller('file') +export class FileController { + constructor(private readonly fileService: FileService) { } + + @Get() + @Render('file/upload') + generateUploadForm() { + return {} + } + + @Get(':key') + @Redirect('', 302) + async getFile(@Param('key') key: string) { + const url = await this.fileService.generatePresignedUrl(key) + return { url } + } + + @Post() + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @Render('file/upload-result') + async handleFileUpload(@UploadedFile() file: Express.Multer.File): Promise { + const upload = await this.fileService.handleFileUpload(file); + return { + ...upload, + expireTime: new Date(upload.expireAt * 1000).toLocaleString(), + } + } +} diff --git a/src/file/file.module.ts b/src/file/file.module.ts new file mode 100644 index 0000000..ab9d681 --- /dev/null +++ b/src/file/file.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { FileService } from './file.service'; +import { FileController } from './file.controller'; +import { MinioService } from 'src/minio/minio.service'; + +@Module({ + providers: [FileService, MinioService], + controllers: [FileController] +}) +export class FileModule { } diff --git a/src/file/file.service.ts b/src/file/file.service.ts new file mode 100644 index 0000000..ab1bf0b --- /dev/null +++ b/src/file/file.service.ts @@ -0,0 +1,73 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron } from '@nestjs/schedule'; +import { UploadedObjectInfo } from 'minio'; +import { MinioService } from 'src/minio/minio.service'; + + +@Injectable() +export class FileService { + private readonly logger = new Logger(FileService.name); + private readonly bucketName; + private readonly filePrefix = 'file'; + private expirationTime: number; + + constructor( + private readonly minioService: MinioService, + private readonly configService: ConfigService, + ) { + this.expirationTime = this.configService.get('file.defaultTtl', 30 * 24 * 60 * 60); + this.bucketName = this.configService.get('file.bucketName', 'api.us.dev-files'); + try { + this.minioService.listBucketObjects(this.bucketName); + } catch (e) { + this.logger.log(`Error with bucket ${this.bucketName}: ${e.message}`); + } + this.deleteExpiredFiles() + } + + private generateRandomKey(): string { + return Math.random().toString(36).substring(2); + } + + 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.filePrefix, key].join('/'), file.buffer, { + expireAt, + originalFilename: file.originalname, + 'content-type': file.mimetype, + }); + return { + uploadResult, + key, + expireAt, + originalFilename: file.originalname, + } + } + + @Cron('0 0 * * *') + private async deleteExpiredFiles(): Promise { + 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 generatePresignedUrl(key: string): Promise { + 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); + } + +} diff --git a/src/minio/minio.service.ts b/src/minio/minio.service.ts index fae11ec..6b3f060 100644 --- a/src/minio/minio.service.ts +++ b/src/minio/minio.service.ts @@ -89,8 +89,9 @@ export class MinioService { public async listBucketObjects( bucketName: string, prefix?: string, + recursive: boolean = false, ): Promise { - const objectStream = await this.client.listObjects(bucketName, prefix); + const objectStream = await this.client.listObjectsV2(bucketName, prefix, recursive); const objects = await new Promise( (resolve, reject) => { const objects: ItemBucketMetadata[] = []; @@ -109,6 +110,10 @@ export class MinioService { await this.client.removeObject(bucketName, objectName); } + public async makeBucket(bucketName: string): Promise { + return this.client.makeBucket(bucketName); + } + /** * * @param bucketName diff --git a/views/file/upload-result.hbs b/views/file/upload-result.hbs new file mode 100644 index 0000000..68af02e --- /dev/null +++ b/views/file/upload-result.hbs @@ -0,0 +1,8 @@ +
+

Upload Result

+

File uploaded successfully.

+

File name: {{originalFilename}}

+

Right-click copy: {{key}}

+

Expires at {{expireTime}}

+

Upload another file

+
\ No newline at end of file diff --git a/views/file/upload.hbs b/views/file/upload.hbs new file mode 100644 index 0000000..543f79e --- /dev/null +++ b/views/file/upload.hbs @@ -0,0 +1,9 @@ +
+
+ + +
+
+ +
+
\ No newline at end of file