feat: refactor folder structure (#4498)

* feat: wip refactor folder structure

* Fix

* fix position

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2024-03-15 14:40:58 +01:00
committed by GitHub
parent 52f1b3ac98
commit 94487f6737
760 changed files with 3215 additions and 3155 deletions

View File

@ -0,0 +1,27 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileService } from 'src/engine/modules/file/services/file.service';
import { FileController } from './file.controller';
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,35 @@
import { Controller, Get, Param, Res } from '@nestjs/common';
import { Response } from 'express';
import {
checkFilePath,
checkFilename,
} from 'src/engine/modules/file/file.utils';
import { FileService } from 'src/engine/modules/file/services/file.service';
// TODO: Add cookie authentication
@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,13 @@
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 { BadRequestException } from '@nestjs/common';
import { basename } from 'path';
import { KebabCase } from 'type-fest';
import { kebabCase } from 'src/utils/kebab-case';
import { settings } from 'src/engine/constants/settings';
import { FileFolder } from './interfaces/file-folder.interface';
type AllowedFolders = KebabCase<keyof typeof FileFolder>;
export const 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[folder]?.includes(size)) {
throw new BadRequestException(`Size ${size} is not allowed`);
}
return sanitizedFilePath;
};
export const 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,12 @@
import { registerEnumType } from '@nestjs/graphql';
export enum FileFolder {
ProfilePicture = 'profile-picture',
WorkspaceLogo = 'workspace-logo',
Attachment = 'attachment',
PersonPicture = 'person-picture',
}
registerEnumType(FileFolder, {
name: 'FileFolder',
});

View File

@ -0,0 +1,27 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileUploadService } from 'src/engine/modules/file/services/file-upload.service';
import { FileUploadResolver } from './file-upload.resolver';
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,56 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GraphQLUpload, FileUpload } from 'graphql-upload';
import { FileFolder } from 'src/engine/modules/file/interfaces/file-folder.interface';
import { FileUploadService } from 'src/engine/modules/file/services/file-upload.service';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@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 { path } = await this.fileUploadService.uploadFile({
file: buffer,
filename,
mimeType: mimetype,
fileFolder,
});
return path;
}
@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 { paths } = await this.fileUploadService.uploadImage({
file: buffer,
filename,
mimeType: mimetype,
fileFolder,
});
return paths[0];
}
}

View File

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

View File

@ -0,0 +1,117 @@
import { Injectable } from '@nestjs/common';
import sharp from 'sharp';
import { v4 as uuidV4 } from 'uuid';
import { FileFolder } from 'src/engine/modules/file/interfaces/file-folder.interface';
import { getCropSize } from 'src/utils/image';
import { settings } from 'src/engine/constants/settings';
import { FileStorageService } from 'src/integrations/file-storage/file-storage.service';
@Injectable()
export class FileUploadService {
constructor(private readonly fileStorage: FileStorageService) {}
private async _uploadFile({
file,
filename,
mimeType,
fileFolder,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
}) {
await this.fileStorage.write({
file,
name: filename,
mimeType,
folder: fileFolder,
});
}
async uploadFile({
file,
filename,
mimeType,
fileFolder,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
}) {
const ext = filename.split('.')?.[1];
const id = uuidV4();
const name = `${id}${ext ? `.${ext}` : ''}`;
await this._uploadFile({
file,
filename: name,
mimeType,
fileFolder,
});
return {
id,
mimeType,
path: `${fileFolder}/${name}`,
};
}
async uploadImage({
file,
filename,
mimeType,
fileFolder,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
}) {
const ext = filename.split('.')?.[1];
const id = uuidV4();
const name = `${id}${ext ? `.${ext}` : ''}`;
const cropSizes = settings.storage.imageCropSizes[fileFolder];
if (!cropSizes) {
throw new Error(`No crop sizes found for ${fileFolder}`);
}
const sizes = cropSizes.map((shortSize) => getCropSize(shortSize));
const images = await Promise.all(
sizes.map((size) =>
sharp(file).resize({
[size?.type || 'width']: size?.value ?? undefined,
}),
),
);
const paths: Array<string> = [];
await Promise.all(
images.map(async (image, index) => {
const buffer = await image.toBuffer();
paths.push(`${fileFolder}/${cropSizes[index]}/${name}`);
return this._uploadFile({
file: buffer,
filename: `${cropSizes[index]}/${name}`,
mimeType,
fileFolder,
});
}),
);
return {
id,
mimeType,
paths,
};
}
}

View File

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

View File

@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { FileStorageService } from 'src/integrations/file-storage/file-storage.service';
@Injectable()
export class FileService {
constructor(private readonly fileStorageService: FileStorageService) {}
async getFileStream(folderPath: string, filename: string) {
return this.fileStorageService.read({
folderPath,
filename,
});
}
}