Compare commits

..

3 Commits

Author SHA1 Message Date
Aaron Po
ade2128a73 Switch Argon2 hashes to PHC string format (#256) 2026-08-30 03:53:54 -04:00
Aaron Po
e5b354e322 Update outdated documentation (#255) 2026-08-29 23:50:05 -04:00
Aaron Po
be672b6ca9 Integrate SeaweedFS for S3-compatible file storage and photo upload (#254) 2026-08-29 23:37:11 -04:00
34 changed files with 984 additions and 84 deletions

View File

@@ -50,6 +50,7 @@ Active areas in the repository:
- React 19 website (React Router 7 + Vite) - React 19 website (React Router 7 + Vite)
- Shared Biergarten theme system + Storybook coverage - Shared Biergarten theme system + Storybook coverage
- Auth flows and account/email integration (local Mailpit in dev compose) - Auth flows and account/email integration (local Mailpit in dev compose)
- Photo upload to S3-compatible object storage (local SeaweedFS in dev compose)
- Data generation pipeline with C++ and Llama.cpp - Data generation pipeline with C++ and Llama.cpp
Archived/reference areas: Archived/reference areas:
@@ -98,6 +99,7 @@ Backend access:
- API Swagger: http://localhost:8080/swagger - API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health - Health Check: http://localhost:8080/health
- Mailpit UI (dev SMTP): http://localhost:8025 - Mailpit UI (dev SMTP): http://localhost:8025
- SeaweedFS S3 API (dev object storage): http://localhost:8333
### Frontend (Node) ### Frontend (Node)

View File

@@ -35,10 +35,11 @@ For visual representations, see:
The backend organizes business capabilities as feature slices instead of The backend organizes business capabilities as feature slices instead of
technical layers. Each feature (`Features.Auth`, `Features.Breweries`, technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
`Features.UserManagement`, `Features.Emails`, `Features.Locations`) is a single `Features.UserManagement`, `Features.Emails`, `Features.Locations`,
project that owns its own MediatR commands/queries/handlers, validators, and `Features.PhotoUpload`) is a single project that owns its own MediatR
repository end to end. `Features.Emails` and `Features.Locations` have no commands/queries/handlers, validators, and repository end to end.
`Controllers/` folder — both are invoked internally by other slices via `Features.Emails`, `Features.Locations`, and `Features.PhotoUpload` have no
`Controllers/` folder — all three are invoked internally by other slices via
MediatR/DI, never over HTTP: MediatR/DI, never over HTTP:
``` ```
@@ -48,15 +49,15 @@ MediatR/DI, never over HTTP:
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │ │ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
└───────────────────────────────────────────────────────────────────────┘ └───────────────────────────────────────────────────────────────────────┘
↓ discovers controllers via AddApplicationPart ↓ discovers controllers via AddApplicationPart
┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┐ ┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┬──────────────────┐
│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │ │ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │ Features. │
│ │Breweries │ UserManagement │ (no controller, │ Locations │ │ │Breweries │ UserManagement │ (no controller, │ Locations │ PhotoUpload │
│ Controller │ Controller │ Controller │ internal only) │ (no controller, │ │ Controller │ Controller │ Controller │ internal only) │ (no controller, │ (no controller, │
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │ │ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │ internal only) │
│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │ │ Queries + │ Queries + │ Queries + │ Handlers │ Repository │ Commands/ │
│ Handlers │ Handlers │ Handlers │ │ │ │ Handlers │ Handlers │ Handlers │ │ │ Handlers │
│ Repository │ Repository │ Repository │ EmailDispatcher │ │ │ Repository │ Repository │ Repository │ EmailDispatcher │ │ Repository │
└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘ └───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┴──────────────────┘
↓ each slice depends only on shared/domain/infra, never on another slice ↓ each slice depends only on shared/domain/infra, never on another slice
┌─────────────────────────┬─────────────────────────┬────────────────────┐ ┌─────────────────────────┬─────────────────────────┬────────────────────┐
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │ │ Shared.Contracts │ Shared.Application │ Domain.Entities / │
@@ -109,7 +110,7 @@ own
- No controllers, no business logic, no feature-specific contracts - No controllers, no business logic, no feature-specific contracts
- Exists purely to host and wire up the feature slices - Exists purely to host and wire up the feature slices
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`) #### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`, `Features.PhotoUpload`)
**Purpose**: Each slice is the complete vertical for one business capability **Purpose**: Each slice is the complete vertical for one business capability
@@ -132,7 +133,12 @@ no `Controllers/` folder and no MediatR handlers — it exposes
`ILocationRepository` directly as a plain service, currently consumed only by `ILocationRepository` directly as a plain service, currently consumed only by
`Database.Seed` to look up cities while seeding brewery locations. Other slices `Database.Seed` to look up cities while seeding brewery locations. Other slices
(for example, `Features.Breweries`) do not yet depend on it; they run their own (for example, `Features.Breweries`) do not yet depend on it; they run their own
`CityID` existence checks inline. `CityID` existence checks inline. `Features.PhotoUpload` also has no
`Controllers/` folder — its `UploadPhotoCommand` is sent by other features'
handlers (for example, a future brewery photo upload command) rather than
bound to an HTTP route directly. The one exception today is the `/upload-test`
minimal API endpoint wired directly in `API.Core/Program.cs`, a temporary route
for exercising the upload path end to end.
**Dependencies**: **Dependencies**:
@@ -140,7 +146,8 @@ no `Controllers/` folder and no MediatR handlers — it exposes
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure - `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure
project the slice needs (`Infrastructure.Jwt`/ project the slice needs (`Infrastructure.Jwt`/
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/ `Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
`Infrastructure.Email.Templates` for Emails) `Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload)
- `Shared.Contracts`, `Shared.Application` - `Shared.Contracts`, `Shared.Application`
- **Never** another `Features.*` project - **Never** another `Features.*` project
@@ -184,11 +191,16 @@ whichever slices need them
- **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit) - **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit)
- **Infrastructure.Email.Templates**: Email template rendering (Razor - **Infrastructure.Email.Templates**: Email template rendering (Razor
components) components)
- **Infrastructure.FileUpload**: `IFileStorageProvider` abstraction over
S3-compatible object storage, implemented by `S3FileStorageProvider`
(targets the SeaweedFS container in dev; any S3-compatible endpoint in
other environments) using the AWS SDK's `AmazonS3Client` with
`ForcePathStyle = true`
**Dependencies**: **Dependencies**:
- Domain entities - Domain entities
- External libraries (ADO.NET, JWT, Argon2, MailKit, and so on) - External libraries (ADO.NET, JWT, Argon2, MailKit, AWSSDK.S3, and so on)
**Rules**: **Rules**:
@@ -212,6 +224,8 @@ whichever slices need them
- `BeerStyle` - A beer style/category (schema table exists; no repository yet) - `BeerStyle` - A beer style/category (schema table exists; no repository yet)
- `BeerPost` - A beer listing tied to a brewery and style (schema table - `BeerPost` - A beer listing tied to a brewery and style (schema table
exists; no repository yet) exists; no repository yet)
- `Photo` - A photo uploaded by a user account, written via
`Features.PhotoUpload`'s `IPhotoUploadRepository`
**Dependencies**: **Dependencies**:
@@ -266,8 +280,14 @@ only:
- `Features.Auth/Repository/IAuthRepository.cs` - `Features.Auth/Repository/IAuthRepository.cs`
- `Features.Breweries/Repository/IBreweryRepository.cs` - `Features.Breweries/Repository/IBreweryRepository.cs`
- `Features.UserManagement/Repository/IUserAccountRepository.cs` - `Features.UserManagement/Repository/IUserAccountRepository.cs`
- `Features.PhotoUpload/Repository/IPhotoUploadRepository.cs`
- `Database.Connection/DefaultSqlConnectionFactory.cs`: the generic connection - `Database.Connection/DefaultSqlConnectionFactory.cs`: the generic connection
factory every slice's repository builds on factory every slice's repository builds on
- `Database.Connection/DapperRepository.cs`: shared abstract base class every
repository extends, providing `CreateConnection()` and a
`RollbackQuietlyAsync()` helper that rolls back a transaction while
swallowing any exception the rollback itself raises, so the exception that
triggered the rollback is what propagates
**Benefits**: **Benefits**:
@@ -524,9 +544,11 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
- User accounts (via `Features.Auth`'s repository) - User accounts (via `Features.Auth`'s repository)
- Brewery posts with locations (via `Features.Breweries`' repository) - Brewery posts with locations (via `Features.Breweries`' repository)
Tables that exist in the schema but have no repository yet (`Photo`, `Photo` rows are written via `Features.PhotoUpload`'s `IPhotoUploadRepository`,
`UserAvatar`, `UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, but `Database.Seed` doesn't call it, so photos aren't part of the seed data.
`BeerPostComment`, `BreweryPostPhoto`) are not seeded. Tables that exist in the schema but have no repository yet (`UserAvatar`,
`UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`,
`BreweryPostPhoto`) are not seeded either.
## Deployment architecture ## Deployment architecture
@@ -539,6 +561,7 @@ Tables that exist in the schema but have no repository yet (`Photo`,
- `database.seed` - Data seeder - `database.seed` - Data seeder
- `api.core` - ASP.NET Core Web API - `api.core` - ASP.NET Core Web API
- `mailpit` - Local dev/test SMTP server + web UI (not used in production) - `mailpit` - Local dev/test SMTP server + web UI (not used in production)
- `seaweedfs` - S3-compatible object storage for uploaded photos (dev only)
**Environments**: **Environments**:
@@ -573,9 +596,9 @@ healthcheck:
│ Tests │ │ Tests │
├──────────────┤ ├──────────────┤
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests, │ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests │ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests,
│ handler │ (commands/queries/handlers, with repository/service │ handler │ Features.PhotoUpload.Tests (commands/queries/handlers,
│ tests) │ dependencies mocked via Moq) │ tests) │ with repository/service dependencies mocked via Moq)
└──────────────┘ └──────────────┘
``` ```

