feat: upload module (#486)

* feat: wip upload module

* feat: local storage and serve local images

* feat: protect against injections

* feat: server local and s3 files

* fix: use storage location when serving local files

* feat: cross field env validation
This commit is contained in:
Jérémy M
2023-07-04 16:02:44 +02:00
committed by GitHub
parent 820ef184d3
commit 5e1fc1ad11
52 changed files with 2632 additions and 64 deletions

View File

@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileController } from './file.controller';
import { FileService } from '../services/file.service';
describe('FileController', () => {
let controller: FileController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FileController],
providers: [
{
provide: FileService,
useValue: {},
},
],
}).compile();
controller = module.get<FileController>(FileController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,30 @@
import { Controller, Get, Param, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { checkFilePath, checkFilename } from '../file.utils';
import { FileService } from '../services/file.service';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
@UseGuards(JwtAuthGuard)
@Controller('files')
export class FileController {
constructor(private readonly fileService: FileService) {}
/**
* Serve files from local storage
* We recommend using an s3 bucket for production
*/
@Get('*/:filename')
async getFile(@Param() params: string[], @Res() res: Response) {
const folderPath = checkFilePath(params[0]);
const filename = checkFilename(params['filename']);
const fileStream = await this.fileService.getFileStream(
folderPath,
filename,
);
fileStream.on('error', () => {
res.status(404).send({ error: 'File not found' });
});
fileStream.pipe(res);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { FileService } from './services/file.service';
import { FileUploadService } from './services/file-upload.service';
import { FileUploadResolver } from './resolvers/file-upload.resolver';
import { FileController } from './controllers/file.controller';
@Module({
providers: [FileService, FileUploadService, FileUploadResolver],
exports: [FileService, FileUploadService],
controllers: [FileController],
})
export class FileModule {}

View File

@ -0,0 +1,46 @@
import { kebabCase } from 'src/utils/kebab-case';
import { FileFolder } from './interfaces/file-folder.interface';
import { KebabCase } from 'type-fest';
import { BadRequestException } from '@nestjs/common';
import { basename } from 'path';
import { settings } from 'src/constants/settings';
import { camelCase } from 'src/utils/camel-case';
type AllowedFolders = KebabCase<keyof typeof FileFolder>;
export function checkFilePath(filePath: string): string {
const allowedFolders = Object.values(FileFolder).map((value) =>
kebabCase(value),
);
const sanitizedFilePath = filePath.replace(/\0/g, '');
const [folder, size] = sanitizedFilePath.split('/');
if (!allowedFolders.includes(folder as AllowedFolders)) {
throw new BadRequestException(`Folder ${folder} is not allowed`);
}
if (
size &&
!settings.storage.imageCropSizes[camelCase(folder)]?.includes(size)
) {
throw new BadRequestException(`Size ${size} is not allowed`);
}
return sanitizedFilePath;
}
export function checkFilename(filename: string) {
const sanitizedFilename = basename(filename.replace(/\0/g, ''));
if (
!sanitizedFilename ||
sanitizedFilename.includes('/') ||
sanitizedFilename.includes('\\') ||
!sanitizedFilename.includes('.')
) {
throw new BadRequestException(`Filename is not allowed`);
}
return basename(sanitizedFilename);
}

View File

@ -0,0 +1,9 @@
import { registerEnumType } from '@nestjs/graphql';
export enum FileFolder {
ProfilePicture = 'profilePicture',
}
registerEnumType(FileFolder, {
name: 'FileFolder',
});

View File

@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileUploadResolver } from './file-upload.resolver';
import { FileUploadService } from '../services/file-upload.service';
describe('FileUploadResolver', () => {
let resolver: FileUploadResolver;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FileUploadResolver,
{
provide: FileUploadService,
useValue: {},
},
],
}).compile();
resolver = module.get<FileUploadResolver>(FileUploadResolver);
});
it('should be defined', () => {
expect(resolver).toBeDefined();
});
});

View File

@ -0,0 +1,60 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { GraphQLUpload, FileUpload } from 'graphql-upload';
import { v4 as uuidV4 } from 'uuid';
import { FileUploadService } from '../services/file-upload.service';
import { UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { FileFolder } from '../interfaces/file-folder.interface';
@UseGuards(JwtAuthGuard)
@Resolver()
export class FileUploadResolver {
constructor(private readonly fileUploadService: FileUploadService) {}
@Mutation(() => String)
async uploadFile(
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@Args('fileFolder', { type: () => FileFolder, nullable: true })
fileFolder: FileFolder,
): Promise<string> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const ext = filename.split('.')?.[1];
const id = uuidV4();
const name = `${id}${ext ? `.${ext}` : ''}`;
const path = await this.fileUploadService.uploadFile({
file: buffer,
name,
mimeType: mimetype,
fileFolder,
});
return path.name;
}
@Mutation(() => String)
async uploadImage(
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@Args('fileFolder', { type: () => FileFolder, nullable: true })
fileFolder: FileFolder,
): Promise<string> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const ext = filename.split('.')?.[1];
const id = uuidV4();
const name = `${id}${ext ? `.${ext}` : ''}`;
const path = await this.fileUploadService.uploadImage({
file: buffer,
name,
mimeType: mimetype,
fileFolder,
});
return path.name;
}
}

