feat: add S3 presigned URL redirect for file downloads (#18864)

## Summary

- When `STORAGE_S3_PRESIGNED_URL_BASE` is configured, the file
controller returns a **302 redirect** to a presigned S3 URL instead of
proxying every byte through the server. This eliminates server bandwidth
and CPU overhead for S3-backed deployments.
- For local storage or S3 without a public endpoint, behavior is
unchanged (stream + pipe with security headers).
- Added `getPresignedUrl` to the `StorageDriver` interface (required
method returning `string | null`), with implementations in S3Driver
(uses a separate presign client with the public endpoint), LocalDriver
(returns `null`), and ValidatedStorageDriver (path traversal protection
+ delegation).
- Added a unified `getFileResponseById` method in `FileService` that
performs a single DB lookup and returns either a redirect URL or a
stream, avoiding double lookups.
- Extracted `getContentDisposition` from the header util so both the
proxy path and presigned URL path share the same inline/attachment
allowlist.
- Added MinIO service to `docker-compose.dev.yml` (optional `s3`
profile) for local S3 testing.
- Documented S3 presigned URL setup, CORS, and `nosniff` requirements in
the self-hosting docs.

## Test plan

- [x] All 63 unit tests pass across 5 test suites (util, S3 driver,
validated driver, file storage service, controller)
- [x] `npx nx typecheck twenty-server` passes
- [ ] Manual E2E test with MinIO: `docker compose --profile s3 up -d`,
configure S3 env vars, verify `curl -I` returns 302 with `Location`
header pointing to MinIO
- [ ] Verify local storage (no `STORAGE_S3_PRESIGNED_URL_BASE`) still
streams files with 200 + security headers
- [ ] Verify public assets endpoint still proxies (no redirect)


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-25 16:15:15 +01:00
committed by GitHub
parent 4fbe0a92ae
commit 895bb58fc6
20 changed files with 550 additions and 47 deletions
@@ -34,6 +34,7 @@ const createMockStream = (): Readable => {
const createMockResponse = () => ({
setHeader: jest.fn(),
redirect: jest.fn(),
});
describe('FileController', () => {
@@ -56,6 +57,7 @@ describe('FileController', () => {
useValue: {
getFileStreamById: jest.fn(),
getFileStreamByPath: jest.fn(),
getFileResponseById: jest.fn(),
},
},
],
@@ -79,10 +81,38 @@ describe('FileController', () => {
});
describe('getFileById', () => {
it('should call fileService.getFileStreamById and pipe the result with headers', async () => {
it('should 302 redirect when presigned URL is available', async () => {
jest.spyOn(fileService, 'getFileResponseById').mockResolvedValue({
type: 'redirect',
presignedUrl: 'https://s3.example.com/file?signed=abc',
});
const mockRequest = { workspaceId: 'workspace-id' } as any;
const mockResponse = createMockResponse() as any;
await controller.getFileById(
mockResponse,
mockRequest,
FileFolder.Workflow,
'file-123',
);
expect(fileService.getFileResponseById).toHaveBeenCalledWith({
fileId: 'file-123',
workspaceId: 'workspace-id',
fileFolder: FileFolder.Workflow,
});
expect(mockResponse.redirect).toHaveBeenCalledWith(
'https://s3.example.com/file?signed=abc',
);
expect(mockResponse.setHeader).not.toHaveBeenCalled();
});
it('should stream with headers when no presigned URL (local driver)', async () => {
const mockStream = createMockStream();
jest.spyOn(fileService, 'getFileStreamById').mockResolvedValue({
jest.spyOn(fileService, 'getFileResponseById').mockResolvedValue({
type: 'stream',
stream: mockStream,
mimeType: 'image/png',
});
@@ -97,11 +127,6 @@ describe('FileController', () => {
'file-123',
);
expect(fileService.getFileStreamById).toHaveBeenCalledWith({
fileId: 'file-123',
workspaceId: 'workspace-id',
fileFolder: FileFolder.CorePicture,
});
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Type',
'image/png',
@@ -120,7 +145,8 @@ describe('FileController', () => {
it('should force attachment disposition for non-safe MIME types', async () => {
const mockStream = createMockStream();
jest.spyOn(fileService, 'getFileStreamById').mockResolvedValue({
jest.spyOn(fileService, 'getFileResponseById').mockResolvedValue({
type: 'stream',
stream: mockStream,
mimeType: 'text/html',
});
@@ -147,7 +173,7 @@ describe('FileController', () => {
it('should throw FileException with FILE_NOT_FOUND when file is not found', async () => {
jest
.spyOn(fileService, 'getFileStreamById')
.spyOn(fileService, 'getFileResponseById')
.mockRejectedValue(
new FileStorageException(
'File not found',
@@ -172,7 +198,7 @@ describe('FileController', () => {
it('should throw FileException with INTERNAL_SERVER_ERROR for unexpected errors', async () => {
jest
.spyOn(fileService, 'getFileStreamById')
.spyOn(fileService, 'getFileResponseById')
.mockRejectedValue(new Error('Storage unavailable'));
const mockRequest = { workspaceId: 'workspace-id' } as any;
@@ -96,22 +96,29 @@ export class FileController {
const workspaceId = (req as any)?.workspaceId;
try {
const { stream, mimeType } = await this.fileService.getFileStreamById({
const fileResponse = await this.fileService.getFileResponseById({
fileId,
workspaceId,
fileFolder,
});
setFileResponseHeaders(res, mimeType);
if (fileResponse.type === 'redirect') {
return res.redirect(fileResponse.presignedUrl);
}
stream.on('error', () => {
throw new FileException(
'Error streaming file from storage',
FileExceptionCode.INTERNAL_SERVER_ERROR,
);
setFileResponseHeaders(res, fileResponse.mimeType);
fileResponse.stream.on('error', () => {
if (!res.headersSent) {
res.status(500).send('Error streaming file from storage');
return;
}
res.destroy();
});
stream.pipe(res);
fileResponse.stream.pipe(res);
} catch (error) {
if (
error instanceof FileStorageException &&
@@ -19,6 +19,8 @@ import {
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
import { type FileResponse } from 'src/engine/core-modules/file/types/file-response.type';
import { getContentDisposition } from 'src/engine/core-modules/file/utils/get-content-disposition.utils';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@@ -111,6 +113,52 @@ export class FileService {
};
}
async getFileResponseById(params: {
fileId: string;
workspaceId: string;
fileFolder: FileFolder;
}): Promise<FileResponse> {
const file = await this.fileRepository.findOneOrFail({
where: {
id: params.fileId,
workspaceId: params.workspaceId,
path: Like(`${params.fileFolder}/%`),
},
});
const application = await this.applicationRepository.findOneOrFail({
where: {
id: file.applicationId,
workspaceId: params.workspaceId,
},
});
const mimeType = file.mimeType ?? 'application/octet-stream';
const resourceIdentifier = {
resourcePath: removeFileFolderFromFileEntityPath(file.path),
fileFolder: params.fileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId: params.workspaceId,
};
const presignedUrl = await this.fileStorageService.getPresignedUrl({
...resourceIdentifier,
expiresInSeconds: this.twentyConfigService.get(
'STORAGE_S3_PRESIGNED_URL_EXPIRES_IN',
),
responseContentType: mimeType,
responseContentDisposition: getContentDisposition(mimeType),
});
if (presignedUrl) {
return { type: 'redirect', presignedUrl };
}
const stream = await this.fileStorageService.readFile(resourceIdentifier);
return { type: 'stream', stream, mimeType };
}
async getFileContentById({
fileId,
workspaceId,
@@ -0,0 +1,5 @@
import { type Readable } from 'stream';
export type FileResponse =
| { type: 'redirect'; presignedUrl: string }
| { type: 'stream'; stream: Readable; mimeType: string };
@@ -0,0 +1,90 @@
import { getContentDisposition } from 'src/engine/core-modules/file/utils/get-content-disposition.utils';
import { setFileResponseHeaders } from 'src/engine/core-modules/file/utils/set-file-response-headers.utils';
const createMockResponse = () => ({
setHeader: jest.fn(),
});
describe('setFileResponseHeaders', () => {
it('should set Content-Type from mimeType', () => {
const res = createMockResponse();
setFileResponseHeaders(res as any, 'image/png');
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png');
});
it('should fall back to application/octet-stream for empty mimeType', () => {
const res = createMockResponse();
setFileResponseHeaders(res as any, '');
expect(res.setHeader).toHaveBeenCalledWith(
'Content-Type',
'application/octet-stream',
);
});
it('should always set X-Content-Type-Options: nosniff', () => {
const res = createMockResponse();
setFileResponseHeaders(res as any, 'text/html');
expect(res.setHeader).toHaveBeenCalledWith(
'X-Content-Type-Options',
'nosniff',
);
});
it.each([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'video/mp4',
'audio/mpeg',
])('should set Content-Disposition: inline for safe type %s', (mimeType) => {
const res = createMockResponse();
setFileResponseHeaders(res as any, mimeType);
expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'inline');
});
it.each([
'text/html',
'image/svg+xml',
'application/xml',
'application/octet-stream',
'application/javascript',
])(
'should set Content-Disposition: attachment for unsafe type %s',
(mimeType) => {
const res = createMockResponse();
setFileResponseHeaders(res as any, mimeType);
expect(res.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'attachment',
);
},
);
});
describe('getContentDisposition', () => {
it('should return inline for safe MIME types', () => {
expect(getContentDisposition('image/png')).toBe('inline');
expect(getContentDisposition('application/pdf')).toBe('inline');
});
it('should return attachment for unsafe MIME types', () => {
expect(getContentDisposition('text/html')).toBe('attachment');
expect(getContentDisposition('application/xml')).toBe('attachment');
expect(getContentDisposition('application/octet-stream')).toBe(
'attachment',
);
});
});
@@ -0,0 +1,22 @@
const INLINE_SAFE_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
'image/tiff',
'application/pdf',
'text/plain',
'audio/mpeg',
'audio/wav',
'audio/ogg',
'video/mp4',
'video/webm',
'video/ogg',
'image/x-icon',
]);
export const getContentDisposition = (mimeType: string): string => {
return INLINE_SAFE_MIME_TYPES.has(mimeType) ? 'inline' : 'attachment';
};
@@ -1,31 +1,11 @@
import { type Response } from 'express';
const INLINE_SAFE_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
'image/tiff',
'application/pdf',
'text/plain',
'audio/mpeg',
'audio/wav',
'audio/ogg',
'video/mp4',
'video/webm',
'video/ogg',
'image/x-icon',
]);
import { getContentDisposition } from 'src/engine/core-modules/file/utils/get-content-disposition.utils';
export const setFileResponseHeaders = (res: Response, mimeType: string) => {
const contentType = mimeType || 'application/octet-stream';
const disposition = INLINE_SAFE_MIME_TYPES.has(contentType)
? 'inline'
: 'attachment';
res.setHeader('Content-Type', contentType);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', disposition);
res.setHeader('Content-Disposition', getContentDisposition(contentType));
};