From 9afab64f67b2531261c4c4870ea24000d158798e Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 11 Oct 2026 22:20:25 -0400 Subject: [PATCH] Sign avatar download URLs against a browser-reachable endpoint --- docs/web/environment-variables.md | 12 ++++++++- web/.env.example | 6 ++++- .../Configuration/ConfigurationKeys.cs | 8 ++++++ .../Services/S3FileStorageProvider.cs | 26 +++++++++++++++++-- web/docker-compose.db.yaml | 1 + web/docker-compose.dev.yaml | 2 ++ web/docker-compose.min.yaml | 1 + web/docker-compose.prod.seed.yaml | 1 + web/docker-compose.prod.yaml | 1 + web/docker-compose.test.yaml | 1 + 10 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/web/environment-variables.md b/docs/web/environment-variables.md index 656d0cc1..54035a7d 100644 --- a/docs/web/environment-variables.md +++ b/docs/web/environment-variables.md @@ -260,17 +260,25 @@ and retrieving photos via an S3-compatible object store. ```bash SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default +SEAWEEDFS_PUBLIC_URL=http://localhost:8333 # Optional, defaults to SEAWEEDFS_SERVICE_URL SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default SEAWEEDFS_BUCKET=my-bucket # Required, no default ``` - **Implementation**: `S3FileStorageProvider` throws on construction if any of - these four values is missing + the four required values is missing - **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333) - **Production**: point at any S3-compatible endpoint (AWS S3, a hosted SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which SeaweedFS requires and AWS S3 also accepts +- **`SEAWEEDFS_PUBLIC_URL`**: the endpoint download URLs are signed against, + used by `GetPresignedUrl` and reaching the API response as + `PublicUserProfileDto.AvatarUrl`. A presigned URL is signed over its host, so + this has to be the address the browser uses (`http://localhost:8333` with the + dev compose file, which publishes the S3 API port), separate from the + in-network `SEAWEEDFS_SERVICE_URL` the API uploads through. The two can be the + same value wherever one address serves both ## Docker-specific variables @@ -341,6 +349,7 @@ from the template in place; edit them manually if you go that route. | `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address | | `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` | | `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL | +| `SEAWEEDFS_PUBLIC_URL` | ✓ | | ✓ | No | Endpoint download URLs are signed against; defaults to `SEAWEEDFS_SERVICE_URL` | | `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key | | `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key | | `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name | @@ -415,6 +424,7 @@ SMTP_FROM_NAME=The Biergarten App # SeaweedFS (S3-compatible storage) SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 +SEAWEEDFS_PUBLIC_URL=http://localhost:8333 SEAWEEDFS_ACCESS_KEY_ID= SEAWEEDFS_SECRET_ACCESS_KEY= SEAWEEDFS_BUCKET=my-bucket diff --git a/web/.env.example b/web/.env.example index bbad7355..01ed405b 100644 --- a/web/.env.example +++ b/web/.env.example @@ -68,8 +68,12 @@ WEBSITE_BASE_URL=http://localhost:3000 # ====================== # SeaweedFS (S3) Configuration # ====================== -# Credentials and default bucket for the local SeaweedFS S3-compatible storage +# Credentials and default bucket for the local SeaweedFS S3-compatible storage. +# SEAWEEDFS_SERVICE_URL is the in-network endpoint the API uploads through; +# SEAWEEDFS_PUBLIC_URL is the endpoint download URLs are signed against, so it has to be +# reachable from the browser. It defaults to SEAWEEDFS_SERVICE_URL when left blank. SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 +SEAWEEDFS_PUBLIC_URL=http://localhost:8333 SEAWEEDFS_ACCESS_KEY_ID=admin SEAWEEDFS_SECRET_ACCESS_KEY=secret SEAWEEDFS_BUCKET=my-bucket diff --git a/web/backend/Configuration/ConfigurationKeys.cs b/web/backend/Configuration/ConfigurationKeys.cs index 06763a01..a01927e8 100644 --- a/web/backend/Configuration/ConfigurationKeys.cs +++ b/web/backend/Configuration/ConfigurationKeys.cs @@ -23,6 +23,14 @@ public static class ConfigurationKeys public const string SmtpFromName = "SMTP_FROM_NAME"; public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL"; + + /// + /// The browser-reachable base URL of the object store, used to sign download URLs. Falls back to + /// when unset: SigV4 covers the host, so a URL signed against + /// the container-internal endpoint is only usable by callers that can resolve that host. + /// + public const string SeaweedFsPublicUrl = "SEAWEEDFS_PUBLIC_URL"; + public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID"; public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY"; public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET"; diff --git a/web/backend/Features/Features.PhotoUpload/Services/S3FileStorageProvider.cs b/web/backend/Features/Features.PhotoUpload/Services/S3FileStorageProvider.cs index 236d851a..bc5879b1 100644 --- a/web/backend/Features/Features.PhotoUpload/Services/S3FileStorageProvider.cs +++ b/web/backend/Features/Features.PhotoUpload/Services/S3FileStorageProvider.cs @@ -13,11 +13,13 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable { private readonly string _bucket; private readonly IAmazonS3 _client; + private readonly IAmazonS3 _presignClient; /// /// Initializes a new instance of the class, reading storage /// configuration (SEAWEEDFS_SERVICE_URL, SEAWEEDFS_ACCESS_KEY_ID, - /// SEAWEEDFS_SECRET_ACCESS_KEY, SEAWEEDFS_BUCKET) from . + /// SEAWEEDFS_SECRET_ACCESS_KEY, SEAWEEDFS_BUCKET, and the optional + /// SEAWEEDFS_PUBLIC_URL) from . /// /// /// Thrown when any of the required configuration values is not set. @@ -45,6 +47,23 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true }; _client = new AmazonS3Client(accessKeyId, secretAccessKey, config); + + // A presigned URL is signed over its host, so one signed against the container-internal + // endpoint is only usable from inside the network. Sign downloads against the public + // endpoint instead, so a browser can fetch the object. + string? configuredPublicUrl = configuration[ConfigurationKeys.SeaweedFsPublicUrl]; + string publicUrl = string.IsNullOrWhiteSpace(configuredPublicUrl) + ? serviceUrl + : configuredPublicUrl; + + _presignClient = + publicUrl == serviceUrl + ? _client + : new AmazonS3Client( + accessKeyId, + secretAccessKey, + new AmazonS3Config { ServiceURL = publicUrl, ForcePathStyle = true } + ); } /// @@ -84,12 +103,15 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable Expires = DateTime.UtcNow.Add(expiresIn), }; - return _client.GetPreSignedURL(request); + return _presignClient.GetPreSignedURL(request); } /// public void Dispose() { + if (!ReferenceEquals(_presignClient, _client)) + _presignClient.Dispose(); + _client.Dispose(); GC.SuppressFinalize(this); } diff --git a/web/docker-compose.db.yaml b/web/docker-compose.db.yaml index e678d997..bea0ae8c 100644 --- a/web/docker-compose.db.yaml +++ b/web/docker-compose.db.yaml @@ -70,6 +70,7 @@ services: DB_USER: "${DB_USER}" DB_PASSWORD: "${DB_PASSWORD}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" diff --git a/web/docker-compose.dev.yaml b/web/docker-compose.dev.yaml index afdfa5d2..9a94b516 100644 --- a/web/docker-compose.dev.yaml +++ b/web/docker-compose.dev.yaml @@ -72,6 +72,7 @@ services: DB_USER: "${DB_USER}" DB_PASSWORD: "${DB_PASSWORD}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" @@ -110,6 +111,7 @@ services: CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" diff --git a/web/docker-compose.min.yaml b/web/docker-compose.min.yaml index d2c9ae7f..d77c930c 100644 --- a/web/docker-compose.min.yaml +++ b/web/docker-compose.min.yaml @@ -84,6 +84,7 @@ services: DB_USER: "${DB_USER}" DB_PASSWORD: "${DB_PASSWORD}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" diff --git a/web/docker-compose.prod.seed.yaml b/web/docker-compose.prod.seed.yaml index bc46f2af..1dbf7314 100644 --- a/web/docker-compose.prod.seed.yaml +++ b/web/docker-compose.prod.seed.yaml @@ -19,6 +19,7 @@ services: DB_USER: "${DB_USER}" DB_PASSWORD: "${DB_PASSWORD}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" diff --git a/web/docker-compose.prod.yaml b/web/docker-compose.prod.yaml index 4b99d938..b35f9033 100644 --- a/web/docker-compose.prod.yaml +++ b/web/docker-compose.prod.yaml @@ -51,6 +51,7 @@ services: CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" diff --git a/web/docker-compose.test.yaml b/web/docker-compose.test.yaml index 45405a94..ca079b25 100644 --- a/web/docker-compose.test.yaml +++ b/web/docker-compose.test.yaml @@ -70,6 +70,7 @@ services: DB_USER: "${DB_USER}" DB_PASSWORD: "${DB_PASSWORD}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"