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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user