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:
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user