View File

@@ -33,6 +33,7 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` | | `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins | | `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins | | `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` |
### Tables defined in the schema but not yet wired to a feature slice ### Tables defined in the schema but not yet wired to a feature slice
@@ -41,7 +42,6 @@ repository or seed data yet:
| Table | Purpose (from schema) | | Table | Purpose (from schema) |
| ------------------ | -------------------------------------------------------------------------------------------------------- | | ------------------ | -------------------------------------------------------------------------------------------------------- |
| `Photo` | A photo uploaded by a user account |
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar | | `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) | | `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet | | `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
@@ -66,8 +66,9 @@ an already-applied script means it won't re-run against existing databases.
`Database.Seed` populates a target database from data produced by the C++ `Database.Seed` populates a target database from data produced by the C++
pipeline (see [Pipeline README](../pipeline/README.md)): countries, pipeline (see [Pipeline README](../pipeline/README.md)): countries,
state/provinces, and cities (created on demand while resolving each brewery's state/provinces, and cities (created on demand while resolving each brewery's
location), user accounts, and brewery posts with locations. It only seeds location), user accounts, and brewery posts with locations. It doesn't call
tables that have a feature slice to write through (see the table above). every feature slice that has a write path — `Photo`, for example, has a
repository (`Features.PhotoUpload`) but no seed data.
## Database error handling ## Database error handling

View File

@@ -29,6 +29,7 @@ representation.
- Swagger UI enabled - Swagger UI enabled
- Seed data included - Seed data included
- Local Mailpit SMTP server for dev email testing - Local Mailpit SMTP server for dev email testing
- Local SeaweedFS S3-compatible object storage for photo uploads
- `CLEAR_DATABASE=true` (drops and recreates schema) - `CLEAR_DATABASE=true` (drops and recreates schema)
**Services**: **Services**:
@@ -39,6 +40,7 @@ database.migrations # DbUp migrations
database.seed # Seed initial data database.seed # Seed initial data
api.core # Web API (ports 8080, 8081) api.core # Web API (ports 8080, 8081)
mailpit # Local dev SMTP server + UI (port 8025) mailpit # Local dev SMTP server + UI (port 8025)
seaweedfs # S3-compatible object storage (ports 8333, 8888, 9333, 23646)
``` ```
**Start Development Environment**: **Start Development Environment**:
@@ -53,6 +55,10 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
- Health Check: http://localhost:8080/health - Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025 - Mailpit UI: http://localhost:8025
- SQL Server: localhost:1434 (sa credentials from .env.dev) - SQL Server: localhost:1434 (sa credentials from .env.dev)
- SeaweedFS S3 API: http://localhost:8333 (credentials from
`SEAWEEDFS_ACCESS_KEY_ID`/`SEAWEEDFS_SECRET_ACCESS_KEY` in .env.dev)
- SeaweedFS Filer UI: http://localhost:8888
- SeaweedFS Master UI: http://localhost:9333
**Stop Environment**: **Stop Environment**:
@@ -182,8 +188,12 @@ graph TD
A[sqlserver: health check passes] --> B[database.migrations: completes successfully] A[sqlserver: health check passes] --> B[database.migrations: completes successfully]
B --> C[database.seed: completes successfully] B --> C[database.seed: completes successfully]
C --> D[api.core / tests: start when ready] C --> D[api.core / tests: start when ready]
E[seaweedfs: container started] --> D
``` ```
In development, `api.core` also depends on `seaweedfs` (`condition:
service_started`, not a health check) alongside `database.seed`.
**Health Check Example** (SQL Server): **Health Check Example** (SQL Server):
```yaml ```yaml
@@ -216,6 +226,8 @@ api.core:
- `sqlserverdata-dev` - Database files persist between restarts - `sqlserverdata-dev` - Database files persist between restarts
- `nuget-cache-dev` - NuGet package cache (speeds up builds) - `nuget-cache-dev` - NuGet package cache (speeds up builds)
- `seaweedfsdata-dev` - SeaweedFS object storage data persists between
restarts
**Testing**: **Testing**:
@@ -262,6 +274,10 @@ environment:
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
``` ```
For complete list, see [Environment Variables](environment-variables.md). For complete list, see [Environment Variables](environment-variables.md).

