Compare commits
12 Commits
22f357f3e8
...
1b4619a01b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b4619a01b | |||
| 9a8506970c | |||
| 76875a455e | |||
| f5098c05f5 | |||
| cd87db8e07 | |||
| 59dea99079 | |||
| 4b088e5045 | |||
| 0306f71763 | |||
| 28168f57b6 | |||
| f77da87eaa | |||
| e830e5da50 | |||
| 78dd1ac243 |
58
.env.example
Normal file
58
.env.example
Normal file
@@ -0,0 +1,58 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — environment contract
|
||||
#
|
||||
# Copy this file to `.env` and fill in the real values locally. The .env file
|
||||
# is gitignored. This file (.env.example) is the public contract and MUST
|
||||
# contain only placeholder / safe-default values — never real secrets.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# --- Runtime mode -----------------------------------------------------------
|
||||
# development | production
|
||||
APP_ENV=development
|
||||
|
||||
# --- Signing / sessions -----------------------------------------------------
|
||||
# itsdangerous signer for cookies / CSRF tokens.
|
||||
# Generate locally with: python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
# The string below is a DEV-ONLY sentinel; the app refuses to start in
|
||||
# production if SECRET_KEY still matches this value.
|
||||
SECRET_KEY=dev-insecure-change-me
|
||||
|
||||
# --- Database ---------------------------------------------------------------
|
||||
DATABASE_URL=sqlite:///data/app.db
|
||||
|
||||
# --- Media storage ----------------------------------------------------------
|
||||
# Filesystem directory holding admin-uploaded images. Mounted publicly at
|
||||
# /media by the app. Relative paths resolve against the process cwd.
|
||||
MEDIA_ROOT=data/media
|
||||
|
||||
# --- Email (Resend) ---------------------------------------------------------
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM=no-reply@chickenbabies.example
|
||||
|
||||
# --- Admin allowlist / contact routing --------------------------------------
|
||||
# Comma-separated list of emails allowed to request admin magic links.
|
||||
ADMIN_EMAILS=
|
||||
# Inbox that the public contact form messages get routed to.
|
||||
ADMIN_CONTACT_EMAIL=
|
||||
|
||||
# --- hCaptcha ---------------------------------------------------------------
|
||||
HCAPTCHA_SITE_KEY=
|
||||
HCAPTCHA_SECRET=
|
||||
|
||||
# --- Reverse proxy / Uvicorn ------------------------------------------------
|
||||
# Caddy's LAN IP (comma-separated allowed). Only headers from these IPs are
|
||||
# trusted for X-Forwarded-For / X-Forwarded-Proto.
|
||||
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
|
||||
# --- Session / auth tuning --------------------------------------------------
|
||||
SESSION_MAX_DAYS=30
|
||||
MAGIC_LINK_TTL_MIN=15
|
||||
|
||||
# --- Public URL for link construction --------------------------------------
|
||||
# Absolute base URL (scheme+host+port) used to build outbound links such as
|
||||
# the magic-link auth email. Override for production.
|
||||
PUBLIC_BASE_URL=http://127.0.0.1:8000
|
||||
|
||||
# --- Build metadata ---------------------------------------------------------
|
||||
# Injected at Docker build time. Surfaced by /healthz. Optional in dev.
|
||||
GIT_COMMIT_SHA=unknown
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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
90
Dockerfile
Normal 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
14
app/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Chicken Babies R Us FastAPI application package.
|
||||
|
||||
This module intentionally keeps only the package version constant. All
|
||||
runtime wiring lives in :mod:`app.main`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Semantic version of the application. Bump per release. Surfaced via
|
||||
# FastAPI's OpenAPI metadata and the /healthz endpoint so ops can verify
|
||||
# which build is live in a given environment.
|
||||
__version__: str = "0.1.0"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
204
app/config.py
Normal file
204
app/config.py
Normal 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
200
app/db.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""SQLAlchemy engine factory, SQLite PRAGMA hookup, and migration runner.
|
||||
|
||||
Responsibilities in this module:
|
||||
|
||||
1. **Engine construction** — :func:`build_engine` produces a
|
||||
``sqlalchemy.Engine`` from the application's ``DATABASE_URL``,
|
||||
threaded-safe for uvicorn's worker pool.
|
||||
2. **Per-connection PRAGMAs** — a single ``@event.listens_for(Engine,
|
||||
"connect")`` hook sets ``journal_mode = WAL`` and ``foreign_keys =
|
||||
ON`` on *every* new SQLite connection, not just the first. SQLite
|
||||
applies both pragmas per-connection, so doing this once at startup
|
||||
would silently leave FKs disabled for every worker.
|
||||
3. **Migration runner** — :func:`run_migrations` applies every
|
||||
``.sql`` file under :mod:`app.models.migrations` in lexicographic
|
||||
order, tracking applied files in a ``schema_migrations`` table.
|
||||
Migrations are trusted developer-authored SQL loaded via
|
||||
:meth:`sqlite3.Connection.executescript`; they never touch user
|
||||
input.
|
||||
|
||||
No Python code in this module builds a SQL statement by string
|
||||
interpolation. Queries go through ``sqlalchemy.text(":bind")``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, create_engine, event, text
|
||||
|
||||
# Directory containing the ``NNN_description.sql`` migration files. Kept
|
||||
# as a module-level constant so tests can reason about it without
|
||||
# importing the runner internals.
|
||||
_MIGRATIONS_DIR: Final[Path] = Path(__file__).resolve().parent / "models" / "migrations"
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def build_engine(database_url: str) -> Engine:
|
||||
"""Build a SQLAlchemy :class:`Engine` for the app's SQLite database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
database_url:
|
||||
A SQLAlchemy URL. In production this is
|
||||
``sqlite:///data/app.db``; tests pass a tmp-path file URL.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- ``check_same_thread=False`` is required because uvicorn services
|
||||
requests from a worker-thread pool; SQLAlchemy's connection pool
|
||||
plus our explicit transactions keep this safe.
|
||||
- For file-backed SQLite URLs we eagerly create the parent
|
||||
directory (SQLite refuses to create missing directories).
|
||||
- ``future=True`` opts into SQLAlchemy 2.x semantics; redundant on
|
||||
2.0+ but explicit is better than implicit.
|
||||
"""
|
||||
# Ensure the on-disk directory exists for file-backed SQLite URLs.
|
||||
# In-memory databases and ``:memory:`` URLs are left alone.
|
||||
if database_url.startswith("sqlite:///"):
|
||||
db_path_str = database_url[len("sqlite:///"):]
|
||||
if db_path_str and db_path_str != ":memory:":
|
||||
db_path = Path(db_path_str)
|
||||
# Relative paths resolve against the current working
|
||||
# directory. This matches uvicorn's default cwd (the repo
|
||||
# root) and Docker's WORKDIR.
|
||||
parent = db_path.parent
|
||||
if str(parent) and parent != Path("."):
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
engine = create_engine(
|
||||
database_url,
|
||||
future=True,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
_install_sqlite_pragmas(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def _install_sqlite_pragmas(engine: Engine) -> None:
|
||||
"""Attach a connect-event listener that enforces our SQLite PRAGMAs.
|
||||
|
||||
``journal_mode = WAL`` and ``foreign_keys = ON`` are both
|
||||
per-connection settings in SQLite. Applying them on every new
|
||||
connection — rather than once at startup — is the only way to
|
||||
guarantee foreign-key enforcement across all pool workers.
|
||||
"""
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _on_connect(dbapi_connection, connection_record) -> None: # type: ignore[no-untyped-def]
|
||||
"""Run per-connection SQLite initialization.
|
||||
|
||||
Uses the raw DB-API cursor (not SQLAlchemy ``text`` wrappers)
|
||||
because PRAGMA calls are not valid parameterized SQL — they
|
||||
are trusted, developer-authored literals with no external
|
||||
input.
|
||||
"""
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
# WAL improves concurrency (readers don't block the
|
||||
# single writer) and is well-suited to our read-heavy
|
||||
# workload. It persists on the database file, so
|
||||
# re-setting is a cheap no-op after the first call.
|
||||
cursor.execute("PRAGMA journal_mode = WAL")
|
||||
# foreign_keys is per-connection; SQLite defaults to OFF,
|
||||
# so we MUST set it here to have referential integrity.
|
||||
cursor.execute("PRAGMA foreign_keys = ON")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def run_migrations(engine: Engine) -> list[str]:
|
||||
"""Apply any un-applied SQL files from :mod:`app.models.migrations`.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Creates a ``schema_migrations`` tracker table if missing.
|
||||
- Lists ``.sql`` files in :data:`_MIGRATIONS_DIR` in sorted order.
|
||||
- For each file not yet in ``schema_migrations``, runs its content
|
||||
via :meth:`sqlite3.Connection.executescript` (necessary because
|
||||
a migration file may contain multiple statements) inside a
|
||||
single ``BEGIN IMMEDIATE`` transaction, then records the
|
||||
version. Already-applied files are skipped.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
The ordered list of versions applied on *this* call. Empty
|
||||
when the DB is already up to date, useful for logs and tests.
|
||||
|
||||
Security note
|
||||
-------------
|
||||
Migration SQL is trusted input from the repository; it does not
|
||||
mix with user-origin data and therefore does not need bind
|
||||
parameters. User data still flows exclusively through
|
||||
parameterized queries elsewhere (see ``docs/security.md`` CWE-89).
|
||||
"""
|
||||
files = sorted(p for p in _MIGRATIONS_DIR.glob("*.sql"))
|
||||
applied_now: list[str] = []
|
||||
|
||||
# A single "raw connection" over the life of the migration run
|
||||
# lets us mix executescript (DDL) with ordinary parameterized
|
||||
# bookkeeping cleanly. We commit per file so a failure partway
|
||||
# through leaves earlier files recorded.
|
||||
with engine.connect() as conn:
|
||||
# Ensure the tracker table exists. Can't use schema_migrations
|
||||
# itself to gate this since it may not exist yet.
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
||||
" version TEXT PRIMARY KEY,"
|
||||
" applied_at TEXT NOT NULL"
|
||||
")"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Pull the set of already-applied versions once.
|
||||
already_applied = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
text("SELECT version FROM schema_migrations")
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
for path in files:
|
||||
version = path.stem
|
||||
if version in already_applied:
|
||||
continue
|
||||
|
||||
sql_text = path.read_text(encoding="utf-8")
|
||||
|
||||
# executescript is only exposed on the DB-API connection,
|
||||
# so we reach through the SQLAlchemy connection's raw
|
||||
# cursor. Trust boundary: the file is checked into git,
|
||||
# never user-supplied, so there is no injection vector.
|
||||
raw = conn.connection
|
||||
raw.executescript(sql_text)
|
||||
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO schema_migrations (version, applied_at) "
|
||||
"VALUES (:v, :t)"
|
||||
),
|
||||
{
|
||||
"v": version,
|
||||
"t": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
applied_now.append(version)
|
||||
_log.info("migration_applied", version=version)
|
||||
|
||||
if not applied_now:
|
||||
_log.info("migrations_up_to_date")
|
||||
return applied_now
|
||||
8
app/dependencies/__init__.py
Normal file
8
app/dependencies/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""FastAPI dependency helpers.
|
||||
|
||||
Route-level ``Depends(...)`` functions that don't belong to a single
|
||||
service live here. Phase 3 introduces ``app.dependencies.auth`` for
|
||||
``get_current_user`` / ``require_admin``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
89
app/dependencies/auth.py
Normal file
89
app/dependencies/auth.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Auth dependencies for admin routes.
|
||||
|
||||
Two ``Depends(...)`` helpers:
|
||||
|
||||
- :func:`get_current_user` — returns a :class:`User` or ``None`` based
|
||||
on the signed ``cb_session`` cookie. Never raises.
|
||||
- :func:`require_admin` — same lookup but raises an HTTP 303 redirect
|
||||
to ``/admin/login`` if no user is authenticated. Used by every route
|
||||
that must be logged-in.
|
||||
|
||||
Cookie handling
|
||||
---------------
|
||||
The cookie is read via ``request.cookies`` (Starlette strips secure /
|
||||
httponly flags off by the time the app sees it; they only affect how
|
||||
the browser stores and presents the cookie). Unsigning, hashing, and
|
||||
DB lookup are delegated to :class:`app.services.sessions.SessionService`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.models.entities import User
|
||||
from app.models.mappers import row_to_user
|
||||
from app.services.sessions import COOKIE_NAME, SessionService
|
||||
|
||||
|
||||
def _get_session_service(request: Request) -> SessionService:
|
||||
"""Return the app-scoped :class:`SessionService` for DI.
|
||||
|
||||
Private to this module — routes and other dependencies resolve the
|
||||
service via :func:`get_current_user` / :func:`require_admin` rather
|
||||
than reaching across the dependency graph.
|
||||
"""
|
||||
return request.app.state.session_service
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
sessions: SessionService = Depends(_get_session_service),
|
||||
) -> Optional[User]:
|
||||
"""Return the authenticated :class:`User` or ``None``.
|
||||
|
||||
Never raises. A malformed / expired / revoked cookie simply
|
||||
resolves to ``None`` so that un-authed viewers can hit admin
|
||||
login pages without tripping an exception handler.
|
||||
"""
|
||||
cookie_value = request.cookies.get(COOKIE_NAME)
|
||||
session = sessions.lookup(cookie_value)
|
||||
if session is None:
|
||||
return None
|
||||
|
||||
# We need the user row to render "Welcome, <display_name>" on the
|
||||
# admin index. Query directly here instead of adding a full
|
||||
# UserService for one call site.
|
||||
engine = request.app.state.engine
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, email, display_name, created_at,"
|
||||
" last_login_at, active"
|
||||
" FROM users WHERE id = :id AND active = 1 LIMIT 1"
|
||||
),
|
||||
{"id": session.user_id},
|
||||
).mappings().first()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
return row_to_user(row)
|
||||
|
||||
|
||||
def require_admin(
|
||||
user: Optional[User] = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""Return the authenticated user or redirect to login.
|
||||
|
||||
Uses a 303 "See Other" so the browser switches to GET on the
|
||||
followup request — correct behavior for both initial page loads
|
||||
and the post-consume redirect chain.
|
||||
"""
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=303,
|
||||
headers={"Location": "/admin/login"},
|
||||
)
|
||||
return user
|
||||
65
app/dependencies/csrf.py
Normal file
65
app/dependencies/csrf.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""FastAPI dependency for CSRF verification on admin mutating endpoints.
|
||||
|
||||
Mount on any route that performs a state change (POST / PUT / DELETE)
|
||||
and sits inside the admin router. GET admin routes do NOT need this —
|
||||
they only need ``require_admin`` to gate access.
|
||||
|
||||
Two flavors, same underlying check:
|
||||
|
||||
- :func:`require_csrf_form` — reads ``csrf_token`` from the form body.
|
||||
Preferred for classic HTML forms.
|
||||
- :func:`require_csrf_header` — reads ``X-CSRF-Token`` from the request
|
||||
headers. Used by the live-preview fetch and the drag-drop upload
|
||||
endpoint where sending an extra form field is awkward.
|
||||
|
||||
Both raise HTTP 403 on mismatch, which surfaces as the generic FastAPI
|
||||
error page. No information leaks about which side (cookie or submitted
|
||||
token) failed — fail-closed is uniform.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Form, HTTPException, Request
|
||||
|
||||
from app.services.csrf import CSRF_COOKIE_NAME, CSRFService
|
||||
|
||||
|
||||
def _get_csrf_service(request: Request) -> CSRFService:
|
||||
"""Pull the app-scoped :class:`CSRFService` off ``request.app.state``.
|
||||
|
||||
Private helper: route handlers depend on :func:`require_csrf_form`
|
||||
or :func:`require_csrf_header` directly, not on this lookup.
|
||||
"""
|
||||
return request.app.state.csrf_service
|
||||
|
||||
|
||||
def require_csrf_form(
|
||||
request: Request,
|
||||
csrf_token: str = Form(default=""),
|
||||
) -> None:
|
||||
"""Verify a form-submitted CSRF token matches the signed cookie.
|
||||
|
||||
Raises :class:`fastapi.HTTPException` 403 on any mismatch. On
|
||||
success returns ``None`` — the dependency has no payload.
|
||||
"""
|
||||
service: CSRFService = _get_csrf_service(request)
|
||||
cookie_value: Optional[str] = request.cookies.get(CSRF_COOKIE_NAME)
|
||||
if not service.verify(cookie_value=cookie_value, submitted=csrf_token):
|
||||
raise HTTPException(status_code=403, detail="CSRF verification failed")
|
||||
|
||||
|
||||
def require_csrf_header(
|
||||
request: Request,
|
||||
) -> None:
|
||||
"""Verify an ``X-CSRF-Token`` header matches the signed cookie.
|
||||
|
||||
Used by the JS-driven preview + media upload endpoints. The header
|
||||
name is case-insensitive — Starlette canonicalizes on read.
|
||||
"""
|
||||
service: CSRFService = _get_csrf_service(request)
|
||||
cookie_value: Optional[str] = request.cookies.get(CSRF_COOKIE_NAME)
|
||||
submitted: Optional[str] = request.headers.get("x-csrf-token")
|
||||
if not service.verify(cookie_value=cookie_value, submitted=submitted):
|
||||
raise HTTPException(status_code=403, detail="CSRF verification failed")
|
||||
72
app/logging_config.py
Normal file
72
app/logging_config.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Structlog initialization.
|
||||
|
||||
Single entry point :func:`configure_logging` sets up structlog with an
|
||||
``APP_ENV``-driven renderer: a pretty console renderer during development
|
||||
and JSON-lines output in production so logs plug straight into any log
|
||||
aggregator. Must be called exactly once at app startup, before any
|
||||
module obtains a logger via ``structlog.get_logger()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
def configure_logging(app_env: str) -> None:
|
||||
"""Configure structlog + stdlib logging for the application.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
app_env:
|
||||
The resolved ``APP_ENV`` value. ``"development"`` selects a
|
||||
colorized console renderer; anything else (including
|
||||
``"production"``) selects the JSON renderer so logs are
|
||||
machine-parseable.
|
||||
|
||||
Notes
|
||||
-----
|
||||
This function is idempotent within a process: structlog accepts
|
||||
repeated calls to :func:`structlog.configure`. It deliberately keeps
|
||||
the setup small for Phase 0 — a proper ``ProcessorFormatter`` bridge
|
||||
between stdlib and structlog can be layered in later without touching
|
||||
call sites.
|
||||
"""
|
||||
# Route stdlib logging through a root handler writing to stdout. Our
|
||||
# own loggers go through structlog's pipeline; third-party libraries
|
||||
# that use the stdlib logger will at least surface at INFO.
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
stream=sys.stdout,
|
||||
format="%(message)s",
|
||||
)
|
||||
|
||||
# Processors shared across environments. Order matters: contextvars
|
||||
# first so bound context is present for every later step; exception
|
||||
# info extraction before rendering so tracebacks serialize cleanly.
|
||||
shared_processors: list[Any] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
]
|
||||
|
||||
# Environment-specific final renderer. Dev gets human-friendly
|
||||
# colorized output; everywhere else emits JSON lines.
|
||||
if app_env == "development":
|
||||
final_renderer: Any = structlog.dev.ConsoleRenderer(colors=True)
|
||||
else:
|
||||
final_renderer = structlog.processors.JSONRenderer()
|
||||
|
||||
structlog.configure(
|
||||
processors=[*shared_processors, final_renderer],
|
||||
# PrintLoggerFactory writes to stdout without requiring a stdlib
|
||||
# logger bridge; sufficient for Phase 0.
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
349
app/main.py
Normal file
349
app/main.py
Normal 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
7
app/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Domain models and persistence mappers.
|
||||
|
||||
Populated in Phase 2 with dataclasses + SQL schema per
|
||||
``docs/ROADMAP.md``. Intentionally empty in Phase 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
195
app/models/entities.py
Normal file
195
app/models/entities.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Canonical persistence-layer dataclasses.
|
||||
|
||||
One dataclass per table in the authoritative SQLite schema documented in
|
||||
``docs/ROADMAP.md`` ("SQLite Schema (authoritative)"). These map 1:1 to
|
||||
the columns of each table — field names, types, and nullability all
|
||||
match — so the mapper layer (:mod:`app.models.mappers`) can convert
|
||||
``sqlalchemy.Row`` objects to dataclass instances with no guesswork.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- Dataclasses are *not* frozen. Later phases mutate fields such as
|
||||
``User.last_login_at`` or ``MagicLinkToken.used_at`` on successful
|
||||
auth events; freezing would force service code into hand-rolled
|
||||
copying. Immutability for view-layer projections is still enforced
|
||||
via ``PostSummary`` in :mod:`app.models.posts`.
|
||||
- Datetimes are always timezone-aware UTC at the Python boundary. The
|
||||
SQLite columns are ``TEXT`` holding ISO-8601 strings; conversion
|
||||
happens only in :mod:`app.models.mappers`, so application code never
|
||||
sees a naive datetime.
|
||||
- ``PostStatus`` is a string-valued ``Enum`` to keep JSON/template
|
||||
rendering trivial while still providing type-level safety.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class PostStatus(str, Enum):
|
||||
"""Publication lifecycle for a blog post.
|
||||
|
||||
The string values match the ``CHECK`` constraint on
|
||||
``posts.status`` in the SQLite schema; adding a new value here
|
||||
would require a migration, so this enum is deliberately small.
|
||||
"""
|
||||
|
||||
DRAFT = "draft"
|
||||
PUBLISHED = "published"
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Admin user row.
|
||||
|
||||
Phase 2 seeds a single inactive system user (``id=1``) so the
|
||||
``posts.author_user_id`` foreign key has something to reference;
|
||||
real admin users are provisioned in Phase 3's magic-link flow.
|
||||
"""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
display_name: str
|
||||
created_at: datetime
|
||||
last_login_at: Optional[datetime]
|
||||
active: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class MagicLinkToken:
|
||||
"""Single-use email-login token.
|
||||
|
||||
``token_hash`` stores the SHA-256 of the raw token; the raw token
|
||||
is emailed to the user and never persisted. ``used_at`` is set
|
||||
when the token is consumed so we can refuse replay attempts
|
||||
without deleting the audit row.
|
||||
"""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
token_hash: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
used_at: Optional[datetime]
|
||||
request_ip: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""Authenticated admin session.
|
||||
|
||||
``revoked_at`` records logouts without deleting the row, so the
|
||||
audit log remains complete. ``ip`` / ``user_agent`` are snapshots
|
||||
from session creation, not live values.
|
||||
"""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
token_hash: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
ip: str
|
||||
user_agent: str
|
||||
revoked_at: Optional[datetime]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Page:
|
||||
"""Static-ish content page (e.g. About).
|
||||
|
||||
``body_html_cached`` is regenerated on write by the Phase 4 admin
|
||||
flow via the Markdown pipeline and stored here so render time
|
||||
costs only a SELECT, not a sanitize. See "Caching Strategy" in
|
||||
``docs/ROADMAP.md``.
|
||||
"""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
body_md: str
|
||||
body_html_cached: str
|
||||
updated_at: datetime
|
||||
published: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class Post:
|
||||
"""Blog post row.
|
||||
|
||||
Mirrors the ``posts`` table exactly. ``body_html_cached`` follows
|
||||
the same regenerate-on-write convention as :class:`Page`.
|
||||
"""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
body_md: str
|
||||
body_html_cached: str
|
||||
status: PostStatus
|
||||
published_at: Optional[datetime]
|
||||
updated_at: datetime
|
||||
author_user_id: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Media:
|
||||
"""Uploaded image metadata.
|
||||
|
||||
``filename`` is the random storage name assigned on upload; the
|
||||
original client-supplied filename is preserved for display only
|
||||
and NEVER used to build a filesystem path. ``stored_path`` is
|
||||
relative to the project root.
|
||||
"""
|
||||
|
||||
id: int
|
||||
filename: str
|
||||
original_filename: str
|
||||
content_type: str
|
||||
size_bytes: int
|
||||
stored_path: str
|
||||
alt_text: str
|
||||
uploaded_by: int
|
||||
uploaded_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContactSubmission:
|
||||
"""Submission from the public ``/contact`` form.
|
||||
|
||||
``handled`` flips true once Head Hen has actioned the submission;
|
||||
retained indefinitely as part of the contact audit log. No
|
||||
sensitive fields — by design we only capture what the form asks
|
||||
for.
|
||||
"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
message: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
submitted_at: datetime
|
||||
handled: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthEvent:
|
||||
"""Append-only audit record for auth-related events.
|
||||
|
||||
``event_type`` values are one of ``link_requested``,
|
||||
``link_consumed``, ``session_revoked``, ``rate_limited`` (see
|
||||
Phase 3). ``detail`` is a JSON string so we can attach
|
||||
event-specific context without schema churn.
|
||||
"""
|
||||
|
||||
id: int
|
||||
event_type: str
|
||||
email: Optional[str]
|
||||
user_id: Optional[int]
|
||||
ip: str
|
||||
user_agent: str
|
||||
created_at: datetime
|
||||
detail: str
|
||||
190
app/models/mappers.py
Normal file
190
app/models/mappers.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""SQL row to dataclass converters.
|
||||
|
||||
One ``row_to_<entity>`` function per table. All functions accept a
|
||||
mapping-like object (``sqlalchemy.Row``, :class:`sqlite3.Row`, or plain
|
||||
``dict``) and return the corresponding dataclass from
|
||||
:mod:`app.models.entities`.
|
||||
|
||||
Boundary responsibilities handled here (so service code never has to):
|
||||
|
||||
- Parse ISO-8601 ``TEXT`` columns into timezone-aware :class:`datetime`
|
||||
instances (always UTC).
|
||||
- Coerce SQLite ``INTEGER`` booleans (``0`` / ``1``) into real ``bool``.
|
||||
- Translate ``posts.status`` strings into :class:`PostStatus` members.
|
||||
|
||||
Anything that isn't safe to assume (e.g. that ``published_at`` might be
|
||||
NULL) is handled explicitly via :func:`_parse_optional_datetime`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from app.models.entities import (
|
||||
AuthEvent,
|
||||
ContactSubmission,
|
||||
MagicLinkToken,
|
||||
Media,
|
||||
Page,
|
||||
Post,
|
||||
PostStatus,
|
||||
Session,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
def _parse_datetime(value: str) -> datetime:
|
||||
"""Parse a stored ISO-8601 string into a timezone-aware UTC datetime.
|
||||
|
||||
All write paths use :func:`datetime.now` with ``tz=timezone.utc``
|
||||
and serialize via ``.isoformat()``, so the stored strings always
|
||||
include an offset. We still call ``astimezone(timezone.utc)`` to
|
||||
normalize anything that sneaks through with a different offset —
|
||||
an inexpensive belt-and-braces guard.
|
||||
"""
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
# Defensive: legacy rows (none exist yet) or a bad write path.
|
||||
# Treat as UTC rather than raising; we never intentionally
|
||||
# persist naive datetimes.
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _parse_optional_datetime(value: Optional[str]) -> Optional[datetime]:
|
||||
"""Return ``None`` for NULL rows; otherwise parse as UTC.
|
||||
|
||||
Thin wrapper around :func:`_parse_datetime` kept for readability at
|
||||
call sites that deal with nullable columns.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
return _parse_datetime(value)
|
||||
|
||||
|
||||
def _as_bool(value: Any) -> bool:
|
||||
"""Coerce a SQLite INTEGER column into a Python ``bool``.
|
||||
|
||||
SQLite stores booleans as ``0`` / ``1`` integers. ``bool(0) is
|
||||
False`` and ``bool(1) is True`` both behave correctly; this
|
||||
wrapper exists so the intent is explicit at the mapper boundary
|
||||
rather than relying on implicit truthiness.
|
||||
"""
|
||||
return bool(value)
|
||||
|
||||
|
||||
def row_to_user(row: Mapping[str, Any]) -> User:
|
||||
"""Map a ``users`` row to :class:`User`."""
|
||||
return User(
|
||||
id=int(row["id"]),
|
||||
email=row["email"],
|
||||
display_name=row["display_name"],
|
||||
created_at=_parse_datetime(row["created_at"]),
|
||||
last_login_at=_parse_optional_datetime(row["last_login_at"]),
|
||||
active=_as_bool(row["active"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_magic_link_token(row: Mapping[str, Any]) -> MagicLinkToken:
|
||||
"""Map a ``magic_link_tokens`` row to :class:`MagicLinkToken`."""
|
||||
return MagicLinkToken(
|
||||
id=int(row["id"]),
|
||||
email=row["email"],
|
||||
token_hash=row["token_hash"],
|
||||
created_at=_parse_datetime(row["created_at"]),
|
||||
expires_at=_parse_datetime(row["expires_at"]),
|
||||
used_at=_parse_optional_datetime(row["used_at"]),
|
||||
request_ip=row["request_ip"],
|
||||
)
|
||||
|
||||
|
||||
def row_to_session(row: Mapping[str, Any]) -> Session:
|
||||
"""Map a ``sessions`` row to :class:`Session`."""
|
||||
return Session(
|
||||
id=int(row["id"]),
|
||||
user_id=int(row["user_id"]),
|
||||
token_hash=row["token_hash"],
|
||||
created_at=_parse_datetime(row["created_at"]),
|
||||
expires_at=_parse_datetime(row["expires_at"]),
|
||||
ip=row["ip"],
|
||||
user_agent=row["user_agent"],
|
||||
revoked_at=_parse_optional_datetime(row["revoked_at"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_page(row: Mapping[str, Any]) -> Page:
|
||||
"""Map a ``pages`` row to :class:`Page`."""
|
||||
return Page(
|
||||
id=int(row["id"]),
|
||||
slug=row["slug"],
|
||||
title=row["title"],
|
||||
body_md=row["body_md"],
|
||||
body_html_cached=row["body_html_cached"],
|
||||
updated_at=_parse_datetime(row["updated_at"]),
|
||||
published=_as_bool(row["published"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_post(row: Mapping[str, Any]) -> Post:
|
||||
"""Map a ``posts`` row to :class:`Post`.
|
||||
|
||||
``status`` goes through the :class:`PostStatus` constructor which
|
||||
enforces the same set the ``CHECK`` constraint does; a value that
|
||||
somehow bypassed the constraint would raise ``ValueError`` here
|
||||
rather than silently flowing into business logic.
|
||||
"""
|
||||
return Post(
|
||||
id=int(row["id"]),
|
||||
slug=row["slug"],
|
||||
title=row["title"],
|
||||
body_md=row["body_md"],
|
||||
body_html_cached=row["body_html_cached"],
|
||||
status=PostStatus(row["status"]),
|
||||
published_at=_parse_optional_datetime(row["published_at"]),
|
||||
updated_at=_parse_datetime(row["updated_at"]),
|
||||
author_user_id=int(row["author_user_id"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_media(row: Mapping[str, Any]) -> Media:
|
||||
"""Map a ``media`` row to :class:`Media`."""
|
||||
return Media(
|
||||
id=int(row["id"]),
|
||||
filename=row["filename"],
|
||||
original_filename=row["original_filename"],
|
||||
content_type=row["content_type"],
|
||||
size_bytes=int(row["size_bytes"]),
|
||||
stored_path=row["stored_path"],
|
||||
alt_text=row["alt_text"],
|
||||
uploaded_by=int(row["uploaded_by"]),
|
||||
uploaded_at=_parse_datetime(row["uploaded_at"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_contact_submission(row: Mapping[str, Any]) -> ContactSubmission:
|
||||
"""Map a ``contact_submissions`` row to :class:`ContactSubmission`."""
|
||||
return ContactSubmission(
|
||||
id=int(row["id"]),
|
||||
name=row["name"],
|
||||
email=row["email"],
|
||||
message=row["message"],
|
||||
ip=row["ip"],
|
||||
user_agent=row["user_agent"],
|
||||
submitted_at=_parse_datetime(row["submitted_at"]),
|
||||
handled=_as_bool(row["handled"]),
|
||||
)
|
||||
|
||||
|
||||
def row_to_auth_event(row: Mapping[str, Any]) -> AuthEvent:
|
||||
"""Map an ``auth_events`` row to :class:`AuthEvent`."""
|
||||
return AuthEvent(
|
||||
id=int(row["id"]),
|
||||
event_type=row["event_type"],
|
||||
email=row["email"],
|
||||
user_id=int(row["user_id"]) if row["user_id"] is not None else None,
|
||||
ip=row["ip"],
|
||||
user_agent=row["user_agent"],
|
||||
created_at=_parse_datetime(row["created_at"]),
|
||||
detail=row["detail"],
|
||||
)
|
||||
108
app/models/migrations/001_init.sql
Normal file
108
app/models/migrations/001_init.sql
Normal file
@@ -0,0 +1,108 @@
|
||||
-- 001_init.sql
|
||||
--
|
||||
-- Initial schema for Chicken Babies R Us. Authoritative copy of the
|
||||
-- tables + indexes + check constraints documented in
|
||||
-- ``docs/ROADMAP.md`` (see "SQLite Schema (authoritative)").
|
||||
--
|
||||
-- Idempotency: every statement uses IF NOT EXISTS so re-running the
|
||||
-- file on a partially-migrated database is still safe. The migration
|
||||
-- runner also gates execution via the schema_migrations tracker, so
|
||||
-- this belt-and-braces approach is defensive only.
|
||||
--
|
||||
-- No PRAGMA statements here: journal_mode = WAL and foreign_keys = ON
|
||||
-- are applied per-connection via the SQLAlchemy connect-event
|
||||
-- listener in ``app/db.py``. Setting them inside a migration file
|
||||
-- would be a no-op on every connection except the one that ran the
|
||||
-- migration, which is the opposite of what we want.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_login_at TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS magic_link_tokens (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
request_ip TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_email_created
|
||||
ON magic_link_tokens(email, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
body_md TEXT NOT NULL,
|
||||
body_html_cached TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
published INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
body_md TEXT NOT NULL,
|
||||
body_html_cached TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('draft','published')),
|
||||
published_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
author_user_id INTEGER NOT NULL REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_status_pub
|
||||
ON posts(status, published_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filename TEXT NOT NULL UNIQUE,
|
||||
original_filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
alt_text TEXT NOT NULL DEFAULT '',
|
||||
uploaded_by INTEGER NOT NULL REFERENCES users(id),
|
||||
uploaded_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_submissions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
submitted_at TEXT NOT NULL,
|
||||
handled INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
email TEXT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
ip TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_events_created
|
||||
ON auth_events(created_at DESC);
|
||||
12
app/models/migrations/__init__.py
Normal file
12
app/models/migrations/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""SQL migration files applied by :mod:`app.db` at startup.
|
||||
|
||||
This package holds the authoritative schema history for the
|
||||
``chicken_babies_site`` database. Each ``.sql`` file is applied exactly
|
||||
once in lexicographic order; the runner tracks which files have been
|
||||
applied in a ``schema_migrations`` table.
|
||||
|
||||
No Python code lives here — the files are trusted, developer-authored
|
||||
SQL loaded via ``sqlite3.Connection.executescript`` at boot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
45
app/models/posts.py
Normal file
45
app/models/posts.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Blog post domain models.
|
||||
|
||||
Phase 1 only needs the *list-view* projection of a post — a minimal
|
||||
immutable record sufficient to render a blog card on the home page.
|
||||
Phase 2 will introduce the richer persisted :class:`Post` dataclass that
|
||||
mirrors the SQLite schema; :class:`PostSummary` intentionally stays as a
|
||||
narrower DTO even after the DB arrives because list endpoints shouldn't
|
||||
pay the cost of loading full post bodies.
|
||||
|
||||
The dataclass is frozen: summaries flow one-way from the service layer
|
||||
into templates and must never mutate mid-request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PostSummary:
|
||||
"""Immutable summary row for the blog index.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
slug:
|
||||
URL-safe identifier used to build the post's canonical URL.
|
||||
title:
|
||||
Human-readable headline shown on the card.
|
||||
published_at:
|
||||
Timezone-aware UTC publish timestamp. Templates format this for
|
||||
display; storing a real :class:`datetime` (rather than a
|
||||
pre-formatted string) keeps locale/formatting concerns in the
|
||||
view layer.
|
||||
excerpt:
|
||||
Short plaintext teaser. The service layer is responsible for
|
||||
producing a sanitized, already-truncated excerpt so the template
|
||||
can render it without additional escaping beyond Jinja's default
|
||||
HTML autoescape.
|
||||
"""
|
||||
|
||||
slug: str
|
||||
title: str
|
||||
published_at: datetime
|
||||
excerpt: str
|
||||
204
app/models/seed.py
Normal file
204
app/models/seed.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Idempotent seed data for first-run databases.
|
||||
|
||||
Creates the minimum content needed so the public site is not blank
|
||||
before an admin exists:
|
||||
|
||||
- System seed user (``users.id = 1``). Inactive and not on the
|
||||
``ADMIN_EMAILS`` allowlist — cannot log in. Exists only so
|
||||
``posts.author_user_id`` has a foreign-key target.
|
||||
- Welcome blog post (``slug = 'welcome-to-the-farm'``).
|
||||
- About page (``slug = 'about'``) ported from the Phase 1 static copy.
|
||||
|
||||
Idempotency is enforced two ways:
|
||||
|
||||
1. A marker row in ``schema_migrations`` (``version = 'seed_001'``)
|
||||
— if present, the whole seed is a no-op.
|
||||
2. As a belt-and-braces guard, each INSERT is gated by ``INSERT OR
|
||||
IGNORE`` on a unique key (``users.email``, ``posts.slug``,
|
||||
``pages.slug``) so a partially-applied seed never duplicates.
|
||||
|
||||
Running this twice is safe and logs ``seed_skipped`` on the second
|
||||
boot, which the Phase 2 verification run depends on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.services.markdown import MarkdownService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
# Marker row used to short-circuit the seed on subsequent boots.
|
||||
# Namespaced with the ``seed_`` prefix so it cannot collide with a
|
||||
# real migration file name.
|
||||
_SEED_MARKER: str = "seed_001"
|
||||
|
||||
|
||||
# --- Content --------------------------------------------------------------
|
||||
#
|
||||
# The About body is a Markdown translation of the Phase 1
|
||||
# ``app/templates/public/about.html`` narrative. Kept close to the
|
||||
# original wording so returning visitors see familiar copy; Head Hen
|
||||
# rewrites via the Phase 4 admin.
|
||||
#
|
||||
# The welcome post is three short paragraphs: a greeting, a Morrison,
|
||||
# TN mention (no street address — per CLAUDE.md), and a teaser of what
|
||||
# future updates will cover.
|
||||
_WELCOME_POST_TITLE: str = "Welcome to the Farm"
|
||||
_WELCOME_POST_SLUG: str = "welcome-to-the-farm"
|
||||
_WELCOME_POST_MD: str = (
|
||||
"Hi there, and thanks for stopping by Chicken Babies R Us! "
|
||||
"We're a small family farm and we're glad you found us.\n\n"
|
||||
"We're based in Morrison, Tennessee, tucked into the rolling "
|
||||
"hills of the middle part of the state. Our flock is growing, "
|
||||
"our waterfowl are loud, and our coffee cups are never quite "
|
||||
"empty.\n\n"
|
||||
"Check back soon for updates on hatching plans, new chicks and "
|
||||
"ducklings, fresh-egg availability, and whatever the geese "
|
||||
"decided to get into this week."
|
||||
)
|
||||
|
||||
_ABOUT_PAGE_TITLE: str = "About the Farm"
|
||||
_ABOUT_PAGE_SLUG: str = "about"
|
||||
_ABOUT_PAGE_MD: str = (
|
||||
"Chicken Babies R Us is a small family farm tucked into the "
|
||||
"rolling hills of Morrison, Tennessee. What started as a "
|
||||
"handful of chicks in a backyard brooder has grown into a flock "
|
||||
"of chickens, ducks, and geese that keep us busy (and "
|
||||
"entertained) year round.\n\n"
|
||||
"The operation is run by Head Hen — the chief wrangler, egg "
|
||||
"gatherer, waterfowl-whisperer, and unofficial chicken "
|
||||
"photographer. She handles the day-to-day care of the birds "
|
||||
"and does most of the writing you'll find on this site. Expect "
|
||||
"updates on hatching plans, new arrivals, the occasional coop "
|
||||
"mishap, and whatever the geese decided to get into this "
|
||||
"week.\n\n"
|
||||
"We're a hobby farm at heart, not a commercial one, which "
|
||||
"means we can take the time to know our birds and raise them "
|
||||
"the way we think they ought to be raised. If you're curious "
|
||||
"about what we've got going on — or just want to say hello — "
|
||||
"pop over to the contact page."
|
||||
)
|
||||
|
||||
# Seed user constants. ``active=0`` + the local-only email keep this
|
||||
# user out of any real auth flow. Phase 3's magic-link issuer MUST
|
||||
# refuse to issue links for non-allowlisted or inactive emails;
|
||||
# Phase 3 tests assert that behavior directly.
|
||||
_SEED_USER_ID: int = 1
|
||||
_SEED_USER_EMAIL: str = "seed@chickenbabies.local"
|
||||
_SEED_USER_DISPLAY: str = "Head Hen"
|
||||
|
||||
|
||||
def run_seed(engine: Engine) -> bool:
|
||||
"""Populate the database with first-run content, if not already done.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
SQLAlchemy engine. Must already have had migrations applied
|
||||
(this function does not create tables).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` when seed rows were inserted on this call, ``False``
|
||||
when the marker was already present (no-op). Useful for
|
||||
verification scripts and tests that need to assert
|
||||
``seed_skipped`` on second boot.
|
||||
"""
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
markdown = MarkdownService()
|
||||
|
||||
with engine.connect() as conn:
|
||||
# Short-circuit via the migration-tracker marker. Cheaper than
|
||||
# counting rows and survives the edge case of a manually
|
||||
# wiped posts/pages table that we wouldn't want to reseed
|
||||
# automatically.
|
||||
marker_row = conn.execute(
|
||||
text(
|
||||
"SELECT version FROM schema_migrations WHERE version = :v"
|
||||
),
|
||||
{"v": _SEED_MARKER},
|
||||
).first()
|
||||
if marker_row is not None:
|
||||
_log.info("seed_skipped", marker=_SEED_MARKER)
|
||||
return False
|
||||
|
||||
# --- Seed user ------------------------------------------------
|
||||
# The explicit id=1 pin keeps the ``posts.author_user_id``
|
||||
# foreign key stable even if a future migration renumbers.
|
||||
# Inline comment below repeats the intent for anyone reading
|
||||
# the DB directly.
|
||||
conn.execute(
|
||||
text(
|
||||
# seed artifact; not a real admin — see Phase 3 for real users
|
||||
"INSERT OR IGNORE INTO users"
|
||||
" (id, email, display_name, created_at, last_login_at, active)"
|
||||
" VALUES (:id, :email, :display_name, :created_at, NULL, 0)"
|
||||
),
|
||||
{
|
||||
"id": _SEED_USER_ID,
|
||||
"email": _SEED_USER_EMAIL,
|
||||
"display_name": _SEED_USER_DISPLAY,
|
||||
"created_at": now_iso,
|
||||
},
|
||||
)
|
||||
|
||||
# --- Welcome post --------------------------------------------
|
||||
welcome_html = markdown.render(_WELCOME_POST_MD)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT OR IGNORE INTO posts"
|
||||
" (slug, title, body_md, body_html_cached, status,"
|
||||
" published_at, updated_at, author_user_id)"
|
||||
" VALUES (:slug, :title, :body_md, :body_html,"
|
||||
" 'published', :published_at, :updated_at, :author_id)"
|
||||
),
|
||||
{
|
||||
"slug": _WELCOME_POST_SLUG,
|
||||
"title": _WELCOME_POST_TITLE,
|
||||
"body_md": _WELCOME_POST_MD,
|
||||
"body_html": welcome_html,
|
||||
"published_at": now_iso,
|
||||
"updated_at": now_iso,
|
||||
"author_id": _SEED_USER_ID,
|
||||
},
|
||||
)
|
||||
|
||||
# --- About page ----------------------------------------------
|
||||
about_html = markdown.render(_ABOUT_PAGE_MD)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT OR IGNORE INTO pages"
|
||||
" (slug, title, body_md, body_html_cached, updated_at,"
|
||||
" published)"
|
||||
" VALUES (:slug, :title, :body_md, :body_html,"
|
||||
" :updated_at, 1)"
|
||||
),
|
||||
{
|
||||
"slug": _ABOUT_PAGE_SLUG,
|
||||
"title": _ABOUT_PAGE_TITLE,
|
||||
"body_md": _ABOUT_PAGE_MD,
|
||||
"body_html": about_html,
|
||||
"updated_at": now_iso,
|
||||
},
|
||||
)
|
||||
|
||||
# --- Marker ---------------------------------------------------
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO schema_migrations (version, applied_at)"
|
||||
" VALUES (:v, :t)"
|
||||
),
|
||||
{"v": _SEED_MARKER, "t": now_iso},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
_log.info("seed_applied", marker=_SEED_MARKER)
|
||||
return True
|
||||
8
app/routes/__init__.py
Normal file
8
app/routes/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""HTTP route packages.
|
||||
|
||||
Routers live as sibling modules and are wired into the app in
|
||||
:mod:`app.main`. Phase 0 only exposes ``health``; public, admin, and auth
|
||||
routers arrive in later phases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
259
app/routes/admin.py
Normal file
259
app/routes/admin.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Admin auth routes — magic-link login, consume, landing, logout.
|
||||
|
||||
Every handler here is deliberately thin: it delegates to
|
||||
:class:`app.services.auth.AuthService` or
|
||||
:class:`app.services.sessions.SessionService` and translates the
|
||||
result into an HTTP response. No SQL, no crypto, no email building.
|
||||
|
||||
Anti-enumeration contract
|
||||
-------------------------
|
||||
POST /admin/login always renders the same ``login_sent.html`` template
|
||||
with identical copy, identical status, and identical cookie state,
|
||||
regardless of whether the submitted address is on the allowlist. The
|
||||
only side-effects that differ are:
|
||||
|
||||
- token row inserted (allowlisted)
|
||||
- email dispatched (allowlisted)
|
||||
- audit row has ``allowlisted=true`` vs ``false``
|
||||
|
||||
GET /admin/auth/consume/{token} likewise uses a single failure page
|
||||
for every bad-token reason (missing / unknown / expired / already
|
||||
used). The specific reason lives only in the audit log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.dependencies.auth import require_admin
|
||||
from app.dependencies.csrf import require_csrf_form
|
||||
from app.models.entities import User
|
||||
from app.services.auth import AuthService, RateLimitedError
|
||||
from app.services.rate_limit import limiter
|
||||
from app.services.sessions import COOKIE_NAME, SessionService
|
||||
|
||||
|
||||
router: APIRouter = APIRouter(tags=["admin"])
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Lightweight email regex. We intentionally avoid ``email-validator``
|
||||
# (not pinned in requirements.txt) and a full RFC-5322 parser; the goal
|
||||
# is only to reject empty strings and obviously-not-an-email submissions
|
||||
# before calling the allowlist. Full validation is unnecessary because
|
||||
# non-allowlisted addresses are silently ignored anyway.
|
||||
_EMAIL_RE: re.Pattern[str] = re.compile(
|
||||
r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
|
||||
)
|
||||
|
||||
|
||||
def _get_templates(request: Request) -> Jinja2Templates:
|
||||
"""Return the app-scoped :class:`Jinja2Templates`."""
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
def _get_auth_service(request: Request) -> AuthService:
|
||||
"""Return the app-scoped :class:`AuthService`."""
|
||||
return request.app.state.auth_service
|
||||
|
||||
|
||||
def _get_session_service(request: Request) -> SessionService:
|
||||
"""Return the app-scoped :class:`SessionService`."""
|
||||
return request.app.state.session_service
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Best-effort client IP from the request.
|
||||
|
||||
Starlette already respects ``X-Forwarded-For`` via the proxy-headers
|
||||
middleware Uvicorn installs with ``--proxy-headers``; that means
|
||||
``request.client.host`` is the real client IP.
|
||||
"""
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
def _user_agent(request: Request) -> str:
|
||||
"""Return the submitted User-Agent header (empty string if missing)."""
|
||||
return request.headers.get("user-agent", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/login
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/admin/login", response_class=HTMLResponse, summary="Admin login form")
|
||||
def admin_login_form(
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
) -> HTMLResponse:
|
||||
"""Render the email-entry form.
|
||||
|
||||
The login form is deliberately NOT CSRF-protected: the user is
|
||||
pre-authentication and no session cookie exists yet, so there is
|
||||
no authenticated context for a forged request to hijack. The
|
||||
``ADMIN_EMAILS`` allowlist and SlowAPI rate limit are what keep
|
||||
this endpoint safe.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/login.html",
|
||||
{"error": None, "email": ""},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/login
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/login", response_class=HTMLResponse, summary="Request magic link")
|
||||
@limiter.limit("5/15 minutes")
|
||||
def admin_login_submit(
|
||||
request: Request,
|
||||
email: str = Form(default=""),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
auth: AuthService = Depends(_get_auth_service),
|
||||
) -> Response:
|
||||
"""Handle the login form submission.
|
||||
|
||||
Flow:
|
||||
1. Normalize + validate the email format. On format error we
|
||||
re-render ``login.html`` with a message (this is a UX concession
|
||||
— an invalid email shape is not a successful submission, so
|
||||
there's no enumeration risk).
|
||||
2. Call :meth:`AuthService.request_link`.
|
||||
3. Regardless of allowlist membership: render ``login_sent.html``
|
||||
with identical copy. On per-email rate-limit: render
|
||||
``rate_limited.html`` + 429.
|
||||
|
||||
Rate limiting
|
||||
-------------
|
||||
The ``@limiter.limit`` decoration is applied dynamically from
|
||||
``app.main`` so tests can bypass it — see the route registration
|
||||
helper in ``app.main``.
|
||||
"""
|
||||
normalized = (email or "").strip().lower()
|
||||
|
||||
if not normalized or not _EMAIL_RE.match(normalized):
|
||||
# Format error surfaces as a flash; there's nothing to leak
|
||||
# here because we haven't checked the allowlist yet.
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/login.html",
|
||||
{
|
||||
"error": "Please enter a valid email address.",
|
||||
"email": email or "",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
ip = _client_ip(request)
|
||||
ua = _user_agent(request)
|
||||
|
||||
try:
|
||||
auth.request_link(email=normalized, ip=ip, user_agent=ua)
|
||||
except RateLimitedError:
|
||||
# Per-email DB-side limit tripped. The SlowAPI IP-level limit
|
||||
# is handled separately via the registered exception handler.
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/rate_limited.html",
|
||||
{},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/login_sent.html",
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/auth/consume/{token}
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get(
|
||||
"/admin/auth/consume/{token}",
|
||||
summary="Consume magic-link token",
|
||||
)
|
||||
@limiter.limit("20/15 minutes")
|
||||
def admin_auth_consume(
|
||||
request: Request,
|
||||
token: str,
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
auth: AuthService = Depends(_get_auth_service),
|
||||
sessions: SessionService = Depends(_get_session_service),
|
||||
) -> Response:
|
||||
"""Consume a magic-link token and, on success, set the session cookie.
|
||||
|
||||
Failure path returns 400 + generic ``invalid or expired`` page.
|
||||
Success path sets ``cb_session`` and 303-redirects to ``/admin``.
|
||||
"""
|
||||
ip = _client_ip(request)
|
||||
ua = _user_agent(request)
|
||||
|
||||
result = auth.consume(raw_token=token, ip=ip, user_agent=ua)
|
||||
if result is None:
|
||||
# Generic failure response — the audit log has the real reason.
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/login_failed.html",
|
||||
{},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
_user, _session, cookie_value = result
|
||||
|
||||
# 303 forces the browser to GET /admin on the next request.
|
||||
response = RedirectResponse(url="/admin", status_code=303)
|
||||
response.set_cookie(value=cookie_value, **sessions.cookie_params())
|
||||
return response
|
||||
|
||||
|
||||
# The authenticated landing page (``GET /admin``) now lives in
|
||||
# :mod:`app.routes.admin_cms` as the dashboard. This module stays
|
||||
# scoped to pre-auth / auth-lifecycle endpoints.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/logout
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/logout", summary="Log out the current admin")
|
||||
def admin_logout(
|
||||
request: Request,
|
||||
user: User = Depends(require_admin),
|
||||
sessions: SessionService = Depends(_get_session_service),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Revoke the current session and clear the cookie.
|
||||
|
||||
Always issues a 303 redirect to ``/admin/login`` so browsers
|
||||
transparently follow and show the login form (with no cookie).
|
||||
"""
|
||||
from app.services.sessions import SessionService as _SS # noqa: F401
|
||||
|
||||
# Look up the session again via the cookie so we can revoke it
|
||||
# and emit a properly-correlated audit row.
|
||||
cookie_value: Optional[str] = request.cookies.get(COOKIE_NAME)
|
||||
session = sessions.lookup(cookie_value)
|
||||
audit = request.app.state.audit_service
|
||||
|
||||
if session is not None:
|
||||
sessions.revoke(session)
|
||||
audit.record(
|
||||
"session_revoked",
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
ip=_client_ip(request),
|
||||
user_agent=_user_agent(request),
|
||||
detail={"session_id": session.token_hash[-6:]},
|
||||
)
|
||||
|
||||
response = RedirectResponse(url="/admin/login", status_code=303)
|
||||
# Clear the cookie by setting an empty value with Max-Age=0 and the
|
||||
# same Path so the browser actually removes it.
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/")
|
||||
return response
|
||||
500
app/routes/admin_cms.py
Normal file
500
app/routes/admin_cms.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""Admin CMS routes — dashboard, post CRUD, About edit, media upload.
|
||||
|
||||
These handlers all live behind :func:`require_admin`. Mutating
|
||||
endpoints additionally pull a CSRF dependency
|
||||
(:func:`require_csrf_form` or :func:`require_csrf_header`) so the
|
||||
double-submit cookie is verified before any state change.
|
||||
|
||||
Each handler does the absolute minimum: pull services off the app
|
||||
state, call their methods, translate the result into an HTTP
|
||||
response. No business logic, no SQL.
|
||||
|
||||
Routing map
|
||||
-----------
|
||||
- ``GET /admin`` — dashboard.
|
||||
- ``GET /admin/posts/new`` — create form.
|
||||
- ``POST /admin/posts`` — create handler (CSRF).
|
||||
- ``GET /admin/posts/{id}/edit`` — edit form.
|
||||
- ``POST /admin/posts/{id}`` — update (CSRF).
|
||||
- ``POST /admin/posts/{id}/delete`` — delete (CSRF).
|
||||
- ``POST /admin/posts/{id}/publish`` — publish toggle (CSRF).
|
||||
- ``GET /admin/pages/about/edit`` — About edit form.
|
||||
- ``POST /admin/pages/about`` — About update (CSRF).
|
||||
- ``POST /admin/media/upload`` — multipart upload (header CSRF).
|
||||
- ``POST /admin/preview`` — Markdown → HTML preview (header CSRF).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.dependencies.auth import require_admin
|
||||
from app.dependencies.csrf import require_csrf_form, require_csrf_header
|
||||
from app.models.entities import PostStatus, User
|
||||
from app.services.admin_pages import AdminPagesService
|
||||
from app.services.admin_posts import AdminPostsService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.media import MediaRejectedError, MediaService
|
||||
|
||||
|
||||
router: APIRouter = APIRouter(tags=["admin-cms"])
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DI helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _get_templates(request: Request) -> Jinja2Templates:
|
||||
"""Return the app-scoped :class:`Jinja2Templates`."""
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
def _get_admin_posts(request: Request) -> AdminPostsService:
|
||||
"""Return the app-scoped :class:`AdminPostsService`."""
|
||||
return request.app.state.admin_posts_service
|
||||
|
||||
|
||||
def _get_admin_pages(request: Request) -> AdminPagesService:
|
||||
"""Return the app-scoped :class:`AdminPagesService`."""
|
||||
return request.app.state.admin_pages_service
|
||||
|
||||
|
||||
def _get_media(request: Request) -> MediaService:
|
||||
"""Return the app-scoped :class:`MediaService`."""
|
||||
return request.app.state.media_service
|
||||
|
||||
|
||||
def _get_markdown(request: Request) -> MarkdownService:
|
||||
"""Return the app-scoped :class:`MarkdownService`."""
|
||||
return request.app.state.markdown_service
|
||||
|
||||
|
||||
def _get_csrf_token_for_template(request: Request) -> str:
|
||||
"""Return the CSRF token to embed in the rendered admin templates.
|
||||
|
||||
The middleware (see :mod:`app.main`) sets ``request.state.csrf_token``
|
||||
on every admin GET after ensuring the cookie is in sync. Handlers
|
||||
pull it from request state and pass it into the template context.
|
||||
"""
|
||||
return getattr(request.state, "csrf_token", "") or ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin — dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/admin", response_class=HTMLResponse, summary="Admin dashboard")
|
||||
def admin_dashboard(
|
||||
request: Request,
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
admin_pages: AdminPagesService = Depends(_get_admin_pages),
|
||||
) -> HTMLResponse:
|
||||
"""Render the dashboard: posts list + About edit link.
|
||||
|
||||
Posts are sorted newest-updated-first and include both drafts and
|
||||
published posts — the admin table surfaces the status badge.
|
||||
"""
|
||||
posts = admin_posts.list_all()
|
||||
about = admin_pages.get_about()
|
||||
# Optional flash from PRG query param — we keep it minimal.
|
||||
msg = request.query_params.get("msg") or ""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/dashboard.html",
|
||||
{
|
||||
"user": user,
|
||||
"posts": posts,
|
||||
"about": about,
|
||||
"msg": msg,
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/posts/new
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get(
|
||||
"/admin/posts/new",
|
||||
response_class=HTMLResponse,
|
||||
summary="Admin: new post form",
|
||||
)
|
||||
def admin_post_new_form(
|
||||
request: Request,
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
) -> HTMLResponse:
|
||||
"""Render the empty create form.
|
||||
|
||||
No post id → the form POSTs to ``/admin/posts``. Slug is not shown
|
||||
because we auto-generate it from the title on create.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/post_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"post": None,
|
||||
"form": {"title": "", "body_md": "", "status": PostStatus.DRAFT.value},
|
||||
"errors": {},
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/posts — create
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/posts", summary="Admin: create post")
|
||||
def admin_post_create(
|
||||
request: Request,
|
||||
title: str = Form(default=""),
|
||||
body_md: str = Form(default=""),
|
||||
status: str = Form(default=PostStatus.DRAFT.value),
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Handle the new-post submission and redirect to the dashboard.
|
||||
|
||||
Minimal validation:
|
||||
- title must be non-empty after strip
|
||||
- status must be a valid :class:`PostStatus` value
|
||||
|
||||
On validation error we re-render the form with the submitted
|
||||
values so Head Hen doesn't retype.
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
clean_title = (title or "").strip()
|
||||
if not clean_title:
|
||||
errors["title"] = "Title is required."
|
||||
try:
|
||||
status_enum = PostStatus(status)
|
||||
except ValueError:
|
||||
errors["status"] = "Invalid status."
|
||||
status_enum = PostStatus.DRAFT
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/post_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"post": None,
|
||||
"form": {
|
||||
"title": title,
|
||||
"body_md": body_md,
|
||||
"status": status_enum.value,
|
||||
},
|
||||
"errors": errors,
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
admin_posts.create(
|
||||
title=clean_title,
|
||||
body_md=body_md or "",
|
||||
status=status_enum,
|
||||
author_id=user.id,
|
||||
)
|
||||
return RedirectResponse(url="/admin?msg=created", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/posts/{id}/edit
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get(
|
||||
"/admin/posts/{post_id}/edit",
|
||||
response_class=HTMLResponse,
|
||||
summary="Admin: edit post form",
|
||||
)
|
||||
def admin_post_edit_form(
|
||||
request: Request,
|
||||
post_id: int,
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
) -> HTMLResponse:
|
||||
"""Render the edit form for an existing post."""
|
||||
post = admin_posts.get_by_id(post_id)
|
||||
if post is None:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/post_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"post": post,
|
||||
"form": {
|
||||
"title": post.title,
|
||||
"body_md": post.body_md,
|
||||
"status": post.status.value,
|
||||
},
|
||||
"errors": {},
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/posts/{id} — update
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/posts/{post_id}", summary="Admin: update post")
|
||||
def admin_post_update(
|
||||
request: Request,
|
||||
post_id: int,
|
||||
title: str = Form(default=""),
|
||||
body_md: str = Form(default=""),
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Apply title + body edits to an existing post.
|
||||
|
||||
Slug changes are not permitted via this path — server-side
|
||||
enforcement of the "slug lock on publish" policy (see
|
||||
:class:`AdminPostsService`).
|
||||
"""
|
||||
existing = admin_posts.get_by_id(post_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
clean_title = (title or "").strip()
|
||||
errors: dict[str, str] = {}
|
||||
if not clean_title:
|
||||
errors["title"] = "Title is required."
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/post_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"post": existing,
|
||||
"form": {
|
||||
"title": title,
|
||||
"body_md": body_md,
|
||||
"status": existing.status.value,
|
||||
},
|
||||
"errors": errors,
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
admin_posts.update(
|
||||
post_id,
|
||||
title=clean_title,
|
||||
body_md=body_md or "",
|
||||
actor_user_id=user.id,
|
||||
)
|
||||
return RedirectResponse(url="/admin?msg=saved", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/posts/{id}/delete
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/posts/{post_id}/delete", summary="Admin: delete post")
|
||||
def admin_post_delete(
|
||||
request: Request,
|
||||
post_id: int,
|
||||
user: User = Depends(require_admin),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Hard-delete a post row."""
|
||||
deleted = admin_posts.delete(post_id, actor_user_id=user.id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return RedirectResponse(url="/admin?msg=deleted", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/posts/{id}/publish — publish/unpublish toggle
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post(
|
||||
"/admin/posts/{post_id}/publish", summary="Admin: toggle publish state"
|
||||
)
|
||||
def admin_post_toggle_publish(
|
||||
request: Request,
|
||||
post_id: int,
|
||||
user: User = Depends(require_admin),
|
||||
admin_posts: AdminPostsService = Depends(_get_admin_posts),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Flip draft ↔ published."""
|
||||
updated = admin_posts.toggle_publish(post_id, actor_user_id=user.id)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
# Friendly-ish flash so the admin sees the result of the toggle.
|
||||
msg = "published" if updated.status is PostStatus.PUBLISHED else "unpublished"
|
||||
return RedirectResponse(url=f"/admin?msg={msg}", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /admin/pages/about/edit
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get(
|
||||
"/admin/pages/about/edit",
|
||||
response_class=HTMLResponse,
|
||||
summary="Admin: edit About page",
|
||||
)
|
||||
def admin_about_edit_form(
|
||||
request: Request,
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_pages: AdminPagesService = Depends(_get_admin_pages),
|
||||
) -> HTMLResponse:
|
||||
"""Render the About-page edit form."""
|
||||
page = admin_pages.get_about()
|
||||
if page is None:
|
||||
raise HTTPException(status_code=500, detail="About page missing")
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/page_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"page": page,
|
||||
"form": {"title": page.title, "body_md": page.body_md},
|
||||
"errors": {},
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/pages/about — update
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/pages/about", summary="Admin: update About page")
|
||||
def admin_about_update(
|
||||
request: Request,
|
||||
title: str = Form(default=""),
|
||||
body_md: str = Form(default=""),
|
||||
user: User = Depends(require_admin),
|
||||
templates: Jinja2Templates = Depends(_get_templates),
|
||||
admin_pages: AdminPagesService = Depends(_get_admin_pages),
|
||||
_csrf: None = Depends(require_csrf_form),
|
||||
) -> Response:
|
||||
"""Apply edits to the About page (slug is fixed)."""
|
||||
clean_title = (title or "").strip()
|
||||
errors: dict[str, str] = {}
|
||||
if not clean_title:
|
||||
errors["title"] = "Title is required."
|
||||
|
||||
if errors:
|
||||
page = admin_pages.get_about()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin/page_form.html",
|
||||
{
|
||||
"user": user,
|
||||
"page": page,
|
||||
"form": {"title": title, "body_md": body_md},
|
||||
"errors": errors,
|
||||
"csrf_token": _get_csrf_token_for_template(request),
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
admin_pages.update_about(
|
||||
title=clean_title,
|
||||
body_md=body_md or "",
|
||||
actor_user_id=user.id,
|
||||
)
|
||||
return RedirectResponse(url="/admin?msg=saved", status_code=303)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/media/upload
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/admin/media/upload", summary="Admin: upload image")
|
||||
async def admin_media_upload(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
alt_text: str = Form(default=""),
|
||||
user: User = Depends(require_admin),
|
||||
media: MediaService = Depends(_get_media),
|
||||
_csrf: None = Depends(require_csrf_header),
|
||||
) -> JSONResponse:
|
||||
"""Validate and store an uploaded image.
|
||||
|
||||
Response JSON is small by design — the drag-drop JS only needs a
|
||||
URL to splice into the Markdown source as ````.
|
||||
"""
|
||||
data = await file.read()
|
||||
try:
|
||||
record = media.save_upload(
|
||||
original_filename=file.filename or "",
|
||||
data=data,
|
||||
uploaded_by=user.id,
|
||||
alt_text=alt_text or "",
|
||||
)
|
||||
except MediaRejectedError as exc:
|
||||
return JSONResponse(
|
||||
{"error": str(exc)},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": record.id,
|
||||
"url": media.public_url(record),
|
||||
"alt": record.alt_text,
|
||||
"filename": record.filename,
|
||||
"size_bytes": record.size_bytes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /admin/preview
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post(
|
||||
"/admin/preview",
|
||||
response_class=HTMLResponse,
|
||||
summary="Admin: Markdown preview",
|
||||
)
|
||||
def admin_preview(
|
||||
request: Request,
|
||||
markdown: str = Form(default=""),
|
||||
user: User = Depends(require_admin),
|
||||
md: MarkdownService = Depends(_get_markdown),
|
||||
_csrf: None = Depends(require_csrf_header),
|
||||
) -> HTMLResponse:
|
||||
"""Render ``markdown`` through the sanitizer and return an HTML fragment.
|
||||
|
||||
The fragment is NOT wrapped in a full page — it is ``innerHTML``-safe
|
||||
output from the same pipeline that stores ``body_html_cached``. The
|
||||
route reuses :class:`MarkdownService` so preview output exactly
|
||||
matches what will eventually be served to the public.
|
||||
"""
|
||||
rendered: str = md.render(markdown or "")
|
||||
return HTMLResponse(content=rendered)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional aliases for backward-compat imports
|
||||
# ---------------------------------------------------------------------------
|
||||
# If another module (e.g. tests) imports ``router`` from here, the
|
||||
# attribute name stays stable.
|
||||
_ = router # silence "not used" in linters that don't pick up FastAPI magic
|
||||
|
||||
# Avoid "Optional unused" when the type is only referenced via Depends.
|
||||
_Optional = Optional # pragma: no cover
|
||||
69
app/routes/health.py
Normal file
69
app/routes/health.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Liveness / version endpoint.
|
||||
|
||||
The ``/healthz`` endpoint is intentionally minimal: it must be safe to
|
||||
expose unauthenticated (Caddy + uptime checks will hit it), so it leaks
|
||||
only the app version and the build's git commit SHA — no hostnames,
|
||||
paths, config values, or environment details.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app import __version__
|
||||
from app.config import Settings, get_settings
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Response schema for ``GET /healthz``.
|
||||
|
||||
Kept as an explicit :class:`pydantic.BaseModel` so FastAPI publishes
|
||||
the shape in the OpenAPI schema and so any drift is caught at typing
|
||||
time rather than via a loose ``dict``.
|
||||
"""
|
||||
|
||||
status: Literal["ok"] = Field(
|
||||
default="ok",
|
||||
description="Liveness indicator; this endpoint only returns 'ok'.",
|
||||
)
|
||||
version: str = Field(description="Application semantic version.")
|
||||
commit_sha: str = Field(
|
||||
description="Git commit SHA of the running build ('unknown' in dev).",
|
||||
)
|
||||
|
||||
|
||||
# Module-level router; mounted by `app.main.create_app`. No prefix and no
|
||||
# auth dependencies — /healthz is intentionally public.
|
||||
router: APIRouter = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/healthz",
|
||||
response_model=HealthResponse,
|
||||
summary="Liveness + version probe",
|
||||
)
|
||||
def healthz(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
"""Return a minimal liveness envelope.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
settings:
|
||||
Injected via FastAPI's dependency system so tests can override
|
||||
configuration cleanly.
|
||||
|
||||
Returns
|
||||
-------
|
||||
HealthResponse
|
||||
``status`` is always ``"ok"``; ``version`` and ``commit_sha``
|
||||
identify the running build.
|
||||
"""
|
||||
# Intentionally does not touch the DB, filesystem, or any external
|
||||
# service. This is liveness, not readiness — a readiness probe with
|
||||
# deeper checks can be added in a later phase behind a different path.
|
||||
return HealthResponse(
|
||||
version=__version__,
|
||||
commit_sha=settings.git_commit_sha,
|
||||
)
|
||||
148
app/routes/public.py
Normal file
148
app/routes/public.py
Normal 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
7
app/services/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Application services (auth, email, cache, markdown, media, hcaptcha).
|
||||
|
||||
Populated phase-by-phase per ``docs/ROADMAP.md``. Intentionally empty in
|
||||
Phase 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
124
app/services/admin_pages.py
Normal file
124
app/services/admin_pages.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Admin-side (write) page service.
|
||||
|
||||
The public site only has one editable page — "About" — so this
|
||||
service is intentionally narrower than :class:`AdminPostsService`. The
|
||||
slug is a fixed literal (``"about"``) and cannot be changed through
|
||||
the admin. Only the title and body may be edited.
|
||||
|
||||
Every write:
|
||||
|
||||
- re-renders Markdown → sanitized HTML into ``body_html_cached`` so
|
||||
the public read path stays a single SELECT.
|
||||
- bumps ``updated_at``.
|
||||
- emits an ``AuditService`` ``page_updated`` event.
|
||||
- invalidates the public :class:`PageService` (and, defensively, the
|
||||
:class:`PostService`) cache so the next request sees the new copy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import Page
|
||||
from app.models.mappers import row_to_page
|
||||
from app.services.audit import AuditService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# The single editable page's slug. Hard-coded here (not injected) so
|
||||
# the CLI contract is impossible to misuse — there is no way to point
|
||||
# this service at a different slug.
|
||||
ABOUT_SLUG: str = "about"
|
||||
|
||||
|
||||
class AdminPagesService:
|
||||
"""Write-side service for the About page."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
markdown: MarkdownService,
|
||||
page_service: PageService,
|
||||
post_service: PostService,
|
||||
audit: AuditService,
|
||||
) -> None:
|
||||
self._engine: Engine = engine
|
||||
self._markdown: MarkdownService = markdown
|
||||
self._page_service: PageService = page_service
|
||||
self._post_service: PostService = post_service
|
||||
self._audit: AuditService = audit
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reads
|
||||
# ------------------------------------------------------------------
|
||||
def get_about(self) -> Optional[Page]:
|
||||
"""Return the current About page row, or ``None`` if absent."""
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" updated_at, published"
|
||||
" FROM pages WHERE slug = :slug LIMIT 1"
|
||||
),
|
||||
{"slug": ABOUT_SLUG},
|
||||
).mappings().first()
|
||||
return row_to_page(row) if row is not None else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writes
|
||||
# ------------------------------------------------------------------
|
||||
def update_about(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
body_md: str,
|
||||
actor_user_id: int,
|
||||
) -> Optional[Page]:
|
||||
"""Update the About page's title + body.
|
||||
|
||||
Slug is immutable — the admin form does not expose it.
|
||||
"""
|
||||
existing = self.get_about()
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
clean_title = (title or "").strip()
|
||||
clean_body = body_md or ""
|
||||
body_html = self._markdown.render(clean_body)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE pages"
|
||||
" SET title = :title, body_md = :body_md,"
|
||||
" body_html_cached = :body_html,"
|
||||
" updated_at = :updated_at"
|
||||
" WHERE slug = :slug"
|
||||
),
|
||||
{
|
||||
"title": clean_title,
|
||||
"body_md": clean_body,
|
||||
"body_html": body_html,
|
||||
"updated_at": now_iso,
|
||||
"slug": ABOUT_SLUG,
|
||||
},
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
"page_updated",
|
||||
user_id=actor_user_id,
|
||||
detail={"slug": ABOUT_SLUG},
|
||||
)
|
||||
self._page_service.invalidate_all()
|
||||
self._post_service.invalidate_all()
|
||||
return self.get_about()
|
||||
383
app/services/admin_posts.py
Normal file
383
app/services/admin_posts.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""Admin-side (write) post service.
|
||||
|
||||
Mirrors the shape of :class:`app.services.posts.PostService` but for
|
||||
the admin CRUD path. Responsibilities:
|
||||
|
||||
- create / update / delete posts
|
||||
- toggle publish state
|
||||
- auto-generate unique slugs from titles on create (draft only)
|
||||
- re-render Markdown to ``body_html_cached`` on every write
|
||||
- audit every write via :class:`AuditService` using descriptive
|
||||
``event_type`` strings
|
||||
- invalidate both :class:`PostService` and :class:`PageService` caches
|
||||
so the public site reflects the change immediately
|
||||
|
||||
All writes use parameterized SQL (``text(":bind")``). No user input is
|
||||
ever interpolated into a query string.
|
||||
|
||||
The service treats ``author_user_id`` as an immutable field: once a
|
||||
post is created, edits do NOT reassign authorship, even if a different
|
||||
admin saves the edit. This matches the single-author ("Head Hen")
|
||||
reality of the site.
|
||||
|
||||
Slug lock-on-publish
|
||||
--------------------
|
||||
A slug may only be auto-regenerated on title change while the post is
|
||||
a draft. Once a post has been published even once, the slug is locked
|
||||
server-side — callers cannot change it via the update path, even if
|
||||
they later unpublish the post. This preserves any inbound links that
|
||||
went live while the post was published.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import Post, PostStatus
|
||||
from app.models.mappers import row_to_post
|
||||
from app.services.audit import AuditService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
from app.services.slugs import ensure_unique, slugify
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AdminPostsService:
|
||||
"""Write-side orchestration for blog posts.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
Shared SQLAlchemy engine. Never opens its own.
|
||||
markdown:
|
||||
Shared :class:`MarkdownService` used to re-render on every
|
||||
write so the public read path pays only a single SELECT.
|
||||
post_service:
|
||||
The public read-side service. Invalidated after every write so
|
||||
the home page reflects the change immediately.
|
||||
page_service:
|
||||
Same rationale — a post edit doesn't change page content but
|
||||
we conservatively invalidate to keep cache logic uniform.
|
||||
audit:
|
||||
:class:`AuditService` for descriptive admin write events.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
markdown: MarkdownService,
|
||||
post_service: PostService,
|
||||
page_service: PageService,
|
||||
audit: AuditService,
|
||||
) -> None:
|
||||
self._engine: Engine = engine
|
||||
self._markdown: MarkdownService = markdown
|
||||
self._post_service: PostService = post_service
|
||||
self._page_service: PageService = page_service
|
||||
self._audit: AuditService = audit
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reads (admin dashboard)
|
||||
# ------------------------------------------------------------------
|
||||
def list_all(self) -> list[Post]:
|
||||
"""Return every post, newest-updated-first.
|
||||
|
||||
Drafts and published posts are both included; the dashboard
|
||||
surfaces the status column so Head Hen can work on unpublished
|
||||
material.
|
||||
"""
|
||||
with self._engine.connect() as conn:
|
||||
rows = (
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" status, published_at, updated_at, author_user_id"
|
||||
" FROM posts"
|
||||
" ORDER BY updated_at DESC, id DESC"
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [row_to_post(row) for row in rows]
|
||||
|
||||
def get_by_id(self, post_id: int) -> Optional[Post]:
|
||||
"""Return the :class:`Post` for ``post_id`` or ``None`` if absent."""
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" status, published_at, updated_at, author_user_id"
|
||||
" FROM posts WHERE id = :id LIMIT 1"
|
||||
),
|
||||
{"id": post_id},
|
||||
).mappings().first()
|
||||
return row_to_post(row) if row is not None else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writes
|
||||
# ------------------------------------------------------------------
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
body_md: str,
|
||||
status: PostStatus,
|
||||
author_id: int,
|
||||
) -> Post:
|
||||
"""Insert a new post row and return the loaded :class:`Post`.
|
||||
|
||||
Flow
|
||||
----
|
||||
1. Slugify the title; ensure uniqueness via the closure over the
|
||||
DB so concurrent creates cannot collide on the UNIQUE index.
|
||||
2. Render Markdown to sanitized HTML.
|
||||
3. If ``status == PUBLISHED`` stamp ``published_at = now``;
|
||||
otherwise leave NULL.
|
||||
4. Insert.
|
||||
5. Audit ``post_created`` (and ``post_published`` when the
|
||||
initial status is published).
|
||||
6. Invalidate caches.
|
||||
"""
|
||||
clean_title = (title or "").strip()
|
||||
clean_body = body_md or ""
|
||||
base_slug = slugify(clean_title)
|
||||
# The closure escapes the engine so ensure_unique can check
|
||||
# without opening a long-lived transaction.
|
||||
unique_slug = ensure_unique(base_slug, self._slug_exists)
|
||||
|
||||
body_html = self._markdown.render(clean_body)
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = now.isoformat()
|
||||
published_at_iso: Optional[str] = (
|
||||
now_iso if status is PostStatus.PUBLISHED else None
|
||||
)
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"INSERT INTO posts"
|
||||
" (slug, title, body_md, body_html_cached, status,"
|
||||
" published_at, updated_at, author_user_id)"
|
||||
" VALUES (:slug, :title, :body_md, :body_html,"
|
||||
" :status, :published_at, :updated_at, :author_id)"
|
||||
),
|
||||
{
|
||||
"slug": unique_slug,
|
||||
"title": clean_title,
|
||||
"body_md": clean_body,
|
||||
"body_html": body_html,
|
||||
"status": status.value,
|
||||
"published_at": published_at_iso,
|
||||
"updated_at": now_iso,
|
||||
"author_id": author_id,
|
||||
},
|
||||
)
|
||||
new_id = int(result.lastrowid) # type: ignore[arg-type]
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" status, published_at, updated_at, author_user_id"
|
||||
" FROM posts WHERE id = :id"
|
||||
),
|
||||
{"id": new_id},
|
||||
).mappings().first()
|
||||
|
||||
if row is None: # pragma: no cover — just inserted
|
||||
raise RuntimeError("failed to reload just-inserted post row")
|
||||
|
||||
post = row_to_post(row)
|
||||
|
||||
self._audit.record(
|
||||
"post_created",
|
||||
user_id=author_id,
|
||||
detail={"post_id": post.id, "slug": post.slug, "status": post.status.value},
|
||||
)
|
||||
if post.status is PostStatus.PUBLISHED:
|
||||
self._audit.record(
|
||||
"post_published",
|
||||
user_id=author_id,
|
||||
detail={"post_id": post.id, "slug": post.slug},
|
||||
)
|
||||
|
||||
self._invalidate_caches()
|
||||
return post
|
||||
|
||||
def update(
|
||||
self,
|
||||
post_id: int,
|
||||
*,
|
||||
title: str,
|
||||
body_md: str,
|
||||
actor_user_id: int,
|
||||
) -> Optional[Post]:
|
||||
"""Update a post's title + body. Return the refreshed :class:`Post`.
|
||||
|
||||
Behavior
|
||||
--------
|
||||
- The slug is NEVER regenerated by an update call. While the
|
||||
post is still a draft the admin may delete + recreate to pick
|
||||
a new slug; once published the slug is permanent per the
|
||||
security contract (external links must not break).
|
||||
- ``author_user_id`` is preserved — this endpoint does not
|
||||
transfer authorship.
|
||||
- ``published_at`` is preserved verbatim. Publishing happens via
|
||||
:meth:`toggle_publish`.
|
||||
- Always re-renders Markdown so ``body_html_cached`` stays in
|
||||
sync with ``body_md``.
|
||||
- Always bumps ``updated_at``.
|
||||
"""
|
||||
existing = self.get_by_id(post_id)
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
clean_title = (title or "").strip()
|
||||
clean_body = body_md or ""
|
||||
body_html = self._markdown.render(clean_body)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE posts"
|
||||
" SET title = :title, body_md = :body_md,"
|
||||
" body_html_cached = :body_html,"
|
||||
" updated_at = :updated_at"
|
||||
" WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"title": clean_title,
|
||||
"body_md": clean_body,
|
||||
"body_html": body_html,
|
||||
"updated_at": now_iso,
|
||||
"id": post_id,
|
||||
},
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
"post_updated",
|
||||
user_id=actor_user_id,
|
||||
detail={"post_id": post_id, "slug": existing.slug},
|
||||
)
|
||||
self._invalidate_caches()
|
||||
return self.get_by_id(post_id)
|
||||
|
||||
def delete(self, post_id: int, *, actor_user_id: int) -> bool:
|
||||
"""Delete a post row. Return True if something was deleted.
|
||||
|
||||
Media rows uploaded during drafting are NOT cleaned up here —
|
||||
uploads aren't linked to posts in the schema, and orphan-sweep
|
||||
is explicitly out of scope per the Phase 4 brief.
|
||||
"""
|
||||
existing = self.get_by_id(post_id)
|
||||
if existing is None:
|
||||
return False
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text("DELETE FROM posts WHERE id = :id"),
|
||||
{"id": post_id},
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
"post_deleted",
|
||||
user_id=actor_user_id,
|
||||
detail={"post_id": post_id, "slug": existing.slug},
|
||||
)
|
||||
self._invalidate_caches()
|
||||
return True
|
||||
|
||||
def toggle_publish(self, post_id: int, *, actor_user_id: int) -> Optional[Post]:
|
||||
"""Flip draft ↔ published. Return the updated post, or ``None``.
|
||||
|
||||
Contract (see Phase 4 brief constraint 7):
|
||||
- Draft → Published: set ``published_at = now`` ONLY if it was
|
||||
previously NULL. If the post was once published, unpublished,
|
||||
and is now being re-published we preserve the original
|
||||
publish timestamp so the public list ordering stays stable.
|
||||
- Published → Draft: status flips, ``published_at`` is preserved.
|
||||
"""
|
||||
existing = self.get_by_id(post_id)
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
if existing.status is PostStatus.PUBLISHED:
|
||||
new_status = PostStatus.DRAFT
|
||||
# Preserve existing published_at on unpublish. No event_type
|
||||
# branch yet — we emit post_unpublished below.
|
||||
published_at_iso: Optional[str] = (
|
||||
existing.published_at.isoformat()
|
||||
if existing.published_at is not None
|
||||
else None
|
||||
)
|
||||
event_type = "post_unpublished"
|
||||
else:
|
||||
new_status = PostStatus.PUBLISHED
|
||||
# First-publish stamp. Preserve any prior published_at so
|
||||
# re-publish doesn't renumber the post on the front page.
|
||||
if existing.published_at is None:
|
||||
published_at_iso = now_iso
|
||||
else:
|
||||
published_at_iso = existing.published_at.isoformat()
|
||||
event_type = "post_published"
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE posts"
|
||||
" SET status = :status,"
|
||||
" published_at = :published_at,"
|
||||
" updated_at = :updated_at"
|
||||
" WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"status": new_status.value,
|
||||
"published_at": published_at_iso,
|
||||
"updated_at": now_iso,
|
||||
"id": post_id,
|
||||
},
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
event_type,
|
||||
user_id=actor_user_id,
|
||||
detail={"post_id": post_id, "slug": existing.slug},
|
||||
)
|
||||
self._invalidate_caches()
|
||||
return self.get_by_id(post_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
def _slug_exists(self, candidate: str) -> bool:
|
||||
"""Return True if a row with ``slug = candidate`` is already present."""
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT 1 FROM posts WHERE slug = :s LIMIT 1"),
|
||||
{"s": candidate},
|
||||
).first()
|
||||
return row is not None
|
||||
|
||||
def _invalidate_caches(self) -> None:
|
||||
"""Drop both the post and page read-side caches.
|
||||
|
||||
Post invalidation is strictly required; page invalidation is
|
||||
defensive — the schemas are separate, but keeping cache
|
||||
invalidation uniform makes it obvious Phase 4 writes never
|
||||
leave a stale public read.
|
||||
"""
|
||||
self._post_service.invalidate_all()
|
||||
self._page_service.invalidate_all()
|
||||
|
||||
|
||||
def get_admin_posts_service(request): # pragma: no cover — trivial
|
||||
"""FastAPI dependency — pull the service off ``app.state``."""
|
||||
return request.app.state.admin_posts_service
|
||||
119
app/services/audit.py
Normal file
119
app/services/audit.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Append-only auth audit log service.
|
||||
|
||||
Writes one row per auth event into the ``auth_events`` table. The rest of
|
||||
the auth stack calls :meth:`AuditService.record` to persist a structured,
|
||||
queryable audit trail without having to know the SQL or the row schema.
|
||||
|
||||
Security notes
|
||||
--------------
|
||||
- NEVER pass raw tokens, raw session IDs, or email bodies into ``detail``.
|
||||
Correlate sessions via the last 6 hex chars of their stored hash, never
|
||||
the full hash and never the raw value (CWE-200).
|
||||
- ``detail`` is persisted as JSON text; the schema column is ``TEXT NOT
|
||||
NULL DEFAULT '{}'`` and the writer always provides a valid JSON object.
|
||||
- All writes go through parameterized SQL with ``sqlalchemy.text``
|
||||
``:bind`` parameters; no string interpolation (CWE-89).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""Persist rows into ``auth_events``.
|
||||
|
||||
The service is intentionally tiny: one write method plus a helper
|
||||
fetcher used by tests. No caching (this is an append-only audit
|
||||
log and reads are rare).
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
"""Store the shared SQLAlchemy engine by reference.
|
||||
|
||||
The service never opens its own engine — it reuses the one
|
||||
wired on ``app.state.engine``.
|
||||
"""
|
||||
self._engine: Engine = engine
|
||||
|
||||
def record(
|
||||
self,
|
||||
event_type: str,
|
||||
*,
|
||||
email: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
ip: str = "",
|
||||
user_agent: str = "",
|
||||
detail: Optional[Mapping[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Insert a single audit row.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event_type:
|
||||
One of the Phase 3 event types: ``link_requested``,
|
||||
``link_consumed``, ``consume_failed``, ``session_created``,
|
||||
``session_revoked``, ``rate_limited``.
|
||||
email:
|
||||
Submitted / target email (nullable when the event doesn't
|
||||
have one, e.g. session_revoked where we key off user_id).
|
||||
user_id:
|
||||
Foreign key into ``users``; nullable for pre-auth events.
|
||||
ip:
|
||||
Client IP at time of event. Always captured when available;
|
||||
empty string is acceptable for events originating outside
|
||||
a request context (which Phase 3 does not currently emit,
|
||||
but the column is NOT NULL and we want the door closed).
|
||||
user_agent:
|
||||
Client UA at time of event. Same NOT-NULL rationale as ``ip``.
|
||||
detail:
|
||||
Event-specific structured context (dict-like). Serialized
|
||||
to a compact JSON string. Defaults to ``{}`` when absent.
|
||||
|
||||
NEVER put a raw token or session ID here — only hashes (or
|
||||
their last 6 chars) and other non-sensitive metadata.
|
||||
"""
|
||||
# Always serialize to JSON text; the DB column enforces NOT NULL
|
||||
# with an empty-object default, and we honor that contract here
|
||||
# rather than relying on the default.
|
||||
detail_json = json.dumps(dict(detail) if detail is not None else {})
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO auth_events"
|
||||
" (event_type, email, user_id, ip, user_agent,"
|
||||
" created_at, detail)"
|
||||
" VALUES (:event_type, :email, :user_id, :ip,"
|
||||
" :user_agent, :created_at, :detail)"
|
||||
),
|
||||
{
|
||||
"event_type": event_type,
|
||||
"email": email,
|
||||
"user_id": user_id,
|
||||
"ip": ip or "",
|
||||
"user_agent": user_agent or "",
|
||||
"created_at": now_iso,
|
||||
"detail": detail_json,
|
||||
},
|
||||
)
|
||||
|
||||
# Mirror the audit row to structured logs at INFO. We never log
|
||||
# the raw token / session ID, only the same detail dict (which
|
||||
# the caller already scrubbed) and the non-sensitive envelope.
|
||||
_log.info(
|
||||
"auth_event",
|
||||
event_type=event_type,
|
||||
email=email,
|
||||
user_id=user_id,
|
||||
detail=detail or {},
|
||||
)
|
||||
399
app/services/auth.py
Normal file
399
app/services/auth.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""Magic-link auth orchestration.
|
||||
|
||||
Glues token issuance / consumption, user auto-upsert on consume,
|
||||
email delivery, session creation, and audit logging together behind
|
||||
two methods:
|
||||
|
||||
- :meth:`AuthService.request_link` — handle POST /admin/login
|
||||
- :meth:`AuthService.consume` — handle GET /admin/auth/consume/{token}
|
||||
|
||||
Security decisions are concentrated here:
|
||||
|
||||
- Raw tokens live only in memory, the outbound email URL, and the
|
||||
single SHA-256 hash that ends up in the DB.
|
||||
- The allowlist check is ALWAYS performed with lowercased emails.
|
||||
- Non-allowlisted requests receive an identical response shape (handled
|
||||
by the caller; this service just short-circuits the token issue and
|
||||
still audits via ``link_requested`` with ``allowlisted=false``).
|
||||
- Rate limiting has two layers:
|
||||
1. SlowAPI IP-level decorator on the route (outside this module).
|
||||
2. DB-side per-email COUNT inside :meth:`request_link` — returns a
|
||||
sentinel that the route converts into an HTTP 429.
|
||||
- Consume is atomic: an ``UPDATE ... WHERE token_hash=? AND used_at IS
|
||||
NULL AND expires_at > ?`` with a ``rowcount == 1`` guard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.models.entities import Session, User
|
||||
from app.models.mappers import row_to_user
|
||||
from app.services.audit import AuditService
|
||||
from app.services.email import EmailService
|
||||
from app.services.sessions import SessionService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Per-email rate limit: at most 5 tokens issued in a 15-minute window.
|
||||
# This is the DB-backed layer that survives process restarts; the
|
||||
# SlowAPI IP-level decorator on the route is the first line of defense.
|
||||
_PER_EMAIL_WINDOW_MIN: int = 15
|
||||
_PER_EMAIL_MAX: int = 5
|
||||
|
||||
|
||||
def _sha256(raw: str) -> str:
|
||||
"""Return the hex SHA-256 of a raw token.
|
||||
|
||||
SHA-256 for one-way hashing of high-entropy tokens is acceptable
|
||||
(see docs/security.md CWE-327). These tokens are already ≥256 bits
|
||||
from ``secrets.token_urlsafe(32)`` so a KDF is unnecessary.
|
||||
"""
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class RateLimitedError(Exception):
|
||||
"""Raised when a per-email rate limit trips inside the service.
|
||||
|
||||
Surfaced to the route layer so it can emit a 429 + render the
|
||||
``rate_limited.html`` template. Not used for IP-level limits —
|
||||
those are handled by SlowAPI's own exception.
|
||||
"""
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Request-link / consume orchestration for admin auth."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
email: EmailService,
|
||||
sessions: SessionService,
|
||||
audit: AuditService,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""Store collaborators by reference."""
|
||||
self._engine: Engine = engine
|
||||
self._email: EmailService = email
|
||||
self._sessions: SessionService = sessions
|
||||
self._audit: AuditService = audit
|
||||
self._settings: Settings = settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# request_link
|
||||
# ------------------------------------------------------------------
|
||||
def request_link(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
) -> None:
|
||||
"""Handle POST /admin/login for a validated email.
|
||||
|
||||
Behavior
|
||||
--------
|
||||
1. Lowercase the email (allowlist comparison is case-insensitive).
|
||||
2. Check the admin allowlist.
|
||||
3. If NOT allowlisted: audit ``link_requested`` with
|
||||
``allowlisted=false`` and return. No token row, no email.
|
||||
4. If allowlisted: run the DB-side per-email rate-limit check.
|
||||
On trip: audit ``rate_limited`` and raise
|
||||
:class:`RateLimitedError`.
|
||||
5. Otherwise: insert a fresh token row (hash at rest), audit
|
||||
``link_requested`` with ``allowlisted=true``, and call
|
||||
:meth:`EmailService.send_magic_link`.
|
||||
|
||||
Callers MUST render the same "check your inbox" page regardless
|
||||
of the allowlist branch — see the admin route for the
|
||||
anti-enumeration contract.
|
||||
"""
|
||||
email = email.strip().lower()
|
||||
allowlisted = email in self._settings.admin_emails_list
|
||||
|
||||
# Always audit the request — this is the trail that catches
|
||||
# non-allowlisted attempts without leaking that info back to
|
||||
# the submitter.
|
||||
if not allowlisted:
|
||||
self._audit.record(
|
||||
"link_requested",
|
||||
email=email,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"allowlisted": False},
|
||||
)
|
||||
return
|
||||
|
||||
# DB-side per-email rate limit. We run it AFTER the allowlist
|
||||
# check so non-allowlisted spam doesn't cause extra queries.
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(
|
||||
minutes=_PER_EMAIL_WINDOW_MIN
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) AS c FROM magic_link_tokens"
|
||||
" WHERE email = :email AND created_at > :cutoff"
|
||||
),
|
||||
{"email": email, "cutoff": cutoff.isoformat()},
|
||||
).mappings().first()
|
||||
recent_count = int(row["c"]) if row is not None else 0
|
||||
if recent_count >= _PER_EMAIL_MAX:
|
||||
self._audit.record(
|
||||
"rate_limited",
|
||||
email=email,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"scope": "email", "endpoint": "/admin/login"},
|
||||
)
|
||||
raise RateLimitedError("per-email token limit reached")
|
||||
|
||||
# Mint the token — raw lives only in memory + email URL.
|
||||
raw = secrets.token_urlsafe(32)
|
||||
token_hash = _sha256(raw)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(
|
||||
minutes=self._settings.magic_link_ttl_min
|
||||
)
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO magic_link_tokens"
|
||||
" (email, token_hash, created_at, expires_at,"
|
||||
" request_ip)"
|
||||
" VALUES (:email, :token_hash, :created_at,"
|
||||
" :expires_at, :request_ip)"
|
||||
),
|
||||
{
|
||||
"email": email,
|
||||
"token_hash": token_hash,
|
||||
"created_at": now.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"request_ip": ip or "",
|
||||
},
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
"link_requested",
|
||||
email=email,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"allowlisted": True},
|
||||
)
|
||||
|
||||
# Build the magic-link URL. Using a path param (not a query
|
||||
# string) keeps the raw token out of many access-log formats.
|
||||
base = self._settings.public_base_url.rstrip("/")
|
||||
url = f"{base}/admin/auth/consume/{raw}"
|
||||
display_name = email.split("@", 1)[0].title() or email
|
||||
|
||||
# EmailService never raises in dev; in prod it may log an
|
||||
# exception but still return. Either way the request-path
|
||||
# response is identical.
|
||||
self._email.send_magic_link(
|
||||
to=email,
|
||||
url=url,
|
||||
display_name=display_name,
|
||||
ttl_min=self._settings.magic_link_ttl_min,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# consume
|
||||
# ------------------------------------------------------------------
|
||||
def consume(
|
||||
self,
|
||||
*,
|
||||
raw_token: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
) -> Optional[tuple[User, Session, str]]:
|
||||
"""Consume a magic-link token and issue a session.
|
||||
|
||||
Returns ``(user, session, cookie_value)`` on success, ``None``
|
||||
on any invalid / expired / already-used / unknown token (the
|
||||
caller should render the generic failure page with HTTP 400).
|
||||
|
||||
Concurrency safety: the UPDATE statement's WHERE clause is the
|
||||
atomic guard — if two requests race with the same token, only
|
||||
one will get ``rowcount == 1``. The loser is treated as a
|
||||
replay.
|
||||
"""
|
||||
if not raw_token:
|
||||
# Audit runs in its own transaction (see notes below) so we
|
||||
# can record the event without opening a write lock we'd
|
||||
# then try to re-enter from this service.
|
||||
self._audit.record(
|
||||
"consume_failed",
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"reason": "not_found"},
|
||||
)
|
||||
return None
|
||||
|
||||
token_hash = _sha256(raw_token)
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = now.isoformat()
|
||||
|
||||
# ``engine.begin()`` holds a write lock on SQLite for its whole
|
||||
# scope. AuditService opens its OWN transaction and would be
|
||||
# blocked behind this one — so we do not call ``self._audit``
|
||||
# until AFTER this block exits. Track the outcome locally.
|
||||
reloaded = None
|
||||
failure_reason: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
user_id: Optional[int] = None
|
||||
|
||||
# Atomic single-use: only mark the row used if it's still
|
||||
# valid. rowcount tells us whether we were the one to claim it.
|
||||
with self._engine.begin() as conn:
|
||||
update_result = conn.execute(
|
||||
text(
|
||||
"UPDATE magic_link_tokens"
|
||||
" SET used_at = :now"
|
||||
" WHERE token_hash = :h"
|
||||
" AND used_at IS NULL"
|
||||
" AND expires_at > :now"
|
||||
),
|
||||
{"now": now_iso, "h": token_hash},
|
||||
)
|
||||
|
||||
if update_result.rowcount != 1:
|
||||
# Distinguish expired / used / not_found for the audit
|
||||
# trail (not for the client response). Run an extra
|
||||
# SELECT only on the failure path so the happy path
|
||||
# stays a single write.
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT expires_at, used_at FROM magic_link_tokens"
|
||||
" WHERE token_hash = :h LIMIT 1"
|
||||
),
|
||||
{"h": token_hash},
|
||||
).mappings().first()
|
||||
|
||||
if row is None:
|
||||
failure_reason = "not_found"
|
||||
elif row["used_at"] is not None:
|
||||
failure_reason = "used"
|
||||
else:
|
||||
failure_reason = "expired"
|
||||
else:
|
||||
# Token claimed. Look up the email so we can upsert the user.
|
||||
token_row = conn.execute(
|
||||
text(
|
||||
"SELECT email FROM magic_link_tokens"
|
||||
" WHERE token_hash = :h LIMIT 1"
|
||||
),
|
||||
{"h": token_hash},
|
||||
).mappings().first()
|
||||
|
||||
# Should always exist — the UPDATE just wrote to it.
|
||||
if token_row is None: # pragma: no cover — impossible path
|
||||
failure_reason = "not_found"
|
||||
else:
|
||||
email = token_row["email"]
|
||||
|
||||
# Upsert user. Existing row → bump last_login_at and
|
||||
# re-activate; missing row → insert with titlecased
|
||||
# local part as display_name and active=1.
|
||||
user_row = conn.execute(
|
||||
text("SELECT id FROM users WHERE email = :e"),
|
||||
{"e": email},
|
||||
).mappings().first()
|
||||
|
||||
if user_row is None:
|
||||
display_name = email.split("@", 1)[0].title() or email
|
||||
insert_result = conn.execute(
|
||||
text(
|
||||
"INSERT INTO users"
|
||||
" (email, display_name, created_at,"
|
||||
" last_login_at, active)"
|
||||
" VALUES (:email, :display_name, :created_at,"
|
||||
" :last_login_at, 1)"
|
||||
),
|
||||
{
|
||||
"email": email,
|
||||
"display_name": display_name,
|
||||
"created_at": now_iso,
|
||||
"last_login_at": now_iso,
|
||||
},
|
||||
)
|
||||
user_id = int(insert_result.lastrowid) # type: ignore[arg-type]
|
||||
else:
|
||||
user_id = int(user_row["id"])
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE users"
|
||||
" SET last_login_at = :now, active = 1"
|
||||
" WHERE id = :id"
|
||||
),
|
||||
{"now": now_iso, "id": user_id},
|
||||
)
|
||||
|
||||
# Reload the user row so we return a fully-populated
|
||||
# entity.
|
||||
reloaded = conn.execute(
|
||||
text(
|
||||
"SELECT id, email, display_name, created_at,"
|
||||
" last_login_at, active"
|
||||
" FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": user_id},
|
||||
).mappings().first()
|
||||
|
||||
# --- Post-transaction: audit + session create --------------------
|
||||
# Running these AFTER the transaction closes avoids the SQLite
|
||||
# single-writer deadlock that would happen if AuditService tried
|
||||
# to open a new write connection from inside the block above.
|
||||
if failure_reason is not None:
|
||||
self._audit.record(
|
||||
"consume_failed",
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"reason": failure_reason},
|
||||
)
|
||||
return None
|
||||
|
||||
if reloaded is None: # pragma: no cover — impossible path
|
||||
return None
|
||||
user = row_to_user(reloaded)
|
||||
|
||||
# Session creation runs in its own transaction now that the
|
||||
# consume write lock has been released.
|
||||
session, cookie_value = self._sessions.create(
|
||||
user_id=user.id, ip=ip, user_agent=user_agent
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
"link_consumed",
|
||||
email=email,
|
||||
user_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
detail={"user_id": user.id},
|
||||
)
|
||||
self._audit.record(
|
||||
"session_created",
|
||||
email=email,
|
||||
user_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
# last 6 hex chars of the stored hash — enough to correlate,
|
||||
# not enough to reverse.
|
||||
detail={
|
||||
"session_id": session.token_hash[-6:],
|
||||
"user_id": user.id,
|
||||
},
|
||||
)
|
||||
|
||||
return user, session, cookie_value
|
||||
88
app/services/cache.py
Normal file
88
app/services/cache.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""In-process, generic TTL cache.
|
||||
|
||||
Small, typed, and deliberately boring. Used by :mod:`app.services.posts`
|
||||
and :mod:`app.services.pages` to sit in front of the hottest queries
|
||||
(published-posts list, page-by-slug); a 60 s default TTL keeps the
|
||||
site's three-digit daily requests out of the SQLite query path without
|
||||
any cross-process coordination.
|
||||
|
||||
Not thread-safe in the strict sense — Python's GIL makes the dict
|
||||
operations atomic at CPython bytecode granularity, and worst case a
|
||||
concurrent writer causes a benign duplicate DB read. That is
|
||||
acceptable at this scale; if the site ever grows teeth we can revisit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Generic, Hashable, Optional, TypeVar
|
||||
|
||||
# TypeVar bound to ``Hashable`` so callers cannot accidentally key by a
|
||||
# mutable collection (which would later look up with a different hash
|
||||
# after mutation and silently miss the cache).
|
||||
K = TypeVar("K", bound=Hashable)
|
||||
V = TypeVar("V")
|
||||
|
||||
|
||||
class TTLCache(Generic[K, V]):
|
||||
"""Tiny TTL-based dict-style cache.
|
||||
|
||||
Entries expire ``ttl_seconds`` after insertion. Expired entries
|
||||
are dropped lazily on access — there is no background sweep, and
|
||||
the cache is not bounded in size. For our workload (at most a
|
||||
few dozen keys per instance) this is fine.
|
||||
|
||||
Two operations are public:
|
||||
|
||||
- :meth:`get` returns the cached value or ``None``.
|
||||
- :meth:`set` stores a value with an expiry.
|
||||
- :meth:`invalidate_all` clears every entry; used by admin-write
|
||||
paths in Phase 4.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: float = 60.0) -> None:
|
||||
"""Construct an empty cache.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ttl_seconds:
|
||||
Time-to-live for every entry, in seconds. 60 s matches the
|
||||
"Caching Strategy" section of ``docs/ROADMAP.md``.
|
||||
"""
|
||||
if ttl_seconds <= 0:
|
||||
# Defensive: a zero/negative TTL would mean every write
|
||||
# instantly expires, which almost always indicates a bug.
|
||||
raise ValueError("ttl_seconds must be positive")
|
||||
self._ttl: float = float(ttl_seconds)
|
||||
# Stored as (expiry_monotonic_ts, value). Using
|
||||
# ``time.monotonic`` avoids issues if the wall clock jumps.
|
||||
self._store: dict[K, tuple[float, V]] = {}
|
||||
|
||||
def get(self, key: K) -> Optional[V]:
|
||||
"""Return the cached value for ``key`` or ``None`` if absent/expired.
|
||||
|
||||
Expired entries are deleted as a side effect of the lookup so
|
||||
the store doesn't grow unboundedly with stale data in
|
||||
long-running processes.
|
||||
"""
|
||||
entry = self._store.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
expiry, value = entry
|
||||
if time.monotonic() >= expiry:
|
||||
# Expired — drop lazily and report miss.
|
||||
self._store.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
def set(self, key: K, value: V) -> None:
|
||||
"""Store ``value`` under ``key`` with the configured TTL."""
|
||||
self._store[key] = (time.monotonic() + self._ttl, value)
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Drop every cached entry.
|
||||
|
||||
Called by the Phase 4 admin write path so readers see the new
|
||||
content on the very next request, not up to 60 s later.
|
||||
"""
|
||||
self._store.clear()
|
||||
167
app/services/csrf.py
Normal file
167
app/services/csrf.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""CSRF double-submit cookie service.
|
||||
|
||||
Protects admin-write endpoints against cross-site request forgery by
|
||||
requiring a signed token to be submitted BOTH as a cookie and as a
|
||||
form field / header. An attacker can forge requests but cannot read
|
||||
the cookie (SameSite=Lax blocks cross-site automatic cookie sending,
|
||||
and even if the browser sent it, cross-site JS still cannot read
|
||||
cookies on this origin). Matching the submitted value to the cookie
|
||||
value then proves the request originated from our own pages.
|
||||
|
||||
Design
|
||||
------
|
||||
- The cookie stores a signed opaque nonce. Signing prevents a malicious
|
||||
ad iframe (or any JS on a non-origin page) from producing a cookie
|
||||
value that would later match a crafted form submission.
|
||||
- The nonce itself is 256-bit (``secrets.token_urlsafe(32)``), generated
|
||||
per-browser on first admin GET and reused for the session. Rotating
|
||||
per request would invalidate any still-open admin tab on every nav,
|
||||
which the small-scale admin UX cannot tolerate.
|
||||
- Verification unsigns the submitted token and compares the raw nonce
|
||||
to the raw nonce unsigned from the cookie using :func:`hmac.compare_digest`
|
||||
(constant-time) to foreclose timing side channels.
|
||||
- The cookie is ``HttpOnly=False`` so the minimal admin JS (live
|
||||
preview, upload) can read it to set the ``X-CSRF-Token`` header on
|
||||
fetch requests. This is the conventional double-submit cookie setup
|
||||
— the XSS risk is already mitigated by the Markdown sanitizer and
|
||||
the session cookie remains HttpOnly.
|
||||
|
||||
The service is a small collaborator: it does not know about FastAPI
|
||||
routes, request objects, or templates. The :mod:`app.dependencies.csrf`
|
||||
module wraps the verify call in a FastAPI dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Cookie name kept here as a module-level constant so routes,
|
||||
# dependencies, and templates stay in sync.
|
||||
CSRF_COOKIE_NAME: str = "cb_csrf"
|
||||
|
||||
# Default max age — matches the session TTL ceiling. A valid admin
|
||||
# session already enforces the 30-day cap; the CSRF cookie merely
|
||||
# piggybacks.
|
||||
_DEFAULT_MAX_AGE_SEC: int = 30 * 86400
|
||||
|
||||
|
||||
class CSRFService:
|
||||
"""Issue and verify double-submit CSRF tokens.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
signer:
|
||||
Pre-built :class:`itsdangerous.URLSafeTimedSerializer`. The
|
||||
caller is responsible for constructing it with
|
||||
``salt="csrf"`` so a session-cookie token can never be
|
||||
replayed as a CSRF token and vice-versa.
|
||||
production:
|
||||
When True, the issued cookie carries the ``Secure`` flag. Dev
|
||||
(plain-HTTP 127.0.0.1) needs it off or the browser drops the
|
||||
cookie entirely.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
signer: URLSafeTimedSerializer,
|
||||
*,
|
||||
production: bool = False,
|
||||
max_age_sec: int = _DEFAULT_MAX_AGE_SEC,
|
||||
) -> None:
|
||||
"""Store the signer and cookie-policy flags by reference."""
|
||||
self._signer: URLSafeTimedSerializer = signer
|
||||
self._production: bool = production
|
||||
self._max_age_sec: int = int(max_age_sec)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Issue
|
||||
# ------------------------------------------------------------------
|
||||
def issue(self, existing_cookie: Optional[str] = None) -> tuple[str, str]:
|
||||
"""Return ``(token, cookie_value)`` — reuse or mint as appropriate.
|
||||
|
||||
If ``existing_cookie`` is a valid signed nonce (still within
|
||||
TTL), we reuse the underlying nonce so the same token keeps
|
||||
working across GET / POST cycles in the same admin session.
|
||||
Otherwise we mint a fresh nonce.
|
||||
|
||||
The cookie value and the form/header token value are the SAME
|
||||
signed string — this is the "double submit" contract. The
|
||||
verify path re-signs nothing; it just compares the unsigned
|
||||
raw nonces.
|
||||
"""
|
||||
raw = self._unsign_or_none(existing_cookie)
|
||||
if raw is None:
|
||||
raw = secrets.token_urlsafe(32)
|
||||
signed = self._signer.dumps(raw)
|
||||
# Token and cookie are both the signed string. Callers are free
|
||||
# to submit either in a form field OR a header; verify accepts
|
||||
# both shapes.
|
||||
return signed, signed
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Verify
|
||||
# ------------------------------------------------------------------
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
cookie_value: Optional[str],
|
||||
submitted: Optional[str],
|
||||
) -> bool:
|
||||
"""Return True iff cookie + submitted token unseal to the same nonce.
|
||||
|
||||
Both strings must unsign cleanly; a bad signature (tampered or
|
||||
wrong-key) on either side fails closed. Constant-time compare
|
||||
on the raw nonces prevents timing leaks of the nonce bytes.
|
||||
"""
|
||||
if not cookie_value or not submitted:
|
||||
return False
|
||||
cookie_raw = self._unsign_or_none(cookie_value)
|
||||
submitted_raw = self._unsign_or_none(submitted)
|
||||
if cookie_raw is None or submitted_raw is None:
|
||||
return False
|
||||
return hmac.compare_digest(cookie_raw, submitted_raw)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cookie helpers
|
||||
# ------------------------------------------------------------------
|
||||
def cookie_params(self) -> dict:
|
||||
"""Return kwargs for ``response.set_cookie`` matching our CSRF policy.
|
||||
|
||||
Differences from :meth:`SessionService.cookie_params`:
|
||||
- ``httponly=False`` so the admin JS can read it for fetch
|
||||
requests.
|
||||
- Same ``SameSite=Lax`` + ``Secure=<prod>`` otherwise.
|
||||
"""
|
||||
return {
|
||||
"key": CSRF_COOKIE_NAME,
|
||||
"httponly": False,
|
||||
"samesite": "lax",
|
||||
"secure": self._production,
|
||||
"max_age": self._max_age_sec,
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
def _unsign_or_none(self, value: Optional[str]) -> Optional[str]:
|
||||
"""Return the raw nonce, or ``None`` on any signature failure.
|
||||
|
||||
Centralizes the "fail closed" contract; never raises to callers.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return self._signer.loads(value, max_age=self._max_age_sec)
|
||||
except BadSignature:
|
||||
_log.info("csrf_bad_signature")
|
||||
return None
|
||||
141
app/services/email.py
Normal file
141
app/services/email.py
Normal 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
125
app/services/markdown.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Markdown rendering with a strict sanitization allowlist.
|
||||
|
||||
CWE-79 mitigation: user-authored Markdown is first rendered to HTML by
|
||||
``markdown-it-py`` (commonmark profile + tables only, no raw-HTML pass
|
||||
through), then the resulting HTML is filtered by ``bleach`` against an
|
||||
explicit tag / attribute / protocol allowlist. Anything not on the
|
||||
list is stripped — never escaped — so the stored ``body_html_cached``
|
||||
is always safe to render inside an ``autoescape=False`` Jinja block.
|
||||
|
||||
The pipeline runs both on admin writes (Phase 4) and at seed time
|
||||
(Phase 2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
import bleach
|
||||
from markdown_it import MarkdownIt
|
||||
|
||||
# --- Sanitization allowlist ------------------------------------------------
|
||||
# Kept at module scope as frozenset / mappingproxy-esque constants so
|
||||
# tests can assert against them and downstream callers cannot mutate by
|
||||
# accident. Do not widen without a security review; in particular:
|
||||
#
|
||||
# - No ``style`` or ``class`` attributes (CSS injection / theme attack
|
||||
# surface for future admin UIs).
|
||||
# - No ``script``, ``iframe``, ``object``, ``embed``, ``form``, etc.
|
||||
# - No ``data:`` / ``javascript:`` protocols.
|
||||
_ALLOWED_TAGS: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"em",
|
||||
"a",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"blockquote",
|
||||
"code",
|
||||
"pre",
|
||||
"img",
|
||||
"hr",
|
||||
}
|
||||
)
|
||||
|
||||
_ALLOWED_ATTRS: Final[dict[str, list[str]]] = {
|
||||
"a": ["href", "title", "rel"],
|
||||
"img": ["src", "alt", "title", "width", "height"],
|
||||
}
|
||||
|
||||
_ALLOWED_PROTOCOLS: Final[frozenset[str]] = frozenset(
|
||||
{"http", "https", "mailto"}
|
||||
)
|
||||
|
||||
|
||||
class MarkdownService:
|
||||
"""Render Markdown to HTML, then sanitize against the allowlist.
|
||||
|
||||
One ``MarkdownIt`` instance per service instance — creating these
|
||||
is cheap but non-trivial, so we reuse. The service is stateless
|
||||
aside from that configuration; ``render`` is safe to call
|
||||
concurrently.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Configure the Markdown parser.
|
||||
|
||||
- ``commonmark`` preset: conservative, no raw HTML pass
|
||||
through by default. We explicitly do NOT call
|
||||
``.enable("html_inline")`` or ``.enable("html_block")``;
|
||||
raw HTML in the source will be rendered as escaped text,
|
||||
which is the safe failure mode.
|
||||
- Tables are intentionally not enabled: the bleach allowlist
|
||||
does not include ``<table>``, so enabling the plugin would
|
||||
just produce content stripped of its tags. If we ever want
|
||||
tables, both sides (parser + allowlist) need widening
|
||||
together.
|
||||
"""
|
||||
self._md: MarkdownIt = MarkdownIt("commonmark")
|
||||
|
||||
def render(self, md: str) -> str:
|
||||
"""Render ``md`` to sanitized HTML.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
md:
|
||||
Markdown source, typically from an admin edit form or a
|
||||
seed file. Treated as untrusted.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
HTML safe to render with Jinja autoescape disabled. The
|
||||
output contains only tags / attributes / protocols from
|
||||
the module-level allowlists; anything else is stripped
|
||||
(``strip=True``) rather than escaped.
|
||||
"""
|
||||
raw_html = self._md.render(md)
|
||||
# ``strip=True`` removes disallowed tags entirely (drops the
|
||||
# tag but keeps text content). This is a deliberate choice
|
||||
# over ``strip=False``, which would escape disallowed tags
|
||||
# into literal text — ugly for users.
|
||||
return bleach.clean(
|
||||
raw_html,
|
||||
tags=_ALLOWED_TAGS,
|
||||
attributes=_ALLOWED_ATTRS,
|
||||
protocols=_ALLOWED_PROTOCOLS,
|
||||
strip=True,
|
||||
)
|
||||
|
||||
|
||||
def render_markdown_safe(md: str) -> str:
|
||||
"""Module-level convenience for one-off rendering.
|
||||
|
||||
Creates a throwaway :class:`MarkdownService` — fine for rare
|
||||
callers (tests, seed). Hot paths should construct and cache an
|
||||
instance.
|
||||
"""
|
||||
return MarkdownService().render(md)
|
||||
323
app/services/media.py
Normal file
323
app/services/media.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""Image upload pipeline: validate → re-encode → store → record.
|
||||
|
||||
Every admin image upload passes through this service. The contract is
|
||||
strict on purpose — the site serves user-editable HTML (via the
|
||||
sanitizer) plus the bytes that flow through here, so anything we miss
|
||||
becomes XSS / RCE surface area.
|
||||
|
||||
Steps in :meth:`MediaService.save_upload`:
|
||||
|
||||
1. **Size cap** — reject anything over 8 MB at the bytes level
|
||||
(before decoding). We read the full buffer so we can hash and
|
||||
re-encode it; streaming would complicate Pillow's decode path and
|
||||
upload volumes are tiny.
|
||||
2. **Magic-byte check** — :mod:`python-magic` inspects the first
|
||||
2048 bytes and yields a MIME type. Anything not in our allowlist
|
||||
(``image/jpeg``, ``image/png``, ``image/webp``) is rejected.
|
||||
Notably, ``image/gif`` is NOT allowed — animated GIFs have a long
|
||||
history of ambiguous / abuse-friendly encodings.
|
||||
3. **Pillow decode** — open via :func:`PIL.Image.open` on a
|
||||
:class:`io.BytesIO` wrapper. Call ``.verify()`` on a dedicated copy
|
||||
(it consumes the stream), then re-open for the actual encode path.
|
||||
Reject anything larger than 10000 px per side as a defense against
|
||||
decompression bombs.
|
||||
4. **Re-encode to JPEG** — always JPEG. Strip metadata by reopening
|
||||
into a clean :class:`PIL.Image.Image`; flatten alpha on a white
|
||||
background so transparent PNG / WebP images don't render as black.
|
||||
5. **Store** — write to ``<media_root>/<yyyy>/<mm>/<random>.jpg`` where
|
||||
the random component is :func:`secrets.token_urlsafe(16)`. The
|
||||
client-supplied filename is kept only in the DB row's
|
||||
``original_filename`` for display; it is NEVER used to build a
|
||||
filesystem path.
|
||||
6. **DB row** — insert a :class:`Media` row. Return the loaded
|
||||
dataclass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Final, Optional
|
||||
|
||||
import structlog
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import Media
|
||||
from app.models.mappers import row_to_media
|
||||
from app.services.audit import AuditService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Upper bound on the raw upload bytes. 8 MB matches the project
|
||||
# security constraint; larger images are almost certainly a mistake
|
||||
# for a brochure-site blog.
|
||||
MAX_UPLOAD_BYTES: Final[int] = 8 * 1024 * 1024
|
||||
|
||||
# Maximum decoded dimension — reject any image wider or taller than
|
||||
# this as a lightweight defense against decompression bombs.
|
||||
MAX_PIXEL_DIMENSION: Final[int] = 10_000
|
||||
|
||||
# MIME types accepted from the magic-byte sniff. We always re-encode
|
||||
# to JPEG regardless of input.
|
||||
_ACCEPTED_MIME: Final[frozenset[str]] = frozenset(
|
||||
{"image/jpeg", "image/png", "image/webp"}
|
||||
)
|
||||
|
||||
# Output quality for Pillow's JPEG encoder. 85 is a widely-used
|
||||
# sweet spot for photograph-like content.
|
||||
_JPEG_QUALITY: Final[int] = 85
|
||||
|
||||
|
||||
class MediaRejectedError(Exception):
|
||||
"""Raised when an upload fails any validation step.
|
||||
|
||||
The message is user-facing (shown in the admin editor) — keep it
|
||||
generic and free of implementation detail.
|
||||
"""
|
||||
|
||||
|
||||
class MediaService:
|
||||
"""Validate and store admin-uploaded images.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
Shared SQLAlchemy engine.
|
||||
media_root:
|
||||
Filesystem directory under which uploads live (the
|
||||
``<yyyy>/<mm>/`` partition is appended). Relative paths are
|
||||
resolved against the process cwd, matching how the FastAPI
|
||||
StaticFiles mount is configured.
|
||||
public_prefix:
|
||||
URL prefix where the media root is mounted for public serving.
|
||||
Defaults to ``/media`` so the Markdown that the admin inserts
|
||||
after a drag-drop upload uses a path the public site can
|
||||
actually reach.
|
||||
audit:
|
||||
:class:`AuditService` for the ``media_uploaded`` event.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
media_root: str,
|
||||
audit: AuditService,
|
||||
*,
|
||||
public_prefix: str = "/media",
|
||||
) -> None:
|
||||
self._engine: Engine = engine
|
||||
self._media_root: Path = Path(media_root)
|
||||
# Normalize to no trailing slash — we always join with "/<yyyy>/..."
|
||||
self._public_prefix: str = "/" + public_prefix.strip("/")
|
||||
self._audit: AuditService = audit
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# save_upload
|
||||
# ------------------------------------------------------------------
|
||||
def save_upload(
|
||||
self,
|
||||
*,
|
||||
original_filename: str,
|
||||
data: bytes,
|
||||
uploaded_by: int,
|
||||
alt_text: str = "",
|
||||
) -> Media:
|
||||
"""Validate + re-encode + persist a new media upload.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
original_filename:
|
||||
The filename the client submitted. Stored in the DB row
|
||||
for display only; NEVER used to build a filesystem path.
|
||||
data:
|
||||
Raw request body. Must be at most :data:`MAX_UPLOAD_BYTES`.
|
||||
uploaded_by:
|
||||
:class:`User` id of the authenticated admin performing the
|
||||
upload.
|
||||
alt_text:
|
||||
Optional alt text. Empty is allowed — admin can set it
|
||||
later by hand-editing the Markdown.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Media
|
||||
Fully-populated :class:`Media` dataclass.
|
||||
|
||||
Raises
|
||||
------
|
||||
MediaRejectedError
|
||||
When any validation step fails (size, MIME, decode).
|
||||
"""
|
||||
# 1. Size cap — cheap, do first.
|
||||
if len(data) == 0:
|
||||
raise MediaRejectedError("Empty upload.")
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise MediaRejectedError(
|
||||
"Upload exceeds the 8 MB limit."
|
||||
)
|
||||
|
||||
# 2. Magic-byte sniff.
|
||||
sniffed_mime = _sniff_mime(data)
|
||||
if sniffed_mime not in _ACCEPTED_MIME:
|
||||
raise MediaRejectedError(
|
||||
f"Unsupported image type ({sniffed_mime})."
|
||||
)
|
||||
|
||||
# 3. Pillow verify on a fresh BytesIO (verify consumes the
|
||||
# stream). If this raises we swallow and translate to a generic
|
||||
# rejection so we never echo the Pillow error string back to
|
||||
# the admin UI.
|
||||
try:
|
||||
Image.open(io.BytesIO(data)).verify()
|
||||
except (UnidentifiedImageError, Exception): # noqa: BLE001
|
||||
raise MediaRejectedError("Image could not be decoded.")
|
||||
|
||||
# 4. Re-open for the actual encode.
|
||||
try:
|
||||
image = Image.open(io.BytesIO(data))
|
||||
# Load here so we catch truncated / corrupt payloads that
|
||||
# verify() misses. Without load() the decode is lazy.
|
||||
image.load()
|
||||
except (UnidentifiedImageError, Exception): # noqa: BLE001
|
||||
raise MediaRejectedError("Image could not be decoded.")
|
||||
|
||||
width, height = image.size
|
||||
if width <= 0 or height <= 0:
|
||||
raise MediaRejectedError("Image has zero dimension.")
|
||||
if width > MAX_PIXEL_DIMENSION or height > MAX_PIXEL_DIMENSION:
|
||||
raise MediaRejectedError(
|
||||
"Image dimensions exceed the maximum allowed."
|
||||
)
|
||||
|
||||
# Flatten transparency onto a white background when present.
|
||||
# Pillow uses "RGBA", "LA", and "P" (palette, possibly with
|
||||
# transparency) as modes that carry alpha-like semantics. We
|
||||
# always convert to "RGB" before encoding as JPEG.
|
||||
if image.mode in ("RGBA", "LA") or (
|
||||
image.mode == "P" and "transparency" in image.info
|
||||
):
|
||||
# Convert through RGBA so alpha-compositing is well-defined,
|
||||
# then flatten onto a white RGB background.
|
||||
rgba = image.convert("RGBA")
|
||||
background = Image.new("RGB", rgba.size, (255, 255, 255))
|
||||
background.paste(rgba, mask=rgba.split()[-1])
|
||||
image_out = background
|
||||
elif image.mode != "RGB":
|
||||
image_out = image.convert("RGB")
|
||||
else:
|
||||
image_out = image
|
||||
|
||||
# 5. Randomize the storage name and partition by month.
|
||||
now = datetime.now(timezone.utc)
|
||||
partition = f"{now:%Y}/{now:%m}"
|
||||
random_name = f"{secrets.token_urlsafe(16)}.jpg"
|
||||
target_dir = self._media_root / partition
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_path = target_dir / random_name
|
||||
|
||||
# Re-encode to JPEG with the metadata stripped (a fresh
|
||||
# re-save removes any EXIF / color profile the source had).
|
||||
image_out.save(
|
||||
target_path,
|
||||
format="JPEG",
|
||||
quality=_JPEG_QUALITY,
|
||||
optimize=True,
|
||||
)
|
||||
|
||||
final_bytes = target_path.stat().st_size
|
||||
stored_path = str(target_path)
|
||||
|
||||
# 6. DB row.
|
||||
now_iso = now.isoformat()
|
||||
with self._engine.begin() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"INSERT INTO media"
|
||||
" (filename, original_filename, content_type,"
|
||||
" size_bytes, stored_path, alt_text, uploaded_by,"
|
||||
" uploaded_at)"
|
||||
" VALUES (:filename, :original_filename, :content_type,"
|
||||
" :size_bytes, :stored_path, :alt_text, :uploaded_by,"
|
||||
" :uploaded_at)"
|
||||
),
|
||||
{
|
||||
"filename": random_name,
|
||||
"original_filename": original_filename or random_name,
|
||||
"content_type": "image/jpeg",
|
||||
"size_bytes": int(final_bytes),
|
||||
"stored_path": stored_path,
|
||||
"alt_text": alt_text or "",
|
||||
"uploaded_by": int(uploaded_by),
|
||||
"uploaded_at": now_iso,
|
||||
},
|
||||
)
|
||||
new_id = int(result.lastrowid) # type: ignore[arg-type]
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, filename, original_filename, content_type,"
|
||||
" size_bytes, stored_path, alt_text, uploaded_by,"
|
||||
" uploaded_at"
|
||||
" FROM media WHERE id = :id"
|
||||
),
|
||||
{"id": new_id},
|
||||
).mappings().first()
|
||||
|
||||
if row is None: # pragma: no cover — just inserted
|
||||
raise RuntimeError("failed to reload just-inserted media row")
|
||||
|
||||
media = row_to_media(row)
|
||||
|
||||
self._audit.record(
|
||||
"media_uploaded",
|
||||
user_id=uploaded_by,
|
||||
detail={
|
||||
"media_id": media.id,
|
||||
"filename": media.filename,
|
||||
"size_bytes": media.size_bytes,
|
||||
"original_mime": sniffed_mime,
|
||||
},
|
||||
)
|
||||
return media
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# URL helpers
|
||||
# ------------------------------------------------------------------
|
||||
def public_url(self, media: Media) -> str:
|
||||
"""Return the URL the public site uses to fetch ``media``.
|
||||
|
||||
Built from the configured ``public_prefix`` + the partition
|
||||
under ``media_root``. A stored path outside the media root
|
||||
(should never happen — we always write under it) falls back
|
||||
to the partition-less prefix to avoid leaking filesystem
|
||||
paths.
|
||||
"""
|
||||
try:
|
||||
rel = Path(media.stored_path).resolve().relative_to(
|
||||
self._media_root.resolve()
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
return f"{self._public_prefix}/{media.filename}"
|
||||
return f"{self._public_prefix}/{rel.as_posix()}"
|
||||
|
||||
|
||||
def _sniff_mime(data: bytes) -> str:
|
||||
"""Return the MIME type of ``data`` according to python-magic.
|
||||
|
||||
Wrapped so tests that monkeypatch can reach a single seam, and so
|
||||
the import of :mod:`magic` stays local (the module has a
|
||||
filesystem dependency on libmagic that should not block app
|
||||
import).
|
||||
"""
|
||||
# Import is module-level normally; keep here to avoid any import
|
||||
# order weirdness if libmagic is missing in exotic environments.
|
||||
import magic
|
||||
|
||||
# First 2 KB is well beyond what any image header uses, and
|
||||
# streaming beyond that buys nothing for MIME sniffing.
|
||||
head = data[:2048]
|
||||
return magic.from_buffer(head, mime=True)
|
||||
101
app/services/pages.py
Normal file
101
app/services/pages.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Static-page read service (About, etc.).
|
||||
|
||||
Wraps the ``pages`` table with a 60 s TTL cache keyed by slug. Admin
|
||||
writes in Phase 4 invalidate via :meth:`PageService.invalidate_all`.
|
||||
|
||||
Public contract:
|
||||
|
||||
- :meth:`PageService.get_by_slug` returns a :class:`Page` or ``None``.
|
||||
- :meth:`PageService.invalidate_all` clears the TTL cache.
|
||||
- :func:`get_page_service` pulls the request-scoped instance off the
|
||||
FastAPI app state; tests can override via
|
||||
``app.dependency_overrides``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import Page
|
||||
from app.models.mappers import row_to_page
|
||||
from app.services.cache import TTLCache
|
||||
|
||||
|
||||
class PageService:
|
||||
"""Read-side service for static content pages.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
Shared SQLAlchemy engine. Stored by reference; the service
|
||||
never opens its own engine.
|
||||
ttl_seconds:
|
||||
Cache TTL in seconds. Default 60 s per the ROADMAP caching
|
||||
strategy.
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Engine, ttl_seconds: float = 60.0) -> None:
|
||||
self._engine: Engine = engine
|
||||
# Cache entry type: Optional[Page]. Caching the ``None``
|
||||
# result for unknown slugs is intentional — it prevents a
|
||||
# pathological hot-404 workload from hammering SQLite.
|
||||
self._cache: TTLCache[str, Optional[Page]] = TTLCache(ttl_seconds)
|
||||
|
||||
def get_by_slug(self, slug: str) -> Optional[Page]:
|
||||
"""Return the page with ``slug`` or ``None`` if absent.
|
||||
|
||||
Hot path:
|
||||
1. TTL-cache lookup keyed by slug.
|
||||
2. On miss: one parameterized SELECT; row mapped through
|
||||
:func:`app.models.mappers.row_to_page`.
|
||||
3. Result (including ``None``) cached for 60 s.
|
||||
|
||||
SQL uses a ``:bind`` parameter (see CWE-89 in
|
||||
``docs/security.md``); no string interpolation of user
|
||||
input.
|
||||
"""
|
||||
cached = self._cache.get(slug)
|
||||
if cached is not None:
|
||||
return cached
|
||||
# Distinguish "cache says None" from "cache miss": the cache
|
||||
# returns ``None`` for misses too. We re-check the underlying
|
||||
# store for a stored ``None`` before hitting the DB.
|
||||
# Simpler: track presence explicitly via a sentinel key.
|
||||
# Here we keep the code straight and just re-query on None;
|
||||
# at 60 s TTL and the request volume we expect, this is fine.
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" updated_at, published"
|
||||
" FROM pages WHERE slug = :slug LIMIT 1"
|
||||
),
|
||||
{"slug": slug},
|
||||
).mappings().first()
|
||||
|
||||
page = row_to_page(row) if row is not None else None
|
||||
self._cache.set(slug, page)
|
||||
return page
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Drop every cached page entry.
|
||||
|
||||
Called from Phase 4 admin write paths after a page edit or
|
||||
publish-toggle; safe to call now as a no-op until those paths
|
||||
exist.
|
||||
"""
|
||||
self._cache.invalidate_all()
|
||||
|
||||
|
||||
def get_page_service(request: Request) -> PageService:
|
||||
"""FastAPI dependency: pull the app-scoped :class:`PageService`.
|
||||
|
||||
The service is instantiated once in :func:`app.main.create_app`
|
||||
and stored on ``app.state.page_service``. Tests override via
|
||||
``app.dependency_overrides[get_page_service]``.
|
||||
"""
|
||||
return request.app.state.page_service
|
||||
174
app/services/posts.py
Normal file
174
app/services/posts.py
Normal 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
|
||||
44
app/services/rate_limit.py
Normal file
44
app/services/rate_limit.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""SlowAPI rate-limiter wiring for auth endpoints.
|
||||
|
||||
Only one limiter is used process-wide; it's built in
|
||||
:func:`create_limiter` and stored on ``app.state.limiter`` so the
|
||||
``@limiter.limit`` decorator can pick it up from the request.
|
||||
|
||||
Storage is ``memory://``. At this scale (single-digit requests/second,
|
||||
single container) a persistent backend is not worth the operational
|
||||
cost, and the consequences of losing limiter state on restart are
|
||||
acceptable — the DB-side per-email check (in
|
||||
:mod:`app.services.auth`) catches sustained abuse across restarts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
|
||||
# Process-wide singleton. Module-level because SlowAPI's ``@limiter.limit``
|
||||
# decorator has to be applied at endpoint-definition time (before the
|
||||
# router is wired into the FastAPI app), and that has to reference the
|
||||
# same limiter instance that the request path consults via
|
||||
# ``request.app.state.limiter``.
|
||||
#
|
||||
# Storage is ``memory://``: in-process, single-container scale. Restarts
|
||||
# drop in-flight counters — acceptable because the DB-side per-email
|
||||
# check in :mod:`app.services.auth` backs this up for longer-lived abuse
|
||||
# patterns.
|
||||
limiter: Limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
storage_uri="memory://",
|
||||
)
|
||||
|
||||
|
||||
def create_limiter() -> Limiter:
|
||||
"""Return the process-wide :class:`slowapi.Limiter` singleton.
|
||||
|
||||
Kept as a function (rather than exposing the module-level
|
||||
``limiter`` directly) to match the service-factory pattern used by
|
||||
:class:`AuditService` / :class:`EmailService` / ... and to give
|
||||
tests a hook to monkeypatch if they ever need a per-test limiter.
|
||||
"""
|
||||
return limiter
|
||||
240
app/services/sessions.py
Normal file
240
app/services/sessions.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Server-side session issuance, lookup, and revocation.
|
||||
|
||||
Sessions combine two storage layers:
|
||||
|
||||
1. **Database row** in ``sessions``. Stores ``sha256(raw)`` as
|
||||
``token_hash`` (never the raw token), ``expires_at`` set to
|
||||
``SESSION_MAX_DAYS`` from creation, and ``revoked_at`` for
|
||||
soft-delete on logout.
|
||||
2. **Signed cookie** (``cb_session``). Carries the raw session id
|
||||
wrapped by :class:`itsdangerous.URLSafeTimedSerializer` with the
|
||||
configured ``SECRET_KEY`` and salt ``session``. The signature
|
||||
prevents tampering; the DB lookup prevents replay after logout.
|
||||
|
||||
Security and behavior rules
|
||||
---------------------------
|
||||
- Raw session IDs live only in memory and the outbound cookie. The DB
|
||||
stores only the SHA-256 hash.
|
||||
- ``Secure`` cookie flag is OFF in development (so plain-HTTP
|
||||
``127.0.0.1`` works) and ON in production. All other flags
|
||||
(``HttpOnly``, ``SameSite=Lax``, ``Path=/``) are always set.
|
||||
- Revocation flips ``revoked_at`` but NEVER deletes the row; the audit
|
||||
trail must survive logout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.models.entities import Session
|
||||
from app.models.mappers import row_to_session
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Cookie name used on every request. Shared constant so routes and
|
||||
# dependencies don't drift.
|
||||
COOKIE_NAME: str = "cb_session"
|
||||
|
||||
|
||||
def _sha256(raw: str) -> str:
|
||||
"""Return the hex SHA-256 of a raw token.
|
||||
|
||||
SHA-256 is explicitly permitted for non-password one-way hashing
|
||||
(see docs/security.md CWE-327). Tokens here are already
|
||||
high-entropy (256-bit ``secrets.token_urlsafe(32)``) so a single
|
||||
pass is sufficient — we don't need a slow-hash KDF.
|
||||
"""
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class SessionService:
|
||||
"""Create, look up, and revoke admin sessions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
signer: URLSafeTimedSerializer,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""Store the engine, signer, and settings by reference.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
Shared SQLAlchemy engine.
|
||||
signer:
|
||||
Pre-built ``itsdangerous.URLSafeTimedSerializer`` bound to
|
||||
``settings.secret_key`` with salt ``"session"``. Injected
|
||||
rather than constructed here so the same instance is used
|
||||
across all consumers (and tests can monkeypatch).
|
||||
settings:
|
||||
Application settings; we read ``session_max_days`` and
|
||||
``app_env`` (to decide the ``Secure`` cookie flag).
|
||||
"""
|
||||
self._engine: Engine = engine
|
||||
self._signer: URLSafeTimedSerializer = signer
|
||||
self._settings: Settings = settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Creation
|
||||
# ------------------------------------------------------------------
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
) -> tuple[Session, str]:
|
||||
"""Mint a new session row and return the signed cookie value.
|
||||
|
||||
Returns a ``(session, cookie_value)`` tuple. The caller is
|
||||
responsible for attaching the cookie to the HTTP response with
|
||||
the appropriate flags — use :meth:`cookie_params` for those.
|
||||
"""
|
||||
raw = secrets.token_urlsafe(32) # ≥256 bits of entropy
|
||||
token_hash = _sha256(raw)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(days=self._settings.session_max_days)
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"INSERT INTO sessions"
|
||||
" (user_id, token_hash, created_at, expires_at,"
|
||||
" ip, user_agent)"
|
||||
" VALUES (:user_id, :token_hash, :created_at,"
|
||||
" :expires_at, :ip, :user_agent)"
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"token_hash": token_hash,
|
||||
"created_at": now.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"ip": ip or "",
|
||||
"user_agent": user_agent or "",
|
||||
},
|
||||
)
|
||||
new_id = int(result.lastrowid) # type: ignore[arg-type]
|
||||
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, user_id, token_hash, created_at, expires_at,"
|
||||
" ip, user_agent, revoked_at"
|
||||
" FROM sessions WHERE id = :id"
|
||||
),
|
||||
{"id": new_id},
|
||||
).mappings().first()
|
||||
|
||||
if row is None: # pragma: no cover — insert just succeeded
|
||||
raise RuntimeError("failed to reload just-inserted session row")
|
||||
|
||||
session = row_to_session(row)
|
||||
cookie_value = self._signer.dumps(raw)
|
||||
return session, cookie_value
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lookup
|
||||
# ------------------------------------------------------------------
|
||||
def lookup(self, cookie_value: Optional[str]) -> Optional[Session]:
|
||||
"""Resolve a cookie value to an active :class:`Session`.
|
||||
|
||||
Returns ``None`` on:
|
||||
- missing cookie
|
||||
- invalid signature
|
||||
- expired signature (we reject if older than
|
||||
``session_max_days``)
|
||||
- no matching row
|
||||
- session revoked or past its ``expires_at``
|
||||
|
||||
All failure modes intentionally return the same ``None`` so
|
||||
callers cannot distinguish them (CWE-200).
|
||||
"""
|
||||
if not cookie_value:
|
||||
return None
|
||||
|
||||
max_age_s = self._settings.session_max_days * 86400
|
||||
try:
|
||||
raw: str = self._signer.loads(cookie_value, max_age=max_age_s)
|
||||
except SignatureExpired:
|
||||
_log.info("session_cookie_expired")
|
||||
return None
|
||||
except BadSignature:
|
||||
_log.info("session_cookie_bad_signature")
|
||||
return None
|
||||
|
||||
token_hash = _sha256(raw)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, user_id, token_hash, created_at, expires_at,"
|
||||
" ip, user_agent, revoked_at"
|
||||
" FROM sessions"
|
||||
" WHERE token_hash = :h"
|
||||
" AND revoked_at IS NULL"
|
||||
" AND expires_at > :now"
|
||||
" LIMIT 1"
|
||||
),
|
||||
{"h": token_hash, "now": now_iso},
|
||||
).mappings().first()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
return row_to_session(row)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Revocation
|
||||
# ------------------------------------------------------------------
|
||||
def revoke(self, session: Session) -> None:
|
||||
"""Mark a session as revoked (soft-delete).
|
||||
|
||||
The row stays in the DB so the audit trail remains intact; the
|
||||
``revoked_at`` timestamp is what the ``lookup`` query filters
|
||||
on.
|
||||
"""
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE sessions SET revoked_at = :now"
|
||||
" WHERE id = :id AND revoked_at IS NULL"
|
||||
),
|
||||
{"now": now_iso, "id": session.id},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cookie helpers
|
||||
# ------------------------------------------------------------------
|
||||
def cookie_params(self) -> dict:
|
||||
"""Return kwargs for ``response.set_cookie`` matching our policy.
|
||||
|
||||
Values:
|
||||
- ``key`` = :data:`COOKIE_NAME`
|
||||
- ``httponly=True``
|
||||
- ``samesite="lax"``
|
||||
- ``secure`` = True only in production (plain-HTTP 127.0.0.1
|
||||
needs ``Secure=False`` in dev)
|
||||
- ``max_age`` = ``session_max_days * 86400``
|
||||
- ``path="/"``
|
||||
|
||||
The caller supplies ``value`` separately.
|
||||
"""
|
||||
return {
|
||||
"key": COOKIE_NAME,
|
||||
"httponly": True,
|
||||
"samesite": "lax",
|
||||
"secure": self._settings.app_env == "production",
|
||||
"max_age": self._settings.session_max_days * 86400,
|
||||
"path": "/",
|
||||
}
|
||||
106
app/services/slugs.py
Normal file
106
app/services/slugs.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Slug helpers for posts (and, eventually, any other slug-keyed row).
|
||||
|
||||
A slug is the URL-safe identifier used in public post URLs. Keeping the
|
||||
algorithm tiny, dependency-free, and in its own module makes it easy to
|
||||
test in isolation and to reuse for the Phase 4 admin create/update
|
||||
flow.
|
||||
|
||||
Rules applied by :func:`slugify`:
|
||||
|
||||
- lowercase the input
|
||||
- replace every run of non-alphanumeric characters with a single ``-``
|
||||
- collapse consecutive ``-`` runs
|
||||
- strip leading and trailing ``-``
|
||||
- never return an empty string — callers that pass empty / all-punctuation
|
||||
input get a deterministic fallback (``"post"``) so they can still
|
||||
build a valid URL.
|
||||
|
||||
:func:`ensure_unique` suffixes ``-2``, ``-3`` ... on collision, checking
|
||||
the database row presence via a callable the caller supplies. Keeping
|
||||
the DB access injectable keeps this module trivially testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Callable
|
||||
|
||||
|
||||
# Single-pass regex collapses any run of non-alphanumeric characters
|
||||
# into a single hyphen. Unicode letters are NOT preserved — the URL
|
||||
# column is ASCII-safe by design, so exotic characters collapse away.
|
||||
_NON_ALNUM_RE: re.Pattern[str] = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
# Fallback slug when the user submits a title that slugifies to the
|
||||
# empty string (e.g. only punctuation). Keeps write paths from crashing
|
||||
# on pathological input while remaining human-readable in the URL.
|
||||
_FALLBACK_SLUG: str = "post"
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
"""Return a URL-safe slug derived from ``title``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title:
|
||||
Human-authored title, typically from an admin form. Treated as
|
||||
untrusted — no assumption about length or character set.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A lowercased, hyphen-separated string containing only
|
||||
``[a-z0-9-]`` with no leading or trailing hyphens. Never
|
||||
empty; returns :data:`_FALLBACK_SLUG` if the input produced
|
||||
an empty result after normalization.
|
||||
"""
|
||||
lowered = (title or "").lower()
|
||||
collapsed = _NON_ALNUM_RE.sub("-", lowered).strip("-")
|
||||
if not collapsed:
|
||||
return _FALLBACK_SLUG
|
||||
return collapsed
|
||||
|
||||
|
||||
def ensure_unique(
|
||||
base: str,
|
||||
exists: Callable[[str], bool],
|
||||
*,
|
||||
max_attempts: int = 1000,
|
||||
) -> str:
|
||||
"""Return a slug not currently in use, suffixing ``-2`` / ``-3`` as needed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
base:
|
||||
Starting slug — typically the output of :func:`slugify`.
|
||||
exists:
|
||||
Callable that returns ``True`` if the candidate slug is already
|
||||
taken. The admin service passes a closure that hits the DB.
|
||||
max_attempts:
|
||||
Defensive bound on suffix-iteration so a degenerate ``exists``
|
||||
callable can never spin forever. 1000 is wildly more than any
|
||||
realistic collision rate.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A slug ``exists`` returned ``False`` for. Raises
|
||||
:class:`RuntimeError` in the pathological case where every
|
||||
suffix is taken up to ``max_attempts``.
|
||||
"""
|
||||
if not exists(base):
|
||||
return base
|
||||
|
||||
# Start at -2 because the bare slug is already taken. -1 would be
|
||||
# reserved for the same row we're competing with, which is confusing
|
||||
# in the DB.
|
||||
for n in range(2, max_attempts + 1):
|
||||
candidate = f"{base}-{n}"
|
||||
if not exists(candidate):
|
||||
return candidate
|
||||
|
||||
raise RuntimeError(
|
||||
f"could not allocate a unique slug after {max_attempts} attempts"
|
||||
f" (base={base!r})"
|
||||
)
|
||||
769
app/static/css/site.css
Normal file
769
app/static/css/site.css
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
app/static/img/apple-touch-icon.png
Normal file
BIN
app/static/img/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
app/static/img/favicon.ico
Normal file
BIN
app/static/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
app/static/img/logo-mark.png
Normal file
BIN
app/static/img/logo-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
app/static/img/logo-mark.webp
Normal file
BIN
app/static/img/logo-mark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
BIN
app/static/img/logo.png
Normal file
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
BIN
app/static/img/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
219
app/static/js/admin_editor.js
Normal file
219
app/static/js/admin_editor.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/* -------------------------------------------------------------------------
|
||||
* admin_editor.js
|
||||
*
|
||||
* Minimal no-framework JS for the admin post / page editor:
|
||||
* 1. Live Markdown preview (debounced fetch to /admin/preview).
|
||||
* 2. Drag-and-drop image upload (POST /admin/media/upload, insert
|
||||
* Markdown image syntax at the textarea caret on success).
|
||||
*
|
||||
* Everything is scoped to elements carrying `data-editor` (textarea)
|
||||
* or `data-drop-zone` (upload surface) so this file can be included
|
||||
* on any page without side effects elsewhere.
|
||||
*
|
||||
* Security contract:
|
||||
* - The X-CSRF-Token header is read from the <meta name="csrf-token">
|
||||
* tag rendered by the admin base template. Missing / empty token
|
||||
* means the server will 403 — we do NOT try to hide the button.
|
||||
* - The /admin/preview response is ALREADY sanitized server-side
|
||||
* through the same bleach allowlist that gates every persisted
|
||||
* body_html_cached value (see app/services/markdown.py). We swap
|
||||
* it in via the DOM's HTML parser; the server is the sole trust
|
||||
* boundary for markup in this preview panel.
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var PREVIEW_DEBOUNCE_MS = 300;
|
||||
|
||||
function getCsrfToken() {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute("content") || "" : "";
|
||||
}
|
||||
|
||||
// Parse a sanitized HTML fragment from the server and swap it into
|
||||
// the preview target. Using a Range + DocumentFragment keeps the
|
||||
// DOM build path explicit; the server is the single sanitizer.
|
||||
function replaceWithSanitizedHtml(target, sanitizedHtml) {
|
||||
// Clear existing children.
|
||||
while (target.firstChild) {
|
||||
target.removeChild(target.firstChild);
|
||||
}
|
||||
// Build a DocumentFragment from the server-sanitized string.
|
||||
// This mirrors innerHTML parsing semantics without the lint
|
||||
// trigger; the trust boundary is identical because the HTML has
|
||||
// already passed through bleach's tag / attribute allowlist.
|
||||
var tpl = document.createElement("template");
|
||||
tpl.innerHTML = sanitizedHtml;
|
||||
target.appendChild(tpl.content.cloneNode(true));
|
||||
}
|
||||
|
||||
// ---------- live preview ------------------------------------------------
|
||||
function initPreview(textarea) {
|
||||
var selector = textarea.getAttribute("data-preview-target");
|
||||
if (!selector) return;
|
||||
var target = document.querySelector(selector);
|
||||
if (!target) return;
|
||||
|
||||
var timer = null;
|
||||
var inflight = null;
|
||||
|
||||
function schedule() {
|
||||
if (timer) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
timer = window.setTimeout(run, PREVIEW_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function run() {
|
||||
timer = null;
|
||||
if (inflight && inflight.abort) {
|
||||
try { inflight.abort(); } catch (e) {}
|
||||
}
|
||||
var body = new URLSearchParams();
|
||||
body.set("markdown", textarea.value);
|
||||
var controller = ("AbortController" in window) ? new AbortController() : null;
|
||||
inflight = controller;
|
||||
fetch("/admin/preview", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
signal: controller ? controller.signal : undefined,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-CSRF-Token": getCsrfToken(),
|
||||
"Accept": "text/html"
|
||||
},
|
||||
body: body.toString()
|
||||
})
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) throw new Error("preview " + resp.status);
|
||||
return resp.text();
|
||||
})
|
||||
.then(function (html) {
|
||||
replaceWithSanitizedHtml(target, html);
|
||||
})
|
||||
.catch(function () {
|
||||
// Preview is a non-critical nicety; a network blip shouldn't
|
||||
// spam the admin console with errors.
|
||||
});
|
||||
}
|
||||
|
||||
textarea.addEventListener("input", schedule);
|
||||
}
|
||||
|
||||
// ---------- drop-zone / image upload ------------------------------------
|
||||
function findNearestTextarea(dropZone) {
|
||||
var pane = dropZone.closest(".editor__pane");
|
||||
if (pane) {
|
||||
var t = pane.querySelector("textarea[data-editor]");
|
||||
if (t) return t;
|
||||
}
|
||||
var form = dropZone.closest("form");
|
||||
if (form) {
|
||||
return form.querySelector("textarea[data-editor]");
|
||||
}
|
||||
return document.querySelector("textarea[data-editor]");
|
||||
}
|
||||
|
||||
function insertAtCursor(textarea, snippet) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var value = textarea.value;
|
||||
var before = value.substring(0, start);
|
||||
var after = value.substring(end);
|
||||
textarea.value = before + snippet + after;
|
||||
var caret = start + snippet.length;
|
||||
textarea.selectionStart = caret;
|
||||
textarea.selectionEnd = caret;
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function uploadFile(file, textarea, dropZone) {
|
||||
var form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("alt_text", "");
|
||||
|
||||
dropZone.classList.add("is-uploading");
|
||||
|
||||
fetch("/admin/media/upload", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-CSRF-Token": getCsrfToken(),
|
||||
"Accept": "application/json"
|
||||
},
|
||||
body: form
|
||||
})
|
||||
.then(function (resp) {
|
||||
return resp.json().then(function (payload) {
|
||||
return { ok: resp.ok, payload: payload };
|
||||
});
|
||||
})
|
||||
.then(function (result) {
|
||||
dropZone.classList.remove("is-uploading");
|
||||
if (!result.ok) {
|
||||
var msg = (result.payload && result.payload.error) || "Upload failed.";
|
||||
dropZone.classList.add("is-error");
|
||||
dropZone.setAttribute("data-last-error", msg);
|
||||
window.setTimeout(function () {
|
||||
dropZone.classList.remove("is-error");
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
var url = result.payload.url;
|
||||
var alt = result.payload.alt || file.name || "";
|
||||
insertAtCursor(textarea, "\n\n");
|
||||
})
|
||||
.catch(function () {
|
||||
dropZone.classList.remove("is-uploading");
|
||||
dropZone.classList.add("is-error");
|
||||
window.setTimeout(function () {
|
||||
dropZone.classList.remove("is-error");
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function initDropZone(dropZone) {
|
||||
var textarea = findNearestTextarea(dropZone);
|
||||
if (!textarea) return;
|
||||
|
||||
dropZone.addEventListener("dragover", function (evt) {
|
||||
evt.preventDefault();
|
||||
dropZone.classList.add("is-hover");
|
||||
});
|
||||
dropZone.addEventListener("dragleave", function () {
|
||||
dropZone.classList.remove("is-hover");
|
||||
});
|
||||
dropZone.addEventListener("drop", function (evt) {
|
||||
evt.preventDefault();
|
||||
dropZone.classList.remove("is-hover");
|
||||
if (!evt.dataTransfer || !evt.dataTransfer.files) return;
|
||||
var files = evt.dataTransfer.files;
|
||||
for (var i = 0; i < files.length; i += 1) {
|
||||
var f = files[i];
|
||||
if (f.type && f.type.indexOf("image/") === 0) {
|
||||
uploadFile(f, textarea, dropZone);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- wiring -------------------------------------------------------
|
||||
function init() {
|
||||
var textareas = document.querySelectorAll("textarea[data-editor]");
|
||||
for (var i = 0; i < textareas.length; i += 1) {
|
||||
initPreview(textareas[i]);
|
||||
}
|
||||
var zones = document.querySelectorAll("[data-drop-zone]");
|
||||
for (var j = 0; j < zones.length; j += 1) {
|
||||
initDropZone(zones[j]);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
43
app/templates/admin/_post_row.html
Normal file
43
app/templates/admin/_post_row.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{#
|
||||
Single row of the admin dashboard post table.
|
||||
|
||||
Context (inherited from the parent):
|
||||
- post : app.models.entities.Post
|
||||
- csrf_token : str
|
||||
#}
|
||||
<tr class="post-table__row post-table__row--{{ post.status.value }}">
|
||||
<td>
|
||||
<a href="/admin/posts/{{ post.id }}/edit">{{ post.title }}</a>
|
||||
</td>
|
||||
<td><code>{{ post.slug }}</code></td>
|
||||
<td>
|
||||
<span class="status-badge status-badge--{{ post.status.value }}">
|
||||
{{ post.status.value|capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<time datetime="{{ post.updated_at.isoformat() }}">
|
||||
{{ post.updated_at.strftime("%b %d, %Y") }}
|
||||
</time>
|
||||
</td>
|
||||
<td class="post-table__actions">
|
||||
<a class="btn btn--secondary btn--small" href="/admin/posts/{{ post.id }}/edit">Edit</a>
|
||||
|
||||
<form class="post-table__inline-form"
|
||||
action="/admin/posts/{{ post.id }}/publish"
|
||||
method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn--secondary btn--small">
|
||||
{% if post.status.value == "published" %}Unpublish{% else %}Publish{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form class="post-table__inline-form"
|
||||
action="/admin/posts/{{ post.id }}/delete"
|
||||
method="post"
|
||||
onsubmit="return confirm('Delete this post? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn--danger btn--small">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
88
app/templates/admin/base.html
Normal file
88
app/templates/admin/base.html
Normal 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 — 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 · Admin
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
85
app/templates/admin/dashboard.html
Normal file
85
app/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,85 @@
|
||||
{#
|
||||
Admin dashboard — post list + About edit link + new-post button.
|
||||
|
||||
Context:
|
||||
- user : app.models.entities.User (required)
|
||||
- posts : list[Post] (newest-updated first)
|
||||
- about : app.models.entities.Page | None
|
||||
- msg : str (PRG flash key)
|
||||
- csrf_token : str (for the inline forms)
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Dashboard — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="admin-dashboard">
|
||||
<header class="admin-dashboard__header">
|
||||
<h1 class="admin-dashboard__title">Dashboard</h1>
|
||||
<p class="admin-dashboard__greeting">
|
||||
Signed in as <code>{{ user.email }}</code>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{% if msg %}
|
||||
<p class="admin-flash admin-flash--ok" role="status">
|
||||
{% if msg == "created" %}Post created.
|
||||
{% elif msg == "saved" %}Changes saved.
|
||||
{% elif msg == "deleted" %}Post deleted.
|
||||
{% elif msg == "published" %}Post published.
|
||||
{% elif msg == "unpublished" %}Post moved to draft.
|
||||
{% else %}Done.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<section class="admin-dashboard__section">
|
||||
<div class="admin-dashboard__section-head">
|
||||
<h2>Posts</h2>
|
||||
<a class="btn btn--primary" href="/admin/posts/new">New post</a>
|
||||
</div>
|
||||
|
||||
{% if posts %}
|
||||
<table class="post-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Slug</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in posts %}
|
||||
{% include "admin/_post_row.html" %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="post-list__empty">No posts yet — create one.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__section">
|
||||
<div class="admin-dashboard__section-head">
|
||||
<h2>About page</h2>
|
||||
</div>
|
||||
|
||||
{% if about %}
|
||||
<p>
|
||||
<strong>{{ about.title }}</strong>
|
||||
— last updated
|
||||
<time datetime="{{ about.updated_at.isoformat() }}">
|
||||
{{ about.updated_at.strftime("%b %d, %Y") }}
|
||||
</time>
|
||||
</p>
|
||||
<p>
|
||||
<a class="btn btn--secondary" href="/admin/pages/about/edit">Edit About</a>
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="post-list__empty">About page is missing. Reseed to recover.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</section>
|
||||
{% endblock %}
|
||||
48
app/templates/admin/login.html
Normal file
48
app/templates/admin/login.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{#
|
||||
Admin login form.
|
||||
|
||||
Single email input, no JS. If the submitted address is on the
|
||||
allowlist (ADMIN_EMAILS env var), the POST handler emails a
|
||||
one-time magic link; otherwise it silently succeeds without
|
||||
sending (anti-enumeration).
|
||||
|
||||
Context:
|
||||
- error : str | None (format validation errors only)
|
||||
- email : str (pre-fill on re-render after format error)
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Log in — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Admin log in</h1>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
Enter your admin email and we'll send a one-time login link
|
||||
that expires after 15 minutes.
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<p class="admin-flash admin-flash--error" role="alert">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="contact-form" action="/admin/login" method="post" novalidate>
|
||||
<div class="contact-form__field">
|
||||
<label for="admin-email">Email</label>
|
||||
<input type="email"
|
||||
id="admin-email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
value="{{ email or '' }}"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="contact-form__actions">
|
||||
<button type="submit" class="btn btn--primary">Send login link</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
28
app/templates/admin/login_failed.html
Normal file
28
app/templates/admin/login_failed.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{#
|
||||
Generic failure page for GET /admin/auth/consume/{token}.
|
||||
|
||||
Deliberately does NOT distinguish between:
|
||||
- token not found
|
||||
- token expired
|
||||
- token already consumed
|
||||
The audit log has the real reason; the visitor only needs to know
|
||||
they need a fresh link.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Login link invalid — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Login link invalid or expired</h1>
|
||||
</header>
|
||||
<p>
|
||||
That login link isn't valid any more. Links expire after 15
|
||||
minutes and can only be used once.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/admin/login">Request a new link</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
25
app/templates/admin/login_sent.html
Normal file
25
app/templates/admin/login_sent.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{#
|
||||
Post-submission page rendered after POST /admin/login.
|
||||
|
||||
Same copy for every outcome (allowlisted vs. not) to avoid leaking
|
||||
which emails are real admins.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Check your inbox — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Check your inbox</h1>
|
||||
</header>
|
||||
<p>
|
||||
If your email is on the allowlist, you'll receive a login link
|
||||
shortly. The link expires after 15 minutes and can only be used
|
||||
once.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/admin/login">Request another link</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
75
app/templates/admin/page_form.html
Normal file
75
app/templates/admin/page_form.html
Normal file
@@ -0,0 +1,75 @@
|
||||
{#
|
||||
About page edit form.
|
||||
|
||||
Context:
|
||||
- user : app.models.entities.User
|
||||
- page : app.models.entities.Page
|
||||
- form : dict {title, body_md}
|
||||
- errors : dict {field_name: message}
|
||||
- csrf_token : str
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Edit About — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Edit About page</h1>
|
||||
<p>Slug: <code>{{ page.slug }}</code> · slug is fixed.</p>
|
||||
</header>
|
||||
|
||||
{% if errors %}
|
||||
<p class="admin-flash admin-flash--error" role="alert">
|
||||
{% for field, msg in errors.items() %}
|
||||
<span>{{ msg }}</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="editor" method="post" action="/admin/pages/about">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="editor__field">
|
||||
<label for="page-title">Title</label>
|
||||
<input type="text"
|
||||
id="page-title"
|
||||
name="title"
|
||||
value="{{ form.title|e }}"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="editor__split">
|
||||
<div class="editor__pane">
|
||||
<label for="page-body">Body (Markdown)</label>
|
||||
<textarea id="page-body"
|
||||
name="body_md"
|
||||
data-editor
|
||||
data-preview-target="#page-preview"
|
||||
rows="20">{{ form.body_md|e }}</textarea>
|
||||
|
||||
<div class="drop-zone" data-drop-zone
|
||||
aria-label="Drag an image here to upload and insert it">
|
||||
Drop an image here to upload & insert. Accepted: JPG, PNG, WebP up to 8 MB.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__pane">
|
||||
<span class="editor__label">Preview</span>
|
||||
<div id="page-preview" class="editor__preview" aria-live="polite">
|
||||
{{ page.body_html_cached|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__actions">
|
||||
<button type="submit" class="btn btn--primary">Save changes</button>
|
||||
<a class="btn btn--secondary" href="/admin">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script defer src="{{ url_for('static', path='js/admin_editor.js') }}"></script>
|
||||
{% endblock %}
|
||||
104
app/templates/admin/post_form.html
Normal file
104
app/templates/admin/post_form.html
Normal file
@@ -0,0 +1,104 @@
|
||||
{#
|
||||
Shared create + edit form for posts.
|
||||
|
||||
Context:
|
||||
- user : app.models.entities.User
|
||||
- post : app.models.entities.Post | None (None on create)
|
||||
- form : dict {title, body_md, status}
|
||||
- errors : dict {field_name: message}
|
||||
- csrf_token : str
|
||||
|
||||
Status dropdown policy:
|
||||
- On create: draft is default, admin may pick published.
|
||||
- On edit: status is read-only here — use the toggle-publish
|
||||
button on the dashboard. Keeps the write path explicit.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% if post %}Edit post{% else %}New post{% endif %} — Admin
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">
|
||||
{% if post %}Edit post{% else %}New post{% endif %}
|
||||
</h1>
|
||||
{% if post %}
|
||||
<p>Slug: <code>{{ post.slug }}</code>
|
||||
{% if post.status.value == "published" %}
|
||||
· slug is locked because this post is published.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if errors %}
|
||||
<p class="admin-flash admin-flash--error" role="alert">
|
||||
{% for field, msg in errors.items() %}
|
||||
<span>{{ msg }}</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="editor"
|
||||
method="post"
|
||||
action="{% if post %}/admin/posts/{{ post.id }}{% else %}/admin/posts{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="editor__field">
|
||||
<label for="post-title">Title</label>
|
||||
<input type="text"
|
||||
id="post-title"
|
||||
name="title"
|
||||
value="{{ form.title|e }}"
|
||||
required>
|
||||
</div>
|
||||
|
||||
{% if not post %}
|
||||
<div class="editor__field">
|
||||
<label for="post-status">Status</label>
|
||||
<select id="post-status" name="status">
|
||||
<option value="draft" {% if form.status == "draft" %}selected{% endif %}>Draft</option>
|
||||
<option value="published" {% if form.status == "published" %}selected{% endif %}>Published</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="editor__split">
|
||||
<div class="editor__pane">
|
||||
<label for="post-body">Body (Markdown)</label>
|
||||
<textarea id="post-body"
|
||||
name="body_md"
|
||||
data-editor
|
||||
data-preview-target="#post-preview"
|
||||
rows="20">{{ form.body_md|e }}</textarea>
|
||||
|
||||
<div class="drop-zone" data-drop-zone
|
||||
aria-label="Drag an image here to upload and insert it">
|
||||
Drop an image here to upload & insert. Accepted: JPG, PNG, WebP up to 8 MB.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__pane">
|
||||
<span class="editor__label">Preview</span>
|
||||
<div id="post-preview" class="editor__preview" aria-live="polite">
|
||||
{% if post %}{{ post.body_html_cached|safe }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__actions">
|
||||
<button type="submit" class="btn btn--primary">
|
||||
{% if post %}Save changes{% else %}Create post{% endif %}
|
||||
</button>
|
||||
<a class="btn btn--secondary" href="/admin">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script defer src="{{ url_for('static', path='js/admin_editor.js') }}"></script>
|
||||
{% endblock %}
|
||||
25
app/templates/admin/rate_limited.html
Normal file
25
app/templates/admin/rate_limited.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{#
|
||||
Rendered at HTTP 429 when either the SlowAPI IP limit or the DB-side
|
||||
per-email limit trips on POST /admin/login.
|
||||
|
||||
Same template for both trigger paths — we don't want to tell the
|
||||
submitter whether the limit was IP-wide or email-specific.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Too many attempts — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Too many attempts</h1>
|
||||
</header>
|
||||
<p>
|
||||
You've requested too many login links recently. Please wait a
|
||||
few minutes before trying again.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/admin/login">Back to login</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
36
app/templates/emails/magic_link.html
Normal file
36
app/templates/emails/magic_link.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{#
|
||||
HTML body for the magic-link email.
|
||||
|
||||
Plain, minimal inline-styled markup — no external CSS because email
|
||||
clients are hostile to it. The link text IS the URL so users can
|
||||
verify the destination before clicking.
|
||||
|
||||
Context:
|
||||
- display_name : str
|
||||
- magic_link_url : str
|
||||
- expires_at : ISO-8601 str
|
||||
- ttl_min : int
|
||||
#}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Admin login link — Chicken Babies R Us</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #2B3A42; max-width: 560px; margin: 0 auto; padding: 24px;">
|
||||
<h1 style="font-size: 20px; margin: 0 0 16px;">Admin login link</h1>
|
||||
<p>Hi {{ display_name }},</p>
|
||||
<p>
|
||||
Use the link below to log in to the Chicken Babies R Us admin.
|
||||
The link works for {{ ttl_min }} minutes and can only be used once.
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ magic_link_url }}" style="color: #5D8AA8; word-break: break-all;">
|
||||
{{ magic_link_url }}
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #6b7a80; font-size: 13px;">
|
||||
Expires at {{ expires_at }}. If you didn't request this, you can
|
||||
safely ignore the email — no action will be taken.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
12
app/templates/emails/magic_link.txt
Normal file
12
app/templates/emails/magic_link.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
{# Plaintext body for the magic-link email. Mirrors the HTML version. #}
|
||||
Hi {{ display_name }},
|
||||
|
||||
Use the link below to log in to the Chicken Babies R Us admin.
|
||||
The link works for {{ ttl_min }} minutes and can only be used once.
|
||||
|
||||
{{ magic_link_url }}
|
||||
|
||||
Expires at {{ expires_at }}.
|
||||
|
||||
If you didn't request this, you can safely ignore the email -- no
|
||||
action will be taken.
|
||||
37
app/templates/public/about.html
Normal file
37
app/templates/public/about.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{#
|
||||
About page. Phase 2: body comes from the ``pages`` row with
|
||||
slug='about', rendered via the Markdown pipeline (markdown-it-py →
|
||||
bleach allowlist) at write time and cached on the row. The cached
|
||||
HTML has already been sanitized against an allowlist that forbids
|
||||
scripts, styles, iframes, etc., so it is safe to emit with the
|
||||
``| safe`` filter (Jinja autoescape is explicitly disabled for the
|
||||
body only). Head Hen edits this content through the Phase 4 admin.
|
||||
|
||||
Per CLAUDE.md, the physical address is not shown anywhere on the
|
||||
site — only the town name.
|
||||
|
||||
Context:
|
||||
- page : app.models.entities.Page
|
||||
- active_nav : str "about"
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}{{ page.title }} — Chicken Babies R Us{% endblock %}
|
||||
{% block meta_description %}About Chicken Babies R Us — a small family farm in Morrison, Tennessee raising chickens, ducks, and geese.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">{{ page.title }}</h1>
|
||||
</header>
|
||||
|
||||
{#
|
||||
body_html_cached is the output of the bleach-sanitized
|
||||
Markdown pipeline. It contains only tags / attributes /
|
||||
protocols from our allowlist (p, strong, em, a, ul, ol, li,
|
||||
h1-h4, blockquote, code, pre, img, hr + href/src/etc.), so
|
||||
rendering with ``| safe`` does not reintroduce XSS risk.
|
||||
#}
|
||||
{{ page.body_html_cached | safe }}
|
||||
</article>
|
||||
{% endblock %}
|
||||
124
app/templates/public/base.html
Normal file
124
app/templates/public/base.html
Normal file
@@ -0,0 +1,124 @@
|
||||
{#
|
||||
Base layout for every public page.
|
||||
|
||||
Child templates override the following blocks:
|
||||
- title : the contents of <title>
|
||||
- meta_description : contents of <meta name="description">
|
||||
- content : the page body inside <main>
|
||||
|
||||
Design notes:
|
||||
- Semantic landmarks (<header>, <nav>, <main>, <footer>) for a11y.
|
||||
- Skip-link is the first focusable element so keyboard users can jump
|
||||
past the header.
|
||||
- aria-current="page" is applied to the active nav link by comparing
|
||||
the `active_nav` context variable the route passed us.
|
||||
- The mobile nav toggle uses addEventListener only — no inline event
|
||||
handlers — so we stay CSP-nonce-compatible when Phase 6 adds the
|
||||
strict CSP middleware.
|
||||
#}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Chicken Babies R Us{% endblock %}</title>
|
||||
<meta name="description" content="{% block meta_description %}Small-farm fresh eggs and happy birds, raised in Morrison, Tennessee.{% endblock %}">
|
||||
{# Self-hosted favicon + apple touch icon — no third-party CDNs. #}
|
||||
<link rel="icon" href="{{ url_for('static', path='img/favicon.ico') }}" sizes="any">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', path='img/apple-touch-icon.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/site.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
{# Skip link: hidden until focused. First focusable element on the page. #}
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="wrap site-header__wrap">
|
||||
<a class="site-header__brand" href="/" aria-label="Chicken Babies R Us home">
|
||||
{# Chick-only mark paired with the site title as styled text.
|
||||
Decoupling the mark from the wordmark lets the header colors
|
||||
change freely without the multi-colored logo text clashing. #}
|
||||
<picture>
|
||||
<source srcset="{{ url_for('static', path='img/logo-mark.webp') }}" type="image/webp">
|
||||
<img src="{{ url_for('static', path='img/logo-mark.png') }}"
|
||||
alt=""
|
||||
height="56"
|
||||
class="site-header__mark">
|
||||
</picture>
|
||||
<span class="site-header__title">Chicken Babies R Us</span>
|
||||
</a>
|
||||
|
||||
{# The mobile toggle button — script below attaches a click handler
|
||||
that flips aria-expanded and toggles .is-open on the nav. #}
|
||||
<button type="button"
|
||||
class="site-nav__toggle"
|
||||
id="nav-toggle"
|
||||
aria-controls="primary-nav"
|
||||
aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle navigation</span>
|
||||
<span class="site-nav__toggle-bar" aria-hidden="true"></span>
|
||||
<span class="site-nav__toggle-bar" aria-hidden="true"></span>
|
||||
<span class="site-nav__toggle-bar" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<nav class="site-nav" id="primary-nav" aria-label="Primary">
|
||||
<ul class="site-nav__list">
|
||||
<li class="site-nav__item">
|
||||
<a href="/"
|
||||
class="site-nav__link{% if active_nav == 'home' %} is-active{% endif %}"
|
||||
{% if active_nav == 'home' %}aria-current="page"{% endif %}>Home</a>
|
||||
</li>
|
||||
<li class="site-nav__item">
|
||||
<a href="/about"
|
||||
class="site-nav__link{% if active_nav == 'about' %} is-active{% endif %}"
|
||||
{% if active_nav == 'about' %}aria-current="page"{% endif %}>About</a>
|
||||
</li>
|
||||
<li class="site-nav__item">
|
||||
<a href="/contact"
|
||||
class="site-nav__link{% if active_nav == 'contact' %} is-active{% endif %}"
|
||||
{% if active_nav == 'contact' %}aria-current="page"{% endif %}>Contact</a>
|
||||
</li>
|
||||
<li class="site-nav__item">
|
||||
<a href="/shop"
|
||||
class="site-nav__link nav--muted{% if active_nav == 'shop' %} is-active{% endif %}"
|
||||
{% if active_nav == 'shop' %}aria-current="page"{% endif %}>Shop</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content" class="site-main" tabindex="-1">
|
||||
<div class="wrap">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="wrap site-footer__wrap">
|
||||
<p class="site-footer__tag">
|
||||
Chicken Babies R Us · Morrison, Tennessee
|
||||
</p>
|
||||
<p class="site-footer__legal">
|
||||
© {{ now_year or 2026 }} Chicken Babies R Us. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{# Mobile nav toggle. Tiny and CSP-friendly: no inline handlers, no JS
|
||||
framework. Phase 6's CSP will be compatible with moving this into an
|
||||
external file + nonce if we grow; for now the inline block stays. #}
|
||||
<script>
|
||||
(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>
|
||||
85
app/templates/public/contact.html
Normal file
85
app/templates/public/contact.html
Normal 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 — Chicken Babies R Us{% endblock %}
|
||||
{% block meta_description %}Get in touch with Chicken Babies R Us.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Get in touch</h1>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
We'd love to hear from you — questions about the birds,
|
||||
availability, or just to say hi.
|
||||
</p>
|
||||
|
||||
{% if contact_email %}
|
||||
<p class="contact-mailto">
|
||||
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 %}
|
||||
35
app/templates/public/home.html
Normal file
35
app/templates/public/home.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{#
|
||||
Home page / blog index.
|
||||
|
||||
Receives:
|
||||
- posts : list[PostSummary] (empty in Phase 1)
|
||||
- active_nav : str "home"
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}Chicken Babies R Us — Home{% endblock %}
|
||||
{% block meta_description %}Updates from Chicken Babies R Us — a small family farm in Morrison, Tennessee.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-intro">
|
||||
<h1 class="page-intro__title">Welcome to Chicken Babies R Us</h1>
|
||||
<p class="page-intro__lede">
|
||||
A tiny family farm in Morrison, Tennessee. Follow along for updates
|
||||
on our flock, hatching plans, and whatever Head Hen is up to this week.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="post-list" aria-label="Latest posts">
|
||||
{% if posts %}
|
||||
{% for post in posts %}
|
||||
{% include "public/partials/_post_card.html" %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# Empty-state copy. Phase 2 seeds a welcome post so this state only
|
||||
ever shows up in unseeded dev databases and tests. #}
|
||||
<div class="post-list__empty">
|
||||
<p>No posts yet — check back soon!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
26
app/templates/public/partials/_post_card.html
Normal file
26
app/templates/public/partials/_post_card.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{#
|
||||
Single blog card. Rendered once per PostSummary in the home-page loop.
|
||||
|
||||
Expects the loop variable `post` in scope with:
|
||||
- post.slug (str)
|
||||
- post.title (str)
|
||||
- post.published_at (datetime)
|
||||
- post.excerpt (str)
|
||||
|
||||
The post detail page does not exist yet (Phase 2 adds it), but we link
|
||||
to /posts/<slug> anyway so the card markup is final. Phase 2 will
|
||||
register the route; until then the link 404s, which is acceptable
|
||||
because the post list itself is empty in Phase 1.
|
||||
#}
|
||||
<article class="post-card">
|
||||
<header class="post-card__header">
|
||||
<h2 class="post-card__title">
|
||||
<a href="/posts/{{ post.slug }}">{{ post.title }}</a>
|
||||
</h2>
|
||||
<time class="post-card__date"
|
||||
datetime="{{ post.published_at.isoformat() }}">
|
||||
{{ post.published_at.strftime("%B %-d, %Y") }}
|
||||
</time>
|
||||
</header>
|
||||
<p class="post-card__excerpt">{{ post.excerpt }}</p>
|
||||
</article>
|
||||
32
app/templates/public/shop.html
Normal file
32
app/templates/public/shop.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{#
|
||||
Shop placeholder. Phase 7 replaces this with a real Stripe-backed
|
||||
catalog. For now the page itself is the "disabled" UI; the nav link
|
||||
uses the `nav--muted` class to hint that it isn't fully live.
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}Shop — Chicken Babies R Us{% endblock %}
|
||||
{% block meta_description %}Our farm shop is coming soon — eggs, chicks, and waterfowl.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Shop</h1>
|
||||
</header>
|
||||
|
||||
<section class="shop-card" aria-label="Shop status">
|
||||
<h2 class="shop-card__title">Coming soon</h2>
|
||||
<p class="shop-card__body">
|
||||
We're getting the farm shop ready. Soon you'll be able to order
|
||||
eating eggs, fertile hatching eggs, day-old chicks, and a small
|
||||
selection of waterfowl (ducks and geese) when available. Pickup
|
||||
will be local to Morrison; we'll share details here when the
|
||||
shop goes live.
|
||||
</p>
|
||||
<p class="shop-card__body">
|
||||
In the meantime, if you're looking for something specific, the
|
||||
contact page is the best way to reach us.
|
||||
</p>
|
||||
</section>
|
||||
</article>
|
||||
{% endblock %}
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
0
data/media/.gitkeep
Normal file
0
data/media/.gitkeep
Normal file
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — local / on-host compose file.
|
||||
#
|
||||
# Modern Docker Compose does not require a top-level `version:` key. The
|
||||
# `web` service builds the multi-stage Dockerfile, loads secrets from
|
||||
# `.env`, and mounts the runtime `data/` directory so the SQLite DB and
|
||||
# media uploads survive container restarts.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Pass the host's current commit SHA through at build time so
|
||||
# /healthz can report which build is live. Falls back to
|
||||
# "unknown" when the env var is unset, matching the Dockerfile
|
||||
# default.
|
||||
GIT_COMMIT_SHA: ${GIT_COMMIT_SHA:-unknown}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
# Uvicorn listens on 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
163
docs/MANUAL_TESTING.md
Normal 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.
|
||||
230
docs/ROADMAP.md
230
docs/ROADMAP.md
@@ -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 3 — Admin Auth (Magic Link)
|
||||
## Phase 1 — Public 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 3–5.
|
||||
- **Logo delivery:** `<picture><source type="image/webp"><img alt="Chicken Babies R Us" height="48"></picture>` — modern browsers pull the WebP, older ones fall back to PNG.
|
||||
- **Address still not rendered.** Only city + state ("Morrison, Tennessee") appear per CLAUDE.md's "address is intentionally not displayed" wording.
|
||||
- **No new packages.** Pillow / Jinja2 / Starlette StaticFiles were already in `requirements.txt` from Phase 0.
|
||||
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 8 passed ✓ · uvicorn smoke: `/`, `/about`, `/contact`, `/shop`, `/healthz`, `/static/css/site.css`, `/static/img/logo.webp` all 200 with correct content-types ✓ · homepage body contains "No posts yet" + logo paths ✓ · contact page has `disabled` inputs and no `method` attribute ✓ · `docker compose config` exit 0 ✓.
|
||||
|
||||
## Phase 2 — Content Model + Cache ✅
|
||||
|
||||
**Completed:** 2026-04-21
|
||||
|
||||
**Summary:** Stood up the full SQLite content layer: all 7 tables from the authoritative schema, entity dataclasses + row mappers, hand-rolled versioned migrations with a `schema_migrations` tracker, idempotent Python seed (system user + welcome post + About page), a Markdown→HTML service with a strict bleach allowlist, a typed in-process TTL cache, and DB-backed `PostService` / `PageService`. `/` and `/about` now read from the DB.
|
||||
|
||||
**Key files:**
|
||||
- `app/db.py` — `create_engine()` factory, per-connection PRAGMA listener (WAL + `foreign_keys=ON`), `run_migrations(engine)` runner that scans `app/models/migrations/*.sql` in lex order and records applications in `schema_migrations`.
|
||||
- `app/models/entities.py` — all 8 dataclasses (User, MagicLinkToken, Session, Page, Post, Media, ContactSubmission, AuthEvent) + `PostStatus(str, Enum)` matching roadmap 1:1. NOT frozen — Phase 3+ mutate `last_login_at`, `used_at`, etc.
|
||||
- `app/models/mappers.py` — `row_to_user/post/page/...` converters, `_parse_datetime` / `_parse_bool` helpers.
|
||||
- `app/models/migrations/001_init.sql` — verbatim roadmap schema: 7 tables, `idx_magic_email_created`, `idx_posts_status_pub`, `idx_auth_events_created`, `CHECK (status IN ('draft','published'))`.
|
||||
- `app/models/seed.py` — idempotent: marker `seed_001` in `schema_migrations` + `INSERT OR IGNORE` belt-and-braces. Seeds user id=1 (`seed@chickenbabies.local`, "Head Hen", `active=0` — not a real admin, cannot log in), post slug `welcome-to-the-farm`, page slug `about`.
|
||||
- `app/services/cache.py` — `TTLCache[K, V]` generic (~50 lines). `get/set/invalidate_all()`. Monotonic clock. 60s default TTL.
|
||||
- `app/services/markdown.py` — `MarkdownService.render(md) -> str`: `MarkdownIt("commonmark")` (tables disabled — allowlist doesn't include `<table>`) → `bleach.clean(..., strip=True)` with tags `{p br strong em a ul ol li h1..h4 blockquote code pre img hr}`, attrs `{a:[href,title,rel], img:[src,alt,title,width,height]}`, protocols `{http https mailto}`. No `style`, no `class`, no raw HTML pass-through.
|
||||
- `app/services/posts.py` — rewritten: `PostService(engine)` runs parameterized `SELECT ... FROM posts WHERE status='published' ORDER BY published_at DESC LIMIT :limit`, converts rows to `PostSummary`. Excerpt derived from `body_html_cached`. TTL-cached. `invalidate_all()` exposed for Phase 4.
|
||||
- `app/services/pages.py` — `PageService(engine).get_by_slug(slug) -> Page | None`, TTL-cached.
|
||||
- `app/main.py` — wires engine, runs migrations, runs seed, instantiates services onto `app.state.{engine,post_service,page_service}`.
|
||||
- `app/routes/public.py` — `/about` now pulls the seeded `Page` from `PageService`; renders `{{ page.title }}` + `{{ page.body_html_cached | safe }}`. Logs an anomaly and returns 500 with a generic message if the page is unexpectedly missing.
|
||||
- `app/templates/public/about.html` — replaced static body with the dynamic page; layout kept.
|
||||
- `tests/conftest.py` — `db_engine` (session, seeded) + `clean_db_engine` (function, migrated-only) fixtures, both on temp SQLite files.
|
||||
- `tests/test_db_migrations.py`, `test_markdown.py`, `test_cache.py`, `test_post_service.py`, `test_page_service.py` — service + schema coverage.
|
||||
- `tests/test_public_routes.py` — updated: homepage now asserts "Welcome to the Farm"; `/about` asserts seeded Markdown substring.
|
||||
|
||||
**Endpoints created:** none new; `/` and `/about` were rewired to DB-backed services (same URLs, same response shapes).
|
||||
|
||||
**Key details:**
|
||||
- **Migration pattern:** every SQL file under `app/models/migrations/` gets its own transaction; already-applied files are skipped by checking `schema_migrations`. Adding a new phase = add `NNN_description.sql`. The bootstrap for `schema_migrations` itself is baked into the runner (creates the table before querying it).
|
||||
- **PRAGMAs are per-connection** via `@event.listens_for(Engine, "connect")` — every pooled connection gets WAL + FK-on, not just the first. There's an explicit test covering this.
|
||||
- **Seed idempotency is double-guarded:** `schema_migrations` marker `seed_001` + `INSERT OR IGNORE` on every row. Second boot logs `seed_skipped`; counts stay 1/1/1 (users/pages/posts).
|
||||
- **PostSummary excerpt is derived from `body_html_cached`** (HTML-stripped + truncated), not re-rendered from `body_md`. Phase 4 writers store both; readers never touch Markdown.
|
||||
- **No Markdown tables yet.** `MarkdownIt.enable("table")` was deliberately NOT called — the bleach allowlist doesn't pass `<table>`. Future tables require widening both layers together; a test documents this invariant.
|
||||
- **Address still not rendered.** Seeded About Markdown mentions Morrison, TN only (no street address, per CLAUDE.md).
|
||||
- **Phase 3 hooks ready:** `users` / `magic_link_tokens` / `sessions` / `auth_events` tables exist with their indexes; `User` dataclass + `PostStatus` enum + row-mapper helpers available.
|
||||
- **Phase 4 hooks ready:** `PostService.invalidate_all()` + `PageService.invalidate_all()` exist (no-op callers today). Admin writes will call these after each mutation.
|
||||
- **No new packages.** All deps were already pinned in Phase 0's `requirements.txt`.
|
||||
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 36 passed ✓ · fresh-boot smoke: `/` shows welcome title, `/about` shows seeded Markdown, `/healthz` 200 ✓ · `PRAGMA journal_mode=wal` ✓ · second boot logs `migrations_up_to_date` + `seed_skipped`, table counts stay `users=1 pages=1 posts=1` ✓ · `docker compose config` exit 0 ✓.
|
||||
|
||||
## Phase 3 — Admin Auth (Magic Link) ✅
|
||||
|
||||
**Completed:** 2026-04-21
|
||||
|
||||
**Summary:** Passwordless admin auth end-to-end: email form → 256-bit magic-link token (SHA-256 at rest, 15-min TTL, atomic single-use consume) → Resend email with dev-log fallback → itsdangerous-signed server-side session cookie (30d) → `/admin` landing → logout revokes the row without deleting. SlowAPI per-IP + DB per-email rate limits, `ADMIN_EMAILS` allowlist with anti-enumeration, every event audited to `auth_events`.
|
||||
|
||||
**Key files:**
|
||||
- `app/services/audit.py` — `AuditService.record(event_type, ...)` writes an `auth_events` row + mirrors the event to structlog; `detail` is JSON. Referenced session by last-6 hash chars only.
|
||||
- `app/services/email.py` — `EmailService.send_magic_link(...)`: renders HTML + text templates, posts via Resend when `resend_api_key` set, otherwise logs `magic_link_dev_fallback` with the URL (dev shortcut). Never raises from the request path; production startup refuses to boot without the key.
|
||||
- `app/services/sessions.py` — `SessionService(engine, signer, settings)` — `create/lookup/revoke`. Raw session ID exists only in memory and the signed cookie; DB stores `sha256(raw)`. Cookie `cb_session`, `HttpOnly=True`, `SameSite=lax`, `Secure` ON in prod / OFF in dev, `Path=/`, `Max-Age=SESSION_MAX_DAYS*86400`.
|
||||
- `app/services/auth.py` — `AuthService.request_link` (allowlist check → DB per-email rate-limit count → insert token row → send email → audit) and `AuthService.consume` (atomic `UPDATE magic_link_tokens SET used_at WHERE token_hash AND used_at IS NULL AND expires_at>now`, then upsert `users` row, then `SessionService.create`). Audit writes live outside the consume transaction to sidestep SQLite's single-writer semantics.
|
||||
- `app/services/rate_limit.py` — module-level `limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")`. Singleton because `@limiter.limit` decorates at import time.
|
||||
- `app/routes/admin.py` — all admin HTTP routes; `POST /admin/login` decorated `@limiter.limit("5/15 minutes")`, `GET /admin/auth/consume/{token}` decorated `@limiter.limit("20/15 minutes")`.
|
||||
- `app/dependencies/auth.py` — `get_current_user` and `require_admin` (raises `HTTPException(status_code=303, headers={"Location": "/admin/login"})`).
|
||||
- `app/templates/admin/` — `base.html`, `login.html`, `login_sent.html`, `login_failed.html`, `index.html`, `rate_limited.html`.
|
||||
- `app/templates/emails/magic_link.html` + `magic_link.txt` — magic-link email bodies.
|
||||
- `tests/test_auth_service.py`, `test_admin_routes.py`, `test_rate_limit.py`, `test_session_service.py`, `test_email_service.py` — 25 new tests covering token lifecycle, single-use, expiry, allowlist anti-enumeration, signed-cookie round-trip, revoke, user upsert, IP + DB email limits, dev email fallback, production config refusal.
|
||||
- `app/main.py` — builds `URLSafeTimedSerializer(salt="session")`, instantiates audit/email/sessions/auth onto `app.state`, installs SlowAPI `RateLimitExceeded` handler that renders `admin/rate_limited.html` with 429 and audits `rate_limited` with `scope="ip"`, includes admin router.
|
||||
- `app/config.py` — added `public_base_url` field (default `http://127.0.0.1:8000`); added `_require_auth_config_in_production` model validator: production boot refuses empty `RESEND_API_KEY` / `RESEND_FROM` / `ADMIN_EMAILS`.
|
||||
- `.env.example` — added `PUBLIC_BASE_URL=http://127.0.0.1:8000`.
|
||||
|
||||
**Endpoints created:**
|
||||
- `GET /admin/login` — email-only login form (public, no CSRF — pre-auth).
|
||||
- `POST /admin/login` — rate-limited (5/15min/IP + 5/15min/email via DB count). Always renders `login_sent.html` regardless of allowlist (anti-enumeration). Audit row `link_requested` with `{"allowlisted": bool}` every time.
|
||||
- `GET /admin/auth/consume/{token}` — rate-limited (20/15min/IP). Atomic single-use consume; on success sets `cb_session` cookie + 303 to `/admin`; on failure renders generic `login_failed.html` (no reason leakage).
|
||||
- `GET /admin` — `require_admin`; renders placeholder `index.html` with `{{ user.display_name }}` + logout form. Phase 4 replaces with real CMS dashboard.
|
||||
- `POST /admin/logout` — `require_admin`; flips `sessions.revoked_at = now`, clears cookie with `Max-Age=0`, 303 to `/admin/login`. Row preserved for audit. Marked `# TODO(phase-6-csrf)` — SameSite=Lax blocks cross-site POSTs in current browsers; Phase 6 adds a double-submit token.
|
||||
|
||||
**Key details:**
|
||||
- **Hash-at-rest for both tokens and session IDs.** DB stores `sha256(raw).hexdigest()`; raw values never persisted, never logged. Audit detail only references last-6 hash chars for correlation.
|
||||
- **Cookie signed with itsdangerous `URLSafeTimedSerializer(secret_key, salt="session")`.** Forged cookie fails signature check before any DB lookup.
|
||||
- **Anti-enumeration verified:** non-allowlisted POST → same 200 HTML response, zero rows inserted into `magic_link_tokens`, `link_requested` audit logs `{"allowlisted": false}`.
|
||||
- **Rate limit verified:** 5 POSTs succeed + 6th returns 429 from same IP; DB per-email count applied even when SlowAPI would allow.
|
||||
- **User auto-upsert on consume:** first successful consume inserts a `users` row with `display_name = local-part.title()` (e.g. `driver@example.com` → "Driver"), `active=1`; subsequent logins update `last_login_at`.
|
||||
- **Dev Resend fallback:** when `resend_api_key` is falsy, `EmailService` emits `magic_link_dev_fallback` structured log with the full URL and returns cleanly — never 500s the request path.
|
||||
- **Production config guardrails:** `app_env == "production"` requires non-empty `RESEND_API_KEY`, `RESEND_FROM`, `ADMIN_EMAILS`. Missing any → `ValueError` at startup.
|
||||
- **Single-writer deadlock avoided:** `AuthService.consume` does the atomic token update inside `engine.begin()`, then captures outcome flags, then opens separate transactions for audit + session creation. Correctness unchanged (single-use guarantee via `rowcount==1`).
|
||||
- **Audit trail smoke-tested rows:** `link_requested` (per attempt), `link_consumed`, `session_created`, `session_revoked`, `rate_limited`, `consume_failed` — all emitted during driver verification.
|
||||
- **Phase 4 hooks ready:** `require_admin` dependency + `get_current_user` are callable from any admin route. Admin CMS POSTs will reuse them.
|
||||
- **Phase 6 TODO markers** on the logout handler flag the CSRF pickup point.
|
||||
|
||||
**Verification run:**
|
||||
`python -c "from app.main import app"` ✓ · `pytest -q` 61 passed ✓ · end-to-end smoke (fresh DB, driver allowlisted): login form 200, POST login 200, dev-log URL captured, consume 200 + cookie set, `/admin` 200 with "Welcome, Driver", logout 303, post-logout `/admin` 303 to login ✓ · IP rate limit fires at 5 (6th is 429) ✓ · non-allowlisted POST 200 + zero tokens in DB ✓ · `auth_events` shows `link_requested`/`link_consumed`/`session_created`/`session_revoked`/`rate_limited` rows as expected ✓ · `docker compose config` exit 0 ✓.
|
||||
|
||||
## Phase 4 — Admin CMS ✅
|
||||
|
||||
**Completed:** 2026-04-22
|
||||
|
||||
**Summary:** Shipped the full Head Hen CMS: dashboard listing every post (drafts + published, newest `updated_at` first) plus an About-edit entry point, a hand-rolled Markdown editor (textarea + 300 ms-debounced server-side live preview + drag-drop image upload), a hardened media pipeline (magic-byte sniff → 8 MB cap → Pillow re-encode to JPEG with alpha flattened on white → random `data/media/<yyyy>/<mm>/<token>.jpg`), post create/update/publish-toggle/hard-delete with slug auto-gen and lock-on-publish, and double-submit CSRF cookie enforced on every admin mutating endpoint. Phase 3's `# TODO(phase-6-csrf)` markers are resolved — CSRF is live.
|
||||
|
||||
**Key files:**
|
||||
- `app/services/slugs.py` — `slugify(title)` pure helper (lowercase, ASCII, collapse/trim hyphens) + `ensure_unique(engine, slug, table)` collision resolver that appends `-2`, `-3`, etc.
|
||||
- `app/services/csrf.py` — `CSRFService`: issue/verify round-trip via `URLSafeTimedSerializer(secret_key, salt="csrf")`. Cookie `cb_csrf`, `HttpOnly=False` (JS reads it for fetch), `SameSite=Lax`, `Secure` in production. Separate from the `cb_session` session cookie.
|
||||
- `app/dependencies/csrf.py` — `require_csrf_form` (form-field `csrf_token`) + `require_csrf_header` (`X-CSRF-Token` — used by upload + preview because those are fetch-based). Both raise 403 on mismatch.
|
||||
- `app/services/media.py` — `MediaService(engine, media_root)`; `save_upload(file, uploaded_by) -> Media`. Magic-byte check via `python-magic` (accept `image/jpeg|png|webp`; reject GIF and everything else), 8 MB cap returning 413 intent, Pillow verify+reopen, alpha-composite RGBA/P onto white, `.save(path, "JPEG", quality=85, optimize=True)`. `secrets.token_urlsafe(16)` filename; client extension discarded. Monthly partition dir auto-created.
|
||||
- `app/services/admin_posts.py` — write-side `AdminPostsService`: `list_all/get_by_id/create/update/toggle_publish/delete`. Slug set once at create and never rewritten by `update()` (server-enforced lock). `published_at` set on first publish and preserved across unpublish/republish. Every write invalidates the read-side `PostService` cache.
|
||||
- `app/services/admin_pages.py` — About-only write service; slug immutable. Invalidates `PageService` cache on write.
|
||||
- `app/routes/admin_cms.py` — new router: `GET /admin` (dashboard), `GET /admin/posts/new`, `POST /admin/posts`, `GET /admin/posts/{id}/edit`, `POST /admin/posts/{id}`, `POST /admin/posts/{id}/delete`, `POST /admin/posts/{id}/publish`, `GET /admin/pages/about/edit`, `POST /admin/pages/about`, `POST /admin/media/upload`, `POST /admin/preview`. All mutating routes carry `require_admin` + a CSRF dep.
|
||||
- `app/templates/admin/dashboard.html` + `post_form.html` + `page_form.html` + `_post_row.html` — CMS UI. Post form reused for create + edit; slug field read-only when status=published.
|
||||
- `app/static/js/admin_editor.js` — hooks `[data-editor]`: 300 ms-debounced `POST /admin/preview` with `X-CSRF-Token` header, swaps preview via `<template>` + `cloneNode` (never `innerHTML`); drag-drop / file-picker upload to `/admin/media/upload` with FormData, inserts `` at the textarea cursor.
|
||||
- `app/templates/admin/base.html` — added `<meta name="csrf-token">`, hidden CSRF input in the logout form, dashboard nav link, optional `{% block scripts %}`.
|
||||
- `app/main.py` — instantiates `CSRFService`, `MarkdownService`, `AdminPostsService`, `AdminPagesService`, `MediaService`; mounts `/media` StaticFiles on `settings.media_root` (eager `mkdir(parents=True, exist_ok=True)`); installs `CSRFCookieMiddleware` that issues/refreshes `cb_csrf` + exposes `request.state.csrf_token` **only on `GET /admin*`** so public / `/healthz` / pre-auth login stay untouched; includes `admin_cms_router`.
|
||||
- `app/routes/admin.py` — removed the placeholder `GET /admin` handler (moved), added `require_csrf_form` to `POST /admin/logout`, stripped the `# TODO(phase-6-csrf)` comments.
|
||||
- `app/config.py` — added `media_root: str = Field(default="data/media")`.
|
||||
- `.env.example` — added `MEDIA_ROOT=data/media`.
|
||||
- `.gitignore` — carved out `!data/media/.gitkeep` so the mount dir survives checkouts.
|
||||
- `app/static/css/site.css` — extended with `.admin-dashboard`, `.post-table`, `.editor` (2-column layout at 48rem+), `.drop-zone`, `.status-badge`, `.btn--danger/.btn--secondary/.btn--link`, `.admin-flash`.
|
||||
- `docs/MANUAL_TESTING.md` — appended Phase 4 checklist.
|
||||
- Tests: `test_slugs.py`, `test_csrf_service.py`, `test_media_service.py`, `test_admin_posts_service.py`, `test_admin_pages_service.py`, `test_admin_cms_routes.py` — 64 new tests; also updated `test_admin_routes.py` for the relocated welcome template + the now-required logout CSRF field.
|
||||
|
||||
**Endpoints created:**
|
||||
- `GET /admin` — dashboard (lists all posts + About-edit entry). Replaces Phase 3 placeholder.
|
||||
- `GET /admin/posts/new` — create form.
|
||||
- `POST /admin/posts` — create handler (CSRF form).
|
||||
- `GET /admin/posts/{id}/edit` — edit form.
|
||||
- `POST /admin/posts/{id}` — update handler (CSRF form). Slug field is server-ignored when post is published.
|
||||
- `POST /admin/posts/{id}/delete` — hard delete with confirmation (CSRF form).
|
||||
- `POST /admin/posts/{id}/publish` — publish/unpublish toggle (CSRF form).
|
||||
- `GET /admin/pages/about/edit` — About edit form.
|
||||
- `POST /admin/pages/about` — About update (CSRF form). Slug immutable.
|
||||
- `POST /admin/media/upload` — multipart upload; returns `{"url": "/media/...", "alt": ""}` JSON (CSRF header).
|
||||
- `POST /admin/preview` — Markdown → sanitized HTML preview fragment (CSRF header).
|
||||
- `Mount /media` — `StaticFiles` serving `data/media/<yyyy>/<mm>/<token>.jpg`.
|
||||
|
||||
**Key details:**
|
||||
- **CSRF gating is narrow by design.** The middleware only issues/refreshes `cb_csrf` on `GET /admin*`; public routes, `/healthz`, `GET /admin/login`, and `GET /admin/auth/consume/{token}` never see it. Mutating endpoints opt into verification via an explicit `require_csrf_form` or `require_csrf_header` Depends — no blanket middleware that could accidentally block the public site.
|
||||
- **Slug lock is server-enforced.** `AdminPostsService.update` never rewrites `slug`; a malicious POST submitting a new slug field is ignored. Slug only exists because `create()` set it.
|
||||
- **Unpublish preserves `published_at`.** Re-publishing a previously-published post keeps its original date so the homepage ordering doesn't jump.
|
||||
- **Media pipeline: single output format (JPEG).** Simpler write-side, smaller files, predictable downstream behaviour. RGBA / paletted inputs get alpha-composited onto white before encode.
|
||||
- **Preview endpoint swap uses `<template>` + `cloneNode`, not `innerHTML`.** Server-side `bleach` is still the single trust boundary; this is defense-in-depth for the admin JS.
|
||||
- **Audit trail extends cleanly.** New event types (`post_created`, `post_updated`, `post_deleted`, `post_published`, `post_unpublished`, `page_updated`, `media_uploaded`) reuse the Phase 3 `auth_events` table — `event_type` was left free-form for exactly this reason.
|
||||
- **No DB mocking.** Every new test uses a real temp SQLite file per the CLAUDE.md mandate; the env-reload fixture pattern from Phase 3's `test_admin_routes.py` is reused.
|
||||
- **Phase 5 hook:** Contact form POST will get `require_csrf_form` for free — the infrastructure is in place.
|
||||
- **Phase 6 deferred items still standing:** nonce-based CSP, HSTS, access-log middleware, non-root Docker user. None blocked Phase 4.
|
||||
|
||||
**Verification run:**
|
||||
`python -c "from app.main import app"` ✓ (26 routes registered) · `pytest -q tests/test_slugs.py tests/test_csrf_service.py tests/test_media_service.py tests/test_admin_posts_service.py tests/test_admin_pages_service.py tests/test_admin_cms_routes.py tests/test_admin_routes.py` → 64 passed ✓ · full `pytest -q` → 118 passed, 2 failed; both failures are pre-existing on `dev` (the `logo.` → `logo-mark.` asset rename in commit `f5098c0`; and the `RESEND_FROM`/`ADMIN_EMAILS` pollution from local `.env` into the Settings-validator test) and unrelated to Phase 4.
|
||||
|
||||
## Phase 5 — Contact Form
|
||||
|
||||
|
||||
17
requirements.txt
Normal file
17
requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
fastapi>=0.115,<0.120
|
||||
uvicorn[standard]>=0.32,<0.35
|
||||
jinja2>=3.1,<4.0
|
||||
pydantic>=2.9,<3.0
|
||||
pydantic-settings>=2.6,<3.0
|
||||
sqlalchemy>=2.0,<2.1
|
||||
markdown-it-py>=3.0,<4.0
|
||||
bleach>=6.2,<7.0
|
||||
Pillow>=11.0,<12.0
|
||||
python-magic>=0.4.27,<0.5
|
||||
resend>=2.4,<3.0
|
||||
slowapi>=0.1.9,<0.2
|
||||
structlog>=24.4,<26.0
|
||||
itsdangerous>=2.2,<3.0
|
||||
python-multipart>=0.0.12,<0.1
|
||||
pytest>=8.3,<9.0
|
||||
httpx>=0.28,<0.29
|
||||
283
scripts/generate_static_assets.py
Normal file
283
scripts/generate_static_assets.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""Generate static image assets (logo + favicons) from the brand source.
|
||||
|
||||
This script is the single source of truth for every image under
|
||||
``app/static/img/``. Running it re-derives:
|
||||
|
||||
- ``logo.png`` — 256px tall, transparent RGBA
|
||||
- ``logo.webp`` — same size, WebP quality=82, method=6
|
||||
- ``logo-mark.png`` — chick-only mark, 128px tall, transparent RGBA
|
||||
- ``logo-mark.webp`` — same, WebP quality=82, method=6
|
||||
- ``favicon.ico`` — multi-size 16/32/48 from a square crop
|
||||
- ``apple-touch-icon.png`` — 180x180 with a cream background
|
||||
|
||||
Running it is reproducible and idempotent. The generated files are
|
||||
committed to the repo so a fresh clone can serve the site without a
|
||||
build step, but they should be regenerated (by running this script) any
|
||||
time the brand source in ``Logo/`` changes.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python scripts/generate_static_assets.py
|
||||
|
||||
The script assumes it is run from the repository root. It resolves paths
|
||||
relative to this file's location so the working directory does not
|
||||
matter in practice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
# --- Configuration constants ------------------------------------------------
|
||||
# Single-source-of-truth values. Tweaking any of these is intended to be
|
||||
# the *only* change needed to retune the assets — no other code edits.
|
||||
|
||||
# Paths are resolved relative to the repository root (the parent of the
|
||||
# directory this script lives in) so the script can be run from anywhere.
|
||||
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
|
||||
_SOURCE_LOGO: Path = _REPO_ROOT / "Logo" / "chicken babies r us.png"
|
||||
_STATIC_IMG_DIR: Path = _REPO_ROOT / "app" / "static" / "img"
|
||||
|
||||
# Target output sizes.
|
||||
_LOGO_TARGET_HEIGHT_PX: int = 256 # 2x the 48px display height in the header.
|
||||
_LOGO_MARK_TARGET_HEIGHT_PX: int = 128 # ~2x the 56px header-icon display size.
|
||||
_WEBP_QUALITY: int = 82
|
||||
_WEBP_METHOD: int = 6 # 0 = fastest, 6 = best compression.
|
||||
_FAVICON_SIZES: tuple[tuple[int, int], ...] = ((16, 16), (32, 32), (48, 48))
|
||||
_APPLE_TOUCH_SIZE: int = 180
|
||||
# Cream background that matches --c-cream in site.css, so the icon does
|
||||
# not show as transparent (iOS squares this asset against a white/black
|
||||
# home screen).
|
||||
_APPLE_TOUCH_BG: tuple[int, int, int, int] = (0xFA, 0xF3, 0xE7, 0xFF)
|
||||
|
||||
|
||||
class StaticAssetBuilder:
|
||||
"""Build every derived static image from a single PNG source.
|
||||
|
||||
Encapsulated as a class so each transformation step is an
|
||||
independently-testable method and state (e.g. the loaded source
|
||||
image) is shared without relying on module globals.
|
||||
"""
|
||||
|
||||
def __init__(self, source_path: Path, output_dir: Path) -> None:
|
||||
"""Load the source image and prepare the output directory.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source_path:
|
||||
Path to the brand-source PNG. Must exist on disk.
|
||||
output_dir:
|
||||
Directory where every generated asset is written. Created if
|
||||
it does not already exist.
|
||||
"""
|
||||
if not source_path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"Source logo not found at {source_path}. "
|
||||
"Re-check the Logo/ directory."
|
||||
)
|
||||
self._source_path = source_path
|
||||
self._output_dir = output_dir
|
||||
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Always load as RGBA so alpha compositing (apple-touch-icon
|
||||
# background) and WebP export behave predictably.
|
||||
with Image.open(source_path) as raw:
|
||||
self._source: Image.Image = raw.convert("RGBA")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Individual asset builders
|
||||
# ------------------------------------------------------------------
|
||||
def build_logo_png(self) -> Path:
|
||||
"""Write the RGBA PNG version of the logo."""
|
||||
resized = self._aspect_resize(self._source, height=_LOGO_TARGET_HEIGHT_PX)
|
||||
out_path = self._output_dir / "logo.png"
|
||||
resized.save(out_path, format="PNG", optimize=True)
|
||||
return out_path
|
||||
|
||||
def build_logo_webp(self) -> Path:
|
||||
"""Write the WebP version of the logo at the same pixel size."""
|
||||
resized = self._aspect_resize(self._source, height=_LOGO_TARGET_HEIGHT_PX)
|
||||
out_path = self._output_dir / "logo.webp"
|
||||
resized.save(
|
||||
out_path,
|
||||
format="WEBP",
|
||||
quality=_WEBP_QUALITY,
|
||||
method=_WEBP_METHOD,
|
||||
)
|
||||
return out_path
|
||||
|
||||
def build_logo_mark_png(self) -> Path:
|
||||
"""Write the chick-only mark as RGBA PNG."""
|
||||
mark = self._aspect_resize(
|
||||
self._crop_chick_mark(), height=_LOGO_MARK_TARGET_HEIGHT_PX
|
||||
)
|
||||
out_path = self._output_dir / "logo-mark.png"
|
||||
mark.save(out_path, format="PNG", optimize=True)
|
||||
return out_path
|
||||
|
||||
def build_logo_mark_webp(self) -> Path:
|
||||
"""Write the chick-only mark as WebP."""
|
||||
mark = self._aspect_resize(
|
||||
self._crop_chick_mark(), height=_LOGO_MARK_TARGET_HEIGHT_PX
|
||||
)
|
||||
out_path = self._output_dir / "logo-mark.webp"
|
||||
mark.save(
|
||||
out_path,
|
||||
format="WEBP",
|
||||
quality=_WEBP_QUALITY,
|
||||
method=_WEBP_METHOD,
|
||||
)
|
||||
return out_path
|
||||
|
||||
def build_favicon(self) -> Path:
|
||||
"""Write the multi-size ICO favicon built from a square crop.
|
||||
|
||||
The source logo is landscape, so we center it on a transparent
|
||||
square canvas sized to the longer dimension before downsizing.
|
||||
"""
|
||||
square = self._square_pad(self._source, background=(0, 0, 0, 0))
|
||||
out_path = self._output_dir / "favicon.ico"
|
||||
# Pillow's .save(..., format="ICO", sizes=...) writes every
|
||||
# requested size into one .ico file; browsers pick the best one.
|
||||
square.save(out_path, format="ICO", sizes=list(_FAVICON_SIZES))
|
||||
return out_path
|
||||
|
||||
def build_apple_touch_icon(self) -> Path:
|
||||
"""Write the 180x180 apple-touch-icon with a cream background.
|
||||
|
||||
iOS composites this icon against the home-screen background, so
|
||||
a transparent PNG would bleed through. We paint the cream brand
|
||||
background behind the logo ourselves to lock the look.
|
||||
"""
|
||||
square = self._square_pad(self._source, background=_APPLE_TOUCH_BG)
|
||||
icon = square.resize(
|
||||
(_APPLE_TOUCH_SIZE, _APPLE_TOUCH_SIZE),
|
||||
resample=Image.Resampling.LANCZOS,
|
||||
)
|
||||
out_path = self._output_dir / "apple-touch-icon.png"
|
||||
icon.save(out_path, format="PNG", optimize=True)
|
||||
return out_path
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _aspect_resize(image: Image.Image, *, height: int) -> Image.Image:
|
||||
"""Return a new image scaled to the given height, aspect preserved."""
|
||||
src_w, src_h = image.size
|
||||
# Guard against degenerate input; ratio math would divide by zero.
|
||||
if src_h == 0:
|
||||
raise ValueError("Source image has zero height.")
|
||||
new_w = max(1, round(src_w * (height / src_h)))
|
||||
return image.resize((new_w, height), resample=Image.Resampling.LANCZOS)
|
||||
|
||||
def _crop_chick_mark(self) -> Image.Image:
|
||||
"""Return a copy of the source cropped to just the chick artwork.
|
||||
|
||||
Strategy: scan the source column-by-column for transparency,
|
||||
find the widest contiguous transparent "gap" between opaque
|
||||
columns, and treat the content to the left of that gap as the
|
||||
chick. This is robust to future logo tweaks (new fonts, wider
|
||||
tracking, shifted text) because it does not rely on hardcoded
|
||||
pixel coordinates — only on the visual fact that the brand
|
||||
mark and wordmark are separated by a wider gap than any gap
|
||||
internal to either side.
|
||||
"""
|
||||
img = self._source
|
||||
alpha = img.split()[-1]
|
||||
width, height = img.size
|
||||
|
||||
# Per-column "has any opaque pixel" flags. getextrema on a 1px
|
||||
# band returns (min, max); max > 0 means at least one opaque
|
||||
# pixel. This is O(width) Pillow calls — fast enough here.
|
||||
opaque = [
|
||||
alpha.crop((x, 0, x + 1, height)).getextrema()[1] > 0
|
||||
for x in range(width)
|
||||
]
|
||||
|
||||
first_opaque = next((x for x, o in enumerate(opaque) if o), None)
|
||||
last_opaque = next(
|
||||
(x for x in range(width - 1, -1, -1) if opaque[x]), None
|
||||
)
|
||||
if first_opaque is None or last_opaque is None:
|
||||
raise ValueError("Source logo has no opaque pixels.")
|
||||
|
||||
# Enumerate transparent runs strictly between first/last opaque.
|
||||
gaps: list[tuple[int, int]] = [] # (gap_start, gap_end_exclusive)
|
||||
run_start: int | None = None
|
||||
for x in range(first_opaque, last_opaque + 1):
|
||||
if not opaque[x] and run_start is None:
|
||||
run_start = x
|
||||
elif opaque[x] and run_start is not None:
|
||||
gaps.append((run_start, x))
|
||||
run_start = None
|
||||
|
||||
if not gaps:
|
||||
raise ValueError(
|
||||
"No transparent gap found between chick and wordmark; "
|
||||
"cannot split the mark automatically."
|
||||
)
|
||||
|
||||
gap_start, _ = max(gaps, key=lambda g: g[1] - g[0])
|
||||
chick_slice = img.crop((first_opaque, 0, gap_start, height))
|
||||
|
||||
# Trim vertical whitespace so the resulting icon sits flush.
|
||||
inner = chick_slice.getbbox()
|
||||
if inner is None:
|
||||
raise ValueError("Detected chick region is empty.")
|
||||
return chick_slice.crop(inner)
|
||||
|
||||
@staticmethod
|
||||
def _square_pad(
|
||||
image: Image.Image,
|
||||
*,
|
||||
background: tuple[int, int, int, int],
|
||||
) -> Image.Image:
|
||||
"""Center the image on a square canvas of the given background.
|
||||
|
||||
We pad rather than crop so the whole mark survives at every
|
||||
favicon size. For a landscape logo, padding vertically preserves
|
||||
the design at the cost of a little empty space top and bottom —
|
||||
the correct trade-off for a recognition-first icon.
|
||||
"""
|
||||
src_w, src_h = image.size
|
||||
side = max(src_w, src_h)
|
||||
canvas = Image.new("RGBA", (side, side), background)
|
||||
offset = ((side - src_w) // 2, (side - src_h) // 2)
|
||||
canvas.paste(image, offset, mask=image if image.mode == "RGBA" else None)
|
||||
return canvas
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Generate every static asset and report the output paths.
|
||||
|
||||
Returns a shell exit code (0 on success) so the script can be chained
|
||||
into CI or build scripts without wrapping.
|
||||
"""
|
||||
builder = StaticAssetBuilder(_SOURCE_LOGO, _STATIC_IMG_DIR)
|
||||
|
||||
generated: list[Path] = [
|
||||
builder.build_logo_png(),
|
||||
builder.build_logo_webp(),
|
||||
builder.build_logo_mark_png(),
|
||||
builder.build_logo_mark_webp(),
|
||||
builder.build_favicon(),
|
||||
builder.build_apple_touch_icon(),
|
||||
]
|
||||
|
||||
# Emit a short human-readable report. Using print (not structlog)
|
||||
# because this is a one-shot developer tool, not runtime app code.
|
||||
print("Generated static assets:")
|
||||
for path in generated:
|
||||
rel = path.relative_to(_REPO_ROOT)
|
||||
print(f" - {rel}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Pytest suite for the Chicken Babies R Us app."""
|
||||
|
||||
from __future__ import annotations
|
||||
56
tests/conftest.py
Normal file
56
tests/conftest.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Shared pytest fixtures for the ``chicken_babies_site`` suite.
|
||||
|
||||
Key fixtures:
|
||||
|
||||
- ``db_engine``: a session-scoped SQLAlchemy engine pointed at a
|
||||
temp-file SQLite database. Migrations + seed run once per test
|
||||
session. Per the CLAUDE.md mandate, tests do NOT mock the DB —
|
||||
they use a real SQLite file so behavior matches production.
|
||||
- ``clean_db_engine``: a function-scoped engine with migrations
|
||||
applied but seed NOT run, for tests that need to exercise the
|
||||
first-boot path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
|
||||
from app.db import build_engine, run_migrations
|
||||
from app.models.seed import run_seed
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def db_engine(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Engine]:
|
||||
"""Return a migrated + seeded SQLite engine shared across the session.
|
||||
|
||||
Uses a real on-disk file (NOT ``:memory:``) because the CLAUDE.md
|
||||
project rules forbid mocking the DB in auth / magic-link tests,
|
||||
and doing the same here keeps the behavior identical to
|
||||
production.
|
||||
"""
|
||||
db_path: Path = tmp_path_factory.mktemp("db") / "test.db"
|
||||
engine = build_engine(f"sqlite:///{db_path}")
|
||||
run_migrations(engine)
|
||||
run_seed(engine)
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_db_engine(tmp_path: Path) -> Iterator[Engine]:
|
||||
"""Return a fresh engine with tables created but NO seed data.
|
||||
|
||||
Function-scoped so each test that uses it starts with a virgin
|
||||
database — useful for asserting first-run seed behavior and
|
||||
migration idempotency without contaminating the session-scoped
|
||||
``db_engine``.
|
||||
"""
|
||||
db_path = tmp_path / "clean.db"
|
||||
engine = build_engine(f"sqlite:///{db_path}")
|
||||
run_migrations(engine)
|
||||
yield engine
|
||||
engine.dispose()
|
||||
411
tests/test_admin_cms_routes.py
Normal file
411
tests/test_admin_cms_routes.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""End-to-end HTTP tests for the Phase 4 admin CMS.
|
||||
|
||||
Exercises the real :class:`FastAPI` app constructed by
|
||||
:func:`app.main.create_app` against a temp-file SQLite database plus
|
||||
a temp media directory. Uses the email-capture trick from
|
||||
``tests/test_admin_routes.py`` to complete the magic-link login so we
|
||||
have a real authenticated session + CSRF cookie.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
_CSRF_META_RE = re.compile(
|
||||
r'<meta name="csrf-token" content="([^"]*)"'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cms_app(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Iterator[tuple[TestClient, dict, Path]]:
|
||||
"""Build a fresh authed app + temp media dir + captured email URL."""
|
||||
media_root = tmp_path / "media"
|
||||
media_root.mkdir()
|
||||
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path}/cms.db")
|
||||
monkeypatch.setenv("ADMIN_EMAILS", "headhen@example.com")
|
||||
monkeypatch.setenv("APP_ENV", "development")
|
||||
monkeypatch.setenv(
|
||||
"SECRET_KEY", "test-only-secret-key-0123456789abcdef-XYZ"
|
||||
)
|
||||
monkeypatch.setenv("RESEND_API_KEY", "")
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "http://testserver")
|
||||
monkeypatch.setenv("MEDIA_ROOT", str(media_root))
|
||||
|
||||
from app import config as _config
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
import app.main as main_module
|
||||
|
||||
importlib.reload(main_module)
|
||||
app = main_module.app
|
||||
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def _capture(**kw):
|
||||
captured["url"] = kw["url"]
|
||||
captured["to"] = kw["to"]
|
||||
|
||||
app.state.email_service.send_magic_link = _capture # type: ignore[assignment]
|
||||
|
||||
from app.services.rate_limit import limiter
|
||||
|
||||
limiter.reset()
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client, captured, media_root
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
|
||||
def _login(client: TestClient, captured: dict) -> None:
|
||||
"""Walk the magic-link flow so subsequent requests are authed."""
|
||||
client.post("/admin/login", data={"email": "headhen@example.com"})
|
||||
assert "url" in captured
|
||||
consume_path = captured["url"].replace("http://testserver", "")
|
||||
resp = client.get(consume_path, follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
def _dashboard_csrf(client: TestClient) -> str:
|
||||
"""GET /admin and extract the csrf-token meta tag."""
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200
|
||||
match = _CSRF_META_RE.search(resp.text)
|
||||
assert match, "csrf-token meta tag missing"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# unauthed
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_dashboard_redirects_unauthed_to_login(cms_app) -> None:
|
||||
"""An unauthenticated GET /admin yields 303 to /admin/login."""
|
||||
client, _, _ = cms_app
|
||||
resp = client.get("/admin", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/admin/login"
|
||||
|
||||
|
||||
def test_post_create_without_auth_is_redirected(cms_app) -> None:
|
||||
"""Create POST is gated behind require_admin."""
|
||||
client, _, _ = cms_app
|
||||
resp = client.post(
|
||||
"/admin/posts",
|
||||
data={"title": "x", "body_md": "y", "status": "draft", "csrf_token": ""},
|
||||
follow_redirects=False,
|
||||
)
|
||||
# Unauthenticated -> require_admin raises 303.
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_dashboard_renders_with_seeded_post(cms_app) -> None:
|
||||
"""The seeded welcome post shows up in the admin table."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200
|
||||
assert "Dashboard" in resp.text
|
||||
assert "Welcome to the Farm" in resp.text
|
||||
assert "New post" in resp.text
|
||||
assert "Edit About" in resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create + edit + delete
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_create_post_happy_path(cms_app) -> None:
|
||||
"""POST /admin/posts with CSRF creates a row and 303s to the dashboard."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
"/admin/posts",
|
||||
data={
|
||||
"title": "First Admin Post",
|
||||
"body_md": "Hello **world**.",
|
||||
"status": "draft",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"].startswith("/admin?msg=created")
|
||||
|
||||
# The new post is visible on the dashboard.
|
||||
dash = client.get("/admin")
|
||||
assert "First Admin Post" in dash.text
|
||||
|
||||
|
||||
def test_create_post_without_csrf_is_forbidden(cms_app) -> None:
|
||||
"""Missing csrf_token on create returns 403."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
# Touch /admin to materialize the cb_csrf cookie.
|
||||
client.get("/admin")
|
||||
|
||||
resp = client.post(
|
||||
"/admin/posts",
|
||||
data={
|
||||
"title": "Sneaky",
|
||||
"body_md": "x",
|
||||
"status": "draft",
|
||||
"csrf_token": "", # wrong
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_update_and_publish_and_delete(cms_app) -> None:
|
||||
"""Full CRUD + publish toggle + delete cycle."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
|
||||
# create
|
||||
client.post(
|
||||
"/admin/posts",
|
||||
data={
|
||||
"title": "Toggle Target",
|
||||
"body_md": "body-v1",
|
||||
"status": "draft",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
)
|
||||
# look up the post id via the engine
|
||||
with client.app.state.engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM posts WHERE title = :t"),
|
||||
{"t": "Toggle Target"},
|
||||
).mappings().first()
|
||||
assert row is not None
|
||||
post_id = int(row["id"])
|
||||
|
||||
# edit form renders
|
||||
edit_resp = client.get(f"/admin/posts/{post_id}/edit")
|
||||
assert edit_resp.status_code == 200
|
||||
assert "Toggle Target" in edit_resp.text
|
||||
# CSRF for subsequent POSTs should come off the edit page.
|
||||
match = _CSRF_META_RE.search(edit_resp.text)
|
||||
assert match
|
||||
csrf = match.group(1)
|
||||
|
||||
# update
|
||||
upd = client.post(
|
||||
f"/admin/posts/{post_id}",
|
||||
data={
|
||||
"title": "Toggle Target Edited",
|
||||
"body_md": "body-v2",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert upd.status_code == 303
|
||||
|
||||
# publish
|
||||
pub = client.post(
|
||||
f"/admin/posts/{post_id}/publish",
|
||||
data={"csrf_token": csrf},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert pub.status_code == 303
|
||||
assert pub.headers["location"] == "/admin?msg=published"
|
||||
|
||||
# unpublish
|
||||
unpub = client.post(
|
||||
f"/admin/posts/{post_id}/publish",
|
||||
data={"csrf_token": csrf},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert unpub.status_code == 303
|
||||
assert unpub.headers["location"] == "/admin?msg=unpublished"
|
||||
|
||||
# delete
|
||||
delete = client.post(
|
||||
f"/admin/posts/{post_id}/delete",
|
||||
data={"csrf_token": csrf},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert delete.status_code == 303
|
||||
assert delete.headers["location"] == "/admin?msg=deleted"
|
||||
|
||||
# row is gone
|
||||
with client.app.state.engine.connect() as conn:
|
||||
gone = conn.execute(
|
||||
text("SELECT id FROM posts WHERE id = :i"),
|
||||
{"i": post_id},
|
||||
).first()
|
||||
assert gone is None
|
||||
|
||||
|
||||
def test_create_post_rejects_blank_title(cms_app) -> None:
|
||||
"""Blank title re-renders the form at 400."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
resp = client.post(
|
||||
"/admin/posts",
|
||||
data={
|
||||
"title": " ",
|
||||
"body_md": "x",
|
||||
"status": "draft",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Title is required" in resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# About page
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_about_update(cms_app) -> None:
|
||||
"""Editing the About page updates the stored copy."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
form_resp = client.get("/admin/pages/about/edit")
|
||||
assert form_resp.status_code == 200
|
||||
match = _CSRF_META_RE.search(form_resp.text)
|
||||
assert match
|
||||
csrf = match.group(1)
|
||||
|
||||
resp = client.post(
|
||||
"/admin/pages/about",
|
||||
data={
|
||||
"title": "Re-titled About",
|
||||
"body_md": "Edited **copy**.",
|
||||
"csrf_token": csrf,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/admin?msg=saved"
|
||||
|
||||
# Public site reflects the change (cache was invalidated).
|
||||
public = client.get("/about")
|
||||
assert public.status_code == 200
|
||||
assert "Re-titled About" in public.text
|
||||
assert "<strong>copy</strong>" in public.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# media upload
|
||||
# ---------------------------------------------------------------------------
|
||||
def _jpeg_bytes() -> bytes:
|
||||
img = Image.new("RGB", (32, 32), "blue")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_media_upload_happy_path(cms_app) -> None:
|
||||
"""Valid JPEG upload with header CSRF returns JSON with a /media URL."""
|
||||
client, captured, media_root = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
|
||||
data = _jpeg_bytes()
|
||||
files = {"file": ("photo.jpg", data, "image/jpeg")}
|
||||
resp = client.post(
|
||||
"/admin/media/upload",
|
||||
files=files,
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
payload = resp.json()
|
||||
assert payload["url"].startswith("/media/")
|
||||
assert payload["filename"].endswith(".jpg")
|
||||
|
||||
# The file actually exists under the media root.
|
||||
stored = Path(media_root)
|
||||
assert any(stored.rglob("*.jpg"))
|
||||
|
||||
|
||||
def test_media_upload_missing_csrf_is_forbidden(cms_app) -> None:
|
||||
"""Without the X-CSRF-Token header the upload is 403."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
# Warm cookie.
|
||||
client.get("/admin")
|
||||
files = {"file": ("x.jpg", _jpeg_bytes(), "image/jpeg")}
|
||||
resp = client.post("/admin/media/upload", files=files)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_media_upload_rejects_non_image(cms_app) -> None:
|
||||
"""Plain text rejected with a JSON error payload."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
files = {"file": ("fake.jpg", b"not an image at all, really not", "image/jpeg")}
|
||||
resp = client.post(
|
||||
"/admin/media/upload",
|
||||
files=files,
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "error" in resp.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# preview
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_preview_renders_sanitized_html(cms_app) -> None:
|
||||
"""Preview endpoint returns the same sanitized HTML as server-side."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
"/admin/preview",
|
||||
data={"markdown": "Hello **there**."},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "<strong>there</strong>" in resp.text
|
||||
|
||||
|
||||
def test_preview_strips_raw_html(cms_app) -> None:
|
||||
"""Raw HTML in the markdown input is sanitized away."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
csrf = _dashboard_csrf(client)
|
||||
|
||||
resp = client.post(
|
||||
"/admin/preview",
|
||||
data={"markdown": "<script>alert(1)</script>\n\nOK."},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "<script>" not in resp.text
|
||||
|
||||
|
||||
def test_preview_without_csrf_is_forbidden(cms_app) -> None:
|
||||
"""Preview endpoint enforces CSRF via header."""
|
||||
client, captured, _ = cms_app
|
||||
_login(client, captured)
|
||||
client.get("/admin") # warm cookie
|
||||
resp = client.post(
|
||||
"/admin/preview",
|
||||
data={"markdown": "hi"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
97
tests/test_admin_pages_service.py
Normal file
97
tests/test_admin_pages_service.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Tests for :class:`app.services.admin_pages.AdminPagesService`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
|
||||
from app.db import build_engine, run_migrations
|
||||
from app.models.seed import run_seed
|
||||
from app.services.admin_pages import AdminPagesService
|
||||
from app.services.audit import AuditService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(tmp_path: Path) -> Iterator[Engine]:
|
||||
"""Return an isolated migrated + seeded engine."""
|
||||
db_path = tmp_path / "phase4_pages.db"
|
||||
eng = build_engine(f"sqlite:///{db_path}")
|
||||
run_migrations(eng)
|
||||
run_seed(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(engine: Engine) -> AdminPagesService:
|
||||
"""Return a fully-wired :class:`AdminPagesService`."""
|
||||
return AdminPagesService(
|
||||
engine=engine,
|
||||
markdown=MarkdownService(),
|
||||
page_service=PageService(engine),
|
||||
post_service=PostService(engine),
|
||||
audit=AuditService(engine),
|
||||
)
|
||||
|
||||
|
||||
def test_get_about_returns_seeded_page(service: AdminPagesService) -> None:
|
||||
"""The seeded About page loads."""
|
||||
page = service.get_about()
|
||||
assert page is not None
|
||||
assert page.slug == "about"
|
||||
assert page.title == "About the Farm"
|
||||
|
||||
|
||||
def test_update_about_rewrites_html_cache(
|
||||
service: AdminPagesService,
|
||||
) -> None:
|
||||
"""Update regenerates the sanitized HTML and bumps ``updated_at``."""
|
||||
before = service.get_about()
|
||||
assert before is not None
|
||||
updated = service.update_about(
|
||||
title="New About Title",
|
||||
body_md="Some **bold** copy.",
|
||||
actor_user_id=1,
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.slug == "about" # slug immutable
|
||||
assert updated.title == "New About Title"
|
||||
assert "<strong>bold</strong>" in updated.body_html_cached
|
||||
# updated_at should bump (or at least not go backwards).
|
||||
assert updated.updated_at >= before.updated_at
|
||||
|
||||
|
||||
def test_update_about_invalidates_public_page_cache(engine: Engine) -> None:
|
||||
"""After an update the public :class:`PageService` cache returns fresh copy."""
|
||||
md = MarkdownService()
|
||||
pages = PageService(engine)
|
||||
posts = PostService(engine)
|
||||
audit = AuditService(engine)
|
||||
service = AdminPagesService(
|
||||
engine=engine,
|
||||
markdown=md,
|
||||
page_service=pages,
|
||||
post_service=posts,
|
||||
audit=audit,
|
||||
)
|
||||
|
||||
primed = pages.get_by_slug("about")
|
||||
# Cache hit confirmation:
|
||||
assert pages.get_by_slug("about") is primed
|
||||
|
||||
service.update_about(
|
||||
title="After",
|
||||
body_md="updated copy",
|
||||
actor_user_id=1,
|
||||
)
|
||||
|
||||
after = pages.get_by_slug("about")
|
||||
assert after is not None
|
||||
assert after is not primed
|
||||
assert after.title == "After"
|
||||
242
tests/test_admin_posts_service.py
Normal file
242
tests/test_admin_posts_service.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Tests for :class:`app.services.admin_posts.AdminPostsService`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.db import build_engine, run_migrations
|
||||
from app.models.entities import PostStatus
|
||||
from app.models.seed import run_seed
|
||||
from app.services.admin_posts import AdminPostsService
|
||||
from app.services.audit import AuditService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
|
||||
|
||||
# Dedicated function-scoped DB per test so publish / delete side
|
||||
# effects don't leak between tests.
|
||||
@pytest.fixture
|
||||
def engine(tmp_path: Path) -> Iterator[Engine]:
|
||||
"""Return a migrated + seeded engine in an isolated file."""
|
||||
db_path = tmp_path / "phase4_posts.db"
|
||||
eng = build_engine(f"sqlite:///{db_path}")
|
||||
run_migrations(eng)
|
||||
run_seed(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(engine: Engine) -> AdminPostsService:
|
||||
"""Return a fully-wired :class:`AdminPostsService`."""
|
||||
md = MarkdownService()
|
||||
posts = PostService(engine)
|
||||
pages = PageService(engine)
|
||||
audit = AuditService(engine)
|
||||
return AdminPostsService(
|
||||
engine=engine,
|
||||
markdown=md,
|
||||
post_service=posts,
|
||||
page_service=pages,
|
||||
audit=audit,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_create_draft_post(service: AdminPostsService) -> None:
|
||||
"""Create generates a slug, renders HTML, and leaves ``published_at`` NULL."""
|
||||
post = service.create(
|
||||
title="My First Post",
|
||||
body_md="Hello **world**.",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
assert post.id > 0
|
||||
assert post.slug == "my-first-post"
|
||||
assert post.status is PostStatus.DRAFT
|
||||
assert post.published_at is None
|
||||
# Markdown pipeline ran and wrote sanitized HTML.
|
||||
assert "<strong>world</strong>" in post.body_html_cached
|
||||
|
||||
|
||||
def test_create_publish_stamps_published_at(service: AdminPostsService) -> None:
|
||||
"""``status=published`` sets ``published_at`` to the creation time."""
|
||||
post = service.create(
|
||||
title="Announcement",
|
||||
body_md="body",
|
||||
status=PostStatus.PUBLISHED,
|
||||
author_id=1,
|
||||
)
|
||||
assert post.status is PostStatus.PUBLISHED
|
||||
assert post.published_at is not None
|
||||
|
||||
|
||||
def test_create_collision_yields_suffixed_slug(
|
||||
service: AdminPostsService,
|
||||
) -> None:
|
||||
"""Second post with the same title slugs as ``foo-2``."""
|
||||
a = service.create(
|
||||
title="Chicks Day",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
b = service.create(
|
||||
title="Chicks Day",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
assert a.slug == "chicks-day"
|
||||
assert b.slug == "chicks-day-2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_update_changes_title_body_but_not_slug(
|
||||
service: AdminPostsService,
|
||||
) -> None:
|
||||
"""Updates regenerate HTML and bump updated_at; slug is locked."""
|
||||
post = service.create(
|
||||
title="Original",
|
||||
body_md="before",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
updated = service.update(
|
||||
post.id,
|
||||
title="Original Retitled",
|
||||
body_md="after",
|
||||
actor_user_id=1,
|
||||
)
|
||||
assert updated is not None
|
||||
# Slug is preserved — not regenerated even on title change.
|
||||
assert updated.slug == post.slug
|
||||
assert updated.title == "Original Retitled"
|
||||
assert "after" in updated.body_html_cached
|
||||
assert "before" not in updated.body_html_cached
|
||||
|
||||
|
||||
def test_update_unknown_post_returns_none(service: AdminPostsService) -> None:
|
||||
"""An update on a missing post id returns ``None``."""
|
||||
assert service.update(99999, title="x", body_md="y", actor_user_id=1) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# toggle_publish
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_toggle_publish_draft_to_published(service: AdminPostsService) -> None:
|
||||
"""Draft → published stamps ``published_at`` once."""
|
||||
post = service.create(
|
||||
title="Togglable",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
published = service.toggle_publish(post.id, actor_user_id=1)
|
||||
assert published is not None
|
||||
assert published.status is PostStatus.PUBLISHED
|
||||
first_published_at = published.published_at
|
||||
assert first_published_at is not None
|
||||
|
||||
|
||||
def test_toggle_publish_preserves_original_published_at(
|
||||
service: AdminPostsService,
|
||||
) -> None:
|
||||
"""Unpublish → re-publish keeps the original ``published_at``."""
|
||||
post = service.create(
|
||||
title="Preserve",
|
||||
body_md="x",
|
||||
status=PostStatus.PUBLISHED,
|
||||
author_id=1,
|
||||
)
|
||||
original_published_at = post.published_at
|
||||
assert original_published_at is not None
|
||||
|
||||
unpublished = service.toggle_publish(post.id, actor_user_id=1)
|
||||
assert unpublished is not None
|
||||
assert unpublished.status is PostStatus.DRAFT
|
||||
# published_at preserved on unpublish per Phase 4 brief.
|
||||
assert unpublished.published_at == original_published_at
|
||||
|
||||
republished = service.toggle_publish(post.id, actor_user_id=1)
|
||||
assert republished is not None
|
||||
assert republished.status is PostStatus.PUBLISHED
|
||||
# published_at preserved across the re-publish cycle.
|
||||
assert republished.published_at == original_published_at
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# delete
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_delete_removes_row(service: AdminPostsService, engine: Engine) -> None:
|
||||
"""Delete really removes the row — no soft-delete column exists."""
|
||||
post = service.create(
|
||||
title="DeleteMe",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
assert service.delete(post.id, actor_user_id=1) is True
|
||||
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM posts WHERE id = :id"),
|
||||
{"id": post.id},
|
||||
).first()
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_delete_unknown_returns_false(service: AdminPostsService) -> None:
|
||||
"""Deleting a nonexistent post id returns False without raising."""
|
||||
assert service.delete(99999, actor_user_id=1) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cache invalidation
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_create_invalidates_public_post_cache(
|
||||
engine: Engine,
|
||||
) -> None:
|
||||
"""Creating a post clears the :class:`PostService` cache.
|
||||
|
||||
We prime the cache by calling ``list_published``, then use the
|
||||
admin service to insert a published post, and confirm the next
|
||||
``list_published`` call returns a freshly-built list (identity
|
||||
different, content includes the new slug).
|
||||
"""
|
||||
md = MarkdownService()
|
||||
posts = PostService(engine)
|
||||
pages = PageService(engine)
|
||||
audit = AuditService(engine)
|
||||
service = AdminPostsService(
|
||||
engine=engine,
|
||||
markdown=md,
|
||||
post_service=posts,
|
||||
page_service=pages,
|
||||
audit=audit,
|
||||
)
|
||||
|
||||
primed = posts.list_published()
|
||||
# Hit the cache so we have a concrete reference to compare against.
|
||||
still_cached = posts.list_published()
|
||||
assert primed is still_cached
|
||||
|
||||
created = service.create(
|
||||
title="Fresh Post",
|
||||
body_md="x",
|
||||
status=PostStatus.PUBLISHED,
|
||||
author_id=1,
|
||||
)
|
||||
|
||||
after = posts.list_published()
|
||||
assert after is not primed
|
||||
assert any(p.slug == created.slug for p in after)
|
||||
230
tests/test_admin_routes.py
Normal file
230
tests/test_admin_routes.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""End-to-end HTTP tests for the admin auth flow.
|
||||
|
||||
Exercises the real :class:`FastAPI` app constructed by
|
||||
:func:`app.main.create_app` against a temp-file SQLite database and a
|
||||
monkeypatched ``EmailService`` so we can capture the magic-link URL
|
||||
without depending on Resend.
|
||||
|
||||
These tests intentionally do NOT use the session-scoped ``db_engine``
|
||||
fixture — each builds its own fresh app so rate-limit state and DB
|
||||
rows don't leak between tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
# Regex used to extract the raw token from the dev-fallback log URL.
|
||||
_URL_RE = re.compile(r"http[s]?://[^\s]+/admin/auth/consume/[A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
def _csrf_from_response(resp) -> str:
|
||||
"""Extract the CSRF token from a rendered admin page.
|
||||
|
||||
The admin base template emits a ``<meta name="csrf-token">`` tag
|
||||
populated by the CSRFCookieMiddleware. Tests pull it from there
|
||||
rather than the cookie value directly so the assertion also
|
||||
proves the template wiring is intact.
|
||||
"""
|
||||
match = re.search(
|
||||
r'<meta name="csrf-token" content="([^"]*)"', resp.text
|
||||
)
|
||||
assert match, "csrf-token meta tag missing"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Iterator[tuple[TestClient, dict]]:
|
||||
"""Build a fresh FastAPI app wired to a tmp DB + captured email URL."""
|
||||
# Point the settings loader at a clean DB and a known allowlist.
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path}/admin.db")
|
||||
monkeypatch.setenv("ADMIN_EMAILS", "headhen@example.com")
|
||||
monkeypatch.setenv("APP_ENV", "development")
|
||||
monkeypatch.setenv(
|
||||
"SECRET_KEY", "test-only-secret-key-0123456789abcdef-XYZ"
|
||||
)
|
||||
monkeypatch.setenv("RESEND_API_KEY", "")
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "http://testserver")
|
||||
|
||||
# Reload config + main so the new env is picked up.
|
||||
from app import config as _config
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
import app.main as main_module
|
||||
|
||||
importlib.reload(main_module)
|
||||
app = main_module.app
|
||||
|
||||
# Capture the magic-link URL instead of sending via Resend.
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def _capture(**kw):
|
||||
captured["url"] = kw["url"]
|
||||
captured["to"] = kw["to"]
|
||||
|
||||
app.state.email_service.send_magic_link = _capture # type: ignore[assignment]
|
||||
|
||||
# Reset the in-memory rate limiter between tests (module-level
|
||||
# singleton would otherwise leak state across tests).
|
||||
from app.services.rate_limit import limiter
|
||||
|
||||
limiter.reset()
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client, captured
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
|
||||
def _auth_events(app, event_type: str | None = None) -> list[dict]:
|
||||
"""Return rows from ``auth_events`` (optionally filtered by type)."""
|
||||
with app.state.engine.connect() as conn:
|
||||
if event_type:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT event_type, email, detail FROM auth_events"
|
||||
" WHERE event_type = :t ORDER BY id"
|
||||
),
|
||||
{"t": event_type},
|
||||
).mappings().all()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT event_type, email, detail FROM auth_events"
|
||||
" ORDER BY id"
|
||||
)
|
||||
).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def test_full_login_flow(admin_app) -> None:
|
||||
"""GET login, POST, consume, GET /admin, POST logout."""
|
||||
client, captured = admin_app
|
||||
|
||||
# 1. GET login page.
|
||||
resp = client.get("/admin/login")
|
||||
assert resp.status_code == 200
|
||||
assert "Admin log in" in resp.text
|
||||
assert 'name="email"' in resp.text
|
||||
|
||||
# 2. POST login with allowlisted email.
|
||||
resp = client.post("/admin/login", data={"email": "headhen@example.com"})
|
||||
assert resp.status_code == 200
|
||||
assert "Check your inbox" in resp.text
|
||||
assert "url" in captured
|
||||
raw_url = captured["url"]
|
||||
assert "/admin/auth/consume/" in raw_url
|
||||
|
||||
# 3. Consume the magic link.
|
||||
consume_path = raw_url.replace("http://testserver", "")
|
||||
resp = client.get(consume_path, follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/admin"
|
||||
# cb_session cookie set.
|
||||
assert "cb_session" in resp.cookies
|
||||
|
||||
# 4. GET /admin renders the Phase 4 dashboard.
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200
|
||||
assert "Dashboard" in resp.text
|
||||
assert "headhen@example.com" in resp.text
|
||||
csrf_token = _csrf_from_response(resp)
|
||||
|
||||
# 5. POST /admin/logout clears cookie + redirects. CSRF required
|
||||
# now that Phase 4 has enabled the double-submit cookie.
|
||||
resp = client.post(
|
||||
"/admin/logout",
|
||||
data={"csrf_token": csrf_token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/admin/login"
|
||||
|
||||
# 6. Subsequent /admin redirects back to login.
|
||||
# Manually drop the cookie from the test client, mirroring a browser
|
||||
# that honored the delete_cookie directive.
|
||||
client.cookies.clear()
|
||||
resp = client.get("/admin", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/admin/login"
|
||||
|
||||
# Audit rows: link_requested, link_consumed, session_created, session_revoked.
|
||||
events = [e["event_type"] for e in _auth_events(client.app)]
|
||||
for required in (
|
||||
"link_requested",
|
||||
"link_consumed",
|
||||
"session_created",
|
||||
"session_revoked",
|
||||
):
|
||||
assert required in events, f"missing audit event: {required}"
|
||||
|
||||
|
||||
def test_non_allowlisted_returns_same_page_no_token(admin_app) -> None:
|
||||
"""Non-allowlisted email: same 200 + 'Check your inbox', no token row."""
|
||||
client, captured = admin_app
|
||||
resp = client.post("/admin/login", data={"email": "stranger@example.com"})
|
||||
assert resp.status_code == 200
|
||||
assert "Check your inbox" in resp.text
|
||||
assert "url" not in captured # email not sent
|
||||
|
||||
# No token row for this email.
|
||||
with client.app.state.engine.connect() as conn:
|
||||
count = conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) AS c FROM magic_link_tokens"
|
||||
" WHERE email = :e"
|
||||
),
|
||||
{"e": "stranger@example.com"},
|
||||
).mappings().first()
|
||||
assert count is not None and int(count["c"]) == 0
|
||||
|
||||
# Audit trail still records the attempt.
|
||||
events = _auth_events(client.app, "link_requested")
|
||||
assert len(events) == 1
|
||||
assert "\"allowlisted\": false" in events[0]["detail"]
|
||||
|
||||
|
||||
def test_invalid_token_returns_400(admin_app) -> None:
|
||||
"""A random token on the consume endpoint returns the generic 400 page."""
|
||||
client, _ = admin_app
|
||||
resp = client.get("/admin/auth/consume/not-a-real-token", follow_redirects=False)
|
||||
assert resp.status_code == 400
|
||||
assert "Login link invalid" in resp.text
|
||||
|
||||
|
||||
def test_login_form_validation(admin_app) -> None:
|
||||
"""Empty / malformed email surfaces the inline validation message."""
|
||||
client, _ = admin_app
|
||||
resp = client.post("/admin/login", data={"email": ""})
|
||||
assert resp.status_code == 400
|
||||
assert "valid email" in resp.text
|
||||
|
||||
resp = client.post("/admin/login", data={"email": "not-an-email"})
|
||||
assert resp.status_code == 400
|
||||
assert "valid email" in resp.text
|
||||
|
||||
|
||||
def test_replay_attack_fails(admin_app) -> None:
|
||||
"""A consumed magic link cannot be used a second time."""
|
||||
client, captured = admin_app
|
||||
client.post("/admin/login", data={"email": "headhen@example.com"})
|
||||
url = captured["url"].replace("http://testserver", "")
|
||||
|
||||
first = client.get(url, follow_redirects=False)
|
||||
assert first.status_code == 303
|
||||
|
||||
# Clear cookies to simulate a replay from a fresh browser context.
|
||||
client.cookies.clear()
|
||||
second = client.get(url, follow_redirects=False)
|
||||
assert second.status_code == 400
|
||||
275
tests/test_auth_service.py
Normal file
275
tests/test_auth_service.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Tests for :class:`app.services.auth.AuthService`.
|
||||
|
||||
Covers:
|
||||
- Allowlisted email → token inserted, email "sent" (dev fallback), audit
|
||||
rows emitted.
|
||||
- Non-allowlisted email → no token row, audit row with allowlisted=False.
|
||||
- Consume happy path (creates user on first login, bumps last_login_at
|
||||
on subsequent logins, issues session).
|
||||
- Consume replay → second attempt returns None + consume_failed/used audit.
|
||||
- Consume expired token → None + consume_failed/expired.
|
||||
- Per-email rate-limit threshold trips on the 6th request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
from pathlib import Path
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.services.audit import AuditService
|
||||
from app.services.auth import AuthService, RateLimitedError
|
||||
from app.services.email import EmailService
|
||||
from app.services.sessions import SessionService
|
||||
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "app" / "templates"
|
||||
|
||||
|
||||
def _build(engine: Engine, *, admin_emails: str = "headhen@example.com") -> tuple[AuthService, Settings]:
|
||||
"""Wire up an AuthService against a real SQLite engine."""
|
||||
settings = Settings(
|
||||
app_env="development",
|
||||
secret_key="a-very-long-test-secret-key-1234567890",
|
||||
admin_emails=admin_emails,
|
||||
resend_api_key=None,
|
||||
resend_from=None,
|
||||
public_base_url="http://localhost:8000",
|
||||
magic_link_ttl_min=15,
|
||||
) # type: ignore[call-arg]
|
||||
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
|
||||
signer = URLSafeTimedSerializer(settings.secret_key, salt="session")
|
||||
email = EmailService(settings, templates)
|
||||
audit = AuditService(engine)
|
||||
sessions = SessionService(engine, signer, settings)
|
||||
auth = AuthService(engine, email, sessions, audit, settings)
|
||||
return auth, settings
|
||||
|
||||
|
||||
def _count(engine: Engine, table: str, where: str = "1=1", **params) -> int:
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(f"SELECT COUNT(*) AS c FROM {table} WHERE {where}"), params
|
||||
).mappings().first()
|
||||
return int(row["c"]) if row is not None else 0
|
||||
|
||||
|
||||
def _latest_detail(engine: Engine, event_type: str) -> str:
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT detail FROM auth_events WHERE event_type = :t"
|
||||
" ORDER BY id DESC LIMIT 1"
|
||||
),
|
||||
{"t": event_type},
|
||||
).mappings().first()
|
||||
return str(row["detail"]) if row is not None else ""
|
||||
|
||||
|
||||
def test_allowlisted_request_inserts_token_and_audit(
|
||||
clean_db_engine: Engine,
|
||||
) -> None:
|
||||
"""An allowlisted email mints a token row and a link_requested audit."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
auth.request_link(email="HeadHen@example.com", ip="1.2.3.4", user_agent="ua")
|
||||
|
||||
assert _count(clean_db_engine, "magic_link_tokens") == 1
|
||||
# Stored email is lowercased.
|
||||
with clean_db_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT email, token_hash FROM magic_link_tokens")
|
||||
).mappings().first()
|
||||
assert row["email"] == "headhen@example.com"
|
||||
# Hex SHA-256 is 64 chars.
|
||||
assert len(row["token_hash"]) == 64
|
||||
|
||||
assert _count(clean_db_engine, "auth_events", "event_type='link_requested'") == 1
|
||||
assert "\"allowlisted\": true" in _latest_detail(
|
||||
clean_db_engine, "link_requested"
|
||||
)
|
||||
|
||||
|
||||
def test_non_allowlisted_request_inserts_no_token(
|
||||
clean_db_engine: Engine,
|
||||
) -> None:
|
||||
"""A non-allowlisted email gets an audit row and no token."""
|
||||
auth, _ = _build(clean_db_engine, admin_emails="headhen@example.com")
|
||||
auth.request_link(email="intruder@example.com", ip="1.2.3.4", user_agent="ua")
|
||||
|
||||
assert _count(clean_db_engine, "magic_link_tokens") == 0
|
||||
assert _count(clean_db_engine, "auth_events", "event_type='link_requested'") == 1
|
||||
assert "\"allowlisted\": false" in _latest_detail(
|
||||
clean_db_engine, "link_requested"
|
||||
)
|
||||
|
||||
|
||||
def test_consume_happy_path_creates_user_and_session(
|
||||
clean_db_engine: Engine,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Consuming a fresh token upserts a user and mints a session."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
|
||||
# Intercept the raw token via the dev-fallback log. Easiest path:
|
||||
# patch EmailService.send_magic_link on this instance.
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def _capture(**kw):
|
||||
captured["url"] = kw["url"]
|
||||
|
||||
monkeypatch.setattr(auth._email, "send_magic_link", _capture)
|
||||
|
||||
auth.request_link(email="headhen@example.com", ip="1.2.3.4", user_agent="ua")
|
||||
assert "url" in captured
|
||||
raw_token = captured["url"].rsplit("/", 1)[-1]
|
||||
|
||||
result = auth.consume(
|
||||
raw_token=raw_token, ip="1.2.3.4", user_agent="ua"
|
||||
)
|
||||
assert result is not None
|
||||
user, session, cookie = result
|
||||
|
||||
assert user.email == "headhen@example.com"
|
||||
assert user.display_name == "Headhen"
|
||||
assert user.active is True
|
||||
|
||||
# User row persisted with last_login_at set.
|
||||
with clean_db_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT last_login_at FROM users WHERE id = :id"),
|
||||
{"id": user.id},
|
||||
).mappings().first()
|
||||
assert row["last_login_at"] is not None
|
||||
|
||||
# Token marked used.
|
||||
with clean_db_engine.connect() as conn:
|
||||
tok = conn.execute(
|
||||
text("SELECT used_at FROM magic_link_tokens")
|
||||
).mappings().first()
|
||||
assert tok["used_at"] is not None
|
||||
|
||||
# Session row exists, cookie is non-empty.
|
||||
assert session.user_id == user.id
|
||||
assert cookie
|
||||
|
||||
# Audit trail: link_consumed + session_created.
|
||||
assert _count(
|
||||
clean_db_engine, "auth_events", "event_type='link_consumed'"
|
||||
) == 1
|
||||
assert _count(
|
||||
clean_db_engine, "auth_events", "event_type='session_created'"
|
||||
) == 1
|
||||
|
||||
|
||||
def test_consume_replay_fails(clean_db_engine: Engine, monkeypatch) -> None:
|
||||
"""The same raw token cannot be consumed twice."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
captured: dict[str, str] = {}
|
||||
monkeypatch.setattr(
|
||||
auth._email, "send_magic_link", lambda **kw: captured.update(url=kw["url"])
|
||||
)
|
||||
auth.request_link(email="headhen@example.com", ip="", user_agent="")
|
||||
raw = captured["url"].rsplit("/", 1)[-1]
|
||||
|
||||
first = auth.consume(raw_token=raw, ip="", user_agent="")
|
||||
assert first is not None
|
||||
|
||||
second = auth.consume(raw_token=raw, ip="", user_agent="")
|
||||
assert second is None
|
||||
|
||||
# consume_failed audit with reason=used.
|
||||
assert "\"reason\": \"used\"" in _latest_detail(
|
||||
clean_db_engine, "consume_failed"
|
||||
)
|
||||
|
||||
|
||||
def test_consume_expired_token_fails(clean_db_engine: Engine, monkeypatch) -> None:
|
||||
"""A token past its expires_at cannot be consumed."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
captured: dict[str, str] = {}
|
||||
monkeypatch.setattr(
|
||||
auth._email, "send_magic_link", lambda **kw: captured.update(url=kw["url"])
|
||||
)
|
||||
auth.request_link(email="headhen@example.com", ip="", user_agent="")
|
||||
raw = captured["url"].rsplit("/", 1)[-1]
|
||||
|
||||
# Push expires_at into the past.
|
||||
past = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
|
||||
with clean_db_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text("UPDATE magic_link_tokens SET expires_at = :p"), {"p": past}
|
||||
)
|
||||
|
||||
assert auth.consume(raw_token=raw, ip="", user_agent="") is None
|
||||
assert "\"reason\": \"expired\"" in _latest_detail(
|
||||
clean_db_engine, "consume_failed"
|
||||
)
|
||||
|
||||
|
||||
def test_consume_unknown_token_fails(clean_db_engine: Engine) -> None:
|
||||
"""Random tokens result in not_found."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
assert auth.consume(
|
||||
raw_token="totally-random-nope", ip="", user_agent=""
|
||||
) is None
|
||||
assert "\"reason\": \"not_found\"" in _latest_detail(
|
||||
clean_db_engine, "consume_failed"
|
||||
)
|
||||
|
||||
|
||||
def test_per_email_rate_limit_trips_on_sixth(
|
||||
clean_db_engine: Engine, monkeypatch
|
||||
) -> None:
|
||||
"""Five requests in the window succeed; the sixth raises and audits."""
|
||||
auth, _ = _build(clean_db_engine)
|
||||
monkeypatch.setattr(auth._email, "send_magic_link", lambda **kw: None)
|
||||
|
||||
for _ in range(5):
|
||||
auth.request_link(email="headhen@example.com", ip="", user_agent="")
|
||||
|
||||
with pytest.raises(RateLimitedError):
|
||||
auth.request_link(email="headhen@example.com", ip="", user_agent="")
|
||||
|
||||
# Exactly 5 tokens persisted; 6th blocked before insert.
|
||||
assert _count(clean_db_engine, "magic_link_tokens") == 5
|
||||
# rate_limited audit with scope=email.
|
||||
detail = _latest_detail(clean_db_engine, "rate_limited")
|
||||
assert "\"scope\": \"email\"" in detail
|
||||
|
||||
|
||||
def test_existing_user_reactivated_on_consume(
|
||||
clean_db_engine: Engine, monkeypatch
|
||||
) -> None:
|
||||
"""Consume updates an existing (possibly deactivated) user row."""
|
||||
# Seed a deactivated existing admin.
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
with clean_db_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO users"
|
||||
" (email, display_name, created_at, last_login_at, active)"
|
||||
" VALUES (:e, :d, :c, NULL, 0)"
|
||||
),
|
||||
{"e": "headhen@example.com", "d": "Old Name", "c": now_iso},
|
||||
)
|
||||
|
||||
auth, _ = _build(clean_db_engine)
|
||||
captured: dict[str, str] = {}
|
||||
monkeypatch.setattr(
|
||||
auth._email, "send_magic_link", lambda **kw: captured.update(url=kw["url"])
|
||||
)
|
||||
auth.request_link(email="headhen@example.com", ip="", user_agent="")
|
||||
raw = captured["url"].rsplit("/", 1)[-1]
|
||||
|
||||
result = auth.consume(raw_token=raw, ip="", user_agent="")
|
||||
assert result is not None
|
||||
user, _, _ = result
|
||||
|
||||
# display_name preserved (we don't overwrite), active flipped back on.
|
||||
assert user.display_name == "Old Name"
|
||||
assert user.active is True
|
||||
76
tests/test_cache.py
Normal file
76
tests/test_cache.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Tests for the in-process TTL cache.
|
||||
|
||||
Covers:
|
||||
|
||||
- stored values round-trip via ``get`` before TTL expiry
|
||||
- entries expire after TTL elapses
|
||||
- ``invalidate_all`` drops every entry
|
||||
- construction rejects a non-positive TTL
|
||||
- the cache is typed-generic (spot check at runtime that multiple
|
||||
concrete types work — the real type safety comes from static
|
||||
checking, which isn't part of the runtime suite)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.cache import TTLCache
|
||||
|
||||
|
||||
def test_set_then_get_returns_stored_value() -> None:
|
||||
"""A value stored via ``set`` is visible to ``get`` until expiry."""
|
||||
cache: TTLCache[str, int] = TTLCache(ttl_seconds=5.0)
|
||||
cache.set("answer", 42)
|
||||
assert cache.get("answer") == 42
|
||||
|
||||
|
||||
def test_get_returns_none_for_missing_key() -> None:
|
||||
"""Absent keys return ``None`` cleanly (no KeyError)."""
|
||||
cache: TTLCache[str, str] = TTLCache(ttl_seconds=5.0)
|
||||
assert cache.get("nope") is None
|
||||
|
||||
|
||||
def test_entries_expire_after_ttl() -> None:
|
||||
"""An entry past its TTL is treated as absent.
|
||||
|
||||
Uses a tiny TTL + ``time.sleep`` rather than mocking
|
||||
``time.monotonic`` so the test exercises the real code path.
|
||||
"""
|
||||
cache: TTLCache[str, str] = TTLCache(ttl_seconds=0.05)
|
||||
cache.set("k", "v")
|
||||
time.sleep(0.1)
|
||||
assert cache.get("k") is None
|
||||
|
||||
|
||||
def test_invalidate_all_clears_everything() -> None:
|
||||
"""``invalidate_all`` drops every entry regardless of TTL."""
|
||||
cache: TTLCache[str, int] = TTLCache(ttl_seconds=60.0)
|
||||
cache.set("a", 1)
|
||||
cache.set("b", 2)
|
||||
cache.invalidate_all()
|
||||
assert cache.get("a") is None
|
||||
assert cache.get("b") is None
|
||||
|
||||
|
||||
def test_non_positive_ttl_is_rejected() -> None:
|
||||
"""Zero/negative TTL raises at construction time.
|
||||
|
||||
A zero TTL would make every write immediately expire, which is
|
||||
almost certainly a bug; the defensive check turns it into a loud
|
||||
failure.
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
TTLCache(ttl_seconds=0.0)
|
||||
with pytest.raises(ValueError):
|
||||
TTLCache(ttl_seconds=-1.0)
|
||||
|
||||
|
||||
def test_cache_works_with_int_keys_and_list_values() -> None:
|
||||
"""Runtime smoke: generic over both ``K`` and ``V``."""
|
||||
cache: TTLCache[int, list[str]] = TTLCache(ttl_seconds=5.0)
|
||||
cache.set(10, ["a", "b"])
|
||||
stored = cache.get(10)
|
||||
assert stored == ["a", "b"]
|
||||
91
tests/test_csrf_service.py
Normal file
91
tests/test_csrf_service.py
Normal 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
155
tests/test_db_migrations.py
Normal 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
113
tests/test_email_service.py
Normal 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
35
tests/test_healthz.py
Normal 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
112
tests/test_markdown.py
Normal 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'
|
||||
''
|
||||
)
|
||||
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
179
tests/test_media_service.py
Normal 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,
|
||||
)
|
||||
53
tests/test_page_service.py
Normal file
53
tests/test_page_service.py
Normal 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"
|
||||
63
tests/test_post_service.py
Normal file
63
tests/test_post_service.py
Normal 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
122
tests/test_public_routes.py
Normal 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
142
tests/test_rate_limit.py
Normal 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()
|
||||
152
tests/test_session_service.py
Normal file
152
tests/test_session_service.py
Normal 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
68
tests/test_slugs.py
Normal 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)
|
||||
Reference in New Issue
Block a user