Add file upload
This commit is contained in:
@@ -20,6 +20,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
import { RedisClientOptions } from 'redis';
|
import { RedisClientOptions } from 'redis';
|
||||||
import { redisStore } from 'cache-manager-redis-yet';
|
import { redisStore } from 'cache-manager-redis-yet';
|
||||||
import { RedirectsModule } from './redirects/redirects.module';
|
import { RedirectsModule } from './redirects/redirects.module';
|
||||||
|
import { FileModule } from './file/file.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -74,6 +75,7 @@ import { RedirectsModule } from './redirects/redirects.module';
|
|||||||
MinioModule,
|
MinioModule,
|
||||||
KvModule,
|
KvModule,
|
||||||
RedirectsModule,
|
RedirectsModule,
|
||||||
|
FileModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
@@ -24,4 +24,9 @@ export default () => ({
|
|||||||
password: process.env.REDIS_PASS ?? '',
|
password: process.env.REDIS_PASS ?? '',
|
||||||
db: parseInt(process.env.REDIS_DB ?? '1'),
|
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()),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
36
src/file/file.controller.ts
Normal file
36
src/file/file.controller.ts
Normal file
@@ -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<any> {
|
||||||
|
const upload = await this.fileService.handleFileUpload(file);
|
||||||
|
return {
|
||||||
|
...upload,
|
||||||
|
expireTime: new Date(upload.expireAt * 1000).toLocaleString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
src/file/file.module.ts
Normal file
10
src/file/file.module.ts
Normal file
@@ -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 { }
|
73
src/file/file.service.ts
Normal file
73
src/file/file.service.ts
Normal file
@@ -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<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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -89,8 +89,9 @@ export class MinioService {
|
|||||||
public async listBucketObjects(
|
public async listBucketObjects(
|
||||||
bucketName: string,
|
bucketName: string,
|
||||||
prefix?: string,
|
prefix?: string,
|
||||||
|
recursive: boolean = false,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const objectStream = await this.client.listObjects(bucketName, prefix);
|
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[] = [];
|
||||||
@@ -109,6 +110,10 @@ export class MinioService {
|
|||||||
await this.client.removeObject(bucketName, objectName);
|
await this.client.removeObject(bucketName, objectName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async makeBucket(bucketName: string): Promise<void> {
|
||||||
|
return this.client.makeBucket(bucketName);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param bucketName
|
* @param bucketName
|
||||||
|
8
views/file/upload-result.hbs
Normal file
8
views/file/upload-result.hbs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<div>
|
||||||
|
<h1>Upload Result</h1>
|
||||||
|
<p>File uploaded successfully.</p>
|
||||||
|
<p>File name: {{originalFilename}}</p>
|
||||||
|
<p>Right-click copy: <a href='/file/{{key}}'>{{key}}</a></p>
|
||||||
|
<p>Expires at {{expireTime}}</p>
|
||||||
|
<p><a href='/file'>Upload another file</a></p>
|
||||||
|
</div>
|
9
views/file/upload.hbs
Normal file
9
views/file/upload.hbs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<form method='post' enctype='multipart/form-data'>
|
||||||
|
<div>
|
||||||
|
<label for='file'>Choose file to upload</label>
|
||||||
|
<input type='file' id='file' name='file' multiple />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button>Submit</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
Reference in New Issue
Block a user