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:
@ -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,
|
||||
);
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class SignedFileDTO {
|
||||
@Field(() => String)
|
||||
path: string;
|
||||
|
||||
@Field(() => String)
|
||||
token: string;
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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',
|
||||
);
|
||||
|
||||
@ -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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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}'`),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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)
|
||||
: '';
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user