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
@@ -420,7 +420,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.STORAGE_CONFIG,
description: 'S3 region for storage when using S3 storage type',
description: 'AWS region of the S3 bucket (e.g. eu-west-3). Required.',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S_3)
@@ -429,7 +429,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.STORAGE_CONFIG,
description: 'S3 bucket name for storage when using S3 storage type',
description: 'Name of the S3 bucket used for file storage. Required.',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S_3)
@@ -437,7 +437,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.STORAGE_CONFIG,
description: 'S3 endpoint for storage when using S3 storage type',
description:
'Custom S3 endpoint URL. Optional — only needed for S3-compatible services like MinIO (e.g. http://minio:9000). Omit for native AWS S3, where the SDK resolves the endpoint from the region automatically.',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S_3)
@@ -448,7 +449,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.STORAGE_CONFIG,
isSensitive: true,
description:
'S3 access key ID for authentication when using S3 storage type',
'S3 access key ID. Optional — omit to use the default AWS credential chain (IAM role, instance profile, etc.).',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S_3)
@@ -459,13 +460,44 @@ export class ConfigVariables {
group: ConfigVariablesGroup.STORAGE_CONFIG,
isSensitive: true,
description:
'S3 secret access key for authentication when using S3 storage type',
'S3 secret access key. Required when STORAGE_S3_ACCESS_KEY_ID is set, ignored otherwise.',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S_3)
@IsOptional()
STORAGE_S3_SECRET_ACCESS_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.STORAGE_CONFIG,
description:
'When enabled, file downloads are 302-redirected to S3 presigned URLs instead of being proxied through the server. Reduces server load and bandwidth.',
type: ConfigVariableType.BOOLEAN,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S_3)
@IsOptional()
// TODO: default to true once validated in production
STORAGE_S3_PRESIGNED_URL_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.STORAGE_CONFIG,
description:
'Public S3 endpoint used for generating presigned URLs. Optional — only needed when STORAGE_S3_ENDPOINT is an internal address not reachable by browsers (e.g. http://minio:9000 in Docker). Set this to the publicly accessible equivalent (e.g. https://storage.example.com).',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S_3)
@IsOptional()
STORAGE_S3_PRESIGNED_URL_BASE: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.STORAGE_CONFIG,
description: 'TTL in seconds for S3 presigned URLs.',
type: ConfigVariableType.NUMBER,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S_3)
@CastToPositiveNumber()
@IsOptional()
STORAGE_S3_PRESIGNED_URL_EXPIRES_IN: number = 900;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LOGIC_FUNCTION_CONFIG,
description: 'Type of function execution (local or Lambda)',