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>
205 lines
8.6 KiB
Python
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()
|