file storage workspace id prefix (#6230)

closes https://github.com/twentyhq/twenty/issues/6155

just an idea, i guess this could work well, but im open for discussion

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
rostaklein
2024-08-01 18:07:22 +02:00
committed by GitHub
parent 5c92ab937e
commit a424c63476
26 changed files with 727 additions and 231 deletions

View File

@ -75,11 +75,6 @@ export class SignInUpService {
const passwordHash = password ? await hashPassword(password) : undefined;
let imagePath: string | undefined;
if (picture) {
imagePath = await this.uploadPicture(picture);
}
const existingUser = await this.userRepository.findOne({
where: {
email: email,
@ -103,7 +98,7 @@ export class SignInUpService {
workspaceInviteHash,
firstName,
lastName,
imagePath,
picture,
existingUser,
});
}
@ -113,7 +108,7 @@ export class SignInUpService {
passwordHash,
firstName,
lastName,
imagePath,
picture,
});
}
@ -126,7 +121,7 @@ export class SignInUpService {
workspaceInviteHash,
firstName,
lastName,
imagePath,
picture,
existingUser,
}: {
email: string;
@ -134,7 +129,7 @@ export class SignInUpService {
workspaceInviteHash: string;
firstName: string;
lastName: string;
imagePath: string | undefined;
picture: SignInUpServiceInput['picture'];
existingUser: User | null;
}) {
const workspace = await this.workspaceRepository.findOneBy({
@ -162,6 +157,8 @@ export class SignInUpService {
return Object.assign(existingUser, updatedUser);
}
const imagePath = await this.uploadPicture(picture, workspace.id);
const userToCreate = this.userRepository.create({
email: email,
firstName: firstName,
@ -185,13 +182,13 @@ export class SignInUpService {
passwordHash,
firstName,
lastName,
imagePath,
picture,
}: {
email: string;
passwordHash: string | undefined;
firstName: string;
lastName: string;
imagePath: string | undefined;
picture: SignInUpServiceInput['picture'];
}) {
assert(
!this.environmentService.get('IS_SIGN_UP_DISABLED'),
@ -208,6 +205,8 @@ export class SignInUpService {
const workspace = await this.workspaceRepository.save(workspaceToCreate);
const imagePath = await this.uploadPicture(picture, workspace.id);
const userToCreate = this.userRepository.create({
email: email,
firstName: firstName,
@ -225,7 +224,14 @@ export class SignInUpService {
return user;
}
async uploadPicture(picture: string): Promise<string> {
async uploadPicture(
picture: string | null | undefined,
workspaceId: string,
): Promise<string | undefined> {
if (!picture) {
return;
}
const buffer = await getImageBufferFromUrl(
picture,
this.httpService.axiosRef,
@ -238,6 +244,7 @@ export class SignInUpService {
filename: `${v4()}.${type?.ext}`,
mimeType: type?.mime,
fileFolder: FileFolder.ProfilePicture,
workspaceId,
});
return paths[0];

View File

@ -1,12 +1,17 @@
import { Controller, Get, Param, Res, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Req, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/integrations/file-storage/interfaces/file-storage-exception';
import {
checkFilePath,
checkFilename,
} from 'src/engine/core-modules/file/file.utils';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
// TODO: Add cookie authentication
@ -15,23 +20,38 @@ import { FileService } from 'src/engine/core-modules/file/services/file.service'
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) {
async getFile(
@Param() params: string[],
@Res() res: Response,
@Req() req: Request,
) {
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' });
});
const workspaceId = (req as any)?.workspaceId;
fileStream.pipe(res);
if (!workspaceId) {
return res.status(401).send({ error: 'Unauthorized' });
}
try {
const fileStream = await this.fileService.getFileStream(
folderPath,
filename,
workspaceId,
);
fileStream.pipe(res);
} catch (error) {
if (
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
) {
return res.status(404).send({ error: 'File not found' });
}
return res.status(500).send({ error: 'Internal server error' });
}
}
}

View File

@ -1,14 +1,16 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { GraphQLUpload, FileUpload } from 'graphql-upload';
import { FileUpload, GraphQLUpload } 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
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()
@ -17,6 +19,7 @@ export class FileUploadResolver {
@Mutation(() => String)
async uploadFile(
@AuthWorkspace() { id: workspaceId }: Workspace,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@Args('fileFolder', { type: () => FileFolder, nullable: true })
@ -30,6 +33,7 @@ export class FileUploadResolver {
filename,
mimeType: mimetype,
fileFolder,
workspaceId,
});
return path;
@ -37,6 +41,7 @@ export class FileUploadResolver {
@Mutation(() => String)
async uploadImage(
@AuthWorkspace() { id: workspaceId }: Workspace,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@Args('fileFolder', { type: () => FileFolder, nullable: true })
@ -50,6 +55,7 @@ export class FileUploadResolver {
filename,
mimeType: mimetype,
fileFolder,
workspaceId,
});
return paths[0];

View File

@ -1,15 +1,15 @@
import { Injectable } from '@nestjs/common';
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
import sharp from 'sharp';
import { v4 as uuidV4 } from 'uuid';
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
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';
import { getCropSize } from 'src/utils/image';
@Injectable()
export class FileUploadService {
@ -19,18 +19,18 @@ export class FileUploadService {
file,
filename,
mimeType,
fileFolder,
folder,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
folder: string;
}) {
await this.fileStorage.write({
file,
name: filename,
mimeType,
folder: fileFolder,
folder,
});
}
@ -58,21 +58,24 @@ export class FileUploadService {
filename,
mimeType,
fileFolder,
workspaceId,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
workspaceId: string;
}) {
const ext = filename.split('.')?.[1];
const id = uuidV4();
const name = `${id}${ext ? `.${ext}` : ''}`;
const folder = this.getWorkspaceFolderName(workspaceId, fileFolder);
await this._uploadFile({
file: this._sanitizeFile({ file, ext, mimeType }),
filename: name,
mimeType,
fileFolder,
folder,
});
return {
@ -87,11 +90,13 @@ export class FileUploadService {
filename,
mimeType,
fileFolder,
workspaceId,
}: {
file: Buffer | Uint8Array | string;
filename: string;
mimeType: string | undefined;
fileFolder: FileFolder;
workspaceId: string;
}) {
const ext = filename.split('.')?.[1];
const id = uuidV4();
@ -117,6 +122,7 @@ export class FileUploadService {
await Promise.all(
images.map(async (image, index) => {
const buffer = await image.toBuffer();
const folder = this.getWorkspaceFolderName(workspaceId, fileFolder);
paths.push(`${fileFolder}/${cropSizes[index]}/${name}`);
@ -124,7 +130,7 @@ export class FileUploadService {
file: buffer,
filename: `${cropSizes[index]}/${name}`,
mimeType,
fileFolder,
folder,
});
}),
);
@ -135,4 +141,8 @@ export class FileUploadService {
paths,
};
}
private getWorkspaceFolderName(workspaceId: string, fileFolder: FileFolder) {
return `workspace-${workspaceId}/${fileFolder}`;
}
}

View File

@ -0,0 +1,61 @@
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import {
checkFilename,
checkFilePath,
} from 'src/engine/core-modules/file/file.utils';
describe('FileUtils', () => {
describe('checkFilePath', () => {
it('should return sanitized file path', () => {
const filePath = `${FileFolder.Attachment}\0`;
const sanitizedFilePath = checkFilePath(filePath);
expect(sanitizedFilePath).toBe(`${FileFolder.Attachment}`);
});
it('should return sanitized file path with size', () => {
const filePath = `${FileFolder.ProfilePicture}\0/original`;
const sanitizedFilePath = checkFilePath(filePath);
expect(sanitizedFilePath).toBe(`${FileFolder.ProfilePicture}/original`);
});
it('should throw an error for invalid image size', () => {
const filePath = `${FileFolder.ProfilePicture}\0/invalid-size`;
expect(() => checkFilePath(filePath)).toThrow(
`Size invalid-size is not allowed`,
);
});
it('should throw an error for invalid folder', () => {
const filePath = `invalid-folder`;
expect(() => checkFilePath(filePath)).toThrow(
`Folder invalid-folder is not allowed`,
);
});
});
describe('checkFilename', () => {
it('should return sanitized filename', () => {
const filename = `${FileFolder.Attachment}\0.png`;
const sanitizedFilename = checkFilename(filename);
expect(sanitizedFilename).toBe(`${FileFolder.Attachment}.png`);
});
it('should throw an error for invalid filename', () => {
const filename = `invalid-filename`;
expect(() => checkFilename(filename)).toThrow(`Filename is not allowed`);
});
it('should throw an error for invalid filename', () => {
const filename = `\0`;
expect(() => checkFilename(filename)).toThrow(`Filename is not allowed`);
});
});
});

View File

@ -4,8 +4,8 @@ import { basename } from 'path';
import { KebabCase } from 'type-fest';
import { kebabCase } from 'src/utils/kebab-case';
import { settings } from 'src/engine/constants/settings';
import { kebabCase } from 'src/utils/kebab-case';
import { FileFolder } from './interfaces/file-folder.interface';

View File

@ -1,13 +1,13 @@
import {
Injectable,
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
@Injectable()
export class FilePathGuard implements CanActivate {
@ -17,25 +17,34 @@ export class FilePathGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const query = context.switchToHttp().getRequest().query;
const request = context.switchToHttp().getRequest();
const query = request.query;
if (query && query['token']) {
return !(await this.isExpired(query['token']));
const payloadToDecode = query['token'];
const decodedPayload = await this.tokenService.decodePayload(
payloadToDecode,
{
secret: this.environmentService.get('FILE_TOKEN_SECRET'),
},
);
const expirationDate = decodedPayload?.['expiration_date'];
const workspaceId = decodedPayload?.['workspace_id'];
const isExpired = await this.isExpired(expirationDate);
if (isExpired) {
return false;
}
request.workspaceId = workspaceId;
}
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'];
private async isExpired(expirationDate: string): Promise<boolean> {
if (!expirationDate) {
return true;
}

View File

@ -1,27 +0,0 @@
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

@ -1,57 +0,0 @@
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

@ -1,15 +1,42 @@
import { Injectable } from '@nestjs/common';
import { Stream } from 'stream';
import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/integrations/file-storage/interfaces/file-storage-exception';
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,
});
async getFileStream(
folderPath: string,
filename: string,
workspaceId: string,
): Promise<Stream> {
const workspaceFolderPath = `workspace-${workspaceId}/${folderPath}`;
try {
return await this.fileStorageService.read({
folderPath: workspaceFolderPath,
filename,
});
} catch (error) {
// TODO: Remove this fallback when all files are moved to workspace folders
if (
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
) {
return await this.fileStorageService.read({
folderPath,
filename,
});
}
throw error;
}
}
}

View File

@ -9,6 +9,7 @@ import {
} from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import assert from 'assert';
import crypto from 'crypto';
import { GraphQLJSONObject } from 'graphql-type-json';
@ -32,7 +33,6 @@ import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { LoadServiceWithWorkspaceContext } from 'src/engine/twenty-orm/context/load-service-with-workspace.context';
import { assert } from 'src/utils/assert';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
const getHMACKey = (email?: string, key?: string | null) => {
@ -117,6 +117,7 @@ export class UserResolver {
@Mutation(() => String)
async uploadProfilePicture(
@AuthUser() { id }: User,
@AuthWorkspace() { id: workspaceId }: Workspace,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<string> {
@ -133,6 +134,7 @@ export class UserResolver {
filename,
mimeType: mimetype,
fileFolder,
workspaceId,
});
return paths[0];

View File

@ -85,6 +85,7 @@ export class WorkspaceResolver {
filename,
mimeType: mimetype,
fileFolder,
workspaceId: id,
});
await this.workspaceService.updateOne(id, {