Files
chicken_babies_site/app/config.py
Phillip Tarrant d9090f5055 feat: phase 5 contact form — hCaptcha, honeypot, rate limit, notify
Working /contact POST flow: honeypot → hCaptcha server-verify →
field validation → SlowAPI 3/hr IP rate limit → contact_submissions
row → best-effort Resend notification (Reply-To = submitter) →
generic success page. Spam paths don't persist and render the same
success page (anti-enumeration). Send failures don't break the
request path — the row is already durable.

New services: HCaptchaService (async httpx + dev fallback),
ContactService. EmailService gains send_contact_notification.
Production config validator now requires ADMIN_CONTACT_EMAIL,
HCAPTCHA_SECRET, HCAPTCHA_SITE_KEY. 23 new tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 06:47:06 -05:00

216 lines
9.2 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")
# 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()