diff --git a/.agents/rules/storage.md b/.agents/rules/storage.md index 00733cf4f4..0834be551e 100644 --- a/.agents/rules/storage.md +++ b/.agents/rules/storage.md @@ -12,6 +12,8 @@ `AddHeroStorage(config)` reads `Storage:Provider` **eagerly at registration**: `"s3"` → `S3StorageService` (supports any S3-compatible store, e.g. RustFS, via `ServiceUrl` + `ForcePathStyle`), else `LocalStorageService`. When quotas are enabled the service is wrapped in `QuotaMeteredStorageService` (debits `StorageBytes`). +`Storage:S3:PresignServiceUrl` (optional, validated at startup as an absolute http(s) URL): presigned PUT/GET URLs are signed for this host through a second, keyed `IAmazonS3` (`S3StorageService.PresignClientKey`); all real I/O stays on `ServiceUrl`. Needed when the API reaches the store on an internal address browsers can't resolve (compose: `http://rustfs:9000`). A test factory that re-registers the S3 stack must register that keyed client too (see `FshWebApplicationFactory`). + ## Presigned upload flow (preferred for user uploads) Don't stream large files through the API. The pattern (see Files module): diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index 169155e5d4..4b9bb10295 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -8,21 +8,27 @@ # Where each surface is reachable from end users' browsers (via your # external proxy / TLS terminator). Used by: # • frontends, baked into /config.json at container start -# • API CORS allow-list for the two frontend origins +# • API and RustFS CORS allow-lists for the two frontend origins +# • FSH_S3_PUBLIC_URL: host the API signs presigned upload/download +# URLs for (the RustFS S3 API, FSH_S3_PORT). SigV4 signs the Host +# header, so your proxy must forward it unchanged. FSH_API_URL=https://api.example.com FSH_ADMIN_URL=https://admin.example.com FSH_DASHBOARD_URL=https://app.example.com +FSH_S3_PUBLIC_URL=https://s3.example.com # Default tenant identifier used by the login forms on first paint. FSH_DEFAULT_TENANT=root # ── Host ports for the external proxy to point at ─────────────────── -# Only FSH services publish ports. Postgres/Redis/RustFS stay -# compose-internal by default (uncomment their `ports:` blocks in -# docker-compose.yml if you need host access for psql / redis-cli). +# FSH services and the RustFS S3 API publish ports (browsers upload +# straight to it). Postgres/Redis stay compose-internal by default +# (uncomment their `ports:` blocks in docker-compose.yml if you need +# host access for psql / redis-cli). FSH_API_PORT=8080 FSH_ADMIN_PORT=8081 FSH_DASHBOARD_PORT=8082 +FSH_S3_PORT=9000 # ── Secrets ───────────────────────────────────────────────────────── # JWT signing key — must be 32+ chars and NOT contain the substring diff --git a/deploy/docker/README.md b/deploy/docker/README.md index f4cfbfc304..eb548cd242 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -10,7 +10,7 @@ This brings up the full stack on a single host: | `migrator` | `fsh/dbmigrator:local` | — | One-shot: applies EF migrations + seeds the root tenant + creates the default admin user | | `postgres` | `postgres:18-alpine` | (internal) | Identity, tenant catalog, module schemas | | `redis` | `valkey/valkey:9.1.0-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store | -| `rustfs` | `rustfs/rustfs:1.0.0` | (internal) | S3-compatible blob store for the Files module ([RustFS](https://rustfs.com)) | +| `rustfs` | `rustfs/rustfs:1.0.0` | `FSH_S3_PORT` (default 9000), S3 API only | S3-compatible blob store for the Files module ([RustFS](https://rustfs.com)); published because browsers upload to it through presigned URLs | The compose file does **not** include a reverse proxy or TLS terminator. You bring your own edge — Cloudflare Tunnel, AWS ALB, Tailscale Funnel, your existing nginx, anything that can route a TLS subdomain to a host:port on this machine. @@ -18,13 +18,13 @@ The compose file does **not** include a reverse proxy or TLS terminator. You bri - Docker Engine 24+ with the Compose plugin (`docker compose version` should print v2.x). - 2 GB free RAM, 5 GB disk for first-run images + builds. -- Ports 8080–8082 free on the host (or set custom ports in `.env`). +- Ports 8080–8082 and 9000 free on the host (or set custom ports in `.env`). ## Five-minute deploy ```bash cp .env.example .env -$EDITOR .env # fill JWT_SIGNING_KEY, SEED_ADMIN_PASSWORD, the data-plane passwords, and your three URLs +$EDITOR .env # fill JWT_SIGNING_KEY, SEED_ADMIN_PASSWORD, the data-plane passwords, and your four URLs docker compose up -d --build ``` @@ -44,20 +44,24 @@ curl -fsS http://localhost:8080/health/live # API liveness curl -fsSI http://localhost:8081/ | head -1 # admin SPA — HTTP/1.1 200 OK curl -fsS http://localhost:8081/config.json # admin runtime config — shows FSH_API_URL curl -fsSI http://localhost:8082/ | head -1 # dashboard SPA +curl -fsS http://localhost:9000/health # RustFS S3 API ``` ## Wire up your external proxy -Point three TLS subdomains at the published ports: +Point four TLS subdomains at the published ports: | Public URL (your domain) | Host port | |---|---| | `api.example.com` | `8080` | | `admin.example.com` | `8081` | | `app.example.com` | `8082` | +| `s3.example.com` | `9000` | Make sure the URLs you serve match the `FSH_API_URL` / `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` you set in `.env` — those values are baked into the frontends' runtime `/config.json` (CORS will fail loudly otherwise). They also drive the origins the API is allowed to put inside password-reset and e-mail-confirmation links, with `FSH_DASHBOARD_URL` as the default target for links the API sends on an operator's behalf. +Presigned uploads and downloads skip the API: the browser talks to RustFS through presigned URLs, which the API signs for `FSH_S3_PUBLIC_URL` (it reaches RustFS itself on the internal `http://rustfs:9000`). Set `FSH_S3_PUBLIC_URL` to the URL your proxy serves the S3 port on, and make the proxy forward the original `Host` header: the signature covers it, so a rewritten host fails with `SignatureDoesNotMatch`. RustFS only grants CORS to `FSH_ADMIN_URL` and `FSH_DASHBOARD_URL`, so a browser on any other origin blocks the PUT. + ## Sign in for the first time Open `https://admin.example.com`, sign in as: @@ -98,7 +102,7 @@ Single-host compose is the default story; production deployments often point at 2. Swap the matching env vars on `api` and `migrator`: - `DatabaseOptions__ConnectionString` → your managed Postgres connection string - `CachingOptions__Redis` → your managed Redis connection string (`host:port,password=...,ssl=True` etc.) - - `Storage__Provider`, `Storage__S3__*` → your S3-compatible store + - `Storage__Provider`, `Storage__S3__*` → your S3-compatible store (drop `Storage__S3__PresignServiceUrl` when the store's own endpoint is reachable from browsers, as with AWS S3) 3. `docker compose up -d`. The data-plane volumes (`pg_data`, `redis_data`, `rustfs_data`) can be deleted once you've migrated. @@ -112,4 +116,5 @@ The data-plane volumes (`pg_data`, `redis_data`, `rustfs_data`) can be deleted o | `OptionsValidationException: SigningKey looks like a sample placeholder` | `JWT_SIGNING_KEY` contains `replace-with` (the framework's placeholder detector). Generate a real key: `openssl rand -base64 48`. | | API up but admin shows a CORS error | `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` in `.env` doesn't match what your external proxy serves. Both go on the CORS allow-list. | | A reset or confirmation e-mail links to the API instead of the app | Same cause: `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` don't match the origins the browser actually uses. Both also feed `FrontendOptions__AllowedOrigins`, and `FSH_DASHBOARD_URL` feeds `FrontendOptions__DefaultOrigin`. | +| File upload fails in the browser (network error, CORS error, or `403 SignatureDoesNotMatch`) | `FSH_S3_PUBLIC_URL` doesn't match what your proxy serves on the S3 port, the proxy rewrites the `Host` header, or the page's origin isn't `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` (the RustFS CORS allow-list). | | `migrator` retries Postgres for 2 minutes then fails | Postgres didn't come up — check `docker compose logs postgres`. Most often a `POSTGRES_PASSWORD` change against an existing `pg_data` volume; delete the volume with `docker compose down -v` (destructive) and start over. | diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 0d5e575432..8cc65d6d89 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -4,8 +4,9 @@ # docker compose up -d --build # # Operator owns the edge (TLS / subdomain routing). This file publishes -# only the FSH services on host ports; the data plane (postgres/redis/ -# rustfs) stays compose-internal unless you uncomment their ports blocks. +# the FSH services and the RustFS S3 API on host ports (browsers upload +# straight to presigned S3 URLs); postgres/redis stay compose-internal +# unless you uncomment their ports blocks. name: fsh @@ -63,6 +64,8 @@ services: RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?RUSTFS_ACCESS_KEY is required} RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:?RUSTFS_SECRET_KEY is required} RUSTFS_CONSOLE_ENABLE: "true" + # The admin and dashboard PUT uploads cross-origin to presigned URLs on this store. + RUSTFS_CORS_ALLOWED_ORIGINS: ${FSH_ADMIN_URL},${FSH_DASHBOARD_URL} volumes: - rustfs_data:/data healthcheck: @@ -70,9 +73,11 @@ services: interval: 10s timeout: 3s retries: 12 - # Uncomment to expose the console (admin UI) + S3 API to the host: - # ports: - # - "9000:9000" # S3 API + # The S3 API is published because browsers reach it directly through the + # presigned URLs the API hands out (FSH_S3_PUBLIC_URL). Uncomment the + # console (admin UI) line to expose it too: + ports: + - "${FSH_S3_PORT:-9000}:9000" # S3 API # - "9001:9001" # Web console # One-shot: create the bucket the Files module writes to. The app never @@ -136,6 +141,8 @@ services: Seed__DefaultAdminPassword: ${SEED_ADMIN_PASSWORD} Storage__Provider: s3 Storage__S3__ServiceUrl: http://rustfs:9000 + # Presigned upload/download URLs go to browsers, which cannot resolve rustfs:9000. + Storage__S3__PresignServiceUrl: ${FSH_S3_PUBLIC_URL:?FSH_S3_PUBLIC_URL is required} Storage__S3__AccessKey: ${RUSTFS_ACCESS_KEY} Storage__S3__SecretKey: ${RUSTFS_SECRET_KEY} Storage__S3__Bucket: fsh diff --git a/src/BuildingBlocks/Storage/Extensions.cs b/src/BuildingBlocks/Storage/Extensions.cs index 0d30d8a165..855c4a7954 100644 --- a/src/BuildingBlocks/Storage/Extensions.cs +++ b/src/BuildingBlocks/Storage/Extensions.cs @@ -34,7 +34,11 @@ public static IServiceCollection AddHeroStorage(this IServiceCollection services if (string.Equals(provider, "s3", StringComparison.OrdinalIgnoreCase)) { - services.Configure(configuration.GetSection("Storage:S3")); + services + .AddOptions() + .Bind(configuration.GetSection("Storage:S3")) + .Validate(o => string.IsNullOrWhiteSpace(o.PresignServiceUrl) || IsRootHttpUrl(o.PresignServiceUrl), "Storage:S3:PresignServiceUrl must be an absolute http(s) URL with no path, e.g. https://s3.example.com.") + .ValidateOnStart(); services.AddSingleton(sp => { @@ -45,29 +49,16 @@ public static IServiceCollection AddHeroStorage(this IServiceCollection services throw new InvalidOperationException("Storage:S3:Bucket is required when using S3 storage."); } - var config = new AmazonS3Config(); - - if (!string.IsNullOrWhiteSpace(options.ServiceUrl)) - { - // S3-compatible endpoint (e.g. MinIO). Path-style addressing is typically required - // because these services don't route virtual-hosted-style bucket subdomains. - config.ServiceURL = options.ServiceUrl; - config.ForcePathStyle = options.ForcePathStyle; - - // The SDK still wants an auth region for SigV4 even when hitting a custom endpoint. - config.AuthenticationRegion = string.IsNullOrWhiteSpace(options.Region) ? "us-east-1" : options.Region; - } - else if (!string.IsNullOrWhiteSpace(options.Region)) - { - config.RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region); - } - - var hasExplicitCredentials = !string.IsNullOrWhiteSpace(options.AccessKey) - && !string.IsNullOrWhiteSpace(options.SecretKey); + return CreateS3Client(options, options.ServiceUrl); + }); - return hasExplicitCredentials - ? new AmazonS3Client(new BasicAWSCredentials(options.AccessKey, options.SecretKey), config) - : new AmazonS3Client(config); + // A second client only so presigned URLs are signed for the host browsers use. Signing itself is + // offline, and without explicit keys the only call this client makes is the one-time fetch of + // ambient credentials. All real I/O stays on the client above. + services.AddKeyedSingleton(S3StorageService.PresignClientKey, (sp, _) => + { + var options = sp.GetRequiredService>().Value; + return CreateS3Client(options, options.PresignEndpoint); }); services.AddTransient(); @@ -82,6 +73,42 @@ public static IServiceCollection AddHeroStorage(this IServiceCollection services return services; } + private static AmazonS3Client CreateS3Client(S3StorageOptions options, string? serviceUrl) + { + var config = new AmazonS3Config(); + + if (!string.IsNullOrWhiteSpace(serviceUrl)) + { + // S3-compatible endpoint (e.g. MinIO). Path-style addressing is typically required + // because these services don't route virtual-hosted-style bucket subdomains. + config.ServiceURL = serviceUrl; + config.ForcePathStyle = options.ForcePathStyle; + + // The SDK still wants an auth region for SigV4 even when hitting a custom endpoint. + config.AuthenticationRegion = string.IsNullOrWhiteSpace(options.Region) ? "us-east-1" : options.Region; + } + else if (!string.IsNullOrWhiteSpace(options.Region)) + { + config.RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region); + } + + var hasExplicitCredentials = !string.IsNullOrWhiteSpace(options.AccessKey) + && !string.IsNullOrWhiteSpace(options.SecretKey); + + return hasExplicitCredentials + ? new AmazonS3Client(new BasicAWSCredentials(options.AccessKey, options.SecretKey), config) + : new AmazonS3Client(config); + } + + // A path would be signed into every URL (https://host/s3/bucket/key): a proxy that strips it breaks + // the signature, and one that keeps it makes the store read "s3" as the bucket. + private static bool IsRootHttpUrl(string value) => + Uri.TryCreate(value.Trim(), UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) + && uri.AbsolutePath == "/" + && string.IsNullOrEmpty(uri.Query) + && string.IsNullOrEmpty(uri.Fragment); + private static void RegisterStorageService( IServiceCollection services, bool quotaEnabled, diff --git a/src/BuildingBlocks/Storage/S3/S3StorageOptions.cs b/src/BuildingBlocks/Storage/S3/S3StorageOptions.cs index 6c53018f75..65ec6e8d07 100644 --- a/src/BuildingBlocks/Storage/S3/S3StorageOptions.cs +++ b/src/BuildingBlocks/Storage/S3/S3StorageOptions.cs @@ -14,6 +14,19 @@ public sealed class S3StorageOptions /// public string? ServiceUrl { get; set; } + /// + /// Endpoint that presigned upload/download URLs point at, for when browsers reach the store on a + /// different address than the API does (e.g. compose: "http://rustfs:9000", + /// this "https://s3.example.com"). SigV4 signs the host, so the URL has to be signed for the public + /// one; every other S3 call keeps using . Leave empty to presign against + /// . + /// + public string? PresignServiceUrl { get; set; } + + // One place decides the presign endpoint, so the client's host and the URL's scheme cannot disagree. + // Trimmed because validation (Uri.TryCreate) tolerates padding that the SDK's endpoint parser rejects. + internal string? PresignEndpoint => string.IsNullOrWhiteSpace(PresignServiceUrl) ? ServiceUrl : PresignServiceUrl.Trim(); + /// /// Explicit access key. When either or /// is empty, the AWS SDK's ambient credential chain is used instead. @@ -24,7 +37,8 @@ public sealed class S3StorageOptions /// /// Required for MinIO and most non-AWS S3-compatible services (they do not support - /// virtual-hosted-style subdomains). Ignored when is empty. + /// virtual-hosted-style subdomains). Applies to each client that has a custom endpoint + /// ( or ); ignored for plain AWS S3. /// public bool ForcePathStyle { get; set; } } diff --git a/src/BuildingBlocks/Storage/S3/S3StorageService.cs b/src/BuildingBlocks/Storage/S3/S3StorageService.cs index 2fffdec775..5562c49d20 100644 --- a/src/BuildingBlocks/Storage/S3/S3StorageService.cs +++ b/src/BuildingBlocks/Storage/S3/S3StorageService.cs @@ -4,6 +4,7 @@ using FSH.Framework.Storage.DTOs; using FSH.Framework.Storage.Services; using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Text.RegularExpressions; @@ -12,7 +13,10 @@ namespace FSH.Framework.Storage.S3; internal sealed partial class S3StorageService : IStorageService { + internal const string PresignClientKey = "FSH.Storage.S3.Presign"; + private readonly IAmazonS3 _s3; + private readonly IAmazonS3 _presignS3; private readonly S3StorageOptions _options; private readonly ILogger _logger; private readonly FileExtensionContentTypeProvider _contentTypeProvider; @@ -26,9 +30,14 @@ internal sealed partial class S3StorageService : IStorageService [GeneratedRegex(@"[^a-zA-Z0-9_\.-]")] private static partial Regex FileNameSanitizer(); - public S3StorageService(IAmazonS3 s3, IOptions options, ILogger logger) + public S3StorageService( + IAmazonS3 s3, + [FromKeyedServices(PresignClientKey)] IAmazonS3 presignS3, + IOptions options, + ILogger logger) { _s3 = s3; + _presignS3 = presignS3; _options = options.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger; _contentTypeProvider = new FileExtensionContentTypeProvider(); @@ -248,7 +257,7 @@ public async Task GenerateUploadUrlAsync( Protocol = ResolvePresignProtocol() }; - var url = await _s3.GetPreSignedURLAsync(request).ConfigureAwait(false); + var url = await _presignS3.GetPreSignedURLAsync(request).ConfigureAwait(false); var requiredHeaders = new Dictionary(StringComparer.Ordinal) { @@ -288,7 +297,7 @@ public async Task GenerateDownloadUrlAsync( request.ResponseHeaderOverrides.ContentDisposition = responseContentDisposition; } - var url = await _s3.GetPreSignedURLAsync(request).ConfigureAwait(false); + var url = await _presignS3.GetPreSignedURLAsync(request).ConfigureAwait(false); return new Uri(url); } @@ -341,11 +350,12 @@ public async Task GenerateDownloadUrlAsync( } // MinIO and other S3-compatibles often serve plain HTTP, but the SDK defaults presigned URLs to - // HTTPS regardless of ServiceURL scheme (un-PUTable there). Infer protocol from ServiceURL. + // HTTPS regardless of ServiceURL scheme (un-PUTable there). Infer protocol from the presign endpoint. private Protocol ResolvePresignProtocol() { - if (!string.IsNullOrWhiteSpace(_options.ServiceUrl) - && Uri.TryCreate(_options.ServiceUrl, UriKind.Absolute, out var uri) + var endpoint = _options.PresignEndpoint; + if (!string.IsNullOrWhiteSpace(endpoint) + && Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) && string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) { return Protocol.HTTP; diff --git a/src/Tests/Framework.Tests/Storage/S3PresignEndpointTests.cs b/src/Tests/Framework.Tests/Storage/S3PresignEndpointTests.cs new file mode 100644 index 0000000000..de99c8d734 --- /dev/null +++ b/src/Tests/Framework.Tests/Storage/S3PresignEndpointTests.cs @@ -0,0 +1,188 @@ +using FSH.Framework.Storage; +using FSH.Framework.Storage.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Framework.Tests.Storage; + +/// +/// Presigned URLs are handed to browsers, so they must point at Storage:S3:PresignServiceUrl when the +/// API itself reaches the store on an internal address (compose: http://rustfs:9000). SigV4 presigning +/// is offline, so these run through the real AddHeroStorage wiring without a store to talk to. +/// +public sealed class S3PresignEndpointTests +{ + private const string InternalServiceUrl = "http://internal-store:9000"; + private const string PublicPresignServiceUrl = "https://public.example.test"; + private const string StorageKey = "uploads/probe/file.png"; + + #region Happy Path + + [Fact] + public async Task GenerateUploadUrlAsync_Should_TargetPresignServiceUrl_When_PresignServiceUrlIsSet() + { + // Arrange + using var provider = BuildProvider(InternalServiceUrl, PublicPresignServiceUrl); + var storage = provider.GetRequiredService(); + + // Act + var presigned = await storage.GenerateUploadUrlAsync(StorageKey, "image/png", 1024, TimeSpan.FromMinutes(5)); + + // Assert + presigned.Url.Scheme.ShouldBe(Uri.UriSchemeHttps); + presigned.Url.Host.ShouldBe("public.example.test"); + presigned.Url.Port.ShouldBe(443); + } + + [Fact] + public async Task GenerateDownloadUrlAsync_Should_TargetPresignServiceUrl_When_PresignServiceUrlIsSet() + { + // Arrange + using var provider = BuildProvider(InternalServiceUrl, PublicPresignServiceUrl); + var storage = provider.GetRequiredService(); + + // Act + var url = await storage.GenerateDownloadUrlAsync(StorageKey, TimeSpan.FromMinutes(5)); + + // Assert + url.Scheme.ShouldBe(Uri.UriSchemeHttps); + url.Host.ShouldBe("public.example.test"); + url.Port.ShouldBe(443); + } + + // The protocol has to come from PresignServiceUrl, not ServiceUrl: an http-only internal store behind a TLS + // edge needs https URLs, and an https internal store with a plain-http public endpoint needs http ones. + [Theory] + [InlineData("http://internal-store:9000", "https://public.example.test:8443", "https", 8443)] + [InlineData("https://internal-store:9443", "http://public.example.test:9000", "http", 9000)] + public async Task GeneratePresignedUrls_Should_FollowPresignServiceUrlScheme_When_ItDiffersFromServiceUrl( + string serviceUrl, + string presignServiceUrl, + string expectedScheme, + int expectedPort) + { + // Arrange + using var provider = BuildProvider(serviceUrl, presignServiceUrl); + var storage = provider.GetRequiredService(); + + // Act + var upload = await storage.GenerateUploadUrlAsync(StorageKey, "image/png", 1024, TimeSpan.FromMinutes(5)); + var download = await storage.GenerateDownloadUrlAsync(StorageKey, TimeSpan.FromMinutes(5)); + + // Assert + foreach (var url in new[] { upload.Url, download }) + { + url.Scheme.ShouldBe(expectedScheme); + url.Host.ShouldBe("public.example.test"); + url.Port.ShouldBe(expectedPort); + } + } + + [Fact] + public void AddHeroStorage_Should_PassStartupValidation_When_PresignServiceUrlIsAbsoluteHttpUrl() + { + // Arrange + using var provider = BuildProvider(InternalServiceUrl, PublicPresignServiceUrl); + + // Act + provider.GetRequiredService().Validate(); + + // Assert — sanity: the rule below rejects bad values without rejecting a good one. + provider.GetRequiredService>().Value + .PresignServiceUrl.ShouldBe(PublicPresignServiceUrl); + } + + #endregion + + #region Exception + + [Theory] + [InlineData("not-a-url")] + [InlineData("/relative/path")] + [InlineData("ftp://public.example.test")] + [InlineData("https://public.example.test/s3")] + [InlineData("https://public.example.test/?region=x")] + public void AddHeroStorage_Should_FailAtStartup_When_PresignServiceUrlIsNotAbsoluteHttpUrl(string presignServiceUrl) + { + // Arrange + using var provider = BuildProvider(InternalServiceUrl, presignServiceUrl); + + // Act — through IStartupValidator, which is what .ValidateOnStart() registers; resolving + // IOptions<>.Value would only fail on the first presign, long after boot. + var act = () => provider.GetRequiredService().Validate(); + + // Assert + act.ShouldThrow() + .Failures.ShouldContain(failure => failure.Contains("PresignServiceUrl", StringComparison.Ordinal)); + } + + #endregion + + #region Edge Cases + + // Regression guard: without PresignServiceUrl the URLs are exactly what they were before the option existed. + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task GeneratePresignedUrls_Should_TargetServiceUrl_When_PresignServiceUrlIsEmpty(string? presignServiceUrl) + { + // Arrange + using var provider = BuildProvider(InternalServiceUrl, presignServiceUrl); + var storage = provider.GetRequiredService(); + + // Act + var upload = await storage.GenerateUploadUrlAsync(StorageKey, "image/png", 1024, TimeSpan.FromMinutes(5)); + var download = await storage.GenerateDownloadUrlAsync(StorageKey, TimeSpan.FromMinutes(5)); + + // Assert + foreach (var url in new[] { upload.Url, download }) + { + url.Scheme.ShouldBe(Uri.UriSchemeHttp); + url.Host.ShouldBe("internal-store"); + url.Port.ShouldBe(9000); + } + } + + // A quoted or padded FSH_S3_PUBLIC_URL passes Uri.TryCreate, so it must not reach the SDK untrimmed and + // fail every upload long after startup validation said it was fine. + [Fact] + public async Task GenerateUploadUrlAsync_Should_TargetTrimmedPresignServiceUrl_When_ValueIsPadded() + { + // Arrange + using var provider = BuildProvider(InternalServiceUrl, " https://public.example.test "); + provider.GetRequiredService().Validate(); + var storage = provider.GetRequiredService(); + + // Act + var presigned = await storage.GenerateUploadUrlAsync(StorageKey, "image/png", 1024, TimeSpan.FromMinutes(5)); + + // Assert + presigned.Url.Scheme.ShouldBe(Uri.UriSchemeHttps); + presigned.Url.Host.ShouldBe("public.example.test"); + } + + #endregion + + private static ServiceProvider BuildProvider(string serviceUrl, string? presignServiceUrl) + { + // Explicit keys keep the SDK off the ambient credential chain, which would reach for the network. + var settings = new Dictionary + { + ["Storage:Provider"] = "s3", + ["Storage:S3:Bucket"] = "presign-tests", + ["Storage:S3:Region"] = "us-east-1", + ["Storage:S3:ServiceUrl"] = serviceUrl, + ["Storage:S3:AccessKey"] = "test-access-key", + ["Storage:S3:SecretKey"] = "test-secret-key", + ["Storage:S3:ForcePathStyle"] = "true", + ["Storage:S3:PresignServiceUrl"] = presignServiceUrl + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHeroStorage(configuration); + return services.BuildServiceProvider(); + } +} diff --git a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs index ff71a48d2c..d7b227bc48 100644 --- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs +++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs @@ -29,9 +29,9 @@ namespace Integration.Tests.Infrastructure; public sealed class FshWebApplicationFactory : WebApplicationFactory, IAsyncLifetime { - private const string S3AccessKey = "rustfsadmin"; - private const string S3SecretKey = "rustfsadmin"; - private const string S3Bucket = "fsh-integration-test-uploads"; + internal const string S3AccessKey = "rustfsadmin"; + internal const string S3SecretKey = "rustfsadmin"; + internal const string S3Bucket = "fsh-integration-test-uploads"; private static readonly SemaphoreSlim _migrationLock = new(1, 1); private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:17-alpine") @@ -257,24 +257,30 @@ private void RewireStorageForS3(IServiceCollection services) opts.Region = "us-east-1"; }); - services.AddSingleton(_ => - { - var config = new AmazonS3Config - { - ServiceURL = S3ServiceUrl, - ForcePathStyle = true, - UseHttp = true, - AuthenticationRegion = "us-east-1" - }; - return new AmazonS3Client( - new Amazon.Runtime.BasicAWSCredentials(S3AccessKey, S3SecretKey), - config); - }); + services.AddSingleton(_ => CreateRustFsClient()); + // S3StorageService presigns through a keyed client; tests reach RustFS on one address, so it matches. + services.AddKeyedSingleton( + FSH.Framework.Storage.S3.S3StorageService.PresignClientKey, + (_, _) => CreateRustFsClient()); services.AddTransient(); services.AddTransient(sp => sp.GetRequiredService()); } + private AmazonS3Client CreateRustFsClient() + { + var config = new AmazonS3Config + { + ServiceURL = S3ServiceUrl, + ForcePathStyle = true, + UseHttp = true, + AuthenticationRegion = "us-east-1" + }; + return new AmazonS3Client( + new Amazon.Runtime.BasicAWSCredentials(S3AccessKey, S3SecretKey), + config); + } + protected override IHost CreateHost(IHostBuilder builder) { // Disable ValidateOnBuild for .NET 10 minimal API dual-host model diff --git a/src/Tests/Integration.Tests/Tests/Files/PresignServiceUrlTests.cs b/src/Tests/Integration.Tests/Tests/Files/PresignServiceUrlTests.cs new file mode 100644 index 0000000000..90583f6a27 --- /dev/null +++ b/src/Tests/Integration.Tests/Tests/Files/PresignServiceUrlTests.cs @@ -0,0 +1,97 @@ +using System.Security.Cryptography; +using FSH.Framework.Storage; +using FSH.Framework.Storage.Services; +using Integration.Tests.Infrastructure; +using Microsoft.Extensions.Configuration; + +namespace Integration.Tests.Tests.Files; + +/// +/// Storage:S3:PresignServiceUrl against a real store: the API reaches RustFS as 127.0.0.1 and hands out +/// presigned URLs for localhost, two host strings for the same container. A 200 on the PUT proves the +/// signature was computed for the public host, not the one the API talks to (SigV4 signs the Host header). +/// +[Collection(FshCollectionDefinition.Name)] +public sealed class PresignServiceUrlTests +{ + private const string ContentType = "application/octet-stream"; + private readonly FshWebApplicationFactory _factory; + + public PresignServiceUrlTests(FshWebApplicationFactory factory) + { + _factory = factory; + } + + #region Happy Path + + [Fact] + public async Task PresignedUrls_Should_RoundTripBytes_Through_PresignServiceUrl_When_ItDiffersFromServiceUrl() + { + // Arrange + int port = new Uri(_factory.S3ServiceUrl).Port; + using var provider = BuildProvider($"http://127.0.0.1:{port}", $"http://localhost:{port}"); + var storage = provider.GetRequiredService(); + string key = $"uploads/presign-endpoint/{Guid.NewGuid():N}.bin"; + byte[] bytes = new byte[1024]; + RandomNumberGenerator.Fill(bytes); + + try + { + // Act — presigned PUT straight to the public host + var upload = await storage.GenerateUploadUrlAsync(key, ContentType, bytes.Length, TimeSpan.FromMinutes(5)); + upload.Url.Host.ShouldBe("localhost"); + upload.Url.Port.ShouldBe(port); + + using var raw = new HttpClient(); + using var body = new ByteArrayContent(bytes); + body.Headers.ContentType = new MediaTypeHeaderValue(ContentType); + using var putResp = await raw.PutAsync(upload.Url, body); + + // Assert — the store accepted the signature, the API (on its own host) sees the object, and the + // presigned GET on the public host serves the same bytes back. + putResp.StatusCode.ShouldBe(HttpStatusCode.OK); + + var head = await storage.HeadObjectAsync(key); + head.ShouldNotBeNull(); + head.SizeBytes.ShouldBe(bytes.Length); + + var download = await storage.GenerateDownloadUrlAsync(key, TimeSpan.FromMinutes(5)); + download.Host.ShouldBe("localhost"); + download.Port.ShouldBe(port); + + using var getResp = await raw.GetAsync(download); + getResp.StatusCode.ShouldBe(HttpStatusCode.OK); + (await getResp.Content.ReadAsByteArrayAsync()).ShouldBe(bytes); + } + finally + { + await storage.RemoveAsync(key); + } + + (await storage.HeadObjectAsync(key)).ShouldBeNull(); + } + + #endregion + + private static ServiceProvider BuildProvider(string serviceUrl, string presignServiceUrl) + { + var settings = new Dictionary + { + ["Storage:Provider"] = "s3", + ["Storage:S3:Bucket"] = FshWebApplicationFactory.S3Bucket, + ["Storage:S3:Region"] = "us-east-1", + ["Storage:S3:ServiceUrl"] = serviceUrl, + ["Storage:S3:PresignServiceUrl"] = presignServiceUrl, + ["Storage:S3:AccessKey"] = FshWebApplicationFactory.S3AccessKey, + ["Storage:S3:SecretKey"] = FshWebApplicationFactory.S3SecretKey, + ["Storage:S3:ForcePathStyle"] = "true", + ["Storage:S3:PublicRead"] = "false" + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHeroStorage(configuration); + return services.BuildServiceProvider(); + } +} diff --git a/src/Tools/CLI/Commands/NewCommand.cs b/src/Tools/CLI/Commands/NewCommand.cs index e555b03f97..cc9d36adb3 100644 --- a/src/Tools/CLI/Commands/NewCommand.cs +++ b/src/Tools/CLI/Commands/NewCommand.cs @@ -347,7 +347,8 @@ private static bool GenerateDockerEnv(string output) // Local-working defaults: the compose stack publishes these host ports. ["FSH_API_URL"] = "http://localhost:8080", ["FSH_ADMIN_URL"] = "http://localhost:8081", - ["FSH_DASHBOARD_URL"] = "http://localhost:8082" + ["FSH_DASHBOARD_URL"] = "http://localhost:8082", + ["FSH_S3_PUBLIC_URL"] = "http://localhost:9000" }; string[] lines = File.ReadAllLines(examplePath);