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-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|
||||||
# Runtime data (DB + media uploads live here in prod)
|
# Runtime data (DB + media uploads live here in prod).
|
||||||
data/
|
# 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/.gitkeep
|
||||||
|
!data/media/
|
||||||
|
data/media/*
|
||||||
|
!data/media/.gitkeep
|
||||||
|
|
||||||
# Editors / IDE
|
# Editors / IDE
|
||||||
.vscode/
|
.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`.
|
**Completed:** 2026-04-21
|
||||||
- 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`.
|
|
||||||
|
|
||||||
## 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.
|
**Key files:**
|
||||||
- Mobile-first responsive CSS, no JS framework. CSS custom properties from the palette below.
|
- `app/__init__.py` — package `__version__ = "0.1.0"`
|
||||||
- Routes: `/`, `/about`, `/contact`, `/shop` (shop shows "Coming soon" card, no form).
|
- `app/main.py` — `create_app()` factory + module-level `app`; configures logging then mounts routers
|
||||||
- `/` renders the blog index from DB (empty list is acceptable this phase).
|
- `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
|
||||||
- Manual test checklist → `docs/MANUAL_TESTING.md`.
|
- `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;`.
|
**Key details:**
|
||||||
- Dataclasses (below) as the in-app model; SQL → dataclass mapper lives in `app/models/`.
|
- **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.
|
||||||
- Row-level rendered-HTML cache (`body_html_cached`) regenerated on write.
|
- **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).
|
||||||
- In-process TTL cache (60 s) over *hot query results* (published posts list, page-by-slug); invalidated on admin writes.
|
- **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`.
|
||||||
- Initial migration seeds one welcome blog post + an About page so the site is not blank before admin exists.
|
- **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.
|
**Completed:** 2026-04-21
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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.
|
**Key files:**
|
||||||
- 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.
|
- `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.
|
||||||
- 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>`.
|
- `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).
|
||||||
- CRUD: pages (About), posts (blog) with publish toggle and slug auto-gen.
|
- `app/routes/public.py` — `APIRouter` with `GET /`, `/about`, `/contact`, `/shop`; pulls templates off `app.state.templates` via `get_templates()` DI helper.
|
||||||
- Save path: markdown → `markdown-it-py` → `bleach` allowlist → stored in `body_html_cached`.
|
- `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
|
## 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