"""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`` — form UI + optional ``mailto:`` link. - ``POST /contact`` — Phase 5 live submission endpoint (hCaptcha + honeypot + rate-limit + persist + notify). - ``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 re import structlog from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import HTMLResponse, Response 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.contact import ContactService from app.services.hcaptcha import HCaptchaService from app.services.pages import PageService, get_page_service from app.services.posts import PostService, get_post_service from app.services.rate_limit import limiter # 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__) # Same loose email shape used on the admin login form. Intentionally # permissive: the mapper layer / Resend does the real work; we only # want to reject obvious junk before hitting the service. _EMAIL_RE: re.Pattern[str] = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") # Field length guards — matched on the server regardless of the # HTML5 attributes the template emits. _NAME_MAX: int = 80 _EMAIL_MAX: int = 254 _MESSAGE_MIN: int = 10 _MESSAGE_MAX: int = 4000 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 def _get_hcaptcha_service(request: Request) -> HCaptchaService: """Return the app-scoped :class:`HCaptchaService`.""" return request.app.state.hcaptcha_service def _get_contact_service(request: Request) -> ContactService: """Return the app-scoped :class:`ContactService`.""" return request.app.state.contact_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", "") @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 contact page. Phase 5 wires the form up to a real POST handler; this GET now returns the blank form. ``ADMIN_CONTACT_EMAIL`` is still surfaced as a secondary ``mailto:`` link for visitors who prefer their own inbox over the form. """ 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, "hcaptcha_site_key": settings.hcaptcha_site_key, "errors": {}, "form": {"name": "", "email": "", "message": ""}, "form_error": None, }, ) @router.post("/contact", summary="Submit the contact form") @limiter.limit("3/hour") async def contact_submit( request: Request, name: str = Form(default=""), email: str = Form(default=""), message: str = Form(default=""), website: str = Form(default=""), templates: Jinja2Templates = Depends(get_templates), settings: Settings = Depends(get_settings), hcaptcha: HCaptchaService = Depends(_get_hcaptcha_service), contact_service: ContactService = Depends(_get_contact_service), ) -> Response: """Handle the contact-form POST. Flow (strict order — each stage short-circuits): 1. Honeypot: if ``website`` is non-empty → silent spam. Audit ``contact_spam_rejected`` with reason ``honeypot``; render the generic success page so the bot operator cannot tell they were filtered. 2. hCaptcha: call :meth:`HCaptchaService.verify`. On False → audit ``contact_spam_rejected`` with reason ``hcaptcha``; render the success page. Same anti-enumeration rationale. 3. Validate fields. On any error → re-render the form with inline error messages + HTTP 400 + submitted values preserved. 4. Persist: :meth:`ContactService.record_submission`. After this point the message is durable even if the email fails. 5. Notify: :meth:`ContactService.send_notification`. Best-effort; if it raises (it never should — service is defensive) we still fall through to the success page. 6. Render ``public/contact_sent.html``. CSRF is NOT required here: the endpoint is pre-auth, has no session to hijack, and adding a cookie dance would break the first-contact UX. SlowAPI + hCaptcha + honeypot are the controls. """ ip = _client_ip(request) ua = _user_agent(request) audit = request.app.state.audit_service # --- Stage 1: honeypot ------------------------------------------------ if (website or "").strip(): audit.record( "contact_spam_rejected", ip=ip, user_agent=ua, detail={"reason": "honeypot"}, ) return templates.TemplateResponse( request, "public/contact_sent.html", {"active_nav": "contact"}, ) # --- Stage 2: hCaptcha ------------------------------------------------ token = ( (request.headers.get("h-captcha-response") or "") or "" ) # hCaptcha's widget posts the token as a form field named # ``h-captcha-response``. Starlette's ``Request.form()`` is async; # re-reading it here is fine because FastAPI caches the parsed form # for the lifetime of the request. form_data = await request.form() captcha_token = str(form_data.get("h-captcha-response", token) or "") ok = await hcaptcha.verify(captcha_token, ip) if not ok: audit.record( "contact_spam_rejected", ip=ip, user_agent=ua, detail={"reason": "hcaptcha"}, ) return templates.TemplateResponse( request, "public/contact_sent.html", {"active_nav": "contact"}, ) # --- Stage 3: validation --------------------------------------------- clean_name = (name or "").strip() clean_email = (email or "").strip() clean_message = (message or "").strip() errors: dict[str, str] = {} if not clean_name: errors["name"] = "Please enter your name." elif len(clean_name) > _NAME_MAX: errors["name"] = f"Name must be {_NAME_MAX} characters or fewer." if not clean_email: errors["email"] = "Please enter your email address." elif len(clean_email) > _EMAIL_MAX or not _EMAIL_RE.match(clean_email): errors["email"] = "Please enter a valid email address." if not clean_message: errors["message"] = "Please include a message." elif len(clean_message) < _MESSAGE_MIN: errors["message"] = ( f"Message must be at least {_MESSAGE_MIN} characters." ) elif len(clean_message) > _MESSAGE_MAX: errors["message"] = ( f"Message must be {_MESSAGE_MAX} characters or fewer." ) if errors: return templates.TemplateResponse( request, "public/contact.html", { "active_nav": "contact", "contact_email": settings.admin_contact_email, "hcaptcha_site_key": settings.hcaptcha_site_key, "errors": errors, "form": { "name": clean_name, "email": clean_email, "message": clean_message, }, "form_error": None, }, status_code=400, ) # --- Stage 4: persist ------------------------------------------------- submission = contact_service.record_submission( name=clean_name, email=clean_email, message=clean_message, ip=ip, user_agent=ua, ) # Audit the successful submission. Store the message length + a # short preview so on-call can tell whether a flood is substantive # without exposing full bodies in the audit log. audit.record( "contact_submitted", email=clean_email, ip=ip, user_agent=ua, detail={ "submission_id": submission.id, "message_length": len(clean_message), "message_preview": clean_message[:40], }, ) # --- Stage 5: notify -------------------------------------------------- contact_service.send_notification(submission) # --- Stage 6: success page ------------------------------------------- return templates.TemplateResponse( request, "public/contact_sent.html", {"active_nav": "contact"}, ) @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"}, )