11744 emails broken image in emails (#12265)

- refactor file tokens
- update file token management
  - generate one token per file per workspaceId
  - move token from query params to url path
This commit is contained in:
martmull
2025-05-26 22:05:21 +02:00
committed by GitHub
parent 69badf2a66
commit aa58259019
53 changed files with 775 additions and 386 deletions

View File

@ -8,7 +8,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { Response } from 'express';
import { Request, Response } from 'express';
import {
FileStorageException,
@ -19,13 +19,10 @@ import {
FileException,
FileExceptionCode,
} from 'src/engine/core-modules/file/file.exception';
import {
checkFilePath,
checkFilename,
} from 'src/engine/core-modules/file/file.utils';
import { FileApiExceptionFilter } from 'src/engine/core-modules/file/filters/file-api-exception.filter';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils';
@Controller('files')
@UseFilters(FileApiExceptionFilter)
@ -39,23 +36,14 @@ export class FileController {
@Res() res: Response,
@Req() req: Request,
) {
const folderPath = checkFilePath(params[0]);
// @ts-expect-error legacy noImplicitAny
const filename = checkFilename(params['filename']);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const workspaceId = (req as any)?.workspaceId;
if (!workspaceId) {
throw new FileException(
'Unauthorized: missing workspaceId',
FileExceptionCode.UNAUTHENTICATED,
);
}
const { filename, rawFolder } = extractFileInfoFromRequest(req);
try {
const fileStream = await this.fileService.getFileStream(
folderPath,
rawFolder,
filename,
workspaceId,
);

View File

@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class SignedFileDTO {
@Field(() => String)
path: string;
@Field(() => String)
token: string;
}

View File

@ -10,24 +10,25 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto';
@UseGuards(WorkspaceAuthGuard)
@Resolver()
export class FileUploadResolver {
constructor(private readonly fileUploadService: FileUploadService) {}
@Mutation(() => String)
@Mutation(() => SignedFileDTO)
async uploadFile(
@AuthWorkspace() { id: workspaceId }: Workspace,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@Args('fileFolder', { type: () => FileFolder, nullable: true })
fileFolder: FileFolder,
): Promise<string> {
): Promise<SignedFileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const { path } = await this.fileUploadService.uploadFile({
const { files } = await this.fileUploadService.uploadFile({
file: buffer,
filename,
mimeType: mimetype,
@ -35,21 +36,25 @@ export class FileUploadResolver {
workspaceId,
});
return path;
if (!files.length) {
throw new Error('Failed to upload file');
}
return files[0];
}
@Mutation(() => String)
@Mutation(() => SignedFileDTO)
async uploadImage(
@AuthWorkspace() { id: workspaceId }: Workspace,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@Args('fileFolder', { type: () => FileFolder, nullable: true })
fileFolder: FileFolder,
): Promise<string> {
): Promise<SignedFileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const { paths } = await this.fileUploadService.uploadImage({
const { files } = await this.fileUploadService.uploadImage({
file: buffer,
filename,
mimeType: mimetype,
@ -57,6 +62,10 @@ export class FileUploadResolver {
workspaceId,
});
return paths[0];
if (!files.length) {
throw new Error('Failed to upload image');
}
return files[0];
}
}

View File

@ -5,7 +5,7 @@ import DOMPurify from 'dompurify';
import FileType from 'file-type';
import { JSDOM } from 'jsdom';
import sharp from 'sharp';
import { v4 as uuidV4, v4 } from 'uuid';
import { v4 } from 'uuid';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
@ -13,6 +13,15 @@ import { settings } from 'src/engine/constants/settings';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { getCropSize, getImageBufferFromUrl } from 'src/utils/image';
import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
export type SignedFile = { path: string; token: string };
export type SignedFilesResult = {
name: string;
mimeType: string | undefined;
files: SignedFile[];
};
@Injectable()
export class FileUploadService {
@ -72,10 +81,8 @@ export class FileUploadService {
mimeType: string | undefined;
fileFolder: FileFolder;
workspaceId: string;
}) {
const ext = filename.split('.')?.[1];
const id = uuidV4();
const name = `${id}${ext ? `.${ext}` : ''}`;
}): Promise<SignedFilesResult> {
const { ext, name } = buildFileInfo(filename);
const folder = this.getWorkspaceFolderName(workspaceId, fileFolder);
await this._uploadFile({
@ -85,14 +92,15 @@ export class FileUploadService {
folder,
});
const signedPayload = await this.fileService.encodeFileToken({
const signedPayload = this.fileService.encodeFileToken({
filename: name,
workspaceId: workspaceId,
});
return {
id,
name,
mimeType,
path: `${fileFolder}/${name}?token=${signedPayload}`,
files: [{ path: `${fileFolder}/${name}`, token: signedPayload }],
};
}
@ -133,10 +141,8 @@ export class FileUploadService {
mimeType: string | undefined;
fileFolder: FileFolder;
workspaceId: string;
}) {
const ext = filename.split('.')?.[1];
const id = uuidV4();
const name = `${id}${ext ? `.${ext}` : ''}`;
}): Promise<SignedFilesResult> {
const { name } = buildFileInfo(filename);
const cropSizes = settings.storage.imageCropSizes[fileFolder];
@ -153,14 +159,22 @@ export class FileUploadService {
),
);
const paths: Array<string> = [];
const files: Array<SignedFile> = [];
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}`);
const token = this.fileService.encodeFileToken({
filename: name,
workspaceId: workspaceId,
});
files.push({
path: `${fileFolder}/${cropSizes[index]}/${name}`,
token,
});
return this._uploadFile({
file: buffer,
@ -172,9 +186,9 @@ export class FileUploadService {
);
return {
id,
name,
mimeType,
paths,
files,
};
}

View File

@ -1,92 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import {
checkFilename,
checkFilePath,
checkFileFolder,
} 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`);
});
});
describe('checkFileFolder', () => {
it('should return the root folder when it is allowed', () => {
expect(checkFileFolder(`${FileFolder.Attachment}/file.txt`)).toBe(
FileFolder.Attachment,
);
});
it('should throw BadRequestException for disallowed folders', () => {
expect(() => checkFileFolder('invalid-folder/file.txt')).toThrow(
BadRequestException,
);
});
it('should sanitize null characters in file path', () => {
expect(() => checkFileFolder('\0invalid-folder/file.txt')).toThrow(
BadRequestException,
);
});
it('should handle edge cases like empty file path', () => {
expect(() => checkFileFolder('')).toThrow(BadRequestException);
});
it('should handle cases where filePath has no folder', () => {
expect(() => checkFileFolder('file.txt')).toThrow(BadRequestException);
});
});
});

