Compare commits

..

10 Commits

Author SHA1 Message Date
Spencer Twaddle 1de8fce103 Add Gitea build workflow
Build and Push / build (push) Failing after 2m4s
2026-06-20 17:10:01 -05:00
Spencer Twaddle ad5406a5d7 Fix middleware execution order so rate limiting comes after authentication. 2026-06-20 13:43:18 -05:00
Spencer Twaddle 1c4cc3c79f Updated deploy script, and reduced logging noise 2026-05-07 06:43:04 -05:00
Spencer Twaddle 4fa35dadc3 Fix reorder: normalize SortOrder after move, remove AsNoTracking from write path
The shift-in-place approach broke when SortOrder values had gaps from soft
deletes. Switch to remove/insert then assign SortOrder = index, which is
correct regardless of initial values. Also fix OutgosController Reorder
which had AsNoTracking applied from the M-3 change, silently dropping saves.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:24:30 -05:00
Spencer Twaddle b82b3939ce Improve deploy script reliability
Switch from explicit down/up to --pull always to avoid unnecessary network
teardown. Add exit code check on SSH command and a post-deploy status check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:22:10 -05:00
Spencer Twaddle ac3dcc2f31 Security & resource hardening: eliminate CPU/disk attack surface
Addresses production CPU spike incident. Key changes:

- Guard OTel exporter behind OTEL_EXPORTER_OTLP_ENDPOINT env var; filter
  tracing to /api paths only — unconditional export was primary suspect
- Remove /healthz endpoint entirely (unauthenticated, hit DB on every call)
- Replace KnownUserMiddleware with POST /api/users/me called once on login
  from TokenSync — eliminates unconditional DB write on every request
- Add DB indexes: (BudgetId, IsDeleted) on Incomes/Outgos, OwnerUserId on
  Budgets, SharedWithUserId and (IsPending, SharedWithEmail) on BudgetShares
- Move UseRateLimiter() before UseStaticFiles() so all requests are throttled
- Replace full-array reorder with move-by-position (id + newIndex) — bounded
  input, fewer DB writes, better API design
- Lock ForwardedHeaders to 172.20.0.0/16 subnet; fixes KnownNetworks
  deprecation warning (0 warnings in build now)
- Add AsNoTracking() to all read-only queries in Summary/Incomes/OutgosController
- FrequencyCalculator returns 0 for unknown enum values instead of throwing
- Thread.Sleep → await Task.Delay in OIDC startup loop
- AllowedHosts locked to budget.stwaddle.com

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:17:18 -05:00
Spencer Twaddle 69ec754775 Updated CLAUDE.md and deployment script 2026-05-05 07:38:31 -05:00
Spencer Twaddle efde0f952b Updated plan to fix issues 2026-05-04 17:42:44 -05:00
Spencer Twaddle 8d4d7c7ce3 Fix OTel wiring to match working Auth implementation
Three corrections vs Auth project:
- Replace AddOtlpExporter() (traces only) with UseOtlpExporter() so both
  traces and logs are exported via OTLP
- Remove redundant .WithLogging() call; builder.Logging.AddOpenTelemetry()
  is sufficient on its own
- Pass OTEL_EXPORTER_OTLP_ENDPOINT/PROTOCOL through from host env vars
  instead of hardcoding the collector URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 17:34:45 -05:00
