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:
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user