View File

@@ -35,8 +35,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
`web/generate-env.sh` copies `.env.example` to a target file and replaces the `web/generate-env.sh` copies `.env.example` to a target file and replaces the
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`, secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SMTP_USERNAME`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SMTP_USERNAME`,
`SMTP_PASSWORD`) with freshly randomized ones via `openssl`. Non-secret values `SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`, `SEAWEEDFS_SECRET_ACCESS_KEY`)
are left untouched. The output file is written with `600` permissions. with freshly randomized ones via `openssl`. Non-secret values are left
untouched. The output file is written with `600` permissions.
```bash ```bash
cd web cd web
@@ -221,6 +222,25 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and - **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and
so on) so on)
### File storage configuration (backend)
Read by `Infrastructure.FileUpload/S3FileStorageProvider.cs` for uploading and
retrieving photos via an S3-compatible object store.
```bash
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default
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
- **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
## Docker-specific variables ## Docker-specific variables
### SQL Server container ### SQL Server container
@@ -288,6 +308,10 @@ reference only and is not run as part of the active stack.
| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` | | `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` |
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address | | `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` | | `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL |
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL | | `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing | | `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
| `NODE_ENV` | | ✓ | | No | Runtime mode | | `NODE_ENV` | | ✓ | | No | Runtime mode |
@@ -309,8 +333,10 @@ reference only and is not run as part of the active stack.
Variables are validated at startup: Variables are validated at startup:
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, - Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection `CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`,
values) cause the application to fail with an `InvalidOperationException` `SEAWEEDFS_SERVICE_URL`, `SEAWEEDFS_ACCESS_KEY_ID`,
`SEAWEEDFS_SECRET_ACCESS_KEY`, `SEAWEEDFS_BUCKET`, DB connection values)
cause the application to fail with an `InvalidOperationException`
- No minimum length is enforced on the token secrets in code; the "minimum 32 - No minimum length is enforced on the token secrets in code; the "minimum 32
characters" guidance above is a recommendation, not an enforced check characters" guidance above is a recommendation, not an enforced check
@@ -347,6 +373,12 @@ SMTP_USE_SSL=false
SMTP_FROM_EMAIL=noreply@thebiergarten.app SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten App SMTP_FROM_NAME=The Biergarten App
# SeaweedFS (S3-compatible storage)
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl>
SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl>
SEAWEEDFS_BUCKET=my-bucket
# Migration # Migration
CLEAR_DATABASE=true CLEAR_DATABASE=true

View File

@@ -58,14 +58,16 @@ section for details on `generate-env.sh`.
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
``` ```
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev This starts SQL Server, migrations, seeding, the API, Mailpit (local dev
SMTP). SMTP), and SeaweedFS (local dev S3-compatible object storage for photo
uploads).
Available endpoints: Available endpoints:
- API Swagger: http://localhost:8080/swagger - API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health - Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025 - Mailpit UI: http://localhost:8025
- SeaweedFS S3 API: http://localhost:8333
### 4. Start the active frontend ### 4. Start the active frontend
@@ -124,6 +126,10 @@ export ACCESS_TOKEN_SECRET="<generated>"
export REFRESH_TOKEN_SECRET="<generated>" export REFRESH_TOKEN_SECRET="<generated>"
export CONFIRMATION_TOKEN_SECRET="<generated>" export CONFIRMATION_TOKEN_SECRET="<generated>"
export WEBSITE_BASE_URL="http://localhost:3000" export WEBSITE_BASE_URL="http://localhost:3000"
export SEAWEEDFS_SERVICE_URL="http://localhost:8333"
export SEAWEEDFS_ACCESS_KEY_ID="<generated>"
export SEAWEEDFS_SECRET_ACCESS_KEY="<generated>"
export SEAWEEDFS_BUCKET="my-bucket"
``` ```
### 2. Run migrations and seed ### 2. Run migrations and seed

View File