View File

@ -0,0 +1,35 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileUploadService } from './file-upload.service';
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
import { LocalStorageService } from 'src/integrations/local-storage/local-storage.service';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
describe('FileUploadService', () => {
let service: FileUploadService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FileUploadService,
{
provide: S3StorageService,
useValue: {},
},
{
provide: LocalStorageService,
useValue: {},
},
{
provide: EnvironmentService,
useValue: {},
},
],
}).compile();
service = module.get<FileUploadService>(FileUploadService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,135 @@
import { Injectable } from '@nestjs/common';
import sharp from 'sharp';
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
import { kebabCase } from 'src/utils/kebab-case';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { LocalStorageService } from 'src/integrations/local-storage/local-storage.service';
import { getCropSize } from 'src/utils/image';
import { settings } from 'src/constants/settings';
import { FileFolder } from '../interfaces/file-folder.interface';
@Injectable()
export class FileUploadService {
constructor(
private readonly s3Storage: S3StorageService,
private readonly localStorage: LocalStorageService,
private readonly environmentService: EnvironmentService,
) {}
async uploadFile({
file,
name,
mimeType,
fileFolder,
}: {
file: Buffer | Uint8Array | string;
name: string;
mimeType: string | undefined;
fileFolder: FileFolder;
}) {
const storageType = this.environmentService.getStorageType();
switch (storageType) {
case 's3': {
await this.uploadFileToS3(file, name, mimeType, fileFolder);
return {
name: `/${name}`,
};
}
case 'local':
default: {
await this.uploadToLocal(file, name, fileFolder);
return {
name: `/${name}`,
};
}
}
}
async uploadImage({
file,
name,
mimeType,
fileFolder,
}: {
file: Buffer | Uint8Array | string;
name: string;
mimeType: string | undefined;
fileFolder: FileFolder;
}) {
// Get all cropSizes for this fileFolder
const cropSizes = settings.storage.imageCropSizes[fileFolder];
// Extract the values from ShortCropSize
const sizes = cropSizes.map((shortSize) => getCropSize(shortSize));
// Crop images based on sizes
const images = await Promise.all(
sizes.map((size) =>
sharp(file).resize({
[size?.type || 'width']: size?.value ?? undefined,
}),
),
);
// Upload all images to corresponding folders
await Promise.all(
images.map(async (image, index) => {
const buffer = await image.toBuffer();
return this.uploadFile({
file: buffer,
name: `${cropSizes[index]}/${name}`,
mimeType,
fileFolder,
});
}),
);
return {
name: `/${name}`,
};
}
private async uploadToLocal(
file: Buffer | Uint8Array | string,
name: string,
fileFolder: FileFolder,
): Promise<void> {
const folderName = kebabCase(fileFolder.toString());
try {
const result = await this.localStorage.uploadFile({
file,
name,
folder: folderName,
});
return result;
} catch (err) {
console.log('uploadFile error: ', err);
throw err;
}
}
private async uploadFileToS3(
file: Buffer | Uint8Array | string,
name: string,
mimeType: string | undefined,
fileFolder: FileFolder,
) {
// Aws only accept bucket with kebab-case name
const bucketFolderName = kebabCase(fileFolder.toString());
try {
const result = await this.s3Storage.uploadFile({
Key: `${bucketFolderName}/${name}`,
Body: file,
ContentType: mimeType,
});
return result;
} catch (err) {
console.log('uploadFile error: ', err);
throw err;
}
}
}

View File

@ -0,0 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileService } from './file.service';
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
describe('FileService', () => {
let service: FileService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FileService,
{
provide: S3StorageService,
useValue: {},
},
{
provide: EnvironmentService,
useValue: {},
},
],
}).compile();
service = module.get<FileService>(FileService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,55 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { createReadStream } from 'fs';
import { join } from 'path';
import { Readable } from 'stream';
@Injectable()
export class FileService {
constructor(
private readonly s3Storage: S3StorageService,
private readonly environmentService: EnvironmentService,
) {}
async getFileStream(folderPath: string, filename: string) {
const storageType = this.environmentService.getStorageType();
switch (storageType) {
case 's3':
return this.getS3FileStream(folderPath, filename);
case 'local':
default:
return this.getLocalFileStream(folderPath, filename);
}
}
private async getLocalFileStream(folderPath: string, filename: string) {
const storageLocation = this.environmentService.getStorageLocation();
const filePath = join(
process.cwd(),
`${storageLocation}/`,
folderPath,
filename,
);
return createReadStream(filePath);
}
private async getS3FileStream(folderPath: string, filename: string) {
try {
const file = await this.s3Storage.getFile({
Key: `${folderPath}/${filename}`,
});
if (!file || !file.Body || !(file.Body instanceof Readable)) {
throw new Error('Unable to get file stream');
}
return Readable.from(file.Body);
} catch (error) {
throw new NotFoundException('File not found');
}
}
}