All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
The relations container-nav fragment was inserting nav_label (e.g. "calendars", "markets") as a URL path segment, generating wrong links like /the-village-hall/markets/suma/ instead of /the-village-hall/suma/. The nav_label is for display only, not URL construction. Also adds a rich 404 handler that shows site headers and post breadcrumb when a slug can be resolved from the URL path. Falls back gracefully to the minimal error page if context building fails. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
298 lines
11 KiB
Python
298 lines
11 KiB
Python
from werkzeug.exceptions import HTTPException
|
|
from shared.utils import hx_fragment_request
|
|
|
|
from quart import (
|
|
request,
|
|
render_template,
|
|
make_response,
|
|
current_app
|
|
)
|
|
|
|
from markupsafe import escape
|
|
from shared.infrastructure.fragments import FragmentError as _FragmentError
|
|
|
|
class AppError(ValueError):
|
|
"""
|
|
Base class for app-level, client-safe errors.
|
|
Behaves like ValueError so existing except ValueError: still works.
|
|
"""
|
|
status_code: int = 400
|
|
|
|
def __init__(self, message, *, status_code: int | None = None):
|
|
# Support a single message or a list/tuple of messages
|
|
if isinstance(message, (list, tuple, set)):
|
|
self.messages = [str(m) for m in message]
|
|
msg = self.messages[0] if self.messages else ""
|
|
else:
|
|
self.messages = [str(message)]
|
|
msg = str(message)
|
|
|
|
super().__init__(msg)
|
|
|
|
if status_code is not None:
|
|
self.status_code = status_code
|
|
|
|
|
|
def _error_page(message: str) -> str:
|
|
"""Self-contained error page HTML. Bypasses Jinja/context processors."""
|
|
return (
|
|
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
"<title>Error</title>"
|
|
"<style>"
|
|
"body{margin:0;min-height:100vh;display:flex;align-items:center;"
|
|
"justify-content:center;font-family:system-ui,sans-serif;"
|
|
"background:#fafafa;color:#333}"
|
|
".box{text-align:center;padding:2rem;max-width:480px}"
|
|
".box h1{font-size:1.5rem;color:#ef4444;margin:0 0 1rem}"
|
|
".box p{margin:0 0 1.5rem;line-height:1.6}"
|
|
".box a{color:#3b82f6;text-decoration:none}"
|
|
".box img{max-width:300px;margin:1rem auto}"
|
|
"</style></head><body>"
|
|
"<div class='box'>"
|
|
f"<h1>{message}</h1>"
|
|
"<img src='/static/errors/error.gif' width='300' height='300'>"
|
|
"<p><a href='javascript:location.reload()'>Reload</a></p>"
|
|
"</div></body></html>"
|
|
)
|
|
|
|
|
|
def _sexp_error_page(errnum: str, message: str, image: str | None = None) -> str:
|
|
"""Render an error page via s-expressions. Bypasses Jinja entirely."""
|
|
from shared.sexp.page import render_page
|
|
|
|
return render_page(
|
|
'(~error-page :title title :message message :image image :asset-url "/static")',
|
|
title=f"{errnum} Error",
|
|
message=message,
|
|
image=image,
|
|
)
|
|
|
|
|
|
async def _rich_error_page(errnum: str, message: str, image: str | None = None) -> str | None:
|
|
"""Try to render an error page with site headers and post breadcrumb.
|
|
|
|
Returns HTML string on success, None if we should fall back to the
|
|
minimal error page. All failures are swallowed — this must never
|
|
make a bad situation worse.
|
|
"""
|
|
from quart import g
|
|
|
|
# Skip for internal/static requests
|
|
if request.path.startswith(("/internal/", "/static/", "/auth/")):
|
|
return None
|
|
|
|
try:
|
|
# If the app's url_value_preprocessor didn't run (no route match),
|
|
# manually extract the first path segment as a candidate slug and
|
|
# try to hydrate post data so context processors can use it.
|
|
segments = [s for s in request.path.strip("/").split("/") if s]
|
|
slug = segments[0] if segments else None
|
|
|
|
if slug and not getattr(g, "post_data", None):
|
|
# Set g.post_slug / g.page_slug so app context processors
|
|
# (inject_post, etc.) can pick it up.
|
|
from shared.infrastructure.data_client import fetch_data
|
|
raw = await fetch_data(
|
|
"blog", "post-by-slug",
|
|
params={"slug": slug},
|
|
required=False,
|
|
)
|
|
if raw:
|
|
g.post_slug = slug
|
|
g.page_slug = slug # cart uses page_slug
|
|
g.post_data = {
|
|
"post": {
|
|
"id": raw["id"],
|
|
"title": raw["title"],
|
|
"slug": raw["slug"],
|
|
"feature_image": raw.get("feature_image"),
|
|
"status": raw["status"],
|
|
"visibility": raw["visibility"],
|
|
},
|
|
}
|
|
# Also set page_post for cart app
|
|
from shared.contracts.dtos import PostDTO, dto_from_dict
|
|
post_dto = dto_from_dict(PostDTO, raw)
|
|
if post_dto and getattr(post_dto, "is_page", False):
|
|
g.page_post = post_dto
|
|
|
|
# Build template context (runs app context processors → fragments)
|
|
from shared.sexp.page import get_template_context
|
|
ctx = await get_template_context()
|
|
|
|
# Build headers: root header + post header if available
|
|
from shared.sexp.jinja_bridge import render
|
|
from shared.sexp.helpers import (
|
|
root_header_html, call_url, full_page,
|
|
)
|
|
|
|
hdr = root_header_html(ctx)
|
|
|
|
post = ctx.get("post") or {}
|
|
if post.get("slug"):
|
|
title = (post.get("title") or "")[:160]
|
|
feature_image = post.get("feature_image")
|
|
label_html = ""
|
|
if feature_image:
|
|
label_html += (
|
|
f'<img src="{feature_image}" '
|
|
f'class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
|
|
)
|
|
label_html += f"<span>{escape(title)}</span>"
|
|
post_row = render(
|
|
"menu-row",
|
|
id="post-row", level=1,
|
|
link_href=call_url(ctx, "blog_url", f"/{post['slug']}/"),
|
|
link_label_html=label_html,
|
|
child_id="post-header-child",
|
|
external=True,
|
|
)
|
|
hdr += (
|
|
f'<div id="root-header-child" class="w-full">'
|
|
f'{post_row}'
|
|
f'</div>'
|
|
)
|
|
|
|
# Error content
|
|
error_html = (
|
|
'<div class="text-center p-8 max-w-lg mx-auto">'
|
|
f'<div class="font-bold text-2xl md:text-4xl text-red-500 mb-4">{errnum}</div>'
|
|
f'<div class="text-stone-600 mb-4">{escape(message)}</div>'
|
|
)
|
|
if image:
|
|
error_html += f'<div class="flex justify-center"><img src="{image}" width="300" height="300"></div>'
|
|
error_html += "</div>"
|
|
|
|
return full_page(ctx, header_rows_html=hdr, content_html=error_html)
|
|
except Exception:
|
|
current_app.logger.debug("Rich error page failed, falling back", exc_info=True)
|
|
return None
|
|
|
|
|
|
def errors(app):
|
|
def _info(e):
|
|
return {
|
|
"exception": e,
|
|
"method": request.method,
|
|
"url": str(request.url),
|
|
"base_url": str(request.base_url),
|
|
"root_path": request.root_path,
|
|
"path": request.path,
|
|
"full_path": request.full_path,
|
|
"endpoint": request.endpoint,
|
|
"url_rule": str(request.url_rule) if request.url_rule else None,
|
|
"headers": {k: v for k, v in request.headers.items()
|
|
if k.lower().startswith("x-forwarded") or k in ("Host",)},
|
|
}
|
|
|
|
@app.errorhandler(404)
|
|
async def not_found(e):
|
|
current_app.logger.warning("404 %s", _info(e))
|
|
if hx_fragment_request():
|
|
html = await render_template(
|
|
"_types/root/exceptions/hx/_.html",
|
|
errnum='404'
|
|
)
|
|
else:
|
|
# Try a rich error page with site headers + post breadcrumb
|
|
html = await _rich_error_page(
|
|
"404", "NOT FOUND",
|
|
image="/static/errors/404.gif",
|
|
)
|
|
if html is None:
|
|
try:
|
|
html = _sexp_error_page(
|
|
"404", "NOT FOUND",
|
|
image="/static/errors/404.gif",
|
|
)
|
|
except Exception:
|
|
html = await render_template(
|
|
"_types/root/exceptions/_.html",
|
|
errnum='404',
|
|
)
|
|
|
|
return await make_response(html, 404)
|
|
|
|
@app.errorhandler(403)
|
|
async def not_allowed(e):
|
|
current_app.logger.warning("403 %s", _info(e))
|
|
if hx_fragment_request():
|
|
html = await render_template(
|
|
"_types/root/exceptions/hx/_.html",
|
|
errnum='403'
|
|
)
|
|
else:
|
|
try:
|
|
html = _sexp_error_page(
|
|
"403", "FORBIDDEN",
|
|
image="/static/errors/403.gif",
|
|
)
|
|
except Exception:
|
|
html = await render_template(
|
|
"_types/root/exceptions/_.html",
|
|
errnum='403',
|
|
)
|
|
|
|
return await make_response(html, 403)
|
|
|
|
@app.errorhandler(AppError)
|
|
async def app_error(e: AppError):
|
|
# App-level, client-safe errors
|
|
current_app.logger.info("AppError %s", _info(e))
|
|
status = getattr(e, "status_code", 400)
|
|
messages = getattr(e, "messages", [str(e)])
|
|
|
|
if request.headers.get("HX-Request") == "true":
|
|
from shared.sexp.jinja_bridge import render as render_comp
|
|
items = "".join(
|
|
render_comp("error-list-item", message=str(escape(m)))
|
|
for m in messages if m
|
|
)
|
|
html = render_comp("error-list", items_html=items)
|
|
return await make_response(html, status)
|
|
|
|
# Non-HTMX: show a nicer page with error messages
|
|
html = await render_template(
|
|
"_types/root/exceptions/app_error.html",
|
|
messages=messages,
|
|
)
|
|
return await make_response(html, status)
|
|
|
|
@app.errorhandler(_FragmentError)
|
|
async def fragment_error(e):
|
|
current_app.logger.error("FragmentError %s", _info(e))
|
|
msg = str(e)
|
|
# Extract service name from "Fragment account/auth-menu failed: ..."
|
|
service = msg.split("/")[0].replace("Fragment ", "") if "/" in msg else "unknown"
|
|
if request.headers.get("HX-Request") == "true":
|
|
from shared.sexp.jinja_bridge import render as render_comp
|
|
return await make_response(
|
|
render_comp("fragment-error", service=str(escape(service))),
|
|
503,
|
|
)
|
|
# Raw HTML — cannot use render_template here because the context
|
|
# processor would try to fetch fragments again → infinite loop.
|
|
return await make_response(_error_page(
|
|
f"The <b>{escape(service)}</b> service is currently unavailable. It may be restarting."
|
|
), 503)
|
|
|
|
@app.errorhandler(Exception)
|
|
async def error(e):
|
|
current_app.logger.exception("Exception %s", _info(e))
|
|
|
|
status = 500
|
|
if isinstance(e, HTTPException):
|
|
status = e.code or 500
|
|
|
|
if request.headers.get("HX-Request") == "true":
|
|
return await make_response(
|
|
"Something went wrong. Please try again.",
|
|
status,
|
|
)
|
|
|
|
# Raw HTML — avoids context processor / fragment loop on errors.
|
|
return await make_response(_error_page(
|
|
"WELL THIS IS EMBARRASSING…"
|
|
), status)
|