@@ -11,9 +11,9 @@ The project uses a multi-layered testing approach across backend and frontend:
live, seeded database live, seeded database
- **Features.\*.Tests** - One unit test project per backend feature slice - **Features.\*.Tests** - One unit test project per backend feature slice
(`Features.Auth.Tests`, `Features.Breweries.Tests`, (`Features.Auth.Tests`, `Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that `Features.UserManagement.Tests`, `Features.Emails.Tests`,
slice's command/query handlers, with dependencies mocked via Moq (no real `Features.PhotoUpload.Tests`), covering that slice's command/query
database required) handlers, with dependencies mocked via Moq (no real database required)
- **Storybook Vitest project** - Browser-based interaction tests for shared - **Storybook Vitest project** - Browser-based interaction tests for shared
website stories website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered - **Storybook Playwright suite** - Browser checks against Storybook-rendered
@@ -49,6 +49,7 @@ cat test-results/Features.Auth.Tests.trx
cat test-results/Features.Breweries.Tests.trx cat test-results/Features.Breweries.Tests.trx
cat test-results/Features.UserManagement.Tests.trx cat test-results/Features.UserManagement.Tests.trx
cat test-results/Features.Emails.Tests.trx cat test-results/Features.Emails.Tests.trx
cat test-results/Features.PhotoUpload.Tests.trx
``` ```
### Clean up ### Clean up
@@ -86,6 +87,7 @@ dotnet test Features/Features.Auth.Tests/Features.Auth.Tests.csproj
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
dotnet test Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj dotnet test Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
# Or run all of them at once via the solution: # Or run all of them at once via the solution:
for proj in Features/*.Tests; do dotnet test "$proj"; done for proj in Features/*.Tests; do dotnet test "$proj"; done
@@ -175,6 +177,14 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
- Registration and resend-confirmation email dispatch handlers - Registration and resend-confirmation email dispatch handlers
**Features.PhotoUpload.Tests**:
- Upload command handler: storage key construction, content type passthrough,
and photo persistence via the mocked storage provider and repository
- Upload validator: min/max file size bounds, and PNG/JPEG/WebP signature
checks (including rejection of files with a mismatched or missing
signature)
**Frontend UI Coverage**: **Frontend UI Coverage**:
- Shared submit button states - Shared submit button states
@@ -188,7 +198,6 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
- [ ] Password reset functionality - [ ] Password reset functionality
- [ ] Beer post operations - [ ] Beer post operations
- [ ] User follow/unfollow - [ ] User follow/unfollow
- [ ] Image upload service
- [ ] Frontend route integration coverage beyond Storybook stories - [ ] Frontend route integration coverage beyond Storybook stories
## Testing frameworks and tools ## Testing frameworks and tools
@@ -276,9 +285,11 @@ Features.Auth.Tests/
└── TokenServiceValidationTests.cs └── TokenServiceValidationTests.cs
``` ```
Each of the other three slices (`Features.Breweries.Tests`, Each of the other slices (`Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same `Features.UserManagement.Tests`, `Features.Emails.Tests`,
shape: a `Commands/`/`Queries/` folder with one test file per handler. `Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/`
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds
a validator test file alongside its handler test).
## Writing tests ## Writing tests

View File

@@ -56,3 +56,12 @@ SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten App SMTP_FROM_NAME=The Biergarten App
WEBSITE_BASE_URL=http://localhost:3000 WEBSITE_BASE_URL=http://localhost:3000
# ======================
# SeaweedFS (S3) Configuration
# ======================
# Credentials and default bucket for the local SeaweedFS S3-compatible storage
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_ACCESS_KEY_ID=admin
SEAWEEDFS_SECRET_ACCESS_KEY=secret
SEAWEEDFS_BUCKET=my-bucket

View File

@@ -20,10 +20,12 @@
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" /> <ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" /> <ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" /> <ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -30,11 +30,14 @@ COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emai
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"] COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"]
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj", "Infrastructure/Infrastructure.FileUpload/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]

View File

@@ -7,13 +7,19 @@ using Features.Breweries.Controllers;
using Features.Breweries.DependencyInjection; using Features.Breweries.DependencyInjection;
using Features.Emails.DependencyInjection; using Features.Emails.DependencyInjection;
using Features.Emails.Services; using Features.Emails.Services;
using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection;
using Features.UserManagement.Controllers; using Features.UserManagement.Controllers;
using Features.UserManagement.DependencyInjection; using Features.UserManagement.DependencyInjection;
using FluentValidation; using FluentValidation;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using Infrastructure.FileUpload;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using MediatR;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Shared.Application.Behaviors; using Shared.Application.Behaviors;
using System.Security.Claims;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -65,6 +71,7 @@ builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly);
builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddFluentValidationAutoValidation();
// Add MediatR. // Add MediatR.
@@ -75,6 +82,7 @@ builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly); cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly); cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly); cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
}); });
@@ -93,10 +101,13 @@ builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement(); builder.Services.AddFeaturesUserManagement();
builder.Services.AddFeaturesAuth(); builder.Services.AddFeaturesAuth();
builder.Services.AddFeaturesEmails(); builder.Services.AddFeaturesEmails();
builder.Services.AddFeaturesPhotoUpload();
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly. // ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>(); builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddSingleton<IFileStorageProvider, S3FileStorageProvider>();
builder.Services.AddScoped<GlobalExceptionFilter>(); builder.Services.AddScoped<GlobalExceptionFilter>();
builder builder
@@ -115,11 +126,24 @@ app.UseHttpsRedirection();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// Health check endpoint (used by Docker health checks and orchestrators)
app.MapHealthChecks("/health"); app.MapHealthChecks("/health");
app.MapControllers(); app.MapControllers();
app.MapPost(
"/upload-test",
async (IFormFile file, ClaimsPrincipal user, IMediator mediator) =>
{
Guid uploadedById = Guid.Parse(user.FindFirst(JwtRegisteredClaimNames.Sub)!.Value);
Guid photoId = await mediator.Send(
new UploadPhotoCommand(uploadedById, "upload-test", file)
);
return Results.Ok(photoId);
}
)
.RequireAuthorization()
.DisableAntiforgery();
app.MapFallbackToController("Handle404", "NotFound"); app.MapFallbackToController("Handle404", "NotFound");
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>(); IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();

View File

@@ -20,11 +20,14 @@ COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emai
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"] COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"]
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj", "Infrastructure/Infrastructure.FileUpload/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"] COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]

View File

@@ -16,6 +16,7 @@
<Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" /> <Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" />
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" /> <Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" /> <Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
<Project Path="Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj" />
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" /> <Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" /> <Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
</Folder> </Folder>
@@ -30,6 +31,8 @@
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" /> <Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
<Project Path="Features/Features.Locations/Features.Locations.csproj" /> <Project Path="Features/Features.Locations/Features.Locations.csproj" />
<Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" /> <Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" />
<Project Path="Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<Project Path="Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/Shared/"> <Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" /> <Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />

View File

@@ -18,4 +18,21 @@ public abstract class DapperRepository(ISqlConnectionFactory connectionFactory)
await connection.OpenAsync(); await connection.OpenAsync();
return connection; return connection;
} }
/// <summary>
/// Rolls back <paramref name="transaction" />, swallowing any exception the rollback itself
/// raises (for example when the provider has already completed the transaction after a
/// connection failure) so the exception that triggered the rollback is what propagates.
/// </summary>
protected static async Task RollbackQuietlyAsync(DbTransaction transaction)
{
try
{
await transaction.RollbackAsync();
}
catch
{
// Ignore: the original exception (rethrown by the caller) is what matters here.
}
}
} }

View File

@@ -0,0 +1,17 @@
namespace Domain.Entities;
public sealed class Photo
{
// PK
public Guid PhotoId { get; set; }
// FK References
public Guid UploadedById { get; set; }
// Attributes
public string? Hyperlink { get; set; }
// Audit Fields
public DateTime UploadedAt { get; set; }
public byte[]? RowVersion { get; set; }
}

View File

