Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/rules/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
14 changes: 10 additions & 4 deletions deploy/docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions deploy/docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,21 @@ 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.

## Prerequisites

- 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
```
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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. |
17 changes: 12 additions & 5 deletions deploy/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -63,16 +64,20 @@ 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:
test: ["CMD", "curl", "-fsS", "http://localhost:9000/health"]
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
Expand Down Expand Up @@ -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
Expand Down
73 changes: 50 additions & 23 deletions src/BuildingBlocks/Storage/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ public static IServiceCollection AddHeroStorage(this IServiceCollection services

if (string.Equals(provider, "s3", StringComparison.OrdinalIgnoreCase))
{
services.Configure<S3StorageOptions>(configuration.GetSection("Storage:S3"));
services
.AddOptions<S3StorageOptions>()
.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<IAmazonS3>(sp =>
{
Expand All @@ -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<IAmazonS3>(S3StorageService.PresignClientKey, (sp, _) =>
{
var options = sp.GetRequiredService<IOptions<S3StorageOptions>>().Value;
return CreateS3Client(options, options.PresignEndpoint);
});

services.AddTransient<S3StorageService>();
Expand All @@ -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<TInner>(
IServiceCollection services,
bool quotaEnabled,
Expand Down
16 changes: 15 additions & 1 deletion src/BuildingBlocks/Storage/S3/S3StorageOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ public sealed class S3StorageOptions
/// </summary>
public string? ServiceUrl { get; set; }

/// <summary>
/// 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: <see cref="ServiceUrl"/> "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 <see cref="ServiceUrl"/>. Leave empty to presign against
/// <see cref="ServiceUrl"/>.
/// </summary>
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();

/// <summary>
/// Explicit access key. When either <see cref="AccessKey"/> or <see cref="SecretKey"/>
/// is empty, the AWS SDK's ambient credential chain is used instead.
Expand All @@ -24,7 +37,8 @@ public sealed class S3StorageOptions

/// <summary>
/// Required for MinIO and most non-AWS S3-compatible services (they do not support
/// virtual-hosted-style subdomains). Ignored when <see cref="ServiceUrl"/> is empty.
/// virtual-hosted-style subdomains). Applies to each client that has a custom endpoint
/// (<see cref="ServiceUrl"/> or <see cref="PresignServiceUrl"/>); ignored for plain AWS S3.
/// </summary>
public bool ForcePathStyle { get; set; }
}
22 changes: 16 additions & 6 deletions src/BuildingBlocks/Storage/S3/S3StorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<S3StorageService> _logger;
private readonly FileExtensionContentTypeProvider _contentTypeProvider;
Expand All @@ -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<S3StorageOptions> options, ILogger<S3StorageService> logger)
public S3StorageService(
IAmazonS3 s3,
[FromKeyedServices(PresignClientKey)] IAmazonS3 presignS3,
IOptions<S3StorageOptions> options,
ILogger<S3StorageService> logger)
{
_s3 = s3;
_presignS3 = presignS3;
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger;
_contentTypeProvider = new FileExtensionContentTypeProvider();
Expand Down Expand Up @@ -248,7 +257,7 @@ public async Task<PresignedUploadUrl> GenerateUploadUrlAsync(
Protocol = ResolvePresignProtocol()
};

var url = await _s3.GetPreSignedURLAsync(request).ConfigureAwait(false);
var url = await _presignS3.GetPreSignedURLAsync(request).ConfigureAwait(false);

var requiredHeaders = new Dictionary<string, string>(StringComparer.Ordinal)
{
Expand Down Expand Up @@ -288,7 +297,7 @@ public async Task<Uri> 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);
}

Expand Down Expand Up @@ -341,11 +350,12 @@ public async Task<Uri> 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;
Expand Down
Loading
Loading