Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import {
Controller,
Get,
Param,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';

import { join } from 'path';

import { Request, Response } from 'express';
import { FileFolder } from 'twenty-shared/types';

import {
FileStorageException,
Expand All @@ -23,13 +27,63 @@ import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-gua
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';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import {
FileByIdGuard,
SupportedFileFolder,
} from 'src/engine/core-modules/file/guards/file-by-id.guard';

@Controller('files')
@Controller()
@UseFilters(FileApiExceptionFilter)
export class FileController {
constructor(private readonly fileService: FileService) {}

@Get('*path')
@Get('public-assets/:workspaceId/:applicationId/*path')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getPublicAssets(
@Res() res: Response,
@Req() req: Request,
@Param('workspaceId') workspaceId: string,
@Param('applicationId')
applicationId: string,
) {
const filepath = join(...req.params.path);

try {
const fileStream = await this.fileService.getFileStreamByPath({
workspaceId,
applicationId,
fileFolder: FileFolder.PublicAsset,
filepath,
});

fileStream.on('error', () => {
throw new FileException(
'Error streaming file from storage',
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
});
Comment on lines +60 to +65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Throwing an exception within the fileStream's 'error' event handler will cause an unhandled exception and crash the server because it's not caught by the try-catch block.
Severity: HIGH

Suggested Fix

Replace the fileStream.on('error', ...) and fileStream.pipe(res) pattern with await pipeline(fileStream, res) from the stream/promises module. This ensures stream errors are correctly propagated as promise rejections that can be caught by the try-catch block, preventing a server crash.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts#L60-L65

Potential issue: In the `getPublicAssets` and `getFileById` endpoints, stream errors are
handled by throwing a `FileException` inside the `fileStream.on('error', ...)` event
handler. An exception thrown from within an asynchronous event handler like this will
not be caught by the surrounding `try-catch` block. If a stream error occurs during file
transmission (e.g., a storage read failure), the uncaught exception will propagate up
and crash the Node.js process, causing a service outage.


fileStream.pipe(res);
} catch (error) {
if (
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
) {
throw new FileException(
'File not found',
FileExceptionCode.FILE_NOT_FOUND,
);
}

throw new FileException(
`Error retrieving file: ${error.message}`,
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
}
}

@Get('files/*path')
@UseGuards(FilePathGuard, NoPermissionGuard)
async getFile(@Res() res: Response, @Req() req: Request) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -69,4 +123,48 @@ export class FileController {
);
}
}

@Get('file/:fileFolder/:id')
@UseGuards(FileByIdGuard, NoPermissionGuard)
async getFileById(
@Res() res: Response,
@Req() req: Request,
@Param('fileFolder') fileFolder: SupportedFileFolder,
@Param('id') fileId: string,
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const workspaceId = (req as any)?.workspaceId;

try {
const fileStream = await this.fileService.getFileStreamById({
fileId,
workspaceId,
fileFolder,
});

fileStream.on('error', () => {
throw new FileException(
'Error streaming file from storage',
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
});

fileStream.pipe(res);
} catch (error) {
if (
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
) {
throw new FileException(
'File not found',
FileExceptionCode.FILE_NOT_FOUND,
);
}

throw new FileException(
`Error retrieving file: ${error.message}`,
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-clie
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';

import { FileByIdController } from './controllers/file-by-id.controller';
import { FileController } from './controllers/file.controller';
import { FileEntity } from './entities/file.entity';
import { FileCorePictureModule } from './file-core-picture/file-core-picture.module';
Expand Down Expand Up @@ -59,6 +58,6 @@ import { FileService } from './services/file.service';
FileWorkflowModule,
FileUploadService,
],
controllers: [FileController, FileByIdController],
controllers: [FileController],
})
export class FileModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,32 @@ export class FileService {
});
}

async getFileStreamByPath({
workspaceId,
applicationId,
filepath,
fileFolder,
}: {
workspaceId: string;
applicationId: string;
filepath: string;
fileFolder: FileFolder;
}) {
const application = await this.applicationRepository.findOneOrFail({
where: {
id: applicationId,
workspaceId,
},
});

return this.fileStorageService.readFile({
resourcePath: filepath,
fileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
});
}

async getFileStreamById({
fileId,
workspaceId,
Expand All @@ -57,29 +83,27 @@ export class FileService {
workspaceId: string;
fileFolder: FileFolder;
}): Promise<Readable> {
{
const file = await this.fileRepository.findOneOrFail({
where: {
id: fileId,
workspaceId,
path: Like(`${fileFolder}/%`),
},
});

const application = await this.applicationRepository.findOneOrFail({
where: {
id: file.applicationId,
workspaceId,
},
});
const file = await this.fileRepository.findOneOrFail({
where: {
id: fileId,
workspaceId,
path: Like(`${fileFolder}/%`),
},
});

return this.fileStorageService.readFile({
resourcePath: removeFileFolderFromFileEntityPath(file.path),
fileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
const application = await this.applicationRepository.findOneOrFail({
where: {
id: file.applicationId,
workspaceId,
});
}
},
});

return this.fileStorageService.readFile({
resourcePath: removeFileFolderFromFileEntityPath(file.path),
fileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
});
}

signFileUrl({ url, workspaceId }: { url: string; workspaceId: string }) {
Expand Down
Loading