@@ -106,16 +106,16 @@ public class TokenService : ITokenService
return new RefreshTokenResult(user.Id, user.UserName, newRefresh, newAccess); return new RefreshTokenResult(user.Id, user.UserName, newRefresh, newAccess);
} }
/// <summary> /// <summary>
/// Performs the shared validation logic for access, refresh, and confirmation tokens: /// Performs the shared validation logic for access, refresh, and confirmation tokens:
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims. /// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
/// </summary> /// </summary>
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param> /// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
/// <exception cref="UnauthorizedException"> /// <exception cref="UnauthorizedException">
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />, /// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature). /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
/// </exception> /// </exception>
private async Task<ValidatedToken> ValidateTokenInternalAsync( private async Task<ValidatedToken> ValidateTokenInternalAsync(
string token, string token,
string secret, string secret,
string tokenType string tokenType

View File

@@ -537,22 +537,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
} }
} }
/// <summary>
/// Rolls back <paramref name="transaction" />, swallowing any exception the rollback itself
/// raises (for example when the provider has already completed the transaction after a
/// connection failure) so the exception that triggered the rollback is what propagates.
/// </summary>
private static async Task RollbackQuietlyAsync(DbTransaction transaction)
{
try
{
await transaction.RollbackAsync();
}
catch
{
// Ignore: the original exception (rethrown by the caller) is what matters here.
}
}
/// <summary> /// <summary>
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />, /// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,

View File

@@ -0,0 +1,120 @@
using Domain.Entities;
using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.Repository;
using FluentAssertions;
using Infrastructure.FileUpload;
using Microsoft.AspNetCore.Http;
using Moq;
namespace Features.PhotoUpload.Tests.Commands;
public class UploadPhotoHandlerTests
{
private readonly Mock<IFileStorageProvider> _fileStorageProviderMock = new();
private readonly UploadPhotoHandler _handler;
private readonly Mock<IPhotoUploadRepository> _repoMock = new();
public UploadPhotoHandlerTests()
{
_handler = new UploadPhotoHandler(_fileStorageProviderMock.Object, _repoMock.Object);
}
private static Mock<IFormFile> ValidFile(
string content = "fake-image-bytes",
string contentType = "image/png"
)
{
Mock<IFormFile> file = new();
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(content);
file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
file.Setup(f => f.ContentType).Returns(contentType);
file.Setup(f => f.Length).Returns(bytes.Length);
return file;
}
[Fact]
public async Task Handle_UploadsFileUnderRequestedKey_AndPersistsPhoto_ReturnsNewPhotoId()
{
Mock<IFormFile> file = ValidFile();
UploadPhotoCommand command = new(Guid.NewGuid(), "breweries/some-brewery-id", file.Object);
string? uploadedKey = null;
string? uploadedContentType = null;
_fileStorageProviderMock
.Setup(p =>
p.UploadAsync(
It.IsAny<string>(),
It.IsAny<Stream>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()
)
)
.Callback<string, Stream, string, CancellationToken>(
(key, _, contentType, _) =>
{
uploadedKey = key;
uploadedContentType = contentType;
}
)
.Returns(Task.CompletedTask);
Photo? persisted = null;
_repoMock
.Setup(r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()))
.Callback<Photo, CancellationToken>((photo, _) => persisted = photo)
.Returns(Task.CompletedTask);
Guid photoId = await _handler.Handle(command, CancellationToken.None);
photoId.Should().NotBe(Guid.Empty);
uploadedKey.Should().Be($"breweries/some-brewery-id/{photoId}");
uploadedContentType.Should().Be("image/png");
persisted.Should().NotBeNull();
persisted!.Hyperlink.Should().Be(uploadedKey);
persisted.UploadedById.Should().Be(command.UploadedById);
_fileStorageProviderMock.Verify(
p =>
p.UploadAsync(
uploadedKey!,
It.IsAny<Stream>(),
"image/png",
It.IsAny<CancellationToken>()
),
Times.Once
);
_repoMock.Verify(
r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()),
Times.Once
);
}
[Fact]
public async Task Handle_UploadOrderedBeforePersist_DoesNotPersistWhenUploadFails()
{
Mock<IFormFile> file = ValidFile();
UploadPhotoCommand command = new(Guid.NewGuid(), "avatars", file.Object);
_fileStorageProviderMock
.Setup(p =>
p.UploadAsync(
It.IsAny<string>(),
It.IsAny<Stream>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()
)
)
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
_repoMock.Verify(
r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()),
Times.Never
);
}
}

View File

