diff --git a/src/app.module.ts b/src/app.module.ts index e2ece12..7701bd0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -27,6 +27,7 @@ import { JobsModule } from './jobs/jobs.module'; import { RedisModule, RedisModuleOptions } from '@liaoliaots/nestjs-redis'; import { AdsbExchangeModule } from './adsb-exchange/adsb-exchange.module'; import { APP_INTERCEPTOR } from '@nestjs/core'; +import { JunkDrawerModule } from './junk-drawer/junk-drawer.module'; @Module({ imports: [ @@ -100,6 +101,7 @@ import { APP_INTERCEPTOR } from '@nestjs/core'; FocoCoffeeModule, JobsModule, AdsbExchangeModule, + JunkDrawerModule, ], controllers: [AppController], providers: [ diff --git a/src/config/configuration.ts b/src/config/configuration.ts index ad1d706..38cbd50 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -40,4 +40,8 @@ export default () => ({ apiKey: process.env.FOCO_LIVE_AIRTABLE_APIKEY ?? '', }, }, + junkDrawer: { + bucketName: process.env.JUNK_DRAWER_BUCKET_NAME ?? 'junk-drawer', + rootPath: process.env.JUNK_DRAWER_ROOT_PATH ?? '', + }, }); diff --git a/src/junk-drawer/junk-drawer.controller.ts b/src/junk-drawer/junk-drawer.controller.ts new file mode 100644 index 0000000..29ff066 --- /dev/null +++ b/src/junk-drawer/junk-drawer.controller.ts @@ -0,0 +1,94 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Redirect, + Render, + Res, + UploadedFile, + UploadedFiles, + UseInterceptors, +} from '@nestjs/common'; +import { JunkDrawerService } from './junk-drawer.service'; +import { ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { generateUniqueSlug } from 'src/utils/slug'; +import { JunkDrawerMetadata } from './types'; +import { Response } from 'express'; + +@Controller('junk-drawer') +@ApiTags('junk-drawer') +export class JunkDrawerController { + constructor(private readonly junkDrawerService: JunkDrawerService) {} + + @Get('') + @Render('junk-drawer/upload') + generateUploadForm() { + return {}; + } + + @Get(':slug') + @Render('junk-drawer/view') + async viewJunkDrawer(@Param('slug') slug: string): Promise { + const metadata = await this.junkDrawerService.getJunkDrawerMetadata(slug); + return { ...metadata }; + } + + @Get(':slug/:filename') + async downloadJunkDrawerItem( + @Param('slug') slug: string, + @Param('filename') filename: string, + @Res() res: Response, + ) { + const metadata = await this.junkDrawerService.getJunkDrawerMetadata(slug); + if (!metadata) { + return res.status(404).send('File not found or expired'); + } + const item = metadata.items.find((item) => item.filename === filename); + if (!item) { + return res.status(404).send('File not found or expired'); + } + const buffer = await this.junkDrawerService.getJunkDrawerItem( + slug, + filename, + ); + return res + .header('Content-Type', item.mimetype) + .header('Content-Disposition', `filename="${item.filename}"`) + .send(buffer); + } + + @Post('upload') + @Redirect('', 302) + @ApiConsumes('multipart/form-data') + @UseInterceptors(FilesInterceptor('files')) + async handleFileUpload( + @UploadedFiles() files: Express.Multer.File[], + @Body('description') description: string, + ): Promise { + const uniqueSlug = generateUniqueSlug(); + const metadata: JunkDrawerMetadata = { + slug: uniqueSlug, + version: '1.0.0', + lastModified: new Date(), + description, + items: files.map((file) => ({ + filename: file.originalname, + size: file.size, + lastModified: new Date(), + mimetype: file.mimetype, + })), + }; + for (const file of files) { + await this.junkDrawerService.storeJunkDrawerItem( + uniqueSlug, + file.originalname, + file.buffer, + ); + } + await this.junkDrawerService.storeJunkDrawerMetadata(metadata); + return { url: `/junk-drawer/${uniqueSlug}` }; + } +} diff --git a/src/junk-drawer/junk-drawer.module.ts b/src/junk-drawer/junk-drawer.module.ts new file mode 100644 index 0000000..24ff2c5 --- /dev/null +++ b/src/junk-drawer/junk-drawer.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { JunkDrawerService } from './junk-drawer.service'; +import { JunkDrawerController } from './junk-drawer.controller'; +import { MinioService } from 'src/minio/minio.service'; + +@Module({ + providers: [JunkDrawerService, MinioService], + controllers: [JunkDrawerController], +}) +export class JunkDrawerModule {} diff --git a/src/junk-drawer/junk-drawer.service.ts b/src/junk-drawer/junk-drawer.service.ts new file mode 100644 index 0000000..26c2885 --- /dev/null +++ b/src/junk-drawer/junk-drawer.service.ts @@ -0,0 +1,95 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MinioService } from 'src/minio/minio.service'; +import { JunkDrawerMetadata } from './types'; +import { generateUniqueSlug } from 'src/utils/slug'; + +@Injectable() +export class JunkDrawerService { + constructor( + private readonly minioService: MinioService, + private readonly configService: ConfigService, + ) {} + + private pathForFile(filename: string): string { + return [this.configService.get('junkDrawer.rootPath'), filename] + .join('/') + .replace(/\.\.\//g, ''); + } + + private junkDrawerBucketName(): string { + return this.configService.get( + 'junkDrawer.bucketName', + 'junk-drawer', + ); + } + + public generateSlug(fileName: string): string { + return ( + generateUniqueSlug() + + fileName + .replace(/[^a-z0-9]/gi, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + ); + } + + public async getJunkDrawerMetadata( + slug: string, + ): Promise { + const metadata = await this.minioService.getBuffer( + this.junkDrawerBucketName(), + this.pathForFile(`${slug}/metadata.json`), + ); + if (metadata) { + return JSON.parse(metadata.toString()); + } + } + + public async storeJunkDrawerMetadata( + metadata: JunkDrawerMetadata, + ): Promise { + const uploadResult = await this.minioService.uploadBuffer( + this.junkDrawerBucketName(), + this.pathForFile(`${metadata.slug}/metadata.json`), + Buffer.from(JSON.stringify(metadata)), + ); + if (uploadResult) { + return metadata; + } + } + + public async storeJunkDrawerItem( + slug: string, + filename: string, + buffer: Buffer, + ): Promise { + const uploadResult = this.minioService.uploadBuffer( + this.junkDrawerBucketName(), + this.pathForFile(`${slug}/${filename}`), + buffer, + ); + } + + public async storeJunkDrawerCollection( + metadata: JunkDrawerMetadata, + files: { filename: string; buffer: Buffer }[], + ): Promise { + await this.storeJunkDrawerMetadata(metadata); + await Promise.all( + files.map((file) => + this.storeJunkDrawerItem(metadata.slug, file.filename, file.buffer), + ), + ); + } + + public async getJunkDrawerItem( + slug: string, + filename: string, + ): Promise { + return this.minioService.getBuffer( + this.junkDrawerBucketName(), + this.pathForFile(`${slug}/${filename}`), + ); + } +} diff --git a/src/junk-drawer/types.ts b/src/junk-drawer/types.ts new file mode 100644 index 0000000..60f029d --- /dev/null +++ b/src/junk-drawer/types.ts @@ -0,0 +1,14 @@ +export interface JunkDrawerMetadata { + slug: string; + version: string; + lastModified: Date; + description: string; + items: JunkDrawerItem[]; +} + +export interface JunkDrawerItem { + filename: string; + size: number; + lastModified: Date; + mimetype: string; +} diff --git a/src/utils/slug.ts b/src/utils/slug.ts new file mode 100644 index 0000000..5e4d3c1 --- /dev/null +++ b/src/utils/slug.ts @@ -0,0 +1,43 @@ +interface SlugOptions { + year: boolean; + month: boolean; + day: boolean; + hour: boolean; + minute: boolean; + second: boolean; + random: boolean; + separator: string; +} + +const defaultSlugOptions: SlugOptions = { + year: true, + month: true, + day: true, + hour: true, + minute: true, + second: false, + random: false, + separator: '', +}; + +export const generateUniqueSlug = ( + options: Partial = {}, +): string => { + const { year, month, day, hour, minute, second, random, separator } = { + ...defaultSlugOptions, + ...options, + }; + + const date = new Date(); + const parts = [ + year ? date.getFullYear() : '', + month ? String(date.getMonth() + 1).padStart(2, '0') : '', + day ? String(date.getDate()).padStart(2, '0') : '', + hour ? String(date.getHours()).padStart(2, '0') : '', + minute ? String(date.getMinutes()).padStart(2, '0') : '', + second ? String(date.getSeconds()).padStart(2, '0') : '', + random ? Math.random().toString(36).substring(2, 15) : '', + ].filter(Boolean); + + return parts.join(separator); +}; diff --git a/views/junk-drawer/upload-result.hbs b/views/junk-drawer/upload-result.hbs new file mode 100644 index 0000000..b774f1d --- /dev/null +++ b/views/junk-drawer/upload-result.hbs @@ -0,0 +1,15 @@ +
+

Upload Result

+

File uploaded successfully.

+

{{description}}

+

Right-click copy: {{slug}}

+ {{#if items}} +

Files:

+ + {{/if}} +

Upload another file

+
\ No newline at end of file diff --git a/views/junk-drawer/upload.hbs b/views/junk-drawer/upload.hbs new file mode 100644 index 0000000..51b962e --- /dev/null +++ b/views/junk-drawer/upload.hbs @@ -0,0 +1,18 @@ +
+
+ + +
+
+ + +
+
+ +
+
\ No newline at end of file diff --git a/views/junk-drawer/view.hbs b/views/junk-drawer/view.hbs new file mode 100644 index 0000000..eff090f --- /dev/null +++ b/views/junk-drawer/view.hbs @@ -0,0 +1,16 @@ +
+

Junk Drawer Item

+

The junk drawer item was uploaded {{lastModified}}

+

Description

+

{{description}}

+

Right-click copy: {{slug}}

+ {{#if items}} +

Files:

+ + {{/if}} +

Upload another file

+
\ No newline at end of file