Compare commits

...

12 Commits

Author SHA1 Message Date
1b4619a01b Merge branch 'chore/phase-4-admin-cms' into dev: Phase 4 complete 2026-04-21 20:42:06 -05:00
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
76875a455e Merge branch 'fix/brand-contrast-polish' into dev 2026-04-21 20:07:35 -05:00
f5098c05f5 fix: brand contrast — chick-only header mark, ink callouts, nav polish
Phase 1 brand palette reused --c-sky for both the header background and
the word "Babies" inside the logo art, erasing it visually. Same class
of problem hit the contact page mailto callout.

- Split logo into chick mark + HTML site title so the wordmark colors
  no longer need to coexist with the header surface. Generator gains
  build_logo_mark_{png,webp} with a widest-gap column scan to crop.
- Header moves to --c-wheat; nav active state flips to ink pill with
  cream text; muted Shop link reads as coming-soon (italic + dim +
  not-allowed).
- Contact page mailto callout reskinned to ink/cream (strong CTA) and
  form note shifts from pale sky-deep to ink at 70% opacity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:07:30 -05:00
cd87db8e07 Merge branch 'chore/phase-3-admin-auth' into dev: Phase 3 complete 2026-04-21 16:20:58 -05:00
59dea99079 feat: phase 3 admin magic-link auth — tokens, sessions, rate limits, audit
End-to-end passwordless admin auth. /admin/login accepts an email, POSTs
mint a 256-bit magic-link token stored only as SHA-256 in
magic_link_tokens (15-min TTL, single-use via atomic rowcount UPDATE).
Resend delivers the link; in dev with no API key, EmailService logs a
structured magic_link_dev_fallback event with the URL so the flow works
offline. /admin/auth/consume/{token} verifies, upserts a users row
(display_name from email local-part), creates a sessions row, and drops
an itsdangerous-signed cb_session cookie (HttpOnly, SameSite=Lax, Secure
in prod). /admin renders a placeholder "Welcome, <name>" page pending
Phase 4 CMS. /admin/logout flips revoked_at rather than deleting the row
to preserve the audit trail.

Rate limits use SlowAPI's in-memory limiter (5/15min/IP on login,
20/15min/IP on consume) plus a DB per-email count to catch
IP-rotating abuse. ADMIN_EMAILS enforces allowlist; non-allowlisted
submissions return the same "check your inbox" page with no token
inserted and no email sent (anti-enumeration). Every event lands in
auth_events via AuditService: link_requested, link_consumed,
consume_failed, session_created, session_revoked, rate_limited.

Add a production config validator refusing empty RESEND_API_KEY,
RESEND_FROM, or ADMIN_EMAILS; add PUBLIC_BASE_URL for email link
construction. CSRF deferred to Phase 6 per roadmap scoping; logout
handler marked # TODO(phase-6-csrf).

Mark Phase 3 complete in docs/ROADMAP.md.
2026-04-21 16:20:51 -05:00
4b088e5045 Merge branch 'chore/phase-2-content-model' into dev: Phase 2 complete 2026-04-21 15:40:40 -05:00
0306f71763 feat: phase 2 content model + cache — SQLite schema, markdown, TTL
Stand up the full SQLite content layer: all 7 tables from the authoritative
schema with WAL + foreign-keys enforced per-connection, entity dataclasses
plus row mappers, hand-rolled versioned migrations tracked in
schema_migrations, and an idempotent Python seed (system user + welcome
post + About page).

Add a Markdown->HTML service using markdown-it-py with a strict bleach
allowlist (tables intentionally omitted on both sides). Add a typed
in-process TTLCache[K,V] and wire it into real DB-backed PostService and
PageService, both exposing invalidate_all() for Phase 4 admin writes.

Rewire / and /about to read from the DB; homepage renders the seeded
welcome post, About renders page.title + sanitized body_html_cached.
Update the Phase 1 route tests accordingly.

Mark Phase 2 complete in docs/ROADMAP.md.
2026-04-21 15:40:35 -05:00
28168f57b6 Merge branch 'chore/phase-1-public-skeleton' into dev: Phase 1 complete 2026-04-21 15:21:27 -05:00
f77da87eaa feat: phase 1 public site skeleton — layout, routes, CSS, logo pipeline
Ship base Jinja layout (header/nav/main/footer with skip link and aria-current),
mobile-first single-file CSS using the ROADMAP palette tokens, and four public
routes: /, /about, /contact, /shop. Blog index renders via a stable
PostService.list_published() stub returning [] — Phase 2 only swaps the body.
About is static placeholder copy, /contact ships an inert form plus a mailto:
link driven by ADMIN_CONTACT_EMAIL, /shop shows a "Coming soon" card.

Adds a Pillow-based scripts/generate_static_assets.py producing resized logo
PNG + WebP, multi-size favicon.ico, and a 180x180 apple-touch-icon on a cream
background. Outputs committed for a reproducible build.

Also ship docs/MANUAL_TESTING.md with per-route / responsive / a11y / static-
asset checklists, and mark Phase 1 complete in docs/ROADMAP.md.
2026-04-21 15:21:21 -05:00
e830e5da50 Merge branch 'chore/phase-0-foundation' into dev: Phase 0 complete 2026-04-21 14:44:46 -05:00
78dd1ac243 feat: phase 0 foundation — FastAPI scaffold + /healthz
Scaffold app/ package, pinned requirements.txt, multi-stage Dockerfile,
docker-compose.yml, and .env.example. Add typed pydantic-settings loader
with full env contract and a production validator that refuses the
dev-sentinel SECRET_KEY. Wire structlog with an APP_ENV-driven renderer
(console in dev, JSON in prod). Ship a minimal unauthenticated /healthz
returning {status, version, commit_sha} with commit SHA fed through a
GIT_COMMIT_SHA build arg.

Also mark Phase 0 complete in docs/ROADMAP.md.
2026-04-21 14:44:41 -05:00
90 changed files with 10706 additions and 35 deletions

58
.env.example Normal file
View 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:8000
# --- Build metadata ---------------------------------------------------------
# Injected at Docker build time. Surfaced by /healthz. Optional in dev.
GIT_COMMIT_SHA=unknown

10
.gitignore vendored
View File

@@ -25,9 +25,15 @@ env/
*.db-wal
*.db-shm
# Runtime data (DB + media uploads live here in prod)
data/
# 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/

90
Dockerfile Normal file
View File

@@ -0,0 +1,90 @@
# 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}
EXPOSE 8000
# Run Uvicorn directly. --proxy-headers + --forwarded-allow-ips make
# Starlette's ProxyHeadersMiddleware trust X-Forwarded-* only from the
# listed peer IPs (Caddy on the host). No --reload: this is a prod-shape
# image; local hot-reload is a dev concern and runs outside Docker.
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--proxy-headers", \
"--forwarded-allow-ips", "127.0.0.1"]

14
app/__init__.py Normal file
View 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__"]

204
app/config.py Normal file
View File

