Compare commits
30 Commits
2f84a6327f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| fad417697a | |||
| 5aad7fd48f | |||
| 81cd4eb803 | |||
| fbd822e8dd | |||
| 07d36fe73c | |||
| 62de2685d7 | |||
| daeef6f3ed | |||
| f9f90d408e | |||
| f4dc6c266d | |||
| 149c6580f4 | |||
| 1e5e3252c6 | |||
| d9090f5055 | |||
| 67c848f329 | |||
| 8b9c77c3fb | |||
| 1b4619a01b | |||
| 9a8506970c | |||
| 76875a455e | |||
| f5098c05f5 | |||
| cd87db8e07 | |||
| 59dea99079 | |||
| 4b088e5045 | |||
| 0306f71763 | |||
| 28168f57b6 | |||
| f77da87eaa | |||
| e830e5da50 | |||
| 78dd1ac243 | |||
| 22f357f3e8 | |||
| a376207243 | |||
| 5f3bd69e95 | |||
| bf4352231a |
58
.env.example
Normal file
@@ -0,0 +1,58 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — environment contract
|
||||
#
|
||||
# Copy this file to `.env` and fill in the real values locally. The .env file
|
||||
# is gitignored. This file (.env.example) is the public contract and MUST
|
||||
# contain only placeholder / safe-default values — never real secrets.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# --- Runtime mode -----------------------------------------------------------
|
||||
# development | production
|
||||
APP_ENV=development
|
||||
|
||||
# --- Signing / sessions -----------------------------------------------------
|
||||
# itsdangerous signer for cookies / CSRF tokens.
|
||||
# Generate locally with: python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
# The string below is a DEV-ONLY sentinel; the app refuses to start in
|
||||
# production if SECRET_KEY still matches this value.
|
||||
SECRET_KEY=dev-insecure-change-me
|
||||
|
||||
# --- Database ---------------------------------------------------------------
|
||||
DATABASE_URL=sqlite:///data/app.db
|
||||
|
||||
# --- Media storage ----------------------------------------------------------
|
||||
# Filesystem directory holding admin-uploaded images. Mounted publicly at
|
||||
# /media by the app. Relative paths resolve against the process cwd.
|
||||
MEDIA_ROOT=data/media
|
||||
|
||||
# --- Email (Resend) ---------------------------------------------------------
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM=no-reply@chickenbabies.example
|
||||
|
||||
# --- Admin allowlist / contact routing --------------------------------------
|
||||
# Comma-separated list of emails allowed to request admin magic links.
|
||||
ADMIN_EMAILS=
|
||||
# Inbox that the public contact form messages get routed to.
|
||||
ADMIN_CONTACT_EMAIL=
|
||||
|
||||
# --- hCaptcha ---------------------------------------------------------------
|
||||
HCAPTCHA_SITE_KEY=
|
||||
HCAPTCHA_SECRET=
|
||||
|
||||
# --- Reverse proxy / Uvicorn ------------------------------------------------
|
||||
# Caddy's LAN IP (comma-separated allowed). Only headers from these IPs are
|
||||
# trusted for X-Forwarded-For / X-Forwarded-Proto.
|
||||
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
|
||||
# --- Session / auth tuning --------------------------------------------------
|
||||
SESSION_MAX_DAYS=30
|
||||
MAGIC_LINK_TTL_MIN=15
|
||||
|
||||
# --- Public URL for link construction --------------------------------------
|
||||
# Absolute base URL (scheme+host+port) used to build outbound links such as
|
||||
# the magic-link auth email. Override for production.
|
||||
PUBLIC_BASE_URL=http://127.0.0.1:8080
|
||||
|
||||
# --- Build metadata ---------------------------------------------------------
|
||||
# Injected at Docker build time. Surfaced by /healthz. Optional in dev.
|
||||
GIT_COMMIT_SHA=unknown
|
||||
36
.gitea/workflows/build-image.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Build and Push Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
container: docker.io/catthehacker/ubuntu:act-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.sneakygeek.net
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.sneakygeek.net/ptarrant/chicken_babies_site
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=sha-,format=short
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
build-args: |
|
||||
GIT_COMMIT_SHA=${{ gitea.sha }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=git.sneakygeek.net/ptarrant/chicken_babies_site:buildcache
|
||||
cache-to: type=registry,ref=git.sneakygeek.net/ptarrant/chicken_babies_site:buildcache,mode=max
|
||||
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# Secrets / local config
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# SQLite runtime
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Runtime data (DB + media uploads live here in prod).
|
||||
# Ignore everything inside data/ except the .gitkeep markers; negating a
|
||||
# path whose *directory* is ignored does not work, so we ignore the
|
||||
# contents with a trailing glob instead.
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
!data/media/
|
||||
data/media/*
|
||||
!data/media/.gitkeep
|
||||
|
||||
# Editors / IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Build / Docker
|
||||
build/
|
||||
dist/
|
||||
*.log
|
||||
116
CLAUDE.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Chicken Babies R Us — Project Instructions
|
||||
|
||||
Small farm website for **Chicken Babies R Us** (Morrison TN — address is intentionally **not** displayed publicly). Public brochure site with a blog-style home, About, Contact, and a disabled "Shop (coming soon)" placeholder. The owner's wife, **Head Hen**, edits all content through an admin area protected by email magic-link authentication.
|
||||
|
||||
> This file is authoritative for *this project*. Where it conflicts with `docs/code_guidelines.md`, this file wins.
|
||||
|
||||
---
|
||||
|
||||
## Stack (authoritative for this project)
|
||||
|
||||
| Layer | Choice |
|
||||
|---|---|
|
||||
| Language | Python 3.12 |
|
||||
| Web framework | **FastAPI** (overrides the Flask default in `docs/code_guidelines.md`) |
|
||||
| Templates | Jinja2 |
|
||||
| ASGI server | Uvicorn, behind Caddy reverse proxy |
|
||||
| Database | SQLite (WAL mode) — single-writer is fine at this scale |
|
||||
| Cache | In-process TTL cache + row-level rendered-HTML cache (no Redis) |
|
||||
| Email | Resend (contact form + magic-link auth) |
|
||||
| Anti-spam | hCaptcha + honeypot + SlowAPI rate limits |
|
||||
| Logging | `structlog` |
|
||||
| Container | Docker (multi-stage), target = Debian 12 VM on home server |
|
||||
| Repo host | Gitea (Actions for image build/publish wired later) |
|
||||
|
||||
## Deployment Topology
|
||||
|
||||
```
|
||||
Internet
|
||||
→ Cloudflare (proxied DNS)
|
||||
→ OPNsense firewall (inbound 443 allowed only from Cloudflare IP ranges)
|
||||
→ Virtual IP
|
||||
→ Debian 12 VM
|
||||
→ Caddy (TLS termination)
|
||||
→ Uvicorn + FastAPI container
|
||||
```
|
||||
|
||||
The app MUST trust `X-Forwarded-For` / `X-Forwarded-Proto` **only from Caddy's IP**. Run Uvicorn with `--proxy-headers --forwarded-allow-ips=<caddy-ip>`. Never read `X-Forwarded-*` in application code directly — let Starlette's `ProxyHeadersMiddleware` do it.
|
||||
|
||||
## Target Repository Layout (after Phase 0)
|
||||
|
||||
```
|
||||
/ repo root
|
||||
├── app/ FastAPI application package
|
||||
│ ├── main.py app factory + startup
|
||||
│ ├── config.py typed config loader (pydantic-settings)
|
||||
│ ├── models/ dataclasses + SQL schema + migrations
|
||||
│ ├── routes/ public_router.py, admin_router.py, auth_router.py
|
||||
│ ├── services/ auth, email, cache, markdown, media, hcaptcha
|
||||
│ ├── templates/ Jinja2 (public/ + admin/ + emails/)
|
||||
│ └── static/ CSS, JS, site images, logo
|
||||
├── data/ runtime (SQLite DB + uploads) — mounted volume in prod
|
||||
├── docs/ business + architecture docs (NO application code)
|
||||
│ ├── README.md
|
||||
│ ├── ROADMAP.md
|
||||
│ ├── code_guidelines.md
|
||||
│ ├── security.md
|
||||
│ └── MANUAL_TESTING.md (added in Phase 1)
|
||||
├── tests/ pytest
|
||||
├── Logo/ brand assets (source)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── requirements.txt
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Security Must-Haves (in addition to `docs/security.md`)
|
||||
|
||||
- **SQL**: parameterized statements only (sqlite3 `?` placeholders or SQLAlchemy Core bind params). Never f-string a query.
|
||||
- **Markdown**: hardened pipeline `markdown-it-py` → `bleach` allowlist. No raw HTML pass-through.
|
||||
- **Image uploads**: validate magic bytes with `python-magic`, cap at 8 MB, re-encode through Pillow, store under a random filename, discard the client-supplied extension.
|
||||
- **CSRF**: double-submit cookie on every admin `POST` / `PUT` / `DELETE`.
|
||||
- **Cookies**: `Secure`, `HttpOnly`, `SameSite=Lax`; session IDs signed with `itsdangerous`.
|
||||
- **Security headers** (middleware): strict nonce-based CSP, HSTS, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy`.
|
||||
- **Magic-link tokens**: 256-bit random (`secrets.token_urlsafe(32)`), stored hashed (SHA-256), single-use, 15-minute expiry. IP is logged but not enforced (mobile roaming).
|
||||
- **Rate limits** on auth endpoints: 5 requests / 15 min / IP *and* / email.
|
||||
- **Admin allowlist**: only addresses in `ADMIN_EMAILS` env var may request a magic link. Any other address silently succeeds (no user enumeration) but sends no email.
|
||||
- **Secrets**: env / `.env` only, never committed. `.env.example` is the public contract.
|
||||
- **Audit logging**: every auth event (link requested, link consumed, session created/revoked, rate-limit hit) at INFO. Never log raw tokens or email bodies.
|
||||
|
||||
## How to Run (dev)
|
||||
|
||||
```bash
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # fill RESEND_API_KEY, HCAPTCHA_*, ADMIN_EMAILS, SECRET_KEY
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Docker (parity with prod):
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- `pytest` for auth, magic-link lifecycle, markdown sanitization, rate limits, contact form.
|
||||
- **Never mock the DB** in auth/magic-link tests — use a temp SQLite file so behavior matches prod.
|
||||
- Manual test checklist lives in `docs/MANUAL_TESTING.md` (added in roadmap Phase 1).
|
||||
|
||||
## Git Strategy (this project)
|
||||
|
||||
- Branches: `master` (prod) · `dev` (integration) · `feat/*`, `chore/*`, `docs/*`, `fix/*` (work branches).
|
||||
- **Work branches off `dev`.** Local `--no-ff` merge into `dev`, then push `dev`. Promote `dev` → `master` with `--no-ff` for releases. Tag `master` with `vX.Y.Z`.
|
||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`.
|
||||
- Do **not** push directly to `master`. Do **not** force-push shared branches.
|
||||
|
||||
## Pointers
|
||||
|
||||
- Generic Python standards: `docs/code_guidelines.md` (FastAPI overrides its Flask default)
|
||||
- Security baseline: `docs/security.md`
|
||||
- Phased roadmap, dataclasses, SQL schema, visual design: `docs/ROADMAP.md`
|
||||
- Docs folder guide: `docs/README.md`
|
||||
111
Dockerfile
Normal file
@@ -0,0 +1,111 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — container image
|
||||
#
|
||||
# Multi-stage build:
|
||||
# builder - install build deps, create a venv, resolve Python requirements
|
||||
# runtime - slim image with only runtime libs + the copied venv + app code
|
||||
#
|
||||
# Phase 0 scope: runs as root and has no HEALTHCHECK directive. Phase 6
|
||||
# (per docs/ROADMAP.md) introduces a non-root user and a HEALTHCHECK.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ARG PYTHON_IMAGE=python:3.12-slim-bookworm
|
||||
|
||||
# ==== Stage 1: builder =====================================================
|
||||
FROM ${PYTHON_IMAGE} AS builder
|
||||
|
||||
# Avoid interactive apt prompts and keep pip quiet + deterministic.
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Build dependencies: `build-essential` covers any wheel that needs to
|
||||
# compile from source; libmagic headers are required by python-magic.
|
||||
# Keep this list minimal to reduce attack surface of the builder stage.
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
build-essential \
|
||||
libmagic1 \
|
||||
libmagic-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Dedicated virtualenv at a stable path so the runtime stage can copy it
|
||||
# verbatim. This keeps the runtime image free of build tooling.
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install Python dependencies. Copying only requirements.txt first lets
|
||||
# Docker cache the dependency layer when application code changes.
|
||||
COPY requirements.txt /build/requirements.txt
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
# ==== Stage 2: runtime =====================================================
|
||||
FROM ${PYTHON_IMAGE} AS runtime
|
||||
|
||||
# Runtime-only libs: python-magic needs libmagic1 at import time. Build
|
||||
# tooling is intentionally NOT installed here.
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the pre-built virtualenv from the builder stage and put it first
|
||||
# on PATH so `python` and installed console scripts (uvicorn) resolve.
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Application source. Only the `app/` package is needed at runtime; tests
|
||||
# and docs stay out of the image.
|
||||
COPY app /app/app
|
||||
|
||||
# Git commit SHA wired in at build time. Declared as an ARG with a safe
|
||||
# default so local builds without --build-arg still succeed; surfaced to
|
||||
# runtime via an ENV so the Settings loader can pick it up.
|
||||
ARG GIT_COMMIT_SHA=unknown
|
||||
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
|
||||
|
||||
# Phase 6 hardening: create a dedicated non-root user with a stable
|
||||
# UID/GID so host bind mounts (data/, media/) have predictable
|
||||
# ownership. We pin 10001 because values < 1000 collide with Debian
|
||||
# system accounts on some host distros.
|
||||
RUN groupadd -g 10001 app \
|
||||
&& useradd -m -u 10001 -g app app \
|
||||
&& chown -R app:app /app /opt/venv
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Phase 6 healthcheck: hits /healthz over the loopback with stdlib
|
||||
# urllib so we don't have to install curl/wget in the runtime image.
|
||||
# Intervals tuned for a small VM: probe every 30s, give a fresh
|
||||
# container 10s to finish startup before the first probe counts.
|
||||
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:8080/healthz', timeout=2); sys.exit(0)" || exit 1
|
||||
|
||||
# Run Uvicorn directly. --proxy-headers + --forwarded-allow-ips make
|
||||
# Starlette's ProxyHeadersMiddleware trust X-Forwarded-* only from the
|
||||
# listed peer IPs. The trusted-IP value is env-driven so the image
|
||||
# can be reused across topologies:
|
||||
# - local: defaults to 127.0.0.1 (when running uvicorn on the host)
|
||||
# - docker/compose behind Caddy: set FORWARDED_ALLOW_IPS="*" in .env
|
||||
# because the container's source IP is the bridge gateway, not
|
||||
# 127.0.0.1. Safe because the host only binds 127.0.0.1:8080 so
|
||||
# nothing off-host can reach uvicorn directly.
|
||||
# `sh -c exec` keeps uvicorn as PID 1 so SIGTERM still triggers a
|
||||
# graceful shutdown (exec form was fine before, but we need shell
|
||||
# expansion for ${FORWARDED_ALLOW_IPS}).
|
||||
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port 8080 --proxy-headers --forwarded-allow-ips \"${FORWARDED_ALLOW_IPS:-127.0.0.1}\""]
|
||||
BIN
Logo/chicken babies r us-01.jpg
Normal file
|
After Width: | Height: | Size: 340 KiB |
2166
Logo/chicken babies r us.ai
Normal file
BIN
Logo/chicken babies r us.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
14
app/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Chicken Babies R Us FastAPI application package.
|
||||
|
||||
This module intentionally keeps only the package version constant. All
|
||||
runtime wiring lives in :mod:`app.main`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Semantic version of the application. Bump per release. Surfaced via
|
||||
# FastAPI's OpenAPI metadata and the /healthz endpoint so ops can verify
|
||||
# which build is live in a given environment.
|
||||
__version__: str = "0.1.0"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
215
app/config.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Typed application configuration loader.
|
||||
|
||||
Loads environment variables (with `.env` support for local development)
|
||||
into a strongly-typed :class:`Settings` instance via `pydantic-settings`.
|
||||
|
||||
The full environment contract lives here so every consumer of configuration
|
||||
can import :func:`get_settings` and get a validated object. Values that
|
||||
aren't needed until later roadmap phases are declared as :data:`Optional`
|
||||
so the app boots cleanly in Phase 0 without mandating yet-unused secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
# Sentinel value for the SECRET_KEY in .env.example. The production
|
||||
# validator below refuses to start the app if this sentinel survives into
|
||||
# a production deployment. Keeping it as a named constant makes the
|
||||
# security control explicit and searchable.
|
||||
_DEV_SECRET_KEY_SENTINEL: str = "dev-insecure-change-me"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Strongly-typed runtime configuration.
|
||||
|
||||
Populated from process environment variables and (for local dev) an
|
||||
optional `.env` file. All fields correspond 1:1 with the env-var
|
||||
contract documented in ``docs/ROADMAP.md`` and ``.env.example``.
|
||||
"""
|
||||
|
||||
# Pydantic-settings config: read from .env when present, ignore unknown
|
||||
# keys (so adding new vars to .env ahead of code changes is non-fatal),
|
||||
# and treat env var names case-insensitively.
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# --- Runtime mode ------------------------------------------------------
|
||||
app_env: Literal["development", "production"] = Field(
|
||||
default="development",
|
||||
description="Runtime mode; gates debug behavior and logging renderer.",
|
||||
)
|
||||
|
||||
# --- Signing / sessions -----------------------------------------------
|
||||
# Used by itsdangerous for signed cookies and CSRF tokens. The default
|
||||
# is intentionally a well-known sentinel so local dev just works; the
|
||||
# model validator below blocks it from reaching production.
|
||||
secret_key: str = Field(
|
||||
default=_DEV_SECRET_KEY_SENTINEL,
|
||||
description="itsdangerous signer key; MUST be overridden in production.",
|
||||
)
|
||||
|
||||
# --- Database ----------------------------------------------------------
|
||||
database_url: str = Field(
|
||||
default="sqlite:///data/app.db",
|
||||
description="SQLAlchemy URL for the application database.",
|
||||
)
|
||||
|
||||
# --- Media storage -----------------------------------------------------
|
||||
# Filesystem directory holding admin-uploaded images. Mounted
|
||||
# publicly at ``/media`` by the FastAPI factory, so the Markdown
|
||||
# URL inserted after a drag-drop upload matches what the public
|
||||
# site can fetch. Relative paths resolve against the process cwd
|
||||
# (uvicorn runs from the repo root in dev; Docker's WORKDIR in
|
||||
# prod), matching the DATABASE_URL convention.
|
||||
media_root: str = Field(
|
||||
default="data/media",
|
||||
description="Filesystem directory where admin-uploaded media is stored.",
|
||||
)
|
||||
|
||||
# --- Email (Resend) ----------------------------------------------------
|
||||
# Optional at Phase 0: contact form and magic-link delivery come online
|
||||
# in Phases 3 and 5 respectively.
|
||||
resend_api_key: Optional[str] = Field(default=None)
|
||||
resend_from: Optional[str] = Field(default=None)
|
||||
|
||||
# --- Admin allowlist / contact routing ---------------------------------
|
||||
# Comma-separated list; stored raw so we preserve exact input for audit,
|
||||
# and split on demand via the `admin_emails_list` property.
|
||||
admin_emails: str = Field(
|
||||
default="",
|
||||
description="Comma-separated allowlist of admin email addresses.",
|
||||
)
|
||||
admin_contact_email: Optional[str] = Field(default=None)
|
||||
|
||||
# --- hCaptcha ----------------------------------------------------------
|
||||
hcaptcha_site_key: Optional[str] = Field(default=None)
|
||||
hcaptcha_secret: Optional[str] = Field(default=None)
|
||||
|
||||
# --- Reverse proxy -----------------------------------------------------
|
||||
# Only requests whose peer IP is in this list will have their
|
||||
# X-Forwarded-* headers trusted by Uvicorn's proxy-headers support.
|
||||
forwarded_allow_ips: str = Field(default="127.0.0.1")
|
||||
|
||||
# --- Session / auth tuning --------------------------------------------
|
||||
session_max_days: int = Field(default=30, ge=1, le=365)
|
||||
magic_link_ttl_min: int = Field(default=15, ge=1, le=60)
|
||||
|
||||
# --- Public URL for link construction ---------------------------------
|
||||
# Used to build the absolute URL emailed in magic-link auth. Defaults
|
||||
# to the local uvicorn address so dev flows work out of the box; the
|
||||
# production validator below forbids an un-set value only implicitly
|
||||
# (if the site is served off 127.0.0.1 in prod the deploy is broken
|
||||
# for reasons unrelated to this field).
|
||||
public_base_url: str = Field(
|
||||
default="http://127.0.0.1:8000",
|
||||
description=(
|
||||
"Absolute base URL (scheme+host+port) used to build links in "
|
||||
"outbound emails, e.g. the magic-link URL."
|
||||
),
|
||||
)
|
||||
|
||||
# --- Build metadata ----------------------------------------------------
|
||||
# Injected at Docker build time via an ARG/ENV. Surfaced via /healthz so
|
||||
# operators can confirm which build is live.
|
||||
git_commit_sha: str = Field(default="unknown")
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Derived helpers
|
||||
# ----------------------------------------------------------------------
|
||||
@property
|
||||
def admin_emails_list(self) -> list[str]:
|
||||
"""Return the admin allowlist as a clean list of lowercase strings.
|
||||
|
||||
Splits ``admin_emails`` on commas, strips whitespace, drops empty
|
||||
tokens, and lowercases — matching typical email canonicalization
|
||||
for allowlist comparisons. The raw string is preserved on the
|
||||
instance for audit / diagnostic purposes.
|
||||
"""
|
||||
return [
|
||||
part.strip().lower()
|
||||
for part in self.admin_emails.split(",")
|
||||
if part.strip()
|
||||
]
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Validators
|
||||
# ----------------------------------------------------------------------
|
||||
@model_validator(mode="after")
|
||||
def _refuse_dev_secret_in_production(self) -> "Settings":
|
||||
"""Fail fast if the dev sentinel SECRET_KEY reaches production.
|
||||
|
||||
Security control: prevents accidental deployment with the
|
||||
publicly-documented placeholder secret, which would make every
|
||||
signed cookie / CSRF token forgeable by anyone with the source.
|
||||
"""
|
||||
if (
|
||||
self.app_env == "production"
|
||||
and self.secret_key == _DEV_SECRET_KEY_SENTINEL
|
||||
):
|
||||
raise ValueError(
|
||||
"SECRET_KEY must be overridden in production; "
|
||||
"the dev sentinel value is not permitted."
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_auth_config_in_production(self) -> "Settings":
|
||||
"""Ensure auth-critical settings are populated in production.
|
||||
|
||||
Security control: magic-link auth depends on Resend to deliver
|
||||
one-time login tokens and on the admin allowlist to gate access.
|
||||
A production deploy that's missing any of these would either
|
||||
silently fall back to the dev log (exposing login URLs in logs)
|
||||
or accept an empty allowlist (locking the site open to nobody
|
||||
but also preventing any admin from logging in). Either outcome
|
||||
is a Phase 3 regression; fail fast instead.
|
||||
"""
|
||||
if self.app_env != "production":
|
||||
return self
|
||||
missing: list[str] = []
|
||||
if not self.resend_api_key:
|
||||
missing.append("RESEND_API_KEY")
|
||||
if not self.resend_from:
|
||||
missing.append("RESEND_FROM")
|
||||
if not self.admin_emails or not self.admin_emails_list:
|
||||
missing.append("ADMIN_EMAILS")
|
||||
# Phase 5: the public contact form needs a destination inbox
|
||||
# and a real hCaptcha key pair. Missing any of these would
|
||||
# either drop submissions on the floor (no recipient) or open
|
||||
# the form to unverified bot traffic (no captcha), so we
|
||||
# enforce them at boot.
|
||||
if not self.admin_contact_email:
|
||||
missing.append("ADMIN_CONTACT_EMAIL")
|
||||
if not self.hcaptcha_secret:
|
||||
missing.append("HCAPTCHA_SECRET")
|
||||
if not self.hcaptcha_site_key:
|
||||
missing.append("HCAPTCHA_SITE_KEY")
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"Production configuration is missing required values: "
|
||||
+ ", ".join(missing)
|
||||
+ ". These are needed for admin auth and the contact form."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Return a process-wide cached :class:`Settings` instance.
|
||||
|
||||
Using :func:`functools.lru_cache` ensures the environment is parsed
|
||||
exactly once per process, which is both cheaper and — more importantly
|
||||
— guarantees every caller sees the same validated values. Tests that
|
||||
need a fresh parse can call ``get_settings.cache_clear()``.
|
||||
"""
|
||||
return Settings()
|
||||
200
app/db.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""SQLAlchemy engine factory, SQLite PRAGMA hookup, and migration runner.
|
||||
|
||||
Responsibilities in this module:
|
||||
|
||||
1. **Engine construction** — :func:`build_engine` produces a
|
||||
``sqlalchemy.Engine`` from the application's ``DATABASE_URL``,
|
||||
threaded-safe for uvicorn's worker pool.
|
||||
2. **Per-connection PRAGMAs** — a single ``@event.listens_for(Engine,
|
||||
"connect")`` hook sets ``journal_mode = WAL`` and ``foreign_keys =
|
||||
ON`` on *every* new SQLite connection, not just the first. SQLite
|
||||
applies both pragmas per-connection, so doing this once at startup
|
||||
would silently leave FKs disabled for every worker.
|
||||
3. **Migration runner** — :func:`run_migrations` applies every
|
||||
``.sql`` file under :mod:`app.models.migrations` in lexicographic
|
||||
order, tracking applied files in a ``schema_migrations`` table.
|
||||
Migrations are trusted developer-authored SQL loaded via
|
||||
:meth:`sqlite3.Connection.executescript`; they never touch user
|
||||
input.
|
||||
|
||||
No Python code in this module builds a SQL statement by string
|
||||
interpolation. Queries go through ``sqlalchemy.text(":bind")``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, create_engine, event, text
|
||||
|
||||
# Directory containing the ``NNN_description.sql`` migration files. Kept
|
||||
# as a module-level constant so tests can reason about it without
|
||||
# importing the runner internals.
|
||||
_MIGRATIONS_DIR: Final[Path] = Path(__file__).resolve().parent / "models" / "migrations"
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def build_engine(database_url: str) -> Engine:
|
||||
"""Build a SQLAlchemy :class:`Engine` for the app's SQLite database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
database_url:
|
||||
A SQLAlchemy URL. In production this is
|
||||
``sqlite:///data/app.db``; tests pass a tmp-path file URL.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- ``check_same_thread=False`` is required because uvicorn services
|
||||
requests from a worker-thread pool; SQLAlchemy's connection pool
|
||||
plus our explicit transactions keep this safe.
|
||||
- For file-backed SQLite URLs we eagerly create the parent
|
||||
directory (SQLite refuses to create missing directories).
|
||||
- ``future=True`` opts into SQLAlchemy 2.x semantics; redundant on
|
||||
2.0+ but explicit is better than implicit.
|
||||
"""
|
||||
# Ensure the on-disk directory exists for file-backed SQLite URLs.
|
||||
# In-memory databases and ``:memory:`` URLs are left alone.
|
||||
if database_url.startswith("sqlite:///"):
|
||||
db_path_str = database_url[len("sqlite:///"):]
|
||||
if db_path_str and db_path_str != ":memory:":
|
||||
db_path = Path(db_path_str)
|
||||
# Relative paths resolve against the current working
|
||||
# directory. This matches uvicorn's default cwd (the repo
|
||||
# root) and Docker's WORKDIR.
|
||||
parent = db_path.parent
|
||||
if str(parent) and parent != Path("."):
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
engine = create_engine(
|
||||
database_url,
|
||||
future=True,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
_install_sqlite_pragmas(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def _install_sqlite_pragmas(engine: Engine) -> None:
|
||||
"""Attach a connect-event listener that enforces our SQLite PRAGMAs.
|
||||
|
||||
``journal_mode = WAL`` and ``foreign_keys = ON`` are both
|
||||
per-connection settings in SQLite. Applying them on every new
|
||||
connection — rather than once at startup — is the only way to
|
||||
guarantee foreign-key enforcement across all pool workers.
|
||||
"""
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _on_connect(dbapi_connection, connection_record) -> None: # type: ignore[no-untyped-def]
|
||||
"""Run per-connection SQLite initialization.
|
||||
|
||||
Uses the raw DB-API cursor (not SQLAlchemy ``text`` wrappers)
|
||||
because PRAGMA calls are not valid parameterized SQL — they
|
||||
are trusted, developer-authored literals with no external
|
||||
input.
|
||||
"""
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
# WAL improves concurrency (readers don't block the
|
||||
# single writer) and is well-suited to our read-heavy
|
||||
# workload. It persists on the database file, so
|
||||
# re-setting is a cheap no-op after the first call.
|
||||
cursor.execute("PRAGMA journal_mode = WAL")
|
||||
# foreign_keys is per-connection; SQLite defaults to OFF,
|
||||
# so we MUST set it here to have referential integrity.
|
||||
cursor.execute("PRAGMA foreign_keys = ON")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def run_migrations(engine: Engine) -> list[str]:
|
||||
"""Apply any un-applied SQL files from :mod:`app.models.migrations`.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Creates a ``schema_migrations`` tracker table if missing.
|
||||
- Lists ``.sql`` files in :data:`_MIGRATIONS_DIR` in sorted order.
|
||||
- For each file not yet in ``schema_migrations``, runs its content
|
||||
via :meth:`sqlite3.Connection.executescript` (necessary because
|
||||
a migration file may contain multiple statements) inside a
|
||||
single ``BEGIN IMMEDIATE`` transaction, then records the
|
||||
version. Already-applied files are skipped.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
The ordered list of versions applied on *this* call. Empty
|
||||
when the DB is already up to date, useful for logs and tests.
|
||||
|
||||
Security note
|
||||
-------------
|
||||
Migration SQL is trusted input from the repository; it does not
|
||||
mix with user-origin data and therefore does not need bind
|
||||
parameters. User data still flows exclusively through
|
||||
parameterized queries elsewhere (see ``docs/security.md`` CWE-89).
|
||||
"""
|
||||
files = sorted(p for p in _MIGRATIONS_DIR.glob("*.sql"))
|
||||
applied_now: list[str] = []
|
||||
|
||||
# A single "raw connection" over the life of the migration run
|
||||
# lets us mix executescript (DDL) with ordinary parameterized
|
||||
# bookkeeping cleanly. We commit per file so a failure partway
|
||||
# through leaves earlier files recorded.
|
||||
with engine.connect() as conn:
|
||||
# Ensure the tracker table exists. Can't use schema_migrations
|
||||
# itself to gate this since it may not exist yet.
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
||||
" version TEXT PRIMARY KEY,"
|
||||
" applied_at TEXT NOT NULL"
|
||||
")"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Pull the set of already-applied versions once.
|
||||
already_applied = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
text("SELECT version FROM schema_migrations")
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
for path in files:
|
||||
version = path.stem
|
||||
if version in already_applied:
|
||||
continue
|
||||
|
||||
sql_text = path.read_text(encoding="utf-8")
|
||||
|
||||
# executescript is only exposed on the DB-API connection,
|
||||
# so we reach through the SQLAlchemy connection's raw
|
||||
# cursor. Trust boundary: the file is checked into git,
|
||||
# never user-supplied, so there is no injection vector.
|
||||
raw = conn.connection
|
||||
raw.executescript(sql_text)
|
||||
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO schema_migrations (version, applied_at) "
|
||||
"VALUES (:v, :t)"
|
||||
),
|
||||
{
|
||||
"v": version,
|
||||
"t": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
applied_now.append(version)
|
||||
_log.info("migration_applied", version=version)
|
||||
|
||||
if not applied_now:
|
||||
_log.info("migrations_up_to_date")
|
||||
return applied_now
|
||||
8
app/dependencies/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""FastAPI dependency helpers.
|
||||
|
||||
Route-level ``Depends(...)`` functions that don't belong to a single
|
||||
service live here. Phase 3 introduces ``app.dependencies.auth`` for
|
||||
``get_current_user`` / ``require_admin``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
89
app/dependencies/auth.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Auth dependencies for admin routes.
|
||||
|
||||
Two ``Depends(...)`` helpers:
|
||||
|
||||
- :func:`get_current_user` — returns a :class:`User` or ``None`` based
|
||||
on the signed ``cb_session`` cookie. Never raises.
|
||||
- :func:`require_admin` — same lookup but raises an HTTP 303 redirect
|
||||
to ``/admin/login`` if no user is authenticated. Used by every route
|
||||
that must be logged-in.
|
||||
|
||||
Cookie handling
|
||||
---------------
|
||||
The cookie is read via ``request.cookies`` (Starlette strips secure /
|
||||
httponly flags off by the time the app sees it; they only affect how
|
||||
the browser stores and presents the cookie). Unsigning, hashing, and
|
||||
DB lookup are delegated to :class:`app.services.sessions.SessionService`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.models.entities import User
|
||||
from app.models.mappers import row_to_user
|
||||
from app.services.sessions import COOKIE_NAME, SessionService
|
||||
|
||||
|
||||
def _get_session_service(request: Request) -> SessionService:
|
||||
"""Return the app-scoped :class:`SessionService` for DI.
|
||||
|
||||
Private to this module — routes and other dependencies resolve the
|
||||
service via :func:`get_current_user` / :func:`require_admin` rather
|
||||
than reaching across the dependency graph.
|
||||
"""
|
||||
return request.app.state.session_service
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
sessions: SessionService = Depends(_get_session_service),
|
||||
) -> Optional[User]:
|
||||
"""Return the authenticated :class:`User` or ``None``.
|
||||
|
||||
Never raises. A malformed / expired / revoked cookie simply
|
||||
resolves to ``None`` so that un-authed viewers can hit admin
|
||||
login pages without tripping an exception handler.
|
||||
"""
|
||||
cookie_value = request.cookies.get(COOKIE_NAME)
|
||||
session = sessions.lookup(cookie_value)
|
||||
if session is None:
|
||||
return None
|
||||
|
||||
# We need the user row to render "Welcome, <display_name>" on the
|
||||
# admin index. Query directly here instead of adding a full
|
||||
# UserService for one call site.
|
||||
engine = request.app.state.engine
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, email, display_name, created_at,"
|
||||
" last_login_at, active"
|
||||
" FROM users WHERE id = :id AND active = 1 LIMIT 1"
|
||||
),
|
||||
{"id": session.user_id},
|
||||
).mappings().first()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
return row_to_user(row)
|
||||
|
||||
|
||||
def require_admin(
|
||||
user: Optional[User] = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""Return the authenticated user or redirect to login.
|
||||
|
||||
Uses a 303 "See Other" so the browser switches to GET on the
|
||||
followup request — correct behavior for both initial page loads
|
||||
and the post-consume redirect chain.
|
||||
"""
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=303,
|
||||
headers={"Location": "/admin/login"},
|
||||
)
|
||||
return user
|
||||
65
app/dependencies/csrf.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""FastAPI dependency for CSRF verification on admin mutating endpoints.
|
||||
|
||||
Mount on any route that performs a state change (POST / PUT / DELETE)
|
||||
and sits inside the admin router. GET admin routes do NOT need this —
|
||||
they only need ``require_admin`` to gate access.
|
||||
|
||||
Two flavors, same underlying check:
|
||||
|
||||
- :func:`require_csrf_form` — reads ``csrf_token`` from the form body.
|
||||
Preferred for classic HTML forms.
|
||||
- :func:`require_csrf_header` — reads ``X-CSRF-Token`` from the request
|
||||
headers. Used by the live-preview fetch and the drag-drop upload
|
||||
endpoint where sending an extra form field is awkward.
|
||||
|
||||
Both raise HTTP 403 on mismatch, which surfaces as the generic FastAPI
|
||||
error page. No information leaks about which side (cookie or submitted
|
||||
token) failed — fail-closed is uniform.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Form, HTTPException, Request
|
||||
|
||||
from app.services.csrf import CSRF_COOKIE_NAME, CSRFService
|
||||
|
||||
|
||||
def _get_csrf_service(request: Request) -> CSRFService:
|
||||
"""Pull the app-scoped :class:`CSRFService` off ``request.app.state``.
|
||||
|
||||
Private helper: route handlers depend on :func:`require_csrf_form`
|
||||
or :func:`require_csrf_header` directly, not on this lookup.
|
||||
"""
|
||||
return request.app.state.csrf_service
|
||||
|
||||
|
||||
def require_csrf_form(
|
||||
request: Request,
|
||||
csrf_token: str = Form(default=""),
|
||||
) -> None:
|
||||
"""Verify a form-submitted CSRF token matches the signed cookie.
|
||||
|
||||
Raises :class:`fastapi.HTTPException` 403 on any mismatch. On
|
||||
success returns ``None`` — the dependency has no payload.
|
||||
"""
|
||||
service: CSRFService = _get_csrf_service(request)
|
||||
cookie_value: Optional[str] = request.cookies.get(CSRF_COOKIE_NAME)
|
||||
if not service.verify(cookie_value=cookie_value, submitted=csrf_token):
|
||||
raise HTTPException(status_code=403, detail="CSRF verification failed")
|
||||
|
||||
|
||||
def require_csrf_header(
|
||||
request: Request,
|
||||
) -> None:
|
||||
"""Verify an ``X-CSRF-Token`` header matches the signed cookie.
|
||||
|
||||
Used by the JS-driven preview + media upload endpoints. The header
|
||||
name is case-insensitive — Starlette canonicalizes on read.
|
||||
"""
|
||||
service: CSRFService = _get_csrf_service(request)
|
||||
cookie_value: Optional[str] = request.cookies.get(CSRF_COOKIE_NAME)
|
||||
submitted: Optional[str] = request.headers.get("x-csrf-token")
|
||||
if not service.verify(cookie_value=cookie_value, submitted=submitted):
|
||||
raise HTTPException(status_code=403, detail="CSRF verification failed")
|
||||
72
app/logging_config.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Structlog initialization.
|
||||
|
||||
Single entry point :func:`configure_logging` sets up structlog with an
|
||||
``APP_ENV``-driven renderer: a pretty console renderer during development
|
||||
and JSON-lines output in production so logs plug straight into any log
|
||||
aggregator. Must be called exactly once at app startup, before any
|
||||
module obtains a logger via ``structlog.get_logger()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
def configure_logging(app_env: str) -> None:
|
||||
"""Configure structlog + stdlib logging for the application.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
app_env:
|
||||
The resolved ``APP_ENV`` value. ``"development"`` selects a
|
||||
colorized console renderer; anything else (including
|
||||
``"production"``) selects the JSON renderer so logs are
|
||||
machine-parseable.
|
||||
|
||||
Notes
|
||||
-----
|
||||
This function is idempotent within a process: structlog accepts
|
||||
repeated calls to :func:`structlog.configure`. It deliberately keeps
|
||||
the setup small for Phase 0 — a proper ``ProcessorFormatter`` bridge
|
||||
between stdlib and structlog can be layered in later without touching
|
||||
call sites.
|
||||
"""
|
||||
# Route stdlib logging through a root handler writing to stdout. Our
|
||||
# own loggers go through structlog's pipeline; third-party libraries
|
||||
# that use the stdlib logger will at least surface at INFO.
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
stream=sys.stdout,
|
||||
format="%(message)s",
|
||||
)
|
||||
|
||||
# Processors shared across environments. Order matters: contextvars
|
||||
# first so bound context is present for every later step; exception
|
||||
# info extraction before rendering so tracebacks serialize cleanly.
|
||||
shared_processors: list[Any] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
]
|
||||
|
||||
# Environment-specific final renderer. Dev gets human-friendly
|
||||
# colorized output; everywhere else emits JSON lines.
|
||||
if app_env == "development":
|
||||
final_renderer: Any = structlog.dev.ConsoleRenderer(colors=True)
|
||||
else:
|
||||
final_renderer = structlog.processors.JSONRenderer()
|
||||
|
||||
structlog.configure(
|
||||
processors=[*shared_processors, final_renderer],
|
||||
# PrintLoggerFactory writes to stdout without requiring a stdlib
|
||||
# logger bridge; sufficient for Phase 0.
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
385
app/main.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""FastAPI application factory and module-level app instance.
|
||||
|
||||
The factory pattern (``create_app``) keeps test setup straightforward and
|
||||
lets us swap in alternate configurations without module-level side
|
||||
effects. ``app = create_app()`` at import time is what Uvicorn references
|
||||
via ``app.main:app``.
|
||||
|
||||
Phase 2 additions:
|
||||
- Build a shared SQLAlchemy :class:`~sqlalchemy.Engine` from
|
||||
``settings.database_url`` and attach the per-connection
|
||||
PRAGMA listener (WAL + foreign keys).
|
||||
- Apply SQL migrations from :mod:`app.models.migrations`.
|
||||
- Run the idempotent seed (welcome post, About page, system user).
|
||||
- Instantiate :class:`PostService` and :class:`PageService` and
|
||||
expose them on ``app.state`` for route-level DI.
|
||||
|
||||
Phase 3 additions:
|
||||
- Build an ``itsdangerous.URLSafeTimedSerializer`` on
|
||||
``settings.secret_key`` and attach to ``app.state``.
|
||||
- Instantiate :class:`AuditService`, :class:`EmailService`,
|
||||
:class:`SessionService`, :class:`AuthService` and attach them to
|
||||
``app.state``.
|
||||
- Create a SlowAPI :class:`Limiter` and register the
|
||||
``RateLimitExceeded`` exception handler (renders
|
||||
``admin/rate_limited.html`` + HTTP 429 + audit row).
|
||||
- Include the admin router.
|
||||
|
||||
Phase 4 additions:
|
||||
- Build a separate :class:`itsdangerous.URLSafeTimedSerializer`
|
||||
salted with ``"csrf"`` and wrap it in a :class:`CSRFService`.
|
||||
- Instantiate :class:`MarkdownService`,
|
||||
:class:`AdminPostsService`, :class:`AdminPagesService`, and
|
||||
:class:`MediaService` and attach them to ``app.state``.
|
||||
- Mount the admin CMS router.
|
||||
- Install a lightweight middleware that issues / refreshes the
|
||||
CSRF cookie on admin GET requests and exposes the token via
|
||||
``request.state.csrf_token`` for template rendering.
|
||||
- Mount ``settings.media_root`` at ``/media`` as a StaticFiles
|
||||
route so uploaded images are publicly reachable under the
|
||||
Markdown URLs the admin inserts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app import __version__
|
||||
from app.config import get_settings
|
||||
from app.db import build_engine, run_migrations
|
||||
from app.logging_config import configure_logging
|
||||
from app.middleware import AccessLogMiddleware, SecurityHeadersMiddleware
|
||||
from app.models.seed import run_seed
|
||||
from app.routes.admin import router as admin_router
|
||||
from app.routes.admin_cms import router as admin_cms_router
|
||||
from app.routes.health import router as health_router
|
||||
from app.routes.public import router as public_router
|
||||
from app.services.admin_pages import AdminPagesService
|
||||
from app.services.admin_posts import AdminPostsService
|
||||
from app.services.audit import AuditService
|
||||
from app.services.auth import AuthService
|
||||
from app.services.contact import ContactService
|
||||
from app.services.csrf import CSRF_COOKIE_NAME, CSRFService
|
||||
from app.services.email import EmailService
|
||||
from app.services.hcaptcha import HCaptchaService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.media import MediaService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
from app.services.rate_limit import create_limiter
|
||||
from app.services.sessions import SessionService
|
||||
|
||||
|
||||
# Resolve the package root once so template / static paths stay correct
|
||||
# regardless of the current working directory at startup (running under
|
||||
# uvicorn from the repo root vs. pytest from anywhere vs. inside Docker).
|
||||
_PACKAGE_ROOT: Path = Path(__file__).resolve().parent
|
||||
_TEMPLATES_DIR: Path = _PACKAGE_ROOT / "templates"
|
||||
_STATIC_DIR: Path = _PACKAGE_ROOT / "static"
|
||||
|
||||
|
||||
class CSRFCookieMiddleware(BaseHTTPMiddleware):
|
||||
"""Issue / refresh the CSRF cookie on admin GET responses.
|
||||
|
||||
The middleware is intentionally narrow: it only fires for requests
|
||||
whose path starts with ``/admin`` AND method is GET. That's the
|
||||
set of responses that render HTML with a form waiting to be
|
||||
submitted; mutating endpoints never set the cookie themselves.
|
||||
|
||||
For every such request we:
|
||||
|
||||
1. Read the existing ``cb_csrf`` cookie (if any).
|
||||
2. Call :meth:`CSRFService.issue` which reuses the underlying
|
||||
nonce when the cookie is still valid — this means a tab that
|
||||
GETs the dashboard, then POSTs 30 minutes later, still
|
||||
matches.
|
||||
3. Stash the token on ``request.state.csrf_token`` so route
|
||||
handlers pass it into Jinja.
|
||||
4. After the downstream response is produced, set / refresh the
|
||||
cookie header.
|
||||
"""
|
||||
|
||||
def __init__(self, app, csrf_service: CSRFService) -> None:
|
||||
"""Store the service by reference; BaseHTTPMiddleware takes the ASGI app."""
|
||||
super().__init__(app)
|
||||
self._csrf_service: CSRFService = csrf_service
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Run the admin-GET issue hook around the downstream handler."""
|
||||
should_issue = (
|
||||
request.url.path.startswith("/admin")
|
||||
and request.method == "GET"
|
||||
)
|
||||
if should_issue:
|
||||
existing = request.cookies.get(CSRF_COOKIE_NAME)
|
||||
token, cookie_value = self._csrf_service.issue(existing)
|
||||
request.state.csrf_token = token
|
||||
else:
|
||||
cookie_value = None
|
||||
|
||||
response: Response = await call_next(request)
|
||||
|
||||
if should_issue and cookie_value is not None:
|
||||
response.set_cookie(
|
||||
value=cookie_value,
|
||||
**self._csrf_service.cookie_params(),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Build and return the FastAPI application.
|
||||
|
||||
Responsibilities (in strict order):
|
||||
|
||||
1. Load validated configuration via :func:`get_settings`.
|
||||
2. Initialize structured logging *before* any logger is used.
|
||||
3. Build the SQLAlchemy engine and install the PRAGMA listener.
|
||||
4. Apply SQL migrations (idempotent — no-op after first boot).
|
||||
5. Run the seed (idempotent — marked via ``schema_migrations``).
|
||||
6. Instantiate services and attach them to ``app.state`` so route
|
||||
dependencies can resolve them via ``request.app.state``.
|
||||
7. Mount static files, attach the shared :class:`Jinja2Templates`,
|
||||
and register routers (including admin).
|
||||
8. Wire the SlowAPI limiter and its exception handler.
|
||||
9. Emit a single ``app_started`` structured log event.
|
||||
"""
|
||||
# Parse + validate configuration first so a bad environment fails fast
|
||||
# with a clear pydantic error before we touch logging / FastAPI.
|
||||
settings = get_settings()
|
||||
|
||||
# Configure structlog *before* acquiring any logger in the app so the
|
||||
# very first log line already flows through our processor chain.
|
||||
configure_logging(settings.app_env)
|
||||
|
||||
# --- Database plumbing --------------------------------------------------
|
||||
# Engine is a process-wide resource. Built here so that migrations
|
||||
# and seed both run on the same pool/config as the running app.
|
||||
engine = build_engine(settings.database_url)
|
||||
run_migrations(engine)
|
||||
run_seed(engine)
|
||||
|
||||
# Ensure the media storage directory exists — Starlette's
|
||||
# StaticFiles mount refuses to start without a real directory.
|
||||
media_root = Path(settings.media_root)
|
||||
media_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
application = FastAPI(
|
||||
title="Chicken Babies R Us",
|
||||
version=__version__,
|
||||
# Docs stay on for Phase 0; a later phase can gate these behind
|
||||
# admin auth or disable them entirely in production.
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
# Serve CSS, images, and other static assets from app/static. The
|
||||
# `check_dir=False` would let Starlette skip the existence check; we
|
||||
# leave it at its default so a missing directory surfaces loudly in
|
||||
# dev. In prod the directory is baked into the container image.
|
||||
application.mount(
|
||||
"/static",
|
||||
StaticFiles(directory=_STATIC_DIR),
|
||||
name="static",
|
||||
)
|
||||
|
||||
# Public mount for admin-uploaded images. Kept separate from /static
|
||||
# so admin uploads never collide with the brand assets shipped with
|
||||
# the container image.
|
||||
application.mount(
|
||||
"/media",
|
||||
StaticFiles(directory=str(media_root)),
|
||||
name="media",
|
||||
)
|
||||
|
||||
# Single shared Jinja2 environment. Storing it on ``app.state`` keeps
|
||||
# route modules free of an import dependency on this module (which
|
||||
# would be circular once admin/auth routers are added in later
|
||||
# phases). Route handlers pull it via a ``Depends(get_templates)``
|
||||
# function defined next to the routes.
|
||||
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
|
||||
application.state.templates = templates
|
||||
|
||||
# Store the engine + services on ``app.state`` so the
|
||||
# dependency-injection helpers in :mod:`app.services.*` can find
|
||||
# them without importing this module (circular-import-safe).
|
||||
application.state.engine = engine
|
||||
application.state.post_service = PostService(engine)
|
||||
application.state.page_service = PageService(engine)
|
||||
|
||||
# --- Phase 3 wiring -----------------------------------------------------
|
||||
# itsdangerous signer: signs (and later verifies) session-cookie
|
||||
# values using SECRET_KEY and the salt "session". The same instance
|
||||
# is shared by every request — cheap to construct, no state beyond
|
||||
# the key.
|
||||
signer = URLSafeTimedSerializer(settings.secret_key, salt="session")
|
||||
application.state.signer = signer
|
||||
|
||||
# Audit first — EmailService and AuthService both depend on it
|
||||
# (EmailService indirectly via the request-path contract: failures
|
||||
# are logged, never raised).
|
||||
audit_service = AuditService(engine)
|
||||
email_service = EmailService(settings, templates)
|
||||
session_service = SessionService(engine, signer, settings)
|
||||
auth_service = AuthService(
|
||||
engine, email_service, session_service, audit_service, settings
|
||||
)
|
||||
|
||||
application.state.audit_service = audit_service
|
||||
application.state.email_service = email_service
|
||||
application.state.session_service = session_service
|
||||
application.state.auth_service = auth_service
|
||||
|
||||
# --- Phase 5 wiring -----------------------------------------------------
|
||||
# Public contact form. HCaptchaService has no DB; ContactService
|
||||
# persists via the shared engine and delegates email to the
|
||||
# existing EmailService so the dev-fallback semantics match.
|
||||
hcaptcha_service = HCaptchaService(settings)
|
||||
contact_service = ContactService(
|
||||
engine=engine,
|
||||
email=email_service,
|
||||
audit=audit_service,
|
||||
settings=settings,
|
||||
)
|
||||
application.state.hcaptcha_service = hcaptcha_service
|
||||
application.state.contact_service = contact_service
|
||||
|
||||
# --- Phase 4 wiring -----------------------------------------------------
|
||||
# CSRF signer: separate salt so a session cookie never validates
|
||||
# as a CSRF token (domain separation via salt).
|
||||
csrf_signer = URLSafeTimedSerializer(settings.secret_key, salt="csrf")
|
||||
csrf_service = CSRFService(
|
||||
csrf_signer,
|
||||
production=(settings.app_env == "production"),
|
||||
)
|
||||
application.state.csrf_service = csrf_service
|
||||
|
||||
markdown_service = MarkdownService()
|
||||
application.state.markdown_service = markdown_service
|
||||
|
||||
admin_posts_service = AdminPostsService(
|
||||
engine=engine,
|
||||
markdown=markdown_service,
|
||||
post_service=application.state.post_service,
|
||||
page_service=application.state.page_service,
|
||||
audit=audit_service,
|
||||
)
|
||||
admin_pages_service = AdminPagesService(
|
||||
engine=engine,
|
||||
markdown=markdown_service,
|
||||
page_service=application.state.page_service,
|
||||
post_service=application.state.post_service,
|
||||
audit=audit_service,
|
||||
)
|
||||
media_service = MediaService(
|
||||
engine=engine,
|
||||
media_root=str(media_root),
|
||||
audit=audit_service,
|
||||
)
|
||||
|
||||
application.state.admin_posts_service = admin_posts_service
|
||||
application.state.admin_pages_service = admin_pages_service
|
||||
application.state.media_service = media_service
|
||||
|
||||
# --- Phase 6 middlewares -----------------------------------------------
|
||||
# Install order matters: FastAPI.add_middleware wraps each new entry
|
||||
# AROUND the existing stack, so the LAST one added runs first on the
|
||||
# request. We want:
|
||||
# - SecurityHeadersMiddleware innermost (stamps nonce on
|
||||
# request.state BEFORE the route runs, writes headers on the
|
||||
# response just before it exits the handler layer) -> added FIRST.
|
||||
# - AccessLogMiddleware outermost (times the entire stack,
|
||||
# including security-headers + CSRF cookie work) -> added LAST.
|
||||
application.add_middleware(
|
||||
SecurityHeadersMiddleware,
|
||||
production=(settings.app_env == "production"),
|
||||
)
|
||||
|
||||
# CSRF cookie middleware — narrow to admin GETs; everything else
|
||||
# passes through untouched so public routes are unaffected.
|
||||
application.add_middleware(CSRFCookieMiddleware, csrf_service=csrf_service)
|
||||
|
||||
# Access log — added last so it wraps (and thus times) every other
|
||||
# middleware. Skips /healthz to keep compose healthcheck noise out
|
||||
# of the log; redacts /admin/auth/consume/<token> paths.
|
||||
application.add_middleware(AccessLogMiddleware)
|
||||
|
||||
# SlowAPI limiter + exception handler. The limiter is a module-level
|
||||
# singleton in app.services.rate_limit (because @limiter.limit has
|
||||
# to be applied at endpoint-definition time, before include_router).
|
||||
# We still attach it to app.state so SlowAPI's request-path
|
||||
# middleware can reach it via request.app.state.limiter.
|
||||
limiter = create_limiter()
|
||||
application.state.limiter = limiter
|
||||
|
||||
application.add_exception_handler(
|
||||
RateLimitExceeded, _make_rate_limit_handler(templates, audit_service)
|
||||
)
|
||||
|
||||
# Register routers. Kept explicit (no dynamic discovery) so the set of
|
||||
# mounted endpoints is trivially auditable.
|
||||
application.include_router(health_router)
|
||||
application.include_router(public_router)
|
||||
application.include_router(admin_router)
|
||||
application.include_router(admin_cms_router)
|
||||
|
||||
# Single structured startup event. Do NOT include secret material.
|
||||
logger = structlog.get_logger(__name__)
|
||||
logger.info(
|
||||
"app_started",
|
||||
app_env=settings.app_env,
|
||||
version=__version__,
|
||||
commit_sha=settings.git_commit_sha,
|
||||
)
|
||||
|
||||
return application
|
||||
|
||||
|
||||
def _make_rate_limit_handler(
|
||||
templates: Jinja2Templates,
|
||||
audit_service: AuditService,
|
||||
):
|
||||
"""Build the FastAPI exception handler for ``RateLimitExceeded``.
|
||||
|
||||
Renders ``admin/rate_limited.html`` at HTTP 429 and writes a
|
||||
``rate_limited`` audit row scoped to the IP path. Email-scope
|
||||
rate-limits are handled inside AuthService and don't come through
|
||||
this handler.
|
||||
"""
|
||||
|
||||
async def _handler(request: Request, exc: RateLimitExceeded):
|
||||
# Best-effort endpoint path for the audit detail; the limiter
|
||||
# doesn't surface a structured endpoint name so we use the URL
|
||||
# path which is already stable / non-sensitive.
|
||||
endpoint = request.url.path
|
||||
ip = request.client.host if request.client else ""
|
||||
ua = request.headers.get("user-agent", "")
|
||||
audit_service.record(
|
||||
"rate_limited",
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
detail={"scope": "ip", "endpoint": endpoint},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/rate_limited.html",
|
||||
{},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
# Module-level ASGI handle. Uvicorn / gunicorn import this as
|
||||
# ``app.main:app``. Building it at import time is intentional: it fails
|
||||
# loudly at container start if configuration is invalid.
|
||||
app: FastAPI = create_app()
|
||||
26
app/middleware/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Application-level Starlette middlewares.
|
||||
|
||||
Each middleware is a :class:`starlette.middleware.base.BaseHTTPMiddleware`
|
||||
subclass wired up in :mod:`app.main`. They are kept in their own package
|
||||
(rather than buried in ``app/main.py``) because Phase 6 introduced two
|
||||
cross-cutting middlewares — security headers and access logging — that
|
||||
benefit from isolated unit tests and clear homes for future additions
|
||||
(rate-limit re-shaping, request-id propagation, etc.).
|
||||
|
||||
Public surface:
|
||||
|
||||
- :class:`SecurityHeadersMiddleware` — per-request CSP nonce + strict
|
||||
response headers. See :mod:`app.middleware.security_headers`.
|
||||
- :class:`AccessLogMiddleware` — structured ``http_request`` log line
|
||||
after every response. See :mod:`app.middleware.access_log`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.middleware.access_log import AccessLogMiddleware
|
||||
from app.middleware.security_headers import SecurityHeadersMiddleware
|
||||
|
||||
__all__ = [
|
||||
"AccessLogMiddleware",
|
||||
"SecurityHeadersMiddleware",
|
||||
]
|
||||
113
app/middleware/access_log.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Structured access-log middleware.
|
||||
|
||||
Emits a single ``http_request`` INFO event per request, capturing the
|
||||
HTTP verb, path, status code, wall-clock duration, client IP, and a
|
||||
truncated user-agent. The goal is a compact but auditable trail of
|
||||
production traffic without the noise of a traditional access log and
|
||||
without ever writing secrets to disk.
|
||||
|
||||
Key design choices:
|
||||
|
||||
- **No bodies, no query strings.** We deliberately skip query strings
|
||||
entirely to avoid leaking tokens that might end up there in future
|
||||
(the magic-link consume route keeps its token in the path, so we
|
||||
redact that explicitly below).
|
||||
- **Magic-link path redaction.** Requests to
|
||||
``/admin/auth/consume/{token}`` have the token segment replaced with
|
||||
``<redacted>`` before logging. ``CLAUDE.md`` forbids logging raw
|
||||
tokens anywhere.
|
||||
- **Skip ``/healthz``.** Compose / Docker health probes hit this every
|
||||
few seconds. Logging each one drowns out real traffic.
|
||||
- **Exceptions still get logged.** If the downstream handler raises,
|
||||
we record a ``status_code=500`` entry before re-raising so no failed
|
||||
request vanishes silently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import structlog
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Prefix used to recognise magic-link consume URLs whose trailing token
|
||||
# segment must be redacted before logging.
|
||||
_CONSUME_PREFIX: str = "/admin/auth/consume/"
|
||||
|
||||
# Path we skip entirely to reduce health-probe log noise.
|
||||
_SKIP_PATH: str = "/healthz"
|
||||
|
||||
# User-agent strings are unbounded; cap to 256 chars so a hostile client
|
||||
# can't bloat log lines to arbitrary size.
|
||||
_UA_MAX: int = 256
|
||||
|
||||
|
||||
def _redact_path(path: str) -> str:
|
||||
"""Return ``path`` with magic-link tokens replaced by ``<redacted>``.
|
||||
|
||||
The consume URL is ``/admin/auth/consume/{token}``; everything after
|
||||
the prefix is swapped out. We preserve the prefix so log readers can
|
||||
still see which route was hit.
|
||||
"""
|
||||
if path.startswith(_CONSUME_PREFIX):
|
||||
return _CONSUME_PREFIX + "<redacted>"
|
||||
return path
|
||||
|
||||
|
||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
"""Log one ``http_request`` event per completed request.
|
||||
|
||||
Installed outermost in :mod:`app.main` so the timing measurement
|
||||
covers the entire downstream middleware stack, including security
|
||||
headers and CSRF cookie work.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Time the request, log a structured event, reraise on failure."""
|
||||
path: str = request.url.path
|
||||
|
||||
# Early exit for health probes — don't even record timing.
|
||||
if path == _SKIP_PATH:
|
||||
return await call_next(request)
|
||||
|
||||
method: str = request.method
|
||||
client_ip: str = request.client.host if request.client else ""
|
||||
user_agent: str = request.headers.get("user-agent", "")[:_UA_MAX]
|
||||
redacted_path: str = _redact_path(path)
|
||||
|
||||
start: float = time.monotonic()
|
||||
try:
|
||||
response: Response = await call_next(request)
|
||||
except Exception:
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
# Record the failure before re-raising so unhandled exceptions
|
||||
# don't vanish from the log. Status is synthetic (500) because
|
||||
# the framework hasn't written a response yet at this point.
|
||||
logger.info(
|
||||
"http_request",
|
||||
method=method,
|
||||
path=redacted_path,
|
||||
status_code=500,
|
||||
duration_ms=duration_ms,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
raise
|
||||
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
logger.info(
|
||||
"http_request",
|
||||
method=method,
|
||||
path=redacted_path,
|
||||
status_code=response.status_code,
|
||||
duration_ms=duration_ms,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
return response
|
||||
122
app/middleware/security_headers.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Security-headers middleware.
|
||||
|
||||
Installs a strict-ish set of security response headers on every outgoing
|
||||
response — notably a nonce-based ``Content-Security-Policy`` that locks
|
||||
inline ``<script>`` usage to per-request tokens. Templates read the
|
||||
nonce from ``request.state.csp_nonce`` and stamp it onto whichever
|
||||
``<script>`` blocks need to run.
|
||||
|
||||
Modelled on :class:`app.main.CSRFCookieMiddleware`: the constructor
|
||||
takes the ASGI app plus any configuration it needs by keyword;
|
||||
``dispatch`` is async and always returns the downstream response.
|
||||
|
||||
Header set (matches ``docs/security.md`` + Phase 6 brief):
|
||||
|
||||
- ``Content-Security-Policy`` — nonce-based ``script-src`` that also
|
||||
allowlists hCaptcha; ``frame-ancestors 'none'`` replaces the legacy
|
||||
``X-Frame-Options: DENY``.
|
||||
- ``Strict-Transport-Security`` — **only in production**; the dev
|
||||
server is reached over plain HTTP at ``http://127.0.0.1:8000`` and
|
||||
HSTS would make that session permanently HTTPS-only for the browser.
|
||||
- ``X-Content-Type-Options: nosniff``
|
||||
- ``Referrer-Policy: strict-origin-when-cross-origin``
|
||||
- ``Permissions-Policy`` — disable every sensor/device API we do not
|
||||
use (defense in depth for any future supply-chain compromise).
|
||||
- ``Cross-Origin-Opener-Policy: same-origin``
|
||||
|
||||
The nonce is generated fresh per request (``secrets.token_urlsafe(16)``
|
||||
→ 128 bits, URL-safe) and stored on ``request.state.csp_nonce`` before
|
||||
the downstream handler runs, so Jinja templates can read it via the
|
||||
implicit ``request`` context variable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
# --- CSP template ---------------------------------------------------------
|
||||
# Note the ``{nonce}`` placeholder: we format per-request so the token is
|
||||
# unique to each response. ``style-src 'unsafe-inline'`` is a known
|
||||
# compromise — we don't emit our own inline styles, but third-party
|
||||
# widgets (hCaptcha) and some HTML attribute defaults want it. Locking
|
||||
# this down is a future task.
|
||||
_CSP_TEMPLATE: str = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'nonce-{nonce}' 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'"
|
||||
)
|
||||
|
||||
|
||||
# Static response headers that do not depend on per-request state. Kept
|
||||
# as a module-level dict so we pay the allocation cost once at import
|
||||
# time and just iterate on every response.
|
||||
_STATIC_HEADERS: dict[str, str] = {
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
# HSTS is production-only. One year, subdomains included; no ``preload``
|
||||
# directive (that requires submitting the apex to the HSTS preload list,
|
||||
# which is a separate operational step).
|
||||
_HSTS_VALUE: str = "max-age=31536000; includeSubDomains"
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Attach CSP + friends to every response.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
app:
|
||||
The ASGI application. ``BaseHTTPMiddleware`` stores this.
|
||||
production:
|
||||
When ``True`` the middleware also emits ``Strict-Transport-Security``.
|
||||
Passed explicitly (rather than read from :mod:`app.config` here)
|
||||
so the middleware remains unit-testable without loading settings.
|
||||
"""
|
||||
|
||||
def __init__(self, app, *, production: bool) -> None:
|
||||
"""Store the production flag; the app handle is owned by the base class."""
|
||||
super().__init__(app)
|
||||
self._production: bool = production
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Mint a nonce, run the downstream handler, stamp the headers.
|
||||
|
||||
The nonce is attached to ``request.state`` *before* ``call_next``
|
||||
so any template rendered by the route handler can read it. The
|
||||
CSP header itself is added after the response is produced so it
|
||||
rides on every path (HTML, JSON, static bypass, error pages).
|
||||
"""
|
||||
# 128 bits of entropy, URL-safe base64 — plenty for CSP nonce use.
|
||||
nonce: str = secrets.token_urlsafe(16)
|
||||
request.state.csp_nonce = nonce
|
||||
|
||||
response: Response = await call_next(request)
|
||||
|
||||
response.headers["Content-Security-Policy"] = _CSP_TEMPLATE.format(
|
||||
nonce=nonce
|
||||
)
|
||||
for key, value in _STATIC_HEADERS.items():
|
||||
response.headers[key] = value
|
||||
if self._production:
|
||||
response.headers["Strict-Transport-Security"] = _HSTS_VALUE
|
||||
|
||||
return response
|
||||
7
app/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Domain models and persistence mappers.
|
||||
|
||||
Populated in Phase 2 with dataclasses + SQL schema per
|
||||
``docs/ROADMAP.md``. Intentionally empty in Phase 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
195
app/models/entities.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Canonical persistence-layer dataclasses.
|
||||
|
||||
One dataclass per table in the authoritative SQLite schema documented in
|
||||
``docs/ROADMAP.md`` ("SQLite Schema (authoritative)"). These map 1:1 to
|
||||
the columns of each table — field names, types, and nullability all
|
||||
match — so the mapper layer (:mod:`app.models.mappers`) can convert
|
||||
``sqlalchemy.Row`` objects to dataclass instances with no guesswork.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- Dataclasses are *not* frozen. Later phases mutate fields such as
|
||||
``User.last_login_at`` or ``MagicLinkToken.used_at`` on successful
|
||||
auth events; freezing would force service code into hand-rolled
|
||||
copying. Immutability for view-layer projections is still enforced
|
||||
via ``PostSummary`` in :mod:`app.models.posts`.
|
||||
- Datetimes are always timezone-aware UTC at the Python boundary. The
|
||||
SQLite columns are ``TEXT`` holding ISO-8601 strings; conversion
|
||||
happens only in :mod:`app.models.mappers`, so application code never
|
||||
sees a naive datetime.
|
||||
- ``PostStatus`` is a string-valued ``Enum`` to keep JSON/template
|
||||
rendering trivial while still providing type-level safety.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class PostStatus(str, Enum):
|
||||
"""Publication lifecycle for a blog post.
|
||||
|
||||
The string values match the ``CHECK`` constraint on
|
||||
``posts.status`` in the SQLite schema; adding a new value here
|
||||
would require a migration, so this enum is deliberately small.
|
||||
"""
|
||||
|
||||
DRAFT = "draft"
|
||||
PUBLISHED = "published"
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Admin user row.
|
||||
|
||||
Phase 2 seeds a single inactive system user (``id=1``) so the
|
||||
``posts.author_user_id`` foreign key has something to reference;
|
||||
real admin users are provisioned in Phase 3's magic-link flow.
|
||||
"""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
display_name: str
|
||||
created_at: datetime
|
||||
last_login_at: Optional[datetime]
|
||||
active: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class MagicLinkToken:
|
||||
"""Single-use email-login token.
|
||||
|
||||
``token_hash`` stores the SHA-256 of the raw token; the raw token
|
||||
is emailed to the user and never persisted. ``used_at`` is set
|
||||
when the token is consumed so we can refuse replay attempts
|
||||
without deleting the audit row.
|
||||
"""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
token_hash: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
used_at: Optional[datetime]
|
||||
request_ip: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""Authenticated admin session.
|
||||
|
||||
``revoked_at`` records logouts without deleting the row, so the
|
||||
audit log remains complete. ``ip`` / ``user_agent`` are snapshots
|
||||
from session creation, not live values.
|
||||
"""
|
||||
|
||||
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:
|
||||
"""Static-ish content page (e.g. About).
|
||||
|
||||
``body_html_cached`` is regenerated on write by the Phase 4 admin
|
||||
flow via the Markdown pipeline and stored here so render time
|
||||
costs only a SELECT, not a sanitize. See "Caching Strategy" in
|
||||
``docs/ROADMAP.md``.
|
||||
"""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
body_md: str
|
||||
body_html_cached: str
|
||||
updated_at: datetime
|
||||
published: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class Post:
|
||||
"""Blog post row.
|
||||
|
||||
Mirrors the ``posts`` table exactly. ``body_html_cached`` follows
|
||||
the same regenerate-on-write convention as :class:`Page`.
|
||||
"""
|
||||
|
||||
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:
|
||||
"""Uploaded image metadata.
|
||||
|
||||
``filename`` is the random storage name assigned on upload; the
|
||||
original client-supplied filename is preserved for display only
|
||||
and NEVER used to build a filesystem path. ``stored_path`` is
|
||||
relative to the project root.
|
||||
"""
|
||||
|
||||
id: int
|
||||
filename: str
|
||||
original_filename: str
|
||||
content_type: str
|
||||
size_bytes: int
|
||||
stored_path: str
|
||||
alt_text: str
|
||||
uploaded_by: int
|
||||
uploaded_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContactSubmission:
|
||||
"""Submission from the public ``/contact`` form.
|
||||
|
||||
``handled`` flips true once Head Hen has actioned the submission;
|
||||
retained indefinitely as part of the contact audit log. No
|
||||
sensitive fields — by design we only capture what the form asks
|
||||
for.
|
||||
"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
message: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
submitted_at: datetime
|
||||
handled: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthEvent:
|
||||
"""Append-only audit record for auth-related events.
|
||||
|
||||
``event_type`` values are one of ``link_requested``,
|
||||
``link_consumed``, ``session_revoked``, ``rate_limited`` (see
|
||||
Phase 3). ``detail`` is a JSON string so we can attach
|
||||
event-specific context without schema churn.
|
||||
"""
|
||||
|
||||
id: int
|
||||
event_type: str
|
||||
email: Optional[str]
|
||||
user_id: Optional[int]
|
||||
ip: str
|
||||
user_agent: str
|
||||
created_at: datetime
|
||||
detail: str
|
||||
190
app/models/mappers.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""SQL row to dataclass converters.
|
||||
|
||||
One ``row_to_<entity>`` function per table. All functions accept a
|
||||
mapping-like object (``sqlalchemy.Row``, :class:`sqlite3.Row`, or plain
|
||||
``dict``) and return the corresponding dataclass from
|
||||
:mod:`app.models.entities`.
|
||||
|
||||
Boundary responsibilities handled here (so service code never has to):
|
||||
|
||||
- Parse ISO-8601 ``TEXT`` columns into timezone-aware :class:`datetime`
|
||||
instances (always UTC).
|
||||
- Coerce SQLite ``INTEGER`` booleans (``0`` / ``1``) into real ``bool``.
|
||||
- Translate ``posts.status`` strings into :class:`PostStatus` members.
|
||||
|
||||
Anything that isn't safe to assume (e.g. that ``published_at`` might be
|
||||
NULL) is handled explicitly via :func:`_parse_optional_datetime`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from app.models.entities import (
|
||||
AuthEvent,
|
||||
ContactSubmission,
|
||||
MagicLinkToken,
|
||||
Media,
|
||||
Page,
|
||||
Post,
|
||||
PostStatus,
|
||||
Session,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
def _parse_datetime(value: str) -> datetime:
|
||||
"""Parse a stored ISO-8601 string into a timezone-aware UTC datetime.
|
||||
|
||||
All write paths use :func:`datetime.now` with ``tz=timezone.utc``
|
||||
and serialize via ``.isoformat()``, so the stored strings always
|
||||
include an offset. We still call ``astimezone(timezone.utc)`` to
|
||||
normalize anything that sneaks through with a different offset —
|
||||
an inexpensive belt-and-braces guard.
|
||||
"""
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
# Defensive: legacy rows (none exist yet) or a bad write path.
|
||||
# Treat as UTC rather than raising; we never intentionally
|
||||
# persist naive datetimes.
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _parse_optional_datetime(value: Optional[str]) -> Optional[datetime]:
|
||||
"""Return ``None`` for NULL rows; otherwise parse as UTC.
|
||||
|
||||
Thin wrapper around :func:`_parse_datetime` kept for readability at
|
||||
call sites that deal with nullable columns.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
return _parse_datetime(value)
|
||||
|
||||
|
||||
def _as_bool(value: Any) -> bool:
|
||||
"""Coerce a SQLite INTEGER column into a Python ``bool``.
|
||||
|
||||
SQLite stores booleans as ``0`` / ``1`` integers. ``bool(0) is
|
||||
False`` and ``bool(1) is True`` both behave correctly; this
|
||||
wrapper exists so the intent is explicit at the mapper boundary
|
||||
rather than relying on implicit truthiness.
|
||||
"""
|
||||
return bool(value)
|
||||
|
||||
|
||||
def row_to_user(row: Mapping[str, Any]) -> User:
|
||||
"""Map a ``users`` row to :class:`User`."""
|
||||
return User(
|
||||
id=int(row["id"]),
|
||||
email=row["email"],
|
||||
display_name=row["display_name"],
|
||||
created_at=_parse_datetime(row["created_at"]),
|
||||
last_login_at=_parse_optional_datetime(row["last_login_at"]),
|
||||
active=_as_bool(row["active"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_magic_link_token(row: Mapping[str, Any]) -> MagicLinkToken:
|
||||
"""Map a ``magic_link_tokens`` row to :class:`MagicLinkToken`."""
|
||||
return MagicLinkToken(
|
||||
id=int(row["id"]),
|
||||
email=row["email"],
|
||||
token_hash=row["token_hash"],
|
||||
created_at=_parse_datetime(row["created_at"]),
|
||||
expires_at=_parse_datetime(row["expires_at"]),
|
||||
used_at=_parse_optional_datetime(row["used_at"]),
|
||||
request_ip=row["request_ip"],
|
||||
)
|
||||
|
||||
|
||||
def row_to_session(row: Mapping[str, Any]) -> Session:
|
||||
"""Map a ``sessions`` row to :class:`Session`."""
|
||||
return Session(
|
||||
id=int(row["id"]),
|
||||
user_id=int(row["user_id"]),
|
||||
token_hash=row["token_hash"],
|
||||
created_at=_parse_datetime(row["created_at"]),
|
||||
expires_at=_parse_datetime(row["expires_at"]),
|
||||
ip=row["ip"],
|
||||
user_agent=row["user_agent"],
|
||||
revoked_at=_parse_optional_datetime(row["revoked_at"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_page(row: Mapping[str, Any]) -> Page:
|
||||
"""Map a ``pages`` row to :class:`Page`."""
|
||||
return Page(
|
||||
id=int(row["id"]),
|
||||
slug=row["slug"],
|
||||
title=row["title"],
|
||||
body_md=row["body_md"],
|
||||
body_html_cached=row["body_html_cached"],
|
||||
updated_at=_parse_datetime(row["updated_at"]),
|
||||
published=_as_bool(row["published"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_post(row: Mapping[str, Any]) -> Post:
|
||||
"""Map a ``posts`` row to :class:`Post`.
|
||||
|
||||
``status`` goes through the :class:`PostStatus` constructor which
|
||||
enforces the same set the ``CHECK`` constraint does; a value that
|
||||
somehow bypassed the constraint would raise ``ValueError`` here
|
||||
rather than silently flowing into business logic.
|
||||
"""
|
||||
return Post(
|
||||
id=int(row["id"]),
|
||||
slug=row["slug"],
|
||||
title=row["title"],
|
||||
body_md=row["body_md"],
|
||||
body_html_cached=row["body_html_cached"],
|
||||
status=PostStatus(row["status"]),
|
||||
published_at=_parse_optional_datetime(row["published_at"]),
|
||||
updated_at=_parse_datetime(row["updated_at"]),
|
||||
author_user_id=int(row["author_user_id"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_media(row: Mapping[str, Any]) -> Media:
|
||||
"""Map a ``media`` row to :class:`Media`."""
|
||||
return Media(
|
||||
id=int(row["id"]),
|
||||
filename=row["filename"],
|
||||
original_filename=row["original_filename"],
|
||||
content_type=row["content_type"],
|
||||
size_bytes=int(row["size_bytes"]),
|
||||
stored_path=row["stored_path"],
|
||||
alt_text=row["alt_text"],
|
||||
uploaded_by=int(row["uploaded_by"]),
|
||||
uploaded_at=_parse_datetime(row["uploaded_at"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_contact_submission(row: Mapping[str, Any]) -> ContactSubmission:
|
||||
"""Map a ``contact_submissions`` row to :class:`ContactSubmission`."""
|
||||
return ContactSubmission(
|
||||
id=int(row["id"]),
|
||||
name=row["name"],
|
||||
email=row["email"],
|
||||
message=row["message"],
|
||||
ip=row["ip"],
|
||||
user_agent=row["user_agent"],
|
||||
submitted_at=_parse_datetime(row["submitted_at"]),
|
||||
handled=_as_bool(row["handled"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_auth_event(row: Mapping[str, Any]) -> AuthEvent:
|
||||
"""Map an ``auth_events`` row to :class:`AuthEvent`."""
|
||||
return AuthEvent(
|
||||
id=int(row["id"]),
|
||||
event_type=row["event_type"],
|
||||
email=row["email"],
|
||||
user_id=int(row["user_id"]) if row["user_id"] is not None else None,
|
||||
ip=row["ip"],
|
||||
user_agent=row["user_agent"],
|
||||
created_at=_parse_datetime(row["created_at"]),
|
||||
detail=row["detail"],
|
||||
)
|
||||
108
app/models/migrations/001_init.sql
Normal file
@@ -0,0 +1,108 @@
|
||||
-- 001_init.sql
|
||||
--
|
||||
-- Initial schema for Chicken Babies R Us. Authoritative copy of the
|
||||
-- tables + indexes + check constraints documented in
|
||||
-- ``docs/ROADMAP.md`` (see "SQLite Schema (authoritative)").
|
||||
--
|
||||
-- Idempotency: every statement uses IF NOT EXISTS so re-running the
|
||||
-- file on a partially-migrated database is still safe. The migration
|
||||
-- runner also gates execution via the schema_migrations tracker, so
|
||||
-- this belt-and-braces approach is defensive only.
|
||||
--
|
||||
-- No PRAGMA statements here: journal_mode = WAL and foreign_keys = ON
|
||||
-- are applied per-connection via the SQLAlchemy connect-event
|
||||
-- listener in ``app/db.py``. Setting them inside a migration file
|
||||
-- would be a no-op on every connection except the one that ran the
|
||||
-- migration, which is the opposite of what we want.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS idx_magic_email_created
|
||||
ON magic_link_tokens(email, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS idx_posts_status_pub
|
||||
ON posts(status, published_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS idx_auth_events_created
|
||||
ON auth_events(created_at DESC);
|
||||
12
app/models/migrations/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""SQL migration files applied by :mod:`app.db` at startup.
|
||||
|
||||
This package holds the authoritative schema history for the
|
||||
``chicken_babies_site`` database. Each ``.sql`` file is applied exactly
|
||||
once in lexicographic order; the runner tracks which files have been
|
||||
applied in a ``schema_migrations`` table.
|
||||
|
||||
No Python code lives here — the files are trusted, developer-authored
|
||||
SQL loaded via ``sqlite3.Connection.executescript`` at boot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
45
app/models/posts.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Blog post domain models.
|
||||
|
||||
Phase 1 only needs the *list-view* projection of a post — a minimal
|
||||
immutable record sufficient to render a blog card on the home page.
|
||||
Phase 2 will introduce the richer persisted :class:`Post` dataclass that
|
||||
mirrors the SQLite schema; :class:`PostSummary` intentionally stays as a
|
||||
narrower DTO even after the DB arrives because list endpoints shouldn't
|
||||
pay the cost of loading full post bodies.
|
||||
|
||||
The dataclass is frozen: summaries flow one-way from the service layer
|
||||
into templates and must never mutate mid-request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PostSummary:
|
||||
"""Immutable summary row for the blog index.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
slug:
|
||||
URL-safe identifier used to build the post's canonical URL.
|
||||
title:
|
||||
Human-readable headline shown on the card.
|
||||
published_at:
|
||||
Timezone-aware UTC publish timestamp. Templates format this for
|
||||
display; storing a real :class:`datetime` (rather than a
|
||||
pre-formatted string) keeps locale/formatting concerns in the
|
||||
view layer.
|
||||
excerpt:
|
||||
Short plaintext teaser. The service layer is responsible for
|
||||
producing a sanitized, already-truncated excerpt so the template
|
||||
can render it without additional escaping beyond Jinja's default
|
||||
HTML autoescape.
|
||||
"""
|
||||
|
||||
slug: str
|
||||
title: str
|
||||
published_at: datetime
|
||||
excerpt: str
|
||||
204
app/models/seed.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Idempotent seed data for first-run databases.
|
||||
|
||||
Creates the minimum content needed so the public site is not blank
|
||||
before an admin exists:
|
||||
|
||||
- System seed user (``users.id = 1``). Inactive and not on the
|
||||
``ADMIN_EMAILS`` allowlist — cannot log in. Exists only so
|
||||
``posts.author_user_id`` has a foreign-key target.
|
||||
- Welcome blog post (``slug = 'welcome-to-the-farm'``).
|
||||
- About page (``slug = 'about'``) ported from the Phase 1 static copy.
|
||||
|
||||
Idempotency is enforced two ways:
|
||||
|
||||
1. A marker row in ``schema_migrations`` (``version = 'seed_001'``)
|
||||
— if present, the whole seed is a no-op.
|
||||
2. As a belt-and-braces guard, each INSERT is gated by ``INSERT OR
|
||||
IGNORE`` on a unique key (``users.email``, ``posts.slug``,
|
||||
``pages.slug``) so a partially-applied seed never duplicates.
|
||||
|
||||
Running this twice is safe and logs ``seed_skipped`` on the second
|
||||
boot, which the Phase 2 verification run depends on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.services.markdown import MarkdownService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
# Marker row used to short-circuit the seed on subsequent boots.
|
||||
# Namespaced with the ``seed_`` prefix so it cannot collide with a
|
||||
# real migration file name.
|
||||
_SEED_MARKER: str = "seed_001"
|
||||
|
||||
|
||||
# --- Content --------------------------------------------------------------
|
||||
#
|
||||
# The About body is a Markdown translation of the Phase 1
|
||||
# ``app/templates/public/about.html`` narrative. Kept close to the
|
||||
# original wording so returning visitors see familiar copy; Head Hen
|
||||
# rewrites via the Phase 4 admin.
|
||||
#
|
||||
# The welcome post is three short paragraphs: a greeting, a Morrison,
|
||||
# TN mention (no street address — per CLAUDE.md), and a teaser of what
|
||||
# future updates will cover.
|
||||
_WELCOME_POST_TITLE: str = "Welcome to the Farm"
|
||||
_WELCOME_POST_SLUG: str = "welcome-to-the-farm"
|
||||
_WELCOME_POST_MD: str = (
|
||||
"Hi there, and thanks for stopping by Chicken Babies R Us! "
|
||||
"We're a small family farm and we're glad you found us.\n\n"
|
||||
"We're based in Morrison, Tennessee, tucked into the rolling "
|
||||
"hills of the middle part of the state. Our flock is growing, "
|
||||
"our waterfowl are loud, and our coffee cups are never quite "
|
||||
"empty.\n\n"
|
||||
"Check back soon for updates on hatching plans, new chicks and "
|
||||
"ducklings, fresh-egg availability, and whatever the geese "
|
||||
"decided to get into this week."
|
||||
)
|
||||
|
||||
_ABOUT_PAGE_TITLE: str = "About the Farm"
|
||||
_ABOUT_PAGE_SLUG: str = "about"
|
||||
_ABOUT_PAGE_MD: str = (
|
||||
"Chicken Babies R Us is a small family farm tucked into the "
|
||||
"rolling hills of Morrison, Tennessee. What started as a "
|
||||
"handful of chicks in a backyard brooder has grown into a flock "
|
||||
"of chickens, ducks, and geese that keep us busy (and "
|
||||
"entertained) year round.\n\n"
|
||||
"The operation is run by Head Hen — the chief wrangler, egg "
|
||||
"gatherer, waterfowl-whisperer, and unofficial chicken "
|
||||
"photographer. She handles the day-to-day care of the birds "
|
||||
"and does most of the writing you'll find on this site. Expect "
|
||||
"updates on hatching plans, new arrivals, the occasional coop "
|
||||
"mishap, and whatever the geese decided to get into this "
|
||||
"week.\n\n"
|
||||
"We're a hobby farm at heart, not a commercial one, which "
|
||||
"means we can take the time to know our birds and raise them "
|
||||
"the way we think they ought to be raised. If you're curious "
|
||||
"about what we've got going on — or just want to say hello — "
|
||||
"pop over to the contact page."
|
||||
)
|
||||
|
||||
# Seed user constants. ``active=0`` + the local-only email keep this
|
||||
# user out of any real auth flow. Phase 3's magic-link issuer MUST
|
||||
# refuse to issue links for non-allowlisted or inactive emails;
|
||||
# Phase 3 tests assert that behavior directly.
|
||||
_SEED_USER_ID: int = 1
|
||||
_SEED_USER_EMAIL: str = "seed@chickenbabies.local"
|
||||
_SEED_USER_DISPLAY: str = "Head Hen"
|
||||
|
||||
|
||||
def run_seed(engine: Engine) -> bool:
|
||||
"""Populate the database with first-run content, if not already done.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
SQLAlchemy engine. Must already have had migrations applied
|
||||
(this function does not create tables).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` when seed rows were inserted on this call, ``False``
|
||||
when the marker was already present (no-op). Useful for
|
||||
verification scripts and tests that need to assert
|
||||
``seed_skipped`` on second boot.
|
||||
"""
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
markdown = MarkdownService()
|
||||
|
||||
with engine.connect() as conn:
|
||||
# Short-circuit via the migration-tracker marker. Cheaper than
|
||||
# counting rows and survives the edge case of a manually
|
||||
# wiped posts/pages table that we wouldn't want to reseed
|
||||
# automatically.
|
||||
marker_row = conn.execute(
|
||||
text(
|
||||
"SELECT version FROM schema_migrations WHERE version = :v"
|
||||
),
|
||||
{"v": _SEED_MARKER},
|
||||
).first()
|
||||
if marker_row is not None:
|
||||
_log.info("seed_skipped", marker=_SEED_MARKER)
|
||||
return False
|
||||
|
||||
# --- Seed user ------------------------------------------------
|
||||
# The explicit id=1 pin keeps the ``posts.author_user_id``
|
||||
# foreign key stable even if a future migration renumbers.
|
||||
# Inline comment below repeats the intent for anyone reading
|
||||
# the DB directly.
|
||||
conn.execute(
|
||||
text(
|
||||
# seed artifact; not a real admin — see Phase 3 for real users
|
||||
"INSERT OR IGNORE INTO users"
|
||||
" (id, email, display_name, created_at, last_login_at, active)"
|
||||
" VALUES (:id, :email, :display_name, :created_at, NULL, 0)"
|
||||
),
|
||||
{
|
||||
"id": _SEED_USER_ID,
|
||||
"email": _SEED_USER_EMAIL,
|
||||
"display_name": _SEED_USER_DISPLAY,
|
||||
"created_at": now_iso,
|
||||
},
|
||||
)
|
||||
|
||||
# --- Welcome post --------------------------------------------
|
||||
welcome_html = markdown.render(_WELCOME_POST_MD)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT OR IGNORE INTO posts"
|
||||
" (slug, title, body_md, body_html_cached, status,"
|
||||
" published_at, updated_at, author_user_id)"
|
||||
" VALUES (:slug, :title, :body_md, :body_html,"
|
||||
" 'published', :published_at, :updated_at, :author_id)"
|
||||
),
|
||||
{
|
||||
"slug": _WELCOME_POST_SLUG,
|
||||
"title": _WELCOME_POST_TITLE,
|
||||
"body_md": _WELCOME_POST_MD,
|
||||
"body_html": welcome_html,
|
||||
"published_at": now_iso,
|
||||
"updated_at": now_iso,
|
||||
"author_id": _SEED_USER_ID,
|
||||
},
|
||||
)
|
||||
|
||||
# --- About page ----------------------------------------------
|
||||
about_html = markdown.render(_ABOUT_PAGE_MD)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT OR IGNORE INTO pages"
|
||||
" (slug, title, body_md, body_html_cached, updated_at,"
|
||||
" published)"
|
||||
" VALUES (:slug, :title, :body_md, :body_html,"
|
||||
" :updated_at, 1)"
|
||||
),
|
||||
{
|
||||
"slug": _ABOUT_PAGE_SLUG,
|
||||
"title": _ABOUT_PAGE_TITLE,
|
||||
"body_md": _ABOUT_PAGE_MD,
|
||||
"body_html": about_html,
|
||||
"updated_at": now_iso,
|
||||
},
|
||||
)
|
||||
|
||||
# --- Marker ---------------------------------------------------
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO schema_migrations (version, applied_at)"
|
||||
" VALUES (:v, :t)"
|
||||
),
|
||||
{"v": _SEED_MARKER, "t": now_iso},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
_log.info("seed_applied", marker=_SEED_MARKER)
|
||||
return True
|
||||
8
app/routes/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""HTTP route packages.
|
||||
|
||||
Routers live as sibling modules and are wired into the app in
|
||||
:mod:`app.main`. Phase 0 only exposes ``health``; public, admin, and auth
|
||||
routers arrive in later phases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
259
app/routes/admin.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Admin auth routes — magic-link login, consume, landing, logout.
|
||||
|
||||
Every handler here is deliberately thin: it delegates to
|
||||
:class:`app.services.auth.AuthService` or
|
||||
:class:`app.services.sessions.SessionService` and translates the
|
||||
result into an HTTP response. No SQL, no crypto, no email building.
|
||||
|
||||
Anti-enumeration contract
|
||||
-------------------------
|
||||
POST /admin/login always renders the same ``login_sent.html`` template
|
||||
with identical copy, identical status, and identical cookie state,
|
||||
regardless of whether the submitted address is on the allowlist. The
|
||||
only side-effects that differ are:
|
||||
|
||||
- token row inserted (allowlisted)
|
||||
- email dispatched (allowlisted)
|
||||
- audit row has ``allowlisted=true`` vs ``false``
|
||||
|
||||
GET /admin/auth/consume/{token} likewise uses a single failure page
|
||||
for every bad-token reason (missing / unknown / expired / already
|
||||
used). The specific reason lives only in the audit log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.dependencies.auth import require_admin
|
||||
from app.dependencies.csrf import require_csrf_form
|
||||
from app.models.entities import User
|
||||
from app.services.auth import AuthService, RateLimitedError
|
||||
from app.services.rate_limit import limiter
|
||||
from app.services.sessions import COOKIE_NAME, SessionService
|
||||
|
||||
|
||||
router: APIRouter = APIRouter(tags=["admin"])
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Lightweight email regex. We intentionally avoid ``email-validator``
|
||||
# (not pinned in requirements.txt) and a full RFC-5322 parser; the goal
|
||||
# is only to reject empty strings and obviously-not-an-email submissions
|
||||
# before calling the allowlist. Full validation is unnecessary because
|
||||
# non-allowlisted addresses are silently ignored anyway.
|
||||
_EMAIL_RE: re.Pattern[str] = re.compile(
|
||||
r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
|
||||
)
|
||||
|
||||
|
||||
def _get_templates(request: Request) -> Jinja2Templates:
|
||||
"""Return the app-scoped :class:`Jinja2Templates`."""
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
def _get_auth_service(request: Request) -> AuthService:
|
||||
"""Return the app-scoped :class:`AuthService`."""
|
||||
return request.app.state.auth_service
|
||||
|
||||
|
||||
def _get_session_service(request: Request) -> SessionService:
|
||||
"""Return the app-scoped :class:`SessionService`."""
|
||||
return request.app.state.session_service
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Best-effort client IP from the request.
|
||||
|
||||
Starlette already respects ``X-Forwarded-For`` via the proxy-headers
|
||||
middleware Uvicorn installs with ``--proxy-headers``; that means
|
||||
``request.client.host`` is the real client IP.
|
||||
"""
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
def _user_agent(request: Request) -> str:
|
||||
"""Return the submitted User-Agent header (empty string if missing)."""
|
||||
return request.headers.get("user-agent", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/login
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/admin/login", response_class=HTMLResponse, summary="Admin login form")
|
||||
def admin_login_form(
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
) -> HTMLResponse:
|
||||
"""Render the email-entry form.
|
||||
|
||||
The login form is deliberately NOT CSRF-protected: the user is
|
||||
pre-authentication and no session cookie exists yet, so there is
|
||||
no authenticated context for a forged request to hijack. The
|
||||
``ADMIN_EMAILS`` allowlist and SlowAPI rate limit are what keep
|
||||
this endpoint safe.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/login.html",
|
||||
{"error": None, "email": ""},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/login
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/login", response_class=HTMLResponse, summary="Request magic link")
|
||||
@limiter.limit("5/15 minutes")
|
||||
def admin_login_submit(
|
||||
request: Request,
|
||||
email: str = Form(default=""),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
auth: AuthService = Depends(_get_auth_service),
|
||||
) -> Response:
|
||||
"""Handle the login form submission.
|
||||
|
||||
Flow:
|
||||
1. Normalize + validate the email format. On format error we
|
||||
re-render ``login.html`` with a message (this is a UX concession
|
||||
— an invalid email shape is not a successful submission, so
|
||||
there's no enumeration risk).
|
||||
2. Call :meth:`AuthService.request_link`.
|
||||
3. Regardless of allowlist membership: render ``login_sent.html``
|
||||
with identical copy. On per-email rate-limit: render
|
||||
``rate_limited.html`` + 429.
|
||||
|
||||
Rate limiting
|
||||
-------------
|
||||
The ``@limiter.limit`` decoration is applied dynamically from
|
||||
``app.main`` so tests can bypass it — see the route registration
|
||||
helper in ``app.main``.
|
||||
"""
|
||||
normalized = (email or "").strip().lower()
|
||||
|
||||
if not normalized or not _EMAIL_RE.match(normalized):
|
||||
# Format error surfaces as a flash; there's nothing to leak
|
||||
# here because we haven't checked the allowlist yet.
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/login.html",
|
||||
{
|
||||
"error": "Please enter a valid email address.",
|
||||
"email": email or "",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
ip = _client_ip(request)
|
||||
ua = _user_agent(request)
|
||||
|
||||
try:
|
||||
auth.request_link(email=normalized, ip=ip, user_agent=ua)
|
||||
except RateLimitedError:
|
||||
# Per-email DB-side limit tripped. The SlowAPI IP-level limit
|
||||
# is handled separately via the registered exception handler.
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/rate_limited.html",
|
||||
{},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/login_sent.html",
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/auth/consume/{token}
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get(
|
||||
"/admin/auth/consume/{token}",
|
||||
summary="Consume magic-link token",
|
||||
)
|
||||
@limiter.limit("20/15 minutes")
|
||||
def admin_auth_consume(
|
||||
request: Request,
|
||||
token: str,
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
auth: AuthService = Depends(_get_auth_service),
|
||||
sessions: SessionService = Depends(_get_session_service),
|
||||
) -> Response:
|
||||
"""Consume a magic-link token and, on success, set the session cookie.
|
||||
|
||||
Failure path returns 400 + generic ``invalid or expired`` page.
|
||||
Success path sets ``cb_session`` and 303-redirects to ``/admin``.
|
||||
"""
|
||||
ip = _client_ip(request)
|
||||
ua = _user_agent(request)
|
||||
|
||||
result = auth.consume(raw_token=token, ip=ip, user_agent=ua)
|
||||
if result is None:
|
||||
# Generic failure response — the audit log has the real reason.
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/login_failed.html",
|
||||
{},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
_user, _session, cookie_value = result
|
||||
|
||||
# 303 forces the browser to GET /admin on the next request.
|
||||
response = RedirectResponse(url="/admin", status_code=303)
|
||||
response.set_cookie(value=cookie_value, **sessions.cookie_params())
|
||||
return response
|
||||
|
||||
|
||||
# The authenticated landing page (``GET /admin``) now lives in
|
||||
# :mod:`app.routes.admin_cms` as the dashboard. This module stays
|
||||
# scoped to pre-auth / auth-lifecycle endpoints.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/logout
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/logout", summary="Log out the current admin")
|
||||
def admin_logout(
|
||||
request: Request,
|
||||
user: User = Depends(require_admin),
|
||||
sessions: SessionService = Depends(_get_session_service),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Revoke the current session and clear the cookie.
|
||||
|
||||
Always issues a 303 redirect to ``/admin/login`` so browsers
|
||||
transparently follow and show the login form (with no cookie).
|
||||
"""
|
||||
from app.services.sessions import SessionService as _SS # noqa: F401
|
||||
|
||||
# Look up the session again via the cookie so we can revoke it
|
||||
# and emit a properly-correlated audit row.
|
||||
cookie_value: Optional[str] = request.cookies.get(COOKIE_NAME)
|
||||
session = sessions.lookup(cookie_value)
|
||||
audit = request.app.state.audit_service
|
||||
|
||||
if session is not None:
|
||||
sessions.revoke(session)
|
||||
audit.record(
|
||||
"session_revoked",
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
ip=_client_ip(request),
|
||||
user_agent=_user_agent(request),
|
||||
detail={"session_id": session.token_hash[-6:]},
|
||||
)
|
||||
|
||||
response = RedirectResponse(url="/admin/login", status_code=303)
|
||||
# Clear the cookie by setting an empty value with Max-Age=0 and the
|
||||
# same Path so the browser actually removes it.
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/")
|
||||
return response
|
||||
500
app/routes/admin_cms.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""Admin CMS routes — dashboard, post CRUD, About edit, media upload.
|
||||
|
||||
These handlers all live behind :func:`require_admin`. Mutating
|
||||
endpoints additionally pull a CSRF dependency
|
||||
(:func:`require_csrf_form` or :func:`require_csrf_header`) so the
|
||||
double-submit cookie is verified before any state change.
|
||||
|
||||
Each handler does the absolute minimum: pull services off the app
|
||||
state, call their methods, translate the result into an HTTP
|
||||
response. No business logic, no SQL.
|
||||
|
||||
Routing map
|
||||
-----------
|
||||
- ``GET /admin`` — dashboard.
|
||||
- ``GET /admin/posts/new`` — create form.
|
||||
- ``POST /admin/posts`` — create handler (CSRF).
|
||||
- ``GET /admin/posts/{id}/edit`` — edit form.
|
||||
- ``POST /admin/posts/{id}`` — update (CSRF).
|
||||
- ``POST /admin/posts/{id}/delete`` — delete (CSRF).
|
||||
- ``POST /admin/posts/{id}/publish`` — publish toggle (CSRF).
|
||||
- ``GET /admin/pages/about/edit`` — About edit form.
|
||||
- ``POST /admin/pages/about`` — About update (CSRF).
|
||||
- ``POST /admin/media/upload`` — multipart upload (header CSRF).
|
||||
- ``POST /admin/preview`` — Markdown → HTML preview (header CSRF).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.dependencies.auth import require_admin
|
||||
from app.dependencies.csrf import require_csrf_form, require_csrf_header
|
||||
from app.models.entities import PostStatus, User
|
||||
from app.services.admin_pages import AdminPagesService
|
||||
from app.services.admin_posts import AdminPostsService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.media import MediaRejectedError, MediaService
|
||||
|
||||
|
||||
router: APIRouter = APIRouter(tags=["admin-cms"])
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DI helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _get_templates(request: Request) -> Jinja2Templates:
|
||||
"""Return the app-scoped :class:`Jinja2Templates`."""
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
def _get_admin_posts(request: Request) -> AdminPostsService:
|
||||
"""Return the app-scoped :class:`AdminPostsService`."""
|
||||
return request.app.state.admin_posts_service
|
||||
|
||||
|
||||
def _get_admin_pages(request: Request) -> AdminPagesService:
|
||||
"""Return the app-scoped :class:`AdminPagesService`."""
|
||||
return request.app.state.admin_pages_service
|
||||
|
||||
|
||||
def _get_media(request: Request) -> MediaService:
|
||||
"""Return the app-scoped :class:`MediaService`."""
|
||||
return request.app.state.media_service
|
||||
|
||||
|
||||
def _get_markdown(request: Request) -> MarkdownService:
|
||||
"""Return the app-scoped :class:`MarkdownService`."""
|
||||
return request.app.state.markdown_service
|
||||
|
||||
|
||||
def _get_csrf_token_for_template(request: Request) -> str:
|
||||
"""Return the CSRF token to embed in the rendered admin templates.
|
||||
|
||||
The middleware (see :mod:`app.main`) sets ``request.state.csrf_token``
|
||||
on every admin GET after ensuring the cookie is in sync. Handlers
|
||||
pull it from request state and pass it into the template context.
|
||||
"""
|
||||
return getattr(request.state, "csrf_token", "") or ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin — dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/admin", response_class=HTMLResponse, summary="Admin dashboard")
|
||||
def admin_dashboard(
|
||||
request: Request,
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
admin_pages: AdminPagesService = Depends(_get_admin_pages),
|
||||
) -> HTMLResponse:
|
||||
"""Render the dashboard: posts list + About edit link.
|
||||
|
||||
Posts are sorted newest-updated-first and include both drafts and
|
||||
published posts — the admin table surfaces the status badge.
|
||||
"""
|
||||
posts = admin_posts.list_all()
|
||||
about = admin_pages.get_about()
|
||||
# Optional flash from PRG query param — we keep it minimal.
|
||||
msg = request.query_params.get("msg") or ""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/dashboard.html",
|
||||
{
|
||||
"user": user,
|
||||
"posts": posts,
|
||||
"about": about,
|
||||
"msg": msg,
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/posts/new
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get(
|
||||
"/admin/posts/new",
|
||||
response_class=HTMLResponse,
|
||||
summary="Admin: new post form",
|
||||
)
|
||||
def admin_post_new_form(
|
||||
request: Request,
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
) -> HTMLResponse:
|
||||
"""Render the empty create form.
|
||||
|
||||
No post id → the form POSTs to ``/admin/posts``. Slug is not shown
|
||||
because we auto-generate it from the title on create.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/post_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"post": None,
|
||||
"form": {"title": "", "body_md": "", "status": PostStatus.DRAFT.value},
|
||||
"errors": {},
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/posts — create
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/posts", summary="Admin: create post")
|
||||
def admin_post_create(
|
||||
request: Request,
|
||||
title: str = Form(default=""),
|
||||
body_md: str = Form(default=""),
|
||||
status: str = Form(default=PostStatus.DRAFT.value),
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Handle the new-post submission and redirect to the dashboard.
|
||||
|
||||
Minimal validation:
|
||||
- title must be non-empty after strip
|
||||
- status must be a valid :class:`PostStatus` value
|
||||
|
||||
On validation error we re-render the form with the submitted
|
||||
values so Head Hen doesn't retype.
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
clean_title = (title or "").strip()
|
||||
if not clean_title:
|
||||
errors["title"] = "Title is required."
|
||||
try:
|
||||
status_enum = PostStatus(status)
|
||||
except ValueError:
|
||||
errors["status"] = "Invalid status."
|
||||
status_enum = PostStatus.DRAFT
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/post_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"post": None,
|
||||
"form": {
|
||||
"title": title,
|
||||
"body_md": body_md,
|
||||
"status": status_enum.value,
|
||||
},
|
||||
"errors": errors,
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
admin_posts.create(
|
||||
title=clean_title,
|
||||
body_md=body_md or "",
|
||||
status=status_enum,
|
||||
author_id=user.id,
|
||||
)
|
||||
return RedirectResponse(url="/admin?msg=created", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/posts/{id}/edit
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get(
|
||||
"/admin/posts/{post_id}/edit",
|
||||
response_class=HTMLResponse,
|
||||
summary="Admin: edit post form",
|
||||
)
|
||||
def admin_post_edit_form(
|
||||
request: Request,
|
||||
post_id: int,
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
) -> HTMLResponse:
|
||||
"""Render the edit form for an existing post."""
|
||||
post = admin_posts.get_by_id(post_id)
|
||||
if post is None:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/post_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"post": post,
|
||||
"form": {
|
||||
"title": post.title,
|
||||
"body_md": post.body_md,
|
||||
"status": post.status.value,
|
||||
},
|
||||
"errors": {},
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/posts/{id} — update
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/posts/{post_id}", summary="Admin: update post")
|
||||
def admin_post_update(
|
||||
request: Request,
|
||||
post_id: int,
|
||||
title: str = Form(default=""),
|
||||
body_md: str = Form(default=""),
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Apply title + body edits to an existing post.
|
||||
|
||||
Slug changes are not permitted via this path — server-side
|
||||
enforcement of the "slug lock on publish" policy (see
|
||||
:class:`AdminPostsService`).
|
||||
"""
|
||||
existing = admin_posts.get_by_id(post_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
clean_title = (title or "").strip()
|
||||
errors: dict[str, str] = {}
|
||||
if not clean_title:
|
||||
errors["title"] = "Title is required."
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/post_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"post": existing,
|
||||
"form": {
|
||||
"title": title,
|
||||
"body_md": body_md,
|
||||
"status": existing.status.value,
|
||||
},
|
||||
"errors": errors,
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
admin_posts.update(
|
||||
post_id,
|
||||
title=clean_title,
|
||||
body_md=body_md or "",
|
||||
actor_user_id=user.id,
|
||||
)
|
||||
return RedirectResponse(url="/admin?msg=saved", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/posts/{id}/delete
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/posts/{post_id}/delete", summary="Admin: delete post")
|
||||
def admin_post_delete(
|
||||
request: Request,
|
||||
post_id: int,
|
||||
user: User = Depends(require_admin),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Hard-delete a post row."""
|
||||
deleted = admin_posts.delete(post_id, actor_user_id=user.id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return RedirectResponse(url="/admin?msg=deleted", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/posts/{id}/publish — publish/unpublish toggle
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post(
|
||||
"/admin/posts/{post_id}/publish", summary="Admin: toggle publish state"
|
||||
)
|
||||
def admin_post_toggle_publish(
|
||||
request: Request,
|
||||
post_id: int,
|
||||
user: User = Depends(require_admin),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Flip draft ↔ published."""
|
||||
updated = admin_posts.toggle_publish(post_id, actor_user_id=user.id)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
# Friendly-ish flash so the admin sees the result of the toggle.
|
||||
msg = "published" if updated.status is PostStatus.PUBLISHED else "unpublished"
|
||||
return RedirectResponse(url=f"/admin?msg={msg}", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/pages/about/edit
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get(
|
||||
"/admin/pages/about/edit",
|
||||
response_class=HTMLResponse,
|
||||
summary="Admin: edit About page",
|
||||
)
|
||||
def admin_about_edit_form(
|
||||
request: Request,
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_pages: AdminPagesService = Depends(_get_admin_pages),
|
||||
) -> HTMLResponse:
|
||||
"""Render the About-page edit form."""
|
||||
page = admin_pages.get_about()
|
||||
if page is None:
|
||||
raise HTTPException(status_code=500, detail="About page missing")
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/page_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"page": page,
|
||||
"form": {"title": page.title, "body_md": page.body_md},
|
||||
"errors": {},
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/pages/about — update
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/pages/about", summary="Admin: update About page")
|
||||
def admin_about_update(
|
||||
request: Request,
|
||||
title: str = Form(default=""),
|
||||
body_md: str = Form(default=""),
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_pages: AdminPagesService = Depends(_get_admin_pages),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Apply edits to the About page (slug is fixed)."""
|
||||
clean_title = (title or "").strip()
|
||||
errors: dict[str, str] = {}
|
||||
if not clean_title:
|
||||
errors["title"] = "Title is required."
|
||||
|
||||
if errors:
|
||||
page = admin_pages.get_about()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/page_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"page": page,
|
||||
"form": {"title": title, "body_md": body_md},
|
||||
"errors": errors,
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
admin_pages.update_about(
|
||||
title=clean_title,
|
||||
body_md=body_md or "",
|
||||
actor_user_id=user.id,
|
||||
)
|
||||
return RedirectResponse(url="/admin?msg=saved", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/media/upload
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/media/upload", summary="Admin: upload image")
|
||||
async def admin_media_upload(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
alt_text: str = Form(default=""),
|
||||
user: User = Depends(require_admin),
|
||||
media: MediaService = Depends(_get_media),
|
||||
_csrf: None = Depends(require_csrf_header),
|
||||
) -> JSONResponse:
|
||||
"""Validate and store an uploaded image.
|
||||
|
||||
Response JSON is small by design — the drag-drop JS only needs a
|
||||
URL to splice into the Markdown source as ````.
|
||||
"""
|
||||
data = await file.read()
|
||||
try:
|
||||
record = media.save_upload(
|
||||
original_filename=file.filename or "",
|
||||
data=data,
|
||||
uploaded_by=user.id,
|
||||
alt_text=alt_text or "",
|
||||
)
|
||||
except MediaRejectedError as exc:
|
||||
return JSONResponse(
|
||||
{"error": str(exc)},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": record.id,
|
||||
"url": media.public_url(record),
|
||||
"alt": record.alt_text,
|
||||
"filename": record.filename,
|
||||
"size_bytes": record.size_bytes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/preview
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post(
|
||||
"/admin/preview",
|
||||
response_class=HTMLResponse,
|
||||
summary="Admin: Markdown preview",
|
||||
)
|
||||
def admin_preview(
|
||||
request: Request,
|
||||
markdown: str = Form(default=""),
|
||||
user: User = Depends(require_admin),
|
||||
md: MarkdownService = Depends(_get_markdown),
|
||||
_csrf: None = Depends(require_csrf_header),
|
||||
) -> HTMLResponse:
|
||||
"""Render ``markdown`` through the sanitizer and return an HTML fragment.
|
||||
|
||||
The fragment is NOT wrapped in a full page — it is ``innerHTML``-safe
|
||||
output from the same pipeline that stores ``body_html_cached``. The
|
||||
route reuses :class:`MarkdownService` so preview output exactly
|
||||
matches what will eventually be served to the public.
|
||||
"""
|
||||
rendered: str = md.render(markdown or "")
|
||||
return HTMLResponse(content=rendered)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional aliases for backward-compat imports
|
||||
# ---------------------------------------------------------------------------
|
||||
# If another module (e.g. tests) imports ``router`` from here, the
|
||||
# attribute name stays stable.
|
||||
_ = router # silence "not used" in linters that don't pick up FastAPI magic
|
||||
|
||||
# Avoid "Optional unused" when the type is only referenced via Depends.
|
||||
_Optional = Optional # pragma: no cover
|
||||
69
app/routes/health.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Liveness / version endpoint.
|
||||
|
||||
The ``/healthz`` endpoint is intentionally minimal: it must be safe to
|
||||
expose unauthenticated (Caddy + uptime checks will hit it), so it leaks
|
||||
only the app version and the build's git commit SHA — no hostnames,
|
||||
paths, config values, or environment details.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app import __version__
|
||||
from app.config import Settings, get_settings
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Response schema for ``GET /healthz``.
|
||||
|
||||
Kept as an explicit :class:`pydantic.BaseModel` so FastAPI publishes
|
||||
the shape in the OpenAPI schema and so any drift is caught at typing
|
||||
time rather than via a loose ``dict``.
|
||||
"""
|
||||
|
||||
status: Literal["ok"] = Field(
|
||||
default="ok",
|
||||
description="Liveness indicator; this endpoint only returns 'ok'.",
|
||||
)
|
||||
version: str = Field(description="Application semantic version.")
|
||||
commit_sha: str = Field(
|
||||
description="Git commit SHA of the running build ('unknown' in dev).",
|
||||
)
|
||||
|
||||
|
||||
# Module-level router; mounted by `app.main.create_app`. No prefix and no
|
||||
# auth dependencies — /healthz is intentionally public.
|
||||
router: APIRouter = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/healthz",
|
||||
response_model=HealthResponse,
|
||||
summary="Liveness + version probe",
|
||||
)
|
||||
def healthz(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
"""Return a minimal liveness envelope.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
settings:
|
||||
Injected via FastAPI's dependency system so tests can override
|
||||
configuration cleanly.
|
||||
|
||||
Returns
|
||||
-------
|
||||
HealthResponse
|
||||
``status`` is always ``"ok"``; ``version`` and ``commit_sha``
|
||||
identify the running build.
|
||||
"""
|
||||
# Intentionally does not touch the DB, filesystem, or any external
|
||||
# service. This is liveness, not readiness — a readiness probe with
|
||||
# deeper checks can be added in a later phase behind a different path.
|
||||
return HealthResponse(
|
||||
version=__version__,
|
||||
commit_sha=settings.git_commit_sha,
|
||||
)
|
||||
386
app/routes/public.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""Public-facing HTTP routes.
|
||||
|
||||
Phase 2 scope:
|
||||
|
||||
- ``GET /`` — blog index; posts come from :class:`PostService`
|
||||
which now reads the ``posts`` table.
|
||||
- ``GET /about`` — DB-backed; loads the ``about`` row from the
|
||||
``pages`` table via :class:`PageService` and
|
||||
renders its ``body_html_cached`` directly.
|
||||
- ``GET /contact`` — form UI + optional ``mailto:`` link.
|
||||
- ``POST /contact`` — Phase 5 live submission endpoint (hCaptcha +
|
||||
honeypot + rate-limit + persist + notify).
|
||||
- ``GET /shop`` — "Coming soon" card.
|
||||
|
||||
Every handler is thin: it resolves its dependencies, calls any service
|
||||
methods it needs, and delegates rendering to a Jinja template. No HTML
|
||||
is constructed in Python.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.models.entities import Page, Post
|
||||
from app.models.posts import PostSummary
|
||||
from app.services.contact import ContactService
|
||||
from app.services.hcaptcha import HCaptchaService
|
||||
from app.services.pages import PageService, get_page_service
|
||||
from app.services.posts import PostService, get_post_service
|
||||
from app.services.rate_limit import limiter
|
||||
|
||||
|
||||
# Module-level router. Mounted without a prefix by ``app.main.create_app``
|
||||
# so the routes below live at the site root.
|
||||
router: APIRouter = APIRouter(tags=["public"])
|
||||
|
||||
# One module-level logger is fine; structlog handles context binding.
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Same loose email shape used on the admin login form. Intentionally
|
||||
# permissive: the mapper layer / Resend does the real work; we only
|
||||
# want to reject obvious junk before hitting the service.
|
||||
_EMAIL_RE: re.Pattern[str] = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
# Field length guards — matched on the server regardless of the
|
||||
# HTML5 attributes the template emits.
|
||||
_NAME_MAX: int = 80
|
||||
_EMAIL_MAX: int = 254
|
||||
_MESSAGE_MIN: int = 10
|
||||
_MESSAGE_MAX: int = 4000
|
||||
|
||||
|
||||
def get_templates(request: Request) -> Jinja2Templates:
|
||||
"""Return the shared :class:`Jinja2Templates` instance.
|
||||
|
||||
The singleton is attached to ``app.state.templates`` in
|
||||
:func:`app.main.create_app`. Looking it up via ``request.app.state``
|
||||
(rather than importing from ``app.main``) avoids an import cycle and
|
||||
keeps the handlers test-friendly — tests can swap out the instance by
|
||||
mutating ``app.state.templates`` before issuing requests.
|
||||
"""
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
def _get_hcaptcha_service(request: Request) -> HCaptchaService:
|
||||
"""Return the app-scoped :class:`HCaptchaService`."""
|
||||
return request.app.state.hcaptcha_service
|
||||
|
||||
|
||||
def _get_contact_service(request: Request) -> ContactService:
|
||||
"""Return the app-scoped :class:`ContactService`."""
|
||||
return request.app.state.contact_service
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Best-effort client IP from the request.
|
||||
|
||||
Starlette already respects ``X-Forwarded-For`` via the proxy-headers
|
||||
middleware Uvicorn installs with ``--proxy-headers``; that means
|
||||
``request.client.host`` is the real client IP.
|
||||
"""
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
def _user_agent(request: Request) -> str:
|
||||
"""Return the submitted User-Agent header (empty string if missing)."""
|
||||
return request.headers.get("user-agent", "")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, summary="Blog index")
|
||||
def home(
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
posts: PostService = Depends(get_post_service),
|
||||
) -> HTMLResponse:
|
||||
"""Render the blog index with any published posts.
|
||||
|
||||
Phase 2: the service now returns real ``PostSummary`` rows from
|
||||
SQLite. The homepage template still handles the empty-list case
|
||||
gracefully in case a future deployment starts with an unseeded
|
||||
database.
|
||||
"""
|
||||
# Query the service layer for the most recent published posts. The
|
||||
# template handles the empty-list case; we do not branch here.
|
||||
summaries: list[PostSummary] = posts.list_published()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/home.html",
|
||||
{"posts": summaries, "active_nav": "home"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/posts/{slug}",
|
||||
response_class=HTMLResponse,
|
||||
summary="Single blog post",
|
||||
)
|
||||
def post_detail(
|
||||
slug: str,
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
posts: PostService = Depends(get_post_service),
|
||||
) -> HTMLResponse:
|
||||
"""Render a single published post by slug.
|
||||
|
||||
Drafts and unknown slugs return 404 (same response so a mistyped
|
||||
URL cannot be used to enumerate unpublished titles).
|
||||
"""
|
||||
post: Post | None = posts.get_published_by_slug(slug)
|
||||
if post is None:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/post.html",
|
||||
{"active_nav": "home", "post": post},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/about", response_class=HTMLResponse, summary="About the farm")
|
||||
def about(
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
pages: PageService = Depends(get_page_service),
|
||||
) -> HTMLResponse:
|
||||
"""Render the About page from the ``pages`` table.
|
||||
|
||||
Phase 2 rewires this route: the body comes from ``pages.about``
|
||||
(seeded at first boot and editable via Phase 4 admin). If the
|
||||
page row is missing — which should not happen after a successful
|
||||
seed — we log the anomaly and return a generic 500 without
|
||||
leaking implementation details (CWE-200).
|
||||
"""
|
||||
page: Page | None = pages.get_by_slug("about")
|
||||
if page is None:
|
||||
# Anomalous: the seed should always have populated this row.
|
||||
# Log with enough context to diagnose without exposing it to
|
||||
# the visitor.
|
||||
_log.error("about_page_missing", slug="about")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="The About page is temporarily unavailable.",
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/about.html",
|
||||
{"active_nav": "about", "page": page},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/contact", response_class=HTMLResponse, summary="Contact the farm")
|
||||
def contact(
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> HTMLResponse:
|
||||
"""Render the contact page.
|
||||
|
||||
Phase 5 wires the form up to a real POST handler; this GET now
|
||||
returns the blank form. ``ADMIN_CONTACT_EMAIL`` is still surfaced
|
||||
as a secondary ``mailto:`` link for visitors who prefer their own
|
||||
inbox over the form.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/contact.html",
|
||||
{
|
||||
"active_nav": "contact",
|
||||
# None when unset; the template hides the mailto link in that
|
||||
# case. We pass the value through settings so tests can
|
||||
# override it without touching environment variables.
|
||||
"contact_email": settings.admin_contact_email,
|
||||
"hcaptcha_site_key": settings.hcaptcha_site_key,
|
||||
"errors": {},
|
||||
"form": {"name": "", "email": "", "message": ""},
|
||||
"form_error": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/contact", summary="Submit the contact form")
|
||||
@limiter.limit("3/hour")
|
||||
async def contact_submit(
|
||||
request: Request,
|
||||
name: str = Form(default=""),
|
||||
email: str = Form(default=""),
|
||||
message: str = Form(default=""),
|
||||
website: str = Form(default=""),
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
settings: Settings = Depends(get_settings),
|
||||
hcaptcha: HCaptchaService = Depends(_get_hcaptcha_service),
|
||||
contact_service: ContactService = Depends(_get_contact_service),
|
||||
) -> Response:
|
||||
"""Handle the contact-form POST.
|
||||
|
||||
Flow (strict order — each stage short-circuits):
|
||||
|
||||
1. Honeypot: if ``website`` is non-empty → silent spam. Audit
|
||||
``contact_spam_rejected`` with reason ``honeypot``; render the
|
||||
generic success page so the bot operator cannot tell they were
|
||||
filtered.
|
||||
2. hCaptcha: call :meth:`HCaptchaService.verify`. On False → audit
|
||||
``contact_spam_rejected`` with reason ``hcaptcha``; render the
|
||||
success page. Same anti-enumeration rationale.
|
||||
3. Validate fields. On any error → re-render the form with inline
|
||||
error messages + HTTP 400 + submitted values preserved.
|
||||
4. Persist: :meth:`ContactService.record_submission`. After this
|
||||
point the message is durable even if the email fails.
|
||||
5. Notify: :meth:`ContactService.send_notification`. Best-effort;
|
||||
if it raises (it never should — service is defensive) we still
|
||||
fall through to the success page.
|
||||
6. Render ``public/contact_sent.html``.
|
||||
|
||||
CSRF is NOT required here: the endpoint is pre-auth, has no
|
||||
session to hijack, and adding a cookie dance would break the
|
||||
first-contact UX. SlowAPI + hCaptcha + honeypot are the controls.
|
||||
"""
|
||||
ip = _client_ip(request)
|
||||
ua = _user_agent(request)
|
||||
audit = request.app.state.audit_service
|
||||
|
||||
# --- Stage 1: honeypot ------------------------------------------------
|
||||
if (website or "").strip():
|
||||
audit.record(
|
||||
"contact_spam_rejected",
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
detail={"reason": "honeypot"},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/contact_sent.html",
|
||||
{"active_nav": "contact"},
|
||||
)
|
||||
|
||||
# --- Stage 2: hCaptcha ------------------------------------------------
|
||||
token = (
|
||||
(request.headers.get("h-captcha-response") or "")
|
||||
or ""
|
||||
)
|
||||
# hCaptcha's widget posts the token as a form field named
|
||||
# ``h-captcha-response``. Starlette's ``Request.form()`` is async;
|
||||
# re-reading it here is fine because FastAPI caches the parsed form
|
||||
# for the lifetime of the request.
|
||||
form_data = await request.form()
|
||||
captcha_token = str(form_data.get("h-captcha-response", token) or "")
|
||||
|
||||
ok = await hcaptcha.verify(captcha_token, ip)
|
||||
if not ok:
|
||||
audit.record(
|
||||
"contact_spam_rejected",
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
detail={"reason": "hcaptcha"},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/contact_sent.html",
|
||||
{"active_nav": "contact"},
|
||||
)
|
||||
|
||||
# --- Stage 3: validation ---------------------------------------------
|
||||
clean_name = (name or "").strip()
|
||||
clean_email = (email or "").strip()
|
||||
clean_message = (message or "").strip()
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
if not clean_name:
|
||||
errors["name"] = "Please enter your name."
|
||||
elif len(clean_name) > _NAME_MAX:
|
||||
errors["name"] = f"Name must be {_NAME_MAX} characters or fewer."
|
||||
|
||||
if not clean_email:
|
||||
errors["email"] = "Please enter your email address."
|
||||
elif len(clean_email) > _EMAIL_MAX or not _EMAIL_RE.match(clean_email):
|
||||
errors["email"] = "Please enter a valid email address."
|
||||
|
||||
if not clean_message:
|
||||
errors["message"] = "Please include a message."
|
||||
elif len(clean_message) < _MESSAGE_MIN:
|
||||
errors["message"] = (
|
||||
f"Message must be at least {_MESSAGE_MIN} characters."
|
||||
)
|
||||
elif len(clean_message) > _MESSAGE_MAX:
|
||||
errors["message"] = (
|
||||
f"Message must be {_MESSAGE_MAX} characters or fewer."
|
||||
)
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/contact.html",
|
||||
{
|
||||
"active_nav": "contact",
|
||||
"contact_email": settings.admin_contact_email,
|
||||
"hcaptcha_site_key": settings.hcaptcha_site_key,
|
||||
"errors": errors,
|
||||
"form": {
|
||||
"name": clean_name,
|
||||
"email": clean_email,
|
||||
"message": clean_message,
|
||||
},
|
||||
"form_error": None,
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# --- Stage 4: persist -------------------------------------------------
|
||||
submission = contact_service.record_submission(
|
||||
name=clean_name,
|
||||
email=clean_email,
|
||||
message=clean_message,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
# Audit the successful submission. Store the message length + a
|
||||
# short preview so on-call can tell whether a flood is substantive
|
||||
# without exposing full bodies in the audit log.
|
||||
audit.record(
|
||||
"contact_submitted",
|
||||
email=clean_email,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
detail={
|
||||
"submission_id": submission.id,
|
||||
"message_length": len(clean_message),
|
||||
"message_preview": clean_message[:40],
|
||||
},
|
||||
)
|
||||
|
||||
# --- Stage 5: notify --------------------------------------------------
|
||||
contact_service.send_notification(submission)
|
||||
|
||||
# --- Stage 6: success page -------------------------------------------
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/contact_sent.html",
|
||||
{"active_nav": "contact"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/shop", response_class=HTMLResponse, summary="Shop placeholder")
|
||||
def shop(
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
) -> HTMLResponse:
|
||||
"""Render the "Coming soon" placeholder for the future shop.
|
||||
|
||||
The nav link to ``/shop`` remains enabled so visitors can preview the
|
||||
offering; it is the landing page itself that signals the shop is not
|
||||
yet live. Phase 7 replaces this template with the real catalog.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/shop.html",
|
||||
{"active_nav": "shop"},
|
||||
)
|
||||
7
app/services/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Application services (auth, email, cache, markdown, media, hcaptcha).
|
||||
|
||||
Populated phase-by-phase per ``docs/ROADMAP.md``. Intentionally empty in
|
||||
Phase 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
124
app/services/admin_pages.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Admin-side (write) page service.
|
||||
|
||||
The public site only has one editable page — "About" — so this
|
||||
service is intentionally narrower than :class:`AdminPostsService`. The
|
||||
slug is a fixed literal (``"about"``) and cannot be changed through
|
||||
the admin. Only the title and body may be edited.
|
||||
|
||||
Every write:
|
||||
|
||||
- re-renders Markdown → sanitized HTML into ``body_html_cached`` so
|
||||
the public read path stays a single SELECT.
|
||||
- bumps ``updated_at``.
|
||||
- emits an ``AuditService`` ``page_updated`` event.
|
||||
- invalidates the public :class:`PageService` (and, defensively, the
|
||||
:class:`PostService`) cache so the next request sees the new copy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import Page
|
||||
from app.models.mappers import row_to_page
|
||||
from app.services.audit import AuditService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# The single editable page's slug. Hard-coded here (not injected) so
|
||||
# the CLI contract is impossible to misuse — there is no way to point
|
||||
# this service at a different slug.
|
||||
ABOUT_SLUG: str = "about"
|
||||
|
||||
|
||||
class AdminPagesService:
|
||||
"""Write-side service for the About page."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
markdown: MarkdownService,
|
||||
page_service: PageService,
|
||||
post_service: PostService,
|
||||
audit: AuditService,
|
||||
) -> None:
|
||||
self._engine: Engine = engine
|
||||
self._markdown: MarkdownService = markdown
|
||||
self._page_service: PageService = page_service
|
||||
self._post_service: PostService = post_service
|
||||
self._audit: AuditService = audit
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reads
|
||||
# ------------------------------------------------------------------
|
||||
def get_about(self) -> Optional[Page]:
|
||||
"""Return the current About page row, or ``None`` if absent."""
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" updated_at, published"
|
||||
" FROM pages WHERE slug = :slug LIMIT 1"
|
||||
),
|
||||
{"slug": ABOUT_SLUG},
|
||||
).mappings().first()
|
||||
return row_to_page(row) if row is not None else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writes
|
||||
# ------------------------------------------------------------------
|
||||
def update_about(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
body_md: str,
|
||||
actor_user_id: int,
|
||||
) -> Optional[Page]:
|
||||
"""Update the About page's title + body.
|
||||
|
||||
Slug is immutable — the admin form does not expose it.
|
||||
"""
|
||||
existing = self.get_about()
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
clean_title = (title or "").strip()
|
||||
clean_body = body_md or ""
|
||||
body_html = self._markdown.render(clean_body)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE pages"
|
||||
" SET title = :title, body_md = :body_md,"
|
||||
" body_html_cached = :body_html,"
|
||||
" updated_at = :updated_at"
|
||||
" WHERE slug = :slug"
|
||||
),
|
||||
{
|
||||
"title": clean_title,
|
||||
"body_md": clean_body,
|
||||
"body_html": body_html,
|
||||
"updated_at": now_iso,
|
||||
"slug": ABOUT_SLUG,
|
||||
},
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
"page_updated",
|
||||
user_id=actor_user_id,
|
||||
detail={"slug": ABOUT_SLUG},
|
||||
)
|
||||
self._page_service.invalidate_all()
|
||||
self._post_service.invalidate_all()
|
||||
return self.get_about()
|
||||
383
app/services/admin_posts.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""Admin-side (write) post service.
|
||||
|
||||
Mirrors the shape of :class:`app.services.posts.PostService` but for
|
||||
the admin CRUD path. Responsibilities:
|
||||
|
||||
- create / update / delete posts
|
||||
- toggle publish state
|
||||
- auto-generate unique slugs from titles on create (draft only)
|
||||
- re-render Markdown to ``body_html_cached`` on every write
|
||||
- audit every write via :class:`AuditService` using descriptive
|
||||
``event_type`` strings
|
||||
- invalidate both :class:`PostService` and :class:`PageService` caches
|
||||
so the public site reflects the change immediately
|
||||
|
||||
All writes use parameterized SQL (``text(":bind")``). No user input is
|
||||
ever interpolated into a query string.
|
||||
|
||||
The service treats ``author_user_id`` as an immutable field: once a
|
||||
post is created, edits do NOT reassign authorship, even if a different
|
||||
admin saves the edit. This matches the single-author ("Head Hen")
|
||||
reality of the site.
|
||||
|
||||
Slug lock-on-publish
|
||||
--------------------
|
||||
A slug may only be auto-regenerated on title change while the post is
|
||||
a draft. Once a post has been published even once, the slug is locked
|
||||
server-side — callers cannot change it via the update path, even if
|
||||
they later unpublish the post. This preserves any inbound links that
|
||||
went live while the post was published.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import Post, PostStatus
|
||||
from app.models.mappers import row_to_post
|
||||
from app.services.audit import AuditService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
from app.services.slugs import ensure_unique, slugify
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AdminPostsService:
|
||||
"""Write-side orchestration for blog posts.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
Shared SQLAlchemy engine. Never opens its own.
|
||||
markdown:
|
||||
Shared :class:`MarkdownService` used to re-render on every
|
||||
write so the public read path pays only a single SELECT.
|
||||
post_service:
|
||||
The public read-side service. Invalidated after every write so
|
||||
the home page reflects the change immediately.
|
||||
page_service:
|
||||
Same rationale — a post edit doesn't change page content but
|
||||
we conservatively invalidate to keep cache logic uniform.
|
||||
audit:
|
||||
:class:`AuditService` for descriptive admin write events.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
markdown: MarkdownService,
|
||||
post_service: PostService,
|
||||
page_service: PageService,
|
||||
audit: AuditService,
|
||||
) -> None:
|
||||
self._engine: Engine = engine
|
||||
self._markdown: MarkdownService = markdown
|
||||
self._post_service: PostService = post_service
|
||||
self._page_service: PageService = page_service
|
||||
self._audit: AuditService = audit
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reads (admin dashboard)
|
||||
# ------------------------------------------------------------------
|
||||
def list_all(self) -> list[Post]:
|
||||
"""Return every post, newest-updated-first.
|
||||
|
||||
Drafts and published posts are both included; the dashboard
|
||||
surfaces the status column so Head Hen can work on unpublished
|
||||
material.
|
||||
"""
|
||||
with self._engine.connect() as conn:
|
||||
rows = (
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" status, published_at, updated_at, author_user_id"
|
||||
" FROM posts"
|
||||
" ORDER BY updated_at DESC, id DESC"
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [row_to_post(row) for row in rows]
|
||||
|
||||
def get_by_id(self, post_id: int) -> Optional[Post]:
|
||||
"""Return the :class:`Post` for ``post_id`` or ``None`` if absent."""
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" status, published_at, updated_at, author_user_id"
|
||||
" FROM posts WHERE id = :id LIMIT 1"
|
||||
),
|
||||
{"id": post_id},
|
||||
).mappings().first()
|
||||
return row_to_post(row) if row is not None else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writes
|
||||
# ------------------------------------------------------------------
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
body_md: str,
|
||||
status: PostStatus,
|
||||
author_id: int,
|
||||
) -> Post:
|
||||
"""Insert a new post row and return the loaded :class:`Post`.
|
||||
|
||||
Flow
|
||||
----
|
||||
1. Slugify the title; ensure uniqueness via the closure over the
|
||||
DB so concurrent creates cannot collide on the UNIQUE index.
|
||||
2. Render Markdown to sanitized HTML.
|
||||
3. If ``status == PUBLISHED`` stamp ``published_at = now``;
|
||||
otherwise leave NULL.
|
||||
4. Insert.
|
||||
5. Audit ``post_created`` (and ``post_published`` when the
|
||||
initial status is published).
|
||||
6. Invalidate caches.
|
||||
"""
|
||||
clean_title = (title or "").strip()
|
||||
clean_body = body_md or ""
|
||||
base_slug = slugify(clean_title)
|
||||
# The closure escapes the engine so ensure_unique can check
|
||||
# without opening a long-lived transaction.
|
||||
unique_slug = ensure_unique(base_slug, self._slug_exists)
|
||||
|
||||
body_html = self._markdown.render(clean_body)
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = now.isoformat()
|
||||
published_at_iso: Optional[str] = (
|
||||
now_iso if status is PostStatus.PUBLISHED else None
|
||||
)
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"INSERT INTO posts"
|
||||
" (slug, title, body_md, body_html_cached, status,"
|
||||
" published_at, updated_at, author_user_id)"
|
||||
" VALUES (:slug, :title, :body_md, :body_html,"
|
||||
" :status, :published_at, :updated_at, :author_id)"
|
||||
),
|
||||
{
|
||||
"slug": unique_slug,
|
||||
"title": clean_title,
|
||||
"body_md": clean_body,
|
||||
"body_html": body_html,
|
||||
"status": status.value,
|
||||
"published_at": published_at_iso,
|
||||
"updated_at": now_iso,
|
||||
"author_id": author_id,
|
||||
},
|
||||
)
|
||||
new_id = int(result.lastrowid) # type: ignore[arg-type]
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" status, published_at, updated_at, author_user_id"
|
||||
" FROM posts WHERE id = :id"
|
||||
),
|
||||
{"id": new_id},
|
||||
).mappings().first()
|
||||
|
||||
if row is None: # pragma: no cover — just inserted
|
||||
raise RuntimeError("failed to reload just-inserted post row")
|
||||
|
||||
post = row_to_post(row)
|
||||
|
||||
self._audit.record(
|
||||
"post_created",
|
||||
user_id=author_id,
|
||||
detail={"post_id": post.id, "slug": post.slug, "status": post.status.value},
|
||||
)
|
||||
if post.status is PostStatus.PUBLISHED:
|
||||
self._audit.record(
|
||||
"post_published",
|
||||
user_id=author_id,
|
||||
detail={"post_id": post.id, "slug": post.slug},
|
||||
)
|
||||
|
||||
self._invalidate_caches()
|
||||
return post
|
||||
|
||||
def update(
|
||||
self,
|
||||
post_id: int,
|
||||
*,
|
||||
title: str,
|
||||
body_md: str,
|
||||
actor_user_id: int,
|
||||
) -> Optional[Post]:
|
||||
"""Update a post's title + body. Return the refreshed :class:`Post`.
|
||||
|
||||
Behavior
|
||||
--------
|
||||
- The slug is NEVER regenerated by an update call. While the
|
||||
post is still a draft the admin may delete + recreate to pick
|
||||
a new slug; once published the slug is permanent per the
|
||||
security contract (external links must not break).
|
||||
- ``author_user_id`` is preserved — this endpoint does not
|
||||
transfer authorship.
|
||||
- ``published_at`` is preserved verbatim. Publishing happens via
|
||||
:meth:`toggle_publish`.
|
||||
- Always re-renders Markdown so ``body_html_cached`` stays in
|
||||
sync with ``body_md``.
|
||||
- Always bumps ``updated_at``.
|
||||
"""
|
||||
existing = self.get_by_id(post_id)
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
clean_title = (title or "").strip()
|
||||
clean_body = body_md or ""
|
||||
body_html = self._markdown.render(clean_body)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE posts"
|
||||
" SET title = :title, body_md = :body_md,"
|
||||
" body_html_cached = :body_html,"
|
||||
" updated_at = :updated_at"
|
||||
" WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"title": clean_title,
|
||||
"body_md": clean_body,
|
||||
"body_html": body_html,
|
||||
"updated_at": now_iso,
|
||||
"id": post_id,
|
||||
},
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
"post_updated",
|
||||
user_id=actor_user_id,
|
||||
detail={"post_id": post_id, "slug": existing.slug},
|
||||
)
|
||||
self._invalidate_caches()
|
||||
return self.get_by_id(post_id)
|
||||
|
||||
def delete(self, post_id: int, *, actor_user_id: int) -> bool:
|
||||
"""Delete a post row. Return True if something was deleted.
|
||||
|
||||
Media rows uploaded during drafting are NOT cleaned up here —
|
||||
uploads aren't linked to posts in the schema, and orphan-sweep
|
||||
is explicitly out of scope per the Phase 4 brief.
|
||||
"""
|
||||
existing = self.get_by_id(post_id)
|
||||
if existing is None:
|
||||
return False
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text("DELETE FROM posts WHERE id = :id"),
|
||||
{"id": post_id},
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
"post_deleted",
|
||||
user_id=actor_user_id,
|
||||
detail={"post_id": post_id, "slug": existing.slug},
|
||||
)
|
||||
self._invalidate_caches()
|
||||
return True
|
||||
|
||||
def toggle_publish(self, post_id: int, *, actor_user_id: int) -> Optional[Post]:
|
||||
"""Flip draft ↔ published. Return the updated post, or ``None``.
|
||||
|
||||
Contract (see Phase 4 brief constraint 7):
|
||||
- Draft → Published: set ``published_at = now`` ONLY if it was
|
||||
previously NULL. If the post was once published, unpublished,
|
||||
and is now being re-published we preserve the original
|
||||
publish timestamp so the public list ordering stays stable.
|
||||
- Published → Draft: status flips, ``published_at`` is preserved.
|
||||
"""
|
||||
existing = self.get_by_id(post_id)
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
if existing.status is PostStatus.PUBLISHED:
|
||||
new_status = PostStatus.DRAFT
|
||||
# Preserve existing published_at on unpublish. No event_type
|
||||
# branch yet — we emit post_unpublished below.
|
||||
published_at_iso: Optional[str] = (
|
||||
existing.published_at.isoformat()
|
||||
if existing.published_at is not None
|
||||
else None
|
||||
)
|
||||
event_type = "post_unpublished"
|
||||
else:
|
||||
new_status = PostStatus.PUBLISHED
|
||||
# First-publish stamp. Preserve any prior published_at so
|
||||
# re-publish doesn't renumber the post on the front page.
|
||||
if existing.published_at is None:
|
||||
published_at_iso = now_iso
|
||||
else:
|
||||
published_at_iso = existing.published_at.isoformat()
|
||||
event_type = "post_published"
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE posts"
|
||||
" SET status = :status,"
|
||||
" published_at = :published_at,"
|
||||
" updated_at = :updated_at"
|
||||
" WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"status": new_status.value,
|
||||
"published_at": published_at_iso,
|
||||
"updated_at": now_iso,
|
||||
"id": post_id,
|
||||
},
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
event_type,
|
||||
user_id=actor_user_id,
|
||||
detail={"post_id": post_id, "slug": existing.slug},
|
||||
)
|
||||
self._invalidate_caches()
|
||||
return self.get_by_id(post_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
def _slug_exists(self, candidate: str) -> bool:
|
||||
"""Return True if a row with ``slug = candidate`` is already present."""
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT 1 FROM posts WHERE slug = :s LIMIT 1"),
|
||||
{"s": candidate},
|
||||
).first()
|
||||
return row is not None
|
||||
|
||||
def _invalidate_caches(self) -> None:
|
||||
"""Drop both the post and page read-side caches.
|
||||
|
||||
Post invalidation is strictly required; page invalidation is
|
||||
defensive — the schemas are separate, but keeping cache
|
||||
invalidation uniform makes it obvious Phase 4 writes never
|
||||
leave a stale public read.
|
||||
"""
|
||||
self._post_service.invalidate_all()
|
||||
self._page_service.invalidate_all()
|
||||
|
||||
|
||||
def get_admin_posts_service(request): # pragma: no cover — trivial
|
||||
"""FastAPI dependency — pull the service off ``app.state``."""
|
||||
return request.app.state.admin_posts_service
|
||||
119
app/services/audit.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Append-only auth audit log service.
|
||||
|
||||
Writes one row per auth event into the ``auth_events`` table. The rest of
|
||||
the auth stack calls :meth:`AuditService.record` to persist a structured,
|
||||
queryable audit trail without having to know the SQL or the row schema.
|
||||
|
||||
Security notes
|
||||
--------------
|
||||
- NEVER pass raw tokens, raw session IDs, or email bodies into ``detail``.
|
||||
Correlate sessions via the last 6 hex chars of their stored hash, never
|
||||
the full hash and never the raw value (CWE-200).
|
||||
- ``detail`` is persisted as JSON text; the schema column is ``TEXT NOT
|
||||
NULL DEFAULT '{}'`` and the writer always provides a valid JSON object.
|
||||
- All writes go through parameterized SQL with ``sqlalchemy.text``
|
||||
``:bind`` parameters; no string interpolation (CWE-89).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""Persist rows into ``auth_events``.
|
||||
|
||||
The service is intentionally tiny: one write method plus a helper
|
||||
fetcher used by tests. No caching (this is an append-only audit
|
||||
log and reads are rare).
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
"""Store the shared SQLAlchemy engine by reference.
|
||||
|
||||
The service never opens its own engine — it reuses the one
|
||||
wired on ``app.state.engine``.
|
||||
"""
|
||||
self._engine: Engine = engine
|
||||
|
||||
def record(
|
||||
self,
|
||||
event_type: str,
|
||||
*,
|
||||
email: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
ip: str = "",
|
||||
user_agent: str = "",
|
||||
detail: Optional[Mapping[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Insert a single audit row.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event_type:
|
||||
One of the Phase 3 event types: ``link_requested``,
|
||||
``link_consumed``, ``consume_failed``, ``session_created``,
|
||||
``session_revoked``, ``rate_limited``.
|
||||
email:
|
||||
Submitted / target email (nullable when the event doesn't
|
||||
have one, e.g. session_revoked where we key off user_id).
|
||||
user_id:
|
||||
Foreign key into ``users``; nullable for pre-auth events.
|
||||
ip:
|
||||
Client IP at time of event. Always captured when available;
|
||||
empty string is acceptable for events originating outside
|
||||
a request context (which Phase 3 does not currently emit,
|
||||
but the column is NOT NULL and we want the door closed).
|
||||
user_agent:
|
||||
Client UA at time of event. Same NOT-NULL rationale as ``ip``.
|
||||
detail:
|
||||
Event-specific structured context (dict-like). Serialized
|
||||
to a compact JSON string. Defaults to ``{}`` when absent.
|
||||
|
||||
NEVER put a raw token or session ID here — only hashes (or
|
||||
their last 6 chars) and other non-sensitive metadata.
|
||||
"""
|
||||
# Always serialize to JSON text; the DB column enforces NOT NULL
|
||||
# with an empty-object default, and we honor that contract here
|
||||
# rather than relying on the default.
|
||||
detail_json = json.dumps(dict(detail) if detail is not None else {})
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO auth_events"
|
||||
" (event_type, email, user_id, ip, user_agent,"
|
||||
" created_at, detail)"
|
||||
" VALUES (:event_type, :email, :user_id, :ip,"
|
||||
" :user_agent, :created_at, :detail)"
|
||||
),
|
||||
{
|
||||
"event_type": event_type,
|
||||
"email": email,
|
||||
"user_id": user_id,
|
||||
"ip": ip or "",
|
||||
"user_agent": user_agent or "",
|
||||
"created_at": now_iso,
|
||||
"detail": detail_json,
|
||||
},
|
||||
)
|
||||
|
||||
# Mirror the audit row to structured logs at INFO. We never log
|
||||
# the raw token / session ID, only the same detail dict (which
|
||||
# the caller already scrubbed) and the non-sensitive envelope.
|
||||
_log.info(
|
||||
"auth_event",
|
||||
event_type=event_type,
|
||||
email=email,
|
||||
user_id=user_id,
|
||||
detail=detail or {},
|
||||
)
|
||||
399
app/services/auth.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""Magic-link auth orchestration.
|
||||
|
||||
Glues token issuance / consumption, user auto-upsert on consume,
|
||||
email delivery, session creation, and audit logging together behind
|
||||
two methods:
|
||||
|
||||
- :meth:`AuthService.request_link` — handle POST /admin/login
|
||||
- :meth:`AuthService.consume` — handle GET /admin/auth/consume/{token}
|
||||
|
||||
Security decisions are concentrated here:
|
||||
|
||||
- Raw tokens live only in memory, the outbound email URL, and the
|
||||
single SHA-256 hash that ends up in the DB.
|
||||
- The allowlist check is ALWAYS performed with lowercased emails.
|
||||
- Non-allowlisted requests receive an identical response shape (handled
|
||||
by the caller; this service just short-circuits the token issue and
|
||||
still audits via ``link_requested`` with ``allowlisted=false``).
|
||||
- Rate limiting has two layers:
|
||||
1. SlowAPI IP-level decorator on the route (outside this module).
|
||||
2. DB-side per-email COUNT inside :meth:`request_link` — returns a
|
||||
sentinel that the route converts into an HTTP 429.
|
||||
- Consume is atomic: an ``UPDATE ... WHERE token_hash=? AND used_at IS
|
||||
NULL AND expires_at > ?`` with a ``rowcount == 1`` guard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.models.entities import Session, User
|
||||
from app.models.mappers import row_to_user
|
||||
from app.services.audit import AuditService
|
||||
from app.services.email import EmailService
|
||||
from app.services.sessions import SessionService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Per-email rate limit: at most 5 tokens issued in a 15-minute window.
|
||||
# This is the DB-backed layer that survives process restarts; the
|
||||
# SlowAPI IP-level decorator on the route is the first line of defense.
|
||||
_PER_EMAIL_WINDOW_MIN: int = 15
|
||||
_PER_EMAIL_MAX: int = 5
|
||||
|
||||
|
||||
def _sha256(raw: str) -> str:
|
||||
"""Return the hex SHA-256 of a raw token.
|
||||
|
||||
SHA-256 for one-way hashing of high-entropy tokens is acceptable
|
||||
(see docs/security.md CWE-327). These tokens are already ≥256 bits
|
||||
from ``secrets.token_urlsafe(32)`` so a KDF is unnecessary.
|
||||
"""
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class RateLimitedError(Exception):
|
||||
"""Raised when a per-email rate limit trips inside the service.
|
||||
|
||||
Surfaced to the route layer so it can emit a 429 + render the
|
||||
``rate_limited.html`` template. Not used for IP-level limits —
|
||||
those are handled by SlowAPI's own exception.
|
||||
"""
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Request-link / consume orchestration for admin auth."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
email: EmailService,
|
||||
sessions: SessionService,
|
||||
audit: AuditService,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""Store collaborators by reference."""
|
||||
self._engine: Engine = engine
|
||||
self._email: EmailService = email
|
||||
self._sessions: SessionService = sessions
|
||||
self._audit: AuditService = audit
|
||||
self._settings: Settings = settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# request_link
|
||||
# ------------------------------------------------------------------
|
||||
def request_link(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
) -> None:
|
||||
"""Handle POST /admin/login for a validated email.
|
||||
|
||||
Behavior
|
||||
--------
|
||||
1. Lowercase the email (allowlist comparison is case-insensitive).
|
||||
2. Check the admin allowlist.
|
||||
3. If NOT allowlisted: audit ``link_requested`` with
|
||||
``allowlisted=false`` and return. No token row, no email.
|
||||
4. If allowlisted: run the DB-side per-email rate-limit check.
|
||||
On trip: audit ``rate_limited`` and raise
|
||||
:class:`RateLimitedError`.
|
||||
5. Otherwise: insert a fresh token row (hash at rest), audit
|
||||
``link_requested`` with ``allowlisted=true``, and call
|
||||
:meth:`EmailService.send_magic_link`.
|
||||
|
||||
Callers MUST render the same "check your inbox" page regardless
|
||||
of the allowlist branch — see the admin route for the
|
||||
anti-enumeration contract.
|
||||
"""
|
||||
email = email.strip().lower()
|
||||
allowlisted = email in self._settings.admin_emails_list
|
||||
|
||||
# Always audit the request — this is the trail that catches
|
||||
# non-allowlisted attempts without leaking that info back to
|
||||
# the submitter.
|
||||
if not allowlisted:
|
||||
self._audit.record(
|
||||
"link_requested",
|
||||
email=email,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"allowlisted": False},
|
||||
)
|
||||
return
|
||||
|
||||
# DB-side per-email rate limit. We run it AFTER the allowlist
|
||||
# check so non-allowlisted spam doesn't cause extra queries.
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(
|
||||
minutes=_PER_EMAIL_WINDOW_MIN
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) AS c FROM magic_link_tokens"
|
||||
" WHERE email = :email AND created_at > :cutoff"
|
||||
),
|
||||
{"email": email, "cutoff": cutoff.isoformat()},
|
||||
).mappings().first()
|
||||
recent_count = int(row["c"]) if row is not None else 0
|
||||
if recent_count >= _PER_EMAIL_MAX:
|
||||
self._audit.record(
|
||||
"rate_limited",
|
||||
email=email,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"scope": "email", "endpoint": "/admin/login"},
|
||||
)
|
||||
raise RateLimitedError("per-email token limit reached")
|
||||
|
||||
# Mint the token — raw lives only in memory + email URL.
|
||||
raw = secrets.token_urlsafe(32)
|
||||
token_hash = _sha256(raw)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(
|
||||
minutes=self._settings.magic_link_ttl_min
|
||||
)
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO magic_link_tokens"
|
||||
" (email, token_hash, created_at, expires_at,"
|
||||
" request_ip)"
|
||||
" VALUES (:email, :token_hash, :created_at,"
|
||||
" :expires_at, :request_ip)"
|
||||
),
|
||||
{
|
||||
"email": email,
|
||||
"token_hash": token_hash,
|
||||
"created_at": now.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"request_ip": ip or "",
|
||||
},
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
"link_requested",
|
||||
email=email,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"allowlisted": True},
|
||||
)
|
||||
|
||||
# Build the magic-link URL. Using a path param (not a query
|
||||
# string) keeps the raw token out of many access-log formats.
|
||||
base = self._settings.public_base_url.rstrip("/")
|
||||
url = f"{base}/admin/auth/consume/{raw}"
|
||||
display_name = email.split("@", 1)[0].title() or email
|
||||
|
||||
# EmailService never raises in dev; in prod it may log an
|
||||
# exception but still return. Either way the request-path
|
||||
# response is identical.
|
||||
self._email.send_magic_link(
|
||||
to=email,
|
||||
url=url,
|
||||
display_name=display_name,
|
||||
ttl_min=self._settings.magic_link_ttl_min,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# consume
|
||||
# ------------------------------------------------------------------
|
||||
def consume(
|
||||
self,
|
||||
*,
|
||||
raw_token: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
) -> Optional[tuple[User, Session, str]]:
|
||||
"""Consume a magic-link token and issue a session.
|
||||
|
||||
Returns ``(user, session, cookie_value)`` on success, ``None``
|
||||
on any invalid / expired / already-used / unknown token (the
|
||||
caller should render the generic failure page with HTTP 400).
|
||||
|
||||
Concurrency safety: the UPDATE statement's WHERE clause is the
|
||||
atomic guard — if two requests race with the same token, only
|
||||
one will get ``rowcount == 1``. The loser is treated as a
|
||||
replay.
|
||||
"""
|
||||
if not raw_token:
|
||||
# Audit runs in its own transaction (see notes below) so we
|
||||
# can record the event without opening a write lock we'd
|
||||
# then try to re-enter from this service.
|
||||
self._audit.record(
|
||||
"consume_failed",
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"reason": "not_found"},
|
||||
)
|
||||
return None
|
||||
|
||||
token_hash = _sha256(raw_token)
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = now.isoformat()
|
||||
|
||||
# ``engine.begin()`` holds a write lock on SQLite for its whole
|
||||
# scope. AuditService opens its OWN transaction and would be
|
||||
# blocked behind this one — so we do not call ``self._audit``
|
||||
# until AFTER this block exits. Track the outcome locally.
|
||||
reloaded = None
|
||||
failure_reason: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
user_id: Optional[int] = None
|
||||
|
||||
# Atomic single-use: only mark the row used if it's still
|
||||
# valid. rowcount tells us whether we were the one to claim it.
|
||||
with self._engine.begin() as conn:
|
||||
update_result = conn.execute(
|
||||
text(
|
||||
"UPDATE magic_link_tokens"
|
||||
" SET used_at = :now"
|
||||
" WHERE token_hash = :h"
|
||||
" AND used_at IS NULL"
|
||||
" AND expires_at > :now"
|
||||
),
|
||||
{"now": now_iso, "h": token_hash},
|
||||
)
|
||||
|
||||
if update_result.rowcount != 1:
|
||||
# Distinguish expired / used / not_found for the audit
|
||||
# trail (not for the client response). Run an extra
|
||||
# SELECT only on the failure path so the happy path
|
||||
# stays a single write.
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT expires_at, used_at FROM magic_link_tokens"
|
||||
" WHERE token_hash = :h LIMIT 1"
|
||||
),
|
||||
{"h": token_hash},
|
||||
).mappings().first()
|
||||
|
||||
if row is None:
|
||||
failure_reason = "not_found"
|
||||
elif row["used_at"] is not None:
|
||||
failure_reason = "used"
|
||||
else:
|
||||
failure_reason = "expired"
|
||||
else:
|
||||
# Token claimed. Look up the email so we can upsert the user.
|
||||
token_row = conn.execute(
|
||||
text(
|
||||
"SELECT email FROM magic_link_tokens"
|
||||
" WHERE token_hash = :h LIMIT 1"
|
||||
),
|
||||
{"h": token_hash},
|
||||
).mappings().first()
|
||||
|
||||
# Should always exist — the UPDATE just wrote to it.
|
||||
if token_row is None: # pragma: no cover — impossible path
|
||||
failure_reason = "not_found"
|
||||
else:
|
||||
email = token_row["email"]
|
||||
|
||||
# Upsert user. Existing row → bump last_login_at and
|
||||
# re-activate; missing row → insert with titlecased
|
||||
# local part as display_name and active=1.
|
||||
user_row = conn.execute(
|
||||
text("SELECT id FROM users WHERE email = :e"),
|
||||
{"e": email},
|
||||
).mappings().first()
|
||||
|
||||
if user_row is None:
|
||||
display_name = email.split("@", 1)[0].title() or email
|
||||
insert_result = conn.execute(
|
||||
text(
|
||||
"INSERT INTO users"
|
||||
" (email, display_name, created_at,"
|
||||
" last_login_at, active)"
|
||||
" VALUES (:email, :display_name, :created_at,"
|
||||
" :last_login_at, 1)"
|
||||
),
|
||||
{
|
||||
"email": email,
|
||||
"display_name": display_name,
|
||||
"created_at": now_iso,
|
||||
"last_login_at": now_iso,
|
||||
},
|
||||
)
|
||||
user_id = int(insert_result.lastrowid) # type: ignore[arg-type]
|
||||
else:
|
||||
user_id = int(user_row["id"])
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE users"
|
||||
" SET last_login_at = :now, active = 1"
|
||||
" WHERE id = :id"
|
||||
),
|
||||
{"now": now_iso, "id": user_id},
|
||||
)
|
||||
|
||||
# Reload the user row so we return a fully-populated
|
||||
# entity.
|
||||
reloaded = conn.execute(
|
||||
text(
|
||||
"SELECT id, email, display_name, created_at,"
|
||||
" last_login_at, active"
|
||||
" FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": user_id},
|
||||
).mappings().first()
|
||||
|
||||
# --- Post-transaction: audit + session create --------------------
|
||||
# Running these AFTER the transaction closes avoids the SQLite
|
||||
# single-writer deadlock that would happen if AuditService tried
|
||||
# to open a new write connection from inside the block above.
|
||||
if failure_reason is not None:
|
||||
self._audit.record(
|
||||
"consume_failed",
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"reason": failure_reason},
|
||||
)
|
||||
return None
|
||||
|
||||
if reloaded is None: # pragma: no cover — impossible path
|
||||
return None
|
||||
user = row_to_user(reloaded)
|
||||
|
||||
# Session creation runs in its own transaction now that the
|
||||
# consume write lock has been released.
|
||||
session, cookie_value = self._sessions.create(
|
||||
user_id=user.id, ip=ip, user_agent=user_agent
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
"link_consumed",
|
||||
email=email,
|
||||
user_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"user_id": user.id},
|
||||
)
|
||||
self._audit.record(
|
||||
"session_created",
|
||||
email=email,
|
||||
user_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
# last 6 hex chars of the stored hash — enough to correlate,
|
||||
# not enough to reverse.
|
||||
detail={
|
||||
"session_id": session.token_hash[-6:],
|
||||
"user_id": user.id,
|
||||
},
|
||||
)
|
||||
|
||||
return user, session, cookie_value
|
||||
88
app/services/cache.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""In-process, generic TTL cache.
|
||||
|
||||
Small, typed, and deliberately boring. Used by :mod:`app.services.posts`
|
||||
and :mod:`app.services.pages` to sit in front of the hottest queries
|
||||
(published-posts list, page-by-slug); a 60 s default TTL keeps the
|
||||
site's three-digit daily requests out of the SQLite query path without
|
||||
any cross-process coordination.
|
||||
|
||||
Not thread-safe in the strict sense — Python's GIL makes the dict
|
||||
operations atomic at CPython bytecode granularity, and worst case a
|
||||
concurrent writer causes a benign duplicate DB read. That is
|
||||
acceptable at this scale; if the site ever grows teeth we can revisit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Generic, Hashable, Optional, TypeVar
|
||||
|
||||
# TypeVar bound to ``Hashable`` so callers cannot accidentally key by a
|
||||
# mutable collection (which would later look up with a different hash
|
||||
# after mutation and silently miss the cache).
|
||||
K = TypeVar("K", bound=Hashable)
|
||||
V = TypeVar("V")
|
||||
|
||||
|
||||
class TTLCache(Generic[K, V]):
|
||||
"""Tiny TTL-based dict-style cache.
|
||||
|
||||
Entries expire ``ttl_seconds`` after insertion. Expired entries
|
||||
are dropped lazily on access — there is no background sweep, and
|
||||
the cache is not bounded in size. For our workload (at most a
|
||||
few dozen keys per instance) this is fine.
|
||||
|
||||
Two operations are public:
|
||||
|
||||
- :meth:`get` returns the cached value or ``None``.
|
||||
- :meth:`set` stores a value with an expiry.
|
||||
- :meth:`invalidate_all` clears every entry; used by admin-write
|
||||
paths in Phase 4.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: float = 60.0) -> None:
|
||||
"""Construct an empty cache.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ttl_seconds:
|
||||
Time-to-live for every entry, in seconds. 60 s matches the
|
||||
"Caching Strategy" section of ``docs/ROADMAP.md``.
|
||||
"""
|
||||
if ttl_seconds <= 0:
|
||||
# Defensive: a zero/negative TTL would mean every write
|
||||
# instantly expires, which almost always indicates a bug.
|
||||
raise ValueError("ttl_seconds must be positive")
|
||||
self._ttl: float = float(ttl_seconds)
|
||||
# Stored as (expiry_monotonic_ts, value). Using
|
||||
# ``time.monotonic`` avoids issues if the wall clock jumps.
|
||||
self._store: dict[K, tuple[float, V]] = {}
|
||||
|
||||
def get(self, key: K) -> Optional[V]:
|
||||
"""Return the cached value for ``key`` or ``None`` if absent/expired.
|
||||
|
||||
Expired entries are deleted as a side effect of the lookup so
|
||||
the store doesn't grow unboundedly with stale data in
|
||||
long-running processes.
|
||||
"""
|
||||
entry = self._store.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
expiry, value = entry
|
||||
if time.monotonic() >= expiry:
|
||||
# Expired — drop lazily and report miss.
|
||||
self._store.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
def set(self, key: K, value: V) -> None:
|
||||
"""Store ``value`` under ``key`` with the configured TTL."""
|
||||
self._store[key] = (time.monotonic() + self._ttl, value)
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Drop every cached entry.
|
||||
|
||||
Called by the Phase 4 admin write path so readers see the new
|
||||
content on the very next request, not up to 60 s later.
|
||||
"""
|
||||
self._store.clear()
|
||||
159
app/services/contact.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Contact-form persistence + notification orchestration.
|
||||
|
||||
Thin service wired at app startup:
|
||||
|
||||
- :meth:`ContactService.record_submission` — insert a row into
|
||||
``contact_submissions`` and return a mapped
|
||||
:class:`ContactSubmission` entity.
|
||||
- :meth:`ContactService.send_notification` — best-effort pass through
|
||||
to :class:`EmailService.send_contact_notification`. NEVER raises.
|
||||
|
||||
The route handler (``/contact``) short-circuits the spam path (honeypot
|
||||
tripped or hCaptcha failed) BEFORE calling either method, so anything
|
||||
that reaches this service has already passed the bot screen. The audit
|
||||
log is written by the route, not here — keeps this service free of
|
||||
request-scoped context (ip/ua are persisted on the row itself).
|
||||
|
||||
Security notes
|
||||
--------------
|
||||
- Parameterized SQL only (sqlalchemy ``text("...:param...")``).
|
||||
- Datetimes are timezone-aware UTC and serialized via ``.isoformat()``,
|
||||
matching the rest of the codebase.
|
||||
- Raw message bodies never flow through logs or audit detail; only
|
||||
``len(message)`` and a short preview (if any) appear in audit rows,
|
||||
and that is the route's responsibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.models.entities import ContactSubmission
|
||||
from app.models.mappers import row_to_contact_submission
|
||||
from app.services.audit import AuditService
|
||||
from app.services.email import EmailService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ContactService:
|
||||
"""Persist contact submissions and trigger notification emails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
email: EmailService,
|
||||
audit: AuditService,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""Store collaborators by reference."""
|
||||
self._engine: Engine = engine
|
||||
self._email: EmailService = email
|
||||
self._audit: AuditService = audit
|
||||
self._settings: Settings = settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# record_submission
|
||||
# ------------------------------------------------------------------
|
||||
def record_submission(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
email: str,
|
||||
message: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
) -> ContactSubmission:
|
||||
"""Insert a ``contact_submissions`` row and return the entity.
|
||||
|
||||
All inputs are taken at face value — validation is the route's
|
||||
responsibility. The mapper guarantees tz-aware UTC on read so
|
||||
callers can safely format ``submitted_at.isoformat()`` in
|
||||
downstream emails.
|
||||
"""
|
||||
submitted_at = datetime.now(timezone.utc)
|
||||
submitted_iso = submitted_at.isoformat()
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"INSERT INTO contact_submissions"
|
||||
" (name, email, message, ip, user_agent,"
|
||||
" submitted_at, handled)"
|
||||
" VALUES (:name, :email, :message, :ip, :user_agent,"
|
||||
" :submitted_at, 0)"
|
||||
),
|
||||
{
|
||||
"name": name,
|
||||
"email": email,
|
||||
"message": message,
|
||||
"ip": ip or "",
|
||||
"user_agent": user_agent or "",
|
||||
"submitted_at": submitted_iso,
|
||||
},
|
||||
)
|
||||
new_id = int(result.lastrowid) # type: ignore[arg-type]
|
||||
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, name, email, message, ip, user_agent,"
|
||||
" submitted_at, handled"
|
||||
" FROM contact_submissions WHERE id = :id"
|
||||
),
|
||||
{"id": new_id},
|
||||
).mappings().first()
|
||||
|
||||
# ``row`` is always present — we just inserted it in the same
|
||||
# transaction — but we type-guard defensively so mypy is happy.
|
||||
if row is None: # pragma: no cover — impossible path
|
||||
raise RuntimeError(
|
||||
"contact_submission row disappeared between insert and select"
|
||||
)
|
||||
return row_to_contact_submission(row)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# send_notification
|
||||
# ------------------------------------------------------------------
|
||||
def send_notification(self, submission: ContactSubmission) -> None:
|
||||
"""Dispatch the admin notification email; never raise.
|
||||
|
||||
Best-effort: any exception is caught and logged under
|
||||
``contact_send_failed`` so the caller can still render the
|
||||
success page. The submission row is already persisted when
|
||||
this method is invoked, so the admin has a DB trail even if
|
||||
the outbound email fails.
|
||||
|
||||
Silently no-ops when ``ADMIN_CONTACT_EMAIL`` is unset. That
|
||||
only happens in dev (production validator requires the field)
|
||||
and is logged for visibility.
|
||||
"""
|
||||
to = self._settings.admin_contact_email
|
||||
if not to:
|
||||
_log.info(
|
||||
"contact_notification_skipped_no_recipient",
|
||||
reason="admin_contact_email_unset",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
self._email.send_contact_notification(
|
||||
to=to,
|
||||
submission_name=submission.name,
|
||||
submission_email=submission.email,
|
||||
message=submission.message,
|
||||
submitted_at=submission.submitted_at,
|
||||
ip=submission.ip,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
# The email service is itself defensive (it swallows Resend
|
||||
# errors internally). This belt-and-braces catch protects
|
||||
# the request path from any unexpected programming error.
|
||||
_log.exception(
|
||||
"contact_send_failed",
|
||||
submission_id=submission.id,
|
||||
)
|
||||
167
app/services/csrf.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""CSRF double-submit cookie service.
|
||||
|
||||
Protects admin-write endpoints against cross-site request forgery by
|
||||
requiring a signed token to be submitted BOTH as a cookie and as a
|
||||
form field / header. An attacker can forge requests but cannot read
|
||||
the cookie (SameSite=Lax blocks cross-site automatic cookie sending,
|
||||
and even if the browser sent it, cross-site JS still cannot read
|
||||
cookies on this origin). Matching the submitted value to the cookie
|
||||
value then proves the request originated from our own pages.
|
||||
|
||||
Design
|
||||
------
|
||||
- The cookie stores a signed opaque nonce. Signing prevents a malicious
|
||||
ad iframe (or any JS on a non-origin page) from producing a cookie
|
||||
value that would later match a crafted form submission.
|
||||
- The nonce itself is 256-bit (``secrets.token_urlsafe(32)``), generated
|
||||
per-browser on first admin GET and reused for the session. Rotating
|
||||
per request would invalidate any still-open admin tab on every nav,
|
||||
which the small-scale admin UX cannot tolerate.
|
||||
- Verification unsigns the submitted token and compares the raw nonce
|
||||
to the raw nonce unsigned from the cookie using :func:`hmac.compare_digest`
|
||||
(constant-time) to foreclose timing side channels.
|
||||
- The cookie is ``HttpOnly=False`` so the minimal admin JS (live
|
||||
preview, upload) can read it to set the ``X-CSRF-Token`` header on
|
||||
fetch requests. This is the conventional double-submit cookie setup
|
||||
— the XSS risk is already mitigated by the Markdown sanitizer and
|
||||
the session cookie remains HttpOnly.
|
||||
|
||||
The service is a small collaborator: it does not know about FastAPI
|
||||
routes, request objects, or templates. The :mod:`app.dependencies.csrf`
|
||||
module wraps the verify call in a FastAPI dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Cookie name kept here as a module-level constant so routes,
|
||||
# dependencies, and templates stay in sync.
|
||||
CSRF_COOKIE_NAME: str = "cb_csrf"
|
||||
|
||||
# Default max age — matches the session TTL ceiling. A valid admin
|
||||
# session already enforces the 30-day cap; the CSRF cookie merely
|
||||
# piggybacks.
|
||||
_DEFAULT_MAX_AGE_SEC: int = 30 * 86400
|
||||
|
||||
|
||||
class CSRFService:
|
||||
"""Issue and verify double-submit CSRF tokens.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
signer:
|
||||
Pre-built :class:`itsdangerous.URLSafeTimedSerializer`. The
|
||||
caller is responsible for constructing it with
|
||||
``salt="csrf"`` so a session-cookie token can never be
|
||||
replayed as a CSRF token and vice-versa.
|
||||
production:
|
||||
When True, the issued cookie carries the ``Secure`` flag. Dev
|
||||
(plain-HTTP 127.0.0.1) needs it off or the browser drops the
|
||||
cookie entirely.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
signer: URLSafeTimedSerializer,
|
||||
*,
|
||||
production: bool = False,
|
||||
max_age_sec: int = _DEFAULT_MAX_AGE_SEC,
|
||||
) -> None:
|
||||
"""Store the signer and cookie-policy flags by reference."""
|
||||
self._signer: URLSafeTimedSerializer = signer
|
||||
self._production: bool = production
|
||||
self._max_age_sec: int = int(max_age_sec)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Issue
|
||||
# ------------------------------------------------------------------
|
||||
def issue(self, existing_cookie: Optional[str] = None) -> tuple[str, str]:
|
||||
"""Return ``(token, cookie_value)`` — reuse or mint as appropriate.
|
||||
|
||||
If ``existing_cookie`` is a valid signed nonce (still within
|
||||
TTL), we reuse the underlying nonce so the same token keeps
|
||||
working across GET / POST cycles in the same admin session.
|
||||
Otherwise we mint a fresh nonce.
|
||||
|
||||
The cookie value and the form/header token value are the SAME
|
||||
signed string — this is the "double submit" contract. The
|
||||
verify path re-signs nothing; it just compares the unsigned
|
||||
raw nonces.
|
||||
"""
|
||||
raw = self._unsign_or_none(existing_cookie)
|
||||
if raw is None:
|
||||
raw = secrets.token_urlsafe(32)
|
||||
signed = self._signer.dumps(raw)
|
||||
# Token and cookie are both the signed string. Callers are free
|
||||
# to submit either in a form field OR a header; verify accepts
|
||||
# both shapes.
|
||||
return signed, signed
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Verify
|
||||
# ------------------------------------------------------------------
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
cookie_value: Optional[str],
|
||||
submitted: Optional[str],
|
||||
) -> bool:
|
||||
"""Return True iff cookie + submitted token unseal to the same nonce.
|
||||
|
||||
Both strings must unsign cleanly; a bad signature (tampered or
|
||||
wrong-key) on either side fails closed. Constant-time compare
|
||||
on the raw nonces prevents timing leaks of the nonce bytes.
|
||||
"""
|
||||
if not cookie_value or not submitted:
|
||||
return False
|
||||
cookie_raw = self._unsign_or_none(cookie_value)
|
||||
submitted_raw = self._unsign_or_none(submitted)
|
||||
if cookie_raw is None or submitted_raw is None:
|
||||
return False
|
||||
return hmac.compare_digest(cookie_raw, submitted_raw)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cookie helpers
|
||||
# ------------------------------------------------------------------
|
||||
def cookie_params(self) -> dict:
|
||||
"""Return kwargs for ``response.set_cookie`` matching our CSRF policy.
|
||||
|
||||
Differences from :meth:`SessionService.cookie_params`:
|
||||
- ``httponly=False`` so the admin JS can read it for fetch
|
||||
requests.
|
||||
- Same ``SameSite=Lax`` + ``Secure=<prod>`` otherwise.
|
||||
"""
|
||||
return {
|
||||
"key": CSRF_COOKIE_NAME,
|
||||
"httponly": False,
|
||||
"samesite": "lax",
|
||||
"secure": self._production,
|
||||
"max_age": self._max_age_sec,
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
def _unsign_or_none(self, value: Optional[str]) -> Optional[str]:
|
||||
"""Return the raw nonce, or ``None`` on any signature failure.
|
||||
|
||||
Centralizes the "fail closed" contract; never raises to callers.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return self._signer.loads(value, max_age=self._max_age_sec)
|
||||
except BadSignature:
|
||||
_log.info("csrf_bad_signature")
|
||||
return None
|
||||
219
app/services/email.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Transactional email sender with a dev-mode log fallback.
|
||||
|
||||
Thin wrapper around the Resend API (``resend.Emails.send``). Renders
|
||||
both HTML and plaintext magic-link bodies from Jinja templates to keep
|
||||
copy out of Python code.
|
||||
|
||||
Security and UX rules
|
||||
---------------------
|
||||
- **Never raise on missing credentials in development.** A 500 from the
|
||||
login POST would expose whether an email is on the allowlist
|
||||
(successful sends would succeed, non-allowlisted "sends" would still
|
||||
short-circuit) and it would also break local dev. In development we
|
||||
log a ``magic_link_dev_fallback`` structured event with the full
|
||||
magic-link URL so the developer can copy it.
|
||||
- **Production must fail at startup** if Resend credentials are absent;
|
||||
that validator lives in :class:`app.config.Settings`, not here.
|
||||
- Never log the raw token on its own — only as part of the URL in the
|
||||
dev fallback (which is the whole point of the fallback).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Send magic-link emails via Resend, with a dev-mode log fallback."""
|
||||
|
||||
def __init__(self, settings: Settings, templates: Jinja2Templates) -> None:
|
||||
"""Store dependencies by reference.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
settings:
|
||||
Application settings; used to pick up ``resend_api_key`` /
|
||||
``resend_from`` / ``app_env`` at send time so rotating them
|
||||
at runtime (dev) works.
|
||||
templates:
|
||||
Shared Jinja2 environment. We reuse the app-level one so
|
||||
template autoescape defaults and the search path match the
|
||||
rest of the site.
|
||||
"""
|
||||
self._settings: Settings = settings
|
||||
self._templates: Jinja2Templates = templates
|
||||
|
||||
def send_contact_notification(
|
||||
self,
|
||||
*,
|
||||
to: str,
|
||||
submission_name: str,
|
||||
submission_email: str,
|
||||
message: str,
|
||||
submitted_at: datetime,
|
||||
ip: str,
|
||||
) -> None:
|
||||
"""Send the contact-form notification email to the admin inbox.
|
||||
|
||||
Behavior
|
||||
--------
|
||||
- If ``settings.resend_api_key`` (or ``resend_from``) is falsy,
|
||||
log a ``contact_notification_dev_fallback`` event at INFO and
|
||||
return. Dev convenience only — the production validator
|
||||
refuses to boot without a Resend key.
|
||||
- Otherwise render both template bodies, set ``Reply-To`` to the
|
||||
submitter's email (so Head Hen just hits reply), and dispatch
|
||||
via Resend. Transport errors are logged (``contact_notification_failed``)
|
||||
and never re-raised — the request path must always complete
|
||||
with the generic success page.
|
||||
|
||||
Subject format (fixed) matches the Phase 5 brief:
|
||||
``"New contact submission from {submission_name}"``.
|
||||
"""
|
||||
ctx = {
|
||||
"submission_name": submission_name,
|
||||
"submission_email": submission_email,
|
||||
"message": message,
|
||||
"submitted_at": submitted_at.isoformat(),
|
||||
"ip": ip or "",
|
||||
}
|
||||
html_body = self._render("emails/contact_notification.html", ctx)
|
||||
text_body = self._render("emails/contact_notification.txt", ctx)
|
||||
|
||||
api_key: Optional[str] = self._settings.resend_api_key
|
||||
sender: Optional[str] = self._settings.resend_from
|
||||
|
||||
# Dev fallback — log enough to confirm the flow reached the
|
||||
# email layer without echoing the whole message body at INFO.
|
||||
if not api_key or not sender:
|
||||
_log.info(
|
||||
"contact_notification_dev_fallback",
|
||||
to=to,
|
||||
submission_name=submission_name,
|
||||
submission_email=submission_email,
|
||||
message_length=len(message or ""),
|
||||
)
|
||||
return
|
||||
|
||||
subject = f"New contact submission from {submission_name}"
|
||||
try:
|
||||
import resend # type: ignore[import-untyped]
|
||||
|
||||
resend.api_key = api_key
|
||||
resend.Emails.send(
|
||||
{
|
||||
"from": sender,
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"html": html_body,
|
||||
"text": text_body,
|
||||
# Reply-To lets Head Hen reply directly from her
|
||||
# inbox without exposing the From: address to
|
||||
# public rewriting.
|
||||
"reply_to": submission_email,
|
||||
}
|
||||
)
|
||||
_log.info("contact_notification_sent", to=to)
|
||||
except Exception: # noqa: BLE001
|
||||
# Never raise from the request path — see docstring.
|
||||
_log.exception(
|
||||
"contact_notification_failed",
|
||||
to=to,
|
||||
)
|
||||
|
||||
def send_magic_link(
|
||||
self,
|
||||
*,
|
||||
to: str,
|
||||
url: str,
|
||||
display_name: str,
|
||||
ttl_min: int,
|
||||
expires_at: datetime,
|
||||
) -> None:
|
||||
"""Send a magic-link email to ``to`` or log the URL in dev.
|
||||
|
||||
Behavior
|
||||
--------
|
||||
- If ``settings.resend_api_key`` is truthy, render both bodies
|
||||
and send via Resend.
|
||||
- Otherwise (development only — the production config validator
|
||||
refuses to boot without a key), emit a structured log event
|
||||
``magic_link_dev_fallback`` that includes the full URL.
|
||||
|
||||
Never raises in the dev fallback path; errors from the Resend
|
||||
API surface as logged exceptions so the request-handler layer
|
||||
always returns the same response shape regardless of whether
|
||||
the email was actually sent (CWE-200 / anti-enumeration).
|
||||
"""
|
||||
# Build both template bodies first so any rendering error
|
||||
# surfaces before we talk to the network.
|
||||
ctx = {
|
||||
"display_name": display_name,
|
||||
"magic_link_url": url,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"ttl_min": ttl_min,
|
||||
}
|
||||
html_body = self._render("emails/magic_link.html", ctx)
|
||||
text_body = self._render("emails/magic_link.txt", ctx)
|
||||
|
||||
api_key: Optional[str] = self._settings.resend_api_key
|
||||
sender: Optional[str] = self._settings.resend_from
|
||||
|
||||
# Dev fallback path: no key configured. Log the URL at INFO so
|
||||
# the developer can complete the flow, and return.
|
||||
if not api_key or not sender:
|
||||
_log.info(
|
||||
"magic_link_dev_fallback",
|
||||
to=to,
|
||||
# Raw token is embedded in the URL; acceptable because
|
||||
# this path ONLY runs in local dev (production validator
|
||||
# refuses to boot without RESEND_API_KEY).
|
||||
magic_link_url=url,
|
||||
ttl_min=ttl_min,
|
||||
)
|
||||
return
|
||||
|
||||
# Real send. We import here to avoid taking a hard import-time
|
||||
# dependency on `resend`'s module-level state during tests that
|
||||
# never exercise the send path.
|
||||
try:
|
||||
import resend # type: ignore[import-untyped]
|
||||
|
||||
resend.api_key = api_key
|
||||
resend.Emails.send(
|
||||
{
|
||||
"from": sender,
|
||||
"to": to,
|
||||
"subject": "Your Chicken Babies R Us admin login link",
|
||||
"html": html_body,
|
||||
"text": text_body,
|
||||
}
|
||||
)
|
||||
_log.info("magic_link_email_sent", to=to)
|
||||
except Exception: # noqa: BLE001
|
||||
# Do NOT re-raise from the request path — see anti-enumeration
|
||||
# note at module top. Log with redacted context.
|
||||
_log.exception("magic_link_email_failed", to=to)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
def _render(self, template_name: str, context: dict) -> str:
|
||||
"""Render a Jinja template to a string.
|
||||
|
||||
We use the underlying Jinja environment directly so we get a
|
||||
plain string back (``Jinja2Templates.TemplateResponse`` wraps
|
||||
the output in an HTTP response, which is not what we want for
|
||||
outbound email bodies).
|
||||
"""
|
||||
template = self._templates.env.get_template(template_name)
|
||||
return template.render(**context)
|
||||
132
app/services/hcaptcha.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""hCaptcha server-side verification.
|
||||
|
||||
Wraps the hCaptcha ``siteverify`` endpoint with a dev-mode bypass so
|
||||
local work does not require a real site-key / secret pair.
|
||||
|
||||
Security and UX rules
|
||||
---------------------
|
||||
- **Never raise from the request path.** The caller (``/contact``)
|
||||
treats a ``False`` return as "reject the submission as spam" and a
|
||||
``True`` return as "continue to validation". Any network hiccup /
|
||||
non-200 / malformed-JSON surfaces as ``False`` — it is safer to drop
|
||||
a legitimate visitor's submission than to accept a spam flood if
|
||||
hCaptcha is temporarily unavailable.
|
||||
- **Dev fallback:** when ``settings.hcaptcha_secret`` is falsy we log a
|
||||
structured ``hcaptcha_dev_fallback`` event at INFO and return
|
||||
``True``. The production config validator refuses to boot without a
|
||||
secret, so this path only runs locally.
|
||||
- Raw tokens are single-use, short-lived, and bound to the submitter's
|
||||
session — we do not persist or log them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
# hCaptcha's server-side verify endpoint. Documented at
|
||||
# https://docs.hcaptcha.com/#verify-the-user-response-server-side.
|
||||
_SITEVERIFY_URL: str = "https://hcaptcha.com/siteverify"
|
||||
|
||||
# Bounded network timeout. hCaptcha's own advice is 5s; longer would
|
||||
# block the request path unnecessarily under an incident.
|
||||
_TIMEOUT_SECONDS: float = 5.0
|
||||
|
||||
|
||||
class HCaptchaService:
|
||||
"""Verify hCaptcha responses server-side.
|
||||
|
||||
Usage
|
||||
-----
|
||||
``await hcaptcha_service.verify(token, remote_ip)`` returns a bool.
|
||||
``True`` means "treat the request as human"; ``False`` means "reject".
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Store settings by reference so rotation at runtime works."""
|
||||
self._settings: Settings = settings
|
||||
|
||||
async def verify(self, token: str, remote_ip: str) -> bool:
|
||||
"""Verify ``token`` against hCaptcha; return True on success.
|
||||
|
||||
Behavior
|
||||
--------
|
||||
- If ``settings.hcaptcha_secret`` is falsy → log
|
||||
``hcaptcha_dev_fallback`` and return ``True`` (dev).
|
||||
- Otherwise POST to ``/siteverify`` with a 5s timeout.
|
||||
- Non-200, network error, malformed JSON, or ``success=False``
|
||||
all return ``False``.
|
||||
|
||||
The method never raises — errors are logged and converted to
|
||||
``False`` so the caller can render the generic thank-you page
|
||||
without leaking internal state.
|
||||
"""
|
||||
secret: Optional[str] = self._settings.hcaptcha_secret
|
||||
if not secret:
|
||||
# Dev bypass. The config validator forbids this in
|
||||
# production, so reaching this branch is always a dev/test
|
||||
# condition and safe to trust.
|
||||
_log.info("hcaptcha_dev_fallback", remote_ip=remote_ip or "")
|
||||
return True
|
||||
|
||||
payload = {
|
||||
"secret": secret,
|
||||
"response": token or "",
|
||||
"remoteip": remote_ip or "",
|
||||
}
|
||||
|
||||
data = await self._post_siteverify(payload)
|
||||
if data is None:
|
||||
# Timeout / transport error / non-200 / parse failure —
|
||||
# already logged by the helper. Treat as spam.
|
||||
return False
|
||||
|
||||
success = bool(data.get("success", False))
|
||||
if not success:
|
||||
# Log error codes if present so operators can diagnose
|
||||
# misconfigured keys without exposing the token.
|
||||
_log.info(
|
||||
"hcaptcha_verify_failed",
|
||||
error_codes=list(data.get("error-codes") or []),
|
||||
)
|
||||
return success
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
async def _post_siteverify(self, payload: dict) -> Optional[dict[str, Any]]:
|
||||
"""POST to hCaptcha's verify endpoint and return parsed JSON.
|
||||
|
||||
Returns ``None`` on any failure (timeout, non-200, transport
|
||||
error, malformed JSON). Kept separate from :meth:`verify` so
|
||||
tests can monkeypatch the HTTP boundary without touching the
|
||||
decision logic.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client:
|
||||
resp = await client.post(_SITEVERIFY_URL, data=payload)
|
||||
except httpx.HTTPError:
|
||||
# Network error / timeout. Do not raise; do not log the
|
||||
# payload (contains the secret).
|
||||
_log.exception("hcaptcha_request_failed")
|
||||
return None
|
||||
|
||||
if resp.status_code != 200:
|
||||
_log.info(
|
||||
"hcaptcha_non_200",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
_log.exception("hcaptcha_malformed_json")
|
||||
return None
|
||||
125
app/services/markdown.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Markdown rendering with a strict sanitization allowlist.
|
||||
|
||||
CWE-79 mitigation: user-authored Markdown is first rendered to HTML by
|
||||
``markdown-it-py`` (commonmark profile + tables only, no raw-HTML pass
|
||||
through), then the resulting HTML is filtered by ``bleach`` against an
|
||||
explicit tag / attribute / protocol allowlist. Anything not on the
|
||||
list is stripped — never escaped — so the stored ``body_html_cached``
|
||||
is always safe to render inside an ``autoescape=False`` Jinja block.
|
||||
|
||||
The pipeline runs both on admin writes (Phase 4) and at seed time
|
||||
(Phase 2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
import bleach
|
||||
from markdown_it import MarkdownIt
|
||||
|
||||
# --- Sanitization allowlist ------------------------------------------------
|
||||
# Kept at module scope as frozenset / mappingproxy-esque constants so
|
||||
# tests can assert against them and downstream callers cannot mutate by
|
||||
# accident. Do not widen without a security review; in particular:
|
||||
#
|
||||
# - No ``style`` or ``class`` attributes (CSS injection / theme attack
|
||||
# surface for future admin UIs).
|
||||
# - No ``script``, ``iframe``, ``object``, ``embed``, ``form``, etc.
|
||||
# - No ``data:`` / ``javascript:`` protocols.
|
||||
_ALLOWED_TAGS: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"em",
|
||||
"a",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"blockquote",
|
||||
"code",
|
||||
"pre",
|
||||
"img",
|
||||
"hr",
|
||||
}
|
||||
)
|
||||
|
||||
_ALLOWED_ATTRS: Final[dict[str, list[str]]] = {
|
||||
"a": ["href", "title", "rel"],
|
||||
"img": ["src", "alt", "title", "width", "height"],
|
||||
}
|
||||
|
||||
_ALLOWED_PROTOCOLS: Final[frozenset[str]] = frozenset(
|
||||
{"http", "https", "mailto"}
|
||||
)
|
||||
|
||||
|
||||
class MarkdownService:
|
||||
"""Render Markdown to HTML, then sanitize against the allowlist.
|
||||
|
||||
One ``MarkdownIt`` instance per service instance — creating these
|
||||
is cheap but non-trivial, so we reuse. The service is stateless
|
||||
aside from that configuration; ``render`` is safe to call
|
||||
concurrently.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Configure the Markdown parser.
|
||||
|
||||
- ``commonmark`` preset: conservative, no raw HTML pass
|
||||
through by default. We explicitly do NOT call
|
||||
``.enable("html_inline")`` or ``.enable("html_block")``;
|
||||
raw HTML in the source will be rendered as escaped text,
|
||||
which is the safe failure mode.
|
||||
- Tables are intentionally not enabled: the bleach allowlist
|
||||
does not include ``<table>``, so enabling the plugin would
|
||||
just produce content stripped of its tags. If we ever want
|
||||
tables, both sides (parser + allowlist) need widening
|
||||
together.
|
||||
"""
|
||||
self._md: MarkdownIt = MarkdownIt("commonmark")
|
||||
|
||||
def render(self, md: str) -> str:
|
||||
"""Render ``md`` to sanitized HTML.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
md:
|
||||
Markdown source, typically from an admin edit form or a
|
||||
seed file. Treated as untrusted.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
HTML safe to render with Jinja autoescape disabled. The
|
||||
output contains only tags / attributes / protocols from
|
||||
the module-level allowlists; anything else is stripped
|
||||
(``strip=True``) rather than escaped.
|
||||
"""
|
||||
raw_html = self._md.render(md)
|
||||
# ``strip=True`` removes disallowed tags entirely (drops the
|
||||
# tag but keeps text content). This is a deliberate choice
|
||||
# over ``strip=False``, which would escape disallowed tags
|
||||
# into literal text — ugly for users.
|
||||
return bleach.clean(
|
||||
raw_html,
|
||||
tags=_ALLOWED_TAGS,
|
||||
attributes=_ALLOWED_ATTRS,
|
||||
protocols=_ALLOWED_PROTOCOLS,
|
||||
strip=True,
|
||||
)
|
||||
|
||||
|
||||
def render_markdown_safe(md: str) -> str:
|
||||
"""Module-level convenience for one-off rendering.
|
||||
|
||||
Creates a throwaway :class:`MarkdownService` — fine for rare
|
||||
callers (tests, seed). Hot paths should construct and cache an
|
||||
instance.
|
||||
"""
|
||||
return MarkdownService().render(md)
|
||||
323
app/services/media.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""Image upload pipeline: validate → re-encode → store → record.
|
||||
|
||||
Every admin image upload passes through this service. The contract is
|
||||
strict on purpose — the site serves user-editable HTML (via the
|
||||
sanitizer) plus the bytes that flow through here, so anything we miss
|
||||
becomes XSS / RCE surface area.
|
||||
|
||||
Steps in :meth:`MediaService.save_upload`:
|
||||
|
||||
1. **Size cap** — reject anything over 8 MB at the bytes level
|
||||
(before decoding). We read the full buffer so we can hash and
|
||||
re-encode it; streaming would complicate Pillow's decode path and
|
||||
upload volumes are tiny.
|
||||
2. **Magic-byte check** — :mod:`python-magic` inspects the first
|
||||
2048 bytes and yields a MIME type. Anything not in our allowlist
|
||||
(``image/jpeg``, ``image/png``, ``image/webp``) is rejected.
|
||||
Notably, ``image/gif`` is NOT allowed — animated GIFs have a long
|
||||
history of ambiguous / abuse-friendly encodings.
|
||||
3. **Pillow decode** — open via :func:`PIL.Image.open` on a
|
||||
:class:`io.BytesIO` wrapper. Call ``.verify()`` on a dedicated copy
|
||||
(it consumes the stream), then re-open for the actual encode path.
|
||||
Reject anything larger than 10000 px per side as a defense against
|
||||
decompression bombs.
|
||||
4. **Re-encode to JPEG** — always JPEG. Strip metadata by reopening
|
||||
into a clean :class:`PIL.Image.Image`; flatten alpha on a white
|
||||
background so transparent PNG / WebP images don't render as black.
|
||||
5. **Store** — write to ``<media_root>/<yyyy>/<mm>/<random>.jpg`` where
|
||||
the random component is :func:`secrets.token_urlsafe(16)`. The
|
||||
client-supplied filename is kept only in the DB row's
|
||||
``original_filename`` for display; it is NEVER used to build a
|
||||
filesystem path.
|
||||
6. **DB row** — insert a :class:`Media` row. Return the loaded
|
||||
dataclass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Final, Optional
|
||||
|
||||
import structlog
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import Media
|
||||
from app.models.mappers import row_to_media
|
||||
from app.services.audit import AuditService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Upper bound on the raw upload bytes. 8 MB matches the project
|
||||
# security constraint; larger images are almost certainly a mistake
|
||||
# for a brochure-site blog.
|
||||
MAX_UPLOAD_BYTES: Final[int] = 8 * 1024 * 1024
|
||||
|
||||
# Maximum decoded dimension — reject any image wider or taller than
|
||||
# this as a lightweight defense against decompression bombs.
|
||||
MAX_PIXEL_DIMENSION: Final[int] = 10_000
|
||||
|
||||
# MIME types accepted from the magic-byte sniff. We always re-encode
|
||||
# to JPEG regardless of input.
|
||||
_ACCEPTED_MIME: Final[frozenset[str]] = frozenset(
|
||||
{"image/jpeg", "image/png", "image/webp"}
|
||||
)
|
||||
|
||||
# Output quality for Pillow's JPEG encoder. 85 is a widely-used
|
||||
# sweet spot for photograph-like content.
|
||||
_JPEG_QUALITY: Final[int] = 85
|
||||
|
||||
|
||||
class MediaRejectedError(Exception):
|
||||
"""Raised when an upload fails any validation step.
|
||||
|
||||
The message is user-facing (shown in the admin editor) — keep it
|
||||
generic and free of implementation detail.
|
||||
"""
|
||||
|
||||
|
||||
class MediaService:
|
||||
"""Validate and store admin-uploaded images.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
Shared SQLAlchemy engine.
|
||||
media_root:
|
||||
Filesystem directory under which uploads live (the
|
||||
``<yyyy>/<mm>/`` partition is appended). Relative paths are
|
||||
resolved against the process cwd, matching how the FastAPI
|
||||
StaticFiles mount is configured.
|
||||
public_prefix:
|
||||
URL prefix where the media root is mounted for public serving.
|
||||
Defaults to ``/media`` so the Markdown that the admin inserts
|
||||
after a drag-drop upload uses a path the public site can
|
||||
actually reach.
|
||||
audit:
|
||||
:class:`AuditService` for the ``media_uploaded`` event.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
media_root: str,
|
||||
audit: AuditService,
|
||||
*,
|
||||
public_prefix: str = "/media",
|
||||
) -> None:
|
||||
self._engine: Engine = engine
|
||||
self._media_root: Path = Path(media_root)
|
||||
# Normalize to no trailing slash — we always join with "/<yyyy>/..."
|
||||
self._public_prefix: str = "/" + public_prefix.strip("/")
|
||||
self._audit: AuditService = audit
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# save_upload
|
||||
# ------------------------------------------------------------------
|
||||
def save_upload(
|
||||
self,
|
||||
*,
|
||||
original_filename: str,
|
||||
data: bytes,
|
||||
uploaded_by: int,
|
||||
alt_text: str = "",
|
||||
) -> Media:
|
||||
"""Validate + re-encode + persist a new media upload.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
original_filename:
|
||||
The filename the client submitted. Stored in the DB row
|
||||
for display only; NEVER used to build a filesystem path.
|
||||
data:
|
||||
Raw request body. Must be at most :data:`MAX_UPLOAD_BYTES`.
|
||||
uploaded_by:
|
||||
:class:`User` id of the authenticated admin performing the
|
||||
upload.
|
||||
alt_text:
|
||||
Optional alt text. Empty is allowed — admin can set it
|
||||
later by hand-editing the Markdown.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Media
|
||||
Fully-populated :class:`Media` dataclass.
|
||||
|
||||
Raises
|
||||
------
|
||||
MediaRejectedError
|
||||
When any validation step fails (size, MIME, decode).
|
||||
"""
|
||||
# 1. Size cap — cheap, do first.
|
||||
if len(data) == 0:
|
||||
raise MediaRejectedError("Empty upload.")
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise MediaRejectedError(
|
||||
"Upload exceeds the 8 MB limit."
|
||||
)
|
||||
|
||||
# 2. Magic-byte sniff.
|
||||
sniffed_mime = _sniff_mime(data)
|
||||
if sniffed_mime not in _ACCEPTED_MIME:
|
||||
raise MediaRejectedError(
|
||||
f"Unsupported image type ({sniffed_mime})."
|
||||
)
|
||||
|
||||
# 3. Pillow verify on a fresh BytesIO (verify consumes the
|
||||
# stream). If this raises we swallow and translate to a generic
|
||||
# rejection so we never echo the Pillow error string back to
|
||||
# the admin UI.
|
||||
try:
|
||||
Image.open(io.BytesIO(data)).verify()
|
||||
except (UnidentifiedImageError, Exception): # noqa: BLE001
|
||||
raise MediaRejectedError("Image could not be decoded.")
|
||||
|
||||
# 4. Re-open for the actual encode.
|
||||
try:
|
||||
image = Image.open(io.BytesIO(data))
|
||||
# Load here so we catch truncated / corrupt payloads that
|
||||
# verify() misses. Without load() the decode is lazy.
|
||||
image.load()
|
||||
except (UnidentifiedImageError, Exception): # noqa: BLE001
|
||||
raise MediaRejectedError("Image could not be decoded.")
|
||||
|
||||
width, height = image.size
|
||||
if width <= 0 or height <= 0:
|
||||
raise MediaRejectedError("Image has zero dimension.")
|
||||
if width > MAX_PIXEL_DIMENSION or height > MAX_PIXEL_DIMENSION:
|
||||
raise MediaRejectedError(
|
||||
"Image dimensions exceed the maximum allowed."
|
||||
)
|
||||
|
||||
# Flatten transparency onto a white background when present.
|
||||
# Pillow uses "RGBA", "LA", and "P" (palette, possibly with
|
||||
# transparency) as modes that carry alpha-like semantics. We
|
||||
# always convert to "RGB" before encoding as JPEG.
|
||||
if image.mode in ("RGBA", "LA") or (
|
||||
image.mode == "P" and "transparency" in image.info
|
||||
):
|
||||
# Convert through RGBA so alpha-compositing is well-defined,
|
||||
# then flatten onto a white RGB background.
|
||||
rgba = image.convert("RGBA")
|
||||
background = Image.new("RGB", rgba.size, (255, 255, 255))
|
||||
background.paste(rgba, mask=rgba.split()[-1])
|
||||
image_out = background
|
||||
elif image.mode != "RGB":
|
||||
image_out = image.convert("RGB")
|
||||
else:
|
||||
image_out = image
|
||||
|
||||
# 5. Randomize the storage name and partition by month.
|
||||
now = datetime.now(timezone.utc)
|
||||
partition = f"{now:%Y}/{now:%m}"
|
||||
random_name = f"{secrets.token_urlsafe(16)}.jpg"
|
||||
target_dir = self._media_root / partition
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_path = target_dir / random_name
|
||||
|
||||
# Re-encode to JPEG with the metadata stripped (a fresh
|
||||
# re-save removes any EXIF / color profile the source had).
|
||||
image_out.save(
|
||||
target_path,
|
||||
format="JPEG",
|
||||
quality=_JPEG_QUALITY,
|
||||
optimize=True,
|
||||
)
|
||||
|
||||
final_bytes = target_path.stat().st_size
|
||||
stored_path = str(target_path)
|
||||
|
||||
# 6. DB row.
|
||||
now_iso = now.isoformat()
|
||||
with self._engine.begin() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"INSERT INTO media"
|
||||
" (filename, original_filename, content_type,"
|
||||
" size_bytes, stored_path, alt_text, uploaded_by,"
|
||||
" uploaded_at)"
|
||||
" VALUES (:filename, :original_filename, :content_type,"
|
||||
" :size_bytes, :stored_path, :alt_text, :uploaded_by,"
|
||||
" :uploaded_at)"
|
||||
),
|
||||
{
|
||||
"filename": random_name,
|
||||
"original_filename": original_filename or random_name,
|
||||
"content_type": "image/jpeg",
|
||||
"size_bytes": int(final_bytes),
|
||||
"stored_path": stored_path,
|
||||
"alt_text": alt_text or "",
|
||||
"uploaded_by": int(uploaded_by),
|
||||
"uploaded_at": now_iso,
|
||||
},
|
||||
)
|
||||
new_id = int(result.lastrowid) # type: ignore[arg-type]
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, filename, original_filename, content_type,"
|
||||
" size_bytes, stored_path, alt_text, uploaded_by,"
|
||||
" uploaded_at"
|
||||
" FROM media WHERE id = :id"
|
||||
),
|
||||
{"id": new_id},
|
||||
).mappings().first()
|
||||
|
||||
if row is None: # pragma: no cover — just inserted
|
||||
raise RuntimeError("failed to reload just-inserted media row")
|
||||
|
||||
media = row_to_media(row)
|
||||
|
||||
self._audit.record(
|
||||
"media_uploaded",
|
||||
user_id=uploaded_by,
|
||||
detail={
|
||||
"media_id": media.id,
|
||||
"filename": media.filename,
|
||||
"size_bytes": media.size_bytes,
|
||||
"original_mime": sniffed_mime,
|
||||
},
|
||||
)
|
||||
return media
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# URL helpers
|
||||
# ------------------------------------------------------------------
|
||||
def public_url(self, media: Media) -> str:
|
||||
"""Return the URL the public site uses to fetch ``media``.
|
||||
|
||||
Built from the configured ``public_prefix`` + the partition
|
||||
under ``media_root``. A stored path outside the media root
|
||||
(should never happen — we always write under it) falls back
|
||||
to the partition-less prefix to avoid leaking filesystem
|
||||
paths.
|
||||
"""
|
||||
try:
|
||||
rel = Path(media.stored_path).resolve().relative_to(
|
||||
self._media_root.resolve()
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
return f"{self._public_prefix}/{media.filename}"
|
||||
return f"{self._public_prefix}/{rel.as_posix()}"
|
||||
|
||||
|
||||
def _sniff_mime(data: bytes) -> str:
|
||||
"""Return the MIME type of ``data`` according to python-magic.
|
||||
|
||||
Wrapped so tests that monkeypatch can reach a single seam, and so
|
||||
the import of :mod:`magic` stays local (the module has a
|
||||
filesystem dependency on libmagic that should not block app
|
||||
import).
|
||||
"""
|
||||
# Import is module-level normally; keep here to avoid any import
|
||||
# order weirdness if libmagic is missing in exotic environments.
|
||||
import magic
|
||||
|
||||
# First 2 KB is well beyond what any image header uses, and
|
||||
# streaming beyond that buys nothing for MIME sniffing.
|
||||
head = data[:2048]
|
||||
return magic.from_buffer(head, mime=True)
|
||||
101
app/services/pages.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Static-page read service (About, etc.).
|
||||
|
||||
Wraps the ``pages`` table with a 60 s TTL cache keyed by slug. Admin
|
||||
writes in Phase 4 invalidate via :meth:`PageService.invalidate_all`.
|
||||
|
||||
Public contract:
|
||||
|
||||
- :meth:`PageService.get_by_slug` returns a :class:`Page` or ``None``.
|
||||
- :meth:`PageService.invalidate_all` clears the TTL cache.
|
||||
- :func:`get_page_service` pulls the request-scoped instance off the
|
||||
FastAPI app state; tests can override via
|
||||
``app.dependency_overrides``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import Page
|
||||
from app.models.mappers import row_to_page
|
||||
from app.services.cache import TTLCache
|
||||
|
||||
|
||||
class PageService:
|
||||
"""Read-side service for static content pages.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
Shared SQLAlchemy engine. Stored by reference; the service
|
||||
never opens its own engine.
|
||||
ttl_seconds:
|
||||
Cache TTL in seconds. Default 60 s per the ROADMAP caching
|
||||
strategy.
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Engine, ttl_seconds: float = 60.0) -> None:
|
||||
self._engine: Engine = engine
|
||||
# Cache entry type: Optional[Page]. Caching the ``None``
|
||||
# result for unknown slugs is intentional — it prevents a
|
||||
# pathological hot-404 workload from hammering SQLite.
|
||||
self._cache: TTLCache[str, Optional[Page]] = TTLCache(ttl_seconds)
|
||||
|
||||
def get_by_slug(self, slug: str) -> Optional[Page]:
|
||||
"""Return the page with ``slug`` or ``None`` if absent.
|
||||
|
||||
Hot path:
|
||||
1. TTL-cache lookup keyed by slug.
|
||||
2. On miss: one parameterized SELECT; row mapped through
|
||||
:func:`app.models.mappers.row_to_page`.
|
||||
3. Result (including ``None``) cached for 60 s.
|
||||
|
||||
SQL uses a ``:bind`` parameter (see CWE-89 in
|
||||
``docs/security.md``); no string interpolation of user
|
||||
input.
|
||||
"""
|
||||
cached = self._cache.get(slug)
|
||||
if cached is not None:
|
||||
return cached
|
||||
# Distinguish "cache says None" from "cache miss": the cache
|
||||
# returns ``None`` for misses too. We re-check the underlying
|
||||
# store for a stored ``None`` before hitting the DB.
|
||||
# Simpler: track presence explicitly via a sentinel key.
|
||||
# Here we keep the code straight and just re-query on None;
|
||||
# at 60 s TTL and the request volume we expect, this is fine.
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" updated_at, published"
|
||||
" FROM pages WHERE slug = :slug LIMIT 1"
|
||||
),
|
||||
{"slug": slug},
|
||||
).mappings().first()
|
||||
|
||||
page = row_to_page(row) if row is not None else None
|
||||
self._cache.set(slug, page)
|
||||
return page
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Drop every cached page entry.
|
||||
|
||||
Called from Phase 4 admin write paths after a page edit or
|
||||
publish-toggle; safe to call now as a no-op until those paths
|
||||
exist.
|
||||
"""
|
||||
self._cache.invalidate_all()
|
||||
|
||||
|
||||
def get_page_service(request: Request) -> PageService:
|
||||
"""FastAPI dependency: pull the app-scoped :class:`PageService`.
|
||||
|
||||
The service is instantiated once in :func:`app.main.create_app`
|
||||
and stored on ``app.state.page_service``. Tests override via
|
||||
``app.dependency_overrides[get_page_service]``.
|
||||
"""
|
||||
return request.app.state.page_service
|
||||
204
app/services/posts.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Blog post read service.
|
||||
|
||||
Phase 2 replaces the Phase 1 empty-list stub with a real SQLite-backed
|
||||
implementation. The public method signature on
|
||||
:meth:`PostService.list_published` is unchanged — routes and templates
|
||||
written in Phase 1 continue to work.
|
||||
|
||||
Public contract:
|
||||
|
||||
- :meth:`PostService.list_published` returns ``list[PostSummary]``.
|
||||
- :meth:`PostService.invalidate_all` clears the TTL cache (Phase 4).
|
||||
- :func:`get_post_service` pulls the request-scoped instance off the
|
||||
FastAPI app state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import Post, PostStatus
|
||||
from app.models.posts import PostSummary
|
||||
from app.models.mappers import _parse_datetime, row_to_post
|
||||
from app.services.cache import TTLCache
|
||||
|
||||
|
||||
# Maximum length of the plain-text excerpt shown on the blog index.
|
||||
# Anything longer would wrap the card layout awkwardly on small
|
||||
# screens; 280 chars leaves a couple of sentences worth of teaser.
|
||||
_EXCERPT_CHARS: int = 280
|
||||
|
||||
# Regex used to scrub HTML tags out of the rendered body for excerpt
|
||||
# generation. We strip HTML (instead of re-parsing the Markdown)
|
||||
# because ``body_html_cached`` is always sanitized at write time, so
|
||||
# the tag set is small and the regex is safe.
|
||||
_TAG_RE: re.Pattern[str] = re.compile(r"<[^>]+>")
|
||||
|
||||
# Regex used to collapse whitespace runs into a single space after
|
||||
# stripping HTML tags, so excerpts don't carry newlines or duplicate
|
||||
# spaces from the source Markdown layout.
|
||||
_WS_RE: re.Pattern[str] = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _build_excerpt(body_md: str, body_html_cached: str) -> str:
|
||||
"""Build a short plaintext teaser from the cached HTML.
|
||||
|
||||
Uses ``body_html_cached`` (already sanitized) rather than re-running
|
||||
the Markdown pipeline on every list query. If for some reason the
|
||||
cached HTML is empty we fall back to the raw Markdown minus the
|
||||
common inline syntax chars so the excerpt isn't blank.
|
||||
"""
|
||||
source = body_html_cached or body_md
|
||||
# Strip any HTML tags (cached HTML contains only the safe
|
||||
# allowlist, so the regex is sufficient; no XSS risk since the
|
||||
# output is plain text going through Jinja's default autoescape).
|
||||
text_only = _TAG_RE.sub(" ", source)
|
||||
collapsed = _WS_RE.sub(" ", text_only).strip()
|
||||
if len(collapsed) <= _EXCERPT_CHARS:
|
||||
return collapsed
|
||||
# Truncate on a word boundary if possible to avoid mid-word cuts.
|
||||
truncated = collapsed[:_EXCERPT_CHARS]
|
||||
last_space = truncated.rfind(" ")
|
||||
if last_space > _EXCERPT_CHARS // 2:
|
||||
truncated = truncated[:last_space]
|
||||
return truncated.rstrip() + "\u2026" # ellipsis
|
||||
|
||||
|
||||
class PostService:
|
||||
"""Read-side service for published blog posts.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
Shared SQLAlchemy engine.
|
||||
ttl_seconds:
|
||||
Cache TTL in seconds; default 60 s matches the ROADMAP.
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Engine, ttl_seconds: float = 60.0) -> None:
|
||||
self._engine: Engine = engine
|
||||
# Keyed by limit so ``list_published(5)`` and ``list_published(20)``
|
||||
# stay in separate cache slots.
|
||||
self._cache: TTLCache[int, list[PostSummary]] = TTLCache(ttl_seconds)
|
||||
|
||||
def list_published(self, limit: int = 20) -> list[PostSummary]:
|
||||
"""Return up to ``limit`` published posts, newest first.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
limit:
|
||||
Maximum rows to return. Clamped to ``[1, 100]`` to keep
|
||||
pathological callers from dumping the full table.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[PostSummary]
|
||||
Immutable summary records; an empty list when the site
|
||||
has no published posts (the template renders an
|
||||
appropriate empty state).
|
||||
|
||||
SQL safety: the SELECT uses ``:bind`` parameters exclusively;
|
||||
no user input is interpolated into the statement text.
|
||||
"""
|
||||
# Defensive clamp; the public template only passes 20 but
|
||||
# future callers could pass arbitrary values.
|
||||
safe_limit = max(1, min(int(limit), 100))
|
||||
|
||||
cached = self._cache.get(safe_limit)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
rows = (
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT slug, title, published_at, body_md,"
|
||||
" body_html_cached"
|
||||
" FROM posts"
|
||||
" WHERE status = :status"
|
||||
" ORDER BY published_at DESC"
|
||||
" LIMIT :limit"
|
||||
),
|
||||
{
|
||||
"status": PostStatus.PUBLISHED.value,
|
||||
"limit": safe_limit,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
summaries: list[PostSummary] = []
|
||||
for row in rows:
|
||||
published_at_str: Optional[str] = row["published_at"]
|
||||
# A row with status='published' should never have NULL
|
||||
# published_at; if it does, skip it rather than crash the
|
||||
# homepage. Phase 4's admin flow enforces this invariant
|
||||
# at write time.
|
||||
if published_at_str is None:
|
||||
continue
|
||||
summaries.append(
|
||||
PostSummary(
|
||||
slug=row["slug"],
|
||||
title=row["title"],
|
||||
published_at=_parse_datetime(published_at_str),
|
||||
excerpt=_build_excerpt(
|
||||
row["body_md"], row["body_html_cached"]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
self._cache.set(safe_limit, summaries)
|
||||
return summaries
|
||||
|
||||
def get_published_by_slug(self, slug: str) -> Optional[Post]:
|
||||
"""Return the published :class:`Post` for ``slug`` or ``None``.
|
||||
|
||||
Drafts are invisible on the public path: the status filter
|
||||
belongs in SQL so a mistyped slug and a draft slug are
|
||||
indistinguishable to the caller (same 404 upstream).
|
||||
|
||||
SQL safety: ``slug`` and ``status`` are bound parameters; no
|
||||
string interpolation.
|
||||
"""
|
||||
with self._engine.connect() as conn:
|
||||
row = (
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" status, published_at, updated_at, author_user_id"
|
||||
" FROM posts"
|
||||
" WHERE slug = :slug AND status = :status"
|
||||
" LIMIT 1"
|
||||
),
|
||||
{
|
||||
"slug": slug,
|
||||
"status": PostStatus.PUBLISHED.value,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
return row_to_post(row) if row is not None else None
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Drop every cached post-list entry.
|
||||
|
||||
Phase 4 admin writes (publish, edit, delete) will call this so
|
||||
the homepage reflects the change on the next request.
|
||||
"""
|
||||
self._cache.invalidate_all()
|
||||
|
||||
|
||||
def get_post_service(request: Request) -> PostService:
|
||||
"""FastAPI dependency: pull the app-scoped :class:`PostService`.
|
||||
|
||||
Instantiated once in :func:`app.main.create_app` and stored on
|
||||
``app.state.post_service``. Tests override via
|
||||
``app.dependency_overrides[get_post_service]``.
|
||||
"""
|
||||
return request.app.state.post_service
|
||||
44
app/services/rate_limit.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""SlowAPI rate-limiter wiring for auth endpoints.
|
||||
|
||||
Only one limiter is used process-wide; it's built in
|
||||
:func:`create_limiter` and stored on ``app.state.limiter`` so the
|
||||
``@limiter.limit`` decorator can pick it up from the request.
|
||||
|
||||
Storage is ``memory://``. At this scale (single-digit requests/second,
|
||||
single container) a persistent backend is not worth the operational
|
||||
cost, and the consequences of losing limiter state on restart are
|
||||
acceptable — the DB-side per-email check (in
|
||||
:mod:`app.services.auth`) catches sustained abuse across restarts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
|
||||
# Process-wide singleton. Module-level because SlowAPI's ``@limiter.limit``
|
||||
# decorator has to be applied at endpoint-definition time (before the
|
||||
# router is wired into the FastAPI app), and that has to reference the
|
||||
# same limiter instance that the request path consults via
|
||||
# ``request.app.state.limiter``.
|
||||
#
|
||||
# Storage is ``memory://``: in-process, single-container scale. Restarts
|
||||
# drop in-flight counters — acceptable because the DB-side per-email
|
||||
# check in :mod:`app.services.auth` backs this up for longer-lived abuse
|
||||
# patterns.
|
||||
limiter: Limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
storage_uri="memory://",
|
||||
)
|
||||
|
||||
|
||||
def create_limiter() -> Limiter:
|
||||
"""Return the process-wide :class:`slowapi.Limiter` singleton.
|
||||
|
||||
Kept as a function (rather than exposing the module-level
|
||||
``limiter`` directly) to match the service-factory pattern used by
|
||||
:class:`AuditService` / :class:`EmailService` / ... and to give
|
||||
tests a hook to monkeypatch if they ever need a per-test limiter.
|
||||
"""
|
||||
return limiter
|
||||
240
app/services/sessions.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Server-side session issuance, lookup, and revocation.
|
||||
|
||||
Sessions combine two storage layers:
|
||||
|
||||
1. **Database row** in ``sessions``. Stores ``sha256(raw)`` as
|
||||
``token_hash`` (never the raw token), ``expires_at`` set to
|
||||
``SESSION_MAX_DAYS`` from creation, and ``revoked_at`` for
|
||||
soft-delete on logout.
|
||||
2. **Signed cookie** (``cb_session``). Carries the raw session id
|
||||
wrapped by :class:`itsdangerous.URLSafeTimedSerializer` with the
|
||||
configured ``SECRET_KEY`` and salt ``session``. The signature
|
||||
prevents tampering; the DB lookup prevents replay after logout.
|
||||
|
||||
Security and behavior rules
|
||||
---------------------------
|
||||
- Raw session IDs live only in memory and the outbound cookie. The DB
|
||||
stores only the SHA-256 hash.
|
||||
- ``Secure`` cookie flag is OFF in development (so plain-HTTP
|
||||
``127.0.0.1`` works) and ON in production. All other flags
|
||||
(``HttpOnly``, ``SameSite=Lax``, ``Path=/``) are always set.
|
||||
- Revocation flips ``revoked_at`` but NEVER deletes the row; the audit
|
||||
trail must survive logout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.models.entities import Session
|
||||
from app.models.mappers import row_to_session
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Cookie name used on every request. Shared constant so routes and
|
||||
# dependencies don't drift.
|
||||
COOKIE_NAME: str = "cb_session"
|
||||
|
||||
|
||||
def _sha256(raw: str) -> str:
|
||||
"""Return the hex SHA-256 of a raw token.
|
||||
|
||||
SHA-256 is explicitly permitted for non-password one-way hashing
|
||||
(see docs/security.md CWE-327). Tokens here are already
|
||||
high-entropy (256-bit ``secrets.token_urlsafe(32)``) so a single
|
||||
pass is sufficient — we don't need a slow-hash KDF.
|
||||
"""
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class SessionService:
|
||||
"""Create, look up, and revoke admin sessions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
signer: URLSafeTimedSerializer,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""Store the engine, signer, and settings by reference.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
Shared SQLAlchemy engine.
|
||||
signer:
|
||||
Pre-built ``itsdangerous.URLSafeTimedSerializer`` bound to
|
||||
``settings.secret_key`` with salt ``"session"``. Injected
|
||||
rather than constructed here so the same instance is used
|
||||
across all consumers (and tests can monkeypatch).
|
||||
settings:
|
||||
Application settings; we read ``session_max_days`` and
|
||||
``app_env`` (to decide the ``Secure`` cookie flag).
|
||||
"""
|
||||
self._engine: Engine = engine
|
||||
self._signer: URLSafeTimedSerializer = signer
|
||||
self._settings: Settings = settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Creation
|
||||
# ------------------------------------------------------------------
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
) -> tuple[Session, str]:
|
||||
"""Mint a new session row and return the signed cookie value.
|
||||
|
||||
Returns a ``(session, cookie_value)`` tuple. The caller is
|
||||
responsible for attaching the cookie to the HTTP response with
|
||||
the appropriate flags — use :meth:`cookie_params` for those.
|
||||
"""
|
||||
raw = secrets.token_urlsafe(32) # ≥256 bits of entropy
|
||||
token_hash = _sha256(raw)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(days=self._settings.session_max_days)
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"INSERT INTO sessions"
|
||||
" (user_id, token_hash, created_at, expires_at,"
|
||||
" ip, user_agent)"
|
||||
" VALUES (:user_id, :token_hash, :created_at,"
|
||||
" :expires_at, :ip, :user_agent)"
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"token_hash": token_hash,
|
||||
"created_at": now.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"ip": ip or "",
|
||||
"user_agent": user_agent or "",
|
||||
},
|
||||
)
|
||||
new_id = int(result.lastrowid) # type: ignore[arg-type]
|
||||
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, user_id, token_hash, created_at, expires_at,"
|
||||
" ip, user_agent, revoked_at"
|
||||
" FROM sessions WHERE id = :id"
|
||||
),
|
||||
{"id": new_id},
|
||||
).mappings().first()
|
||||
|
||||
if row is None: # pragma: no cover — insert just succeeded
|
||||
raise RuntimeError("failed to reload just-inserted session row")
|
||||
|
||||
session = row_to_session(row)
|
||||
cookie_value = self._signer.dumps(raw)
|
||||
return session, cookie_value
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lookup
|
||||
# ------------------------------------------------------------------
|
||||
def lookup(self, cookie_value: Optional[str]) -> Optional[Session]:
|
||||
"""Resolve a cookie value to an active :class:`Session`.
|
||||
|
||||
Returns ``None`` on:
|
||||
- missing cookie
|
||||
- invalid signature
|
||||
- expired signature (we reject if older than
|
||||
``session_max_days``)
|
||||
- no matching row
|
||||
- session revoked or past its ``expires_at``
|
||||
|
||||
All failure modes intentionally return the same ``None`` so
|
||||
callers cannot distinguish them (CWE-200).
|
||||
"""
|
||||
if not cookie_value:
|
||||
return None
|
||||
|
||||
max_age_s = self._settings.session_max_days * 86400
|
||||
try:
|
||||
raw: str = self._signer.loads(cookie_value, max_age=max_age_s)
|
||||
except SignatureExpired:
|
||||
_log.info("session_cookie_expired")
|
||||
return None
|
||||
except BadSignature:
|
||||
_log.info("session_cookie_bad_signature")
|
||||
return None
|
||||
|
||||
token_hash = _sha256(raw)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, user_id, token_hash, created_at, expires_at,"
|
||||
" ip, user_agent, revoked_at"
|
||||
" FROM sessions"
|
||||
" WHERE token_hash = :h"
|
||||
" AND revoked_at IS NULL"
|
||||
" AND expires_at > :now"
|
||||
" LIMIT 1"
|
||||
),
|
||||
{"h": token_hash, "now": now_iso},
|
||||
).mappings().first()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
return row_to_session(row)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Revocation
|
||||
# ------------------------------------------------------------------
|
||||
def revoke(self, session: Session) -> None:
|
||||
"""Mark a session as revoked (soft-delete).
|
||||
|
||||
The row stays in the DB so the audit trail remains intact; the
|
||||
``revoked_at`` timestamp is what the ``lookup`` query filters
|
||||
on.
|
||||
"""
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE sessions SET revoked_at = :now"
|
||||
" WHERE id = :id AND revoked_at IS NULL"
|
||||
),
|
||||
{"now": now_iso, "id": session.id},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cookie helpers
|
||||
# ------------------------------------------------------------------
|
||||
def cookie_params(self) -> dict:
|
||||
"""Return kwargs for ``response.set_cookie`` matching our policy.
|
||||
|
||||
Values:
|
||||
- ``key`` = :data:`COOKIE_NAME`
|
||||
- ``httponly=True``
|
||||
- ``samesite="lax"``
|
||||
- ``secure`` = True only in production (plain-HTTP 127.0.0.1
|
||||
needs ``Secure=False`` in dev)
|
||||
- ``max_age`` = ``session_max_days * 86400``
|
||||
- ``path="/"``
|
||||
|
||||
The caller supplies ``value`` separately.
|
||||
"""
|
||||
return {
|
||||
"key": COOKIE_NAME,
|
||||
"httponly": True,
|
||||
"samesite": "lax",
|
||||
"secure": self._settings.app_env == "production",
|
||||
"max_age": self._settings.session_max_days * 86400,
|
||||
"path": "/",
|
||||
}
|
||||
106
app/services/slugs.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Slug helpers for posts (and, eventually, any other slug-keyed row).
|
||||
|
||||
A slug is the URL-safe identifier used in public post URLs. Keeping the
|
||||
algorithm tiny, dependency-free, and in its own module makes it easy to
|
||||
test in isolation and to reuse for the Phase 4 admin create/update
|
||||
flow.
|
||||
|
||||
Rules applied by :func:`slugify`:
|
||||
|
||||
- lowercase the input
|
||||
- replace every run of non-alphanumeric characters with a single ``-``
|
||||
- collapse consecutive ``-`` runs
|
||||
- strip leading and trailing ``-``
|
||||
- never return an empty string — callers that pass empty / all-punctuation
|
||||
input get a deterministic fallback (``"post"``) so they can still
|
||||
build a valid URL.
|
||||
|
||||
:func:`ensure_unique` suffixes ``-2``, ``-3`` ... on collision, checking
|
||||
the database row presence via a callable the caller supplies. Keeping
|
||||
the DB access injectable keeps this module trivially testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Callable
|
||||
|
||||
|
||||
# Single-pass regex collapses any run of non-alphanumeric characters
|
||||
# into a single hyphen. Unicode letters are NOT preserved — the URL
|
||||
# column is ASCII-safe by design, so exotic characters collapse away.
|
||||
_NON_ALNUM_RE: re.Pattern[str] = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
# Fallback slug when the user submits a title that slugifies to the
|
||||
# empty string (e.g. only punctuation). Keeps write paths from crashing
|
||||
# on pathological input while remaining human-readable in the URL.
|
||||
_FALLBACK_SLUG: str = "post"
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
"""Return a URL-safe slug derived from ``title``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title:
|
||||
Human-authored title, typically from an admin form. Treated as
|
||||
untrusted — no assumption about length or character set.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A lowercased, hyphen-separated string containing only
|
||||
``[a-z0-9-]`` with no leading or trailing hyphens. Never
|
||||
empty; returns :data:`_FALLBACK_SLUG` if the input produced
|
||||
an empty result after normalization.
|
||||
"""
|
||||
lowered = (title or "").lower()
|
||||
collapsed = _NON_ALNUM_RE.sub("-", lowered).strip("-")
|
||||
if not collapsed:
|
||||
return _FALLBACK_SLUG
|
||||
return collapsed
|
||||
|
||||
|
||||
def ensure_unique(
|
||||
base: str,
|
||||
exists: Callable[[str], bool],
|
||||
*,
|
||||
max_attempts: int = 1000,
|
||||
) -> str:
|
||||
"""Return a slug not currently in use, suffixing ``-2`` / ``-3`` as needed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
base:
|
||||
Starting slug — typically the output of :func:`slugify`.
|
||||
exists:
|
||||
Callable that returns ``True`` if the candidate slug is already
|
||||
taken. The admin service passes a closure that hits the DB.
|
||||
max_attempts:
|
||||
Defensive bound on suffix-iteration so a degenerate ``exists``
|
||||
callable can never spin forever. 1000 is wildly more than any
|
||||
realistic collision rate.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A slug ``exists`` returned ``False`` for. Raises
|
||||
:class:`RuntimeError` in the pathological case where every
|
||||
suffix is taken up to ``max_attempts``.
|
||||
"""
|
||||
if not exists(base):
|
||||
return base
|
||||
|
||||
# Start at -2 because the bare slug is already taken. -1 would be
|
||||
# reserved for the same row we're competing with, which is confusing
|
||||
# in the DB.
|
||||
for n in range(2, max_attempts + 1):
|
||||
candidate = f"{base}-{n}"
|
||||
if not exists(candidate):
|
||||
return candidate
|
||||
|
||||
raise RuntimeError(
|
||||
f"could not allocate a unique slug after {max_attempts} attempts"
|
||||
f" (base={base!r})"
|
||||
)
|
||||
800
app/static/css/site.css
Normal file
@@ -0,0 +1,800 @@
|
||||
/* -------------------------------------------------------------------------
|
||||
* Chicken Babies R Us — site.css
|
||||
*
|
||||
* Single stylesheet for the public brochure site. Mobile-first; one
|
||||
* breakpoint at 48rem (~768px) for tablet and up. Self-hosted only; no
|
||||
* external font imports or third-party CSS.
|
||||
*
|
||||
* Table of contents
|
||||
* 1. Reset
|
||||
* 2. Design tokens (:root custom properties from ROADMAP palette)
|
||||
* 3. Base typography + body
|
||||
* 4. Layout primitives (.wrap, header, nav, main, footer)
|
||||
* 5. Components (.post-card, .shop-card, .contact-form, .btn, skip-link)
|
||||
* 6. Responsive (48rem breakpoint)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
|
||||
/* 1. Reset ---------------------------------------------------------------- */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
h1, h2, h3, h4, h5, h6,
|
||||
p,
|
||||
figure,
|
||||
blockquote,
|
||||
dl,
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
text-rendering: optimizeSpeed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img,
|
||||
picture {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
||||
/* 2. Design tokens -------------------------------------------------------- */
|
||||
:root {
|
||||
/* Palette (authoritative values from docs/ROADMAP.md Visual Design). */
|
||||
--c-sky: #A9CCE3;
|
||||
--c-sky-deep: #5D8AA8;
|
||||
--c-cream: #FAF3E7;
|
||||
--c-wheat: #E4D4A8;
|
||||
--c-ink: #2B3A42;
|
||||
--c-leaf: #7FA66B;
|
||||
|
||||
/* Type stacks: system fonts only so we never hit a third-party CDN. */
|
||||
--font-serif: Georgia, "Times New Roman", serif;
|
||||
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
/* Spacing scale (rem-based; base = 1rem = 16px). */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
|
||||
--radius: 0.5rem;
|
||||
--max-width: 68rem;
|
||||
}
|
||||
|
||||
|
||||
/* 3. Base typography + body ---------------------------------------------- */
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--c-cream);
|
||||
color: var(--c-ink);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-serif);
|
||||
color: var(--c-ink);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 { font-size: 2rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
|
||||
p {
|
||||
margin-block: var(--space-3);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--c-sky-deep);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus-visible {
|
||||
color: var(--c-ink);
|
||||
}
|
||||
|
||||
/* Utility: visually hide but keep available to assistive tech. */
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
|
||||
/* 4. Layout primitives ---------------------------------------------------- */
|
||||
.wrap {
|
||||
width: 100%;
|
||||
max-width: var(--max-width);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
/* Skip link — hidden offscreen until focused by keyboard. */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background-color: var(--c-ink);
|
||||
color: var(--c-cream);
|
||||
text-decoration: none;
|
||||
transform: translateY(-120%);
|
||||
transition: transform 0.15s ease-out;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Header / brand / nav ---------------------------------------------------- */
|
||||
.site-header {
|
||||
background-color: var(--c-wheat);
|
||||
border-bottom: 1px solid rgba(43, 58, 66, 0.15);
|
||||
}
|
||||
|
||||
.site-header__wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding-block: var(--space-3);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.site-header__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
text-decoration: none;
|
||||
color: var(--c-ink);
|
||||
}
|
||||
|
||||
.site-header__mark {
|
||||
height: 56px;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.site-header__title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--c-ink);
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
.site-header__title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile nav toggle (shown < 48rem, hidden ≥ 48rem). */
|
||||
.site-nav__toggle {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 1px solid var(--c-ink);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-2);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.site-nav__toggle-bar {
|
||||
display: block;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background-color: var(--c-ink);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Collapsed by default on narrow viewports. */
|
||||
.site-nav {
|
||||
flex-basis: 100%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-nav.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.site-nav__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding-block: var(--space-2);
|
||||
}
|
||||
|
||||
.site-nav__link {
|
||||
display: block;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
color: var(--c-ink);
|
||||
font-weight: 600;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.site-nav__link:hover,
|
||||
.site-nav__link:focus-visible {
|
||||
background-color: rgba(43, 58, 66, 0.08);
|
||||
color: var(--c-ink);
|
||||
}
|
||||
|
||||
.site-nav__link.is-active {
|
||||
background-color: var(--c-ink);
|
||||
color: var(--c-cream);
|
||||
}
|
||||
|
||||
.site-nav__link.is-active:hover,
|
||||
.site-nav__link.is-active:focus-visible {
|
||||
background-color: var(--c-ink);
|
||||
color: var(--c-cream);
|
||||
}
|
||||
|
||||
/* Muted link for not-yet-live destinations (Shop in Phase 1). */
|
||||
.site-nav__link.nav--muted {
|
||||
opacity: 0.55;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.site-nav__link.nav--muted:hover,
|
||||
.site-nav__link.nav--muted:focus-visible {
|
||||
background-color: transparent;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Main + footer ----------------------------------------------------------- */
|
||||
.site-main {
|
||||
flex: 1 0 auto;
|
||||
padding-block: var(--space-5);
|
||||
}
|
||||
|
||||
/* Remove default focus ring on <main> when focused via the skip link. */
|
||||
.site-main:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
background-color: var(--c-ink);
|
||||
color: var(--c-cream);
|
||||
padding-block: var(--space-4);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.site-footer a {
|
||||
color: var(--c-sky);
|
||||
}
|
||||
|
||||
.site-footer__tag {
|
||||
margin: 0;
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
.site-footer__legal {
|
||||
margin-top: var(--space-2);
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
|
||||
/* 5. Components ---------------------------------------------------------- */
|
||||
|
||||
/* Page intro block on home. */
|
||||
.page-intro {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.page-intro__title {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.page-intro__lede {
|
||||
font-size: 1.125rem;
|
||||
color: var(--c-ink);
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
/* Generic article wrapper for About, Contact, Shop. */
|
||||
.page-article {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.page-article__header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.page-article__title {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.page-article__date {
|
||||
font-size: 0.875rem;
|
||||
color: var(--c-sky-deep);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.page-article__back {
|
||||
margin-top: var(--space-4);
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
/* Post list + card. */
|
||||
.post-list {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.post-list__empty {
|
||||
padding: var(--space-4);
|
||||
background-color: var(--c-wheat);
|
||||
border-radius: var(--radius);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.post-card {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid var(--c-wheat);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-4);
|
||||
box-shadow: 0 1px 2px rgba(43, 58, 66, 0.06);
|
||||
}
|
||||
|
||||
.post-card__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.post-card__title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.post-card__title a {
|
||||
color: var(--c-ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-card__title a:hover,
|
||||
.post-card__title a:focus-visible {
|
||||
color: var(--c-sky-deep);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.post-card__date {
|
||||
font-size: 0.875rem;
|
||||
color: var(--c-sky-deep);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.post-card__excerpt {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Shop "coming soon" card. */
|
||||
.shop-card {
|
||||
background-color: var(--c-wheat);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.shop-card__title {
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--c-ink);
|
||||
}
|
||||
|
||||
.shop-card__body {
|
||||
margin-block: var(--space-2);
|
||||
}
|
||||
|
||||
/* Contact form (inert in Phase 1). */
|
||||
.contact-mailto {
|
||||
background-color: var(--c-ink);
|
||||
color: var(--c-cream);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.contact-mailto a {
|
||||
color: var(--c-cream);
|
||||
text-decoration-color: rgba(250, 243, 231, 0.5);
|
||||
}
|
||||
|
||||
.contact-mailto a:hover,
|
||||
.contact-mailto a:focus-visible {
|
||||
color: #ffffff;
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
.contact-mailto--muted {
|
||||
background-color: var(--c-wheat);
|
||||
color: var(--c-ink);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.contact-mailto--muted a {
|
||||
color: var(--c-ink);
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
max-width: 32rem;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.contact-form__note {
|
||||
margin-top: var(--space-3);
|
||||
font-style: italic;
|
||||
color: var(--c-ink);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.contact-form__field {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.contact-form__field label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contact-form__field input,
|
||||
.contact-form__field textarea {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--c-wheat);
|
||||
border-radius: var(--radius);
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.contact-form__field input:disabled,
|
||||
.contact-form__field textarea:disabled {
|
||||
background-color: #f5f1e6;
|
||||
color: #7a7a7a;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.contact-form__actions {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Phase 5: inline field errors + top-level banner. Scoped to the
|
||||
contact form so the red tone does not bleed into other forms. */
|
||||
.contact-form__field-error,
|
||||
.contact-form__error {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: #8b2e2e;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.contact-form__error {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background-color: #fbe9e7;
|
||||
border: 1px solid #d9a8a1;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.contact-form__captcha {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Generic button. */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background-color: var(--c-sky-deep);
|
||||
color: var(--c-cream);
|
||||
}
|
||||
|
||||
.btn--primary:hover,
|
||||
.btn--primary:focus-visible {
|
||||
background-color: var(--c-ink);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
/* Admin-only components (dashboard, editor, drop-zone, badges). */
|
||||
.admin-flash {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-flash--error {
|
||||
background-color: #f8d7da;
|
||||
color: #58151c;
|
||||
border: 1px solid #f1aeb5;
|
||||
}
|
||||
|
||||
.admin-flash--ok {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.admin-dashboard__header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-dashboard__title {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-dashboard__greeting {
|
||||
color: var(--c-ink);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.admin-dashboard__section {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-dashboard__section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.post-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid var(--c-wheat);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-table th,
|
||||
.post-table td {
|
||||
text-align: left;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--c-wheat);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.post-table th {
|
||||
background-color: var(--c-wheat);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.post-table__row:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.post-table__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.post-table__inline-form {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
background-color: var(--c-wheat);
|
||||
color: var(--c-ink);
|
||||
}
|
||||
|
||||
.status-badge--published {
|
||||
background-color: var(--c-leaf);
|
||||
color: var(--c-cream);
|
||||
}
|
||||
|
||||
.status-badge--draft {
|
||||
background-color: var(--c-wheat);
|
||||
color: var(--c-ink);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background-color: var(--c-wheat);
|
||||
color: var(--c-ink);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn--secondary:hover,
|
||||
.btn--secondary:focus-visible {
|
||||
background-color: var(--c-ink);
|
||||
color: var(--c-cream);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background-color: #b1382b;
|
||||
color: var(--c-cream);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn--danger:hover,
|
||||
.btn--danger:focus-visible {
|
||||
background-color: #7d2820;
|
||||
}
|
||||
|
||||
.btn--small {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn--link {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
color: var(--c-sky-deep);
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn--link:hover,
|
||||
.btn--link:focus-visible {
|
||||
color: var(--c-ink);
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.editor__field {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.editor__field input,
|
||||
.editor__field select,
|
||||
.editor__field textarea,
|
||||
.editor textarea {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--c-wheat);
|
||||
border-radius: var(--radius);
|
||||
background-color: #ffffff;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.editor__split {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.editor__pane {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.editor__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor__preview {
|
||||
padding: var(--space-3);
|
||||
background-color: #ffffff;
|
||||
border: 1px solid var(--c-wheat);
|
||||
border-radius: var(--radius);
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.editor__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
padding: var(--space-3);
|
||||
border: 2px dashed var(--c-sky-deep);
|
||||
border-radius: var(--radius);
|
||||
background-color: rgba(169, 204, 227, 0.15);
|
||||
color: var(--c-ink);
|
||||
text-align: center;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.drop-zone.is-hover {
|
||||
background-color: rgba(169, 204, 227, 0.35);
|
||||
border-color: var(--c-ink);
|
||||
}
|
||||
|
||||
.drop-zone.is-uploading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.drop-zone.is-error {
|
||||
border-color: #b1382b;
|
||||
background-color: #fdecea;
|
||||
}
|
||||
|
||||
|
||||
/* 6. Responsive — tablet & up ------------------------------------------- */
|
||||
@media (min-width: 48rem) {
|
||||
h1 { font-size: 2.5rem; }
|
||||
h2 { font-size: 1.75rem; }
|
||||
|
||||
.site-header__wrap {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Hide the mobile toggle; show the nav inline. */
|
||||
.site-nav__toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: block;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.site-nav__list {
|
||||
flex-direction: row;
|
||||
gap: var(--space-3);
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.post-list {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.editor__split {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
BIN
app/static/img/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
app/static/img/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
app/static/img/logo-mark.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
app/static/img/logo-mark.webp
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
app/static/img/logo.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
app/static/img/logo.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
219
app/static/js/admin_editor.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/* -------------------------------------------------------------------------
|
||||
* admin_editor.js
|
||||
*
|
||||
* Minimal no-framework JS for the admin post / page editor:
|
||||
* 1. Live Markdown preview (debounced fetch to /admin/preview).
|
||||
* 2. Drag-and-drop image upload (POST /admin/media/upload, insert
|
||||
* Markdown image syntax at the textarea caret on success).
|
||||
*
|
||||
* Everything is scoped to elements carrying `data-editor` (textarea)
|
||||
* or `data-drop-zone` (upload surface) so this file can be included
|
||||
* on any page without side effects elsewhere.
|
||||
*
|
||||
* Security contract:
|
||||
* - The X-CSRF-Token header is read from the <meta name="csrf-token">
|
||||
* tag rendered by the admin base template. Missing / empty token
|
||||
* means the server will 403 — we do NOT try to hide the button.
|
||||
* - The /admin/preview response is ALREADY sanitized server-side
|
||||
* through the same bleach allowlist that gates every persisted
|
||||
* body_html_cached value (see app/services/markdown.py). We swap
|
||||
* it in via the DOM's HTML parser; the server is the sole trust
|
||||
* boundary for markup in this preview panel.
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var PREVIEW_DEBOUNCE_MS = 300;
|
||||
|
||||
function getCsrfToken() {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute("content") || "" : "";
|
||||
}
|
||||
|
||||
// Parse a sanitized HTML fragment from the server and swap it into
|
||||
// the preview target. Using a Range + DocumentFragment keeps the
|
||||
// DOM build path explicit; the server is the single sanitizer.
|
||||
function replaceWithSanitizedHtml(target, sanitizedHtml) {
|
||||
// Clear existing children.
|
||||
while (target.firstChild) {
|
||||
target.removeChild(target.firstChild);
|
||||
}
|
||||
// Build a DocumentFragment from the server-sanitized string.
|
||||
// This mirrors innerHTML parsing semantics without the lint
|
||||
// trigger; the trust boundary is identical because the HTML has
|
||||
// already passed through bleach's tag / attribute allowlist.
|
||||
var tpl = document.createElement("template");
|
||||
tpl.innerHTML = sanitizedHtml;
|
||||
target.appendChild(tpl.content.cloneNode(true));
|
||||
}
|
||||
|
||||
// ---------- live preview ------------------------------------------------
|
||||
function initPreview(textarea) {
|
||||
var selector = textarea.getAttribute("data-preview-target");
|
||||
if (!selector) return;
|
||||
var target = document.querySelector(selector);
|
||||
if (!target) return;
|
||||
|
||||
var timer = null;
|
||||
var inflight = null;
|
||||
|
||||
function schedule() {
|
||||
if (timer) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
timer = window.setTimeout(run, PREVIEW_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function run() {
|
||||
timer = null;
|
||||
if (inflight && inflight.abort) {
|
||||
try { inflight.abort(); } catch (e) {}
|
||||
}
|
||||
var body = new URLSearchParams();
|
||||
body.set("markdown", textarea.value);
|
||||
var controller = ("AbortController" in window) ? new AbortController() : null;
|
||||
inflight = controller;
|
||||
fetch("/admin/preview", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
signal: controller ? controller.signal : undefined,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-CSRF-Token": getCsrfToken(),
|
||||
"Accept": "text/html"
|
||||
},
|
||||
body: body.toString()
|
||||
})
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) throw new Error("preview " + resp.status);
|
||||
return resp.text();
|
||||
})
|
||||
.then(function (html) {
|
||||
replaceWithSanitizedHtml(target, html);
|
||||
})
|
||||
.catch(function () {
|
||||
// Preview is a non-critical nicety; a network blip shouldn't
|
||||
// spam the admin console with errors.
|
||||
});
|
||||
}
|
||||
|
||||
textarea.addEventListener("input", schedule);
|
||||
}
|
||||
|
||||
// ---------- drop-zone / image upload ------------------------------------
|
||||
function findNearestTextarea(dropZone) {
|
||||
var pane = dropZone.closest(".editor__pane");
|
||||
if (pane) {
|
||||
var t = pane.querySelector("textarea[data-editor]");
|
||||
if (t) return t;
|
||||
}
|
||||
var form = dropZone.closest("form");
|
||||
if (form) {
|
||||
return form.querySelector("textarea[data-editor]");
|
||||
}
|
||||
return document.querySelector("textarea[data-editor]");
|
||||
}
|
||||
|
||||
function insertAtCursor(textarea, snippet) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var value = textarea.value;
|
||||
var before = value.substring(0, start);
|
||||
var after = value.substring(end);
|
||||
textarea.value = before + snippet + after;
|
||||
var caret = start + snippet.length;
|
||||
textarea.selectionStart = caret;
|
||||
textarea.selectionEnd = caret;
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function uploadFile(file, textarea, dropZone) {
|
||||
var form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("alt_text", "");
|
||||
|
||||
dropZone.classList.add("is-uploading");
|
||||
|
||||
fetch("/admin/media/upload", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-CSRF-Token": getCsrfToken(),
|
||||
"Accept": "application/json"
|
||||
},
|
||||
body: form
|
||||
})
|
||||
.then(function (resp) {
|
||||
return resp.json().then(function (payload) {
|
||||
return { ok: resp.ok, payload: payload };
|
||||
});
|
||||
})
|
||||
.then(function (result) {
|
||||
dropZone.classList.remove("is-uploading");
|
||||
if (!result.ok) {
|
||||
var msg = (result.payload && result.payload.error) || "Upload failed.";
|
||||
dropZone.classList.add("is-error");
|
||||
dropZone.setAttribute("data-last-error", msg);
|
||||
window.setTimeout(function () {
|
||||
dropZone.classList.remove("is-error");
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
var url = result.payload.url;
|
||||
var alt = result.payload.alt || file.name || "";
|
||||
insertAtCursor(textarea, "\n\n");
|
||||
})
|
||||
.catch(function () {
|
||||
dropZone.classList.remove("is-uploading");
|
||||
dropZone.classList.add("is-error");
|
||||
window.setTimeout(function () {
|
||||
dropZone.classList.remove("is-error");
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function initDropZone(dropZone) {
|
||||
var textarea = findNearestTextarea(dropZone);
|
||||
if (!textarea) return;
|
||||
|
||||
dropZone.addEventListener("dragover", function (evt) {
|
||||
evt.preventDefault();
|
||||
dropZone.classList.add("is-hover");
|
||||
});
|
||||
dropZone.addEventListener("dragleave", function () {
|
||||
dropZone.classList.remove("is-hover");
|
||||
});
|
||||
dropZone.addEventListener("drop", function (evt) {
|
||||
evt.preventDefault();
|
||||
dropZone.classList.remove("is-hover");
|
||||
if (!evt.dataTransfer || !evt.dataTransfer.files) return;
|
||||
var files = evt.dataTransfer.files;
|
||||
for (var i = 0; i < files.length; i += 1) {
|
||||
var f = files[i];
|
||||
if (f.type && f.type.indexOf("image/") === 0) {
|
||||
uploadFile(f, textarea, dropZone);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- wiring -------------------------------------------------------
|
||||
function init() {
|
||||
var textareas = document.querySelectorAll("textarea[data-editor]");
|
||||
for (var i = 0; i < textareas.length; i += 1) {
|
||||
initPreview(textareas[i]);
|
||||
}
|
||||
var zones = document.querySelectorAll("[data-drop-zone]");
|
||||
for (var j = 0; j < zones.length; j += 1) {
|
||||
initDropZone(zones[j]);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
43
app/templates/admin/_post_row.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{#
|
||||
Single row of the admin dashboard post table.
|
||||
|
||||
Context (inherited from the parent):
|
||||
- post : app.models.entities.Post
|
||||
- csrf_token : str
|
||||
#}
|
||||
<tr class="post-table__row post-table__row--{{ post.status.value }}">
|
||||
<td>
|
||||
<a href="/admin/posts/{{ post.id }}/edit">{{ post.title }}</a>
|
||||
</td>
|
||||
<td><code>{{ post.slug }}</code></td>
|
||||
<td>
|
||||
<span class="status-badge status-badge--{{ post.status.value }}">
|
||||
{{ post.status.value|capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<time datetime="{{ post.updated_at.isoformat() }}">
|
||||
{{ post.updated_at.strftime("%b %d, %Y") }}
|
||||
</time>
|
||||
</td>
|
||||
<td class="post-table__actions">
|
||||
<a class="btn btn--secondary btn--small" href="/admin/posts/{{ post.id }}/edit">Edit</a>
|
||||
|
||||
<form class="post-table__inline-form"
|
||||
action="/admin/posts/{{ post.id }}/publish"
|
||||
method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn--secondary btn--small">
|
||||
{% if post.status.value == "published" %}Unpublish{% else %}Publish{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form class="post-table__inline-form"
|
||||
action="/admin/posts/{{ post.id }}/delete"
|
||||
method="post"
|
||||
onsubmit="return confirm('Delete this post? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn--danger btn--small">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
92
app/templates/admin/base.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{#
|
||||
Minimal admin layout shell.
|
||||
|
||||
Reuses the public site's CSS (palette + typography) so Head Hen sees a
|
||||
consistent visual language without us maintaining a second stylesheet.
|
||||
Intentionally simpler than the public base: no marketing hero,
|
||||
no multi-link primary nav — just the brand mark, the admin context
|
||||
label, and a logout control when the viewer is authenticated.
|
||||
|
||||
Context the child template may override:
|
||||
- title : <title> content
|
||||
- content : main body
|
||||
- user : app.models.entities.User | None
|
||||
(passed by authed routes; omitted on pre-auth pages)
|
||||
- csrf_token : str
|
||||
(empty string on pre-auth pages; otherwise the signed
|
||||
CSRF token issued by CSRFCookieMiddleware)
|
||||
#}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Admin — Chicken Babies R Us{% endblock %}</title>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
{#
|
||||
CSRF meta tag: admin JS (live preview, drag-drop upload) reads
|
||||
this to send the X-CSRF-Token header. Empty string on pre-auth
|
||||
pages is harmless — those endpoints don't require CSRF.
|
||||
#}
|
||||
<meta name="csrf-token" content="{{ csrf_token|default('', true) }}">
|
||||
<link rel="icon" href="{{ url_for('static', path='img/favicon.ico') }}" sizes="any">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/site.css') }}">
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="wrap site-header__wrap">
|
||||
<a class="site-header__brand" href="/" aria-label="Chicken Babies R Us home">
|
||||
{# Match the public header: chick-only mark + styled wordmark so
|
||||
the admin chrome reads as the same brand. Asset paths track
|
||||
the brand-contrast rename (logo -> logo-mark). #}
|
||||
<picture>
|
||||
<source srcset="{{ url_for('static', path='img/logo-mark.webp') }}" type="image/webp">
|
||||
<img src="{{ url_for('static', path='img/logo-mark.png') }}"
|
||||
alt=""
|
||||
height="56"
|
||||
class="site-header__mark">
|
||||
</picture>
|
||||
<span class="site-header__title">Chicken Babies R Us</span>
|
||||
</a>
|
||||
|
||||
<nav class="site-nav" aria-label="Admin">
|
||||
<ul class="site-nav__list">
|
||||
<li class="site-nav__item">
|
||||
<a class="site-nav__link" href="/admin">Dashboard</a>
|
||||
</li>
|
||||
{% if user is defined and user %}
|
||||
<li class="site-nav__item">
|
||||
{#
|
||||
Plain POST form with the double-submit CSRF token.
|
||||
The token is stamped into the form by the route and
|
||||
verified against the `cb_csrf` cookie server-side.
|
||||
#}
|
||||
<form action="/admin/logout" method="post" class="site-nav__logout-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token|default('', true) }}">
|
||||
<button type="submit" class="btn btn--link">Log out</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content" class="site-main" tabindex="-1">
|
||||
<div class="wrap">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="wrap site-footer__wrap">
|
||||
<p class="site-footer__tag">
|
||||
Chicken Babies R Us · Admin
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
85
app/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,85 @@
|
||||
{#
|
||||
Admin dashboard — post list + About edit link + new-post button.
|
||||
|
||||
Context:
|
||||
- user : app.models.entities.User (required)
|
||||
- posts : list[Post] (newest-updated first)
|
||||
- about : app.models.entities.Page | None
|
||||
- msg : str (PRG flash key)
|
||||
- csrf_token : str (for the inline forms)
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Dashboard — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="admin-dashboard">
|
||||
<header class="admin-dashboard__header">
|
||||
<h1 class="admin-dashboard__title">Dashboard</h1>
|
||||
<p class="admin-dashboard__greeting">
|
||||
Signed in as <code>{{ user.email }}</code>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{% if msg %}
|
||||
<p class="admin-flash admin-flash--ok" role="status">
|
||||
{% if msg == "created" %}Post created.
|
||||
{% elif msg == "saved" %}Changes saved.
|
||||
{% elif msg == "deleted" %}Post deleted.
|
||||
{% elif msg == "published" %}Post published.
|
||||
{% elif msg == "unpublished" %}Post moved to draft.
|
||||
{% else %}Done.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<section class="admin-dashboard__section">
|
||||
<div class="admin-dashboard__section-head">
|
||||
<h2>Posts</h2>
|
||||
<a class="btn btn--primary" href="/admin/posts/new">New post</a>
|
||||
</div>
|
||||
|
||||
{% if posts %}
|
||||
<table class="post-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Slug</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in posts %}
|
||||
{% include "admin/_post_row.html" %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="post-list__empty">No posts yet — create one.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__section">
|
||||
<div class="admin-dashboard__section-head">
|
||||
<h2>About page</h2>
|
||||
</div>
|
||||
|
||||
{% if about %}
|
||||
<p>
|
||||
<strong>{{ about.title }}</strong>
|
||||
— last updated
|
||||
<time datetime="{{ about.updated_at.isoformat() }}">
|
||||
{{ about.updated_at.strftime("%b %d, %Y") }}
|
||||
</time>
|
||||
</p>
|
||||
<p>
|
||||
<a class="btn btn--secondary" href="/admin/pages/about/edit">Edit About</a>
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="post-list__empty">About page is missing. Reseed to recover.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</section>
|
||||
{% endblock %}
|
||||
48
app/templates/admin/login.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{#
|
||||
Admin login form.
|
||||
|
||||
Single email input, no JS. If the submitted address is on the
|
||||
allowlist (ADMIN_EMAILS env var), the POST handler emails a
|
||||
one-time magic link; otherwise it silently succeeds without
|
||||
sending (anti-enumeration).
|
||||
|
||||
Context:
|
||||
- error : str | None (format validation errors only)
|
||||
- email : str (pre-fill on re-render after format error)
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Log in — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Admin log in</h1>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
Enter your admin email and we'll send a one-time login link
|
||||
that expires after 15 minutes.
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<p class="admin-flash admin-flash--error" role="alert">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="contact-form" action="/admin/login" method="post" novalidate>
|
||||
<div class="contact-form__field">
|
||||
<label for="admin-email">Email</label>
|
||||
<input type="email"
|
||||
id="admin-email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
value="{{ email or '' }}"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="contact-form__actions">
|
||||
<button type="submit" class="btn btn--primary">Send login link</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
28
app/templates/admin/login_failed.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{#
|
||||
Generic failure page for GET /admin/auth/consume/{token}.
|
||||
|
||||
Deliberately does NOT distinguish between:
|
||||
- token not found
|
||||
- token expired
|
||||
- token already consumed
|
||||
The audit log has the real reason; the visitor only needs to know
|
||||
they need a fresh link.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Login link invalid — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Login link invalid or expired</h1>
|
||||
</header>
|
||||
<p>
|
||||
That login link isn't valid any more. Links expire after 15
|
||||
minutes and can only be used once.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/admin/login">Request a new link</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
25
app/templates/admin/login_sent.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{#
|
||||
Post-submission page rendered after POST /admin/login.
|
||||
|
||||
Same copy for every outcome (allowlisted vs. not) to avoid leaking
|
||||
which emails are real admins.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Check your inbox — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Check your inbox</h1>
|
||||
</header>
|
||||
<p>
|
||||
If your email is on the allowlist, you'll receive a login link
|
||||
shortly. The link expires after 15 minutes and can only be used
|
||||
once.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/admin/login">Request another link</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
75
app/templates/admin/page_form.html
Normal file
@@ -0,0 +1,75 @@
|
||||
{#
|
||||
About page edit form.
|
||||
|
||||
Context:
|
||||
- user : app.models.entities.User
|
||||
- page : app.models.entities.Page
|
||||
- form : dict {title, body_md}
|
||||
- errors : dict {field_name: message}
|
||||
- csrf_token : str
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Edit About — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Edit About page</h1>
|
||||
<p>Slug: <code>{{ page.slug }}</code> · slug is fixed.</p>
|
||||
</header>
|
||||
|
||||
{% if errors %}
|
||||
<p class="admin-flash admin-flash--error" role="alert">
|
||||
{% for field, msg in errors.items() %}
|
||||
<span>{{ msg }}</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="editor" method="post" action="/admin/pages/about">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="editor__field">
|
||||
<label for="page-title">Title</label>
|
||||
<input type="text"
|
||||
id="page-title"
|
||||
name="title"
|
||||
value="{{ form.title|e }}"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="editor__split">
|
||||
<div class="editor__pane">
|
||||
<label for="page-body">Body (Markdown)</label>
|
||||
<textarea id="page-body"
|
||||
name="body_md"
|
||||
data-editor
|
||||
data-preview-target="#page-preview"
|
||||
rows="20">{{ form.body_md|e }}</textarea>
|
||||
|
||||
<div class="drop-zone" data-drop-zone
|
||||
aria-label="Drag an image here to upload and insert it">
|
||||
Drop an image here to upload & insert. Accepted: JPG, PNG, WebP up to 8 MB.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__pane">
|
||||
<span class="editor__label">Preview</span>
|
||||
<div id="page-preview" class="editor__preview" aria-live="polite">
|
||||
{{ page.body_html_cached|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__actions">
|
||||
<button type="submit" class="btn btn--primary">Save changes</button>
|
||||
<a class="btn btn--secondary" href="/admin">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script defer src="{{ url_for('static', path='js/admin_editor.js') }}"></script>
|
||||
{% endblock %}
|
||||
104
app/templates/admin/post_form.html
Normal file
@@ -0,0 +1,104 @@
|
||||
{#
|
||||
Shared create + edit form for posts.
|
||||
|
||||
Context:
|
||||
- user : app.models.entities.User
|
||||
- post : app.models.entities.Post | None (None on create)
|
||||
- form : dict {title, body_md, status}
|
||||
- errors : dict {field_name: message}
|
||||
- csrf_token : str
|
||||
|
||||
Status dropdown policy:
|
||||
- On create: draft is default, admin may pick published.
|
||||
- On edit: status is read-only here — use the toggle-publish
|
||||
button on the dashboard. Keeps the write path explicit.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% if post %}Edit post{% else %}New post{% endif %} — Admin
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">
|
||||
{% if post %}Edit post{% else %}New post{% endif %}
|
||||
</h1>
|
||||
{% if post %}
|
||||
<p>Slug: <code>{{ post.slug }}</code>
|
||||
{% if post.status.value == "published" %}
|
||||
· slug is locked because this post is published.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if errors %}
|
||||
<p class="admin-flash admin-flash--error" role="alert">
|
||||
{% for field, msg in errors.items() %}
|
||||
<span>{{ msg }}</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="editor"
|
||||
method="post"
|
||||
action="{% if post %}/admin/posts/{{ post.id }}{% else %}/admin/posts{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="editor__field">
|
||||
<label for="post-title">Title</label>
|
||||
<input type="text"
|
||||
id="post-title"
|
||||
name="title"
|
||||
value="{{ form.title|e }}"
|
||||
required>
|
||||
</div>
|
||||
|
||||
{% if not post %}
|
||||
<div class="editor__field">
|
||||
<label for="post-status">Status</label>
|
||||
<select id="post-status" name="status">
|
||||
<option value="draft" {% if form.status == "draft" %}selected{% endif %}>Draft</option>
|
||||
<option value="published" {% if form.status == "published" %}selected{% endif %}>Published</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="editor__split">
|
||||
<div class="editor__pane">
|
||||
<label for="post-body">Body (Markdown)</label>
|
||||
<textarea id="post-body"
|
||||
name="body_md"
|
||||
data-editor
|
||||
data-preview-target="#post-preview"
|
||||
rows="20">{{ form.body_md|e }}</textarea>
|
||||
|
||||
<div class="drop-zone" data-drop-zone
|
||||
aria-label="Drag an image here to upload and insert it">
|
||||
Drop an image here to upload & insert. Accepted: JPG, PNG, WebP up to 8 MB.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__pane">
|
||||
<span class="editor__label">Preview</span>
|
||||
<div id="post-preview" class="editor__preview" aria-live="polite">
|
||||
{% if post %}{{ post.body_html_cached|safe }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__actions">
|
||||
<button type="submit" class="btn btn--primary">
|
||||
{% if post %}Save changes{% else %}Create post{% endif %}
|
||||
</button>
|
||||
<a class="btn btn--secondary" href="/admin">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script defer src="{{ url_for('static', path='js/admin_editor.js') }}"></script>
|
||||
{% endblock %}
|
||||
25
app/templates/admin/rate_limited.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{#
|
||||
Rendered at HTTP 429 when either the SlowAPI IP limit or the DB-side
|
||||
per-email limit trips on POST /admin/login.
|
||||
|
||||
Same template for both trigger paths — we don't want to tell the
|
||||
submitter whether the limit was IP-wide or email-specific.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Too many attempts — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Too many attempts</h1>
|
||||
</header>
|
||||
<p>
|
||||
You've requested too many login links recently. Please wait a
|
||||
few minutes before trying again.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/admin/login">Back to login</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
43
app/templates/emails/contact_notification.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{#
|
||||
HTML body for the admin contact-form notification email.
|
||||
|
||||
Deliberately plain — email clients are hostile to CSS. ``message`` is
|
||||
rendered inside a <pre> so newlines submitted by the visitor are
|
||||
preserved without us having to manually convert them to <br>s (which
|
||||
bleach would strip anyway).
|
||||
|
||||
Jinja2 autoescape is on for .html templates; every field below is
|
||||
rendered via ``{{ ... }}`` without ``| safe``, so user input cannot
|
||||
escape into the DOM.
|
||||
|
||||
Context:
|
||||
- submission_name : str
|
||||
- submission_email : str
|
||||
- message : str
|
||||
- submitted_at : ISO-8601 str
|
||||
- ip : str
|
||||
#}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>New contact submission — Chicken Babies R Us</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #2B3A42; max-width: 560px; margin: 0 auto; padding: 24px;">
|
||||
<h1 style="font-size: 20px; margin: 0 0 16px;">New contact submission</h1>
|
||||
<p style="margin: 0 0 8px;"><strong>Name:</strong> {{ submission_name }}</p>
|
||||
<p style="margin: 0 0 8px;">
|
||||
<strong>Email:</strong>
|
||||
<a href="mailto:{{ submission_email }}" style="color: #5D8AA8;">{{ submission_email }}</a>
|
||||
</p>
|
||||
<p style="margin: 0 0 8px;"><strong>Submitted at:</strong> {{ submitted_at }}</p>
|
||||
<p style="margin: 0 0 16px;"><strong>IP:</strong> {{ ip }}</p>
|
||||
|
||||
<h2 style="font-size: 16px; margin: 16px 0 8px;">Message</h2>
|
||||
<pre style="white-space: pre-wrap; word-break: break-word; font-family: inherit; font-size: 14px; background: #FAF3E7; padding: 12px 16px; border-radius: 6px; margin: 0;">{{ message }}</pre>
|
||||
|
||||
<p style="color: #6b7a80; font-size: 13px; margin-top: 24px;">
|
||||
Replying to this email will respond directly to the sender
|
||||
({{ submission_email }}).
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
17
app/templates/emails/contact_notification.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
{# Plaintext body for the admin contact-form notification email.
|
||||
Mirrors the HTML version so clients that reject HTML still get a
|
||||
readable message. #}New contact submission
|
||||
======================
|
||||
|
||||
Name: {{ submission_name }}
|
||||
Email: {{ submission_email }}
|
||||
Submitted at: {{ submitted_at }}
|
||||
IP: {{ ip }}
|
||||
|
||||
Message
|
||||
-------
|
||||
{{ message }}
|
||||
|
||||
--
|
||||
Replying to this email will respond directly to the sender
|
||||
({{ submission_email }}).
|
||||
36
app/templates/emails/magic_link.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{#
|
||||
HTML body for the magic-link email.
|
||||
|
||||
Plain, minimal inline-styled markup — no external CSS because email
|
||||
clients are hostile to it. The link text IS the URL so users can
|
||||
verify the destination before clicking.
|
||||
|
||||
Context:
|
||||
- display_name : str
|
||||
- magic_link_url : str
|
||||
- expires_at : ISO-8601 str
|
||||
- ttl_min : int
|
||||
#}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Admin login link — Chicken Babies R Us</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #2B3A42; max-width: 560px; margin: 0 auto; padding: 24px;">
|
||||
<h1 style="font-size: 20px; margin: 0 0 16px;">Admin login link</h1>
|
||||
<p>Hi {{ display_name }},</p>
|
||||
<p>
|
||||
Use the link below to log in to the Chicken Babies R Us admin.
|
||||
The link works for {{ ttl_min }} minutes and can only be used once.
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ magic_link_url }}" style="color: #5D8AA8; word-break: break-all;">
|
||||
{{ magic_link_url }}
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #6b7a80; font-size: 13px;">
|
||||
Expires at {{ expires_at }}. If you didn't request this, you can
|
||||
safely ignore the email — no action will be taken.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
12
app/templates/emails/magic_link.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
{# Plaintext body for the magic-link email. Mirrors the HTML version. #}
|
||||
Hi {{ display_name }},
|
||||
|
||||
Use the link below to log in to the Chicken Babies R Us admin.
|
||||
The link works for {{ ttl_min }} minutes and can only be used once.
|
||||
|
||||
{{ magic_link_url }}
|
||||
|
||||
Expires at {{ expires_at }}.
|
||||
|
||||
If you didn't request this, you can safely ignore the email -- no
|
||||
action will be taken.
|
||||
37
app/templates/public/about.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{#
|
||||
About page. Phase 2: body comes from the ``pages`` row with
|
||||
slug='about', rendered via the Markdown pipeline (markdown-it-py →
|
||||
bleach allowlist) at write time and cached on the row. The cached
|
||||
HTML has already been sanitized against an allowlist that forbids
|
||||
scripts, styles, iframes, etc., so it is safe to emit with the
|
||||
``| safe`` filter (Jinja autoescape is explicitly disabled for the
|
||||
body only). Head Hen edits this content through the Phase 4 admin.
|
||||
|
||||
Per CLAUDE.md, the physical address is not shown anywhere on the
|
||||
site — only the town name.
|
||||
|
||||
Context:
|
||||
- page : app.models.entities.Page
|
||||
- active_nav : str "about"
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}{{ page.title }} — Chicken Babies R Us{% endblock %}
|
||||
{% block meta_description %}About Chicken Babies R Us — a small family farm in Morrison, Tennessee raising chickens, ducks, and geese.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">{{ page.title }}</h1>
|
||||
</header>
|
||||
|
||||
{#
|
||||
body_html_cached is the output of the bleach-sanitized
|
||||
Markdown pipeline. It contains only tags / attributes /
|
||||
protocols from our allowlist (p, strong, em, a, ul, ol, li,
|
||||
h1-h4, blockquote, code, pre, img, hr + href/src/etc.), so
|
||||
rendering with ``| safe`` does not reintroduce XSS risk.
|
||||
#}
|
||||
{{ page.body_html_cached | safe }}
|
||||
</article>
|
||||
{% endblock %}
|
||||
124
app/templates/public/base.html
Normal file
@@ -0,0 +1,124 @@
|
||||
{#
|
||||
Base layout for every public page.
|
||||
|
||||
Child templates override the following blocks:
|
||||
- title : the contents of <title>
|
||||
- meta_description : contents of <meta name="description">
|
||||
- content : the page body inside <main>
|
||||
|
||||
Design notes:
|
||||
- Semantic landmarks (<header>, <nav>, <main>, <footer>) for a11y.
|
||||
- Skip-link is the first focusable element so keyboard users can jump
|
||||
past the header.
|
||||
- aria-current="page" is applied to the active nav link by comparing
|
||||
the `active_nav` context variable the route passed us.
|
||||
- The mobile nav toggle uses addEventListener only — no inline event
|
||||
handlers — so we stay CSP-nonce-compatible when Phase 6 adds the
|
||||
strict CSP middleware.
|
||||
#}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Chicken Babies R Us{% endblock %}</title>
|
||||
<meta name="description" content="{% block meta_description %}Small-farm fresh eggs and happy birds, raised in Morrison, Tennessee.{% endblock %}">
|
||||
{# Self-hosted favicon + apple touch icon — no third-party CDNs. #}
|
||||
<link rel="icon" href="{{ url_for('static', path='img/favicon.ico') }}" sizes="any">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', path='img/apple-touch-icon.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/site.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
{# Skip link: hidden until focused. First focusable element on the page. #}
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="wrap site-header__wrap">
|
||||
<a class="site-header__brand" href="/" aria-label="Chicken Babies R Us home">
|
||||
{# Chick-only mark paired with the site title as styled text.
|
||||
Decoupling the mark from the wordmark lets the header colors
|
||||
change freely without the multi-colored logo text clashing. #}
|
||||
<picture>
|
||||
<source srcset="{{ url_for('static', path='img/logo-mark.webp') }}" type="image/webp">
|
||||
<img src="{{ url_for('static', path='img/logo-mark.png') }}"
|
||||
alt=""
|
||||
height="56"
|
||||
class="site-header__mark">
|
||||
</picture>
|
||||
<span class="site-header__title">Chicken Babies R Us</span>
|
||||
</a>
|
||||
|
||||
{# The mobile toggle button — script below attaches a click handler
|
||||
that flips aria-expanded and toggles .is-open on the nav. #}
|
||||
<button type="button"
|
||||
class="site-nav__toggle"
|
||||
id="nav-toggle"
|
||||
aria-controls="primary-nav"
|
||||
aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle navigation</span>
|
||||
<span class="site-nav__toggle-bar" aria-hidden="true"></span>
|
||||
<span class="site-nav__toggle-bar" aria-hidden="true"></span>
|
||||
<span class="site-nav__toggle-bar" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<nav class="site-nav" id="primary-nav" aria-label="Primary">
|
||||
<ul class="site-nav__list">
|
||||
<li class="site-nav__item">
|
||||
<a href="/"
|
||||
class="site-nav__link{% if active_nav == 'home' %} is-active{% endif %}"
|
||||
{% if active_nav == 'home' %}aria-current="page"{% endif %}>Home</a>
|
||||
</li>
|
||||
<li class="site-nav__item">
|
||||
<a href="/about"
|
||||
class="site-nav__link{% if active_nav == 'about' %} is-active{% endif %}"
|
||||
{% if active_nav == 'about' %}aria-current="page"{% endif %}>About</a>
|
||||
</li>
|
||||
<li class="site-nav__item">
|
||||
<a href="/contact"
|
||||
class="site-nav__link{% if active_nav == 'contact' %} is-active{% endif %}"
|
||||
{% if active_nav == 'contact' %}aria-current="page"{% endif %}>Contact</a>
|
||||
</li>
|
||||
<li class="site-nav__item">
|
||||
<a href="/shop"
|
||||
class="site-nav__link nav--muted{% if active_nav == 'shop' %} is-active{% endif %}"
|
||||
{% if active_nav == 'shop' %}aria-current="page"{% endif %}>Shop</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content" class="site-main" tabindex="-1">
|
||||
<div class="wrap">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="wrap site-footer__wrap">
|
||||
<p class="site-footer__tag">
|
||||
Chicken Babies R Us · Morrison, Tennessee
|
||||
</p>
|
||||
<p class="site-footer__legal">
|
||||
© {{ now_year or 2026 }} Chicken Babies R Us. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{# Mobile nav toggle. Tiny and CSP-friendly: no inline handlers, no JS
|
||||
framework. Phase 6's CSP will be compatible with moving this into an
|
||||
external file + nonce if we grow; for now the inline block stays. #}
|
||||
<script nonce="{{ request.state.csp_nonce }}">
|
||||
(function () {
|
||||
"use strict";
|
||||
var toggle = document.getElementById("nav-toggle");
|
||||
var nav = document.getElementById("primary-nav");
|
||||
if (!toggle || !nav) { return; }
|
||||
toggle.addEventListener("click", function () {
|
||||
var expanded = toggle.getAttribute("aria-expanded") === "true";
|
||||
toggle.setAttribute("aria-expanded", expanded ? "false" : "true");
|
||||
nav.classList.toggle("is-open");
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
134
app/templates/public/contact.html
Normal file
@@ -0,0 +1,134 @@
|
||||
{#
|
||||
Contact page — live Phase 5 form.
|
||||
|
||||
POSTs to /contact. Honeypot + hCaptcha + SlowAPI rate-limit protect
|
||||
the endpoint. Every field carries an id/label pair for a11y and a
|
||||
maxlength/minlength to match the server-side validator — the HTML5
|
||||
attributes are a UX hint only, not the security boundary.
|
||||
|
||||
Honeypot:
|
||||
- The ``website`` field is wrapped in a .visually-hidden container
|
||||
marked ``aria-hidden="true"`` so assistive tech hides it too.
|
||||
- It is NOT ``required`` and has ``tabindex="-1"`` so a keyboard
|
||||
user can't accidentally focus it.
|
||||
- The server rejects any submission where the field is non-empty.
|
||||
|
||||
hCaptcha:
|
||||
- When ``hcaptcha_site_key`` is truthy the widget div + script tag
|
||||
render. When empty (dev) we skip them and rely on the dev-mode
|
||||
fallback in :class:`HCaptchaService`.
|
||||
|
||||
Context:
|
||||
- contact_email : str | None (from settings.admin_contact_email)
|
||||
- active_nav : "contact"
|
||||
- errors : dict[str, str] (field_name -> message)
|
||||
- form : dict[str, str] (prior submitted values)
|
||||
- form_error : str | None (top-level error banner)
|
||||
- hcaptcha_site_key : str | None (rendered when truthy)
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}Contact — Chicken Babies R Us{% endblock %}
|
||||
{% block meta_description %}Get in touch with Chicken Babies R Us.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Get in touch</h1>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
We'd love to hear from you — questions about the birds,
|
||||
availability, or just to say hi.
|
||||
</p>
|
||||
|
||||
{% if contact_email %}
|
||||
<p class="contact-mailto">
|
||||
Prefer email? Reach us at
|
||||
<a href="mailto:{{ contact_email }}">{{ contact_email }}</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if form_error %}
|
||||
<p class="contact-form__error" role="alert">{{ form_error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="contact-form"
|
||||
method="POST"
|
||||
action="/contact"
|
||||
aria-describedby="contact-form-note">
|
||||
{# Honeypot — visually hidden + aria-hidden so neither sighted
|
||||
users nor screen readers encounter it. Bots fill it in and
|
||||
get silently filed as spam. #}
|
||||
<div class="visually-hidden" aria-hidden="true">
|
||||
<label for="contact-website">Website</label>
|
||||
<input type="text"
|
||||
id="contact-website"
|
||||
name="website"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
class="contact-hp"
|
||||
value="">
|
||||
</div>
|
||||
|
||||
<div class="contact-form__field">
|
||||
<label for="contact-name">Name</label>
|
||||
<input type="text"
|
||||
id="contact-name"
|
||||
name="name"
|
||||
autocomplete="name"
|
||||
required
|
||||
maxlength="80"
|
||||
value="{{ (form.name if form else '') or '' }}">
|
||||
{% if errors and errors.name %}
|
||||
<p class="contact-form__field-error" role="alert">{{ errors.name }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="contact-form__field">
|
||||
<label for="contact-email">Email</label>
|
||||
<input type="email"
|
||||
id="contact-email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
maxlength="254"
|
||||
value="{{ (form.email if form else '') or '' }}">
|
||||
{% if errors and errors.email %}
|
||||
<p class="contact-form__field-error" role="alert">{{ errors.email }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="contact-form__field">
|
||||
<label for="contact-message">Message</label>
|
||||
<textarea id="contact-message"
|
||||
name="message"
|
||||
rows="6"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="4000">{{ (form.message if form else '') or '' }}</textarea>
|
||||
{% if errors and errors.message %}
|
||||
<p class="contact-form__field-error" role="alert">{{ errors.message }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if hcaptcha_site_key %}
|
||||
<div class="contact-form__captcha">
|
||||
<div class="h-captcha" data-sitekey="{{ hcaptcha_site_key }}"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
{# hCaptcha disabled in dev — HCaptchaService returns True. #}
|
||||
{% endif %}
|
||||
|
||||
<div class="contact-form__actions">
|
||||
<button type="submit" class="btn btn--primary">
|
||||
Send message
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
{% if hcaptcha_site_key %}
|
||||
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
31
app/templates/public/contact_sent.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{#
|
||||
Contact success page — rendered after a successful POST /contact
|
||||
OR after a silent spam rejection (honeypot tripped / hCaptcha
|
||||
failed). Copy MUST stay identical across those branches so a bot
|
||||
operator can't use the response body to distinguish "we accepted
|
||||
your message" from "we filed your message under spam".
|
||||
|
||||
Context:
|
||||
- active_nav : "contact"
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}Message sent — Chicken Babies R Us{% endblock %}
|
||||
{% block meta_description %}Thanks for reaching out to Chicken Babies R Us.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Thanks for reaching out</h1>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
Your message is on its way to Head Hen. We'll get back to you as
|
||||
soon as the chickens let us.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a class="btn btn--primary" href="/">Back to the home page</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
35
app/templates/public/home.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{#
|
||||
Home page / blog index.
|
||||
|
||||
Receives:
|
||||
- posts : list[PostSummary] (empty in Phase 1)
|
||||
- active_nav : str "home"
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}Chicken Babies R Us — Home{% endblock %}
|
||||
{% block meta_description %}Updates from Chicken Babies R Us — a small family farm in Morrison, Tennessee.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-intro">
|
||||
<h1 class="page-intro__title">Welcome to Chicken Babies R Us</h1>
|
||||
<p class="page-intro__lede">
|
||||
A tiny family farm in Morrison, Tennessee. Follow along for updates
|
||||
on our flock, hatching plans, and whatever Head Hen is up to this week.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="post-list" aria-label="Latest posts">
|
||||
{% if posts %}
|
||||
{% for post in posts %}
|
||||
{% include "public/partials/_post_card.html" %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# Empty-state copy. Phase 2 seeds a welcome post so this state only
|
||||
ever shows up in unseeded dev databases and tests. #}
|
||||
<div class="post-list__empty">
|
||||
<p>No posts yet — check back soon!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
26
app/templates/public/partials/_post_card.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{#
|
||||
Single blog card. Rendered once per PostSummary in the home-page loop.
|
||||
|
||||
Expects the loop variable `post` in scope with:
|
||||
- post.slug (str)
|
||||
- post.title (str)
|
||||
- post.published_at (datetime)
|
||||
- post.excerpt (str)
|
||||
|
||||
The post detail page does not exist yet (Phase 2 adds it), but we link
|
||||
to /posts/<slug> anyway so the card markup is final. Phase 2 will
|
||||
register the route; until then the link 404s, which is acceptable
|
||||
because the post list itself is empty in Phase 1.
|
||||
#}
|
||||
<article class="post-card">
|
||||
<header class="post-card__header">
|
||||
<h2 class="post-card__title">
|
||||
<a href="/posts/{{ post.slug }}">{{ post.title }}</a>
|
||||
</h2>
|
||||
<time class="post-card__date"
|
||||
datetime="{{ post.published_at.isoformat() }}">
|
||||
{{ post.published_at.strftime("%B %-d, %Y") }}
|
||||
</time>
|
||||
</header>
|
||||
<p class="post-card__excerpt">{{ post.excerpt }}</p>
|
||||
</article>
|
||||
36
app/templates/public/post.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{#
|
||||
Single blog post detail page.
|
||||
|
||||
Receives:
|
||||
- post : app.models.entities.Post
|
||||
- active_nav : str "home"
|
||||
|
||||
``post.body_html_cached`` is the bleach-sanitized output of the
|
||||
Markdown pipeline (allowlisted tags/attrs/protocols only), so
|
||||
rendering with ``| safe`` does not reintroduce XSS risk. Same
|
||||
rationale as ``public/about.html``.
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} — Chicken Babies R Us{% endblock %}
|
||||
{% block meta_description %}{{ post.title }} — a post from Chicken Babies R Us.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">{{ post.title }}</h1>
|
||||
{% if post.published_at %}
|
||||
<time class="page-article__date"
|
||||
datetime="{{ post.published_at.isoformat() }}">
|
||||
{{ post.published_at.strftime("%B %-d, %Y") }}
|
||||
</time>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{{ post.body_html_cached | safe }}
|
||||
</article>
|
||||
|
||||
<p class="page-article__back">
|
||||
<a href="/">← Back to all posts</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
32
app/templates/public/shop.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{#
|
||||
Shop placeholder. Phase 7 replaces this with a real Stripe-backed
|
||||
catalog. For now the page itself is the "disabled" UI; the nav link
|
||||
uses the `nav--muted` class to hint that it isn't fully live.
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}Shop — Chicken Babies R Us{% endblock %}
|
||||
{% block meta_description %}Our farm shop is coming soon — eggs, chicks, and waterfowl.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Shop</h1>
|
||||
</header>
|
||||
|
||||
<section class="shop-card" aria-label="Shop status">
|
||||
<h2 class="shop-card__title">Coming soon</h2>
|
||||
<p class="shop-card__body">
|
||||
We're getting the farm shop ready. Soon you'll be able to order
|
||||
eating eggs, fertile hatching eggs, day-old chicks, and a small
|
||||
selection of waterfowl (ducks and geese) when available. Pickup
|
||||
will be local to Morrison; we'll share details here when the
|
||||
shop goes live.
|
||||
</p>
|
||||
<p class="shop-card__body">
|
||||
In the meantime, if you're looking for something specific, the
|
||||
contact page is the best way to reach us.
|
||||
</p>
|
||||
</section>
|
||||
</article>
|
||||
{% endblock %}
|
||||
0
data/.gitkeep
Normal file
0
data/media/.gitkeep
Normal file
69
docker-compose.prod.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — production compose file.
|
||||
#
|
||||
# Meant for the Debian 12 VM behind Caddy. Unlike docker-compose.yml (which
|
||||
# builds the image from source for local dev), this file pulls the pre-built
|
||||
# image from the Gitea container registry so the VM stays a thin runner.
|
||||
#
|
||||
# Ansible responsibilities (not this file):
|
||||
# - render /opt/chicken-babies/.env with real secrets
|
||||
# - ensure data/ exists and is owned by uid:gid 10001:10001
|
||||
# sudo install -d -o 10001 -g 10001 /opt/chicken-babies/data/media
|
||||
# sudo install -d -o 10001 -g 10001 /opt/chicken-babies/data/backups
|
||||
# - `docker login git.sneakygeek.net -u <user> -p <REGISTRY_TOKEN>`
|
||||
# - copy this file as /opt/chicken-babies/docker-compose.yml
|
||||
# - run `docker compose pull && docker compose up -d`
|
||||
#
|
||||
# Update flow on the VM (after a CI build publishes a new :latest):
|
||||
# docker compose pull
|
||||
# docker compose up -d # restarts only if the image SHA changed
|
||||
# docker image prune -f # reclaim space from the old layers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
services:
|
||||
web:
|
||||
image: git.sneakygeek.net/ptarrant/chicken_babies_site:latest
|
||||
# Docker pulls :latest on `docker compose pull`; no build context needed.
|
||||
pull_policy: always
|
||||
env_file:
|
||||
- .env
|
||||
# Override the Dockerfile CMD so uvicorn trusts X-Forwarded-* headers.
|
||||
# Caddy lives on another server (10.10.99.10) and speaks HTTP to this
|
||||
# VM on port 8080. Even so, the source IP *inside* the container is
|
||||
# the Docker bridge gateway (typically 172.17.0.1), NOT 10.10.99.10,
|
||||
# because Docker NATs the inbound connection. That means allowlisting
|
||||
# 10.10.99.10 would never match — uvicorn would still drop X-Forwarded-*
|
||||
# and Starlette would build http:// URLs under an https:// page,
|
||||
# tripping `img-src 'self'` CSP on the logo, fonts, etc.
|
||||
#
|
||||
# "*" is acceptable here because access to port 8080 is controlled
|
||||
# at the network layer (host firewall / VLAN) — only the Caddy box
|
||||
# can reach it. If you later move Caddy onto this same host, change
|
||||
# this back to a specific gateway IP.
|
||||
command: >
|
||||
uvicorn app.main:app
|
||||
--host 0.0.0.0
|
||||
--port 8080
|
||||
--proxy-headers
|
||||
--forwarded-allow-ips *
|
||||
ports:
|
||||
# Caddy on 10.10.99.10 reverse-proxies to <this-vm>:8080. Binding
|
||||
# on all interfaces keeps the compose portable; lock down access
|
||||
# with the VM's host firewall (nftables / ufw) or upstream
|
||||
# (OPNsense) to only permit 10.10.99.10 → :8080.
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# SQLite DB + media uploads live on the host so container rebuilds /
|
||||
# image rolls don't wipe content. The container runs as uid 10001;
|
||||
# the host dir must be chown'd to match (see Ansible notes above).
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
# Docker picks up the HEALTHCHECK baked into the image. `docker compose
|
||||
# ps` surfaces health status; systemd / Ansible tasks can gate on it.
|
||||
# Container-level logging caps so a chatty bot run doesn't fill the
|
||||
# VM disk before the host-side logrotate catches it.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — local / on-host compose file.
|
||||
#
|
||||
# Modern Docker Compose does not require a top-level `version:` key. The
|
||||
# `web` service builds the multi-stage Dockerfile, loads secrets from
|
||||
# `.env`, and mounts the runtime `data/` directory so the SQLite DB and
|
||||
# media uploads survive container restarts.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Pass the host's current commit SHA through at build time so
|
||||
# /healthz can report which build is live. Falls back to
|
||||
# "unknown" when the env var is unset, matching the Dockerfile
|
||||
# default.
|
||||
GIT_COMMIT_SHA: ${GIT_COMMIT_SHA:-unknown}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
# Uvicorn listens on 8080 inside the container (prod default). In
|
||||
# the production topology Caddy fronts this; for local runs it's
|
||||
# directly on the host loopback via the mapped port.
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# SQLite DB + media uploads live under data/. Mounting it keeps
|
||||
# state on the host so container rebuilds don't wipe content.
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
334
docs/MANUAL_TESTING.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# Manual Testing Checklist
|
||||
|
||||
Living document. Each phase appends its own section — do not delete older
|
||||
sections when the code behind them changes; mark items as superseded
|
||||
instead so the audit trail stays intact.
|
||||
|
||||
Run the site locally before walking through the list:
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:8000/` in a real browser (not just curl).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Public Site Skeleton
|
||||
|
||||
### Home (`/`)
|
||||
|
||||
- [ ] Page returns 200 and renders without console errors.
|
||||
- [ ] Header shows the Chicken Babies R Us logo at ~48px tall.
|
||||
- [ ] `<img>` `alt` attribute reads **Chicken Babies R Us**.
|
||||
- [ ] Nav items appear in order: Home · About · Contact · Shop.
|
||||
- [ ] "Home" is visibly the active nav link and carries `aria-current="page"`.
|
||||
- [ ] "Shop" nav link is visually muted (lower contrast) but still clickable.
|
||||
- [ ] Page intro ("Welcome to Chicken Babies R Us") is present.
|
||||
- [ ] With no posts in the DB, the empty-state reads **"No posts yet — check back soon!"**.
|
||||
- [ ] Footer shows "Chicken Babies R Us · Morrison, Tennessee".
|
||||
- [ ] No street address is visible anywhere on the page (CLAUDE.md constraint).
|
||||
|
||||
### About (`/about`)
|
||||
|
||||
- [ ] Page returns 200 and renders without console errors.
|
||||
- [ ] H1 reads **"About the farm"**.
|
||||
- [ ] Copy mentions Morrison, Tennessee by name.
|
||||
- [ ] Copy name-checks Head Hen.
|
||||
- [ ] No street address appears anywhere.
|
||||
- [ ] Nav marks "About" as active (`aria-current="page"`).
|
||||
|
||||
### Contact (`/contact`)
|
||||
|
||||
- [ ] Page returns 200 and renders without console errors.
|
||||
- [ ] H1 reads **"Get in touch"**.
|
||||
- [ ] When `ADMIN_CONTACT_EMAIL` is set, a `mailto:` link renders above the form.
|
||||
- [ ] When `ADMIN_CONTACT_EMAIL` is unset, the muted placeholder sentence appears and no `mailto:` link renders.
|
||||
- [ ] The note **"Secure contact form coming soon"** is visible.
|
||||
- [ ] Form fields (name, email, message) are visually disabled and cannot be typed into.
|
||||
- [ ] "Send message" button is visually disabled.
|
||||
- [ ] Form has no `method="POST"` attribute (view source).
|
||||
- [ ] Nav marks "Contact" as active.
|
||||
|
||||
### Shop (`/shop`)
|
||||
|
||||
- [ ] Page returns 200 and renders without console errors.
|
||||
- [ ] H1 reads **"Shop"**.
|
||||
- [ ] "Coming soon" card is visible with mention of eggs, chicks, and waterfowl.
|
||||
- [ ] Nav marks "Shop" as active.
|
||||
|
||||
### Responsive
|
||||
|
||||
Use the browser devtools responsive toolbar.
|
||||
|
||||
- [ ] **360 × 800 (mobile):** nav collapses behind a hamburger toggle; toggle opens/closes on click; logo remains legible; no horizontal scroll.
|
||||
- [ ] **768 × 1024 (tablet):** nav appears inline; layout uses full container width; no horizontal scroll.
|
||||
- [ ] **1280 × 800 (desktop):** content capped at `--max-width` (68rem ≈ 1088px); generous whitespace either side.
|
||||
|
||||
### Accessibility
|
||||
|
||||
- [ ] Tab-key order from top of page: skip link → logo → nav links → main content.
|
||||
- [ ] Pressing **Tab** from a cold page load reveals the skip link in the top-left corner.
|
||||
- [ ] Activating the skip link jumps focus into `<main>`.
|
||||
- [ ] Logo has a non-empty `alt` attribute ("Chicken Babies R Us").
|
||||
- [ ] Navigating with a screen reader announces each landmark (`header`, `nav`, `main`, `footer`).
|
||||
- [ ] Spot-check color contrast of `--c-ink` (#2B3A42) on `--c-cream` (#FAF3E7) — should be comfortably above WCAG AA for body text.
|
||||
|
||||
### Assets
|
||||
|
||||
- [ ] `/static/img/logo.png` loads and is roughly 256px tall.
|
||||
- [ ] `/static/img/logo.webp` loads with content-type `image/webp`.
|
||||
- [ ] `/static/img/favicon.ico` is requested by the browser and returns 200.
|
||||
- [ ] `/static/img/apple-touch-icon.png` is 180×180 and has a cream (#FAF3E7) background.
|
||||
|
||||
### Ops smoke
|
||||
|
||||
- [ ] `pytest -q` passes locally.
|
||||
- [ ] `python -c "from app.main import app"` exits cleanly.
|
||||
- [ ] `python scripts/generate_static_assets.py` regenerates the four asset files without error.
|
||||
- [ ] `docker compose config` still parses cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Admin CMS
|
||||
|
||||
Pre-requisites:
|
||||
- Logged in via the Phase 3 magic-link flow (dev-fallback URL in server logs).
|
||||
- Landed on the Phase 4 dashboard at `/admin`.
|
||||
|
||||
### Dashboard (`/admin`)
|
||||
|
||||
- [ ] Page returns 200.
|
||||
- [ ] Page title reads **"Dashboard"**.
|
||||
- [ ] Signed-in email appears in the greeting.
|
||||
- [ ] Posts table lists the seeded **"Welcome to the Farm"** row with status **Published**.
|
||||
- [ ] Each row shows Edit / Publish-or-Unpublish / Delete buttons.
|
||||
- [ ] Delete click triggers a confirmation dialog.
|
||||
- [ ] "New post" button is visible and links to `/admin/posts/new`.
|
||||
- [ ] "Edit About" button links to `/admin/pages/about/edit`.
|
||||
- [ ] `<meta name="csrf-token">` is present in the rendered HTML (view source).
|
||||
- [ ] The `cb_csrf` cookie is set with `SameSite=Lax`, `HttpOnly=false` (readable by JS).
|
||||
|
||||
### Create a post (`/admin/posts/new`)
|
||||
|
||||
- [ ] Form renders without errors.
|
||||
- [ ] Title + status + body fields visible; preview pane on the right.
|
||||
- [ ] Drop zone is visible below the textarea with prompt copy.
|
||||
- [ ] Typing in the textarea causes the preview to update within ~300ms.
|
||||
- [ ] Dragging a JPG / PNG / WebP image onto the drop zone uploads it; a Markdown image tag is inserted at the cursor.
|
||||
- [ ] Dropping a GIF, plain text file, or anything >8 MB triggers the `.is-error` state on the drop zone.
|
||||
- [ ] Submitting with a blank title re-renders with "Title is required." and preserves other fields.
|
||||
- [ ] Submitting a valid form 303-redirects to `/admin?msg=created`.
|
||||
|
||||
### Edit a post (`/admin/posts/{id}/edit`)
|
||||
|
||||
- [ ] Form is pre-populated with the post's current title + body.
|
||||
- [ ] Slug is rendered read-only below the title.
|
||||
- [ ] For a published post, the UI notes the slug is locked.
|
||||
- [ ] Saving updates the row; public `/` reflects the change immediately (no caching delay).
|
||||
|
||||
### Publish / unpublish / delete
|
||||
|
||||
- [ ] Publish button on a draft row flips the status to published and shows a "published" flash on the next dashboard load.
|
||||
- [ ] Unpublish button on a published row flips the status back to draft; `published_at` is preserved in the DB (check via `sqlite3 data/app.db`).
|
||||
- [ ] Delete button on any row removes it entirely after confirmation.
|
||||
|
||||
### About page edit (`/admin/pages/about/edit`)
|
||||
|
||||
- [ ] Form renders with the current About title and body.
|
||||
- [ ] There is no slug editor.
|
||||
- [ ] Saving updates the row and the public `/about` page on next load.
|
||||
|
||||
### Media upload
|
||||
|
||||
- [ ] `data/media/<yyyy>/<mm>/` is created lazily the first time an image is saved.
|
||||
- [ ] Uploaded files are stored under a random filename ending in `.jpg` regardless of the source format.
|
||||
- [ ] Hitting the `/media/<yyyy>/<mm>/<name>.jpg` URL directly serves the image at HTTP 200.
|
||||
- [ ] An uploaded transparent PNG comes through as an RGB JPEG (transparent areas become white).
|
||||
- [ ] Uploading an animated GIF is rejected with a generic error.
|
||||
- [ ] Uploading a >8 MB file is rejected.
|
||||
- [ ] Uploading while the `X-CSRF-Token` header is missing returns 403.
|
||||
|
||||
### CSRF
|
||||
|
||||
- [ ] Admin POST routes (`/admin/logout`, `/admin/posts`, `/admin/posts/*/delete`, `/admin/posts/*/publish`, `/admin/pages/about`, `/admin/media/upload`, `/admin/preview`) all return 403 when the submitted token does not match the cookie.
|
||||
- [ ] A legitimate form submission succeeds because both the cookie and the form field were issued during the previous GET.
|
||||
|
||||
### Public site regression
|
||||
|
||||
- [ ] `/` shows only published posts (newly created drafts do NOT appear).
|
||||
- [ ] A newly-published post shows at the top of `/` within one request.
|
||||
- [ ] `/about` shows the most recently edited copy.
|
||||
- [ ] No admin-facing text (status, dashboard wording) leaks into the public HTML.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Contact Form
|
||||
|
||||
Pre-requisites:
|
||||
- `ADMIN_CONTACT_EMAIL` set in `.env` (the destination inbox).
|
||||
- For the production-like happy path: `RESEND_API_KEY` + `RESEND_FROM`
|
||||
set; otherwise the send path logs `contact_notification_dev_fallback`
|
||||
and the admin inbox will not actually receive mail.
|
||||
- Optionally set `HCAPTCHA_SITE_KEY` + `HCAPTCHA_SECRET` to exercise
|
||||
the real widget; with both unset the dev fallback auto-passes and
|
||||
logs `hcaptcha_dev_fallback`.
|
||||
|
||||
### GET `/contact`
|
||||
|
||||
- [ ] Page returns 200 and renders without console errors.
|
||||
- [ ] H1 reads **"Get in touch"**.
|
||||
- [ ] Name, email, and message fields render as editable inputs (no `disabled` attribute).
|
||||
- [ ] "Send message" button is enabled.
|
||||
- [ ] Form has `method="POST"` and `action="/contact"` (view source).
|
||||
- [ ] Honeypot `<input name="website">` is present in the markup but
|
||||
wrapped in a `.visually-hidden` container marked
|
||||
`aria-hidden="true"` — it is invisible to sighted users.
|
||||
- [ ] When `HCAPTCHA_SITE_KEY` is set, the `h-captcha` div and the
|
||||
`https://js.hcaptcha.com/1/api.js` script appear. When unset,
|
||||
neither appears.
|
||||
- [ ] Nav marks "Contact" as active.
|
||||
|
||||
### Happy path
|
||||
|
||||
- [ ] Fill in Name, Email, Message (>= 10 chars) and submit.
|
||||
- [ ] Response is HTTP 200 and renders **"Thanks for reaching out"**.
|
||||
- [ ] `sqlite3 data/app.db "SELECT id, name, email, length(message), handled FROM contact_submissions"` shows the new row with `handled=0`.
|
||||
- [ ] Server log contains a `contact_submitted` structured event with a
|
||||
`message_preview` at most 40 chars long (no full body).
|
||||
- [ ] With `RESEND_API_KEY` set: admin inbox receives the notification
|
||||
email. `From:` matches `RESEND_FROM`; `Reply-To:` matches the
|
||||
submitted email; subject is `New contact submission from {name}`.
|
||||
- [ ] Without `RESEND_API_KEY`: server log contains
|
||||
`contact_notification_dev_fallback` with the submitter's name,
|
||||
email, and message length.
|
||||
|
||||
### Validation errors
|
||||
|
||||
- [ ] Submitting with a blank name shows **"Please enter your name."** inline.
|
||||
- [ ] Submitting with `not-an-email` shows **"Please enter a valid email address."**.
|
||||
- [ ] Submitting with a 9-character message shows
|
||||
**"Message must be at least 10 characters."**.
|
||||
- [ ] Submitting with a > 4000-character message shows
|
||||
**"Message must be 4000 characters or fewer."**.
|
||||
- [ ] Submitting with a > 80-character name shows
|
||||
**"Name must be 80 characters or fewer."**.
|
||||
- [ ] The response status code on every validation failure is **400**.
|
||||
- [ ] Prior valid values remain filled in so the user doesn't retype.
|
||||
|
||||
### Spam paths
|
||||
|
||||
- [ ] Filling the honeypot `website` field and submitting returns
|
||||
**"Thanks for reaching out"** (same as success) AND no row is
|
||||
persisted AND an audit row with
|
||||
`event_type='contact_spam_rejected'` and `reason=honeypot` exists.
|
||||
- [ ] With a real hCaptcha configured: submitting without solving the
|
||||
widget returns the same generic thank-you page. Audit row:
|
||||
`contact_spam_rejected` / `reason=hcaptcha`. No DB row.
|
||||
|
||||
### Rate limit
|
||||
|
||||
- [ ] Submit the form **4 times** from the same browser session within
|
||||
an hour. The fourth submission returns HTTP **429** and renders
|
||||
the "Too many attempts" template. A `rate_limited` audit row is
|
||||
added with `scope=ip` and `endpoint=/contact`.
|
||||
|
||||
### Email send failure
|
||||
|
||||
- [ ] With a valid form but `RESEND_API_KEY` pointed at an invalid key:
|
||||
the user still sees **"Thanks for reaching out"**, the DB row is
|
||||
created, and the server log contains
|
||||
`contact_notification_failed` (logged by EmailService). The user
|
||||
experience is indistinguishable from success.
|
||||
|
||||
### Ops smoke
|
||||
|
||||
- [ ] `pytest -q tests/test_hcaptcha_service.py tests/test_contact_service.py tests/test_contact_routes.py` passes.
|
||||
- [ ] `python -c "from app.main import app; print(len(app.routes))"` prints a count greater than the Phase 4 count (the new `POST /contact` adds one route).
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Hardening + Deploy
|
||||
|
||||
Run a dev server alongside these checks:
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
### Security headers (`/`, `/contact`, `/admin/login`)
|
||||
|
||||
- [ ] `curl -sI http://127.0.0.1:8000/ | grep -i content-security-policy`
|
||||
returns a policy containing `nonce-<...>`, `frame-ancestors 'none'`,
|
||||
`form-action 'self'`, and `https://js.hcaptcha.com` in `script-src`.
|
||||
- [ ] Two consecutive `curl -sI /` calls print **different** `nonce-<...>`
|
||||
values — the middleware mints one per request.
|
||||
- [ ] `X-Content-Type-Options: nosniff`, `Referrer-Policy:
|
||||
strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy:
|
||||
same-origin`, and a `Permissions-Policy` disabling camera /
|
||||
geolocation / microphone are all present on the response.
|
||||
- [ ] In development, `Strict-Transport-Security` is **absent** (so
|
||||
localhost over HTTP keeps working).
|
||||
- [ ] Set `APP_ENV=production` + all prod-required env vars and restart;
|
||||
confirm `Strict-Transport-Security: max-age=31536000;
|
||||
includeSubDomains` now appears.
|
||||
- [ ] Browser devtools console on `/` shows the inline nav-toggle
|
||||
script executes (the mobile hamburger still toggles the menu).
|
||||
No "Refused to execute inline script" CSP violations appear.
|
||||
- [ ] On `/contact`, the hCaptcha widget renders and its API script
|
||||
loads (no CSP violations in the console).
|
||||
- [ ] On any admin page, `admin_editor.js` loads and the live preview
|
||||
still updates as you type.
|
||||
|
||||
### Access log (`/healthz` quiet, magic-link redacted)
|
||||
|
||||
- [ ] Normal page loads emit one `http_request` line with `method`,
|
||||
`path`, `status_code`, `duration_ms`, `client_ip`, and
|
||||
`user_agent` fields.
|
||||
- [ ] `curl http://127.0.0.1:8000/healthz` does **not** produce an
|
||||
`http_request` log line (the middleware skips `/healthz`).
|
||||
- [ ] Request a magic link, then hit the consume URL in the browser.
|
||||
The server log shows `path=/admin/auth/consume/<redacted>` —
|
||||
the raw token never appears in stdout.
|
||||
|
||||
### Dockerfile hardening
|
||||
|
||||
- [ ] `docker compose build` succeeds.
|
||||
- [ ] `docker compose up -d` brings the service to healthy; inspect
|
||||
with `docker compose ps` — the STATUS column reads `(healthy)`
|
||||
within ~30s of startup.
|
||||
- [ ] `docker compose exec app id` reports `uid=10001(app) gid=10001(app)`.
|
||||
- [ ] `docker inspect --format '{{.State.Health.Status}}' <container>`
|
||||
returns `healthy`.
|
||||
|
||||
### Backup script
|
||||
|
||||
- [ ] `./scripts/backup.sh` exits 0 and produces
|
||||
`data/backups/app-<ts>.db` and `data/backups/media-<ts>.tar.gz`.
|
||||
- [ ] `sqlite3 data/backups/app-<ts>.db "SELECT COUNT(*) FROM posts"`
|
||||
returns the same count as the live DB.
|
||||
- [ ] Run the script 15 times in a loop; after the 15th run, exactly
|
||||
14 `app-*.db` and 14 `media-*.tar.gz` artifacts remain in
|
||||
`data/backups/` (newest kept, older pruned).
|
||||
- [ ] Install the cron entry on the VM (example):
|
||||
`15 3 * * * cd /srv/chicken_babies_site && ./scripts/backup.sh
|
||||
>> /var/log/chicken_babies/backup.log 2>&1`.
|
||||
|
||||
### Gitea Actions workflow
|
||||
|
||||
- [ ] Push a commit to `master` on Gitea and confirm the
|
||||
`Build and Push Docker Image` workflow runs to green.
|
||||
- [ ] `git.sneakygeek.net/ptarrant/chicken_babies_site:latest` and
|
||||
`:sha-<short>` tags exist in the registry afterwards.
|
||||
- [ ] Pulling the `:latest` image and hitting `/healthz` reports the
|
||||
expected `commit_sha` (matches the built commit).
|
||||
|
||||
### Ops smoke
|
||||
|
||||
- [ ] `pytest -q` passes with no new failures beyond the known
|
||||
pre-existing two (`test_production_missing_key_refuses_startup`,
|
||||
`test_layout_includes_logo_image`).
|
||||
- [ ] `python -c "from app.main import app; print(len(app.routes))"`
|
||||
prints the same route count as before Phase 6 (no new routes).
|
||||
27
docs/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Documentation Folder
|
||||
|
||||
This folder contains business planning, architecture decisions, and documentation.
|
||||
|
||||
**No application code belongs here.**
|
||||
|
||||
## Contents
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| `ROADMAP.md` | Phased build plan, data model (dataclasses), SQL schema, visual design, env-var contract |
|
||||
| `code_guidelines.md` | Generic Python coding standards (FastAPI overrides its Flask default for this project) |
|
||||
| `security.md` | Python security baseline (OWASP-aligned) |
|
||||
| `MANUAL_TESTING.md` *(added in Phase 1)* | Manual test checklist for the public site + admin |
|
||||
|
||||
## Related Docs (in repo root)
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| `../CLAUDE.md` | Project instructions — stack, topology, security must-haves, git flow |
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep documents focused and concise.
|
||||
- Update docs when architecture decisions change.
|
||||
- Use markdown tables for structured information.
|
||||
- Link to external documentation where relevant.
|
||||
553
docs/ROADMAP.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# 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` |
|
||||
122
docs/code_guidelines.md
Normal file
@@ -0,0 +1,122 @@
|
||||
### Coding Standards
|
||||
|
||||
**Style & Structure**
|
||||
- Prefer longer, explicit code over compact one-liners
|
||||
- Always include docstrings for functions/classes + inline comments
|
||||
- Strongly prefer OOP-style code (classes over functional/nested functions)
|
||||
- Strong typing throughout (dataclasses, TypedDict, Enums, type hints)
|
||||
- Value future-proofing and expanded usage insights
|
||||
|
||||
**Data Design**
|
||||
- Use dataclasses for internal data modeling
|
||||
- Typed JSON structures
|
||||
- Functions return fully typed objects (no loose dicts)
|
||||
- Snapshot files in JSON or YAML
|
||||
- Human-readable fields (e.g., `scan_duration`)
|
||||
|
||||
**Templates & UI**
|
||||
- Don't mix large HTML/CSS blocks in Python code
|
||||
- Prefer Jinja templates for HTML rendering
|
||||
- Clean CSS, minimal inline clutter, readable template logic
|
||||
|
||||
**Writing & Documentation**
|
||||
- Markdown documentation
|
||||
- Clear section headers
|
||||
- Roadmap/Phase/Feature-Session style documents
|
||||
- Boilerplate templates first, then refinements
|
||||
|
||||
**Logging**
|
||||
- Use structlog (pip package)
|
||||
- Setup logging at app start: `logger = logging.get_logger(__file__)`
|
||||
|
||||
**Preferred Pip Packages**
|
||||
- API/Web Server: Flask
|
||||
- HTTP: Requests
|
||||
- Logging: Structlog
|
||||
- Scheduling: APScheduler
|
||||
|
||||
> **Per-project override:** the `chicken_babies_site` project uses **FastAPI** (not Flask). See `../CLAUDE.md` for the full authoritative stack for that project.
|
||||
|
||||
### Error Handling
|
||||
- Custom exception classes for domain-specific errors
|
||||
- Consistent error response formats (JSON structure)
|
||||
- Logging severity levels (ERROR vs WARNING)
|
||||
|
||||
### Configuration
|
||||
- Each component has environment-specific configs in its own `/config/*.yaml`
|
||||
- API: `/api/config/development.yaml`, `/api/config/production.yaml`
|
||||
- Web: `/public_web/config/development.yaml`, `/public_web/config/production.yaml`
|
||||
- `.env` for secrets (never committed)
|
||||
- Maintain `.env.example` in each component for documentation
|
||||
- Typed config loaders using dataclasses
|
||||
- Validation on startup
|
||||
|
||||
### Containerization & Deployment
|
||||
- Explicit Dockerfiles
|
||||
- Production-friendly hardening (distroless/slim when meaningful)
|
||||
- Clear build/push scripts that:
|
||||
- Use git branch as tag
|
||||
- Ask whether to tag `:latest`
|
||||
- Ask whether to push
|
||||
- Support private registries
|
||||
|
||||
### API Design
|
||||
- RESTful conventions
|
||||
- Versioning strategy (`/api/v1/...`)
|
||||
- Standardized response format:
|
||||
|
||||
```json
|
||||
{
|
||||
"app": "<APP NAME>",
|
||||
"version": "<APP VERSION>",
|
||||
"status": <HTTP STATUS CODE>,
|
||||
"timestamp": "<UTC ISO8601>",
|
||||
"request_id": "<optional request id>",
|
||||
"result": <data OR null>,
|
||||
"error": {
|
||||
"code": "<optional machine code>",
|
||||
"message": "<human message>",
|
||||
"details": {}
|
||||
},
|
||||
"meta": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency Management
|
||||
- Use `requirements.txt` and virtual environments (`python3 -m venv venv`)
|
||||
- Use path `venv` for all virtual environments
|
||||
- Pin versions to version ranges
|
||||
- Activate venv before running code (unless in Docker)
|
||||
|
||||
### Testing Standards
|
||||
- Manual testing preferred for applications
|
||||
- **API Backend:** Maintain `api/docs/API_TESTING.md` with endpoint examples, curl/httpie commands, expected responses
|
||||
- **Unit tests:** Use pytest for API backend (`api/tests/`)
|
||||
- **Web Frontend:** If using a web frontend, Manual testing checklist are created in `public_web/docs`
|
||||
|
||||
### Git Standards
|
||||
|
||||
**Branch Strategy:**
|
||||
- `master` - Production-ready code only
|
||||
- `dev` - Main development branch, integration point
|
||||
- `beta` - (Optional) Public pre-release testing
|
||||
|
||||
**Workflow:**
|
||||
- Feature work branches off `dev` (e.g., `feature/add-scheduler`)
|
||||
- Merge features back to `dev` for testing
|
||||
- Promote `dev` → `beta` for public testing (when applicable)
|
||||
- Promote `beta` (or `dev`) → `master` for production
|
||||
|
||||
**Commit Messages:**
|
||||
- Use conventional commit format: `feat:`, `fix:`, `docs:`, `refactor:`, etc.
|
||||
- Keep commits atomic and focused
|
||||
- Write clear, descriptive messages
|
||||
|
||||
**Tagging:**
|
||||
- Tag releases on `master` with semantic versioning (e.g., `v1.2.3`)
|
||||
- Optionally tag beta releases (e.g., `v1.2.3-beta.1`)
|
||||
|
||||
---
|
||||
|
||||
## Workflow Preference
|
||||
I follow a pattern: **brainstorm → design → code → revise**
|
||||
46
docs/security.md
Normal file
@@ -0,0 +1,46 @@
|
||||
## Foundational Security Instructions
|
||||
|
||||
- Act as a security-aware software engineer generating secure Python code.
|
||||
- Produce implementations that are **secure-by-design and secure-by-default**, not merely cosmetically "secured."
|
||||
- Focus on **preventing vulnerabilities**, not renaming functions or adding superficial security wrappers.
|
||||
- Explicitly identify **trust boundaries** (user input, external systems, internal components) and apply stricter controls at all boundary crossings.
|
||||
- Treat **all external input as untrusted by default**, regardless of source, and validate or sanitize it before use.
|
||||
- Explicitly consider **data sensitivity** (e.g., public, internal, confidential, regulated) and enforce controls appropriate to the highest sensitivity level involved.
|
||||
- Clearly distinguish between **authentication**, **authorization**, and **session management**, and never conflate their responsibilities.
|
||||
- Ensure implementations **fail securely**: errors, exceptions, and edge cases MUST NOT expose sensitive data or weaken security guarantees.
|
||||
- Use inline comments (when generating code) to clearly highlight critical security controls, assumptions, and security-relevant design decisions.
|
||||
- Adhere strictly to OWASP best practices, with particular consideration for the OWASP ASVS.
|
||||
- **Avoid slopsquatting and dependency confusion**: never guess package names or APIs; only reference well-known, reputable, and maintained libraries. Explicitly note any uncommon or low-reputation dependencies.
|
||||
- Do not hardcode secrets, credentials, tokens, or cryptographic material. Always require secure external configuration or secret management mechanisms.
|
||||
|
||||
---
|
||||
|
||||
## Common Weaknesses for Python
|
||||
|
||||
### CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
|
||||
**Summary:** Failure to properly sanitize or encode user input can lead to injection of malicious scripts into web pages, enabling XSS attacks.
|
||||
**Mitigation Rule:** All user input rendered in web pages MUST be sanitized and contextually encoded using a secure library such as `bleach` or `html.escape`.
|
||||
|
||||
### CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
|
||||
**Summary:** Unsanitized user input in SQL queries can allow attackers to execute arbitrary SQL commands, compromising data integrity and confidentiality.
|
||||
**Mitigation Rule:** SQL queries MUST use parameterized statements or prepared statements provided by libraries such as `sqlite3` or `SQLAlchemy`. Direct concatenation of user input into queries MUST NOT be used.
|
||||
|
||||
### CWE-327: Use of a Broken or Risky Cryptographic Algorithm
|
||||
**Summary:** Using outdated or insecure cryptographic algorithms can compromise data confidentiality and integrity.
|
||||
**Mitigation Rule:** Cryptographic operations MUST use secure algorithms provided by the `cryptography` library. Deprecated algorithms such as MD5 or SHA-1 MUST NOT be used.
|
||||
|
||||
### CWE-798: Use of Hard-coded Credentials
|
||||
**Summary:** Hardcoding credentials in source code can lead to unauthorized access if the code is exposed or leaked.
|
||||
**Mitigation Rule:** Secrets, credentials, and tokens MUST be stored securely using environment variables, secret management tools, or configuration files outside the source code repository.
|
||||
|
||||
### CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
|
||||
**Summary:** Improper error handling or logging can expose sensitive data to unauthorized users.
|
||||
**Mitigation Rule:** Error messages and logs MUST NOT include sensitive information such as stack traces, database connection strings, or user credentials. Use logging libraries such as `logging` with appropriate log levels and sanitization.
|
||||
|
||||
### CWE-502: Deserialization of Untrusted Data
|
||||
**Summary:** Deserializing untrusted data can lead to arbitrary code execution or data tampering.
|
||||
**Mitigation Rule:** Deserialization MUST only be performed on trusted data sources. Unsafe libraries such as `pickle` MUST NOT be used for deserialization of untrusted input.
|
||||
|
||||
### CWE-829: Inclusion of Functionality from Untrusted Control Sphere
|
||||
**Summary:** Using dependencies or code from untrusted sources can introduce malicious functionality or vulnerabilities.
|
||||
**Mitigation Rule:** Dependencies MUST be sourced from reputable package repositories such as PyPI. Verify the integrity and reputation of packages before use, and pin dependency versions to avoid supply chain attacks.
|
||||
17
requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
fastapi>=0.115,<0.120
|
||||
uvicorn[standard]>=0.32,<0.35
|
||||
jinja2>=3.1,<4.0
|
||||
pydantic>=2.9,<3.0
|
||||
pydantic-settings>=2.6,<3.0
|
||||
sqlalchemy>=2.0,<2.1
|
||||
markdown-it-py>=3.0,<4.0
|
||||
bleach>=6.2,<7.0
|
||||
Pillow>=11.0,<12.0
|
||||
python-magic>=0.4.27,<0.5
|
||||
resend>=2.4,<3.0
|
||||
slowapi>=0.1.9,<0.2
|
||||
structlog>=24.4,<26.0
|
||||
itsdangerous>=2.2,<3.0
|
||||
python-multipart>=0.0.12,<0.1
|
||||
pytest>=8.3,<9.0
|
||||
httpx>=0.28,<0.29
|
||||
65
run_dev.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — local dev launcher.
|
||||
#
|
||||
# Runs uvicorn directly on the host (no docker). Used for fast iteration on
|
||||
# routes, templates, CSS, and middleware. Set APP_ENV=production in front
|
||||
# of this script if you want to smoke-test the prod-only code paths (HSTS,
|
||||
# JSON logs, prod-config guardrails).
|
||||
#
|
||||
# Usage:
|
||||
# ./run_dev.sh # dev mode, reload on
|
||||
# ./run_dev.sh --no-reload # dev mode, reload off
|
||||
# PORT=8001 ./run_dev.sh # override listen port (default 8000)
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
# Anchor paths to the repo root so the script works from any cwd.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
VENV_DIR="venv"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
|
||||
PORT="${PORT:-8080}"
|
||||
HOST="${HOST:-127.0.0.1}"
|
||||
|
||||
# 1. Virtualenv -------------------------------------------------------------
|
||||
if [[ ! -x "${VENV_DIR}/bin/python" ]]; then
|
||||
echo "==> Creating venv at ${VENV_DIR}/"
|
||||
"${PYTHON_BIN}" -m venv "${VENV_DIR}"
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source "${VENV_DIR}/bin/activate"
|
||||
|
||||
# 2. Requirements (reinstall if requirements.txt is newer than the stamp) ---
|
||||
STAMP="${VENV_DIR}/.requirements.stamp"
|
||||
if [[ requirements.txt -nt "${STAMP}" ]]; then
|
||||
echo "==> Installing / updating dependencies"
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet -r requirements.txt
|
||||
touch "${STAMP}"
|
||||
fi
|
||||
|
||||
# 3. .env ------------------------------------------------------------------
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "==> No .env found; copying from .env.example"
|
||||
cp .env.example .env
|
||||
echo " Edit .env to set SECRET_KEY / RESEND_API_KEY / HCAPTCHA_* as needed."
|
||||
fi
|
||||
|
||||
# 4. Data dir --------------------------------------------------------------
|
||||
mkdir -p data/media data/backups
|
||||
|
||||
# 5. Launch ----------------------------------------------------------------
|
||||
RELOAD_FLAG="--reload"
|
||||
if [[ "${1:-}" == "--no-reload" ]]; then
|
||||
RELOAD_FLAG=""
|
||||
fi
|
||||
|
||||
echo "==> uvicorn on http://${HOST}:${PORT} (APP_ENV=${APP_ENV:-development})"
|
||||
# shellcheck disable=SC2086
|
||||
exec uvicorn app.main:app \
|
||||
--host "${HOST}" \
|
||||
--port "${PORT}" \
|
||||
${RELOAD_FLAG} \
|
||||
--proxy-headers \
|
||||
--forwarded-allow-ips 127.0.0.1
|
||||
64
scripts/backup.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# scripts/backup.sh — local SQLite + media snapshot
|
||||
#
|
||||
# Produces two artifacts under data/backups/:
|
||||
# app-<ts>.db : SQLite .backup of the live database (safe while
|
||||
# the app is running; sqlite3 .backup coordinates
|
||||
# with WAL).
|
||||
# media-<ts>.tar.gz : tar archive of the media upload directory.
|
||||
#
|
||||
# Retention: keep the 14 newest of each artifact; prune the rest.
|
||||
#
|
||||
# The script is intended to be invoked by cron on the Debian VM. Example
|
||||
# crontab line (daily at 03:15 UTC):
|
||||
# 15 3 * * * cd /srv/chicken_babies_site && ./scripts/backup.sh \
|
||||
# >> /var/log/chicken_babies/backup.log 2>&1
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 on success. Any failing step aborts via `set -e` with a non-zero
|
||||
# status so cron emails / log scrapers can flag it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# UTC timestamp: sortable, compact, unambiguous. Matches the ISO-8601
|
||||
# "basic" form with a literal Z suffix.
|
||||
TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
BACKUP_DIR="data/backups"
|
||||
DB_SRC="data/app.db"
|
||||
MEDIA_SRC="data/media"
|
||||
|
||||
# Ensure the output directory exists. `mkdir -p` is idempotent and never
|
||||
# fails if the directory already exists, which is exactly what we want.
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
# --- SQLite snapshot ------------------------------------------------------
|
||||
# Use sqlite3's .backup command rather than `cp` so we get a consistent
|
||||
# copy even if the database is actively being written to. sqlite3 serialises
|
||||
# with the WAL and produces a file that's safe to open elsewhere.
|
||||
DB_DEST="${BACKUP_DIR}/app-${TS}.db"
|
||||
sqlite3 "${DB_SRC}" ".backup ${DB_DEST}"
|
||||
|
||||
# --- Media archive --------------------------------------------------------
|
||||
# tar the whole media tree. gzip compression keeps the archive small for
|
||||
# typical image payloads; restore is just `tar -xzf`.
|
||||
MEDIA_DEST="${BACKUP_DIR}/media-${TS}.tar.gz"
|
||||
tar -czf "${MEDIA_DEST}" "${MEDIA_SRC}"
|
||||
|
||||
# --- Retention prune -------------------------------------------------------
|
||||
# Keep the 14 newest of each type; delete the rest. ls -t sorts newest-
|
||||
# first; tail -n +15 drops the first 14 lines (the survivors). We guard
|
||||
# each rm with `|| true` so a cold start with fewer than 14 artifacts
|
||||
# doesn't cause the script to exit non-zero via `set -e`.
|
||||
keep_newest_14() {
|
||||
local pattern="$1"
|
||||
# shellcheck disable=SC2012 # `ls -t` is the simplest way to sort by mtime
|
||||
ls -1t ${pattern} 2>/dev/null | tail -n +15 | xargs -r rm -f
|
||||
}
|
||||
|
||||
keep_newest_14 "${BACKUP_DIR}/app-*.db"
|
||||
keep_newest_14 "${BACKUP_DIR}/media-*.tar.gz"
|
||||
|
||||
echo "backup complete: ${DB_DEST}, ${MEDIA_DEST}"
|
||||
283
scripts/generate_static_assets.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""Generate static image assets (logo + favicons) from the brand source.
|
||||
|
||||
This script is the single source of truth for every image under
|
||||
``app/static/img/``. Running it re-derives:
|
||||
|
||||
- ``logo.png`` — 256px tall, transparent RGBA
|
||||
- ``logo.webp`` — same size, WebP quality=82, method=6
|
||||
- ``logo-mark.png`` — chick-only mark, 128px tall, transparent RGBA
|
||||
- ``logo-mark.webp`` — same, WebP quality=82, method=6
|
||||
- ``favicon.ico`` — multi-size 16/32/48 from a square crop
|
||||
- ``apple-touch-icon.png`` — 180x180 with a cream background
|
||||
|
||||
Running it is reproducible and idempotent. The generated files are
|
||||
committed to the repo so a fresh clone can serve the site without a
|
||||
build step, but they should be regenerated (by running this script) any
|
||||
time the brand source in ``Logo/`` changes.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python scripts/generate_static_assets.py
|
||||
|
||||
The script assumes it is run from the repository root. It resolves paths
|
||||
relative to this file's location so the working directory does not
|
||||
matter in practice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
# --- Configuration constants ------------------------------------------------
|
||||
# Single-source-of-truth values. Tweaking any of these is intended to be
|
||||
# the *only* change needed to retune the assets — no other code edits.
|
||||
|
||||
# Paths are resolved relative to the repository root (the parent of the
|
||||
# directory this script lives in) so the script can be run from anywhere.
|
||||
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
|
||||
_SOURCE_LOGO: Path = _REPO_ROOT / "Logo" / "chicken babies r us.png"
|
||||
_STATIC_IMG_DIR: Path = _REPO_ROOT / "app" / "static" / "img"
|
||||
|
||||
# Target output sizes.
|
||||
_LOGO_TARGET_HEIGHT_PX: int = 256 # 2x the 48px display height in the header.
|
||||
_LOGO_MARK_TARGET_HEIGHT_PX: int = 128 # ~2x the 56px header-icon display size.
|
||||
_WEBP_QUALITY: int = 82
|
||||
_WEBP_METHOD: int = 6 # 0 = fastest, 6 = best compression.
|
||||
_FAVICON_SIZES: tuple[tuple[int, int], ...] = ((16, 16), (32, 32), (48, 48))
|
||||
_APPLE_TOUCH_SIZE: int = 180
|
||||
# Cream background that matches --c-cream in site.css, so the icon does
|
||||
# not show as transparent (iOS squares this asset against a white/black
|
||||
# home screen).
|
||||
_APPLE_TOUCH_BG: tuple[int, int, int, int] = (0xFA, 0xF3, 0xE7, 0xFF)
|
||||
|
||||
|
||||
class StaticAssetBuilder:
|
||||
"""Build every derived static image from a single PNG source.
|
||||
|
||||
Encapsulated as a class so each transformation step is an
|
||||
independently-testable method and state (e.g. the loaded source
|
||||
image) is shared without relying on module globals.
|
||||
"""
|
||||
|
||||
def __init__(self, source_path: Path, output_dir: Path) -> None:
|
||||
"""Load the source image and prepare the output directory.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source_path:
|
||||
Path to the brand-source PNG. Must exist on disk.
|
||||
output_dir:
|
||||
Directory where every generated asset is written. Created if
|
||||
it does not already exist.
|
||||
"""
|
||||
if not source_path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"Source logo not found at {source_path}. "
|
||||
"Re-check the Logo/ directory."
|
||||
)
|
||||
self._source_path = source_path
|
||||
self._output_dir = output_dir
|
||||
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Always load as RGBA so alpha compositing (apple-touch-icon
|
||||
# background) and WebP export behave predictably.
|
||||
with Image.open(source_path) as raw:
|
||||
self._source: Image.Image = raw.convert("RGBA")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Individual asset builders
|
||||
# ------------------------------------------------------------------
|
||||
def build_logo_png(self) -> Path:
|
||||
"""Write the RGBA PNG version of the logo."""
|
||||
resized = self._aspect_resize(self._source, height=_LOGO_TARGET_HEIGHT_PX)
|
||||
out_path = self._output_dir / "logo.png"
|
||||
resized.save(out_path, format="PNG", optimize=True)
|
||||
return out_path
|
||||
|
||||
def build_logo_webp(self) -> Path:
|
||||
"""Write the WebP version of the logo at the same pixel size."""
|
||||
resized = self._aspect_resize(self._source, height=_LOGO_TARGET_HEIGHT_PX)
|
||||
out_path = self._output_dir / "logo.webp"
|
||||
resized.save(
|
||||
out_path,
|
||||
format="WEBP",
|
||||
quality=_WEBP_QUALITY,
|
||||
method=_WEBP_METHOD,
|
||||
)
|
||||
return out_path
|
||||
|
||||
def build_logo_mark_png(self) -> Path:
|
||||
"""Write the chick-only mark as RGBA PNG."""
|
||||
mark = self._aspect_resize(
|
||||
self._crop_chick_mark(), height=_LOGO_MARK_TARGET_HEIGHT_PX
|
||||
)
|
||||
out_path = self._output_dir / "logo-mark.png"
|
||||
mark.save(out_path, format="PNG", optimize=True)
|
||||
return out_path
|
||||
|
||||
def build_logo_mark_webp(self) -> Path:
|
||||
"""Write the chick-only mark as WebP."""
|
||||
mark = self._aspect_resize(
|
||||
self._crop_chick_mark(), height=_LOGO_MARK_TARGET_HEIGHT_PX
|
||||
)
|
||||
out_path = self._output_dir / "logo-mark.webp"
|
||||
mark.save(
|
||||
out_path,
|
||||
format="WEBP",
|
||||
quality=_WEBP_QUALITY,
|
||||
method=_WEBP_METHOD,
|
||||
)
|
||||
return out_path
|
||||
|
||||
def build_favicon(self) -> Path:
|
||||
"""Write the multi-size ICO favicon built from a square crop.
|
||||
|
||||
The source logo is landscape, so we center it on a transparent
|
||||
square canvas sized to the longer dimension before downsizing.
|
||||
"""
|
||||
square = self._square_pad(self._source, background=(0, 0, 0, 0))
|
||||
out_path = self._output_dir / "favicon.ico"
|
||||
# Pillow's .save(..., format="ICO", sizes=...) writes every
|
||||
# requested size into one .ico file; browsers pick the best one.
|
||||
square.save(out_path, format="ICO", sizes=list(_FAVICON_SIZES))
|
||||
return out_path
|
||||
|
||||
def build_apple_touch_icon(self) -> Path:
|
||||
"""Write the 180x180 apple-touch-icon with a cream background.
|
||||
|
||||
iOS composites this icon against the home-screen background, so
|
||||
a transparent PNG would bleed through. We paint the cream brand
|
||||
background behind the logo ourselves to lock the look.
|
||||
"""
|
||||
square = self._square_pad(self._source, background=_APPLE_TOUCH_BG)
|
||||
icon = square.resize(
|
||||
(_APPLE_TOUCH_SIZE, _APPLE_TOUCH_SIZE),
|
||||
resample=Image.Resampling.LANCZOS,
|
||||
)
|
||||
out_path = self._output_dir / "apple-touch-icon.png"
|
||||
icon.save(out_path, format="PNG", optimize=True)
|
||||
return out_path
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _aspect_resize(image: Image.Image, *, height: int) -> Image.Image:
|
||||
"""Return a new image scaled to the given height, aspect preserved."""
|
||||
src_w, src_h = image.size
|
||||
# Guard against degenerate input; ratio math would divide by zero.
|
||||
if src_h == 0:
|
||||
raise ValueError("Source image has zero height.")
|
||||
new_w = max(1, round(src_w * (height / src_h)))
|
||||
return image.resize((new_w, height), resample=Image.Resampling.LANCZOS)
|
||||
|
||||
def _crop_chick_mark(self) -> Image.Image:
|
||||
"""Return a copy of the source cropped to just the chick artwork.
|
||||
|
||||
Strategy: scan the source column-by-column for transparency,
|
||||
find the widest contiguous transparent "gap" between opaque
|
||||
columns, and treat the content to the left of that gap as the
|
||||
chick. This is robust to future logo tweaks (new fonts, wider
|
||||
tracking, shifted text) because it does not rely on hardcoded
|
||||
pixel coordinates — only on the visual fact that the brand
|
||||
mark and wordmark are separated by a wider gap than any gap
|
||||
internal to either side.
|
||||
"""
|
||||
img = self._source
|
||||
alpha = img.split()[-1]
|
||||
width, height = img.size
|
||||
|
||||
# Per-column "has any opaque pixel" flags. getextrema on a 1px
|
||||
# band returns (min, max); max > 0 means at least one opaque
|
||||
# pixel. This is O(width) Pillow calls — fast enough here.
|
||||
opaque = [
|
||||
alpha.crop((x, 0, x + 1, height)).getextrema()[1] > 0
|
||||
for x in range(width)
|
||||
]
|
||||
|
||||
first_opaque = next((x for x, o in enumerate(opaque) if o), None)
|
||||
last_opaque = next(
|
||||
(x for x in range(width - 1, -1, -1) if opaque[x]), None
|
||||
)
|
||||
if first_opaque is None or last_opaque is None:
|
||||
raise ValueError("Source logo has no opaque pixels.")
|
||||
|
||||
# Enumerate transparent runs strictly between first/last opaque.
|
||||
gaps: list[tuple[int, int]] = [] # (gap_start, gap_end_exclusive)
|
||||
run_start: int | None = None
|
||||
for x in range(first_opaque, last_opaque + 1):
|
||||
if not opaque[x] and run_start is None:
|
||||
run_start = x
|
||||
elif opaque[x] and run_start is not None:
|
||||
gaps.append((run_start, x))
|
||||
run_start = None
|
||||
|
||||
if not gaps:
|
||||
raise ValueError(
|
||||
"No transparent gap found between chick and wordmark; "
|
||||
"cannot split the mark automatically."
|
||||
)
|
||||
|
||||
gap_start, _ = max(gaps, key=lambda g: g[1] - g[0])
|
||||
chick_slice = img.crop((first_opaque, 0, gap_start, height))
|
||||
|
||||
# Trim vertical whitespace so the resulting icon sits flush.
|
||||
inner = chick_slice.getbbox()
|
||||
if inner is None:
|
||||
raise ValueError("Detected chick region is empty.")
|
||||
return chick_slice.crop(inner)
|
||||
|
||||
@staticmethod
|
||||
def _square_pad(
|
||||
image: Image.Image,
|
||||
*,
|
||||
background: tuple[int, int, int, int],
|
||||
) -> Image.Image:
|
||||
"""Center the image on a square canvas of the given background.
|
||||
|
||||
We pad rather than crop so the whole mark survives at every
|
||||
favicon size. For a landscape logo, padding vertically preserves
|
||||
the design at the cost of a little empty space top and bottom —
|
||||
the correct trade-off for a recognition-first icon.
|
||||
"""
|
||||
src_w, src_h = image.size
|
||||
side = max(src_w, src_h)
|
||||
canvas = Image.new("RGBA", (side, side), background)
|
||||
offset = ((side - src_w) // 2, (side - src_h) // 2)
|
||||
canvas.paste(image, offset, mask=image if image.mode == "RGBA" else None)
|
||||
return canvas
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Generate every static asset and report the output paths.
|
||||
|
||||
Returns a shell exit code (0 on success) so the script can be chained
|
||||
into CI or build scripts without wrapping.
|
||||
"""
|
||||
builder = StaticAssetBuilder(_SOURCE_LOGO, _STATIC_IMG_DIR)
|
||||
|
||||
generated: list[Path] = [
|
||||
builder.build_logo_png(),
|
||||
builder.build_logo_webp(),
|
||||
builder.build_logo_mark_png(),
|
||||
builder.build_logo_mark_webp(),
|
||||
builder.build_favicon(),
|
||||
builder.build_apple_touch_icon(),
|
||||
]
|
||||
|
||||
# Emit a short human-readable report. Using print (not structlog)
|
||||
# because this is a one-shot developer tool, not runtime app code.
|
||||
print("Generated static assets:")
|
||||
for path in generated:
|
||||
rel = path.relative_to(_REPO_ROOT)
|
||||
print(f" - {rel}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
3
tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Pytest suite for the Chicken Babies R Us app."""
|
||||
|
||||
from __future__ import annotations
|
||||
56
tests/conftest.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Shared pytest fixtures for the ``chicken_babies_site`` suite.
|
||||
|
||||
Key fixtures:
|
||||
|
||||
- ``db_engine``: a session-scoped SQLAlchemy engine pointed at a
|
||||
temp-file SQLite database. Migrations + seed run once per test
|
||||
session. Per the CLAUDE.md mandate, tests do NOT mock the DB —
|
||||
they use a real SQLite file so behavior matches production.
|
||||
- ``clean_db_engine``: a function-scoped engine with migrations
|
||||
applied but seed NOT run, for tests that need to exercise the
|
||||
first-boot path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
|
||||
from app.db import build_engine, run_migrations
|
||||
from app.models.seed import run_seed
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def db_engine(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Engine]:
|
||||
"""Return a migrated + seeded SQLite engine shared across the session.
|
||||
|
||||
Uses a real on-disk file (NOT ``:memory:``) because the CLAUDE.md
|
||||
project rules forbid mocking the DB in auth / magic-link tests,
|
||||
and doing the same here keeps the behavior identical to
|
||||
production.
|
||||
"""
|
||||
db_path: Path = tmp_path_factory.mktemp("db") / "test.db"
|
||||
engine = build_engine(f"sqlite:///{db_path}")
|
||||
run_migrations(engine)
|
||||
run_seed(engine)
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_db_engine(tmp_path: Path) -> Iterator[Engine]:
|
||||
"""Return a fresh engine with tables created but NO seed data.
|
||||
|
||||
Function-scoped so each test that uses it starts with a virgin
|
||||
database — useful for asserting first-run seed behavior and
|
||||
migration idempotency without contaminating the session-scoped
|
||||
``db_engine``.
|
||||
"""
|
||||
db_path = tmp_path / "clean.db"
|
||||
engine = build_engine(f"sqlite:///{db_path}")
|
||||
run_migrations(engine)
|
||||
yield engine
|
||||
engine.dispose()
|
||||
112
tests/test_access_log.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Tests for :class:`app.middleware.AccessLogMiddleware`.
|
||||
|
||||
structlog in this project is wired through ``PrintLoggerFactory`` (see
|
||||
:mod:`app.logging_config`), which emits rendered log lines to stdout.
|
||||
We therefore capture via pytest's ``capsys`` fixture — the same pattern
|
||||
:mod:`tests.test_email_service` uses for its dev-fallback assertion.
|
||||
|
||||
Coverage:
|
||||
|
||||
- Every request produces an ``http_request`` log line including the
|
||||
method, the path, and a status code.
|
||||
- ``/healthz`` is skipped (reduces compose healthcheck noise).
|
||||
- Magic-link consume paths have their token segment redacted to
|
||||
``<redacted>`` before the line is emitted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.logging_config import configure_logging
|
||||
from app.main import app
|
||||
|
||||
|
||||
# ConsoleRenderer wraps keys / values in ANSI colour escapes; strip them
|
||||
# so substring assertions can pattern-match on the semantic payload
|
||||
# rather than the rendering.
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Return ``text`` with ANSI terminal escapes removed."""
|
||||
return _ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(capsys: pytest.CaptureFixture[str]) -> TestClient:
|
||||
"""Return a TestClient with structlog reconfigured for capture.
|
||||
|
||||
The test harness may be left in any state by a previously run test;
|
||||
re-calling :func:`configure_logging` guarantees the ConsoleRenderer
|
||||
pipeline is active so ``capsys`` actually sees the log lines.
|
||||
|
||||
``capsys.readouterr()`` is called on entry so existing startup
|
||||
output (``app_started``, etc.) doesn't leak into the per-test
|
||||
assertions.
|
||||
"""
|
||||
configure_logging("development")
|
||||
capsys.readouterr() # drain any buffered prior output
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_request_emits_http_request_event(
|
||||
client: TestClient, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A single GET to / produces an ``http_request`` log line."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
out = _strip_ansi(capsys.readouterr().out)
|
||||
assert "http_request" in out
|
||||
# The structured keys must also be present in the rendered line.
|
||||
assert "method=GET" in out
|
||||
assert "path=/" in out
|
||||
assert "status_code=200" in out
|
||||
|
||||
|
||||
def test_healthz_is_skipped(
|
||||
client: TestClient, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""``/healthz`` requests do NOT produce an ``http_request`` event.
|
||||
|
||||
Compose / Docker health probes hit this path every 30s; logging
|
||||
every probe would drown out real traffic.
|
||||
"""
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
|
||||
out = _strip_ansi(capsys.readouterr().out)
|
||||
assert "http_request" not in out, (
|
||||
"AccessLogMiddleware should skip /healthz, but emitted: "
|
||||
f"{out!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_consume_path_is_redacted(
|
||||
client: TestClient, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Magic-link consume tokens must never appear in the access log.
|
||||
|
||||
We hit the consume route with a synthetic token; the downstream
|
||||
handler will return 4xx (token doesn't match any DB row), but
|
||||
the middleware still runs — and its log line must carry the
|
||||
redacted path, not the raw token.
|
||||
"""
|
||||
fake_token = "super-secret-token-value-that-must-not-leak-1234567890"
|
||||
response = client.get(f"/admin/auth/consume/{fake_token}")
|
||||
# We don't care what status the router returns — only that the log
|
||||
# entry for this request redacts the token.
|
||||
assert response.status_code in (302, 303, 400, 401, 403, 404), (
|
||||
f"unexpected status {response.status_code}"
|
||||
)
|
||||
|
||||
out = _strip_ansi(capsys.readouterr().out)
|
||||
assert "http_request" in out
|
||||
assert "/admin/auth/consume/<redacted>" in out
|
||||
assert fake_token not in out, (
|
||||
"raw magic-link token appeared in access log output"
|
||||
)
|
||||
411
tests/test_admin_cms_routes.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""End-to-end HTTP tests for the Phase 4 admin CMS.
|
||||
|
||||
Exercises the real :class:`FastAPI` app constructed by
|
||||
:func:`app.main.create_app` against a temp-file SQLite database plus
|
||||
a temp media directory. Uses the email-capture trick from
|
||||
``tests/test_admin_routes.py`` to complete the magic-link login so we
|
||||
have a real authenticated session + CSRF cookie.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
_CSRF_META_RE = re.compile(
|
||||
r'<meta name="csrf-token" content="([^"]*)"'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cms_app(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Iterator[tuple[TestClient, dict, Path]]:
|
||||
"""Build a fresh authed app + temp media dir + captured email URL."""
|
||||
media_root = tmp_path / "media"
|
||||
media_root.mkdir()
|
||||
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path}/cms.db")
|
||||
monkeypatch.setenv("ADMIN_EMAILS", "headhen@example.com")
|
||||
monkeypatch.setenv("APP_ENV", "development")
|
||||
monkeypatch.setenv(
|
||||
"SECRET_KEY", "test-only-secret-key-0123456789abcdef-XYZ"
|
||||
)
|
||||
monkeypatch.setenv("RESEND_API_KEY", "")
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "http://testserver")
|
||||
monkeypatch.setenv("MEDIA_ROOT", str(media_root))
|
||||
|
||||
from app import config as _config
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
import app.main as main_module
|
||||
|
||||
importlib.reload(main_module)
|
||||
app = main_module.app
|
||||
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def _capture(**kw):
|
||||
captured["url"] = kw["url"]
|
||||
captured["to"] = kw["to"]
|
||||
|
||||
app.state.email_service.send_magic_link = _capture # type: ignore[assignment]
|
||||
|
||||
from app.services.rate_limit import limiter
|
||||
|
||||
limiter.reset()
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client, captured, media_root
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
|
||||
def _login(client: TestClient, captured: dict) -> None:
|
||||
"""Walk the magic-link flow so subsequent requests are authed."""
|
||||
client.post("/admin/login", data={"email": "headhen@example.com"})
|
||||
assert "url" in captured
|
||||
consume_path = captured["url"].replace("http://testserver", "")
|
||||
resp = client.get(consume_path, follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _dashboard_csrf(client: TestClient) -> str:
|
||||
"""GET /admin and extract the csrf-token meta tag."""
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200
|
||||
match = _CSRF_META_RE.search(resp.text)
|
||||
assert match, "csrf-token meta tag missing"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# unauthed
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_dashboard_redirects_unauthed_to_login(cms_app) -> None:
|
||||
"""An unauthenticated GET /admin yields 303 to /admin/login."""
|
||||
client, _, _ = cms_app
|
||||
resp = client.get("/admin", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/admin/login"
|
||||
|
||||
|
||||
def test_post_create_without_auth_is_redirected(cms_app) -> None:
|
||||
"""Create POST is gated behind require_admin."""
|
||||
client, _, _ = cms_app
|
||||
resp = client.post(
|
||||
"/admin/posts",
|
||||
data={"title": "x", "body_md": "y", "status": "draft", "csrf_token": ""},
|
||||
follow_redirects=False,
|
||||
)
|
||||
# Unauthenticated -> require_admin raises 303.
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_dashboard_renders_with_seeded_post(cms_app) -> None:
|
||||
"""The seeded welcome post shows up in the admin table."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200
|
||||
assert "Dashboard" in resp.text
|
||||
assert "Welcome to the Farm" in resp.text
|
||||
assert "New post" in resp.text
|
||||
assert "Edit About" in resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create + edit + delete
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_create_post_happy_path(cms_app) -> None:
|
||||
"""POST /admin/posts with CSRF creates a row and 303s to the dashboard."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
"/admin/posts",
|
||||
data={
|
||||
"title": "First Admin Post",
|
||||
"body_md": "Hello **world**.",
|
||||
"status": "draft",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"].startswith("/admin?msg=created")
|
||||
|
||||
# The new post is visible on the dashboard.
|
||||
dash = client.get("/admin")
|
||||
assert "First Admin Post" in dash.text
|
||||
|
||||
|
||||
def test_create_post_without_csrf_is_forbidden(cms_app) -> None:
|
||||
"""Missing csrf_token on create returns 403."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
# Touch /admin to materialize the cb_csrf cookie.
|
||||
client.get("/admin")
|
||||
|
||||
resp = client.post(
|
||||
"/admin/posts",
|
||||
data={
|
||||
"title": "Sneaky",
|
||||
"body_md": "x",
|
||||
"status": "draft",
|
||||
"csrf_token": "", # wrong
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_update_and_publish_and_delete(cms_app) -> None:
|
||||
"""Full CRUD + publish toggle + delete cycle."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
|
||||
# create
|
||||
client.post(
|
||||
"/admin/posts",
|
||||
data={
|
||||
"title": "Toggle Target",
|
||||
"body_md": "body-v1",
|
||||
"status": "draft",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
)
|
||||
# look up the post id via the engine
|
||||
with client.app.state.engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM posts WHERE title = :t"),
|
||||
{"t": "Toggle Target"},
|
||||
).mappings().first()
|
||||
assert row is not None
|
||||
post_id = int(row["id"])
|
||||
|
||||
# edit form renders
|
||||
edit_resp = client.get(f"/admin/posts/{post_id}/edit")
|
||||
assert edit_resp.status_code == 200
|
||||
assert "Toggle Target" in edit_resp.text
|
||||
# CSRF for subsequent POSTs should come off the edit page.
|
||||
match = _CSRF_META_RE.search(edit_resp.text)
|
||||
assert match
|
||||
csrf = match.group(1)
|
||||
|
||||
# update
|
||||
upd = client.post(
|
||||
f"/admin/posts/{post_id}",
|
||||
data={
|
||||
"title": "Toggle Target Edited",
|
||||
"body_md": "body-v2",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert upd.status_code == 303
|
||||
|
||||
# publish
|
||||
pub = client.post(
|
||||
f"/admin/posts/{post_id}/publish",
|
||||
data={"csrf_token": csrf},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert pub.status_code == 303
|
||||
assert pub.headers["location"] == "/admin?msg=published"
|
||||
|
||||
# unpublish
|
||||
unpub = client.post(
|
||||
f"/admin/posts/{post_id}/publish",
|
||||
data={"csrf_token": csrf},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert unpub.status_code == 303
|
||||
assert unpub.headers["location"] == "/admin?msg=unpublished"
|
||||
|
||||
# delete
|
||||
delete = client.post(
|
||||
f"/admin/posts/{post_id}/delete",
|
||||
data={"csrf_token": csrf},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert delete.status_code == 303
|
||||
assert delete.headers["location"] == "/admin?msg=deleted"
|
||||
|
||||
# row is gone
|
||||
with client.app.state.engine.connect() as conn:
|
||||
gone = conn.execute(
|
||||
text("SELECT id FROM posts WHERE id = :i"),
|
||||
{"i": post_id},
|
||||
).first()
|
||||
assert gone is None
|
||||
|
||||
|
||||
def test_create_post_rejects_blank_title(cms_app) -> None:
|
||||
"""Blank title re-renders the form at 400."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
resp = client.post(
|
||||
"/admin/posts",
|
||||
data={
|
||||
"title": " ",
|
||||
"body_md": "x",
|
||||
"status": "draft",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Title is required" in resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# About page
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_about_update(cms_app) -> None:
|
||||
"""Editing the About page updates the stored copy."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
form_resp = client.get("/admin/pages/about/edit")
|
||||
assert form_resp.status_code == 200
|
||||
match = _CSRF_META_RE.search(form_resp.text)
|
||||
assert match
|
||||
csrf = match.group(1)
|
||||
|
||||
resp = client.post(
|
||||
"/admin/pages/about",
|
||||
data={
|
||||
"title": "Re-titled About",
|
||||
"body_md": "Edited **copy**.",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/admin?msg=saved"
|
||||
|
||||
# Public site reflects the change (cache was invalidated).
|
||||
public = client.get("/about")
|
||||
assert public.status_code == 200
|
||||
assert "Re-titled About" in public.text
|
||||
assert "<strong>copy</strong>" in public.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# media upload
|
||||
# ---------------------------------------------------------------------------
|
||||
def _jpeg_bytes() -> bytes:
|
||||
img = Image.new("RGB", (32, 32), "blue")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_media_upload_happy_path(cms_app) -> None:
|
||||
"""Valid JPEG upload with header CSRF returns JSON with a /media URL."""
|
||||
client, captured, media_root = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
|
||||
data = _jpeg_bytes()
|
||||
files = {"file": ("photo.jpg", data, "image/jpeg")}
|
||||
resp = client.post(
|
||||
"/admin/media/upload",
|
||||
files=files,
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
payload = resp.json()
|
||||
assert payload["url"].startswith("/media/")
|
||||
assert payload["filename"].endswith(".jpg")
|
||||
|
||||
# The file actually exists under the media root.
|
||||
stored = Path(media_root)
|
||||
assert any(stored.rglob("*.jpg"))
|
||||
|
||||
|
||||
def test_media_upload_missing_csrf_is_forbidden(cms_app) -> None:
|
||||
"""Without the X-CSRF-Token header the upload is 403."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
# Warm cookie.
|
||||
client.get("/admin")
|
||||
files = {"file": ("x.jpg", _jpeg_bytes(), "image/jpeg")}
|
||||
resp = client.post("/admin/media/upload", files=files)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_media_upload_rejects_non_image(cms_app) -> None:
|
||||
"""Plain text rejected with a JSON error payload."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
files = {"file": ("fake.jpg", b"not an image at all, really not", "image/jpeg")}
|
||||
resp = client.post(
|
||||
"/admin/media/upload",
|
||||
files=files,
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "error" in resp.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# preview
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_preview_renders_sanitized_html(cms_app) -> None:
|
||||
"""Preview endpoint returns the same sanitized HTML as server-side."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
"/admin/preview",
|
||||
data={"markdown": "Hello **there**."},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "<strong>there</strong>" in resp.text
|
||||
|
||||
|
||||
def test_preview_strips_raw_html(cms_app) -> None:
|
||||
"""Raw HTML in the markdown input is sanitized away."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
"/admin/preview",
|
||||
data={"markdown": "<script>alert(1)</script>\n\nOK."},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "<script>" not in resp.text
|
||||
|
||||
|
||||
def test_preview_without_csrf_is_forbidden(cms_app) -> None:
|
||||
"""Preview endpoint enforces CSRF via header."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
client.get("/admin") # warm cookie
|
||||
resp = client.post(
|
||||
"/admin/preview",
|
||||
data={"markdown": "hi"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
97
tests/test_admin_pages_service.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Tests for :class:`app.services.admin_pages.AdminPagesService`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
|
||||
from app.db import build_engine, run_migrations
|
||||
from app.models.seed import run_seed
|
||||
from app.services.admin_pages import AdminPagesService
|
||||
from app.services.audit import AuditService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(tmp_path: Path) -> Iterator[Engine]:
|
||||
"""Return an isolated migrated + seeded engine."""
|
||||
db_path = tmp_path / "phase4_pages.db"
|
||||
eng = build_engine(f"sqlite:///{db_path}")
|
||||
run_migrations(eng)
|
||||
run_seed(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(engine: Engine) -> AdminPagesService:
|
||||
"""Return a fully-wired :class:`AdminPagesService`."""
|
||||
return AdminPagesService(
|
||||
engine=engine,
|
||||
markdown=MarkdownService(),
|
||||
page_service=PageService(engine),
|
||||
post_service=PostService(engine),
|
||||
audit=AuditService(engine),
|
||||
)
|
||||
|
||||
|
||||
def test_get_about_returns_seeded_page(service: AdminPagesService) -> None:
|
||||
"""The seeded About page loads."""
|
||||
page = service.get_about()
|
||||
assert page is not None
|
||||
assert page.slug == "about"
|
||||
assert page.title == "About the Farm"
|
||||
|
||||
|
||||
def test_update_about_rewrites_html_cache(
|
||||
service: AdminPagesService,
|
||||
) -> None:
|
||||
"""Update regenerates the sanitized HTML and bumps ``updated_at``."""
|
||||
before = service.get_about()
|
||||
assert before is not None
|
||||
updated = service.update_about(
|
||||
title="New About Title",
|
||||
body_md="Some **bold** copy.",
|
||||
actor_user_id=1,
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.slug == "about" # slug immutable
|
||||
assert updated.title == "New About Title"
|
||||
assert "<strong>bold</strong>" in updated.body_html_cached
|
||||
# updated_at should bump (or at least not go backwards).
|
||||
assert updated.updated_at >= before.updated_at
|
||||
|
||||
|
||||
def test_update_about_invalidates_public_page_cache(engine: Engine) -> None:
|
||||
"""After an update the public :class:`PageService` cache returns fresh copy."""
|
||||
md = MarkdownService()
|
||||
pages = PageService(engine)
|
||||
posts = PostService(engine)
|
||||
audit = AuditService(engine)
|
||||
service = AdminPagesService(
|
||||
engine=engine,
|
||||
markdown=md,
|
||||
page_service=pages,
|
||||
post_service=posts,
|
||||
audit=audit,
|
||||
)
|
||||
|
||||
primed = pages.get_by_slug("about")
|
||||
# Cache hit confirmation:
|
||||
assert pages.get_by_slug("about") is primed
|
||||
|
||||
service.update_about(
|
||||
title="After",
|
||||
body_md="updated copy",
|
||||
actor_user_id=1,
|
||||
)
|
||||
|
||||
after = pages.get_by_slug("about")
|
||||
assert after is not None
|
||||
assert after is not primed
|
||||
assert after.title == "After"
|
||||
242
tests/test_admin_posts_service.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Tests for :class:`app.services.admin_posts.AdminPostsService`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.db import build_engine, run_migrations
|
||||
from app.models.entities import PostStatus
|
||||
from app.models.seed import run_seed
|
||||
from app.services.admin_posts import AdminPostsService
|
||||
from app.services.audit import AuditService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
|
||||
|
||||
# Dedicated function-scoped DB per test so publish / delete side
|
||||
# effects don't leak between tests.
|
||||
@pytest.fixture
|
||||
def engine(tmp_path: Path) -> Iterator[Engine]:
|
||||
"""Return a migrated + seeded engine in an isolated file."""
|
||||
db_path = tmp_path / "phase4_posts.db"
|
||||
eng = build_engine(f"sqlite:///{db_path}")
|
||||
run_migrations(eng)
|
||||
run_seed(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(engine: Engine) -> AdminPostsService:
|
||||
"""Return a fully-wired :class:`AdminPostsService`."""
|
||||
md = MarkdownService()
|
||||
posts = PostService(engine)
|
||||
pages = PageService(engine)
|
||||
audit = AuditService(engine)
|
||||
return AdminPostsService(
|
||||
engine=engine,
|
||||
markdown=md,
|
||||
post_service=posts,
|
||||
page_service=pages,
|
||||
audit=audit,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_create_draft_post(service: AdminPostsService) -> None:
|
||||
"""Create generates a slug, renders HTML, and leaves ``published_at`` NULL."""
|
||||
post = service.create(
|
||||
title="My First Post",
|
||||
body_md="Hello **world**.",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
assert post.id > 0
|
||||
assert post.slug == "my-first-post"
|
||||
assert post.status is PostStatus.DRAFT
|
||||
assert post.published_at is None
|
||||
# Markdown pipeline ran and wrote sanitized HTML.
|
||||
assert "<strong>world</strong>" in post.body_html_cached
|
||||
|
||||
|
||||
def test_create_publish_stamps_published_at(service: AdminPostsService) -> None:
|
||||
"""``status=published`` sets ``published_at`` to the creation time."""
|
||||
post = service.create(
|
||||
title="Announcement",
|
||||
body_md="body",
|
||||
status=PostStatus.PUBLISHED,
|
||||
author_id=1,
|
||||
)
|
||||
assert post.status is PostStatus.PUBLISHED
|
||||
assert post.published_at is not None
|
||||
|
||||
|
||||
def test_create_collision_yields_suffixed_slug(
|
||||
service: AdminPostsService,
|
||||
) -> None:
|
||||
"""Second post with the same title slugs as ``foo-2``."""
|
||||
a = service.create(
|
||||
title="Chicks Day",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
b = service.create(
|
||||
title="Chicks Day",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
assert a.slug == "chicks-day"
|
||||
assert b.slug == "chicks-day-2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_update_changes_title_body_but_not_slug(
|
||||
service: AdminPostsService,
|
||||
) -> None:
|
||||
"""Updates regenerate HTML and bump updated_at; slug is locked."""
|
||||
post = service.create(
|
||||
title="Original",
|
||||
body_md="before",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
updated = service.update(
|
||||
post.id,
|
||||
title="Original Retitled",
|
||||
body_md="after",
|
||||
actor_user_id=1,
|
||||
)
|
||||
assert updated is not None
|
||||
# Slug is preserved — not regenerated even on title change.
|
||||
assert updated.slug == post.slug
|
||||
assert updated.title == "Original Retitled"
|
||||
assert "after" in updated.body_html_cached
|
||||
assert "before" not in updated.body_html_cached
|
||||
|
||||
|
||||
def test_update_unknown_post_returns_none(service: AdminPostsService) -> None:
|
||||
"""An update on a missing post id returns ``None``."""
|
||||
assert service.update(99999, title="x", body_md="y", actor_user_id=1) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# toggle_publish
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_toggle_publish_draft_to_published(service: AdminPostsService) -> None:
|
||||
"""Draft → published stamps ``published_at`` once."""
|
||||
post = service.create(
|
||||
title="Togglable",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
published = service.toggle_publish(post.id, actor_user_id=1)
|
||||
assert published is not None
|
||||
assert published.status is PostStatus.PUBLISHED
|
||||
first_published_at = published.published_at
|
||||
assert first_published_at is not None
|
||||
|
||||
|
||||
def test_toggle_publish_preserves_original_published_at(
|
||||
service: AdminPostsService,
|
||||
) -> None:
|
||||
"""Unpublish → re-publish keeps the original ``published_at``."""
|
||||
post = service.create(
|
||||
title="Preserve",
|
||||
body_md="x",
|
||||
status=PostStatus.PUBLISHED,
|
||||
author_id=1,
|
||||
)
|
||||
original_published_at = post.published_at
|
||||
assert original_published_at is not None
|
||||
|
||||
unpublished = service.toggle_publish(post.id, actor_user_id=1)
|
||||
assert unpublished is not None
|
||||
assert unpublished.status is PostStatus.DRAFT
|
||||
# published_at preserved on unpublish per Phase 4 brief.
|
||||
assert unpublished.published_at == original_published_at
|
||||
|
||||
republished = service.toggle_publish(post.id, actor_user_id=1)
|
||||
assert republished is not None
|
||||
assert republished.status is PostStatus.PUBLISHED
|
||||
# published_at preserved across the re-publish cycle.
|
||||
assert republished.published_at == original_published_at
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# delete
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_delete_removes_row(service: AdminPostsService, engine: Engine) -> None:
|
||||
"""Delete really removes the row — no soft-delete column exists."""
|
||||
post = service.create(
|
||||
title="DeleteMe",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
assert service.delete(post.id, actor_user_id=1) is True
|
||||
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM posts WHERE id = :id"),
|
||||
{"id": post.id},
|
||||
).first()
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_delete_unknown_returns_false(service: AdminPostsService) -> None:
|
||||
"""Deleting a nonexistent post id returns False without raising."""
|
||||
assert service.delete(99999, actor_user_id=1) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cache invalidation
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_create_invalidates_public_post_cache(
|
||||
engine: Engine,
|
||||
) -> None:
|
||||
"""Creating a post clears the :class:`PostService` cache.
|
||||
|
||||
We prime the cache by calling ``list_published``, then use the
|
||||
admin service to insert a published post, and confirm the next
|
||||
``list_published`` call returns a freshly-built list (identity
|
||||
different, content includes the new slug).
|
||||
"""
|
||||
md = MarkdownService()
|
||||
posts = PostService(engine)
|
||||
pages = PageService(engine)
|
||||
audit = AuditService(engine)
|
||||
service = AdminPostsService(
|
||||
engine=engine,
|
||||
markdown=md,
|
||||
post_service=posts,
|
||||
page_service=pages,
|
||||
audit=audit,
|
||||
)
|
||||
|
||||
primed = posts.list_published()
|
||||
# Hit the cache so we have a concrete reference to compare against.
|
||||
still_cached = posts.list_published()
|
||||
assert primed is still_cached
|
||||
|
||||
created = service.create(
|
||||
title="Fresh Post",
|
||||
body_md="x",
|
||||
status=PostStatus.PUBLISHED,
|
||||
author_id=1,
|
||||
)
|
||||
|
||||
after = posts.list_published()
|
||||
assert after is not primed
|
||||
assert any(p.slug == created.slug for p in after)
|
||||
230
tests/test_admin_routes.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""End-to-end HTTP tests for the admin auth flow.
|
||||
|
||||
Exercises the real :class:`FastAPI` app constructed by
|
||||
:func:`app.main.create_app` against a temp-file SQLite database and a
|
||||
monkeypatched ``EmailService`` so we can capture the magic-link URL
|
||||
without depending on Resend.
|
||||
|
||||
These tests intentionally do NOT use the session-scoped ``db_engine``
|
||||
fixture — each builds its own fresh app so rate-limit state and DB
|
||||
rows don't leak between tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
# Regex used to extract the raw token from the dev-fallback log URL.
|
||||
_URL_RE = re.compile(r"http[s]?://[^\s]+/admin/auth/consume/[A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
def _csrf_from_response(resp) -> str:
|
||||
"""Extract the CSRF token from a rendered admin page.
|
||||
|
||||
The admin base template emits a ``<meta name="csrf-token">`` tag
|
||||
populated by the CSRFCookieMiddleware. Tests pull it from there
|
||||
rather than the cookie value directly so the assertion also
|
||||
proves the template wiring is intact.
|
||||
"""
|
||||
match = re.search(
|
||||
r'<meta name="csrf-token" content="([^"]*)"', resp.text
|
||||
)
|
||||
assert match, "csrf-token meta tag missing"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Iterator[tuple[TestClient, dict]]:
|
||||
"""Build a fresh FastAPI app wired to a tmp DB + captured email URL."""
|
||||
# Point the settings loader at a clean DB and a known allowlist.
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path}/admin.db")
|
||||
monkeypatch.setenv("ADMIN_EMAILS", "headhen@example.com")
|
||||
monkeypatch.setenv("APP_ENV", "development")
|
||||
monkeypatch.setenv(
|
||||
"SECRET_KEY", "test-only-secret-key-0123456789abcdef-XYZ"
|
||||
)
|
||||
monkeypatch.setenv("RESEND_API_KEY", "")
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "http://testserver")
|
||||
|
||||
# Reload config + main so the new env is picked up.
|
||||
from app import config as _config
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
import app.main as main_module
|
||||
|
||||
importlib.reload(main_module)
|
||||
app = main_module.app
|
||||
|
||||
# Capture the magic-link URL instead of sending via Resend.
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def _capture(**kw):
|
||||
captured["url"] = kw["url"]
|
||||
captured["to"] = kw["to"]
|
||||
|
||||
app.state.email_service.send_magic_link = _capture # type: ignore[assignment]
|
||||
|
||||
# Reset the in-memory rate limiter between tests (module-level
|
||||
# singleton would otherwise leak state across tests).
|
||||
from app.services.rate_limit import limiter
|
||||
|
||||
limiter.reset()
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client, captured
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
|
||||
def _auth_events(app, event_type: str | None = None) -> list[dict]:
|
||||
"""Return rows from ``auth_events`` (optionally filtered by type)."""
|
||||
with app.state.engine.connect() as conn:
|
||||
if event_type:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT event_type, email, detail FROM auth_events"
|
||||
" WHERE event_type = :t ORDER BY id"
|
||||
),
|
||||
{"t": event_type},
|
||||
).mappings().all()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT event_type, email, detail FROM auth_events"
|
||||
" ORDER BY id"
|
||||
)
|
||||
).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def test_full_login_flow(admin_app) -> None:
|
||||
"""GET login, POST, consume, GET /admin, POST logout."""
|
||||
client, captured = admin_app
|
||||
|
||||
# 1. GET login page.
|
||||
resp = client.get("/admin/login")
|
||||
assert resp.status_code == 200
|
||||
assert "Admin log in" in resp.text
|
||||
assert 'name="email"' in resp.text
|
||||
|
||||
# 2. POST login with allowlisted email.
|
||||
resp = client.post("/admin/login", data={"email": "headhen@example.com"})
|
||||
assert resp.status_code == 200
|
||||
assert "Check your inbox" in resp.text
|
||||
assert "url" in captured
|
||||
raw_url = captured["url"]
|
||||
assert "/admin/auth/consume/" in raw_url
|
||||
|
||||
# 3. Consume the magic link.
|
||||
consume_path = raw_url.replace("http://testserver", "")
|
||||
resp = client.get(consume_path, follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/admin"
|
||||
# cb_session cookie set.
|
||||
assert "cb_session" in resp.cookies
|
||||
|
||||
# 4. GET /admin renders the Phase 4 dashboard.
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200
|
||||
assert "Dashboard" in resp.text
|
||||
assert "headhen@example.com" in resp.text
|
||||
csrf_token = _csrf_from_response(resp)
|
||||
|
||||
# 5. POST /admin/logout clears cookie + redirects. CSRF required
|
||||
# now that Phase 4 has enabled the double-submit cookie.
|
||||
resp = client.post(
|
||||
"/admin/logout",
|
||||
data={"csrf_token": csrf_token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/admin/login"
|
||||
|
||||
# 6. Subsequent /admin redirects back to login.
|
||||
# Manually drop the cookie from the test client, mirroring a browser
|
||||
# that honored the delete_cookie directive.
|
||||
client.cookies.clear()
|
||||
resp = client.get("/admin", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/admin/login"
|
||||
|
||||
# Audit rows: link_requested, link_consumed, session_created, session_revoked.
|
||||
events = [e["event_type"] for e in _auth_events(client.app)]
|
||||
for required in (
|
||||
"link_requested",
|
||||
"link_consumed",
|
||||
"session_created",
|
||||
"session_revoked",
|
||||
):
|
||||
assert required in events, f"missing audit event: {required}"
|
||||
|
||||
|
||||
def test_non_allowlisted_returns_same_page_no_token(admin_app) -> None:
|
||||
"""Non-allowlisted email: same 200 + 'Check your inbox', no token row."""
|
||||
client, captured = admin_app
|
||||
resp = client.post("/admin/login", data={"email": "stranger@example.com"})
|
||||
assert resp.status_code == 200
|
||||
assert "Check your inbox" in resp.text
|
||||
assert "url" not in captured # email not sent
|
||||
|
||||
# No token row for this email.
|
||||
with client.app.state.engine.connect() as conn:
|
||||
count = conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) AS c FROM magic_link_tokens"
|
||||
" WHERE email = :e"
|
||||
),
|
||||
{"e": "stranger@example.com"},
|
||||
).mappings().first()
|
||||
assert count is not None and int(count["c"]) == 0
|
||||
|
||||
# Audit trail still records the attempt.
|
||||
events = _auth_events(client.app, "link_requested")
|
||||
assert len(events) == 1
|
||||
assert "\"allowlisted\": false" in events[0]["detail"]
|
||||
|
||||
|
||||
def test_invalid_token_returns_400(admin_app) -> None:
|
||||
"""A random token on the consume endpoint returns the generic 400 page."""
|
||||
client, _ = admin_app
|
||||
resp = client.get("/admin/auth/consume/not-a-real-token", follow_redirects=False)
|
||||
assert resp.status_code == 400
|
||||
assert "Login link invalid" in resp.text
|
||||
|
||||
|
||||
def test_login_form_validation(admin_app) -> None:
|
||||
"""Empty / malformed email surfaces the inline validation message."""
|
||||
client, _ = admin_app
|
||||
resp = client.post("/admin/login", data={"email": ""})
|
||||
assert resp.status_code == 400
|
||||
assert "valid email" in resp.text
|
||||
|
||||
resp = client.post("/admin/login", data={"email": "not-an-email"})
|
||||
assert resp.status_code == 400
|
||||
assert "valid email" in resp.text
|
||||
|
||||
|
||||
def test_replay_attack_fails(admin_app) -> None:
|
||||
"""A consumed magic link cannot be used a second time."""
|
||||
client, captured = admin_app
|
||||
client.post("/admin/login", data={"email": "headhen@example.com"})
|
||||
url = captured["url"].replace("http://testserver", "")
|
||||
|
||||
first = client.get(url, follow_redirects=False)
|
||||
assert first.status_code == 303
|
||||
|
||||
# Clear cookies to simulate a replay from a fresh browser context.
|
||||
client.cookies.clear()
|
||||
second = client.get(url, follow_redirects=False)
|
||||
assert second.status_code == 400
|
||||
275
tests/test_auth_service.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Tests for :class:`app.services.auth.AuthService`.
|
||||
|
||||
Covers:
|
||||
- Allowlisted email → token inserted, email "sent" (dev fallback), audit
|
||||
rows emitted.
|
||||
- Non-allowlisted email → no token row, audit row with allowlisted=False.
|
||||
- Consume happy path (creates user on first login, bumps last_login_at
|
||||
on subsequent logins, issues session).
|
||||
- Consume replay → second attempt returns None + consume_failed/used audit.
|
||||
- Consume expired token → None + consume_failed/expired.
|
||||
- Per-email rate-limit threshold trips on the 6th request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
from pathlib import Path
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.services.audit import AuditService
|
||||
from app.services.auth import AuthService, RateLimitedError
|
||||
from app.services.email import EmailService
|
||||
from app.services.sessions import SessionService
|
||||
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "app" / "templates"
|
||||
|
||||
|
||||
def _build(engine: Engine, *, admin_emails: str = "headhen@example.com") -> tuple[AuthService, Settings]:
|
||||
"""Wire up an AuthService against a real SQLite engine."""
|
||||
settings = Settings(
|
||||
app_env="development",
|
||||
secret_key="a-very-long-test-secret-key-1234567890",
|
||||
admin_emails=admin_emails,
|
||||
resend_api_key=None,
|
||||
resend_from=None,
|
||||
public_base_url="http://localhost:8000",
|
||||
magic_link_ttl_min=15,
|
||||
) # type: ignore[call-arg]
|
||||
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
|
||||
signer = URLSafeTimedSerializer(settings.secret_key, salt="session")
|
||||
email = EmailService(settings, templates)
|
||||
audit = AuditService(engine)
|
||||
sessions = SessionService(engine, signer, settings)
|
||||
auth = AuthService(engine, email, sessions, audit, settings)
|
||||
return auth, settings
|
||||
|
||||
|
||||
def _count(engine: Engine, table: str, where: str = "1=1", **params) -> int:
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(f"SELECT COUNT(*) AS c FROM {table} WHERE {where}"), params
|
||||
).mappings().first()
|
||||
return int(row["c"]) if row is not None else 0
|
||||
|
||||
|
||||
def _latest_detail(engine: Engine, event_type: str) -> str:
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT detail FROM auth_events WHERE event_type = :t"
|
||||
" ORDER BY id DESC LIMIT 1"
|
||||
),
|
||||
{"t": event_type},
|
||||
).mappings().first()
|
||||
return str(row["detail"]) if row is not None else ""
|
||||
|
||||
|
||||
def test_allowlisted_request_inserts_token_and_audit(
|
||||
clean_db_engine: Engine,
|
||||
) -> None:
|
||||
"""An allowlisted email mints a token row and a link_requested audit."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
auth.request_link(email="HeadHen@example.com", ip="1.2.3.4", user_agent="ua")
|
||||
|
||||
assert _count(clean_db_engine, "magic_link_tokens") == 1
|
||||
# Stored email is lowercased.
|
||||
with clean_db_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT email, token_hash FROM magic_link_tokens")
|
||||
).mappings().first()
|
||||
assert row["email"] == "headhen@example.com"
|
||||
# Hex SHA-256 is 64 chars.
|
||||
assert len(row["token_hash"]) == 64
|
||||
|
||||
assert _count(clean_db_engine, "auth_events", "event_type='link_requested'") == 1
|
||||
assert "\"allowlisted\": true" in _latest_detail(
|
||||
clean_db_engine, "link_requested"
|
||||
)
|
||||
|
||||
|
||||
def test_non_allowlisted_request_inserts_no_token(
|
||||
clean_db_engine: Engine,
|
||||
) -> None:
|
||||
"""A non-allowlisted email gets an audit row and no token."""
|
||||
auth, _ = _build(clean_db_engine, admin_emails="headhen@example.com")
|
||||
auth.request_link(email="intruder@example.com", ip="1.2.3.4", user_agent="ua")
|
||||
|
||||
assert _count(clean_db_engine, "magic_link_tokens") == 0
|
||||
assert _count(clean_db_engine, "auth_events", "event_type='link_requested'") == 1
|
||||
assert "\"allowlisted\": false" in _latest_detail(
|
||||
clean_db_engine, "link_requested"
|
||||
)
|
||||
|
||||
|
||||
def test_consume_happy_path_creates_user_and_session(
|
||||
clean_db_engine: Engine,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Consuming a fresh token upserts a user and mints a session."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
|
||||
# Intercept the raw token via the dev-fallback log. Easiest path:
|
||||
# patch EmailService.send_magic_link on this instance.
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def _capture(**kw):
|
||||
captured["url"] = kw["url"]
|
||||
|
||||
monkeypatch.setattr(auth._email, "send_magic_link", _capture)
|
||||
|
||||
auth.request_link(email="headhen@example.com", ip="1.2.3.4", user_agent="ua")
|
||||
assert "url" in captured
|
||||
raw_token = captured["url"].rsplit("/", 1)[-1]
|
||||
|
||||
result = auth.consume(
|
||||
raw_token=raw_token, ip="1.2.3.4", user_agent="ua"
|
||||
)
|
||||
assert result is not None
|
||||
user, session, cookie = result
|
||||
|
||||
assert user.email == "headhen@example.com"
|
||||
assert user.display_name == "Headhen"
|
||||
assert user.active is True
|
||||
|
||||
# User row persisted with last_login_at set.
|
||||
with clean_db_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT last_login_at FROM users WHERE id = :id"),
|
||||
{"id": user.id},
|
||||
).mappings().first()
|
||||
assert row["last_login_at"] is not None
|
||||
|
||||
# Token marked used.
|
||||
with clean_db_engine.connect() as conn:
|
||||
tok = conn.execute(
|
||||
text("SELECT used_at FROM magic_link_tokens")
|
||||
).mappings().first()
|
||||
assert tok["used_at"] is not None
|
||||
|
||||
# Session row exists, cookie is non-empty.
|
||||
assert session.user_id == user.id
|
||||
assert cookie
|
||||
|
||||
# Audit trail: link_consumed + session_created.
|
||||
assert _count(
|
||||
clean_db_engine, "auth_events", "event_type='link_consumed'"
|
||||
) == 1
|
||||
assert _count(
|
||||
clean_db_engine, "auth_events", "event_type='session_created'"
|
||||
) == 1
|
||||
|
||||
|
||||
def test_consume_replay_fails(clean_db_engine: Engine, monkeypatch) -> None:
|
||||
"""The same raw token cannot be consumed twice."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
captured: dict[str, str] = {}
|
||||
monkeypatch.setattr(
|
||||
auth._email, "send_magic_link", lambda **kw: captured.update(url=kw["url"])
|
||||
)
|
||||
auth.request_link(email="headhen@example.com", ip="", user_agent="")
|
||||
raw = captured["url"].rsplit("/", 1)[-1]
|
||||
|
||||
first = auth.consume(raw_token=raw, ip="", user_agent="")
|
||||
assert first is not None
|
||||
|
||||
second = auth.consume(raw_token=raw, ip="", user_agent="")
|
||||
assert second is None
|
||||
|
||||
# consume_failed audit with reason=used.
|
||||
assert "\"reason\": \"used\"" in _latest_detail(
|
||||
clean_db_engine, "consume_failed"
|
||||
)
|
||||
|
||||
|
||||
def test_consume_expired_token_fails(clean_db_engine: Engine, monkeypatch) -> None:
|
||||
"""A token past its expires_at cannot be consumed."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
captured: dict[str, str] = {}
|
||||
monkeypatch.setattr(
|
||||
auth._email, "send_magic_link", lambda **kw: captured.update(url=kw["url"])
|
||||
)
|
||||
auth.request_link(email="headhen@example.com", ip="", user_agent="")
|
||||
raw = captured["url"].rsplit("/", 1)[-1]
|
||||
|
||||
# Push expires_at into the past.
|
||||
past = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
|
||||
with clean_db_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text("UPDATE magic_link_tokens SET expires_at = :p"), {"p": past}
|
||||
)
|
||||
|
||||
assert auth.consume(raw_token=raw, ip="", user_agent="") is None
|
||||
assert "\"reason\": \"expired\"" in _latest_detail(
|
||||
clean_db_engine, "consume_failed"
|
||||
)
|
||||
|
||||
|
||||
def test_consume_unknown_token_fails(clean_db_engine: Engine) -> None:
|
||||
"""Random tokens result in not_found."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
assert auth.consume(
|
||||
raw_token="totally-random-nope", ip="", user_agent=""
|
||||
) is None
|
||||
assert "\"reason\": \"not_found\"" in _latest_detail(
|
||||
clean_db_engine, "consume_failed"
|
||||
)
|
||||
|
||||
|
||||
def test_per_email_rate_limit_trips_on_sixth(
|
||||
clean_db_engine: Engine, monkeypatch
|
||||
) -> None:
|
||||
"""Five requests in the window succeed; the sixth raises and audits."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
monkeypatch.setattr(auth._email, "send_magic_link", lambda **kw: None)
|
||||
|
||||
for _ in range(5):
|
||||
auth.request_link(email="headhen@example.com", ip="", user_agent="")
|
||||
|
||||
with pytest.raises(RateLimitedError):
|
||||
auth.request_link(email="headhen@example.com", ip="", user_agent="")
|
||||
|
||||
# Exactly 5 tokens persisted; 6th blocked before insert.
|
||||
assert _count(clean_db_engine, "magic_link_tokens") == 5
|
||||
# rate_limited audit with scope=email.
|
||||
detail = _latest_detail(clean_db_engine, "rate_limited")
|
||||
assert "\"scope\": \"email\"" in detail
|
||||
|
||||
|
||||
def test_existing_user_reactivated_on_consume(
|
||||
clean_db_engine: Engine, monkeypatch
|
||||
) -> None:
|
||||
"""Consume updates an existing (possibly deactivated) user row."""
|
||||
# Seed a deactivated existing admin.
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
with clean_db_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO users"
|
||||
" (email, display_name, created_at, last_login_at, active)"
|
||||
" VALUES (:e, :d, :c, NULL, 0)"
|
||||
),
|
||||
{"e": "headhen@example.com", "d": "Old Name", "c": now_iso},
|
||||
)
|
||||
|
||||
auth, _ = _build(clean_db_engine)
|
||||
captured: dict[str, str] = {}
|
||||
monkeypatch.setattr(
|
||||
auth._email, "send_magic_link", lambda **kw: captured.update(url=kw["url"])
|
||||
)
|
||||
auth.request_link(email="headhen@example.com", ip="", user_agent="")
|
||||
raw = captured["url"].rsplit("/", 1)[-1]
|
||||
|
||||
result = auth.consume(raw_token=raw, ip="", user_agent="")
|
||||
assert result is not None
|
||||
user, _, _ = result
|
||||
|
||||
# display_name preserved (we don't overwrite), active flipped back on.
|
||||
assert user.display_name == "Old Name"
|
||||
assert user.active is True
|
||||
76
tests/test_cache.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Tests for the in-process TTL cache.
|
||||
|
||||
Covers:
|
||||
|
||||
- stored values round-trip via ``get`` before TTL expiry
|
||||
- entries expire after TTL elapses
|
||||
- ``invalidate_all`` drops every entry
|
||||
- construction rejects a non-positive TTL
|
||||
- the cache is typed-generic (spot check at runtime that multiple
|
||||
concrete types work — the real type safety comes from static
|
||||
checking, which isn't part of the runtime suite)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.cache import TTLCache
|
||||
|
||||
|
||||
def test_set_then_get_returns_stored_value() -> None:
|
||||
"""A value stored via ``set`` is visible to ``get`` until expiry."""
|
||||
cache: TTLCache[str, int] = TTLCache(ttl_seconds=5.0)
|
||||
cache.set("answer", 42)
|
||||
assert cache.get("answer") == 42
|
||||
|
||||
|
||||
def test_get_returns_none_for_missing_key() -> None:
|
||||
"""Absent keys return ``None`` cleanly (no KeyError)."""
|
||||
cache: TTLCache[str, str] = TTLCache(ttl_seconds=5.0)
|
||||
assert cache.get("nope") is None
|
||||
|
||||
|
||||
def test_entries_expire_after_ttl() -> None:
|
||||
"""An entry past its TTL is treated as absent.
|
||||
|
||||
Uses a tiny TTL + ``time.sleep`` rather than mocking
|
||||
``time.monotonic`` so the test exercises the real code path.
|
||||
"""
|
||||
cache: TTLCache[str, str] = TTLCache(ttl_seconds=0.05)
|
||||
cache.set("k", "v")
|
||||
time.sleep(0.1)
|
||||
assert cache.get("k") is None
|
||||
|
||||
|
||||
def test_invalidate_all_clears_everything() -> None:
|
||||
"""``invalidate_all`` drops every entry regardless of TTL."""
|
||||
cache: TTLCache[str, int] = TTLCache(ttl_seconds=60.0)
|
||||
cache.set("a", 1)
|
||||
cache.set("b", 2)
|
||||
cache.invalidate_all()
|
||||
assert cache.get("a") is None
|
||||
assert cache.get("b") is None
|
||||
|
||||
|
||||
def test_non_positive_ttl_is_rejected() -> None:
|
||||
"""Zero/negative TTL raises at construction time.
|
||||
|
||||
A zero TTL would make every write immediately expire, which is
|
||||
almost certainly a bug; the defensive check turns it into a loud
|
||||
failure.
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
TTLCache(ttl_seconds=0.0)
|
||||
with pytest.raises(ValueError):
|
||||
TTLCache(ttl_seconds=-1.0)
|
||||
|
||||
|
||||
def test_cache_works_with_int_keys_and_list_values() -> None:
|
||||
"""Runtime smoke: generic over both ``K`` and ``V``."""
|
||||
cache: TTLCache[int, list[str]] = TTLCache(ttl_seconds=5.0)
|
||||
cache.set(10, ["a", "b"])
|
||||
stored = cache.get(10)
|
||||
assert stored == ["a", "b"]
|
||||
319
tests/test_contact_routes.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""End-to-end HTTP tests for the Phase 5 contact form.
|
||||
|
||||
Each test builds its own FastAPI app against a fresh temp-file SQLite
|
||||
database and resets the SlowAPI limiter between runs, matching the
|
||||
pattern used by ``test_admin_routes.py`` / ``test_rate_limit.py``.
|
||||
|
||||
We stub :meth:`HCaptchaService.verify` at the ``app.state`` boundary
|
||||
so hCaptcha decisions are deterministic without network access; the
|
||||
service's own unit tests exercise the real verification path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def contact_app(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Iterator[tuple[TestClient, dict]]:
|
||||
"""Build a fresh FastAPI app wired to a tmp DB + captured sends."""
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path}/contact.db")
|
||||
monkeypatch.setenv("ADMIN_EMAILS", "headhen@example.com")
|
||||
monkeypatch.setenv("ADMIN_CONTACT_EMAIL", "head-hen@example.com")
|
||||
monkeypatch.setenv("APP_ENV", "development")
|
||||
monkeypatch.setenv(
|
||||
"SECRET_KEY", "test-only-secret-key-0123456789abcdef-XYZ"
|
||||
)
|
||||
monkeypatch.setenv("RESEND_API_KEY", "")
|
||||
monkeypatch.setenv("HCAPTCHA_SECRET", "")
|
||||
monkeypatch.setenv("HCAPTCHA_SITE_KEY", "")
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "http://testserver")
|
||||
|
||||
from app import config as _config
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
import app.main as main_module
|
||||
|
||||
importlib.reload(main_module)
|
||||
app = main_module.app
|
||||
|
||||
captured: dict = {"emails": [], "hcaptcha": []}
|
||||
|
||||
# Intercept the email dispatch so we can assert it was called
|
||||
# (or NOT called) without talking to Resend.
|
||||
def _capture_email(**kw) -> None:
|
||||
captured["emails"].append(kw)
|
||||
|
||||
app.state.email_service.send_contact_notification = _capture_email # type: ignore[assignment]
|
||||
|
||||
# Default: hCaptcha returns True. Individual tests override.
|
||||
async def _hc_ok(token: str, remote_ip: str) -> bool:
|
||||
captured["hcaptcha"].append({"token": token, "ip": remote_ip})
|
||||
return True
|
||||
|
||||
app.state.hcaptcha_service.verify = _hc_ok # type: ignore[assignment]
|
||||
|
||||
# Reset rate limiter between tests (module-level singleton).
|
||||
from app.services.rate_limit import limiter
|
||||
|
||||
limiter.reset()
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client, captured
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
|
||||
def _count(app, table: str, where: str = "1=1") -> int:
|
||||
with app.state.engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(f"SELECT COUNT(*) AS c FROM {table} WHERE {where}")
|
||||
).mappings().first()
|
||||
return int(row["c"]) if row is not None else 0
|
||||
|
||||
|
||||
def _audit_details(app, event_type: str) -> list[str]:
|
||||
with app.state.engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT detail FROM auth_events WHERE event_type = :t"
|
||||
" ORDER BY id"
|
||||
),
|
||||
{"t": event_type},
|
||||
).mappings().all()
|
||||
return [str(r["detail"]) for r in rows]
|
||||
|
||||
|
||||
def test_get_contact_renders_live_form(contact_app) -> None:
|
||||
"""GET /contact shows the live form + honeypot + no disabled attr."""
|
||||
client, _ = contact_app
|
||||
resp = client.get("/contact")
|
||||
assert resp.status_code == 200
|
||||
assert "Get in touch" in resp.text
|
||||
assert 'method="POST"' in resp.text
|
||||
assert 'action="/contact"' in resp.text
|
||||
# Honeypot field is present but inside a visually-hidden container.
|
||||
assert 'name="website"' in resp.text
|
||||
assert 'aria-hidden="true"' in resp.text
|
||||
# Dev: no hCaptcha site key → widget not rendered.
|
||||
assert "js.hcaptcha.com" not in resp.text
|
||||
|
||||
|
||||
def test_post_contact_happy_path_persists_and_sends(contact_app) -> None:
|
||||
"""A valid submission writes a row, calls email, renders success."""
|
||||
client, captured = contact_app
|
||||
|
||||
# Use a message whose final words sit past the 40-char preview
|
||||
# cutoff so the full body doesn't leak into the audit detail.
|
||||
long_message = (
|
||||
"Hello there, I'd like to reserve a ROOSTER named Bernard as soon"
|
||||
" as possible."
|
||||
)
|
||||
resp = client.post(
|
||||
"/contact",
|
||||
data={
|
||||
"name": "Ada Lovelace",
|
||||
"email": "ada@example.com",
|
||||
"message": long_message,
|
||||
"website": "", # honeypot empty
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "Thanks for reaching out" in resp.text
|
||||
|
||||
# Row persisted.
|
||||
assert _count(client.app, "contact_submissions") == 1
|
||||
with client.app.state.engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT name, email, message FROM contact_submissions")
|
||||
).mappings().first()
|
||||
assert row is not None
|
||||
assert row["name"] == "Ada Lovelace"
|
||||
assert row["email"] == "ada@example.com"
|
||||
assert row["message"] == long_message
|
||||
|
||||
# Email dispatched (intercepted).
|
||||
assert len(captured["emails"]) == 1
|
||||
assert captured["emails"][0]["to"] == "head-hen@example.com"
|
||||
assert captured["emails"][0]["submission_name"] == "Ada Lovelace"
|
||||
|
||||
# Audit row emitted.
|
||||
details = _audit_details(client.app, "contact_submitted")
|
||||
assert len(details) == 1
|
||||
assert "message_length" in details[0]
|
||||
# The full message body must NOT leak in the audit detail — only
|
||||
# the first 40 chars (preview) and the length.
|
||||
assert "Bernard" not in details[0], (
|
||||
"audit detail leaked past the 40-char preview window"
|
||||
)
|
||||
# Preview is truncated to 40 chars.
|
||||
assert "message_preview" in details[0]
|
||||
|
||||
|
||||
def test_honeypot_tripped_rejected_silently(contact_app) -> None:
|
||||
"""A non-empty honeypot short-circuits to the success page, no row."""
|
||||
client, captured = contact_app
|
||||
|
||||
resp = client.post(
|
||||
"/contact",
|
||||
data={
|
||||
"name": "Spammy",
|
||||
"email": "spam@example.com",
|
||||
"message": "This is a valid-looking message.",
|
||||
"website": "http://spam.example.com",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "Thanks for reaching out" in resp.text
|
||||
|
||||
# No DB row, no email.
|
||||
assert _count(client.app, "contact_submissions") == 0
|
||||
assert captured["emails"] == []
|
||||
|
||||
# Audit row captures the rejection reason.
|
||||
details = _audit_details(client.app, "contact_spam_rejected")
|
||||
assert len(details) == 1
|
||||
assert "\"reason\": \"honeypot\"" in details[0]
|
||||
|
||||
|
||||
def test_hcaptcha_fail_rejected_silently(contact_app) -> None:
|
||||
"""hCaptcha False also lands on the generic success page silently."""
|
||||
client, captured = contact_app
|
||||
|
||||
async def _hc_fail(token: str, remote_ip: str) -> bool:
|
||||
return False
|
||||
|
||||
client.app.state.hcaptcha_service.verify = _hc_fail # type: ignore[assignment]
|
||||
|
||||
resp = client.post(
|
||||
"/contact",
|
||||
data={
|
||||
"name": "Ada",
|
||||
"email": "ada@example.com",
|
||||
"message": "Please get back to me about eggs.",
|
||||
"website": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "Thanks for reaching out" in resp.text
|
||||
|
||||
assert _count(client.app, "contact_submissions") == 0
|
||||
assert captured["emails"] == []
|
||||
|
||||
details = _audit_details(client.app, "contact_spam_rejected")
|
||||
assert len(details) == 1
|
||||
assert "\"reason\": \"hcaptcha\"" in details[0]
|
||||
|
||||
|
||||
def test_validation_errors_rerender_form(contact_app) -> None:
|
||||
"""Empty / short / malformed fields re-render the form at 400."""
|
||||
client, _ = contact_app
|
||||
|
||||
# Empty name + bad email + short message.
|
||||
resp = client.post(
|
||||
"/contact",
|
||||
data={
|
||||
"name": "",
|
||||
"email": "not-an-email",
|
||||
"message": "hi",
|
||||
"website": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# Inline error messages surface.
|
||||
assert "Please enter your name." in resp.text
|
||||
assert "valid email" in resp.text
|
||||
assert "at least 10 characters" in resp.text
|
||||
# The form echoes the submitted values so the user doesn't retype.
|
||||
assert 'value="not-an-email"' in resp.text
|
||||
|
||||
# Nothing persisted.
|
||||
assert _count(client.app, "contact_submissions") == 0
|
||||
|
||||
|
||||
def test_name_too_long_rejected(contact_app) -> None:
|
||||
"""Name > 80 chars is rejected with an inline error."""
|
||||
client, _ = contact_app
|
||||
resp = client.post(
|
||||
"/contact",
|
||||
data={
|
||||
"name": "A" * 81,
|
||||
"email": "ada@example.com",
|
||||
"message": "Please do get back to me.",
|
||||
"website": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "80 characters" in resp.text
|
||||
|
||||
|
||||
def test_message_too_long_rejected(contact_app) -> None:
|
||||
"""Message > 4000 chars is rejected with an inline error."""
|
||||
client, _ = contact_app
|
||||
resp = client.post(
|
||||
"/contact",
|
||||
data={
|
||||
"name": "Ada",
|
||||
"email": "ada@example.com",
|
||||
"message": "x" * 4001,
|
||||
"website": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "4000 characters" in resp.text
|
||||
|
||||
|
||||
def test_rate_limit_trips_on_fourth(contact_app) -> None:
|
||||
"""3 submissions/hour per IP; the 4th returns 429."""
|
||||
client, _ = contact_app
|
||||
|
||||
data = {
|
||||
"name": "Ada",
|
||||
"email": "ada@example.com",
|
||||
"message": "Please get back to me about eggs.",
|
||||
"website": "",
|
||||
}
|
||||
for i in range(3):
|
||||
resp = client.post("/contact", data=data)
|
||||
assert resp.status_code == 200, (i, resp.text[:200])
|
||||
|
||||
resp = client.post("/contact", data=data)
|
||||
assert resp.status_code == 429
|
||||
assert "Too many attempts" in resp.text
|
||||
|
||||
|
||||
def test_email_send_failure_still_returns_success(
|
||||
contact_app, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""If the email dispatch blows up, the user still sees the thank-you page."""
|
||||
client, _ = contact_app
|
||||
|
||||
def _boom(**kw) -> None:
|
||||
raise RuntimeError("pretend Resend died")
|
||||
|
||||
client.app.state.email_service.send_contact_notification = _boom # type: ignore[assignment]
|
||||
|
||||
resp = client.post(
|
||||
"/contact",
|
||||
data={
|
||||
"name": "Ada",
|
||||
"email": "ada@example.com",
|
||||
"message": "Please reach out when you have a moment.",
|
||||
"website": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "Thanks for reaching out" in resp.text
|
||||
|
||||
# Row still persisted — the user's message is not lost even though
|
||||
# the notification failed.
|
||||
assert _count(client.app, "contact_submissions") == 1
|
||||