mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Compare commits
3 Commits
a580314583
...
ade2128a73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ade2128a73 | ||
|
|
e5b354e322 | ||
|
|
be672b6ca9 |
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
└──────────────┘
|
└──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/"]
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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/"]
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
web/backend/Domain/Domain.Entities/Entities/Photo.cs
Normal file
17
web/backend/Domain/Domain.Entities/Entities/Photo.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />,
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user