@@ -0,0 +1,204 @@
"""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()

200
app/db.py Normal file
View 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

View 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
View 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
View 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
View 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,
)

349
app/main.py Normal file
View File

@@ -0,0 +1,349 @@
"""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.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.csrf import CSRF_COOKIE_NAME, CSRFService
from app.services.email import EmailService
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 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
# 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)
# 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()

7
app/models/__init__.py Normal file
View 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
View 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
View 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"],
)

View 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);

View 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
View 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
View 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
View 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
View 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
View 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 ``![alt](url)``.
"""
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
View 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,
)

148
app/routes/public.py Normal file
View File

@@ -0,0 +1,148 @@
"""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`` — inert contact form UI + optional ``mailto:`` link.
- ``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 structlog
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.config import Settings, get_settings
from app.models.entities import Page
from app.models.posts import PostSummary
from app.services.pages import PageService, get_page_service
from app.services.posts import PostService, get_post_service
# 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__)
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
@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("/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 inert contact page.
The form fields are marked ``disabled`` and the form has no ``method``
attribute — it is UI-only. If ``ADMIN_CONTACT_EMAIL`` is configured
the template renders a ``mailto:`` link so visitors still have a way
to reach the farm before Phase 5 wires up the real POST flow.
"""
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,
},
)
@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
View 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
View 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
View 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
View 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
View 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
View 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()

167
app/services/csrf.py Normal file
View 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

141
app/services/email.py Normal file
View File

@@ -0,0 +1,141 @@
"""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_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)

125
app/services/markdown.py Normal file
View 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
View 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
View 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

174
app/services/posts.py Normal file
View File

