Ships the cross-cutting hardening set: - SecurityHeadersMiddleware: per-request nonce-based CSP, HSTS (production only), Referrer-Policy, Permissions-Policy, X-Content-Type-Options, frame-ancestors 'none', form-action 'self'. - AccessLogMiddleware: one http_request INFO event per request (method/path/status/duration_ms/ip/ua). Skips /healthz, redacts /admin/auth/consume/<token> paths, logs 500 + re-raises on downstream exceptions. - Public base.html inline nav-toggle script gets a nonce so it passes strict CSP without relaxing to 'unsafe-inline'. - Dockerfile: non-root app user (uid/gid 10001) + stdlib-only HEALTHCHECK against /healthz. - scripts/backup.sh: sqlite3 .backup + tar data/media with 14-entry retention; host-side cron install documented. - .gitea/workflows/build-image.yml: on push to master / workflow_dispatch, builds and publishes git.sneakygeek.net/ptarrant/chicken_babies_site:latest + sha-<short>, with GIT_COMMIT_SHA threaded as a build-arg so /healthz keeps reporting the right commit in deployed images. - 8 new tests (security headers + access log). Pre-existing dev failures (logo asset rename + RESEND env pollution) remain unchanged; verified not Phase 6 regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
554 lines
51 KiB
Markdown
554 lines
51 KiB
Markdown
# Chicken Babies R Us — Roadmap
|
||
|
||
High-level phased plan. Each phase ends in a mergeable `dev` state and a passing manual test. Claude implements phase-by-phase. This document intentionally avoids application code; the only code here is **data model (dataclasses)** and **SQL schema**, which are authoritative.
|
||
|
||
---
|
||
|
||
## Phase 0 — Foundation ✅
|
||
|
||
**Completed:** 2026-04-21
|
||
|
||
**Summary:** Scaffolded the FastAPI skeleton — package layout, pinned deps, multi-stage Dockerfile, compose file, typed config loader, structlog init, and a `/healthz` liveness endpoint surfacing app version + git commit SHA.
|
||
|
||
**Key files:**
|
||
- `app/__init__.py` — package `__version__ = "0.1.0"`
|
||
- `app/main.py` — `create_app()` factory + module-level `app`; configures logging then mounts routers
|
||
- `app/config.py` — `Settings(BaseSettings)` with the full env contract (Optional where unused in Phase 0) + model validator refusing the dev-sentinel `SECRET_KEY` in production; `get_settings()` is `lru_cache`-d
|
||
- `app/logging_config.py` — `configure_logging(app_env)`; `ConsoleRenderer` in dev, `JSONRenderer` otherwise
|
||
- `app/routes/health.py` — `APIRouter` mounting `GET /healthz`, typed `HealthResponse` pydantic model
|
||
- `app/models/`, `app/services/`, `app/templates/`, `app/static/` — placeholder dirs with `.gitkeep`
|
||
- `tests/test_healthz.py` — FastAPI `TestClient` smoke test
|
||
- `requirements.txt` — 17 pinned packages, `>=X,<next-major` ranges
|
||
- `.env.example` — public env contract; `.env` stays gitignored
|
||
- `Dockerfile` — multi-stage (`builder` + `runtime`), `python:3.12-slim-bookworm`, `libmagic1` runtime; root user and no HEALTHCHECK (Phase 6 hardening)
|
||
- `docker-compose.yml` — `web` service with `env_file: .env`, `./data:/app/data` bind mount, `GIT_COMMIT_SHA` build arg
|
||
- `.gitignore` — adjusted `data/` rule to `data/*` so `!data/.gitkeep` works
|
||
|
||
**Endpoints created:**
|
||
- `GET /healthz` — public, unauthenticated. Returns minimal flat JSON `{"status":"ok","version":"0.1.0","commit_sha":"<sha-or-unknown>"}`.
|
||
|
||
**Key details:**
|
||
- **Healthz shape:** minimal flat JSON (not the full code_guidelines envelope). Future phases that add JSON APIs will use the envelope; the healthcheck stays small on purpose.
|
||
- **Version source:** `app.__version__` string constant. **Commit SHA source:** `GIT_COMMIT_SHA` env var, baked into the image via `ARG GIT_COMMIT_SHA=unknown` + `ENV GIT_COMMIT_SHA`. Surfaces as `"unknown"` when unset (dev).
|
||
- **Config loader:** single `Settings` class covers every ROADMAP env-var row. Fields not yet used at Phase 0 (`RESEND_*`, `HCAPTCHA_*`, `ADMIN_CONTACT_EMAIL`) are `Optional[str] = None`. `SECRET_KEY` defaults to the sentinel `"dev-insecure-change-me"`; a `@model_validator(mode="after")` refuses to boot if that sentinel survives into `APP_ENV=production`.
|
||
- **Admin emails:** stored as raw comma-separated string; `admin_emails_list` property returns stripped+lowercased list for allowlist comparisons (used by Phase 3 auth).
|
||
- **Logging:** `configure_logging` runs inside `create_app()` before any `structlog.get_logger` call; `app_started` structured event fires once at startup with `app_env`, `version`, `commit_sha` (no secrets).
|
||
- **Docker CMD:** uvicorn runs with `--proxy-headers --forwarded-allow-ips=127.0.0.1` (Phase 6 will swap the IP for Caddy's LAN address).
|
||
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 1 passed ✓ · `curl /healthz` returned both the default `"unknown"` payload and the real commit SHA when `GIT_COMMIT_SHA=$(git rev-parse HEAD)` was set ✓ · `docker compose config` exit 0 ✓.
|
||
- **Branch:** built on `chore/phase-0-foundation` off `dev`; merged `--no-ff` into `dev` on completion. Not pushed.
|
||
|
||
## Phase 1 — Public Site Skeleton ✅
|
||
|
||
**Completed:** 2026-04-21
|
||
|
||
**Summary:** Shipped the public brochure site: base Jinja layout with logo + nav + footer, mobile-first single-file CSS using the ROADMAP palette, and four public routes (`/`, `/about`, `/contact`, `/shop`). Blog index renders via a service stub returning `[]`; Phase 2 swaps the body for SQLite without touching the route.
|
||
|
||
**Key files:**
|
||
- `app/models/posts.py` — `PostSummary` `@dataclass(frozen=True)` with `slug/title/published_at/excerpt` — list-view projection used by the homepage; richer `Post` arrives in Phase 2 alongside.
|
||
- `app/services/posts.py` — `PostService.list_published(limit=20) -> list[PostSummary]` stub returning `[]`; `get_post_service()` DI helper (Phase 2 keeps the signature, swaps the body).
|
||
- `app/routes/public.py` — `APIRouter` with `GET /`, `/about`, `/contact`, `/shop`; pulls templates off `app.state.templates` via `get_templates()` DI helper.
|
||
- `app/templates/public/base.html` — layout: skip link, `<header>`/`<nav>`/`<main>`/`<footer>`, `aria-current` on active nav item, `<picture>` logo (WebP + PNG fallback), favicon/apple-touch-icon links, mobile nav toggle via plain `addEventListener` script.
|
||
- `app/templates/public/home.html` — blog index; loops `_post_card.html` or renders "No posts yet — check back soon!" on empty list.
|
||
- `app/templates/public/about.html` — static placeholder copy (Head Hen rewrites via Phase 4 admin).
|
||
- `app/templates/public/contact.html` — inert form: all inputs `disabled`, no `method="POST"`, `action=""`, shows `mailto:` link only if `settings.admin_contact_email` is truthy.
|
||
- `app/templates/public/shop.html` — "Coming soon" card teasing eggs / chicks / waterfowl.
|
||
- `app/templates/public/partials/_post_card.html` — single post-card partial.
|
||
- `app/static/css/site.css` — single stylesheet: reset, `:root` palette tokens (`--c-sky`, `--c-sky-deep`, `--c-cream`, `--c-wheat`, `--c-ink`, `--c-leaf`) + spacing/radius scale, system font stacks, components, one 48rem breakpoint.
|
||
- `app/static/img/logo.png` (573×256 RGBA), `logo.webp` (q=82, method=6), `favicon.ico` (16/32/48), `apple-touch-icon.png` (180×180 on `#FAF3E7`).
|
||
- `scripts/generate_static_assets.py` — Pillow CLI `StaticAssetBuilder` that regenerates the four image assets from `Logo/chicken babies r us.png`; committed for reproducibility.
|
||
- `docs/MANUAL_TESTING.md` — per-route + responsive (360/768/1280px) + a11y + static-assets checklist.
|
||
- `tests/test_public_routes.py` — 7 tests (4 parametrized route smokes + empty-state copy + logo path + `aria-current`).
|
||
- `app/main.py` — modified: `StaticFiles` mount at `/static`, `Jinja2Templates` instantiated once onto `app.state.templates`, `public_router` included; `create_app()` stays idempotent.
|
||
|
||
**Endpoints created:**
|
||
- `GET /` — blog index (empty-state message until Phase 2 seeds content).
|
||
- `GET /about` — static About page.
|
||
- `GET /contact` — inert form + optional `mailto:` from `ADMIN_CONTACT_EMAIL`. Phase 5 replaces with a working POST.
|
||
- `GET /shop` — "Coming soon" card.
|
||
- `Mount /static` — `StaticFiles` serving `app/static/{css,img}` (and `fonts/` later if ever needed).
|
||
|
||
**Key details:**
|
||
- **Stable seam for Phase 2:** `PostService.list_published()` + `PostSummary` names are fixed contracts. Phase 2 only changes the method body to hit SQLite.
|
||
- **No DB, no CSRF, no CSP, no auth, no contact POST.** Scope held strictly to the roadmap's Phase 1 bullets.
|
||
- **Templates live under `app/templates/public/`** to reserve `app/templates/admin/` and `app/templates/emails/` for Phases 3–5.
|
||
- **Logo delivery:** `<picture><source type="image/webp"><img alt="Chicken Babies R Us" height="48"></picture>` — modern browsers pull the WebP, older ones fall back to PNG.
|
||
- **Address still not rendered.** Only city + state ("Morrison, Tennessee") appear per CLAUDE.md's "address is intentionally not displayed" wording.
|
||
- **No new packages.** Pillow / Jinja2 / Starlette StaticFiles were already in `requirements.txt` from Phase 0.
|
||
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 8 passed ✓ · uvicorn smoke: `/`, `/about`, `/contact`, `/shop`, `/healthz`, `/static/css/site.css`, `/static/img/logo.webp` all 200 with correct content-types ✓ · homepage body contains "No posts yet" + logo paths ✓ · contact page has `disabled` inputs and no `method` attribute ✓ · `docker compose config` exit 0 ✓.
|
||
|
||
## Phase 2 — Content Model + Cache ✅
|
||
|
||
**Completed:** 2026-04-21
|
||
|
||
**Summary:** Stood up the full SQLite content layer: all 7 tables from the authoritative schema, entity dataclasses + row mappers, hand-rolled versioned migrations with a `schema_migrations` tracker, idempotent Python seed (system user + welcome post + About page), a Markdown→HTML service with a strict bleach allowlist, a typed in-process TTL cache, and DB-backed `PostService` / `PageService`. `/` and `/about` now read from the DB.
|
||
|
||
**Key files:**
|
||
- `app/db.py` — `create_engine()` factory, per-connection PRAGMA listener (WAL + `foreign_keys=ON`), `run_migrations(engine)` runner that scans `app/models/migrations/*.sql` in lex order and records applications in `schema_migrations`.
|
||
- `app/models/entities.py` — all 8 dataclasses (User, MagicLinkToken, Session, Page, Post, Media, ContactSubmission, AuthEvent) + `PostStatus(str, Enum)` matching roadmap 1:1. NOT frozen — Phase 3+ mutate `last_login_at`, `used_at`, etc.
|
||
- `app/models/mappers.py` — `row_to_user/post/page/...` converters, `_parse_datetime` / `_parse_bool` helpers.
|
||
- `app/models/migrations/001_init.sql` — verbatim roadmap schema: 7 tables, `idx_magic_email_created`, `idx_posts_status_pub`, `idx_auth_events_created`, `CHECK (status IN ('draft','published'))`.
|
||
- `app/models/seed.py` — idempotent: marker `seed_001` in `schema_migrations` + `INSERT OR IGNORE` belt-and-braces. Seeds user id=1 (`seed@chickenbabies.local`, "Head Hen", `active=0` — not a real admin, cannot log in), post slug `welcome-to-the-farm`, page slug `about`.
|
||
- `app/services/cache.py` — `TTLCache[K, V]` generic (~50 lines). `get/set/invalidate_all()`. Monotonic clock. 60s default TTL.
|
||
- `app/services/markdown.py` — `MarkdownService.render(md) -> str`: `MarkdownIt("commonmark")` (tables disabled — allowlist doesn't include `<table>`) → `bleach.clean(..., strip=True)` with tags `{p br strong em a ul ol li h1..h4 blockquote code pre img hr}`, attrs `{a:[href,title,rel], img:[src,alt,title,width,height]}`, protocols `{http https mailto}`. No `style`, no `class`, no raw HTML pass-through.
|
||
- `app/services/posts.py` — rewritten: `PostService(engine)` runs parameterized `SELECT ... FROM posts WHERE status='published' ORDER BY published_at DESC LIMIT :limit`, converts rows to `PostSummary`. Excerpt derived from `body_html_cached`. TTL-cached. `invalidate_all()` exposed for Phase 4.
|
||
- `app/services/pages.py` — `PageService(engine).get_by_slug(slug) -> Page | None`, TTL-cached.
|
||
- `app/main.py` — wires engine, runs migrations, runs seed, instantiates services onto `app.state.{engine,post_service,page_service}`.
|
||
- `app/routes/public.py` — `/about` now pulls the seeded `Page` from `PageService`; renders `{{ page.title }}` + `{{ page.body_html_cached | safe }}`. Logs an anomaly and returns 500 with a generic message if the page is unexpectedly missing.
|
||
- `app/templates/public/about.html` — replaced static body with the dynamic page; layout kept.
|
||
- `tests/conftest.py` — `db_engine` (session, seeded) + `clean_db_engine` (function, migrated-only) fixtures, both on temp SQLite files.
|
||
- `tests/test_db_migrations.py`, `test_markdown.py`, `test_cache.py`, `test_post_service.py`, `test_page_service.py` — service + schema coverage.
|
||
- `tests/test_public_routes.py` — updated: homepage now asserts "Welcome to the Farm"; `/about` asserts seeded Markdown substring.
|
||
|
||
**Endpoints created:** none new; `/` and `/about` were rewired to DB-backed services (same URLs, same response shapes).
|
||
|
||
**Key details:**
|
||
- **Migration pattern:** every SQL file under `app/models/migrations/` gets its own transaction; already-applied files are skipped by checking `schema_migrations`. Adding a new phase = add `NNN_description.sql`. The bootstrap for `schema_migrations` itself is baked into the runner (creates the table before querying it).
|
||
- **PRAGMAs are per-connection** via `@event.listens_for(Engine, "connect")` — every pooled connection gets WAL + FK-on, not just the first. There's an explicit test covering this.
|
||
- **Seed idempotency is double-guarded:** `schema_migrations` marker `seed_001` + `INSERT OR IGNORE` on every row. Second boot logs `seed_skipped`; counts stay 1/1/1 (users/pages/posts).
|
||
- **PostSummary excerpt is derived from `body_html_cached`** (HTML-stripped + truncated), not re-rendered from `body_md`. Phase 4 writers store both; readers never touch Markdown.
|
||
- **No Markdown tables yet.** `MarkdownIt.enable("table")` was deliberately NOT called — the bleach allowlist doesn't pass `<table>`. Future tables require widening both layers together; a test documents this invariant.
|
||
- **Address still not rendered.** Seeded About Markdown mentions Morrison, TN only (no street address, per CLAUDE.md).
|
||
- **Phase 3 hooks ready:** `users` / `magic_link_tokens` / `sessions` / `auth_events` tables exist with their indexes; `User` dataclass + `PostStatus` enum + row-mapper helpers available.
|
||
- **Phase 4 hooks ready:** `PostService.invalidate_all()` + `PageService.invalidate_all()` exist (no-op callers today). Admin writes will call these after each mutation.
|
||
- **No new packages.** All deps were already pinned in Phase 0's `requirements.txt`.
|
||
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 36 passed ✓ · fresh-boot smoke: `/` shows welcome title, `/about` shows seeded Markdown, `/healthz` 200 ✓ · `PRAGMA journal_mode=wal` ✓ · second boot logs `migrations_up_to_date` + `seed_skipped`, table counts stay `users=1 pages=1 posts=1` ✓ · `docker compose config` exit 0 ✓.
|
||
|
||
## Phase 3 — Admin Auth (Magic Link) ✅
|
||
|
||
**Completed:** 2026-04-21
|
||
|
||
**Summary:** Passwordless admin auth end-to-end: email form → 256-bit magic-link token (SHA-256 at rest, 15-min TTL, atomic single-use consume) → Resend email with dev-log fallback → itsdangerous-signed server-side session cookie (30d) → `/admin` landing → logout revokes the row without deleting. SlowAPI per-IP + DB per-email rate limits, `ADMIN_EMAILS` allowlist with anti-enumeration, every event audited to `auth_events`.
|
||
|
||
**Key files:**
|
||
- `app/services/audit.py` — `AuditService.record(event_type, ...)` writes an `auth_events` row + mirrors the event to structlog; `detail` is JSON. Referenced session by last-6 hash chars only.
|
||
- `app/services/email.py` — `EmailService.send_magic_link(...)`: renders HTML + text templates, posts via Resend when `resend_api_key` set, otherwise logs `magic_link_dev_fallback` with the URL (dev shortcut). Never raises from the request path; production startup refuses to boot without the key.
|
||
- `app/services/sessions.py` — `SessionService(engine, signer, settings)` — `create/lookup/revoke`. Raw session ID exists only in memory and the signed cookie; DB stores `sha256(raw)`. Cookie `cb_session`, `HttpOnly=True`, `SameSite=lax`, `Secure` ON in prod / OFF in dev, `Path=/`, `Max-Age=SESSION_MAX_DAYS*86400`.
|
||
- `app/services/auth.py` — `AuthService.request_link` (allowlist check → DB per-email rate-limit count → insert token row → send email → audit) and `AuthService.consume` (atomic `UPDATE magic_link_tokens SET used_at WHERE token_hash AND used_at IS NULL AND expires_at>now`, then upsert `users` row, then `SessionService.create`). Audit writes live outside the consume transaction to sidestep SQLite's single-writer semantics.
|
||
- `app/services/rate_limit.py` — module-level `limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")`. Singleton because `@limiter.limit` decorates at import time.
|
||
- `app/routes/admin.py` — all admin HTTP routes; `POST /admin/login` decorated `@limiter.limit("5/15 minutes")`, `GET /admin/auth/consume/{token}` decorated `@limiter.limit("20/15 minutes")`.
|
||
- `app/dependencies/auth.py` — `get_current_user` and `require_admin` (raises `HTTPException(status_code=303, headers={"Location": "/admin/login"})`).
|
||
- `app/templates/admin/` — `base.html`, `login.html`, `login_sent.html`, `login_failed.html`, `index.html`, `rate_limited.html`.
|
||
- `app/templates/emails/magic_link.html` + `magic_link.txt` — magic-link email bodies.
|
||
- `tests/test_auth_service.py`, `test_admin_routes.py`, `test_rate_limit.py`, `test_session_service.py`, `test_email_service.py` — 25 new tests covering token lifecycle, single-use, expiry, allowlist anti-enumeration, signed-cookie round-trip, revoke, user upsert, IP + DB email limits, dev email fallback, production config refusal.
|
||
- `app/main.py` — builds `URLSafeTimedSerializer(salt="session")`, instantiates audit/email/sessions/auth onto `app.state`, installs SlowAPI `RateLimitExceeded` handler that renders `admin/rate_limited.html` with 429 and audits `rate_limited` with `scope="ip"`, includes admin router.
|
||
- `app/config.py` — added `public_base_url` field (default `http://127.0.0.1:8000`); added `_require_auth_config_in_production` model validator: production boot refuses empty `RESEND_API_KEY` / `RESEND_FROM` / `ADMIN_EMAILS`.
|
||
- `.env.example` — added `PUBLIC_BASE_URL=http://127.0.0.1:8000`.
|
||
|
||
**Endpoints created:**
|
||
- `GET /admin/login` — email-only login form (public, no CSRF — pre-auth).
|
||
- `POST /admin/login` — rate-limited (5/15min/IP + 5/15min/email via DB count). Always renders `login_sent.html` regardless of allowlist (anti-enumeration). Audit row `link_requested` with `{"allowlisted": bool}` every time.
|
||
- `GET /admin/auth/consume/{token}` — rate-limited (20/15min/IP). Atomic single-use consume; on success sets `cb_session` cookie + 303 to `/admin`; on failure renders generic `login_failed.html` (no reason leakage).
|
||
- `GET /admin` — `require_admin`; renders placeholder `index.html` with `{{ user.display_name }}` + logout form. Phase 4 replaces with real CMS dashboard.
|
||
- `POST /admin/logout` — `require_admin`; flips `sessions.revoked_at = now`, clears cookie with `Max-Age=0`, 303 to `/admin/login`. Row preserved for audit. Marked `# TODO(phase-6-csrf)` — SameSite=Lax blocks cross-site POSTs in current browsers; Phase 6 adds a double-submit token.
|
||
|
||
**Key details:**
|
||
- **Hash-at-rest for both tokens and session IDs.** DB stores `sha256(raw).hexdigest()`; raw values never persisted, never logged. Audit detail only references last-6 hash chars for correlation.
|
||
- **Cookie signed with itsdangerous `URLSafeTimedSerializer(secret_key, salt="session")`.** Forged cookie fails signature check before any DB lookup.
|
||
- **Anti-enumeration verified:** non-allowlisted POST → same 200 HTML response, zero rows inserted into `magic_link_tokens`, `link_requested` audit logs `{"allowlisted": false}`.
|
||
- **Rate limit verified:** 5 POSTs succeed + 6th returns 429 from same IP; DB per-email count applied even when SlowAPI would allow.
|
||
- **User auto-upsert on consume:** first successful consume inserts a `users` row with `display_name = local-part.title()` (e.g. `driver@example.com` → "Driver"), `active=1`; subsequent logins update `last_login_at`.
|
||
- **Dev Resend fallback:** when `resend_api_key` is falsy, `EmailService` emits `magic_link_dev_fallback` structured log with the full URL and returns cleanly — never 500s the request path.
|
||
- **Production config guardrails:** `app_env == "production"` requires non-empty `RESEND_API_KEY`, `RESEND_FROM`, `ADMIN_EMAILS`. Missing any → `ValueError` at startup.
|
||
- **Single-writer deadlock avoided:** `AuthService.consume` does the atomic token update inside `engine.begin()`, then captures outcome flags, then opens separate transactions for audit + session creation. Correctness unchanged (single-use guarantee via `rowcount==1`).
|
||
- **Audit trail smoke-tested rows:** `link_requested` (per attempt), `link_consumed`, `session_created`, `session_revoked`, `rate_limited`, `consume_failed` — all emitted during driver verification.
|
||
- **Phase 4 hooks ready:** `require_admin` dependency + `get_current_user` are callable from any admin route. Admin CMS POSTs will reuse them.
|
||
- **Phase 6 TODO markers** on the logout handler flag the CSRF pickup point.
|
||
|
||
**Verification run:**
|
||
`python -c "from app.main import app"` ✓ · `pytest -q` 61 passed ✓ · end-to-end smoke (fresh DB, driver allowlisted): login form 200, POST login 200, dev-log URL captured, consume 200 + cookie set, `/admin` 200 with "Welcome, Driver", logout 303, post-logout `/admin` 303 to login ✓ · IP rate limit fires at 5 (6th is 429) ✓ · non-allowlisted POST 200 + zero tokens in DB ✓ · `auth_events` shows `link_requested`/`link_consumed`/`session_created`/`session_revoked`/`rate_limited` rows as expected ✓ · `docker compose config` exit 0 ✓.
|
||
|
||
## Phase 4 — Admin CMS ✅
|
||
|
||
**Completed:** 2026-04-22
|
||
|
||
**Summary:** Shipped the full Head Hen CMS: dashboard listing every post (drafts + published, newest `updated_at` first) plus an About-edit entry point, a hand-rolled Markdown editor (textarea + 300 ms-debounced server-side live preview + drag-drop image upload), a hardened media pipeline (magic-byte sniff → 8 MB cap → Pillow re-encode to JPEG with alpha flattened on white → random `data/media/<yyyy>/<mm>/<token>.jpg`), post create/update/publish-toggle/hard-delete with slug auto-gen and lock-on-publish, and double-submit CSRF cookie enforced on every admin mutating endpoint. Phase 3's `# TODO(phase-6-csrf)` markers are resolved — CSRF is live.
|
||
|
||
**Key files:**
|
||
- `app/services/slugs.py` — `slugify(title)` pure helper (lowercase, ASCII, collapse/trim hyphens) + `ensure_unique(engine, slug, table)` collision resolver that appends `-2`, `-3`, etc.
|
||
- `app/services/csrf.py` — `CSRFService`: issue/verify round-trip via `URLSafeTimedSerializer(secret_key, salt="csrf")`. Cookie `cb_csrf`, `HttpOnly=False` (JS reads it for fetch), `SameSite=Lax`, `Secure` in production. Separate from the `cb_session` session cookie.
|
||
- `app/dependencies/csrf.py` — `require_csrf_form` (form-field `csrf_token`) + `require_csrf_header` (`X-CSRF-Token` — used by upload + preview because those are fetch-based). Both raise 403 on mismatch.
|
||
- `app/services/media.py` — `MediaService(engine, media_root)`; `save_upload(file, uploaded_by) -> Media`. Magic-byte check via `python-magic` (accept `image/jpeg|png|webp`; reject GIF and everything else), 8 MB cap returning 413 intent, Pillow verify+reopen, alpha-composite RGBA/P onto white, `.save(path, "JPEG", quality=85, optimize=True)`. `secrets.token_urlsafe(16)` filename; client extension discarded. Monthly partition dir auto-created.
|
||
- `app/services/admin_posts.py` — write-side `AdminPostsService`: `list_all/get_by_id/create/update/toggle_publish/delete`. Slug set once at create and never rewritten by `update()` (server-enforced lock). `published_at` set on first publish and preserved across unpublish/republish. Every write invalidates the read-side `PostService` cache.
|
||
- `app/services/admin_pages.py` — About-only write service; slug immutable. Invalidates `PageService` cache on write.
|
||
- `app/routes/admin_cms.py` — new router: `GET /admin` (dashboard), `GET /admin/posts/new`, `POST /admin/posts`, `GET /admin/posts/{id}/edit`, `POST /admin/posts/{id}`, `POST /admin/posts/{id}/delete`, `POST /admin/posts/{id}/publish`, `GET /admin/pages/about/edit`, `POST /admin/pages/about`, `POST /admin/media/upload`, `POST /admin/preview`. All mutating routes carry `require_admin` + a CSRF dep.
|
||
- `app/templates/admin/dashboard.html` + `post_form.html` + `page_form.html` + `_post_row.html` — CMS UI. Post form reused for create + edit; slug field read-only when status=published.
|
||
- `app/static/js/admin_editor.js` — hooks `[data-editor]`: 300 ms-debounced `POST /admin/preview` with `X-CSRF-Token` header, swaps preview via `<template>` + `cloneNode` (never `innerHTML`); drag-drop / file-picker upload to `/admin/media/upload` with FormData, inserts `` at the textarea cursor.
|
||
- `app/templates/admin/base.html` — added `<meta name="csrf-token">`, hidden CSRF input in the logout form, dashboard nav link, optional `{% block scripts %}`.
|
||
- `app/main.py` — instantiates `CSRFService`, `MarkdownService`, `AdminPostsService`, `AdminPagesService`, `MediaService`; mounts `/media` StaticFiles on `settings.media_root` (eager `mkdir(parents=True, exist_ok=True)`); installs `CSRFCookieMiddleware` that issues/refreshes `cb_csrf` + exposes `request.state.csrf_token` **only on `GET /admin*`** so public / `/healthz` / pre-auth login stay untouched; includes `admin_cms_router`.
|
||
- `app/routes/admin.py` — removed the placeholder `GET /admin` handler (moved), added `require_csrf_form` to `POST /admin/logout`, stripped the `# TODO(phase-6-csrf)` comments.
|
||
- `app/config.py` — added `media_root: str = Field(default="data/media")`.
|
||
- `.env.example` — added `MEDIA_ROOT=data/media`.
|
||
- `.gitignore` — carved out `!data/media/.gitkeep` so the mount dir survives checkouts.
|
||
- `app/static/css/site.css` — extended with `.admin-dashboard`, `.post-table`, `.editor` (2-column layout at 48rem+), `.drop-zone`, `.status-badge`, `.btn--danger/.btn--secondary/.btn--link`, `.admin-flash`.
|
||
- `docs/MANUAL_TESTING.md` — appended Phase 4 checklist.
|
||
- Tests: `test_slugs.py`, `test_csrf_service.py`, `test_media_service.py`, `test_admin_posts_service.py`, `test_admin_pages_service.py`, `test_admin_cms_routes.py` — 64 new tests; also updated `test_admin_routes.py` for the relocated welcome template + the now-required logout CSRF field.
|
||
|
||
**Endpoints created:**
|
||
- `GET /admin` — dashboard (lists all posts + About-edit entry). Replaces Phase 3 placeholder.
|
||
- `GET /admin/posts/new` — create form.
|
||
- `POST /admin/posts` — create handler (CSRF form).
|
||
- `GET /admin/posts/{id}/edit` — edit form.
|
||
- `POST /admin/posts/{id}` — update handler (CSRF form). Slug field is server-ignored when post is published.
|
||
- `POST /admin/posts/{id}/delete` — hard delete with confirmation (CSRF form).
|
||
- `POST /admin/posts/{id}/publish` — publish/unpublish toggle (CSRF form).
|
||
- `GET /admin/pages/about/edit` — About edit form.
|
||
- `POST /admin/pages/about` — About update (CSRF form). Slug immutable.
|
||
- `POST /admin/media/upload` — multipart upload; returns `{"url": "/media/...", "alt": ""}` JSON (CSRF header).
|
||
- `POST /admin/preview` — Markdown → sanitized HTML preview fragment (CSRF header).
|
||
- `Mount /media` — `StaticFiles` serving `data/media/<yyyy>/<mm>/<token>.jpg`.
|
||
|
||
**Key details:**
|
||
- **CSRF gating is narrow by design.** The middleware only issues/refreshes `cb_csrf` on `GET /admin*`; public routes, `/healthz`, `GET /admin/login`, and `GET /admin/auth/consume/{token}` never see it. Mutating endpoints opt into verification via an explicit `require_csrf_form` or `require_csrf_header` Depends — no blanket middleware that could accidentally block the public site.
|
||
- **Slug lock is server-enforced.** `AdminPostsService.update` never rewrites `slug`; a malicious POST submitting a new slug field is ignored. Slug only exists because `create()` set it.
|
||
- **Unpublish preserves `published_at`.** Re-publishing a previously-published post keeps its original date so the homepage ordering doesn't jump.
|
||
- **Media pipeline: single output format (JPEG).** Simpler write-side, smaller files, predictable downstream behaviour. RGBA / paletted inputs get alpha-composited onto white before encode.
|
||
- **Preview endpoint swap uses `<template>` + `cloneNode`, not `innerHTML`.** Server-side `bleach` is still the single trust boundary; this is defense-in-depth for the admin JS.
|
||
- **Audit trail extends cleanly.** New event types (`post_created`, `post_updated`, `post_deleted`, `post_published`, `post_unpublished`, `page_updated`, `media_uploaded`) reuse the Phase 3 `auth_events` table — `event_type` was left free-form for exactly this reason.
|
||
- **No DB mocking.** Every new test uses a real temp SQLite file per the CLAUDE.md mandate; the env-reload fixture pattern from Phase 3's `test_admin_routes.py` is reused.
|
||
- **Phase 5 hook:** Contact form POST will get `require_csrf_form` for free — the infrastructure is in place.
|
||
- **Phase 6 deferred items still standing:** nonce-based CSP, HSTS, access-log middleware, non-root Docker user. None blocked Phase 4.
|
||
|
||
**Verification run:**
|
||
`python -c "from app.main import app"` ✓ (26 routes registered) · `pytest -q tests/test_slugs.py tests/test_csrf_service.py tests/test_media_service.py tests/test_admin_posts_service.py tests/test_admin_pages_service.py tests/test_admin_cms_routes.py tests/test_admin_routes.py` → 64 passed ✓ · full `pytest -q` → 118 passed, 2 failed; both failures are pre-existing on `dev` (the `logo.` → `logo-mark.` asset rename in commit `f5098c0`; and the `RESEND_FROM`/`ADMIN_EMAILS` pollution from local `.env` into the Settings-validator test) and unrelated to Phase 4.
|
||
|
||
## Phase 5 — Contact Form ✅
|
||
|
||
**Completed:** 2026-04-22
|
||
|
||
**Summary:** Shipped the public contact form: live POST handler with honeypot → hCaptcha (server-verify) → field validation → SlowAPI rate limit → `contact_submissions` row insert → best-effort Resend notification to `ADMIN_CONTACT_EMAIL` (Reply-To = submitter) → generic `contact_sent.html` success page. Spam / honeypot / hCaptcha-fail paths don't persist, still render the success page (anti-enumeration). Send failures don't break the request path — the row is already durable.
|
||
|
||
**Key files:**
|
||
- `app/services/hcaptcha.py` — `HCaptchaService(settings).verify(token, remote_ip) -> bool`. Async `httpx.AsyncClient` POST to `https://hcaptcha.com/siteverify` with a 5s timeout. Dev fallback: when `hcaptcha_secret` is falsy, logs `hcaptcha_dev_fallback` and returns `True`. Fail-closed on any network error / non-200 / malformed JSON / `success=false` (returns `False`). Never raises from the request path.
|
||
- `app/services/contact.py` — `ContactService(engine, email, audit, settings)`: `record_submission(...)` inserts a `contact_submissions` row and returns a mapped `ContactSubmission`; `send_notification(submission)` is best-effort (never raises, no-ops when `admin_contact_email` is unset — only possible in dev).
|
||
- `app/services/email.py` — added `send_contact_notification(*, to, submission_name, submission_email, message, submitted_at, ip)`. Subject `"New contact submission from {submission_name}"`. `from_=settings.resend_from`, `reply_to=submission.email`. Dev fallback logs `contact_notification_dev_fallback`. Resend errors are caught + logged (`contact_email_failed`); request path never sees them.
|
||
- `app/templates/emails/contact_notification.html` + `.txt` — admin notification bodies (name, email, message, submitted_at ISO, ip). Jinja2 autoescape handles all user-supplied fields.
|
||
- `app/templates/public/contact.html` — replaces the Phase 1 inert form: `method="POST"`, name / email / message inputs with HTML5 length bounds + server-side re-validation, visually-hidden honeypot `website` input, hCaptcha widget rendered only when `hcaptcha_site_key` is truthy (dev just shows an HTML comment), inline `errors.{field}` + top-level `form_error` flash slots.
|
||
- `app/templates/public/contact_sent.html` — thank-you page; `active_nav = "contact"`; back-to-home link.
|
||
- `app/routes/public.py` — added `POST /contact` handler decorated `@limiter.limit("3/hour")`. Strict 6-stage flow (honeypot → hCaptcha → validate → persist → notify → success). DI helpers `_get_hcaptcha_service` / `_get_contact_service`; `_client_ip` / `_user_agent` shared with admin module's pattern. Also tightened `GET /contact` to pass the new template context (`hcaptcha_site_key`, `errors`, `form`, `form_error`).
|
||
- `app/main.py` — instantiates `HCaptchaService(settings)` and `ContactService(engine, email_service, audit_service, settings)`; attaches to `app.state.hcaptcha_service` / `app.state.contact_service`.
|
||
- `app/config.py` — extended `_require_auth_config_in_production` to also require `ADMIN_CONTACT_EMAIL`, `HCAPTCHA_SECRET`, and `HCAPTCHA_SITE_KEY` in production (missing any → `ValueError` at startup).
|
||
- `app/static/css/site.css` — added `.contact-form__field-error`, `.contact-form__error`, `.contact-form__captcha`, and the visually-hidden honeypot rule.
|
||
- `docs/MANUAL_TESTING.md` — appended Phase 5 checklist (happy path, honeypot, hCaptcha fail, rate-limit 429, inline validation, dev fallback log, admin inbox Reply-To, production-config refusal).
|
||
- Tests: `tests/test_hcaptcha_service.py` (9), `tests/test_contact_service.py` (5), `tests/test_contact_routes.py` (9) — 23 new tests; temp-SQLite fixtures per CLAUDE.md, httpx boundary mocked at `_post_siteverify`.
|
||
|
||
**Endpoints created:**
|
||
- `POST /contact` — the public submission endpoint. Accepts `name`, `email`, `message`, hidden `website` (honeypot), and `h-captcha-response` as multipart/form-urlencoded. Returns 200 `contact_sent.html` on success / spam / honeypot; 400 `contact.html` with inline errors on validation fail; 429 `admin/rate_limited.html` when the SlowAPI 3/hour IP limit trips.
|
||
- `GET /contact` — unchanged URL, now returns the live (non-disabled) form with hCaptcha widget when configured. Pre-Phase-5 this was the inert placeholder.
|
||
|
||
**Key details:**
|
||
- **Spam short-circuit is anti-enumeration.** Honeypot trip or hCaptcha `False` both render the same generic success page so bot operators can't probe which filter caught them. The audit row (`contact_spam_rejected` with `{"reason":"honeypot"|"hcaptcha"}`) is the only signal — in the DB, not the HTTP response.
|
||
- **Send failure is idempotent from the user's perspective.** `ContactService.record_submission` commits before `send_notification` is called; if Resend is down, the row is still in `contact_submissions` and Head Hen can action it from the table. The request path always ends at `contact_sent.html`.
|
||
- **No CSRF on `/contact`.** Public, pre-auth, no session cookie to hijack. SlowAPI + hCaptcha + honeypot + DB-side `contact_submissions` audit are the controls. Documented inline in the route docstring.
|
||
- **Field validation lives in the route, not the service.** `name` 1-80; `email` matches `^[^@\s]+@[^@\s]+\.[^@\s]+$` AND length ≤254; `message` 10-4000 after `.strip()`. On error: re-render with `errors` dict + preserved values + HTTP 400.
|
||
- **Audit trail extends cleanly.** New event types on the existing `auth_events` table: `contact_submitted` (with `submission_id`, `message_length`, truncated 40-char `message_preview`), `contact_spam_rejected` (with `reason`), `contact_send_failed` (from email service's internal catch). No full message bodies ever flow into audit detail.
|
||
- **hCaptcha widget is optional in dev.** Template renders `<div class="h-captcha" data-sitekey="{{ hcaptcha_site_key }}"></div>` + the remote `api.js` only when the site key is truthy; otherwise an HTML comment stands in. The server-side verify service mirrors this by returning `True` when the secret is unset.
|
||
- **Reusing Phase 3's 429 handler is acceptable for now.** The registered `RateLimitExceeded` handler renders `admin/rate_limited.html`; on `/contact` it works but carries admin styling. Flagged in the Phase 6 polish list (not blocking).
|
||
- **Production config now requires three more fields.** Missing any of `ADMIN_CONTACT_EMAIL`, `HCAPTCHA_SECRET`, `HCAPTCHA_SITE_KEY` in `APP_ENV=production` raises at startup via `_require_auth_config_in_production`. Mirrors the Phase 3 guardrail pattern.
|
||
- **Phase 6 hooks ready:** nonce-based CSP, HSTS, access-log middleware, a public-styled 429 template, and a Dockerfile hardening pass are all still standing as originally planned — none blocked Phase 5.
|
||
- **No new packages.** All deps (`httpx`, `slowapi`, `resend`, `jinja2`, `structlog`, `itsdangerous`) were already pinned in Phase 0's `requirements.txt`.
|
||
|
||
**Verification run:**
|
||
`python -c "from app.main import app; print(len(app.routes))"` → **27** (Phase 4 registered 26; one new `POST /contact`) ✓ · `pytest -q` → **141 passed, 2 failed**; both failures confirmed pre-existing on `dev` (the Phase 4 `logo.` → `logo-mark.` asset rename and the `RESEND_FROM`/`ADMIN_EMAILS` pollution from local `.env` into `test_production_missing_key_refuses_startup`) — verified by running the same two targeted tests on a clean `dev` checkout, both failed there too ✓ · `docker compose config` exit 0 ✓ · route registry confirmed: `('/contact', ['GET'])` + `('/contact', ['POST'])` ✓.
|
||
|
||
**Branch:** `feat/phase-5-contact-form` off `dev`. Not committed, not merged, not pushed — changes staged for human review before `--no-ff` merge into `dev` per CLAUDE.md git strategy.
|
||
|
||
## Phase 6 — Hardening + Deploy ✅
|
||
|
||
**Completed:** 2026-04-22
|
||
|
||
**Summary:** Locked down the HTTP surface with a strict nonce-based CSP (plus HSTS in prod, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `frame-ancestors`, `form-action`), added a structured per-request access-log middleware with magic-link path redaction and `/healthz` suppression, hardened the Docker image (non-root uid/gid 10001 + `HEALTHCHECK` against `/healthz`), shipped a backup script with 14-day retention, and wired a Gitea Actions workflow that publishes `git.sneakygeek.net/ptarrant/chicken_babies_site:latest` + `:sha-<short>` on every push to `master` (plus `workflow_dispatch`). CSRF middleware was already live from Phase 4 — that roadmap bullet was a no-op here.
|
||
|
||
**Key files:**
|
||
- `app/middleware/__init__.py` — package marker.
|
||
- `app/middleware/security_headers.py` — `SecurityHeadersMiddleware(BaseHTTPMiddleware)`. Per-request nonce via `secrets.token_urlsafe(16)`, stashed on `request.state.csp_nonce`. CSP builds on each dispatch so the nonce threads into `script-src`. Conditional HSTS: constructor takes `production: bool`, only emits `Strict-Transport-Security: max-age=31536000; includeSubDomains` when `True` so localhost-over-http dev still works. Also sets `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()`, `Cross-Origin-Opener-Policy: same-origin`.
|
||
- `app/middleware/access_log.py` — `AccessLogMiddleware(BaseHTTPMiddleware)` emits one `http_request` INFO event per request with `method`, `path`, `status_code`, `duration_ms` (int), `client_ip` (from `request.client.host` — already resolved through `ProxyHeadersMiddleware`), `user_agent` (truncated to 256 chars). Skips `/healthz` to avoid compose-loop noise. Paths matching `^/admin/auth/consume/` have the token segment replaced with `<redacted>` before logging. Unhandled downstream exceptions are logged with `status_code=500`, then re-raised so FastAPI's exception machinery still runs.
|
||
- `app/main.py` — imports + wires both middlewares. Install order: `SecurityHeadersMiddleware` added first, `AccessLogMiddleware` added last. FastAPI/Starlette wraps in reverse registration order, so the access log runs outermost (timing covers the whole stack) and the security headers run innermost (stamped onto the response before the access log times it). Inline comment documents the ordering so future edits don't flip it.
|
||
- `app/templates/public/base.html` — line 110 inline `<script>` now carries `nonce="{{ request.state.csp_nonce }}"` so it passes strict CSP. External hCaptcha script on `public/contact.html` left alone (allowlisted by origin in `script-src`, not nonced). Admin templates were all-external-JS already; nothing to patch there.
|
||
- `Dockerfile` — runtime stage adds `groupadd -g 10001 app && useradd -m -u 10001 -g app app`, `chown -R app:app /app /opt/venv`, and `USER app` before `CMD`. Stable UID/GID keeps host bind-mount ownership predictable. `HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"` — stdlib only, no curl/wget runtime dep.
|
||
- `scripts/backup.sh` — `set -euo pipefail`, UTC timestamp (`%Y%m%dT%H%M%SZ`), `sqlite3 data/app.db ".backup data/backups/app-<ts>.db"` + `tar -czf data/backups/media-<ts>.tar.gz data/media`, retention `ls -t | tail -n +15 | xargs -r rm` so only the newest 14 of each artifact survive. `mkdir -p data/backups` is idempotent. Chmod +x committed.
|
||
- `.gitea/workflows/build-image.yml` — triggers on `push` to `master` + `workflow_dispatch`. Buildx + `docker/login-action@v3` using `${{ gitea.actor }}` / `${{ secrets.REGISTRY_TOKEN }}`. `metadata-action@v5` tags: `latest` + `sha-<short>`. `build-push-action@v5` passes `build-args: GIT_COMMIT_SHA=${{ gitea.sha }}` so `/healthz` reports the real commit in deployed images. Registry cache-from/to pinned to the `:buildcache` tag.
|
||
- `docs/MANUAL_TESTING.md` — appended Phase 6 checklist (security headers via curl, nonce uniqueness, HSTS dev/prod diff, hCaptcha still loads, admin editor still works, HEALTHCHECK `docker ps` healthy, backup dry run, retention prune).
|
||
- `tests/test_security_headers.py` (4 tests) — asserts every expected header is present on `/`, CSP contains `nonce-`, two back-to-back requests get different nonces, HSTS is absent when the middleware is constructed with `production=False` and present with `max-age=31536000; includeSubDomains` when `production=True`.
|
||
- `tests/test_access_log.py` (4 tests) — capsys-based capture with ANSI-escape stripping. Verifies `http_request` events fire for 200/404, `/healthz` is skipped, `/admin/auth/consume/<token>` is redacted before logging, and a downstream-handler exception is logged with `status_code=500` then re-raised.
|
||
|
||
**Endpoints created:** none. Phase 6 is cross-cutting — route count stayed at 28.
|
||
|
||
**Key details:**
|
||
- **CSP policy (effective):** `default-src 'self'; script-src 'self' 'nonce-<N>' https://js.hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://*.hcaptcha.com; frame-src https://*.hcaptcha.com https://newassets.hcaptcha.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`. `style-src 'unsafe-inline'` is kept for now because hCaptcha injects inline style into its widget; tightening it is a future task.
|
||
- **Single inline script remaining:** `public/base.html`'s mobile-nav toggle. The admin editor and everything else are external files already, so no admin template needed a nonce. `contact.html`'s external hCaptcha script is allowed by origin; external scripts don't take nonces.
|
||
- **HSTS is production-gated.** Local dev over `http://127.0.0.1:8000` stays reachable. Prod flips on the header via the `production=(settings.app_env == "production")` flag in `create_app`.
|
||
- **Access log redaction is defence-in-depth.** The magic-link consume route already signs / hashes / single-uses the token, so even if a log line leaked it'd be unreplayable. Redacting anyway keeps the log grep-friendly and avoids any accidental downstream ingestion storing the raw value.
|
||
- **`/healthz` skipped from access logs** to keep compose's 30s HEALTHCHECK from drowning signal in noise. Still fully served — just not logged.
|
||
- **Middleware install order pitfall documented.** FastAPI/Starlette wrap in reverse registration order; the outermost middleware is the LAST one added. `app/main.py` now has an inline comment so a future refactor doesn't silently flip timing vs. security behaviour.
|
||
- **Docker image runs as non-root (uid 10001).** Host bind-mount (`./data:/app/data`) must be writable by this uid. Docs/MANUAL_TESTING notes `chown -R 10001:10001 data/` or mounting into a uid-remapped volume as the host-side prep step.
|
||
- **Gitea workflow publishes on every `master` push.** No tag trigger this phase; release tags can be layered on later without touching the workflow.
|
||
- **Build-arg discipline preserved.** Workflow passes `GIT_COMMIT_SHA=${{ gitea.sha }}` so the published image keeps reporting the correct SHA through `/healthz` — the Phase 0 contract holds.
|
||
- **Backup script is host-side only.** Cron installation is a sysadmin step documented in MANUAL_TESTING; the repo deliberately does not ship a systemd unit to keep it host-agnostic.
|
||
- **Pre-existing test failures are still pre-existing.** `test_production_missing_key_refuses_startup` (env pollution) and `test_layout_includes_logo_image` (Phase 4 `logo` → `logo-mark` asset rename) failed on `dev` before this phase; they still fail, and they are NOT Phase 6 regressions.
|
||
|
||
**Verification run:**
|
||
`python -c "from app.main import app; print(len(app.routes))"` → **28** (unchanged) ✓ · `pytest -q` → **149 passed, 2 failed** — both failures confirmed pre-existing on `dev` ✓ · dev-mode TestClient `/`: CSP present with unique nonce across two requests, HSTS absent, `X-Content-Type-Options=nosniff`, `Referrer-Policy=strict-origin-when-cross-origin`, `Permissions-Policy` present, `frame-ancestors 'none'` in CSP, nonce value matches the `nonce="..."` attribute emitted into the HTML ✓ · prod-mode (`APP_ENV=production` + full config env) `/`: `Strict-Transport-Security: max-age=31536000; includeSubDomains` present, JSON access-log line emitted ✓ · `/healthz` returns 200 without producing an `http_request` log entry ✓ · `bash -n scripts/backup.sh` clean ✓ · `python -c "import yaml; yaml.safe_load(open('.gitea/workflows/build-image.yml'))"` clean ✓ · `scripts/backup.sh` is `-rwxrwxr-x` (executable) ✓.
|
||
|
||
## Phase 7 (Future) — Shop
|
||
|
||
- Stripe Checkout integration (test-mode first).
|
||
- Product catalog: eggs (fertile/hatchable, eating), live birds (geese, ducks, chickens).
|
||
- Inventory counts, order history, email confirmations.
|
||
- Admin views for orders.
|
||
|
||
---
|
||
|
||
## Data Model (dataclasses)
|
||
|
||
```python
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from enum import Enum
|
||
from typing import Optional
|
||
|
||
|
||
class PostStatus(str, Enum):
|
||
DRAFT = "draft"
|
||
PUBLISHED = "published"
|
||
|
||
|
||
@dataclass
|
||
class User:
|
||
id: int
|
||
email: str
|
||
display_name: str
|
||
created_at: datetime
|
||
last_login_at: Optional[datetime]
|
||
active: bool
|
||
|
||
|
||
@dataclass
|
||
class MagicLinkToken:
|
||
id: int
|
||
email: str
|
||
token_hash: str # sha256 of raw token; raw token never stored
|
||
created_at: datetime
|
||
expires_at: datetime
|
||
used_at: Optional[datetime]
|
||
request_ip: str
|
||
|
||
|
||
@dataclass
|
||
class Session:
|
||
id: int
|
||
user_id: int
|
||
token_hash: str
|
||
created_at: datetime
|
||
expires_at: datetime
|
||
ip: str
|
||
user_agent: str
|
||
revoked_at: Optional[datetime]
|
||
|
||
|
||
@dataclass
|
||
class Page:
|
||
id: int
|
||
slug: str # e.g. "about"
|
||
title: str
|
||
body_md: str
|
||
body_html_cached: str # sanitized, ready-to-render HTML
|
||
updated_at: datetime
|
||
published: bool
|
||
|
||
|
||
@dataclass
|
||
class Post:
|
||
id: int
|
||
slug: str
|
||
title: str
|
||
body_md: str
|
||
body_html_cached: str
|
||
status: PostStatus
|
||
published_at: Optional[datetime]
|
||
updated_at: datetime
|
||
author_user_id: int
|
||
|
||
|
||
@dataclass
|
||
class Media:
|
||
id: int
|
||
filename: str # random storage name
|
||
original_filename: str
|
||
content_type: str
|
||
size_bytes: int
|
||
stored_path: str # e.g. data/media/2026/04/abc123.jpg
|
||
alt_text: str
|
||
uploaded_by: int
|
||
uploaded_at: datetime
|
||
|
||
|
||
@dataclass
|
||
class ContactSubmission:
|
||
id: int
|
||
name: str
|
||
email: str
|
||
message: str
|
||
ip: str
|
||
user_agent: str
|
||
submitted_at: datetime
|
||
handled: bool
|
||
|
||
|
||
@dataclass
|
||
class AuthEvent:
|
||
id: int
|
||
event_type: str # link_requested | link_consumed | session_revoked | rate_limited
|
||
email: Optional[str]
|
||
user_id: Optional[int]
|
||
ip: str
|
||
user_agent: str
|
||
created_at: datetime
|
||
detail: str # JSON string
|
||
```
|
||
|
||
## SQLite Schema (authoritative)
|
||
|
||
```sql
|
||
PRAGMA journal_mode = WAL;
|
||
PRAGMA foreign_keys = ON;
|
||
|
||
CREATE TABLE users (
|
||
id INTEGER PRIMARY KEY,
|
||
email TEXT NOT NULL UNIQUE,
|
||
display_name TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
last_login_at TEXT,
|
||
active INTEGER NOT NULL DEFAULT 1
|
||
);
|
||
|
||
CREATE TABLE magic_link_tokens (
|
||
id INTEGER PRIMARY KEY,
|
||
email TEXT NOT NULL,
|
||
token_hash TEXT NOT NULL UNIQUE,
|
||
created_at TEXT NOT NULL,
|
||
expires_at TEXT NOT NULL,
|
||
used_at TEXT,
|
||
request_ip TEXT NOT NULL
|
||
);
|
||
CREATE INDEX idx_magic_email_created ON magic_link_tokens(email, created_at);
|
||
|
||
CREATE TABLE sessions (
|
||
id INTEGER PRIMARY KEY,
|
||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||
token_hash TEXT NOT NULL UNIQUE,
|
||
created_at TEXT NOT NULL,
|
||
expires_at TEXT NOT NULL,
|
||
ip TEXT NOT NULL,
|
||
user_agent TEXT NOT NULL,
|
||
revoked_at TEXT
|
||
);
|
||
|
||
CREATE TABLE pages (
|
||
id INTEGER PRIMARY KEY,
|
||
slug TEXT NOT NULL UNIQUE,
|
||
title TEXT NOT NULL,
|
||
body_md TEXT NOT NULL,
|
||
body_html_cached TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL,
|
||
published INTEGER NOT NULL DEFAULT 1
|
||
);
|
||
|
||
CREATE TABLE posts (
|
||
id INTEGER PRIMARY KEY,
|
||
slug TEXT NOT NULL UNIQUE,
|
||
title TEXT NOT NULL,
|
||
body_md TEXT NOT NULL,
|
||
body_html_cached TEXT NOT NULL,
|
||
status TEXT NOT NULL CHECK (status IN ('draft','published')),
|
||
published_at TEXT,
|
||
updated_at TEXT NOT NULL,
|
||
author_user_id INTEGER NOT NULL REFERENCES users(id)
|
||
);
|
||
CREATE INDEX idx_posts_status_pub ON posts(status, published_at DESC);
|
||
|
||
CREATE TABLE media (
|
||
id INTEGER PRIMARY KEY,
|
||
filename TEXT NOT NULL UNIQUE,
|
||
original_filename TEXT NOT NULL,
|
||
content_type TEXT NOT NULL,
|
||
size_bytes INTEGER NOT NULL,
|
||
stored_path TEXT NOT NULL,
|
||
alt_text TEXT NOT NULL DEFAULT '',
|
||
uploaded_by INTEGER NOT NULL REFERENCES users(id),
|
||
uploaded_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE contact_submissions (
|
||
id INTEGER PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
email TEXT NOT NULL,
|
||
message TEXT NOT NULL,
|
||
ip TEXT NOT NULL,
|
||
user_agent TEXT NOT NULL,
|
||
submitted_at TEXT NOT NULL,
|
||
handled INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
|
||
CREATE TABLE auth_events (
|
||
id INTEGER PRIMARY KEY,
|
||
event_type TEXT NOT NULL,
|
||
email TEXT,
|
||
user_id INTEGER REFERENCES users(id),
|
||
ip TEXT NOT NULL,
|
||
user_agent TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
detail TEXT NOT NULL DEFAULT '{}'
|
||
);
|
||
CREATE INDEX idx_auth_events_created ON auth_events(created_at DESC);
|
||
```
|
||
|
||
## Caching Strategy
|
||
|
||
- **Row-level cache**: `pages.body_html_cached` and `posts.body_html_cached` store the fully rendered, sanitized HTML. Regenerated on write, never on read. Request path = one indexed SELECT + one template render.
|
||
- **Query cache**: a small in-process TTL cache (60 s) wraps hot queries (published-posts list, page-by-slug). Keyed by route + slug. Explicitly invalidated by any admin write.
|
||
- **No Redis / memcached**: traffic scale (single-digit requests/second at most) doesn't justify it.
|
||
|
||
## Visual Design
|
||
|
||
Palette anchored on light blues (Head Hen's preference), softened with farm neutrals. CSS custom-property tokens:
|
||
|
||
| Token | Value | Use |
|
||
|---|---|---|
|
||
| `--c-sky` | `#A9CCE3` | Primary surfaces, header accent |
|
||
| `--c-sky-deep` | `#5D8AA8` | Links, active states |
|
||
| `--c-cream` | `#FAF3E7` | Page background |
|
||
| `--c-wheat` | `#E4D4A8` | Card surfaces, subtle emphasis |
|
||
| `--c-ink` | `#2B3A42` | Body text |
|
||
| `--c-leaf` | `#7FA66B` | Success / small accent |
|
||
|
||
Typography:
|
||
- One serif display face for headers (system serif fallback).
|
||
- One humanist sans for body (system sans fallback).
|
||
- Self-host any webfont. Do **not** hit Google Fonts on the hot path.
|
||
|
||
Logo:
|
||
- Source assets in `Logo/`.
|
||
- Ship PNG (and ideally a WebP conversion) in `app/static/img/`.
|
||
- Header uses logo at ~48 px tall; include `alt="Chicken Babies R Us"`.
|
||
|
||
---
|
||
|
||
## Environment Variables (contract)
|
||
|
||
`.env.example` will expose these. None have defaults baked in for secrets.
|
||
|
||
| Var | Purpose |
|
||
|---|---|
|
||
| `APP_ENV` | `development` \| `production` |
|
||
| `SECRET_KEY` | itsdangerous signer for cookies / CSRF |
|
||
| `DATABASE_URL` | `sqlite:///data/app.db` |
|
||
| `RESEND_API_KEY` | Resend server key |
|
||
| `RESEND_FROM` | e.g. `no-reply@chickenbabies.example` |
|
||
| `ADMIN_EMAILS` | Comma-separated allowlist |
|
||
| `ADMIN_CONTACT_EMAIL` | Target inbox for contact form |
|
||
| `HCAPTCHA_SITE_KEY` | Public key (rendered in template) |
|
||
| `HCAPTCHA_SECRET` | Server verification secret |
|
||
| `FORWARDED_ALLOW_IPS` | Caddy LAN IP (for Uvicorn) |
|
||
| `SESSION_MAX_DAYS` | Default `30` |
|
||
| `MAGIC_LINK_TTL_MIN` | Default `15` |
|