Files
chicken_babies_site/app/config.py
Phillip Tarrant 9a8506970c feat: phase 4 admin CMS — dashboard, editor, media, CSRF
Head Hen CMS end-to-end: dashboard lists all posts (drafts + published),
Markdown editor with live preview + drag-drop image upload, Pillow media
pipeline re-encoding every upload to JPEG, post CRUD + publish toggle +
hard delete, About page edit, and double-submit CSRF cookie enforced on
every admin mutating endpoint (Phase 3's TODO markers resolved).

Slug auto-generated on create and server-locked once a post has been
published. Unpublish preserves `published_at` so re-publish keeps
original date ordering. Every admin write invalidates the read-side
Post/Page TTL caches and records an `auth_events` audit row.

CSRF middleware is narrow by design — issues/refreshes the `cb_csrf`
cookie only on `GET /admin*`, and mutating endpoints opt in via
`require_csrf_form` or `require_csrf_header` Depends. Public routes,
healthz, and pre-auth login stay untouched.

64 new tests cover slugs, CSRF, media, admin posts/pages services, and
end-to-end CMS routes. Tests never mock the DB — real temp SQLite files
per the CLAUDE.md mandate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:42:01 -05:00

205 lines
8.6 KiB
Python

"""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")
if missing:
raise ValueError(
"Production configuration is missing required values: "
+ ", ".join(missing)
+ ". These are needed for magic-link admin auth."
)
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()