feat: files visiblity with file configuration (#10438)

Ref: #10404 

- Added `FileFolderConfig` with `isPublic` key.
- Updated `file-path-guard.ts` to `ignoreExpiration` to validate the
token if `isPublic` is `true`.
- Token verification ignores expiration, assuming it's used to fetch
file metadata with a required workspaceId as we cannot remove the token
as we will loose the `workspaceId`.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Sujith Thirumalaisamy
2025-03-05 21:47:24 +05:30
committed by GitHub
parent f4fcf39eb5
commit 3cd52b052e
4 changed files with 76 additions and 0 deletions

View File

@ -1,8 +1,11 @@
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', () => {
@ -58,4 +61,32 @@ describe('FileUtils', () => {
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

@ -48,3 +48,18 @@ export const checkFilename = (filename: string) => {
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,5 +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';
@Injectable()
@ -8,6 +11,10 @@ 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;
if (!query || !query['token']) {
@ -18,6 +25,7 @@ export class FilePathGuard implements CanActivate {
const payload = await this.jwtWrapperService.verifyWorkspaceToken(
query['token'],
'FILE',
ignoreExpirationToken ? { ignoreExpiration: true } : {},
);
if (!payload.workspaceId) {

View File

@ -11,3 +11,25 @@ export enum FileFolder {
registerEnumType(FileFolder, {
name: 'FileFolder',
});
export type FileFolderConfig = {
ignoreExpirationToken: boolean;
};
export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.ProfilePicture]: {
ignoreExpirationToken: true,
},
[FileFolder.WorkspaceLogo]: {
ignoreExpirationToken: true,
},
[FileFolder.Attachment]: {
ignoreExpirationToken: false,
},
[FileFolder.PersonPicture]: {
ignoreExpirationToken: false,
},
[FileFolder.ServerlessFunction]: {
ignoreExpirationToken: false,
},
};