View File

@ -1,66 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { basename } from 'path';
import { KebabCase } from 'type-fest';
import { settings } from 'src/engine/constants/settings';
import { kebabCase } from 'src/utils/kebab-case';
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 (
folder !== kebabCase(FileFolder.ServerlessFunction) &&
size &&
// @ts-expect-error legacy noImplicitAny
!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);
};
export const checkFileFolder = (filePath: string): FileFolder => {
const allowedFolders = Object.values(FileFolder).map((value) =>
kebabCase(value),
);
const sanitizedFilePath = filePath.replace(/\0/g, '');
const [rootFolder] = sanitizedFilePath.split('/');
if (!allowedFolders.includes(rootFolder as AllowedFolders)) {
throw new BadRequestException(`Folder ${rootFolder} is not allowed`);
}
return rootFolder as FileFolder;
};

View File

@ -1,9 +1,8 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { checkFileFolder } from 'src/engine/core-modules/file/file.utils';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { FilePayloadToEncode } from 'src/engine/core-modules/file/services/file.service';
import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils';
@Injectable()
export class FilePathGuard implements CanActivate {
@ -11,37 +10,37 @@ export class FilePathGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const fileFolder = checkFileFolder(request.params[0]);
const ignoreExpirationToken =
fileFolderConfigs[fileFolder].ignoreExpirationToken;
const query = request.query;
const { filename, fileSignature, ignoreExpirationToken } =
extractFileInfoFromRequest(request);
if (!query || !query['token']) {
if (!fileSignature) {
return false;
}
try {
const payload = await this.jwtWrapperService.verifyWorkspaceToken(
query['token'],
const payload = (await this.jwtWrapperService.verifyWorkspaceToken(
fileSignature,
'FILE',
ignoreExpirationToken ? { ignoreExpiration: true } : {},
);
)) as FilePayloadToEncode;
if (!payload.workspaceId) {
if (
!payload.workspaceId ||
!payload.filename ||
filename !== payload.filename
) {
return false;
}
} catch (error) {
return false;
}
const decodedPayload = await this.jwtWrapperService.decode(query['token'], {
const decodedPayload = (await this.jwtWrapperService.decode(fileSignature, {
json: true,
});
})) as FilePayloadToEncode;
const workspaceId = decodedPayload?.['workspaceId'];
request.workspaceId = workspaceId;
request.workspaceId = decodedPayload.workspaceId;
return true;
}

View File

@ -1,5 +1,7 @@
import { registerEnumType } from '@nestjs/graphql';
import { KebabCase } from 'type-fest';
export enum FileFolder {
ProfilePicture = 'profile-picture',
WorkspaceLogo = 'workspace-logo',
@ -33,3 +35,5 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
ignoreExpirationToken: false,
},
};
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;

View File

@ -9,6 +9,11 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
export type FilePayloadToEncode = {
workspaceId: string;
filename: string;
};
@Injectable()
export class FileService {
constructor(
@ -30,8 +35,7 @@ export class FileService {
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
encodeFileToken(payloadToEncode: Record<string, any>) {
encodeFileToken(payloadToEncode: FilePayloadToEncode) {
const fileTokenExpiresIn = this.twentyConfigService.get(
'FILE_TOKEN_EXPIRES_IN',
);

View File

@ -0,0 +1,42 @@
import { v4 as uuidV4 } from 'uuid';
import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
jest.mock('uuid', () => ({
v4: jest.fn(),
}));
describe('buildFileInfo', () => {
const mockId = '1234-uuid';
beforeEach(() => {
(uuidV4 as jest.Mock).mockReturnValue(mockId);
});
it('should extract extension and generate correct name with extension', () => {
const result = buildFileInfo('file.txt');
expect(result).toEqual({
ext: 'txt',
name: `${mockId}.txt`,
});
});
it('should handle filenames without extension', () => {
const result = buildFileInfo('file');
expect(result).toEqual({
ext: '',
name: mockId,
});
});
it('should handle filenames with multiple dots', () => {
const result = buildFileInfo('archive.tar.gz');
expect(result).toEqual({
ext: 'gz',
name: `${mockId}.gz`,
});
});
});

View File

@ -0,0 +1,33 @@
import { BadRequestException } from '@nestjs/common';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { checkFileFolder } from 'src/engine/core-modules/file/utils/check-file-folder.utils';
describe('checkFileFolder', () => {
it('should return the root folder when it is allowed', () => {
expect(checkFileFolder(`${FileFolder.Attachment}/file.txt`)).toBe(
FileFolder.Attachment,
);
});
it('should throw BadRequestException for disallowed folders', () => {
expect(() => checkFileFolder('invalid-folder/file.txt')).toThrow(
BadRequestException,
);
});
it('should sanitize null characters in file path', () => {
expect(() => checkFileFolder('\0invalid-folder/file.txt')).toThrow(
BadRequestException,
);
});
it('should handle edge cases like empty file path', () => {
expect(() => checkFileFolder('')).toThrow(BadRequestException);
});
it('should handle cases where filePath has no folder', () => {
expect(() => checkFileFolder('file.txt')).toThrow(BadRequestException);
});
});

View File

@ -0,0 +1,28 @@
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { checkFilename } from 'src/engine/core-modules/file/utils/check-file-name.utils';
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 'invalid-filename' is not allowed`,
);
});
it('should throw an error for invalid filename', () => {
const filename = `\0`;
expect(() => checkFilename(filename)).toThrow(
`Filename '\0' is not allowed`,
);
});
});

View File

@ -0,0 +1,35 @@
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { checkFilePath } from 'src/engine/core-modules/file/utils/check-file-path.utils';
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`,
);
});
});

View File

@ -0,0 +1,37 @@
import { extractFilenameFromPath } from 'src/engine/core-modules/file/utils/extract-file-id-from-path.utils';
describe('extractFileIdFromPath', () => {
it('should return the last segment of a normal path', () => {
const result = extractFilenameFromPath('uploads/files/1234.txt');
expect(result).toBe('1234.txt');
});
it('should return the last segment when there is no slash', () => {
const result = extractFilenameFromPath('file.txt');
expect(result).toBe('file.txt');
});
it('should throw when empty path', () => {
expect(() => extractFilenameFromPath('')).toThrow(
new Error('Cannot extract id from empty path'),
);
});
it('should throw when empty filename', () => {
const folderPath = 'uploads/files/';
expect(() => extractFilenameFromPath(folderPath)).toThrow(
new Error(`Cannot extract id from folder path '${folderPath}'`),
);
});
it('should throw when empty filename absolute path', () => {
const folderPath = '/a/b/c/';
expect(() => extractFilenameFromPath(folderPath)).toThrow(
new Error(`Cannot extract id from folder path '${folderPath}'`),
);
});
});

View File

@ -0,0 +1,49 @@
import { Request } from 'express';
import { checkFilename } from 'src/engine/core-modules/file/utils/check-file-name.utils';
import { checkFileFolder } from 'src/engine/core-modules/file/utils/check-file-folder.utils';
import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils';
jest.mock('src/engine/core-modules/file/utils/check-file-name.utils', () => ({
checkFilename: jest.fn(),
}));
jest.mock('src/engine/core-modules/file/utils/check-file-folder.utils', () => ({
checkFileFolder: jest.fn(),
}));
jest.mock(
'src/engine/core-modules/file/interfaces/file-folder.interface',
() => ({
fileFolderConfigs: {
'some-folder': { ignoreExpirationToken: true },
},
}),
);
describe('extractFileInfoFromRequest', () => {
it('should extract all file info correctly from request', () => {
const mockRequest = {
params: {
filename: 'myfile.txt',
'0': 'some-folder/some-subfolder/filesig123',
},
} as unknown as Request;
(checkFilename as jest.Mock).mockReturnValue('validated-file.txt');
(checkFileFolder as jest.Mock).mockReturnValue('some-folder');
const result = extractFileInfoFromRequest(mockRequest);
expect(checkFilename).toHaveBeenCalledWith('myfile.txt');
expect(checkFileFolder).toHaveBeenCalledWith('some-folder/some-subfolder');
expect(result).toEqual({
filename: 'validated-file.txt',
fileSignature: 'filesig123',
rawFolder: 'some-folder/some-subfolder',
fileFolder: 'some-folder',
ignoreExpirationToken: true,
});
});
});

View File

@ -0,0 +1,11 @@
import { v4 as uuidV4 } from 'uuid';
export const buildFileInfo = (filename: string) => {
const parts = filename.split('.');
const ext = parts.length > 1 ? parts.pop() || '' : '';
const name = `${uuidV4()}${ext ? `.${ext}` : ''}`;
return { ext, name };
};

View File

@ -0,0 +1,23 @@
import { BadRequestException } from '@nestjs/common';
import {
AllowedFolders,
FileFolder,
} from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { kebabCase } from 'src/utils/kebab-case';
export const checkFileFolder = (filePath: string): FileFolder => {
const allowedFolders = Object.values(FileFolder).map((value) =>
kebabCase(value),
);
const sanitizedFilePath = filePath.replace(/\0/g, '');
const [rootFolder] = sanitizedFilePath.split('/');
if (!allowedFolders.includes(rootFolder as AllowedFolders)) {
throw new BadRequestException(`Folder ${rootFolder} is not allowed`);
}
return rootFolder as FileFolder;
};

View File

@ -0,0 +1,18 @@
import { BadRequestException } from '@nestjs/common';
import { basename } from 'path';
export const checkFilename = (filename: string) => {
const sanitizedFilename = filename.replace(/\0/g, '');
if (
!sanitizedFilename ||
sanitizedFilename.includes('/') ||
sanitizedFilename.includes('\\') ||
!sanitizedFilename.includes('.')
) {
throw new BadRequestException(`Filename '${filename}' is not allowed`);
}
return basename(sanitizedFilename);
};

View File

@ -0,0 +1,33 @@
import { BadRequestException } from '@nestjs/common';
import {
AllowedFolders,
FileFolder,
} from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { kebabCase } from 'src/utils/kebab-case';
import { settings } from 'src/engine/constants/settings';
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 (
folder !== kebabCase(FileFolder.ServerlessFunction) &&
size &&
// @ts-expect-error legacy noImplicitAny
!settings.storage.imageCropSizes[folder]?.includes(size)
) {
throw new BadRequestException(`Size ${size} is not allowed`);
}
return sanitizedFilePath;
};