@@ -0,0 +1,154 @@
using Features.ImageUploads.Commands.UploadPhoto;
using FluentAssertions;
using FluentValidation.Results;
using Microsoft.AspNetCore.Http;
using Moq;
namespace Features.PhotoUpload.Tests.Commands;
public class UploadPhotoValidatorTests
{
private static readonly byte[] PngSignature =
[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A
];
private static readonly byte[] JpegSignature =
[
0xFF, 0xD8, 0xFF
];
private static readonly byte[] WebPSignature =
[
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50 // "RIFF____WEBP"
];
private static readonly byte[] GifSignature =
[
0x47, 0x49, 0x46, 0x38, 0x39, 0x61 // "GIF89a"
];
private static readonly byte[] BmpSignature =
[
0x42, 0x4D // "BM"
];
private static readonly byte[] PdfSignature =
[
0x25, 0x50, 0x44, 0x46 // "%PDF"
];
private static readonly byte[] PlainTextBytes = "not an image"u8.ToArray();
private const long MinSizeBytes = 1024; // 1 KiB
private const long MaxSizeBytes = 24 * 1024 * 1024; // 24 MiB
private readonly UploadPhotoValidator _validator = new();
private static Mock<IFormFile> FileOfSize(long sizeInBytes)
{
byte[] bytes = new byte[Math.Max(sizeInBytes, PngSignature.Length)];
PngSignature.CopyTo(bytes, 0);
Mock<IFormFile> file = new();
file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
file.Setup(f => f.ContentType).Returns("image/png");
file.Setup(f => f.Length).Returns(sizeInBytes);
return file;
}
private static Mock<IFormFile> FileWithHeader(byte[] header, long sizeInBytes = 0)
{
byte[] bytes = new byte[Math.Max(sizeInBytes, Math.Max(header.Length, MinSizeBytes))];
header.CopyTo(bytes, 0);
Mock<IFormFile> file = new();
file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
file.Setup(f => f.ContentType).Returns("image/png");
file.Setup(f => f.Length).Returns(bytes.Length);
return file;
}
private static UploadPhotoCommand CommandWithFile(IFormFile file) =>
new(Guid.NewGuid(), "breweries/some-brewery-id", file);
public static TheoryData<string, byte[]> ValidSignatures => new()
{
{ "PNG", PngSignature },
{ "JPEG", JpegSignature },
{ "WebP", WebPSignature }
};
public static TheoryData<string, byte[]> InvalidSignatures => new()
{
{ "GIF", GifSignature },
{ "BMP", BmpSignature },
{ "PDF", PdfSignature },
{ "plain text", PlainTextBytes }
};
[Fact]
public async Task Validate_FileBelowMinSize_FailsWithSizeError()
{
Mock<IFormFile> file = FileOfSize(MinSizeBytes - 1);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "File.Length");
}
[Fact]
public async Task Validate_FileAtMinSize_PassesSizeCheck()
{
Mock<IFormFile> file = FileOfSize(MinSizeBytes);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.Errors.Should().NotContain(e => e.PropertyName == "File.Length");
}
[Fact]
public async Task Validate_FileAtMaxSize_PassesSizeCheck()
{
Mock<IFormFile> file = FileOfSize(MaxSizeBytes);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.Errors.Should().NotContain(e => e.PropertyName == "File.Length");
}
[Fact]
public async Task Validate_FileAboveMaxSize_FailsWithSizeError()
{
Mock<IFormFile> file = FileOfSize(MaxSizeBytes + 1);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "File.Length");
}
[Theory]
[MemberData(nameof(ValidSignatures))]
public async Task Validate_ValidImageSignature_PassesSignatureCheck(string formatName, byte[] header)
{
Mock<IFormFile> file = FileWithHeader(header);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.Errors.Should().NotContain(e => e.PropertyName == "File", formatName);
}
[Theory]
[MemberData(nameof(InvalidSignatures))]
public async Task Validate_InvalidImageSignature_FailsWithSignatureError(string formatName, byte[] header)
{
Mock<IFormFile> file = FileWithHeader(header);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.IsValid.Should().BeFalse(formatName);
result.Errors.Should().Contain(e => e.PropertyName == "File", formatName);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Features.PhotoUpload.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Features.PhotoUpload\Features.PhotoUpload.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
using MediatR;
using Microsoft.AspNetCore.Http;
namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>
/// Uploads a new photo to storage under <c>{Key}/{generated photo id}</c> and records it. Sent by
/// other features' handlers (e.g. a brewery photo upload command) rather than bound from a request
/// directly, since this feature exposes no controller of its own.
/// </summary>
public record UploadPhotoCommand(Guid UploadedById, string Key, IFormFile File) : IRequest<Guid>;

View File

@@ -0,0 +1,34 @@
using Domain.Entities;
using Features.ImageUploads.Repository;
using Infrastructure.FileUpload;
using MediatR;
namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>Handles <see cref="UploadPhotoCommand" />.</summary>
public class UploadPhotoHandler(
IFileStorageProvider fileStorageProvider,
IPhotoUploadRepository photoUploadRepository
) : IRequestHandler<UploadPhotoCommand, Guid>
{
public async Task<Guid> Handle(UploadPhotoCommand request, CancellationToken cancellationToken)
{
Guid photoId = Guid.NewGuid();
string key = $"{request.Key}/{photoId}";
await using Stream stream = request.File.OpenReadStream();
await fileStorageProvider.UploadAsync(
key,
stream,
request.File.ContentType,
cancellationToken
);
Photo photo = new() { Hyperlink = key, UploadedById = request.UploadedById };
await photoUploadRepository.CreateAsync(photo, cancellationToken);
return photoId;
}
}

View File

@@ -0,0 +1,82 @@
using FluentValidation;
using Microsoft.AspNetCore.Http;
namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>Validates <see cref="UploadPhotoCommand" />.</summary>
public class UploadPhotoValidator : AbstractValidator<UploadPhotoCommand>
{
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private static readonly byte[] JpegSignature = [0xFF, 0xD8, 0xFF];
private static readonly byte[] RiffSignature =
[
0x52,
0x49,
0x46,
0x46, // "RIFF"
];
private static readonly byte[] WebPMarker =
[
0x57,
0x45,
0x42,
0x50, // "WEBP"
];
private const long MinSizeBytes = 1024; // 1 KiB
private const long MaxSizeBytes = 24 * 1024 * 1024; // 24 MiB
public UploadPhotoValidator()
{
RuleFor(x => x.UploadedById).NotEmpty().WithMessage("UploadedById is required.");
RuleFor(x => x.File).NotNull().WithMessage("File is required.");
RuleFor(x => x.Key).NotEmpty().WithMessage("Key is required.");
RuleFor(x => x.File.ContentType)
.Must(contentType =>
contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
)
.WithMessage("File must be an image.");
RuleFor(x => x.File.Length)
.InclusiveBetween(MinSizeBytes, MaxSizeBytes)
.WithMessage("The file must be between 1 KiB and 24 MiB.");
RuleFor(x => x.File)
.MustAsync(HaveValidImageSignatureAsync)
.WithMessage("The file is not a valid PNG, JPEG, or WebP image.");
}
private static async Task<bool> HaveValidImageSignatureAsync(
IFormFile file,
CancellationToken cancellationToken
)
{
var buffer = new byte[12];
await using var stream = file.OpenReadStream();
var bytesRead = await stream.ReadAsync(
buffer.AsMemory(0, buffer.Length),
cancellationToken
);
return IsPng(buffer, bytesRead) || IsJpeg(buffer, bytesRead) || IsWebP(buffer, bytesRead);
}
private static bool IsPng(byte[] buffer, int bytesRead) =>
bytesRead >= PngSignature.Length
&& buffer.AsSpan(0, PngSignature.Length).SequenceEqual(PngSignature);
private static bool IsJpeg(byte[] buffer, int bytesRead) =>
bytesRead >= JpegSignature.Length
&& buffer.AsSpan(0, JpegSignature.Length).SequenceEqual(JpegSignature);
private static bool IsWebP(byte[] buffer, int bytesRead) =>
bytesRead >= 12
&& buffer.AsSpan(0, 4).SequenceEqual(RiffSignature)
&& buffer.AsSpan(8, 4).SequenceEqual(WebPMarker);
}

View File

@@ -0,0 +1,16 @@
using Features.ImageUploads.Repository;
using Microsoft.Extensions.DependencyInjection;
namespace Features.ImageUploads.DependencyInjection;
/// <summary>Registers the services required by the PhotoUpload feature.</summary>
public static class FeaturesPhotoUploadServiceCollectionExtensions
{
/// <summary>Registers <see cref="IPhotoUploadRepository" /> and its implementation.</summary>
/// <returns><paramref name="services" />, for chaining.</returns>
public static IServiceCollection AddFeaturesPhotoUpload(this IServiceCollection services)
{
services.AddScoped<IPhotoUploadRepository, PhotoUploadRepository>();
return services;
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Features.ImageUploads</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,63 @@
using System.Data.Common;
using Dapper;
using Database.Connection;
using Domain.Entities;
using Domain.Exceptions;
namespace Features.ImageUploads.Repository;
public interface IPhotoUploadRepository
{
Task CreateAsync(Photo photo, CancellationToken cancellationToken);
}
public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory)
: DapperRepository(connectionFactory),
IPhotoUploadRepository
{
public async Task CreateAsync(Photo photo, CancellationToken cancellationToken)
{
await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync();
try
{
bool userExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1
FROM
dbo.UserAccount
WHERE
UserAccountID = @UploadedById
""",
new { photo.UploadedById },
transaction
)
)
is not null;
if (!userExists)
throw new NotFoundException("User not found.");
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO dbo.Photo (Hyperlink, UploadedByID)
VALUES (@Hyperlink, @UploadedById)
""",
new { photo.Hyperlink, photo.UploadedById },
transaction
)
);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await RollbackQuietlyAsync(transaction);
throw;
}
}
}

