Compare commits
14 Commits
389446ee21
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aafc0efaab | |||
| ecb1d92df3 | |||
| 98a433f53f | |||
| 60e70f7acc | |||
| 1de8fce103 | |||
| ad5406a5d7 | |||
| 1c4cc3c79f | |||
| 4fa35dadc3 | |||
| b82b3939ce | |||
| ac3dcc2f31 | |||
| 69ec754775 | |||
| efde0f952b | |||
| 8d4d7c7ce3 | |||
| bfd5880b9c |
@@ -7,3 +7,6 @@ POSTGRES_PASSWORD=changeme
|
|||||||
|
|
||||||
# Note: client OIDC values live in src/Budget.Client/.env (committed).
|
# Note: client OIDC values live in src/Budget.Client/.env (committed).
|
||||||
# Override locally in src/Budget.Client/.env.local (gitignored).
|
# Override locally in src/Budget.Client/.env.local (gitignored).
|
||||||
|
|
||||||
|
# Trusted reverse-proxy networks (comma-separated CIDRs). Default covers the Docker bridge range.
|
||||||
|
# Budget__TrustedProxyNetworks=172.16.0.0/12
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: Build and Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
cache-binary: false
|
||||||
|
|
||||||
|
- name: Determine image name and tags
|
||||||
|
id: meta
|
||||||
|
env:
|
||||||
|
# Pass context values through env so the workflow templater does not
|
||||||
|
# interpolate them into the script body (prevents shell injection via
|
||||||
|
# attacker-controlled ref names). Shell vars below are NOT templated.
|
||||||
|
REF_TYPE: ${{ gitea.ref_type }}
|
||||||
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
REPOSITORY: ${{ gitea.repository }}
|
||||||
|
SHA: ${{ gitea.sha }}
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
run: |
|
||||||
|
# Image names must be lowercase; lowercase the full owner/name path.
|
||||||
|
IMAGE="${REGISTRY}/$(echo "$REPOSITORY" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
if [ "$REF_TYPE" = "tag" ]; then
|
||||||
|
# Reject tags that aren't clean semver-ish refs.
|
||||||
|
case "$REF_NAME" in
|
||||||
|
v[0-9]*) : ;;
|
||||||
|
*) echo "Refusing to build non-version tag: $REF_NAME" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$REF_NAME" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
SHORT_SHA="$(echo "$SHA" | cut -c1-8)"
|
||||||
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=dev-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
if: steps.meta.outputs.is_release == 'true'
|
||||||
|
env:
|
||||||
|
# The job image (node:20-bullseye) has no docker CLI, so docker/login-action
|
||||||
|
# can't run. buildx reads ~/.docker/config.json directly, so write the auth
|
||||||
|
# there ourselves. Secrets via env keep them out of the templated script.
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$HOME/.docker"
|
||||||
|
AUTH="$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 -w0)"
|
||||||
|
printf '{"auths":{"%s":{"auth":"%s"}}}' "$REGISTRY" "$AUTH" > "$HOME/.docker/config.json"
|
||||||
|
|
||||||
|
- name: Build and push release image
|
||||||
|
if: steps.meta.outputs.is_release == 'true'
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
|
||||||
|
${{ steps.meta.outputs.image }}:latest
|
||||||
|
|
||||||
|
- name: Build dev image (no push)
|
||||||
|
if: steps.meta.outputs.is_release == 'false'
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
tags: ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: 'Image tag to deploy (e.g. v1.2.3, or "latest")'
|
||||||
|
required: true
|
||||||
|
default: 'latest'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||||
|
env:
|
||||||
|
# Pass the user-supplied tag as an env var (not interpolated into the
|
||||||
|
# remote script body). The remote script runs as the deploy user, which
|
||||||
|
# has docker access == root on prod, so the tag is validated before use.
|
||||||
|
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.PROD_SSH_HOST }}
|
||||||
|
username: ${{ secrets.PROD_SSH_USER }}
|
||||||
|
key: ${{ secrets.PROD_SSH_KEY }}
|
||||||
|
envs: IMAGE_TAG
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
case "$IMAGE_TAG" in
|
||||||
|
latest|v[0-9]*) : ;;
|
||||||
|
*) echo "Refusing to deploy invalid tag: $IMAGE_TAG" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
IMAGE="gitea.stwaddle.com/stwaddle/budget"
|
||||||
|
cd /srv/stwaddlecom
|
||||||
|
docker pull "${IMAGE}:${IMAGE_TAG}"
|
||||||
|
# Re-tag as :latest so the compose definition (which references :latest) picks it up.
|
||||||
|
docker tag "${IMAGE}:${IMAGE_TAG}" "${IMAGE}:latest"
|
||||||
|
docker compose up -d budget
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Security & Resource Hardening Plan
|
||||||
|
**Date:** 2026-05-06
|
||||||
|
**Trigger:** Production host CPU pegged >100% for several hours; Disk I/O high; network normal.
|
||||||
|
**Root cause hypothesis:** C-1 (unconditional OTel exporter) + H-1 (unconditional DB write per request).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical
|
||||||
|
|
||||||
|
### ~~C-1 — Guard `UseOtlpExporter()` Behind Env Var Check~~ ✅ DONE
|
||||||
|
**Resolved 2026-05-06:** Entire OTel block (logging + tracing + exporter) is now gated on `OTEL_EXPORTER_OTLP_ENDPOINT` being set. AspNetCore instrumentation also filtered to `/api` paths only so static file requests never generate spans. The duplicate `builder.Logging.AddOpenTelemetry()` is inside the same guard, eliminating the double-pipeline issue. Build verified clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ~~C-2 — Require Auth on `/healthz` (or Remove DB Check)~~ ✅ DONE
|
||||||
|
**Resolved 2026-05-06:** Endpoint removed entirely. Not wired to any orchestrator health check; budget app is lower priority than recipes/journal. `AddHealthChecks()`, `AddDbContextCheck<AppDbContext>()`, `MapHealthChecks()`, and both health-check `using` statements removed from `Program.cs`. Build verified clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ~~C-3 — Move `UseRateLimiter()` Before `UseStaticFiles()`~~ ✅ DONE
|
||||||
|
**File:** `src/Budget.Api/Program.cs`
|
||||||
|
**Risk:** `UseStaticFiles` short-circuits the pipeline before `UseRateLimiter`, so all SPA asset requests (JS bundles, CSS, `index.html` fallback) bypass rate limiting. Combined with C-1, every unthrottled static file request still generates a telemetry span.
|
||||||
|
|
||||||
|
**Fix:** Reorder middleware so `app.UseRateLimiter()` appears before `app.UseDefaultFiles()` and `app.UseStaticFiles()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High
|
||||||
|
|
||||||
|
### ~~H-1 — Throttle `KnownUserMiddleware` DB Writes~~ ✅ DONE
|
||||||
|
**Resolved 2026-05-06:** `KnownUserMiddleware` removed entirely. Replaced with `POST /api/users/me` endpoint in new `UsersController` — same upsert + pending share resolution logic, but now called once on login from `TokenSync.tsx` rather than on every request. `api.post` body param made optional to support the no-body call. Backend and frontend builds verified clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-2 — Reduce `BudgetAuthorizationService` to a Single Query
|
||||||
|
**File:** `src/Budget.Infrastructure/Services/BudgetAuthorizationService.cs`
|
||||||
|
**Risk:** `GetAccessAsync` always issues two sequential queries (budget row, then share row). Combined with controller entity loads, a single write endpoint makes 4+ DB round-trips; `GET /summary` makes 5.
|
||||||
|
|
||||||
|
**Fix:** Combine into one projected query:
|
||||||
|
```csharp
|
||||||
|
var result = await db.Budgets
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(b => b.Id == budgetId)
|
||||||
|
.Select(b => new {
|
||||||
|
b.OwnerUserId,
|
||||||
|
Share = b.Shares.FirstOrDefault(s => s.SharedWithUserId == userId && !s.IsPending)
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ~~H-3 — Add Missing Database Indexes~~ ✅ DONE
|
||||||
|
**Resolved 2026-05-06:** Added 5 indexes to `AppDbContext.OnModelCreating`. Migration `20260507024323_AddHardeningIndexes` generated and verified. Replaces single-column `BudgetId` indexes on Incomes/Outgos with composite `(BudgetId, IsDeleted)` versions; adds `BudgetShares.(IsPending, SharedWithEmail)`, `BudgetShares.SharedWithUserId`, and `Budgets.OwnerUserId`. Will be applied automatically at next startup via `MigrateAsync()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ~~H-4 — Cap `Reorder` Endpoint Input Size~~ ✅ DONE
|
||||||
|
**Resolved 2026-05-06:** Replaced full-array reorder with a move-by-position design. `ReorderIncomesRequest`/`ReorderOutgosRequest` removed and replaced with `MoveIncomeRequest(Guid Id, int NewIndex)` / `MoveOutgoRequest(Guid Id, int NewIndex)`. Server loads items sorted by SortOrder, finds the item, shifts only the affected range, sets the new position. Frontend sends `{ id, newIndex }` from dnd-kit's existing `oldIdx`/`newIdx` — `arrayMove` still used for optimistic local state. Input is inherently bounded to one GUID and one integer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ~~H-5 — Lock Down `ForwardedHeaders` to Known Proxy IP~~ ✅ DONE
|
||||||
|
**Resolved 2026-05-06:** Replaced clear-all with explicit subnet trust. `KnownIPNetworks` (the .NET 10 replacement for deprecated `KnownNetworks`) is cleared and populated with `172.20.0.0/16` — the Docker network subnet for the nginx-proxy stack. Using the subnet rather than a specific container IP avoids breakage if nginx-proxy gets a different IP on restart. Deprecation warning eliminated; build clean with 0 warnings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium
|
||||||
|
|
||||||
|
### M-1 — OIDC JWKS Fetched Over Plain HTTP
|
||||||
|
**File:** `src/Budget.Api/Program.cs`
|
||||||
|
**Risk:** `MetadataAddress` points to `http://auth:8080/...`. JWKS (token signing keys) arrive over plain HTTP. On a compromised Docker network, an attacker could substitute keys and forge valid JWTs. Low severity on a single-host deployment; higher risk in any multi-host topology.
|
||||||
|
|
||||||
|
**Fix:** Enable internal TLS on the auth service, or pin the signing key in configuration instead of relying on discovery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ~~M-2 — Unknown `Frequency` Enum Crashes Entire Budget~~ ✅ DONE
|
||||||
|
**File:** `src/Budget.Core/Services/FrequencyCalculator.cs`
|
||||||
|
**Risk:** An unrecognised enum integer (data corruption or future migration) throws `ArgumentOutOfRangeException`, making all income/outgo/summary endpoints permanently broken for that budget and generating error spans that amplify OTel export volume.
|
||||||
|
|
||||||
|
**Fix:** Validate enum values at the controller layer with `[EnumDataType(typeof(Frequency))]` on write DTOs. Return a graceful default or 422 in the calculator rather than throwing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ~~M-3 — Missing `AsNoTracking()` on Read Queries~~ ✅ DONE
|
||||||
|
**Files:** `src/Budget.Api/Controllers/SummaryController.cs`, `IncomesController.cs`, `OutgosController.cs`
|
||||||
|
**Risk:** EF change tracking runs on every loaded entity even for pure-read endpoints, wasting CPU/memory. With OTel EF instrumentation active, every tracked entity event adds overhead.
|
||||||
|
|
||||||
|
**Fix:** Add `.AsNoTracking()` to all `List` and `Get` queries that do not write.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ~~M-4 — `Thread.Sleep` in Async Startup Loop~~ ✅ DONE
|
||||||
|
**File:** `src/Budget.Api/Program.cs`
|
||||||
|
**Risk:** Synchronous `Thread.Sleep` inside the async OIDC discovery retry loop blocks a thread pool thread for up to 2 minutes during container startup.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```csharp
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ~~M-5 — `AllowedHosts: "*"` Disables Host Header Validation~~ ✅ DONE
|
||||||
|
**File:** `src/Budget.Api/appsettings.json`
|
||||||
|
**Risk:** Allows HTTP host header injection if the app is ever behind a misconfigured proxy.
|
||||||
|
|
||||||
|
**Fix:** Set to the actual public hostname:
|
||||||
|
```json
|
||||||
|
"AllowedHosts": "budget.stwaddle.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Low / Informational
|
||||||
|
|
||||||
|
| ID | File | Issue | Fix |
|
||||||
|
|----|------|-------|-----|
|
||||||
|
| L-1 | `appsettings.json` | Docker-internal `MetadataAddress` in base config — if the Docker service name `auth` is not resolvable, OIDC discovery silently fails | Move `MetadataAddress` to a Docker Compose env override; base config default should be empty |
|
||||||
|
| L-2 | `Program.cs` | No timeout on startup `MigrateAsync()` — hangs indefinitely if Postgres is unresponsive | Wrap with a `CancellationTokenSource` with a deadline |
|
||||||
|
| L-3 | `ErrorHandlingMiddleware.cs` | Full exception stack traces exported via OTel on every unhandled error — exception storms spike collector disk I/O | Consider `LogWarning` for expected exceptions; reserve `LogError` for truly unexpected ones |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. ~~**C-1** — Guard `UseOtlpExporter()` (likely root cause; deploy ASAP)~~ ✅ Done (2026-05-06)
|
||||||
|
2. ~~**H-1** — Throttle `KnownUserMiddleware` writes (amplifies Disk I/O on every request)~~ ✅ Done (2026-05-06)
|
||||||
|
3. ~~**C-2** — Auth-gate or simplify `/healthz`~~ ✅ Removed entirely (2026-05-06)
|
||||||
|
4. ~~**H-3** — Add missing indexes (single migration, low risk)~~ ✅ Done (2026-05-06)
|
||||||
|
5. ~~**C-3** — Move rate limiter before static files~~ ✅ Done (2026-05-06)
|
||||||
|
6. ~~**H-4** — Cap `Reorder` input~~ ✅ Done — redesigned as move-by-position (2026-05-06)
|
||||||
|
7. ~~**H-5** — Lock down `ForwardedHeaders` to proxy IP~~ ✅ Done (2026-05-06)
|
||||||
|
8. ~~**M-3** — Add `AsNoTracking()` across read controllers~~ ✅ Done (2026-05-06)
|
||||||
|
9. ~~**M-2** — Harden `FrequencyCalculator` enum handling~~ ✅ Done (2026-05-06)
|
||||||
|
10. ~~**M-4** — `Thread.Sleep` → `await Task.Delay` in startup loop~~ ✅ Done (2026-05-06)
|
||||||
|
11. ~~**M-5** — `AllowedHosts: "*"` → `budget.stwaddle.com`~~ ✅ Done (2026-05-06)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# OTel Integration — Budget
|
||||||
|
|
||||||
|
Wire up OpenTelemetry in Budget.Api so logs and traces flow through the OTel Collector
|
||||||
|
to Seq. Depends on the infrastructure plan (`docker/.plans/otel-seq-infrastructure.md`)
|
||||||
|
being applied first.
|
||||||
|
|
||||||
|
## Packages to add (Budget.Api.csproj)
|
||||||
|
|
||||||
|
Verify latest stable versions on NuGet before adding — do not guess.
|
||||||
|
|
||||||
|
```
|
||||||
|
OpenTelemetry.Extensions.Hosting
|
||||||
|
OpenTelemetry.Instrumentation.AspNetCore
|
||||||
|
OpenTelemetry.Instrumentation.Http
|
||||||
|
OpenTelemetry.Instrumentation.EntityFrameworkCore
|
||||||
|
OpenTelemetry.Exporter.OpenTelemetryProtocol
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 1 — Wire up OTel in Program.cs
|
||||||
|
|
||||||
|
Add after `var builder = WebApplication.CreateBuilder(args);`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Logging.AddOpenTelemetry(logging =>
|
||||||
|
{
|
||||||
|
logging.IncludeFormattedMessage = true;
|
||||||
|
logging.IncludeScopes = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddOpenTelemetry()
|
||||||
|
.ConfigureResource(resource => resource
|
||||||
|
.AddService(serviceName: "budget", serviceVersion: "1.0.0"))
|
||||||
|
.WithLogging()
|
||||||
|
.WithTracing(tracing => tracing
|
||||||
|
.AddAspNetCoreInstrumentation()
|
||||||
|
.AddHttpClientInstrumentation()
|
||||||
|
.AddEntityFrameworkCoreInstrumentation()
|
||||||
|
.AddOtlpExporter());
|
||||||
|
```
|
||||||
|
|
||||||
|
The `AddOtlpExporter()` call with no arguments reads the endpoint from environment variables
|
||||||
|
(`OTEL_EXPORTER_OTLP_ENDPOINT`), keeping config out of code.
|
||||||
|
|
||||||
|
## Phase 2 — docker-compose environment variables
|
||||||
|
|
||||||
|
Add to the `budget` service in `docker/docker-compose.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||||
|
- OTEL_EXPORTER_OTLP_PROTOCOL=grpc
|
||||||
|
- OTEL_SERVICE_NAME=budget
|
||||||
|
```
|
||||||
|
|
||||||
|
`otel-collector` resolves via the `telemetry` network added in the infrastructure plan.
|
||||||
|
|
||||||
|
## Phase 3 — Verification
|
||||||
|
|
||||||
|
1. Build and push a new Budget image
|
||||||
|
2. `docker compose up -d budget`
|
||||||
|
3. Make a few API calls (create a transaction, fetch accounts)
|
||||||
|
4. In Seq, search `@ServiceName = 'budget'` — you should see structured log events
|
||||||
|
including the OIDC discovery retry loop logs already present in Program.cs
|
||||||
|
5. Confirm DB spans appear for EF Core queries
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Budget already has structured logging calls in the OIDC startup retry loop
|
||||||
|
(`startupLogger.LogInformation`, `LogWarning`). These will automatically become
|
||||||
|
structured events in Seq with no code changes.
|
||||||
|
- The `ErrorHandlingMiddleware` and `KnownUserMiddleware` are good candidates to add
|
||||||
|
structured properties (e.g. `userId`, `endpoint`) to log calls, making Seq searches
|
||||||
|
more useful.
|
||||||
|
- `AddHttpClientInstrumentation()` will trace outgoing calls to the Auth server (OIDC
|
||||||
|
discovery, token introspection), which is useful for diagnosing auth latency.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend (ASP.NET Core)
|
||||||
|
```bash
|
||||||
|
# Run API (must listen on port 5000 for the Vite proxy to work)
|
||||||
|
cd src/Budget.Api
|
||||||
|
ASPNETCORE_URLS=http://localhost:5000 dotnet run
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dotnet build src/Budget.Api/Budget.Api.csproj
|
||||||
|
|
||||||
|
# Add an EF Core migration
|
||||||
|
dotnet ef migrations add <Name> --project src/Budget.Infrastructure --startup-project src/Budget.Api
|
||||||
|
|
||||||
|
# Remove last migration (before it has been applied)
|
||||||
|
dotnet ef migrations remove --project src/Budget.Infrastructure --startup-project src/Budget.Api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (Vite/React)
|
||||||
|
```bash
|
||||||
|
cd src/Budget.Client
|
||||||
|
npm run dev # dev server at http://localhost:5173
|
||||||
|
npm run build # tsc + vite build (what CI and Docker use)
|
||||||
|
npm run lint # eslint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```powershell
|
||||||
|
.\deploy.ps1 # builds image, pushes to docker.stwaddle.com/budget:latest, and deploys to prod via SSH
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The app is a single Docker container: the .NET API serves the compiled React SPA as static files and also handles all `/api` requests. In development the two processes run separately; Vite proxies `/api` to `http://localhost:5000`.
|
||||||
|
|
||||||
|
### Project layout
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
Budget.Core/ # Domain models, DTOs, FrequencyCalculator — no EF, no ASP.NET
|
||||||
|
Budget.Infrastructure/# EF Core DbContext, migrations, BudgetAuthorizationService
|
||||||
|
Budget.Api/ # ASP.NET Core controllers, middleware, Program.cs
|
||||||
|
Budget.Client/ # Vite/React SPA
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
- **Budget** (owner, name) → owns **Incomes**, **Outgos**, **BudgetShares**
|
||||||
|
- All entities use **soft delete** (`IsDeleted` / `DeletedAt`) with a global EF query filter — hard deletes are not used
|
||||||
|
- **Budget** uses PostgreSQL's `xmin` shadow property as an EF concurrency token
|
||||||
|
- **BudgetShare** links a budget to another user by email; `IsPending = true` until the target user logs in (resolved by `KnownUserMiddleware`)
|
||||||
|
- **Frequency** is a C# enum (`Monthly`, `Biweekly`, `Annually`, etc.); `FrequencyCalculator` converts any frequency to monthly/annual equivalents
|
||||||
|
- The 50/30/20 rule is hardcoded in `SummaryController`: Need=50%, Want=30%, Save=20%
|
||||||
|
|
||||||
|
### API conventions
|
||||||
|
- All controllers require `[Authorize(Roles = "admin,user")]` — tokens that lack a `role` claim matching either value get a 403
|
||||||
|
- `RoleClaimType = "role"` and `NameClaimType = "sub"` are set in JWT options; role checks work with `User.IsInRole()`
|
||||||
|
- `BudgetAuthorizationService` centralises read/write/owner checks against the budget's owner and any accepted shares — always call `CanReadAsync` / `CanWriteAsync` / `IsOwnerAsync` before touching budget data
|
||||||
|
- The `TryGetUserId` helper pattern (returns an `IActionResult?` error or out-params the user id) is used consistently across all controllers
|
||||||
|
- **Enum serialisation**: `JsonStringEnumConverter` is registered globally, so all C# enums are sent/received as strings (e.g. `"Monthly"`, not `5`)
|
||||||
|
- Write endpoints use the `"writes"` rate-limit policy (30 req/min per user); reads use the global limiter (120 req/min)
|
||||||
|
- EF migrations run automatically via `MigrateAsync()` at startup
|
||||||
|
|
||||||
|
### Frontend conventions
|
||||||
|
- **All server state** goes through TanStack Query (`src/api/`). Query keys follow the pattern `['budgets']`, `['budgets', id]`, `['budgets', id, 'summary']`, etc.
|
||||||
|
- **All forms** use `react-hook-form` + `zod` schemas defined in `src/schemas/index.ts`. Never use uncontrolled inputs outside of RHF.
|
||||||
|
- **CSS design system** lives entirely in `src/index.css` as CSS custom properties — no Tailwind, no CSS modules. Use the existing variables (`--color-primary`, `--green-*`, etc.) rather than hardcoded hex values. `App.css` is intentionally near-empty.
|
||||||
|
- **Icons** come from `lucide-react` (tree-shaken per-import). Use `btn-icon` class for icon-only buttons; always add a `title` attribute for accessibility.
|
||||||
|
- **Inline table editing**: rows show read-only data; clicking a cell enters edit mode in-place using a `useState(editing)` toggle. New rows use a separate input row appended to `<tbody>` (not a modal).
|
||||||
|
- **Sorting**: `SortableHeader` component and `SortState` type in `src/components/SortableHeader.tsx`. Sorting is client-side only and derives a view from the drag-ordered `displayItems` array — clearing sort restores drag order. Drag handles are disabled while a sort is active.
|
||||||
|
- **Frequency calculations** for real-time previews are in `src/utils/frequency.ts`, mirroring the C# `FrequencyCalculator` exactly.
|
||||||
|
- **Drag-and-drop ordering**: `@dnd-kit` is used on income/outgo rows. Row order is persisted to the server. Sort handles are hidden while a column sort is active.
|
||||||
|
- **Auth**: `react-oidc-context` wraps the whole app; `AuthGuard` protects all budget routes; `TokenSync` wires the OIDC access token into the API client on every auth state change. After OIDC callback, navigation uses `window.location.replace('/')` (not `history.replaceState`) so React Router picks up the URL change.
|
||||||
|
|
||||||
|
### Local development environment
|
||||||
|
Required env vars when running the API locally (set in `appsettings.Development.json` or shell):
|
||||||
|
- `ConnectionStrings__DefaultConnection` — Postgres connection string
|
||||||
|
- `Oidc__Audience` — `budget_api`
|
||||||
|
- `OTEL_EXPORTER_OTLP_ENDPOINT` — optional; omit or set to skip telemetry export
|
||||||
|
|
||||||
|
### Auth / OIDC
|
||||||
|
- Authority: `https://auth.stwaddle.com/` (OpenIddict-based, at `src.Auth.Server` in a sibling repo)
|
||||||
|
- API audience claim: `budget_api` (underscore — must match `Oidc__Audience` env var)
|
||||||
|
- Scope: `budget_api` (underscore, not hyphen)
|
||||||
|
- Roles `admin` and `user` are granted per-user on the client registration in the auth server admin panel. A user with no role gets `access_denied` at the authorize endpoint.
|
||||||
|
- Frontend OIDC config is baked into the build via `VITE_*` env vars; override locally in `src/Budget.Client/.env.local`
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
$image = "docker.stwaddle.com/budget:latest"
|
|
||||||
docker build -t $image .
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
docker push $image
|
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
$image = "docker.stwaddle.com/budget:latest"
|
||||||
|
|
||||||
|
docker build -t $image .
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
docker push $image
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
ssh stwaddle_com "cd stwaddlecom && docker compose pull budget && docker compose down budget && docker compose up -d budget"
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
|
Write-Host "Waiting for container to start..."
|
||||||
|
Start-Sleep -Seconds 8
|
||||||
|
ssh stwaddle_com "docker inspect --format='{{.State.Status}}' stwaddlecom-budget-1"
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
services:
|
|
||||||
budget:
|
|
||||||
image: ${IMAGE_REGISTRY:-}budget:latest
|
|
||||||
environment:
|
|
||||||
- VIRTUAL_HOST=${BUDGET_VIRTUAL_HOST:-budget.stwaddle.com}
|
|
||||||
- LETSENCRYPT_HOST=${BUDGET_LETSENCRYPT_HOST:-budget.stwaddle.com}
|
|
||||||
- VIRTUAL_PORT=8080
|
|
||||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production}
|
|
||||||
- ConnectionStrings__DefaultConnection=Host=apps-db;Port=5432;Database=${POSTGRES_DB:-budget};Username=${POSTGRES_USER:-budget};Password=${POSTGRES_PASSWORD}
|
|
||||||
depends_on:
|
|
||||||
- apps-db
|
|
||||||
- auth
|
|
||||||
networks:
|
|
||||||
- web
|
|
||||||
- apps-internal
|
|
||||||
- auth-public
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
apps-db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB:-budget}
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER:-budget}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- apps-db-data:/var/lib/postgresql/data
|
|
||||||
networks:
|
|
||||||
- apps-internal
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
networks:
|
|
||||||
web:
|
|
||||||
external: true
|
|
||||||
apps-internal:
|
|
||||||
external: true
|
|
||||||
auth-public:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
apps-db-data:
|
|
||||||
@@ -36,6 +36,7 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
|
|||||||
if (TryGetUserId(out var userId) is { } err) return err;
|
if (TryGetUserId(out var userId) is { } err) return err;
|
||||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||||
var incomes = await db.Incomes
|
var incomes = await db.Incomes
|
||||||
|
.AsNoTracking()
|
||||||
.Where(i => i.BudgetId == budgetId)
|
.Where(i => i.BudgetId == budgetId)
|
||||||
.OrderBy(i => i.SortOrder)
|
.OrderBy(i => i.SortOrder)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -94,17 +95,20 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
|
|||||||
|
|
||||||
[HttpPut("order")]
|
[HttpPut("order")]
|
||||||
[EnableRateLimiting("writes")]
|
[EnableRateLimiting("writes")]
|
||||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderIncomesRequest req)
|
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] MoveIncomeRequest req)
|
||||||
{
|
{
|
||||||
if (TryGetUserId(out var userId) is { } err) return err;
|
if (TryGetUserId(out var userId) is { } err) return err;
|
||||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
|
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).OrderBy(i => i.SortOrder).ToListAsync();
|
||||||
var lookup = incomes.ToDictionary(i => i.Id);
|
var item = incomes.FirstOrDefault(i => i.Id == req.Id);
|
||||||
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
|
if (item is null) return NotFound();
|
||||||
{
|
var oldIdx = incomes.IndexOf(item);
|
||||||
if (lookup.TryGetValue(req.OrderedIds[idx], out var income))
|
var newIdx = Math.Clamp(req.NewIndex, 0, incomes.Count - 1);
|
||||||
income.SortOrder = idx;
|
if (oldIdx == newIdx) return NoContent();
|
||||||
}
|
incomes.RemoveAt(oldIdx);
|
||||||
|
incomes.Insert(newIdx, item);
|
||||||
|
for (int i = 0; i < incomes.Count; i++)
|
||||||
|
incomes[i].SortOrder = i;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
|||||||
|
|
||||||
private async Task<decimal> GetMonthlyIncomeAsync(Guid budgetId)
|
private async Task<decimal> GetMonthlyIncomeAsync(Guid budgetId)
|
||||||
{
|
{
|
||||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
|
var incomes = await db.Incomes.AsNoTracking().Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||||
return incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency));
|
return incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
|||||||
{
|
{
|
||||||
if (TryGetUserId(out var userId) is { } err) return err;
|
if (TryGetUserId(out var userId) is { } err) return err;
|
||||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
|
var outgos = await db.Outgos.AsNoTracking().Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
|
||||||
var monthlyIncome = await GetMonthlyIncomeAsync(budgetId);
|
var monthlyIncome = await GetMonthlyIncomeAsync(budgetId);
|
||||||
return Ok(outgos.Select(o => ToDto(o, monthlyIncome)));
|
return Ok(outgos.Select(o => ToDto(o, monthlyIncome)));
|
||||||
}
|
}
|
||||||
@@ -112,17 +112,20 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
|||||||
|
|
||||||
[HttpPut("order")]
|
[HttpPut("order")]
|
||||||
[EnableRateLimiting("writes")]
|
[EnableRateLimiting("writes")]
|
||||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderOutgosRequest req)
|
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] MoveOutgoRequest req)
|
||||||
{
|
{
|
||||||
if (TryGetUserId(out var userId) is { } err) return err;
|
if (TryGetUserId(out var userId) is { } err) return err;
|
||||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
|
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
|
||||||
var lookup = outgos.ToDictionary(o => o.Id);
|
var item = outgos.FirstOrDefault(o => o.Id == req.Id);
|
||||||
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
|
if (item is null) return NotFound();
|
||||||
{
|
var oldIdx = outgos.IndexOf(item);
|
||||||
if (lookup.TryGetValue(req.OrderedIds[idx], out var outgo))
|
var newIdx = Math.Clamp(req.NewIndex, 0, outgos.Count - 1);
|
||||||
outgo.SortOrder = idx;
|
if (oldIdx == newIdx) return NoContent();
|
||||||
}
|
outgos.RemoveAt(oldIdx);
|
||||||
|
outgos.Insert(newIdx, item);
|
||||||
|
for (int i = 0; i < outgos.Count; i++)
|
||||||
|
outgos[i].SortOrder = i;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ public class SummaryController(AppDbContext db, BudgetAuthorizationService authz
|
|||||||
if (TryGetUserId(out var userId) is { } err) return err;
|
if (TryGetUserId(out var userId) is { } err) return err;
|
||||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||||
|
|
||||||
var budget = await db.Budgets.FindAsync(budgetId);
|
var budget = await db.Budgets.AsNoTracking().FirstOrDefaultAsync(b => b.Id == budgetId);
|
||||||
if (budget is null) return NotFound();
|
if (budget is null) return NotFound();
|
||||||
|
|
||||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
|
var incomes = await db.Incomes.AsNoTracking().Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
|
var outgos = await db.Outgos.AsNoTracking().Where(o => o.BudgetId == budgetId).ToListAsync();
|
||||||
|
|
||||||
var monthlyIncome = incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency));
|
var monthlyIncome = incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency));
|
||||||
var annualIncome = monthlyIncome * 12m;
|
var annualIncome = monthlyIncome * 12m;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Budget.Core.Models;
|
||||||
|
using Budget.Infrastructure.Data;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Budget.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/users")]
|
||||||
|
[Authorize(Roles = "admin,user")]
|
||||||
|
public class UsersController(AppDbContext db) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("me")]
|
||||||
|
public async Task<IActionResult> RegisterMe()
|
||||||
|
{
|
||||||
|
var sub = User.FindFirst("sub")?.Value;
|
||||||
|
var email = User.FindFirst("email")?.Value;
|
||||||
|
var name = User.FindFirst("name")?.Value;
|
||||||
|
|
||||||
|
if (sub is null || email is null || name is null)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var known = await db.KnownUsers.FindAsync(sub);
|
||||||
|
if (known is null)
|
||||||
|
{
|
||||||
|
db.KnownUsers.Add(new KnownUser { Id = sub, Email = email, Name = name, LastSeenAt = DateTimeOffset.UtcNow });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
known.Email = email;
|
||||||
|
known.Name = name;
|
||||||
|
known.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending = await db.BudgetShares
|
||||||
|
.Where(s => s.IsPending && s.SharedWithEmail == email)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var share in pending)
|
||||||
|
{
|
||||||
|
share.SharedWithUserId = sub;
|
||||||
|
share.IsPending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-35
@@ -3,33 +3,36 @@ using Budget.Api.Services;
|
|||||||
using Budget.Infrastructure.Data;
|
using Budget.Infrastructure.Data;
|
||||||
using Budget.Infrastructure.Services;
|
using Budget.Infrastructure.Services;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using OpenTelemetry;
|
||||||
using OpenTelemetry.Resources;
|
using OpenTelemetry.Resources;
|
||||||
using OpenTelemetry.Trace;
|
using OpenTelemetry.Trace;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Logging.AddOpenTelemetry(logging =>
|
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
||||||
|
if (!string.IsNullOrEmpty(otlpEndpoint))
|
||||||
{
|
{
|
||||||
logging.IncludeFormattedMessage = true;
|
builder.Logging.AddOpenTelemetry(logging =>
|
||||||
logging.IncludeScopes = true;
|
{
|
||||||
});
|
logging.IncludeFormattedMessage = true;
|
||||||
|
logging.IncludeScopes = true;
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddOpenTelemetry()
|
builder.Services.AddOpenTelemetry()
|
||||||
.ConfigureResource(resource => resource
|
.ConfigureResource(resource => resource
|
||||||
.AddService(serviceName: "budget", serviceVersion: "1.0.0"))
|
.AddService(serviceName: "budget", serviceVersion: "1.0.0"))
|
||||||
.WithLogging()
|
.WithTracing(tracing => tracing
|
||||||
.WithTracing(tracing => tracing
|
.AddAspNetCoreInstrumentation(opts =>
|
||||||
.AddAspNetCoreInstrumentation()
|
opts.Filter = ctx => ctx.Request.Path.StartsWithSegments("/api"))
|
||||||
.AddHttpClientInstrumentation()
|
.AddHttpClientInstrumentation()
|
||||||
.AddEntityFrameworkCoreInstrumentation()
|
.AddEntityFrameworkCoreInstrumentation())
|
||||||
.AddOtlpExporter());
|
.UseOtlpExporter();
|
||||||
|
}
|
||||||
|
|
||||||
var connStr = builder.Configuration.GetConnectionString("DefaultConnection")
|
var connStr = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
?? $"Host={builder.Configuration["POSTGRES_HOST"] ?? "localhost"};" +
|
?? $"Host={builder.Configuration["POSTGRES_HOST"] ?? "localhost"};" +
|
||||||
@@ -38,13 +41,32 @@ var connStr = builder.Configuration.GetConnectionString("DefaultConnection")
|
|||||||
$"Username={builder.Configuration["POSTGRES_USER"] ?? "budget"};" +
|
$"Username={builder.Configuration["POSTGRES_USER"] ?? "budget"};" +
|
||||||
$"Password={builder.Configuration["POSTGRES_PASSWORD"] ?? "changeme"}";
|
$"Password={builder.Configuration["POSTGRES_PASSWORD"] ?? "changeme"}";
|
||||||
|
|
||||||
builder.Services.AddDbContext<AppDbContext>(opt => opt.UseNpgsql(connStr));
|
builder.Services.AddSingleton<Budget.Infrastructure.Data.SlowQueryInterceptor>();
|
||||||
|
builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
|
||||||
|
{
|
||||||
|
opt.UseNpgsql(connStr);
|
||||||
|
opt.AddInterceptors(sp.GetRequiredService<Budget.Infrastructure.Data.SlowQueryInterceptor>());
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
{
|
{
|
||||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
|
||||||
options.KnownNetworks.Clear();
|
| ForwardedHeaders.XForwardedProto
|
||||||
|
| ForwardedHeaders.XForwardedHost;
|
||||||
|
|
||||||
|
options.KnownIPNetworks.Clear();
|
||||||
options.KnownProxies.Clear();
|
options.KnownProxies.Clear();
|
||||||
|
|
||||||
|
var trustedNetworks = builder.Configuration["Budget:TrustedProxyNetworks"] ?? "172.16.0.0/12";
|
||||||
|
foreach (var cidr in trustedNetworks.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
var parts = cidr.Split('/');
|
||||||
|
var prefix = System.Net.IPAddress.Parse(parts[0]);
|
||||||
|
var prefixLength = parts.Length > 1
|
||||||
|
? int.Parse(parts[1])
|
||||||
|
: (prefix.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? 128 : 32);
|
||||||
|
options.KnownIPNetworks.Add(new System.Net.IPNetwork(prefix, prefixLength));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var oidc = builder.Configuration.GetSection("Oidc");
|
var oidc = builder.Configuration.GetSection("Oidc");
|
||||||
@@ -109,9 +131,6 @@ builder.Services.AddControllers()
|
|||||||
.AddJsonOptions(opts =>
|
.AddJsonOptions(opts =>
|
||||||
opts.JsonSerializerOptions.Converters.Add(
|
opts.JsonSerializerOptions.Converters.Add(
|
||||||
new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
||||||
builder.Services.AddHealthChecks()
|
|
||||||
.AddDbContextCheck<AppDbContext>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Block startup until OIDC discovery succeeds (handles auth/app container race).
|
// Block startup until OIDC discovery succeeds (handles auth/app container race).
|
||||||
@@ -126,6 +145,8 @@ while (true)
|
|||||||
jwtOpts.ConfigurationManager!.RequestRefresh();
|
jwtOpts.ConfigurationManager!.RequestRefresh();
|
||||||
await jwtOpts.ConfigurationManager!.GetConfigurationAsync(CancellationToken.None);
|
await jwtOpts.ConfigurationManager!.GetConfigurationAsync(CancellationToken.None);
|
||||||
startupLogger.LogInformation("OIDC discovery succeeded");
|
startupLogger.LogInformation("OIDC discovery succeeded");
|
||||||
|
startupLogger.LogInformation("Trusted proxy networks: {Networks}",
|
||||||
|
app.Configuration["Budget:TrustedProxyNetworks"] ?? "172.16.0.0/12 (default)");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -136,7 +157,7 @@ while (true)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
startupLogger.LogWarning(ex, "OIDC discovery failed; retrying in 5 s");
|
startupLogger.LogWarning(ex, "OIDC discovery failed; retrying in 5 s");
|
||||||
Thread.Sleep(TimeSpan.FromSeconds(5));
|
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,23 +174,14 @@ app.UseMiddleware<ErrorHandlingMiddleware>();
|
|||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
// Authentication must run before the rate limiter so limiter partitions can
|
||||||
|
// read the validated "sub" claim; otherwise every authenticated request falls
|
||||||
|
// back to the per-IP bucket (auth-hardening-review.md, finding B-1).
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseMiddleware<KnownUserMiddleware>();
|
app.UseRateLimiter();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.UseRateLimiter();
|
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapHealthChecks("/healthz", new HealthCheckOptions
|
|
||||||
{
|
|
||||||
ResultStatusCodes =
|
|
||||||
{
|
|
||||||
[HealthStatus.Healthy] = StatusCodes.Status200OK,
|
|
||||||
[HealthStatus.Degraded] = StatusCodes.Status200OK,
|
|
||||||
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapFallbackToFile("index.html");
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
using Budget.Core.Models;
|
|
||||||
using Budget.Infrastructure.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Budget.Api.Services;
|
|
||||||
|
|
||||||
public class KnownUserMiddleware(RequestDelegate next)
|
|
||||||
{
|
|
||||||
public async Task InvokeAsync(HttpContext context, AppDbContext db)
|
|
||||||
{
|
|
||||||
if (context.User.Identity?.IsAuthenticated == true)
|
|
||||||
{
|
|
||||||
var sub = context.User.FindFirst("sub")?.Value;
|
|
||||||
var email = context.User.FindFirst("email")?.Value;
|
|
||||||
var name = context.User.FindFirst("name")?.Value;
|
|
||||||
|
|
||||||
if (sub != null && email != null && name != null)
|
|
||||||
{
|
|
||||||
var known = await db.KnownUsers.FindAsync(sub);
|
|
||||||
if (known is null)
|
|
||||||
{
|
|
||||||
db.KnownUsers.Add(new KnownUser { Id = sub, Email = email, Name = name, LastSeenAt = DateTimeOffset.UtcNow });
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
known.Email = email;
|
|
||||||
known.Name = name;
|
|
||||||
known.LastSeenAt = DateTimeOffset.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve pending shares for this user's email
|
|
||||||
var pending = await db.BudgetShares
|
|
||||||
.Where(s => s.IsPending && s.SharedWithEmail == email)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
foreach (var share in pending)
|
|
||||||
{
|
|
||||||
share.SharedWithUserId = sub;
|
|
||||||
share.IsPending = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await next(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "budget.stwaddle.com",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": ""
|
"DefaultConnection": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string) => request<T>('GET', path),
|
get: <T>(path: string) => request<T>('GET', path),
|
||||||
post: <T>(path: string, body: unknown) => request<T>('POST', path, body),
|
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||||
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
|
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
|
||||||
delete: (path: string) => request<void>('DELETE', path),
|
delete: (path: string) => request<void>('DELETE', path),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { IncomeDto, Frequency } from '../types/index';
|
|||||||
|
|
||||||
interface CreateIncomeRequest { name: string; frequency: Frequency; amount: number; }
|
interface CreateIncomeRequest { name: string; frequency: Frequency; amount: number; }
|
||||||
interface UpdateIncomeRequest { name: string; frequency: Frequency; amount: number; }
|
interface UpdateIncomeRequest { name: string; frequency: Frequency; amount: number; }
|
||||||
interface ReorderRequest { orderedIds: string[]; }
|
interface MoveRequest { id: string; newIndex: number; }
|
||||||
|
|
||||||
export function useIncomes(budgetId: string) {
|
export function useIncomes(budgetId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -44,7 +44,7 @@ export function useDeleteIncome(budgetId: string) {
|
|||||||
export function useReorderIncomes(budgetId: string) {
|
export function useReorderIncomes(budgetId: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (req: ReorderRequest) => api.put<void>(`/api/budgets/${budgetId}/incomes/order`, req),
|
mutationFn: (req: MoveRequest) => api.put<void>(`/api/budgets/${budgetId}/incomes/order`, req),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'incomes'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'incomes'] }),
|
||||||
meta: { errorMessage: 'Failed to save order' },
|
meta: { errorMessage: 'Failed to save order' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface CreateOutgoRequest {
|
|||||||
frequency: Frequency; amount: number; paymentSource: string | null; notes: string | null;
|
frequency: Frequency; amount: number; paymentSource: string | null; notes: string | null;
|
||||||
}
|
}
|
||||||
interface UpdateOutgoRequest extends CreateOutgoRequest { id: string; }
|
interface UpdateOutgoRequest extends CreateOutgoRequest { id: string; }
|
||||||
interface ReorderRequest { orderedIds: string[]; }
|
interface MoveRequest { id: string; newIndex: number; }
|
||||||
|
|
||||||
export function useOutgos(budgetId: string) {
|
export function useOutgos(budgetId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -61,7 +61,7 @@ export function useDeleteOutgo(budgetId: string) {
|
|||||||
export function useReorderOutgos(budgetId: string) {
|
export function useReorderOutgos(budgetId: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (req: ReorderRequest) => api.put<void>(`/api/budgets/${budgetId}/outgos/order`, req),
|
mutationFn: (req: MoveRequest) => api.put<void>(`/api/budgets/${budgetId}/outgos/order`, req),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'outgos'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'outgos'] }),
|
||||||
meta: { errorMessage: 'Failed to save order' },
|
meta: { errorMessage: 'Failed to save order' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAuth } from 'react-oidc-context';
|
import { useAuth } from 'react-oidc-context';
|
||||||
import { setTokenProvider } from '../api/client';
|
import { setTokenProvider, api } from '../api/client';
|
||||||
|
|
||||||
export function TokenSync() {
|
export function TokenSync() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTokenProvider(() => auth.user?.access_token ?? null);
|
setTokenProvider(() => auth.user?.access_token ?? null);
|
||||||
|
if (auth.user?.access_token) {
|
||||||
|
api.post<void>('/api/users/me', undefined).catch(() => {});
|
||||||
|
}
|
||||||
}, [auth.user]);
|
}, [auth.user]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,9 +238,8 @@ export function IncomePage() {
|
|||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
const oldIdx = displayItems.findIndex(i => i.id === active.id);
|
const oldIdx = displayItems.findIndex(i => i.id === active.id);
|
||||||
const newIdx = displayItems.findIndex(i => i.id === over.id);
|
const newIdx = displayItems.findIndex(i => i.id === over.id);
|
||||||
const reordered = arrayMove(displayItems, oldIdx, newIdx);
|
setDisplayItems(items => arrayMove(items, oldIdx, newIdx));
|
||||||
setDisplayItems(reordered);
|
reorderIncomes.mutate({ id: active.id as string, newIndex: newIdx });
|
||||||
reorderIncomes.mutate({ orderedIds: reordered.map(i => i.id) });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>;
|
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>;
|
||||||
|
|||||||
@@ -384,9 +384,8 @@ export function OutgoPage() {
|
|||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
const oldIdx = displayItems.findIndex(o => o.id === active.id);
|
const oldIdx = displayItems.findIndex(o => o.id === active.id);
|
||||||
const newIdx = displayItems.findIndex(o => o.id === over.id);
|
const newIdx = displayItems.findIndex(o => o.id === over.id);
|
||||||
const reordered = arrayMove(displayItems, oldIdx, newIdx);
|
setDisplayItems(items => arrayMove(items, oldIdx, newIdx));
|
||||||
setDisplayItems(reordered);
|
reorderOutgos.mutate({ id: active.id as string, newIndex: newIdx });
|
||||||
reorderOutgos.mutate({ orderedIds: reordered.map(o => o.id) });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;
|
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ public record UpdateIncomeRequest(
|
|||||||
Frequency Frequency,
|
Frequency Frequency,
|
||||||
[Range(0, 9_999_999_999_999_999.99)] decimal Amount);
|
[Range(0, 9_999_999_999_999_999.99)] decimal Amount);
|
||||||
|
|
||||||
public record ReorderIncomesRequest([Required] List<Guid> OrderedIds);
|
public record MoveIncomeRequest([Required] Guid Id, [Range(0, int.MaxValue)] int NewIndex);
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ public record UpdateOutgoRequest(
|
|||||||
[MaxLength(100)] string? PaymentSource,
|
[MaxLength(100)] string? PaymentSource,
|
||||||
[MaxLength(1000)] string? Notes);
|
[MaxLength(1000)] string? Notes);
|
||||||
|
|
||||||
public record ReorderOutgosRequest([Required] List<Guid> OrderedIds);
|
public record MoveOutgoRequest([Required] Guid Id, [Range(0, int.MaxValue)] int NewIndex);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public static class FrequencyCalculator
|
|||||||
Frequency.SemiMonthly => amount * 2m,
|
Frequency.SemiMonthly => amount * 2m,
|
||||||
Frequency.Biweekly => amount * 26m / 12m,
|
Frequency.Biweekly => amount * 26m / 12m,
|
||||||
Frequency.Weekly => amount * 52m / 12m,
|
Frequency.Weekly => amount * 52m / 12m,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(frequency)),
|
_ => 0m,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static decimal ToAnnually(decimal amount, Frequency frequency) =>
|
public static decimal ToAnnually(decimal amount, Frequency frequency) =>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
.HasColumnType("xid")
|
.HasColumnType("xid")
|
||||||
.ValueGeneratedOnAddOrUpdate()
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
.IsConcurrencyToken();
|
.IsConcurrencyToken();
|
||||||
|
b.HasIndex(x => x.OwnerUserId);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Income>(b =>
|
modelBuilder.Entity<Income>(b =>
|
||||||
@@ -34,6 +35,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
b.Property(x => x.Name).IsRequired().HasMaxLength(200);
|
b.Property(x => x.Name).IsRequired().HasMaxLength(200);
|
||||||
b.Property(x => x.Amount).HasPrecision(18, 2);
|
b.Property(x => x.Amount).HasPrecision(18, 2);
|
||||||
b.HasQueryFilter(x => !x.IsDeleted);
|
b.HasQueryFilter(x => !x.IsDeleted);
|
||||||
|
b.HasIndex(x => new { x.BudgetId, x.IsDeleted });
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Outgo>(b =>
|
modelBuilder.Entity<Outgo>(b =>
|
||||||
@@ -45,6 +47,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
b.Property(x => x.Notes).HasMaxLength(1000);
|
b.Property(x => x.Notes).HasMaxLength(1000);
|
||||||
b.Property(x => x.Amount).HasPrecision(18, 2);
|
b.Property(x => x.Amount).HasPrecision(18, 2);
|
||||||
b.HasQueryFilter(x => !x.IsDeleted);
|
b.HasQueryFilter(x => !x.IsDeleted);
|
||||||
|
b.HasIndex(x => new { x.BudgetId, x.IsDeleted });
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<KnownUser>(b =>
|
modelBuilder.Entity<KnownUser>(b =>
|
||||||
@@ -61,6 +64,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
b.Property(x => x.SharedWithUserId).HasMaxLength(200);
|
b.Property(x => x.SharedWithUserId).HasMaxLength(200);
|
||||||
b.Property(x => x.SharedWithEmail).IsRequired().HasMaxLength(200);
|
b.Property(x => x.SharedWithEmail).IsRequired().HasMaxLength(200);
|
||||||
b.HasIndex(x => new { x.BudgetId, x.SharedWithEmail }).IsUnique();
|
b.HasIndex(x => new { x.BudgetId, x.SharedWithEmail }).IsUnique();
|
||||||
|
b.HasIndex(x => x.SharedWithUserId);
|
||||||
|
b.HasIndex(x => new { x.IsPending, x.SharedWithEmail });
|
||||||
b.HasQueryFilter(x => !x.IsDeleted);
|
b.HasQueryFilter(x => !x.IsDeleted);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+271
@@ -0,0 +1,271 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Budget.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Budget.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260507024323_AddHardeningIndexes")]
|
||||||
|
partial class AddHardeningIndexes
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Budget.Core.Models.Budget", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("OwnerUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<uint>("xmin")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
|
b.ToTable("Budgets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Budget.Core.Models.BudgetShare", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("BudgetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPending")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("Permission")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("SharedWithEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SharedWithUserId")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SharedWithUserId");
|
||||||
|
|
||||||
|
b.HasIndex("BudgetId", "SharedWithEmail")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("IsPending", "SharedWithEmail");
|
||||||
|
|
||||||
|
b.ToTable("BudgetShares");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Budget.Core.Models.Income", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<Guid>("BudgetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Frequency")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BudgetId", "IsDeleted");
|
||||||
|
|
||||||
|
b.ToTable("Incomes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Budget.Core.Models.KnownUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("LastSeenAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("KnownUsers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Budget.Core.Models.Outgo", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<Guid>("BudgetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Frequency")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("PaymentSource")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BudgetId", "IsDeleted");
|
||||||
|
|
||||||
|
b.ToTable("Outgos");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Budget.Core.Models.BudgetShare", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Budget.Core.Models.Budget", "Budget")
|
||||||
|
.WithMany("Shares")
|
||||||
|
.HasForeignKey("BudgetId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Budget");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Budget.Core.Models.Income", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Budget.Core.Models.Budget", "Budget")
|
||||||
|
.WithMany("Incomes")
|
||||||
|
.HasForeignKey("BudgetId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Budget");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Budget.Core.Models.Outgo", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Budget.Core.Models.Budget", "Budget")
|
||||||
|
.WithMany("Outgos")
|
||||||
|
.HasForeignKey("BudgetId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Budget");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Budget.Core.Models.Budget", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Incomes");
|
||||||
|
|
||||||
|
b.Navigation("Outgos");
|
||||||
|
|
||||||
|
b.Navigation("Shares");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Budget.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddHardeningIndexes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Outgos_BudgetId",
|
||||||
|
table: "Outgos");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Incomes_BudgetId",
|
||||||
|
table: "Incomes");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Outgos_BudgetId_IsDeleted",
|
||||||
|
table: "Outgos",
|
||||||
|
columns: new[] { "BudgetId", "IsDeleted" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Incomes_BudgetId_IsDeleted",
|
||||||
|
table: "Incomes",
|
||||||
|
columns: new[] { "BudgetId", "IsDeleted" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BudgetShares_IsPending_SharedWithEmail",
|
||||||
|
table: "BudgetShares",
|
||||||
|
columns: new[] { "IsPending", "SharedWithEmail" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BudgetShares_SharedWithUserId",
|
||||||
|
table: "BudgetShares",
|
||||||
|
column: "SharedWithUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Budgets_OwnerUserId",
|
||||||
|
table: "Budgets",
|
||||||
|
column: "OwnerUserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Outgos_BudgetId_IsDeleted",
|
||||||
|
table: "Outgos");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Incomes_BudgetId_IsDeleted",
|
||||||
|
table: "Incomes");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_BudgetShares_IsPending_SharedWithEmail",
|
||||||
|
table: "BudgetShares");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_BudgetShares_SharedWithUserId",
|
||||||
|
table: "BudgetShares");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Budgets_OwnerUserId",
|
||||||
|
table: "Budgets");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Outgos_BudgetId",
|
||||||
|
table: "Outgos",
|
||||||
|
column: "BudgetId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Incomes_BudgetId",
|
||||||
|
table: "Incomes",
|
||||||
|
column: "BudgetId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,8 @@ namespace Budget.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
b.ToTable("Budgets");
|
b.ToTable("Budgets");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,9 +98,13 @@ namespace Budget.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SharedWithUserId");
|
||||||
|
|
||||||
b.HasIndex("BudgetId", "SharedWithEmail")
|
b.HasIndex("BudgetId", "SharedWithEmail")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("IsPending", "SharedWithEmail");
|
||||||
|
|
||||||
b.ToTable("BudgetShares");
|
b.ToTable("BudgetShares");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,7 +140,7 @@ namespace Budget.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("BudgetId");
|
b.HasIndex("BudgetId", "IsDeleted");
|
||||||
|
|
||||||
b.ToTable("Incomes");
|
b.ToTable("Incomes");
|
||||||
});
|
});
|
||||||
@@ -210,7 +216,7 @@ namespace Budget.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("BudgetId");
|
b.HasIndex("BudgetId", "IsDeleted");
|
||||||
|
|
||||||
b.ToTable("Outgos");
|
b.ToTable("Outgos");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Budget.Infrastructure.Data;
|
||||||
|
|
||||||
|
public class SlowQueryInterceptor(ILogger<SlowQueryInterceptor> logger) : DbCommandInterceptor
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Threshold = TimeSpan.FromMilliseconds(500);
|
||||||
|
|
||||||
|
public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
|
||||||
|
{
|
||||||
|
LogIfSlow(command, eventData.Duration);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<DbDataReader> ReaderExecutedAsync(DbCommand command, CommandExecutedEventData eventData, DbDataReader result, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
LogIfSlow(command, eventData.Duration);
|
||||||
|
return new ValueTask<DbDataReader>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result)
|
||||||
|
{
|
||||||
|
LogIfSlow(command, eventData.Duration);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<int> NonQueryExecutedAsync(DbCommand command, CommandExecutedEventData eventData, int result, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
LogIfSlow(command, eventData.Duration);
|
||||||
|
return new ValueTask<int>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result)
|
||||||
|
{
|
||||||
|
LogIfSlow(command, eventData.Duration);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<object?> ScalarExecutedAsync(DbCommand command, CommandExecutedEventData eventData, object? result, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
LogIfSlow(command, eventData.Duration);
|
||||||
|
return new ValueTask<object?>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogIfSlow(DbCommand command, TimeSpan duration)
|
||||||
|
{
|
||||||
|
if (duration >= Threshold)
|
||||||
|
logger.LogWarning("Slow query ({Duration}ms): {CommandText}",
|
||||||
|
(long)duration.TotalMilliseconds, command.CommandText);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
$image = "docker.stwaddle.com/budget:latest"
|
|
||||||
docker build -t $image .
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
docker push $image
|
|
||||||
ssh stwaddle_com "cd stwaddlecom; ./update-budget.sh"
|
|
||||||
Reference in New Issue
Block a user