chore(server): remove the five dead FileFolder values and their legacy serving pipeline (#22516)

Follow-up cleanup after #22510: shrink `FileFolder` and
`fileFolderConfigs` to only folders that actually exist, so per-folder
policy entries are real decisions.

## What

**Remove the five dead enum values** — `ProfilePicture`,
`WorkspaceLogo`, `Attachment`, `PersonPicture`, `File`. They were
already marked replaced/removed in the enum, have no production write
path, and `FileByIdGuard`'s `SUPPORTED_FILE_FOLDERS` allowlist already
rejects them at the serving endpoint.

**Delete the legacy path-based serving pipeline that existed only for
them** — verified wired to no route:
- `FilePathGuard` — registered as a provider in `FileModule` but applied
to no controller
- `extractFileInfoFromRequest` (parsed the old
`/files/profile-picture/original/TOKEN/file.jpg` format) — only consumer
was `FilePathGuard`
- `checkFileFolder` — only consumer was `extractFileInfoFromRequest`
- `settings.storage.imageCropSizes` — keyed exclusively by the three
dead picture folders, zero consumers
- the crop-size helpers in `utils/image.ts` (`getCropSize`,
`ShortCropSize`, `CropSize`) — zero consumers outside the file;
`getImageBufferFromUrl` is kept
- `AllowedFolders` type — last consumer was `checkFileFolder`

**Test fixtures** referencing dead folders were moved to living ones;
the specs of deleted utils are deleted with them.

**Generated files** (`twenty-front/src/generated-metadata/graphql.ts`,
`twenty-client-sdk` schema) hand-updated to match the shrunk GraphQL
enum.

## Legacy data safety

Workspaces may still hold `File` rows whose `path` starts with a dead
prefix (e.g. `attachment/…`). These stay inert, exactly as today:

- Serving: `FileByIdGuard` rejects non-supported folders before any
config lookup, and file lookups filter by `path LIKE
'<current-folder>/%'`, so dead-prefix rows are unreachable.
- Every consumer that feeds stored paths into
`removeFileFolderFromFileEntityPath` (which throws on unknown prefixes)
is upstream-guarded by a current-folder filter or allowlist — audited
all seven call sites.
- Stored legacy member `avatarUrl` strings are parsed with
`extractFileIdFromUrl(url, FileFolder.CorePicture)` and already fall
back to `''` for old formats; unchanged.

## GraphQL note

`FileFolder` is exposed as a GraphQL enum (input of the dev-only
`uploadApplicationFile` mutation, which only accepts application-code
folders). Clients sending a removed value were already rejected at the
resolver allowlist; they now fail GraphQL enum validation instead. No
supported client sends them — the frontend only uses `CorePicture`.

Net: **+10 / −301** across 17 files.

https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22516?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-03 13:13:14 +02:00
committed by GitHub
parent 8156bf2b89
commit 8b191d6fcc
17 changed files with 10 additions and 301 deletions
@@ -4633,12 +4633,7 @@ input CreateAppTokenInput {
}
enum FileFolder {
ProfilePicture
WorkspaceLogo
Attachment
PersonPicture
CorePicture
File
AgentChat
BuiltLogicFunction
BuiltFrontComponent
@@ -2997,7 +2997,7 @@ export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient' | 'Dpa'
export type FileFolder = 'CorePicture' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient' | 'Dpa'
export interface Subscription {
onEventSubscription?: EventSubscription
@@ -9306,12 +9306,7 @@ export const enumAnalyticsType = {
}
export const enumFileFolder = {
ProfilePicture: 'ProfilePicture' as const,
WorkspaceLogo: 'WorkspaceLogo' as const,
Attachment: 'Attachment' as const,
PersonPicture: 'PersonPicture' as const,
CorePicture: 'CorePicture' as const,
File: 'File' as const,
AgentChat: 'AgentChat' as const,
BuiltLogicFunction: 'BuiltLogicFunction' as const,
BuiltFrontComponent: 'BuiltFrontComponent' as const,
@@ -1911,22 +1911,17 @@ export type FileAttachmentInput = {
export enum FileFolder {
AgentChat = 'AgentChat',
AppTarball = 'AppTarball',
Attachment = 'Attachment',
BuiltFrontComponent = 'BuiltFrontComponent',
BuiltLogicFunction = 'BuiltLogicFunction',
CorePicture = 'CorePicture',
Dependencies = 'Dependencies',
Dpa = 'Dpa',
EmailAttachment = 'EmailAttachment',
File = 'File',
FilesField = 'FilesField',
GeneratedSdkClient = 'GeneratedSdkClient',
PersonPicture = 'PersonPicture',
ProfilePicture = 'ProfilePicture',
PublicAsset = 'PublicAsset',
Source = 'Source',
Workflow = 'Workflow',
WorkspaceLogo = 'WorkspaceLogo'
Workflow = 'Workflow'
}
export type FileWithSignedUrl = {
@@ -2,11 +2,6 @@ import { type Settings } from './interfaces/settings.interface';
export const settings: Settings = {
storage: {
imageCropSizes: {
'profile-picture': ['original'],
'workspace-logo': ['original'],
'person-picture': ['original'],
},
maxFileSize: '10MB',
},
minLengthOfStringForDuplicateCheck: 3,
@@ -1,14 +1,5 @@
import { type FileFolder } from 'twenty-shared/types';
import { type ShortCropSize } from 'src/utils/image';
type ValueOfFileFolder = `${FileFolder}`;
export interface Settings {
storage: {
imageCropSizes: {
[key in ValueOfFileFolder]?: ShortCropSize[];
};
maxFileSize: `${number}MB`;
};
minLengthOfStringForDuplicateCheck: number;
@@ -4,7 +4,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileAiChatModule } from 'src/engine/core-modules/file/file-ai-chat/file-ai-chat.module';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileDeletionJob } from 'src/engine/core-modules/file/jobs/file-deletion.job';
import { FileWorkspaceFolderDeletionJob } from 'src/engine/core-modules/file/jobs/file-workspace-folder-deletion.job';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@@ -38,7 +37,6 @@ import { FileService } from './services/file.service';
],
providers: [
FileService,
FilePathGuard,
FileByIdGuard,
FileWorkspaceFolderDeletionJob,
FileDeletionJob,
@@ -1,51 +0,0 @@
import {
type CanActivate,
type ExecutionContext,
Injectable,
} from '@nestjs/common';
import { FileTokenJwtPayloadLegacy } from 'src/engine/core-modules/auth/types/file-token-jwt-payload-legacy.type';
import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@Injectable()
export class FilePathGuard implements CanActivate {
constructor(private readonly jwtWrapperService: JwtWrapperService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { filename, fileSignature, ignoreExpirationToken } =
extractFileInfoFromRequest(request);
if (!fileSignature) {
return false;
}
try {
const payload = await this.jwtWrapperService.verifyJwtToken(
fileSignature,
ignoreExpirationToken ? { ignoreExpiration: true } : {},
);
if (
!payload.workspaceId ||
!payload.filename ||
filename !== payload.filename
) {
return false;
}
} catch {
return false;
}
const decodedPayload =
this.jwtWrapperService.decode<FileTokenJwtPayloadLegacy>(fileSignature, {
json: true,
});
request.workspaceId = decodedPayload.workspaceId;
return true;
}
}
@@ -1,7 +1,6 @@
import { registerEnumType } from '@nestjs/graphql';
import { FileFolder } from 'twenty-shared/types';
import { type KebabCase } from 'type-fest';
registerEnumType(FileFolder, {
name: 'FileFolder',
@@ -15,30 +14,10 @@ export type FileFolderConfig = {
export const IMMUTABLE_FILE_CACHE_CONTROL = 'private, max-age=86400, immutable';
export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.ProfilePicture]: {
ignoreExpirationToken: true,
immutable: false,
},
[FileFolder.WorkspaceLogo]: {
ignoreExpirationToken: true,
immutable: false,
},
[FileFolder.Attachment]: {
ignoreExpirationToken: false,
immutable: false,
},
[FileFolder.PersonPicture]: {
ignoreExpirationToken: false,
immutable: false,
},
[FileFolder.CorePicture]: {
ignoreExpirationToken: true,
immutable: true,
},
[FileFolder.File]: {
ignoreExpirationToken: false,
immutable: false,
},
[FileFolder.AgentChat]: {
ignoreExpirationToken: false,
immutable: true,
@@ -88,5 +67,3 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
immutable: true,
},
};
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
@@ -1,33 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
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);
});
});
@@ -4,10 +4,10 @@ import { checkFilename } from 'src/engine/core-modules/file/utils/check-file-nam
describe('checkFilename', () => {
it('should return sanitized filename', () => {
const filename = `${FileFolder.Attachment}\0.png`;
const filename = `${FileFolder.FilesField}\0.png`;
const sanitizedFilename = checkFilename(filename);
expect(sanitizedFilename).toBe(`${FileFolder.Attachment}.png`);
expect(sanitizedFilename).toBe(`${FileFolder.FilesField}.png`);
});
it('should throw an error for invalid filename', () => {
@@ -1,69 +0,0 @@
import { type Request } from 'express';
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';
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: {
attachment: { ignoreExpirationToken: false },
'profile-picture': { ignoreExpirationToken: true },
},
}),
);
describe('extractFileInfoFromRequest', () => {
it('should extract all file info correctly from 3-segment path', () => {
const mockRequest = {
path: '/files/attachment/filesig123/myfile.txt',
} as unknown as Request;
(checkFilename as jest.Mock).mockReturnValue('validated-file.txt');
(checkFileFolder as jest.Mock).mockReturnValue('attachment');
const result = extractFileInfoFromRequest(mockRequest);
expect(checkFilename).toHaveBeenCalledWith('myfile.txt');
expect(checkFileFolder).toHaveBeenCalledWith('attachment');
expect(result).toEqual({
filename: 'validated-file.txt',
fileSignature: 'filesig123',
rawFolder: 'attachment',
fileFolder: 'attachment',
ignoreExpirationToken: false,
});
});
it('should extract all file info correctly from 4-segment path with size', () => {
const mockRequest = {
path: '/files/profile-picture/original/filesig456/avatar.jpg',
} as unknown as Request;
(checkFilename as jest.Mock).mockReturnValue('validated-avatar.jpg');
(checkFileFolder as jest.Mock).mockReturnValue('profile-picture');
const result = extractFileInfoFromRequest(mockRequest);
expect(checkFilename).toHaveBeenCalledWith('avatar.jpg');
expect(checkFileFolder).toHaveBeenCalledWith('profile-picture/original');
expect(result).toEqual({
filename: 'validated-avatar.jpg',
fileSignature: 'filesig456',
rawFolder: 'profile-picture/original',
fileFolder: 'profile-picture',
ignoreExpirationToken: true,
});
});
});
@@ -7,14 +7,14 @@ import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file
describe('removeFileFolderFromFileEntityPath', () => {
it('should remove file folder prefix from path', () => {
expect(
removeFileFolderFromFileEntityPath(`${FileFolder.Attachment}/file.txt`),
removeFileFolderFromFileEntityPath(`${FileFolder.FilesField}/file.txt`),
).toBe('file.txt');
});
it('should handle nested paths correctly', () => {
expect(
removeFileFolderFromFileEntityPath(
`${FileFolder.Attachment}/subfolder/file.txt`,
`${FileFolder.FilesField}/subfolder/file.txt`,
),
).toBe('subfolder/file.txt');
});
@@ -22,14 +22,12 @@ describe('removeFileFolderFromFileEntityPath', () => {
it('should work with different valid file folders', () => {
expect(
removeFileFolderFromFileEntityPath(
`${FileFolder.ProfilePicture}/avatar.png`,
`${FileFolder.CorePicture}/avatar.png`,
),
).toBe('avatar.png');
expect(
removeFileFolderFromFileEntityPath(
`${FileFolder.WorkspaceLogo}/logo.svg`,
),
removeFileFolderFromFileEntityPath(`${FileFolder.Workflow}/logo.svg`),
).toBe('logo.svg');
expect(
@@ -108,12 +108,10 @@ describe('setFileResponseHeaders', () => {
);
it.each([
FileFolder.ProfilePicture,
FileFolder.WorkspaceLogo,
FileFolder.PersonPicture,
FileFolder.Attachment,
FileFolder.PublicAsset,
FileFolder.AppTarball,
FileFolder.Source,
FileFolder.BuiltFrontComponent,
])('should not set Cache-Control for mutable folder %s', (fileFolder) => {
const res = createMockResponse();
@@ -1,22 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
import { type AllowedFolders } 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;
};
@@ -1,31 +0,0 @@
import { type 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) => {
// Ex: /files/profile-picture/original/TOKEN/file.jpg
const pathSegments = request.path.split('/').filter((segment) => segment);
const segments = pathSegments.slice(1);
const filename = checkFilename(segments[segments.length - 1]);
const fileSignature = segments[segments.length - 2];
const folderSegments = segments.slice(0, segments.length - 2);
const rawFolder = folderSegments.join('/');
const fileFolder = checkFileFolder(rawFolder);
const ignoreExpirationToken =
fileFolderConfigs[fileFolder].ignoreExpirationToken;
return {
filename,
fileSignature,
rawFolder,
fileFolder,
ignoreExpirationToken,
};
};
-22
View File
@@ -1,28 +1,6 @@
import { type AxiosInstance } from 'axios';
import { isNonEmptyString } from '@sniptt/guards';
const cropRegex = /([w|h])([0-9]+)/;
export type ShortCropSize = `${'w' | 'h'}${number}` | 'original';
export interface CropSize {
type: 'width' | 'height';
value: number;
}
export const getCropSize = (value: ShortCropSize): CropSize | null => {
const match = value.match(cropRegex);
if (value === 'original' || match === null) {
return null;
}
return {
type: match[1] === 'w' ? 'width' : 'height',
value: +match[2],
};
};
export const getImageBufferFromUrl = async (
url: string,
axiosInstance: AxiosInstance,
@@ -1,10 +1,5 @@
export enum FileFolder {
ProfilePicture = 'profile-picture', // replaced by core-picture
WorkspaceLogo = 'workspace-logo', // replaced by core-picture
Attachment = 'attachment', // replaced by files-field
PersonPicture = 'person-picture', // replaced by files-field
CorePicture = 'core-picture',
File = 'file', // removed
AgentChat = 'agent-chat',
BuiltLogicFunction = 'built-logic-function',
BuiltFrontComponent = 'built-front-component',