@@ -0,0 +1,174 @@
"""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 PostStatus
from app.models.posts import PostSummary
from app.models.mappers import _parse_datetime
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 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

View 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
View 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
View 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})"
)

769
app/static/css/site.css Normal file
View File

@@ -0,0 +1,769 @@
/* -------------------------------------------------------------------------
* 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);
}
/* 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);
}
/* 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
app/static/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
app/static/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
app/static/img/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View 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![" + alt + "](" + url + ")\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();
}
})();

View 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>

View File

@@ -0,0 +1,88 @@
{#
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 &mdash; 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">
<picture>
<source srcset="{{ url_for('static', path='img/logo.webp') }}" type="image/webp">
<img src="{{ url_for('static', path='img/logo.png') }}"
alt="Chicken Babies R Us"
height="48"
class="site-header__logo">
</picture>
</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 &middot; Admin
</p>
</div>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View 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 &mdash; 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 &mdash; 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>
&mdash; 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 %}

View 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 &mdash; 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 %}

View 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 &mdash; 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 %}

View 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 &mdash; 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 %}

View 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 &mdash; 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> &middot; 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 &amp; insert. Accepted: JPG, PNG, WebP up to 8&nbsp;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 %}

View 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 %} &mdash; 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" %}
&middot; 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 &amp; insert. Accepted: JPG, PNG, WebP up to 8&nbsp;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 %}

View 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 &mdash; 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 %}

View 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 &mdash; 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 &mdash; no action will be taken.
</p>
</body>
</html>

View 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.

View 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 }} &mdash; Chicken Babies R Us{% endblock %}
{% block meta_description %}About Chicken Babies R Us &mdash; 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 %}

View 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 &middot; Morrison, Tennessee
</p>
<p class="site-footer__legal">
&copy; {{ 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>
(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>

View File

@@ -0,0 +1,85 @@
{#
Contact page — Phase 1 version.
The form is deliberately inert: no `method`, no `action`, all inputs
and the submit button carry the `disabled` attribute. A muted note
explains the form is coming soon; if `ADMIN_CONTACT_EMAIL` is set in
the environment we render a `mailto:` link above the form so visitors
still have a way to reach the farm.
Phase 5 replaces this template with a working POST handler, hCaptcha,
honeypot, and rate limiting.
Context:
- contact_email : str | None (from settings.admin_contact_email)
- active_nav : "contact"
#}
{% extends "public/base.html" %}
{% block title %}Contact &mdash; 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 &mdash; questions about the birds,
availability, or just to say hi.
</p>
{% if contact_email %}
<p class="contact-mailto">
The easiest way to reach us right now is email:
<a href="mailto:{{ contact_email }}">{{ contact_email }}</a>.
</p>
{% else %}
<p class="contact-mailto contact-mailto--muted">
A direct email address will be posted here soon.
</p>
{% endif %}
<p class="contact-form__note" role="note">
Secure contact form coming soon.
</p>
{# action="" and no method = form cannot submit. Every input is
disabled so screen readers and the keyboard both respect the
"not-yet-available" state. #}
<form class="contact-form" action="" aria-describedby="contact-form-note" novalidate>
<div class="contact-form__field">
<label for="contact-name">Name</label>
<input type="text"
id="contact-name"
name="name"
autocomplete="name"
disabled>
</div>
<div class="contact-form__field">
<label for="contact-email">Email</label>
<input type="email"
id="contact-email"
name="email"
autocomplete="email"
disabled>
</div>
<div class="contact-form__field">
<label for="contact-message">Message</label>
<textarea id="contact-message"
name="message"
rows="6"
disabled></textarea>
</div>
<div class="contact-form__actions">
<button type="submit" class="btn btn--primary" disabled>
Send message
</button>
</div>
</form>
</article>
{% endblock %}

View 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 &mdash; Home{% endblock %}
{% block meta_description %}Updates from Chicken Babies R Us &mdash; 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 &mdash; check back soon!</p>
</div>
{% endif %}
</section>
{% endblock %}

View 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>

View 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 &mdash; Chicken Babies R Us{% endblock %}
{% block meta_description %}Our farm shop is coming soon &mdash; 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
View File

0
data/media/.gitkeep Normal file
View File

32
docker-compose.yml Normal file
View 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 8000 inside the container. In the production
# topology Caddy fronts this; for local runs it's directly on the
# host loopback via the mapped port.
- "8000:8000"
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

163
docs/MANUAL_TESTING.md Normal file
View File

@@ -0,0 +1,163 @@
# 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.

View File

@@ -4,48 +4,212 @@ High-level phased plan. Each phase ends in a mergeable `dev` state and a passing
---
## Phase 0 — Foundation
## Phase 0 — Foundation
- Scaffold `app/` package, `requirements.txt`, `.env.example`, `Dockerfile`, `docker-compose.yml`.
- Pinned deps (upper bounds):
`fastapi`, `uvicorn[standard]`, `jinja2`, `pydantic`, `pydantic-settings`, `sqlalchemy` (Core only), `markdown-it-py`, `bleach`, `Pillow`, `python-magic`, `resend`, `slowapi`, `structlog`, `itsdangerous`, `python-multipart`, `pytest`, `httpx`.
- `structlog` init at app startup.
- Health endpoint `/healthz` (returns app version + commit SHA from env).
- Typed config loader reading env via `pydantic-settings`.
**Completed:** 2026-04-21
## Phase 1 — Public Site Skeleton
**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.
- Base Jinja layout: header with logo, nav (Home · About · Contact · Shop (disabled)), footer.
- Mobile-first responsive CSS, no JS framework. CSS custom properties from the palette below.
- Routes: `/`, `/about`, `/contact`, `/shop` (shop shows "Coming soon" card, no form).
- `/` renders the blog index from DB (empty list is acceptable this phase).
- Manual test checklist → `docs/MANUAL_TESTING.md`.
**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
## Phase 2 — Content Model + Cache
**Endpoints created:**
- `GET /healthz` — public, unauthenticated. Returns minimal flat JSON `{"status":"ok","version":"0.1.0","commit_sha":"<sha-or-unknown>"}`.
- SQLite schema (below) with `PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;`.
- Dataclasses (below) as the in-app model; SQL → dataclass mapper lives in `app/models/`.
- Row-level rendered-HTML cache (`body_html_cached`) regenerated on write.
- In-process TTL cache (60 s) over *hot query results* (published posts list, page-by-slug); invalidated on admin writes.
- Initial migration seeds one welcome blog post + an About page so the site is not blank before admin exists.
**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 3Admin Auth (Magic Link)
## Phase 1Public Site Skeleton ✅
- `/admin/login` — email-only form.
- POST creates a magic-link token (256-bit, hashed at rest, 15-min TTL, single-use), sends via Resend.
- Click link → create signed session cookie (30-day) → redirect `/admin`.
- Logout revokes the session row (does not delete — audit trail).
- Rate limits (SlowAPI): 5 / 15 min / IP *and* / email.
- Allowlist from `ADMIN_EMAILS` env var. Non-allowlisted addresses: return same success message, send no email (no user enumeration).
- Audit log row for every auth event.
**Completed:** 2026-04-21
## Phase 4 — Admin CMS
**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.
- `/admin` dashboard: lists pages + posts, links to edit.
- Markdown editor: textarea + live preview + drag-and-drop image upload. Prefer minimal hand-rolled (a single `textarea` + `fetch`-based upload) over a heavy library; EasyMDE acceptable only if the minimal path proves clunky in manual testing.
- Media upload endpoint: magic-byte validation, 8 MB cap, Pillow re-encode (JPEG/WebP/PNG), random storage name under `data/media/<yyyy>/<mm>/<random>.<ext>`.
- CRUD: pages (About), posts (blog) with publish toggle and slug auto-gen.
- Save path: markdown → `markdown-it-py``bleach` allowlist → stored in `body_html_cached`.
**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 35.
- **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 `![alt](url)` 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

17
requirements.txt Normal file
View 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

View 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
View File

@@ -0,0 +1,3 @@
"""Pytest suite for the Chicken Babies R Us app."""
from __future__ import annotations

56
tests/conftest.py Normal file
View 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()

View 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

View 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"

View 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
View 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
View 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
View 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"]

View File

@@ -0,0 +1,91 @@
"""Tests for :class:`app.services.csrf.CSRFService`."""
from __future__ import annotations
import pytest
from itsdangerous import URLSafeTimedSerializer
from app.services.csrf import CSRF_COOKIE_NAME, CSRFService
@pytest.fixture
def signer() -> URLSafeTimedSerializer:
"""Return an ``itsdangerous`` signer with the canonical CSRF salt."""
return URLSafeTimedSerializer("test-key-for-csrf-service-0123456789", salt="csrf")
@pytest.fixture
def service(signer: URLSafeTimedSerializer) -> CSRFService:
"""Return a dev-mode :class:`CSRFService` (Secure flag off)."""
return CSRFService(signer, production=False)
def test_issue_returns_matching_token_and_cookie(service: CSRFService) -> None:
"""Without a prior cookie, :meth:`issue` mints a fresh pair that verify.
The token and the cookie value are the same signed string — by
design — but the critical property is that ``verify`` accepts them
as a match.
"""
token, cookie = service.issue(existing_cookie=None)
assert token
assert cookie
assert service.verify(cookie_value=cookie, submitted=token) is True
def test_issue_reuses_nonce_when_cookie_valid(service: CSRFService) -> None:
"""A still-valid cookie is reused so open admin tabs keep working."""
_, cookie = service.issue()
token2, cookie2 = service.issue(existing_cookie=cookie)
# Cookie value is deterministic post-nonce but itsdangerous signs
# with a timestamp, so the signed strings differ even when the
# underlying nonce is reused. The cross-pair verify matters.
assert service.verify(cookie_value=cookie, submitted=token2) is True
assert service.verify(cookie_value=cookie2, submitted=token2) is True
def test_verify_rejects_tampered_token(service: CSRFService) -> None:
"""A single-char tweak to the signed token breaks the signature."""
token, cookie = service.issue()
# Flip a char in the signature payload.
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
assert service.verify(cookie_value=cookie, submitted=tampered) is False
def test_verify_rejects_different_cookie(service: CSRFService) -> None:
"""Two separately-issued nonces never match each other."""
t1, c1 = service.issue()
t2, c2 = service.issue()
assert service.verify(cookie_value=c1, submitted=t2) is False
assert service.verify(cookie_value=c2, submitted=t1) is False
def test_verify_rejects_missing_values(service: CSRFService) -> None:
"""Empty or None inputs fail closed."""
assert service.verify(cookie_value=None, submitted="x") is False
assert service.verify(cookie_value="x", submitted=None) is False
assert service.verify(cookie_value="", submitted="") is False
def test_verify_rejects_different_key(signer: URLSafeTimedSerializer) -> None:
"""Tokens signed by a different key never verify."""
service_a = CSRFService(signer, production=False)
other_signer = URLSafeTimedSerializer("DIFFERENT-KEY-XYZ", salt="csrf")
service_b = CSRFService(other_signer, production=False)
_, cookie_a = service_a.issue()
token_b, _ = service_b.issue()
assert service_a.verify(cookie_value=cookie_a, submitted=token_b) is False
def test_cookie_params_production_sets_secure(signer: URLSafeTimedSerializer) -> None:
"""``Secure=True`` only when constructed with ``production=True``."""
dev = CSRFService(signer, production=False)
prod = CSRFService(signer, production=True)
assert dev.cookie_params()["secure"] is False
assert prod.cookie_params()["secure"] is True
assert dev.cookie_params()["key"] == CSRF_COOKIE_NAME
# HttpOnly is intentionally off so JS can read the cookie for
# double-submit header POSTs.
assert dev.cookie_params()["httponly"] is False
assert dev.cookie_params()["samesite"] == "lax"

155
tests/test_db_migrations.py Normal file
View File

@@ -0,0 +1,155 @@
"""Tests for the DB migration runner and per-connection PRAGMAs.
Goals:
- ``run_migrations`` applies every file once and is a no-op on re-run.
- Tables and indexes declared in ``001_init.sql`` exist after a fresh
migration.
- The ``schema_migrations`` tracker records the applied version.
- ``journal_mode=WAL`` and ``foreign_keys=ON`` hold on *every* new
connection (the whole point of the ``@event.listens_for`` hook).
Uses a function-scoped temp SQLite file so each test is hermetic.
"""
from __future__ import annotations
from pathlib import Path
from sqlalchemy import Engine, text
from app.db import build_engine, run_migrations
def _fresh_engine(tmp_path: Path) -> Engine:
"""Build a fresh engine on a temp-file SQLite DB.
Helper kept local to this test module — conftest provides
higher-level fixtures, but for migration tests we want direct
control over migration order and re-runs.
"""
return build_engine(f"sqlite:///{tmp_path / 'mig.db'}")
def test_first_migration_creates_all_tables(tmp_path: Path) -> None:
"""Running migrations on a fresh DB creates every authoritative table.
We check for each of the 7 domain tables + ``schema_migrations``;
absence of any one would indicate the SQL file got truncated.
"""
engine = _fresh_engine(tmp_path)
applied = run_migrations(engine)
assert applied == ["001_init"], applied
expected_tables = {
"users",
"magic_link_tokens",
"sessions",
"pages",
"posts",
"media",
"contact_submissions",
"auth_events",
"schema_migrations",
}
with engine.connect() as conn:
rows = conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
found = {r[0] for r in rows}
missing = expected_tables - found
assert not missing, f"migration did not create: {missing}"
def test_required_indexes_exist(tmp_path: Path) -> None:
"""The three named indexes from the ROADMAP schema are present."""
engine = _fresh_engine(tmp_path)
run_migrations(engine)
with engine.connect() as conn:
rows = conn.execute(
text("SELECT name FROM sqlite_master WHERE type='index'")
).fetchall()
names = {r[0] for r in rows}
for required in (
"idx_magic_email_created",
"idx_posts_status_pub",
"idx_auth_events_created",
):
assert required in names, f"missing index: {required} (have {names})"
def test_migrations_are_idempotent(tmp_path: Path) -> None:
"""Re-running migrations on an already-migrated DB is a no-op."""
engine = _fresh_engine(tmp_path)
first = run_migrations(engine)
second = run_migrations(engine)
assert first == ["001_init"]
assert second == [], "re-run should not re-apply migrations"
with engine.connect() as conn:
rows = conn.execute(
text("SELECT version FROM schema_migrations")
).fetchall()
versions = [r[0] for r in rows]
# Exactly one tracker row for the initial migration. Seed is not
# run in this test, so seed_001 should NOT be present here.
assert versions == ["001_init"], versions
def test_posts_status_check_constraint_rejects_invalid(tmp_path: Path) -> None:
"""The CHECK constraint on ``posts.status`` rejects unknown values.
This is a direct smoke test of the authoritative schema — if the
migration accidentally drops the CHECK clause, this test fails.
"""
import sqlalchemy.exc
engine = _fresh_engine(tmp_path)
run_migrations(engine)
# Seed a minimal user so the FK is satisfied.
with engine.connect() as conn:
conn.execute(
text(
"INSERT INTO users (id, email, display_name, created_at,"
" active) VALUES (1, 'x@x', 'x', '2026-01-01T00:00:00+00:00', 1)"
)
)
conn.commit()
with engine.connect() as conn:
try:
conn.execute(
text(
"INSERT INTO posts (slug, title, body_md, body_html_cached,"
" status, updated_at, author_user_id)"
" VALUES ('x', 't', 'm', 'h', 'bogus',"
" '2026-01-01T00:00:00+00:00', 1)"
)
)
conn.commit()
raised = False
except sqlalchemy.exc.IntegrityError:
raised = True
assert raised, "CHECK constraint should have rejected status='bogus'"
def test_pragmas_apply_on_every_connection(tmp_path: Path) -> None:
"""Both PRAGMAs are active on every new connection from the pool.
Issues two separate connect calls; both must show WAL +
foreign_keys=1. This is the regression guard for the
``@event.listens_for(Engine, "connect")`` contract.
"""
engine = _fresh_engine(tmp_path)
run_migrations(engine)
for _ in range(2):
with engine.connect() as conn:
jm = conn.execute(text("PRAGMA journal_mode")).scalar_one()
fk = conn.execute(text("PRAGMA foreign_keys")).scalar_one()
assert str(jm).lower() == "wal", f"expected WAL, got {jm!r}"
assert int(fk) == 1, f"expected foreign_keys=1, got {fk!r}"

113
tests/test_email_service.py Normal file
View File

@@ -0,0 +1,113 @@
"""Tests for :class:`app.services.email.EmailService`.
We exercise the dev-fallback path (no ``RESEND_API_KEY``) and assert:
- It does NOT raise.
- It emits a structured log event ``magic_link_dev_fallback`` with the
full URL so a developer can copy it.
- It doesn't call the real Resend SDK.
Production-missing-key is validated at startup by the pydantic model
validator (covered separately in the config tests — but we also verify
the behavior here via a direct settings construction).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from fastapi.templating import Jinja2Templates
from app.config import Settings
from app.logging_config import configure_logging
from app.services.email import EmailService
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent / "app"
_TEMPLATES_DIR = _PACKAGE_ROOT / "templates"
@pytest.fixture
def templates() -> Jinja2Templates:
"""Return a Jinja2Templates pointing at the real app templates dir."""
return Jinja2Templates(directory=_TEMPLATES_DIR)
def test_dev_fallback_logs_url(
templates: Jinja2Templates, capsys: pytest.CaptureFixture[str]
) -> None:
"""Missing RESEND_API_KEY in dev -> structured log, no exception.
structlog writes via ``PrintLoggerFactory`` (stdout), so we capture
stdout rather than the stdlib ``caplog`` handler.
"""
# Reconfigure structlog so the ConsoleRenderer is active — the test
# harness may have left it in another state.
configure_logging("development")
settings = Settings(
app_env="development",
resend_api_key=None,
resend_from=None,
) # type: ignore[call-arg]
svc = EmailService(settings, templates)
url = "http://localhost:8000/admin/auth/consume/raw-token-123"
svc.send_magic_link(
to="head-hen@example.com",
url=url,
display_name="Head Hen",
ttl_min=15,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
captured = capsys.readouterr()
combined = captured.out + captured.err
assert "magic_link_dev_fallback" in combined
assert url in combined
def test_production_missing_key_refuses_startup() -> None:
"""Production without resend key / from / admin_emails raises at init."""
with pytest.raises(ValueError) as excinfo:
Settings(
app_env="production",
secret_key="long-enough-prod-secret-key-value-000000",
) # type: ignore[call-arg]
msg = str(excinfo.value)
assert "RESEND_API_KEY" in msg
assert "RESEND_FROM" in msg
assert "ADMIN_EMAILS" in msg
def test_dev_fallback_does_not_touch_resend(
templates: Jinja2Templates, monkeypatch
) -> None:
"""If the SDK IS present, the dev fallback must still not call it."""
import resend # type: ignore[import-untyped]
called: dict[str, bool] = {"ok": False}
def _boom(*a, **kw): # pragma: no cover — shouldn't execute
called["ok"] = True
raise RuntimeError("should not be called in dev fallback")
monkeypatch.setattr(resend.Emails, "send", staticmethod(_boom))
settings = Settings(
app_env="development",
resend_api_key=None,
resend_from=None,
) # type: ignore[call-arg]
svc = EmailService(settings, templates)
svc.send_magic_link(
to="a@b.co",
url="http://x/y",
display_name="A",
ttl_min=15,
expires_at=datetime.now(timezone.utc),
)
assert called["ok"] is False

35
tests/test_healthz.py Normal file
View File

@@ -0,0 +1,35 @@
"""Tests for the ``/healthz`` endpoint.
Kept deliberately small in Phase 0: we verify contract and shape, not
content beyond what the response model guarantees.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app import __version__
from app.main import app
def test_healthz_returns_ok_and_version() -> None:
"""``GET /healthz`` returns 200 with status=ok and the app version.
Also asserts ``commit_sha`` is present as a non-empty string — it
defaults to ``"unknown"`` in dev, which still satisfies the contract
surfaced to uptime probes.
"""
# TestClient uses the module-level app, which has already been wired
# by create_app() at import time — exactly the path uvicorn takes in
# production, so the test exercises real startup behavior.
client = TestClient(app)
response = client.get("/healthz")
assert response.status_code == 200, response.text
payload = response.json()
assert payload["status"] == "ok"
assert payload["version"] == __version__
assert isinstance(payload["commit_sha"], str)
assert payload["commit_sha"], "commit_sha must be a non-empty string"

112
tests/test_markdown.py Normal file
View File

@@ -0,0 +1,112 @@
"""Tests for the Markdown → sanitized HTML pipeline.
We care about three things:
1. Safe inline markup (``**bold**``, ``*italic*``, links, lists) round-trips
into the expected HTML tags.
2. Dangerous constructs (``<script>``, ``<iframe>``, ``<style>``, inline
``onclick`` handlers, ``javascript:`` URLs) are stripped — not
escaped — from the output.
3. Tables render (we enabled the ``table`` plugin in
:class:`MarkdownService`).
These are spot checks, not a full fuzz of bleach. The full allowlist
is already enforced in ``app.services.markdown``.
"""
from __future__ import annotations
import pytest
from app.services.markdown import MarkdownService, render_markdown_safe
@pytest.fixture
def md() -> MarkdownService:
"""Return a fresh :class:`MarkdownService`.
Function-scoped to keep tests independent; the service is cheap
to construct.
"""
return MarkdownService()
def test_basic_markdown_renders_paragraphs_and_emphasis(md: MarkdownService) -> None:
"""Simple Markdown constructs produce the expected safe HTML."""
html = md.render("Hello **world** and *friends*.")
assert "<p>" in html
assert "<strong>world</strong>" in html
assert "<em>friends</em>" in html
def test_script_tags_are_stripped(md: MarkdownService) -> None:
"""A ``<script>`` injected through raw HTML is stripped entirely."""
src = "Hello<script>alert('xss')</script>world"
html = md.render(src)
# bleach strip=True drops the tag; the (potentially dangerous)
# content can remain as text but cannot execute.
assert "<script" not in html
assert "</script>" not in html
def test_iframe_and_style_tags_are_stripped(md: MarkdownService) -> None:
"""Disallowed block-level tags are removed from the output."""
html = md.render(
"<iframe src='evil'></iframe>\n\n<style>body{}</style>\n\nsafe"
)
assert "<iframe" not in html
assert "<style" not in html
assert "safe" in html
def test_javascript_urls_are_stripped_from_links(md: MarkdownService) -> None:
"""Raw ``<a href="javascript:...">`` links lose the dangerous href.
We construct the link as raw HTML (rather than ``[text](url)``
Markdown syntax, which commonmark silently refuses to turn into
an anchor for the unknown ``javascript:`` protocol) so the
bleach allowlist actually has an anchor to filter. The assertion
is that the ``javascript:`` URL does not make it into the
sanitized output.
"""
html = md.render('<a href="javascript:alert(1)">click</a>')
assert "javascript:" not in html
def test_allowed_link_and_image_attributes_survive(md: MarkdownService) -> None:
"""Safe link/image attributes are preserved."""
html = md.render(
'[hello](https://example.com "Example")\n\n'
'![alt text](https://example.com/a.png "Caption")'
)
assert 'href="https://example.com"' in html
assert 'title="Example"' in html
assert 'alt="alt text"' in html
assert 'src="https://example.com/a.png"' in html
def test_inline_event_handler_attribute_is_stripped(md: MarkdownService) -> None:
"""``onclick`` and similar inline handlers never survive sanitization."""
html = md.render('<a href="/x" onclick="alert(1)">x</a>')
assert "onclick" not in html
def test_table_tags_are_stripped(md: MarkdownService) -> None:
"""Tables are not in the bleach allowlist, so their tags are stripped.
Documents the intentional policy: the Markdown parser is the
commonmark preset with NO table plugin, and the bleach allowlist
has no table tags — widening either without the other would be
a policy mismatch. If a future phase wants tables, this test
should flip to assert the opposite along with the matching
allowlist change.
"""
src = "| a | b |\n|---|---|\n| 1 | 2 |\n"
html = md.render(src)
assert "<table" not in html
def test_module_level_helper_matches_class(md: MarkdownService) -> None:
"""``render_markdown_safe`` produces the same output as the class."""
src = "Hello **there**."
assert render_markdown_safe(src) == md.render(src)

179
tests/test_media_service.py Normal file
View File

@@ -0,0 +1,179 @@
"""Tests for :class:`app.services.media.MediaService`.
Uses real bytes produced by Pillow in-test so the magic-byte check
exercises real image data. Does NOT mock the filesystem — writes go
to a per-test temp dir so we can assert the stored JPEG is a valid
JPEG after the re-encode pipeline.
"""
from __future__ import annotations
import io
from pathlib import Path
import pytest
from PIL import Image
from sqlalchemy import Engine
from app.services.audit import AuditService
from app.services.media import (
MAX_UPLOAD_BYTES,
MediaRejectedError,
MediaService,
)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _jpeg_bytes(size: tuple[int, int] = (64, 48), color: str = "red") -> bytes:
"""Return valid JPEG bytes produced by Pillow."""
img = Image.new("RGB", size, color)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
def _rgba_png_bytes(size: tuple[int, int] = (32, 32)) -> bytes:
"""Return valid RGBA-transparent PNG bytes."""
img = Image.new("RGBA", size, (0, 128, 0, 128))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
@pytest.fixture
def media_root(tmp_path: Path) -> Path:
"""Return a per-test directory rooted under the pytest tmpdir."""
root = tmp_path / "media"
root.mkdir()
return root
@pytest.fixture
def service(db_engine: Engine, media_root: Path) -> MediaService:
"""Return a :class:`MediaService` wired to a real engine + temp dir."""
return MediaService(
engine=db_engine,
media_root=str(media_root),
audit=AuditService(db_engine),
)
# ---------------------------------------------------------------------------
# happy path
# ---------------------------------------------------------------------------
def test_save_upload_stores_jpeg_and_returns_media(
service: MediaService, media_root: Path
) -> None:
"""A valid JPEG is re-encoded and written under a random filename."""
data = _jpeg_bytes()
media = service.save_upload(
original_filename="cute-chick.jpg",
data=data,
uploaded_by=1,
)
assert media.id > 0
assert media.content_type == "image/jpeg"
assert media.filename.endswith(".jpg")
assert media.original_filename == "cute-chick.jpg"
assert media.size_bytes > 0
stored = Path(media.stored_path)
assert stored.exists()
assert stored.parent.parent.parent == media_root
# Verify the written file is a real JPEG RGB image.
with Image.open(stored) as img:
assert img.format == "JPEG"
assert img.mode == "RGB"
def test_save_upload_assigns_random_filename_not_original(
service: MediaService,
) -> None:
"""The stored filename is a random token — not the client-supplied name."""
data = _jpeg_bytes()
media = service.save_upload(
original_filename="secret.jpg",
data=data,
uploaded_by=1,
)
assert media.filename != "secret.jpg"
# Random component is 16 bytes url-safe (~22 chars) + ".jpg".
name, _, ext = media.filename.rpartition(".")
assert ext == "jpg"
assert len(name) >= 16
def test_public_url_is_under_media_prefix(service: MediaService) -> None:
"""``public_url`` starts with ``/media/`` and a year partition."""
media = service.save_upload(
original_filename="ok.jpg",
data=_jpeg_bytes(),
uploaded_by=1,
)
url = service.public_url(media)
assert url.startswith("/media/")
assert url.endswith(media.filename)
def test_save_upload_flattens_rgba_onto_white(service: MediaService) -> None:
"""An RGBA PNG upload is flattened and stored as RGB JPEG."""
data = _rgba_png_bytes()
media = service.save_upload(
original_filename="transparent.png",
data=data,
uploaded_by=1,
)
stored = Path(media.stored_path)
with Image.open(stored) as img:
assert img.format == "JPEG"
assert img.mode == "RGB"
# ---------------------------------------------------------------------------
# rejection paths
# ---------------------------------------------------------------------------
def test_reject_empty_upload(service: MediaService) -> None:
"""Empty uploads are rejected before any decode runs."""
with pytest.raises(MediaRejectedError):
service.save_upload(
original_filename="empty.jpg",
data=b"",
uploaded_by=1,
)
def test_reject_non_image_bytes(service: MediaService) -> None:
"""A plain-text payload fails the magic-byte sniff."""
with pytest.raises(MediaRejectedError):
service.save_upload(
original_filename="totally.jpg",
data=b"this is definitely not an image payload" * 10,
uploaded_by=1,
)
def test_reject_gif_upload(service: MediaService) -> None:
"""Animated-GIF hazard — GIFs are explicitly not on the allowlist."""
img = Image.new("RGB", (16, 16), "green")
buf = io.BytesIO()
img.save(buf, format="GIF")
with pytest.raises(MediaRejectedError):
service.save_upload(
original_filename="animated.gif",
data=buf.getvalue(),
uploaded_by=1,
)
def test_reject_oversize_upload(service: MediaService) -> None:
"""Payloads over the 8 MB cap are rejected."""
huge = b"\xff" * (MAX_UPLOAD_BYTES + 1)
with pytest.raises(MediaRejectedError):
service.save_upload(
original_filename="huge.jpg",
data=huge,
uploaded_by=1,
)

View File

@@ -0,0 +1,53 @@
"""Tests for :class:`app.services.pages.PageService`.
Uses the session-scoped seeded ``db_engine`` fixture.
"""
from __future__ import annotations
from sqlalchemy import Engine
from app.models.entities import Page
from app.services.pages import PageService
def test_get_by_slug_returns_seeded_about_page(db_engine: Engine) -> None:
"""The seeded About page loads as a :class:`Page` dataclass."""
service = PageService(db_engine)
page = service.get_by_slug("about")
assert page is not None
assert isinstance(page, Page)
assert page.slug == "about"
assert page.title == "About the Farm"
# The sanitized HTML must contain a <p> since the seed Markdown
# has multiple paragraphs; this also proves the Markdown pipeline
# ran at seed time.
assert "<p>" in page.body_html_cached
assert page.published is True
def test_get_by_slug_returns_none_for_unknown_slug(db_engine: Engine) -> None:
"""Unknown slugs return ``None`` rather than raising."""
service = PageService(db_engine)
assert service.get_by_slug("does-not-exist") is None
def test_get_by_slug_is_cached(db_engine: Engine) -> None:
"""The TTL cache wraps page lookups keyed by slug."""
service = PageService(db_engine)
first = service.get_by_slug("about")
second = service.get_by_slug("about")
assert first is second
def test_invalidate_all_forces_reload(db_engine: Engine) -> None:
"""After :meth:`invalidate_all` the next call re-hits the DB."""
service = PageService(db_engine)
first = service.get_by_slug("about")
service.invalidate_all()
second = service.get_by_slug("about")
assert first is not second
# Same slug, same row — content equal, identity different.
assert first is not None and second is not None
assert first.slug == second.slug == "about"

View File

@@ -0,0 +1,63 @@
"""Tests for :class:`app.services.posts.PostService`.
Uses the session-scoped ``db_engine`` fixture (temp file, migrated,
seeded) so we exercise the real SQL path — not a mock.
"""
from __future__ import annotations
from sqlalchemy import Engine
from app.models.posts import PostSummary
from app.services.posts import PostService
def test_list_published_returns_seeded_welcome_post(db_engine: Engine) -> None:
"""The seeded welcome post is visible to the published-list query."""
service = PostService(db_engine)
posts = service.list_published()
assert len(posts) >= 1
slugs = [p.slug for p in posts]
assert "welcome-to-the-farm" in slugs
welcome = next(p for p in posts if p.slug == "welcome-to-the-farm")
assert isinstance(welcome, PostSummary)
assert welcome.title == "Welcome to the Farm"
assert welcome.published_at is not None
# Excerpt must be populated and short enough for a card layout.
assert welcome.excerpt
assert len(welcome.excerpt) <= 281 # 280 + optional ellipsis
def test_list_published_is_cached(db_engine: Engine) -> None:
"""Subsequent calls with the same limit return the same object.
The cache is keyed by limit. Two consecutive calls within the
TTL window should hand back the identical list object, which
proves the cache hit path works.
"""
service = PostService(db_engine)
first = service.list_published()
second = service.list_published()
# Same list object = cache hit. Replaces an explicit "spy on SQL"
# test — simpler and more robust to refactors.
assert first is second
def test_invalidate_all_forces_reload(db_engine: Engine) -> None:
"""After :meth:`invalidate_all` the next call re-hits the DB."""
service = PostService(db_engine)
first = service.list_published()
service.invalidate_all()
second = service.list_published()
assert first is not second
# Content should still match — same DB, same rows.
assert [p.slug for p in first] == [p.slug for p in second]
def test_list_published_respects_limit(db_engine: Engine) -> None:
"""``limit`` is forwarded to the SQL query."""
service = PostService(db_engine)
posts = service.list_published(limit=1)
assert len(posts) <= 1

122
tests/test_public_routes.py Normal file
View File

@@ -0,0 +1,122 @@
"""Smoke tests for the public-site routes.
These tests focus on contract rather than styling:
- every public route returns 200 with an HTML content-type
- each page contains a page-specific substring (proves the template
actually rendered, not just that the route exists)
- the homepage renders the Phase 2 seeded welcome post title
- the About page renders the Phase 2 seeded About markdown
- the shared layout emits the logo image path
- the About nav link carries ``aria-current="page"``
Phase 2 updates: the homepage no longer shows "No posts yet" because
the seed inserts a welcome post, and the About page content now comes
from the DB-backed ``pages`` row rather than the old static template.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture(scope="module")
def client() -> TestClient:
"""Return a module-scoped FastAPI TestClient.
TestClient uses the module-level `app` built by `create_app()` at
import time — i.e. the exact same app uvicorn runs in production,
including migrations + seed.
"""
return TestClient(app)
@pytest.mark.parametrize(
"path,expected_substring",
[
("/", "Chicken Babies"),
# Phase 2: the About page renders the seeded page title
# "About the Farm" (h1 from the template + page.title).
("/about", "About the Farm"),
("/contact", "Get in touch"),
("/shop", "Coming soon"),
],
)
def test_public_route_renders_html(
client: TestClient,
path: str,
expected_substring: str,
) -> None:
"""Every public page returns 200 HTML containing a page-specific string.
The substring is intentionally a headline the template owns so the
test fails loudly if the wrong template is accidentally wired up.
"""
response = client.get(path)
assert response.status_code == 200, (
f"{path} returned {response.status_code}: {response.text[:200]}"
)
content_type = response.headers.get("content-type", "")
assert content_type.startswith("text/html"), (
f"{path} returned unexpected content-type: {content_type!r}"
)
assert expected_substring in response.text, (
f"{path} body missing expected substring {expected_substring!r}"
)
def test_home_shows_welcome_post(client: TestClient) -> None:
"""The Phase 2 seed inserts a welcome post; its title appears on /.
Replaces the Phase 1 "No posts yet" assertion now that the DB
has a real published row on first boot.
"""
response = client.get("/")
assert response.status_code == 200
assert "Welcome to the Farm" in response.text
def test_about_renders_seeded_markdown(client: TestClient) -> None:
"""The About page body comes from the seeded ``pages`` row.
Picks a distinctive substring from the seeded Markdown so the
assertion fails if the old static template ever comes back.
"""
response = client.get("/about")
assert response.status_code == 200
# Substring from the seeded About markdown paragraph 1.
assert "small family farm" in response.text
# Seeded copy explicitly does not expose a street address.
# Spot-check: the word "Morrison" appears (town-level).
assert "Morrison" in response.text
def test_layout_includes_logo_image(client: TestClient) -> None:
"""Shared layout references the generated logo asset paths.
We check for the stem (``/static/img/logo.``) rather than a specific
extension so both the <source srcset="...webp"> and <img src="...png">
markup are covered by a single assertion.
"""
response = client.get("/")
assert response.status_code == 200
assert "/static/img/logo." in response.text
def test_nav_marks_active_page(client: TestClient) -> None:
"""The About page renders ``aria-current=\"page\"`` on its nav link.
Exercises the shared layout's active-nav logic end-to-end without
inspecting internals.
"""
response = client.get("/about")
assert response.status_code == 200
# The pair should appear exactly once per active link; we only need
# to prove it's present at all.
assert 'aria-current="page"' in response.text

142
tests/test_rate_limit.py Normal file
View File

@@ -0,0 +1,142 @@
"""Rate-limit tests for the admin auth flow.
Covers:
- IP-level SlowAPI limit: 6th POST /admin/login from the same IP within
15 minutes returns 429 + ``rate_limited.html`` body.
- Per-email DB limit: 6th POST /admin/login for the same email (even
from different IPs — which TestClient can't really simulate without
middleware tricks, so we directly insert 5 recent tokens to prime
the DB) returns 429.
- Every 429 path writes a ``rate_limited`` audit event.
"""
from __future__ import annotations
import importlib
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
@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."""
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")
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 = {"urls": []}
app.state.email_service.send_magic_link = lambda **kw: captured["urls"].append( # type: ignore[assignment]
kw["url"]
)
from app.services.rate_limit import limiter
limiter.reset()
with TestClient(app) as client:
yield client, captured
_config.get_settings.cache_clear()
def test_ip_rate_limit_trips_on_sixth(admin_app) -> None:
"""Five POSTs succeed; the sixth from the same IP returns 429."""
client, _ = admin_app
# Use a non-allowlisted email so we don't bump the DB per-email
# limit at the same time — isolates the IP-level limit.
for i in range(5):
resp = client.post(
"/admin/login", data={"email": f"nobody{i}@example.com"}
)
assert resp.status_code == 200, (i, resp.text[:200])
resp = client.post("/admin/login", data={"email": "nobody5@example.com"})
assert resp.status_code == 429
assert "Too many attempts" in resp.text
# Audit row written.
with client.app.state.engine.connect() as conn:
rows = conn.execute(
text(
"SELECT detail FROM auth_events"
" WHERE event_type = 'rate_limited'"
)
).mappings().all()
assert len(rows) >= 1
assert any("\"scope\": \"ip\"" in str(r["detail"]) for r in rows)
def test_per_email_rate_limit_trips_on_sixth(
admin_app, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Priming 5 recent tokens for an email causes the 6th request to 429."""
client, _ = admin_app
# Disable the SlowAPI IP limiter temporarily so we can isolate the
# DB-side per-email check. We can't call six requests through the
# IP limiter within 15 minutes — the IP limit would trip first.
from app.services.rate_limit import limiter
limiter.enabled = False
try:
# Seed 5 recent tokens for the allowlisted email.
now = datetime.now(timezone.utc)
with client.app.state.engine.begin() as conn:
for i in range(5):
conn.execute(
text(
"INSERT INTO magic_link_tokens"
" (email, token_hash, created_at, expires_at,"
" request_ip)"
" VALUES (:e, :h, :c, :x, :ip)"
),
{
"e": "headhen@example.com",
"h": f"seed-hash-{i}-" + ("x" * 50),
"c": now.isoformat(),
"x": (now + timedelta(minutes=15)).isoformat(),
"ip": "",
},
)
# 6th request trips the DB-side limiter.
resp = client.post(
"/admin/login", data={"email": "headhen@example.com"}
)
assert resp.status_code == 429
assert "Too many attempts" in resp.text
# rate_limited audit row with scope=email.
with client.app.state.engine.connect() as conn:
rows = conn.execute(
text(
"SELECT detail FROM auth_events"
" WHERE event_type = 'rate_limited'"
)
).mappings().all()
assert any("\"scope\": \"email\"" in str(r["detail"]) for r in rows)
finally:
limiter.enabled = True
limiter.reset()

View File

@@ -0,0 +1,152 @@
"""Tests for :class:`app.services.sessions.SessionService`.
Coverage:
- Signed-cookie round-trip (create → lookup succeeds).
- Revoke makes subsequent lookup return ``None``.
- Bad-signature cookie returns ``None`` without raising.
- Expired session row returns ``None``.
- Cookie flags reflect ``app_env`` (``Secure`` off in dev / on in prod).
"""
from __future__ import annotations
import hashlib
from datetime import datetime, timedelta, timezone
from itsdangerous import URLSafeTimedSerializer
from sqlalchemy import Engine, text
from app.config import Settings
from app.services.sessions import COOKIE_NAME, SessionService
def _seed_user(engine: Engine) -> int:
"""Insert a throwaway admin user; return its id."""
now_iso = datetime.now(timezone.utc).isoformat()
with engine.begin() as conn:
result = conn.execute(
text(
"INSERT INTO users"
" (email, display_name, created_at, last_login_at, active)"
" VALUES (:e, :d, :c, :c, 1)"
),
{"e": "session-test@example.com", "d": "Session Tester", "c": now_iso},
)
return int(result.lastrowid) # type: ignore[arg-type]
def _build(
engine: Engine,
*,
app_env: str = "development",
secret: str = "a-very-long-test-secret-key-1234567890",
) -> SessionService:
"""Return a SessionService wired for a given app_env."""
# In production mode the Settings validator requires these fields
# to be non-empty; populate them with test values so we can still
# exercise the prod-only ``Secure`` flag path.
if app_env == "production":
settings = Settings( # type: ignore[call-arg]
app_env=app_env,
secret_key=secret,
resend_api_key="test-key",
resend_from="no-reply@example.com",
admin_emails="admin@example.com",
)
else:
settings = Settings(app_env=app_env, secret_key=secret) # type: ignore[call-arg]
signer = URLSafeTimedSerializer(secret, salt="session")
return SessionService(engine, signer, settings)
def test_create_then_lookup_roundtrip(clean_db_engine: Engine) -> None:
"""Creating a session and then looking up the signed cookie returns it."""
user_id = _seed_user(clean_db_engine)
svc = _build(clean_db_engine)
session, cookie = svc.create(user_id=user_id, ip="10.0.0.1", user_agent="ua")
# Cookie is non-empty and not the raw session ID.
assert cookie
# DB row stores a sha256 hex hash, never the raw.
assert len(session.token_hash) == 64
found = svc.lookup(cookie)
assert found is not None
assert found.id == session.id
assert found.user_id == user_id
def test_lookup_empty_cookie_returns_none(clean_db_engine: Engine) -> None:
"""No cookie value resolves to None without raising."""
svc = _build(clean_db_engine)
assert svc.lookup(None) is None
assert svc.lookup("") is None
def test_lookup_bad_signature_returns_none(clean_db_engine: Engine) -> None:
"""A cookie signed by a different key resolves to None."""
user_id = _seed_user(clean_db_engine)
svc = _build(clean_db_engine, secret="key-one-abcdefghijklmnopqrstuvwxyz")
_, cookie = svc.create(user_id=user_id, ip="10.0.0.1", user_agent="ua")
# Different signer — same salt, different key — should fail verify.
other = _build(clean_db_engine, secret="key-two-abcdefghijklmnopqrstuvwxyz")
assert other.lookup(cookie) is None
def test_revoke_marks_session_inactive(clean_db_engine: Engine) -> None:
"""Revoking a session prevents further lookups."""
user_id = _seed_user(clean_db_engine)
svc = _build(clean_db_engine)
session, cookie = svc.create(user_id=user_id, ip="", user_agent="")
assert svc.lookup(cookie) is not None
svc.revoke(session)
assert svc.lookup(cookie) is None
def test_expired_session_returns_none(clean_db_engine: Engine) -> None:
"""A session whose DB expires_at is in the past resolves to None."""
user_id = _seed_user(clean_db_engine)
svc = _build(clean_db_engine)
_, cookie = svc.create(user_id=user_id, ip="", user_agent="")
# Force expires_at into the past without touching the cookie.
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
with clean_db_engine.begin() as conn:
conn.execute(text("UPDATE sessions SET expires_at = :p"), {"p": past})
assert svc.lookup(cookie) is None
def test_cookie_params_secure_flag_gated_by_env(clean_db_engine: Engine) -> None:
"""``Secure`` is False in development and True in production."""
dev = _build(clean_db_engine, app_env="development").cookie_params()
prod = _build(clean_db_engine, app_env="production").cookie_params()
assert dev["secure"] is False
assert prod["secure"] is True
for params in (dev, prod):
assert params["key"] == COOKIE_NAME
assert params["httponly"] is True
assert params["samesite"] == "lax"
assert params["path"] == "/"
def test_raw_token_not_in_cookie_or_row(clean_db_engine: Engine) -> None:
"""The raw session token must never appear in the DB row."""
user_id = _seed_user(clean_db_engine)
svc = _build(clean_db_engine)
session, cookie = svc.create(user_id=user_id, ip="", user_agent="")
# Cookie is itsdangerous-signed, not the raw. Decoding confirms it.
signer = URLSafeTimedSerializer(
"a-very-long-test-secret-key-1234567890", salt="session"
)
raw = signer.loads(cookie)
# DB row stores only the sha256(raw) hex digest.
expected_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert session.token_hash == expected_hash
assert raw not in session.token_hash

68
tests/test_slugs.py Normal file
View File

@@ -0,0 +1,68 @@
"""Tests for :mod:`app.services.slugs`."""
from __future__ import annotations
import pytest
from app.services.slugs import ensure_unique, slugify
# ---------------------------------------------------------------------------
# slugify
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"title, expected",
[
("Hello World", "hello-world"),
("Hello, World!", "hello-world"),
(" leading and trailing ", "leading-and-trailing"),
("CamelCaseTitle", "camelcasetitle"),
("Multiple spaces", "multiple-spaces"),
("dashes---everywhere", "dashes-everywhere"),
("mix-of 99 items & stuff", "mix-of-99-items-stuff"),
("Café au lait", "caf-au-lait"), # non-ASCII dropped
("", "post"), # fallback
("...", "post"), # all-punctuation → fallback
("123", "123"), # digits allowed
("snake_case", "snake-case"),
],
)
def test_slugify_expected(title: str, expected: str) -> None:
""":func:`slugify` produces the documented output per input."""
assert slugify(title) == expected
def test_slugify_no_leading_or_trailing_hyphens() -> None:
"""Slugs never have dangling hyphens regardless of input shape."""
assert slugify("!!hello!!") == "hello"
assert slugify("--leading and trailing--") == "leading-and-trailing"
# ---------------------------------------------------------------------------
# ensure_unique
# ---------------------------------------------------------------------------
def test_ensure_unique_returns_base_when_free() -> None:
"""If nothing collides, the base slug is returned unchanged."""
taken: set[str] = set()
result = ensure_unique("hello", lambda s: s in taken)
assert result == "hello"
def test_ensure_unique_suffixes_on_collision() -> None:
"""Collisions produce -2, -3, etc. in order."""
taken = {"hello", "hello-2", "hello-3"}
result = ensure_unique("hello", lambda s: s in taken)
assert result == "hello-4"
def test_ensure_unique_suffix_sequence_starts_at_two() -> None:
"""First collision uses ``-2`` — never ``-1`` or ``-0``."""
taken = {"hello"}
result = ensure_unique("hello", lambda s: s in taken)
assert result == "hello-2"
def test_ensure_unique_raises_when_exhausted() -> None:
"""A pathological ``exists`` callable hits the guard ceiling."""
with pytest.raises(RuntimeError):
ensure_unique("hello", lambda s: True, max_attempts=3)