View File

@@ -22,6 +22,11 @@ public static class ConfigurationKeys
public const string SmtpFromEmail = "SMTP_FROM_EMAIL"; public const string SmtpFromEmail = "SMTP_FROM_EMAIL";
public const string SmtpFromName = "SMTP_FROM_NAME"; public const string SmtpFromName = "SMTP_FROM_NAME";
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";
public const string DbServer = "DB_SERVER"; public const string DbServer = "DB_SERVER";
public const string DbUser = "DB_USER"; public const string DbUser = "DB_USER";
public const string DbPassword = "DB_PASSWORD"; public const string DbPassword = "DB_PASSWORD";

View File

@@ -0,0 +1,29 @@
namespace Infrastructure.FileUpload;
/// <summary>
/// Service for storing and retrieving uploaded files in S3-compatible object storage.
/// </summary>
public interface IFileStorageProvider
{
/// <summary>Uploads a file to the configured bucket.</summary>
/// <param name="key">The object key (path) to store the file under.</param>
/// <param name="content">The file content.</param>
/// <param name="contentType">The MIME type of the file.</param>
/// <param name="cancellationToken">A token to cancel the upload.</param>
Task UploadAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default
);
/// <summary>Deletes a file from the configured bucket.</summary>
/// <param name="key">The object key of the file to delete.</param>
/// <param name="cancellationToken">A token to cancel the deletion.</param>
Task DeleteAsync(string key, CancellationToken cancellationToken = default);
/// <summary>Generates a time-limited URL for retrieving a file directly from storage.</summary>
/// <param name="key">The object key of the file.</param>
/// <param name="expiresIn">How long the URL remains valid.</param>
string GetPresignedUrl(string key, TimeSpan expiresIn);
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.FileUpload</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.102.4" />
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="10.0.1"
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,96 @@
using Amazon.S3;
using Amazon.S3.Model;
using Infrastructure.Configuration;
using Microsoft.Extensions.Configuration;
namespace Infrastructure.FileUpload;
/// <summary>
/// File storage service backed by an S3-compatible object store (e.g. SeaweedFS, AWS S3).
/// Configured via application configuration (backed by environment variables).
/// </summary>
public class S3FileStorageProvider : IFileStorageProvider, IDisposable
{
private readonly string _bucket;
private readonly IAmazonS3 _client;
/// <summary>
/// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage
/// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>,
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>) from <paramref name="configuration" />.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when any of the required configuration values is not set.
/// </exception>
public S3FileStorageProvider(IConfiguration configuration)
{
string serviceUrl = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsServiceUrl
);
string accessKeyId = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsAccessKeyId
);
string secretAccessKey = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsSecretAccessKey
);
_bucket = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsBucket
);
AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true };
_client = new AmazonS3Client(accessKeyId, secretAccessKey, config);
}
/// <inheritdoc/>
public async Task UploadAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default
)
{
PutObjectRequest request = new()
{
BucketName = _bucket,
Key = key,
InputStream = content,
ContentType = contentType,
};
await _client.PutObjectAsync(request, cancellationToken);
}
/// <inheritdoc/>
public async Task DeleteAsync(string key, CancellationToken cancellationToken = default)
{
DeleteObjectRequest request = new() { BucketName = _bucket, Key = key };
await _client.DeleteObjectAsync(request, cancellationToken);
}
/// <inheritdoc/>
public string GetPresignedUrl(string key, TimeSpan expiresIn)
{
GetPreSignedUrlRequest request = new()
{
BucketName = _bucket,
Key = key,
Expires = DateTime.UtcNow.Add(expiresIn),
};
return _client.GetPreSignedURL(request);
}
/// <inheritdoc/>
public void Dispose()
{
_client.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Konscious.Security.Cryptography; using Konscious.Security.Cryptography;
@@ -13,26 +14,32 @@ public class Argon2Infrastructure : IPasswordInfrastructure
private const int HashSize = 32; // 256-bit private const int HashSize = 32; // 256-bit
private const int ArgonIterations = 4; private const int ArgonIterations = 4;
private const int ArgonMemoryKb = 65536; // 64MB private const int ArgonMemoryKb = 65536; // 64MB
private const int DegreeOfParallelism = 2; // Hardcoded to prevent thread pool exhaustion
/// <inheritdoc />
/// <returns>A string of the form <c>"{base64Salt}:{base64Hash}"</c>, suitable for storage.</returns>
public string Hash(string password) public string Hash(string password)
{ {
byte[] salt = RandomNumberGenerator.GetBytes(SaltSize); byte[] salt = RandomNumberGenerator.GetBytes(SaltSize);
Argon2id argon2 = new(Encoding.UTF8.GetBytes(password))
using Argon2id argon2 = new(Encoding.UTF8.GetBytes(password))
{ {
Salt = salt, Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), DegreeOfParallelism = DegreeOfParallelism,
MemorySize = ArgonMemoryKb, MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations, Iterations = ArgonIterations,
}; };
byte[] hash = argon2.GetBytes(HashSize); byte[] hash = argon2.GetBytes(HashSize);
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
// The PHC string standard requires Base64 without the trailing '=' padding characters
string b64Salt = Convert.ToBase64String(salt).TrimEnd('=');
string b64Hash = Convert.ToBase64String(hash).TrimEnd('=');
// Argon2 version is currently 19 (0x13 in hex)
return $"$argon2id$v=19$m={ArgonMemoryKb},t={ArgonIterations},p={DegreeOfParallelism}${b64Salt}${b64Hash}";
} }
/// <inheritdoc /> /// <inheritdoc />
/// <remarks>Uses a fixed-time comparison to mitigate timing attacks.</remarks> /// <remarks>Uses a fixed-time comparison to mitigate timing attacks. Supports standard PHC string format.</remarks>
/// <returns> /// <returns>
/// <c>false</c> if the password doesn't match, if <paramref name="stored" /> is malformed, or if any /// <c>false</c> if the password doesn't match, if <paramref name="stored" /> is malformed, or if any
/// other error occurs during verification. /// other error occurs during verification.
@@ -41,27 +48,58 @@ public class Argon2Infrastructure : IPasswordInfrastructure
{ {
try try
{ {
string[] parts = stored.Split(':', StringSplitOptions.RemoveEmptyEntries); // Split the PHC format: $argon2id$v=19$m=65536,t=3,p=4$salt$hash
if (parts.Length != 2) string[] parts = stored.Split('$', StringSplitOptions.RemoveEmptyEntries);
// We expect exactly 5 parts when empty entries (the one before the first $) are removed
if (parts.Length != 5 || parts[0] != "argon2id")
return false; return false;
byte[] salt = Convert.FromBase64String(parts[0]); int memory = 0,
byte[] expected = Convert.FromBase64String(parts[1]); iterations = 0,
parallelism = 0;
string[] parameters = parts[2].Split(',');
Argon2id argon2 = new(Encoding.UTF8.GetBytes(password)) foreach (string param in parameters)
{
if (param.StartsWith("m="))
memory = int.Parse(param.Substring(2));
else if (param.StartsWith("t="))
iterations = int.Parse(param.Substring(2));
else if (param.StartsWith("p="))
parallelism = int.Parse(param.Substring(2));
}
byte[] salt = DecodeBase64NoPadding(parts[3]);
byte[] expectedHash = DecodeBase64NoPadding(parts[4]);
// Use the parameters embedded in the stored string, so previously hashed
// passwords remain verifiable after a parameter change.
using Argon2id argon2 = new(Encoding.UTF8.GetBytes(password))
{ {
Salt = salt, Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), DegreeOfParallelism = parallelism,
MemorySize = ArgonMemoryKb, MemorySize = memory,
Iterations = ArgonIterations, Iterations = iterations,
}; };
byte[] actual = argon2.GetBytes(expected.Length); byte[] actualHash = argon2.GetBytes(expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
} }
catch catch
{ {
return false; return false;
} }
} }
/// <summary>
/// .NET's Convert.FromBase64String requires padding. The PHC format drops padding.
/// This helper restores the padding before converting.
/// </summary>
private static byte[] DecodeBase64NoPadding(string base64)
{
string padded = base64.PadRight(base64.Length + (4 - base64.Length % 4) % 4, '=');
return Convert.FromBase64String(padded);
}
} }

