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:
+36
-10
@@ -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 };
|
||||
+90
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+22
@@ -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';
|
||||
};
|
||||
+2
-22
@@ -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));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user