View File

@ -0,0 +1,17 @@
import { basename } from 'path';
import { isNonEmptyString } from '@sniptt/guards';
export const extractFilenameFromPath = (path: string) => {
if (path.endsWith('/')) {
throw new Error(`Cannot extract id from folder path '${path}'`);
}
const fileId = basename(path);
if (!isNonEmptyString(fileId)) {
throw new Error(`Cannot extract id from empty path`);
}
return fileId;
};

View File

@ -0,0 +1,29 @@
import { Request } from 'express';
import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { checkFileFolder } from 'src/engine/core-modules/file/utils/check-file-folder.utils';
import { checkFilename } from 'src/engine/core-modules/file/utils/check-file-name.utils';
export const extractFileInfoFromRequest = (request: Request) => {
const filename = checkFilename(request.params.filename);
const parts = request.params[0].split('/');
const fileSignature = parts.pop();
const rawFolder = parts.join('/');
const fileFolder = checkFileFolder(rawFolder);
const ignoreExpirationToken =
fileFolderConfigs[fileFolder].ignoreExpirationToken;
return {
filename,
fileSignature,
rawFolder,
fileFolder,
ignoreExpirationToken,
};
};

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
import { buildSignedPath, getLogoUrlFromDomainName } from 'twenty-shared/utils';
import { Brackets, ObjectLiteral } from 'typeorm';
import chunk from 'lodash.chunk';
@ -43,6 +43,7 @@ import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-s
import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
import { SearchResultConnectionDTO } from 'src/engine/core-modules/search/dtos/search-result-connection.dto';
import { SearchResultEdgeDTO } from 'src/engine/core-modules/search/dtos/search-result-edge.dto';
import { extractFilenameFromPath } from 'src/engine/core-modules/file/utils/extract-file-id-from-path.utils';
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record.dto';
type LastRanks = { tsRankCD: number; tsRank: number };
@ -365,11 +366,15 @@ export class SearchService {
}
private getImageUrlWithToken(avatarUrl: string, workspaceId: string): string {
const avatarUrlToken = this.fileService.encodeFileToken({
const signedPayload = this.fileService.encodeFileToken({
filename: extractFilenameFromPath(avatarUrl),
workspaceId,
});
return `${avatarUrl}?token=${avatarUrlToken}`;
return buildSignedPath({
path: avatarUrl,
token: signedPayload,
});
}
getImageIdentifierValue(
@ -384,7 +389,8 @@ export class SearchService {
return getLogoUrlFromDomainName(record.domainNamePrimaryLinkUrl) || '';
}
return imageIdentifierField
return imageIdentifierField &&
isNonEmptyString(record[imageIdentifierField])
? this.getImageUrlWithToken(record[imageIdentifierField], workspaceId)
: '';
}

View File

@ -1,6 +1,9 @@
import { Injectable, Logger, Optional } from '@nestjs/common';
import { isString } from 'class-validator';
import { LoggerOptions } from 'typeorm/logger/LoggerOptions';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_MASKING_CONFIG } from 'src/engine/core-modules/twenty-config/constants/config-variables-masking-config';
@ -196,6 +199,17 @@ export class TwentyConfigService {
}
}
getLoggingConfig(): LoggerOptions {
switch (this.get('NODE_ENV')) {
case NodeEnvironment.development:
return ['query', 'error'];
case NodeEnvironment.test:
return [];
default:
return ['error'];
}
}
private validateNotEnvOnly<T extends keyof ConfigVariables>(
key: T,
operation: string,

View File

@ -11,7 +11,10 @@ import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-r
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import {
FileUploadService,
SignedFilesResult,
} from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
@ -266,8 +269,8 @@ describe('UserWorkspaceService', () => {
.spyOn(workspaceEventEmitter, 'emitCustomBatchEvent')
.mockImplementation();
jest.spyOn(fileUploadService, 'uploadImageFromUrl').mockResolvedValue({
paths: ['path/to/file'],
} as any);
files: [{ path: 'path/to/file', token: 'token' }],
} as SignedFilesResult);
const result = await service.create({
userId,

View File

@ -371,12 +371,16 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
if (!isDefined(pictureUrl)) return;
const { paths } = await this.fileUploadService.uploadImageFromUrl({
const { files } = await this.fileUploadService.uploadImageFromUrl({
imageUrl: pictureUrl,
fileFolder: FileFolder.ProfilePicture,
workspaceId,
});
return paths[0];
if (!files.length) {
throw new Error('Failed to upload avatar');
}
return files[0].path;
}
}

View File

@ -1,8 +1,11 @@
import { Injectable } from '@nestjs/common';
import { buildSignedPath } from 'twenty-shared/utils';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { DeletedWorkspaceMember } from 'src/engine/core-modules/user/dtos/deleted-workspace-member.dto';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { extractFilenameFromPath } from 'src/engine/core-modules/file/utils/extract-file-id-from-path.utils';
@Injectable()
export class DeletedWorkspaceMemberTranspiler {
@ -15,12 +18,15 @@ export class DeletedWorkspaceMemberTranspiler {
workspaceMember: Pick<WorkspaceMemberWorkspaceEntity, 'avatarUrl' | 'id'>;
workspaceId: string;
}): string {
const avatarUrlToken = this.fileService.encodeFileToken({
workspaceMemberId: workspaceMember.id,
workspaceId: workspaceId,
const signedPayload = this.fileService.encodeFileToken({
filename: extractFilenameFromPath(workspaceMember.avatarUrl),
workspaceId,
});
return `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
return buildSignedPath({
path: workspaceMember.avatarUrl,
token: signedPayload,
});
}
toDeletedWorkspaceMemberDto(

View File

@ -14,6 +14,7 @@ import crypto from 'crypto';
import { GraphQLJSONObject } from 'graphql-type-json';
import { FileUpload, GraphQLUpload } from 'graphql-upload';
import { PermissionsOnAllObjectRecords } from 'twenty-shared/constants';
import { buildSignedPath } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { In, Repository } from 'typeorm';
@ -52,6 +53,8 @@ import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto';
import { extractFilenameFromPath } from 'src/engine/core-modules/file/utils/extract-file-id-from-path.utils';
const getHMACKey = (email?: string, key?: string | null) => {
if (!email || !key) return null;
@ -186,11 +189,14 @@ export class UserResolver {
if (workspaceMember && workspaceMember.avatarUrl) {
const avatarUrlToken = this.fileService.encodeFileToken({
workspaceMemberId: workspaceMember.id,
filename: extractFilenameFromPath(workspaceMember.avatarUrl),
workspaceId: workspace.id,
});
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
workspaceMember.avatarUrl = buildSignedPath({
path: workspaceMember.avatarUrl,
token: avatarUrlToken,
});
}
// TODO Refactor to be transpiled to WorkspaceMember instead
@ -235,11 +241,14 @@ export class UserResolver {
for (const workspaceMemberEntity of workspaceMemberEntities) {
if (workspaceMemberEntity.avatarUrl) {
const avatarUrlToken = this.fileService.encodeFileToken({
workspaceMemberId: workspaceMemberEntity.id,
filename: extractFilenameFromPath(workspaceMemberEntity.avatarUrl),
workspaceId: workspace.id,
});
workspaceMemberEntity.avatarUrl = `${workspaceMemberEntity.avatarUrl}?token=${avatarUrlToken}`;
workspaceMemberEntity.avatarUrl = buildSignedPath({
path: workspaceMemberEntity.avatarUrl,
token: avatarUrlToken,
});
}
// TODO Refactor to be transpiled to WorkspaceMember instead
@ -314,13 +323,13 @@ export class UserResolver {
return getHMACKey(parent.email, key);
}
@Mutation(() => String)
@Mutation(() => SignedFileDTO)
async uploadProfilePicture(
@AuthUser() { id }: User,
@AuthWorkspace() { id: workspaceId }: Workspace,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<string> {
): Promise<SignedFileDTO> {
if (!id) {
throw new Error('User not found');
}
@ -329,7 +338,7 @@ export class UserResolver {
const buffer = await streamToBuffer(stream);
const fileFolder = FileFolder.ProfilePicture;
const { paths } = await this.fileUploadService.uploadImage({
const { files } = await this.fileUploadService.uploadImage({
file: buffer,
filename,
mimeType: mimetype,
@ -337,11 +346,11 @@ export class UserResolver {
workspaceId,
});
const fileToken = this.fileService.encodeFileToken({
workspaceId: workspaceId,
});
if (!files.length) {
throw new Error('Failed to upload profile picture');
}
return `${paths[0]}?token=${fileToken}`;
return files[0];
}
@Mutation(() => User)

View File

@ -1,6 +1,8 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { buildSignedPath } from 'twenty-shared/utils';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { SendInvitationsOutput } from 'src/engine/core-modules/workspace-invitation/dtos/send-invitations.output';
@ -14,6 +16,7 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { extractFilenameFromPath } from 'src/engine/core-modules/file/utils/extract-file-id-from-path.utils';
import { SendInvitationsInput } from './dtos/send-invitations.input';
@ -69,11 +72,15 @@ export class WorkspaceInvitationResolver {
let workspaceLogoWithToken = '';
if (workspace.logo) {
const workspaceLogoToken = this.fileService.encodeFileToken({
const signedPayload = this.fileService.encodeFileToken({
filename: extractFilenameFromPath(workspace.logo),
workspaceId: workspace.id,
});
workspaceLogoWithToken = `${workspace.logo}?token=${workspaceLogoToken}`;
workspaceLogoWithToken = buildSignedPath({
path: workspace.logo,
token: signedPayload,
});
}
return await this.workspaceInvitationService.sendInvitations(

View File

@ -12,7 +12,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import assert from 'assert';
import { FileUpload, GraphQLUpload } from 'graphql-upload';
import { isDefined } from 'twenty-shared/utils';
import { buildSignedPath, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
@ -52,6 +52,8 @@ import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation-exception.filter';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { extractFilenameFromPath } from 'src/engine/core-modules/file/utils/extract-file-id-from-path.utils';
import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto';
import { Workspace } from './workspace.entity';
@ -119,7 +121,7 @@ export class WorkspaceResolver {
}
}
@Mutation(() => String)
@Mutation(() => SignedFileDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(SettingPermissionType.WORKSPACE),
@ -128,12 +130,12 @@ export class WorkspaceResolver {
@AuthWorkspace() { id }: Workspace,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<string> {
): Promise<SignedFileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const fileFolder = FileFolder.WorkspaceLogo;
const { paths } = await this.fileUploadService.uploadImage({
const { files } = await this.fileUploadService.uploadImage({
file: buffer,
filename,
mimeType: mimetype,
@ -141,15 +143,15 @@ export class WorkspaceResolver {
workspaceId: id,
});
if (!files.length) {
throw new Error('Failed to upload workspace logo');
}
await this.workspaceService.updateOne(id, {
logo: paths[0],
logo: files[0].path,
});
const workspaceLogoToken = this.fileService.encodeFileToken({
workspaceId: id,
});
return `${paths[0]}?token=${workspaceLogoToken}`;
return files[0];
}
@ResolveField(() => [FeatureFlagDTO], { nullable: true })
@ -227,11 +229,15 @@ export class WorkspaceResolver {
async logo(@Parent() workspace: Workspace): Promise<string> {
if (workspace.logo) {
try {
const workspaceLogoToken = this.fileService.encodeFileToken({
const signedPayload = this.fileService.encodeFileToken({
filename: extractFilenameFromPath(workspace.logo),
workspaceId: workspace.id,
});
return `${workspace.logo}?token=${workspaceLogoToken}`;
return buildSignedPath({
path: workspace.logo,
token: signedPayload,
});
} catch (e) {
return workspace.logo;
}
@ -298,11 +304,15 @@ export class WorkspaceResolver {
if (workspace.logo) {
try {
const workspaceLogoToken = this.fileService.encodeFileToken({
const signedPayload = this.fileService.encodeFileToken({
filename: extractFilenameFromPath(workspace.logo),
workspaceId: workspace.id,
});
workspaceLogoWithToken = `${workspace.logo}?token=${workspaceLogoToken}`;
workspaceLogoWithToken = buildSignedPath({
path: workspace.logo,
token: signedPayload,
});
} catch (e) {
workspaceLogoWithToken = workspace.logo;
}