View File

@@ -1,4 +1,5 @@
services: services:
sqlserver: sqlserver:
env_file: ".env.dev" env_file: ".env.dev"
image: mcr.microsoft.com/mssql/server:2022-latest image: mcr.microsoft.com/mssql/server:2022-latest
@@ -24,6 +25,7 @@ services:
start_period: 30s start_period: 30s
networks: networks:
- devnet - devnet
database.migrations: database.migrations:
env_file: ".env.dev" env_file: ".env.dev"
image: database.migrations image: database.migrations
@@ -78,6 +80,8 @@ services:
depends_on: depends_on:
database.seed: database.seed:
condition: service_completed_successfully condition: service_completed_successfully
seaweedfs:
condition: service_started
build: build:
context: ./backend context: ./backend
dockerfile: API/API.Core/Dockerfile dockerfile: API/API.Core/Dockerfile
@@ -99,30 +103,55 @@ services:
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: unless-stopped restart: unless-stopped
networks: networks:
- devnet - devnet
volumes: volumes:
- nuget-cache-dev:/root/.nuget/packages - nuget-cache-dev:/root/.nuget/packages
seaweedfs:
env_file: ".env.dev"
image: chrislusf/seaweedfs
container_name: dev-env-seaweedfs
ports:
- "8333:8333" # S3 API
- "8888:8888" # Filer UI
- "9333:9333" # Master UI
- "23646:23646" # Admin UI
environment:
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
volumes:
- seaweedfsdata-dev:/data
restart: unless-stopped
networks:
- devnet
mailpit: mailpit:
image: axllent/mailpit:latest image: axllent/mailpit:latest
container_name: dev-env-mailpit container_name: dev-env-mailpit
ports: ports:
- "8025:8025" # Web UI - "8025:8025" # Web UI
- "1025:1025" # SMTP server - "1025:1025" # SMTP server
restart: unless-stopped restart: unless-stopped
environment: environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1
networks: networks:
- devnet - devnet
volumes: volumes:
sqlserverdata-dev: sqlserverdata-dev:
driver: local driver: local
nuget-cache-dev: nuget-cache-dev:
driver: local driver: local
seaweedfsdata-dev:
driver: local
networks: networks:
devnet: devnet:

View File

@@ -57,6 +57,8 @@ REFRESH_TOKEN_SECRET_VAL=$(gen_jwt_secret)
CONFIRMATION_TOKEN_SECRET_VAL=$(gen_jwt_secret) CONFIRMATION_TOKEN_SECRET_VAL=$(gen_jwt_secret)
SMTP_USERNAME_VAL="smtp_$(gen_username)" SMTP_USERNAME_VAL="smtp_$(gen_username)"
SMTP_PASSWORD_VAL=$(gen_password) SMTP_PASSWORD_VAL=$(gen_password)
SEAWEEDFS_ACCESS_KEY_ID_VAL=$(gen_username)
SEAWEEDFS_SECRET_ACCESS_KEY_VAL=$(gen_password)
cp "$TEMPLATE_FILE" "$OUTPUT_FILE" cp "$TEMPLATE_FILE" "$OUTPUT_FILE"
@@ -68,6 +70,8 @@ sed \
-e "s|^CONFIRMATION_TOKEN_SECRET=.*|CONFIRMATION_TOKEN_SECRET=${CONFIRMATION_TOKEN_SECRET_VAL}|" \ -e "s|^CONFIRMATION_TOKEN_SECRET=.*|CONFIRMATION_TOKEN_SECRET=${CONFIRMATION_TOKEN_SECRET_VAL}|" \
-e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=${SMTP_USERNAME_VAL}|" \ -e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=${SMTP_USERNAME_VAL}|" \
-e "s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=${SMTP_PASSWORD_VAL}|" \ -e "s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=${SMTP_PASSWORD_VAL}|" \
-e "s|^SEAWEEDFS_ACCESS_KEY_ID=.*|SEAWEEDFS_ACCESS_KEY_ID=${SEAWEEDFS_ACCESS_KEY_ID_VAL}|" \
-e "s|^SEAWEEDFS_SECRET_ACCESS_KEY=.*|SEAWEEDFS_SECRET_ACCESS_KEY=${SEAWEEDFS_SECRET_ACCESS_KEY_VAL}|" \
"$OUTPUT_FILE" > "$TMP_FILE" "$OUTPUT_FILE" > "$TMP_FILE"
mv "$TMP_FILE" "$OUTPUT_FILE" mv "$TMP_FILE" "$OUTPUT_FILE"