Spencer Twaddle bfd5880b9c Phase 2: Add OTel env vars and telemetry network to docker-compose
Wires the budget container to the OTel Collector via the shared external
telemetry network. Endpoint, protocol, and service name come from env vars
so the collector address is not baked into application code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 17:29:30 -05:00
30 changed files with 952 additions and 172 deletions
+3
View File
@@ -7,3 +7,6 @@ POSTGRES_PASSWORD=changeme
# Note: client OIDC values live in src/Budget.Client/.env (committed).
# 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
+71
View File
@@ -0,0 +1,71 @@
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: 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'
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- 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 }}
+138
View File
@@ -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)
+74
View File
@@ -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.
+88
View File
@@ -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`
-4
View File
@@ -1,4 +0,0 @@
$image = "docker.stwaddle.com/budget:latest"
docker build -t $image .
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
docker push $image
+15
View File
@@ -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 }
-40
View File
@@ -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 (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
var incomes = await db.Incomes
.AsNoTracking()
.Where(i => i.BudgetId == budgetId)
.OrderBy(i => i.SortOrder)
.ToListAsync();
@@ -94,17 +95,20 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
[HttpPut("order")]
[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 (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
var lookup = incomes.ToDictionary(i => i.Id);
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
{
if (lookup.TryGetValue(req.OrderedIds[idx], out var income))
income.SortOrder = idx;
}
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).OrderBy(i => i.SortOrder).ToListAsync();
var item = incomes.FirstOrDefault(i => i.Id == req.Id);
if (item is null) return NotFound();
var oldIdx = incomes.IndexOf(item);
var newIdx = Math.Clamp(req.NewIndex, 0, incomes.Count - 1);
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();
return NoContent();
}
+13 -10
View File
@@ -36,7 +36,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
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));
}
@@ -45,7 +45,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
{
if (TryGetUserId(out var userId) is { } err) return err;
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);
return Ok(outgos.Select(o => ToDto(o, monthlyIncome)));
}
@@ -112,17 +112,20 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
[HttpPut("order")]
[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 (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
var lookup = outgos.ToDictionary(o => o.Id);
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
{
if (lookup.TryGetValue(req.OrderedIds[idx], out var outgo))
outgo.SortOrder = idx;
}
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
var item = outgos.FirstOrDefault(o => o.Id == req.Id);
if (item is null) return NotFound();
var oldIdx = outgos.IndexOf(item);
var newIdx = Math.Clamp(req.NewIndex, 0, outgos.Count - 1);
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();
return NoContent();
}
@@ -29,11 +29,11 @@ public class SummaryController(AppDbContext db, BudgetAuthorizationService authz
if (TryGetUserId(out var userId) is { } err) return err;
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();
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
var incomes = await db.Incomes.AsNoTracking().Where(i => i.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 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();
}
}
+41 -29
View File
@@ -3,33 +3,36 @@ using Budget.Api.Services;
using Budget.Infrastructure.Data;
using Budget.Infrastructure.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using OpenTelemetry;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddOpenTelemetry(logging =>
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
if (!string.IsNullOrEmpty(otlpEndpoint))
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
});
builder.Services.AddOpenTelemetry()
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(serviceName: "budget", serviceVersion: "1.0.0"))
.WithLogging()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddAspNetCoreInstrumentation(opts =>
opts.Filter = ctx => ctx.Request.Path.StartsWithSegments("/api"))
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter());
.AddEntityFrameworkCoreInstrumentation())
.UseOtlpExporter();
}
var connStr = builder.Configuration.GetConnectionString("DefaultConnection")
?? $"Host={builder.Configuration["POSTGRES_HOST"] ?? "localhost"};" +
@@ -38,13 +41,32 @@ var connStr = builder.Configuration.GetConnectionString("DefaultConnection")
$"Username={builder.Configuration["POSTGRES_USER"] ?? "budget"};" +
$"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 =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto
| ForwardedHeaders.XForwardedHost;
options.KnownIPNetworks.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");
@@ -109,9 +131,6 @@ builder.Services.AddControllers()
.AddJsonOptions(opts =>
opts.JsonSerializerOptions.Converters.Add(
new System.Text.Json.Serialization.JsonStringEnumConverter()));
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Block startup until OIDC discovery succeeds (handles auth/app container race).
@@ -126,6 +145,8 @@ while (true)
jwtOpts.ConfigurationManager!.RequestRefresh();
await jwtOpts.ConfigurationManager!.GetConfigurationAsync(CancellationToken.None);
startupLogger.LogInformation("OIDC discovery succeeded");
startupLogger.LogInformation("Trusted proxy networks: {Networks}",
app.Configuration["Budget:TrustedProxyNetworks"] ?? "172.16.0.0/12 (default)");
break;
}
catch (Exception ex)
@@ -136,7 +157,7 @@ while (true)
break;
}
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.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.UseMiddleware<KnownUserMiddleware>();
app.UseRateLimiter();
app.UseAuthorization();
app.UseRateLimiter();
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.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);
}
}
+3 -2
View File
@@ -2,10 +2,11 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"AllowedHosts": "budget.stwaddle.com",
"ConnectionStrings": {
"DefaultConnection": ""
},
+1 -1
View File
@@ -24,7 +24,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
export const api = {
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),
delete: (path: string) => request<void>('DELETE', path),
};
+2 -2
View File
@@ -4,7 +4,7 @@ import type { IncomeDto, Frequency } from '../types/index';
interface CreateIncomeRequest { 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) {
return useQuery({
@@ -44,7 +44,7 @@ export function useDeleteIncome(budgetId: string) {
export function useReorderIncomes(budgetId: string) {
const qc = useQueryClient();
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'] }),
meta: { errorMessage: 'Failed to save order' },
});
+2 -2
View File
@@ -7,7 +7,7 @@ interface CreateOutgoRequest {
frequency: Frequency; amount: number; paymentSource: string | null; notes: string | null;
}
interface UpdateOutgoRequest extends CreateOutgoRequest { id: string; }
interface ReorderRequest { orderedIds: string[]; }
interface MoveRequest { id: string; newIndex: number; }
export function useOutgos(budgetId: string) {
return useQuery({
@@ -61,7 +61,7 @@ export function useDeleteOutgo(budgetId: string) {
export function useReorderOutgos(budgetId: string) {
const qc = useQueryClient();
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'] }),
meta: { errorMessage: 'Failed to save order' },
});
+6 -1
View File
@@ -1,11 +1,16 @@
import { useEffect } from 'react';
import { useAuth } from 'react-oidc-context';
import { setTokenProvider } from '../api/client';
import { setTokenProvider, api } from '../api/client';
export function TokenSync() {
const auth = useAuth();
useEffect(() => {
setTokenProvider(() => auth.user?.access_token ?? null);
if (auth.user?.access_token) {
api.post<void>('/api/users/me', undefined).catch(() => {});
}
}, [auth.user]);
return null;
}
+2 -3
View File
@@ -238,9 +238,8 @@ export function IncomePage() {
if (!over || active.id === over.id) return;
const oldIdx = displayItems.findIndex(i => i.id === active.id);
const newIdx = displayItems.findIndex(i => i.id === over.id);
const reordered = arrayMove(displayItems, oldIdx, newIdx);
setDisplayItems(reordered);
reorderIncomes.mutate({ orderedIds: reordered.map(i => i.id) });
setDisplayItems(items => arrayMove(items, oldIdx, newIdx));
reorderIncomes.mutate({ id: active.id as string, newIndex: newIdx });
};
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>;
+2 -3
View File
@@ -384,9 +384,8 @@ export function OutgoPage() {
if (!over || active.id === over.id) return;
const oldIdx = displayItems.findIndex(o => o.id === active.id);
const newIdx = displayItems.findIndex(o => o.id === over.id);
const reordered = arrayMove(displayItems, oldIdx, newIdx);
setDisplayItems(reordered);
reorderOutgos.mutate({ orderedIds: reordered.map(o => o.id) });
setDisplayItems(items => arrayMove(items, oldIdx, newIdx));
reorderOutgos.mutate({ id: active.id as string, newIndex: newIdx });
};
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;
+1 -1
View File
@@ -23,4 +23,4 @@ public record UpdateIncomeRequest(
Frequency Frequency,
[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);
+1 -1
View File
@@ -36,4 +36,4 @@ public record UpdateOutgoRequest(
[MaxLength(100)] string? PaymentSource,
[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.Biweekly => amount * 26m / 12m,
Frequency.Weekly => amount * 52m / 12m,
_ => throw new ArgumentOutOfRangeException(nameof(frequency)),
_ => 0m,
};
public static decimal ToAnnually(decimal amount, Frequency frequency) =>
@@ -26,6 +26,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
.HasColumnType("xid")
.ValueGeneratedOnAddOrUpdate()
.IsConcurrencyToken();
b.HasIndex(x => x.OwnerUserId);
});
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.Amount).HasPrecision(18, 2);
b.HasQueryFilter(x => !x.IsDeleted);
b.HasIndex(x => new { x.BudgetId, x.IsDeleted });
});
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.Amount).HasPrecision(18, 2);
b.HasQueryFilter(x => !x.IsDeleted);
b.HasIndex(x => new { x.BudgetId, x.IsDeleted });
});
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.SharedWithEmail).IsRequired().HasMaxLength(200);
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);
});
}
@@ -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.HasIndex("OwnerUserId");
b.ToTable("Budgets");
});
@@ -96,9 +98,13 @@ namespace Budget.Infrastructure.Data.Migrations
b.HasKey("Id");
b.HasIndex("SharedWithUserId");
b.HasIndex("BudgetId", "SharedWithEmail")
.IsUnique();
b.HasIndex("IsPending", "SharedWithEmail");
b.ToTable("BudgetShares");
});
@@ -134,7 +140,7 @@ namespace Budget.Infrastructure.Data.Migrations
b.HasKey("Id");
b.HasIndex("BudgetId");
b.HasIndex("BudgetId", "IsDeleted");
b.ToTable("Incomes");
});
@@ -210,7 +216,7 @@ namespace Budget.Infrastructure.Data.Migrations
b.HasKey("Id");
b.HasIndex("BudgetId");
b.HasIndex("BudgetId", "IsDeleted");
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);
}
}
-5
View File
@@ -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"