feat: wip server folder structure (#4573)

* feat: wip server folder structure

* fix: merge

* fix: wrong merge

* fix: remove unused file

* fix: comment

* fix: lint

* fix: merge

* fix: remove console.log

* fix: metadata graphql arguments broken
This commit is contained in:
Jérémy M
2024-03-20 16:23:46 +01:00
committed by GitHub
parent da12710fe9
commit e5c1309e8c
461 changed files with 1396 additions and 1322 deletions

View File

@ -0,0 +1,33 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CanActivate } from '@nestjs/common';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileController } from './file.controller';
describe('FileController', () => {
let controller: FileController;
const mock_FilePathGuard: CanActivate = { canActivate: jest.fn(() => true) };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FileController],
providers: [
{
provide: FileService,
useValue: {},
},
],
})
.overrideGuard(FilePathGuard)
.useValue(mock_FilePathGuard)
.compile();
controller = module.get<FileController>(FileController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,37 @@
import { Controller, Get, Param, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import {
checkFilePath,
checkFilename,
} from 'src/engine/core-modules/file/file.utils';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
// TODO: Add cookie authentication
@Controller('files')
@UseGuards(FilePathGuard)
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,11 @@
import { Module } from '@nestjs/common';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { FileUploadResolver } from 'src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
@Module({
providers: [FileUploadService, FileUploadResolver, EnvironmentService],
exports: [FileUploadService, FileUploadResolver],
})
export class FileUploadModule {}

View File

@ -0,0 +1,27 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/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,57 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GraphQLUpload, FileUpload } from 'graphql-upload';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
@UseGuards(JwtAuthGuard, DemoEnvGuard)
@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/engine/integrations/environment/environment.service';
import { FileStorageService } from 'src/engine/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/core-modules/file/interfaces/file-folder.interface';
import { getCropSize } from 'src/utils/image';
import { settings } from 'src/engine/constants/settings';
import { FileStorageService } from 'src/engine/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,17 @@
import { Module } from '@nestjs/common';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileService } from './services/file.service';
import { FileController } from './controllers/file.controller';
@Module({
imports: [FileUploadModule, AuthModule],
providers: [FileService, EnvironmentService, FilePathGuard],
exports: [FileService],
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,52 @@
import {
Injectable,
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
@Injectable()
export class FilePathGuard implements CanActivate {
constructor(
private readonly tokenService: TokenService,
private readonly environmentService: EnvironmentService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const query = context.switchToHttp().getRequest().query;
if (query && query['token']) {
return !(await this.isExpired(query['token']));
}
return true;
}
private async isExpired(signedExpirationDate: string): Promise<boolean> {
const decodedPayload = await this.tokenService.decodePayload(
signedExpirationDate,
{
secret: this.environmentService.get('FILE_TOKEN_SECRET'),
},
);
const expirationDate = decodedPayload?.['expiration_date'];
if (!expirationDate) {
return true;
}
if (new Date(expirationDate) < new Date()) {
throw new HttpException(
'This url has expired. Please reload twenty page and open file again.',
HttpStatus.FORBIDDEN,
);
}
return false;
}
}

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/core-modules/file/file-upload/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,57 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GraphQLUpload, FileUpload } from 'graphql-upload';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
@UseGuards(JwtAuthGuard, DemoEnvGuard)
@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/engine/integrations/environment/environment.service';
import { FileStorageService } from 'src/engine/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/engine/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,
});
}
}