feat(ui): add error handling, flash messages and SEO optimization
- Add custom error pages (404, 403, 500) with user-friendly messages
- Add flash message system with signed cookies for security
- Add toast notifications with auto-dismiss and manual close
- Add comprehensive SEO meta tags (description, keywords, OG, Twitter)
- Add canonical URLs for SEO
- Update routes to use slug-based URLs (/posts/{slug} instead of /posts/{id})
- Add Open Graph and Twitter Card meta tags for social sharing
- Add favicon SVG
- Update all templates with proper meta tags and URLs
- Add error handlers registration in main.py
- Add flash middleware for request handling
- Install itsdangerous dependency
This commit is contained in:
@@ -9,6 +9,7 @@ be integrated with use cases in future iterations.
|
||||
"""
|
||||
|
||||
from app.presentation.web.auth import router as auth_router
|
||||
from app.presentation.web.error_handlers import register_error_handlers
|
||||
from app.presentation.web.routes import router
|
||||
|
||||
__all__ = ["router", "auth_router"]
|
||||
__all__ = ["router", "auth_router", "register_error_handlers"]
|
||||
|
||||
188
app/presentation/web/error_handlers.py
Normal file
188
app/presentation/web/error_handlers.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Error handlers and middleware for web UI.
|
||||
|
||||
This module provides custom error pages and flash message middleware
|
||||
for the web interface.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.presentation.web.flash import FlashManager, get_flash_messages
|
||||
|
||||
templates = Jinja2Templates(directory="app/presentation/templates")
|
||||
|
||||
|
||||
async def setup_flash_manager(request: Request) -> None:
|
||||
"""Setup flash manager on request state.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
"""
|
||||
request.state.flash_manager = FlashManager(request)
|
||||
|
||||
|
||||
async def add_flash_to_response(request: Request, response: HTMLResponse) -> None:
|
||||
"""Add flash cookie to response if needed.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
response: FastAPI response object.
|
||||
"""
|
||||
if hasattr(request.state, "flash_manager"):
|
||||
request.state.flash_manager.set_cookie(response)
|
||||
|
||||
|
||||
def get_template_context(request: Request) -> dict[str, Any]:
|
||||
"""Get base template context with flash messages.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
|
||||
Returns:
|
||||
Template context dictionary.
|
||||
"""
|
||||
from app.presentation.web.deps import can_create_post, get_user_role
|
||||
|
||||
user = getattr(request.state, "user", None)
|
||||
user_role = get_user_role(user)
|
||||
|
||||
return {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"user_role": user_role.value if user_role else None,
|
||||
"can_create": can_create_post(user),
|
||||
"flash_messages": get_flash_messages(request),
|
||||
}
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> HTMLResponse:
|
||||
"""Handle HTTP exceptions with custom error pages.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
exc: HTTPException instance.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with error page.
|
||||
"""
|
||||
# Handle redirects (307, 308)
|
||||
if exc.status_code in (307, 308) and "Location" in exc.headers:
|
||||
return RedirectResponse(url=exc.headers["Location"], status_code=exc.status_code)
|
||||
|
||||
error_pages = {
|
||||
403: ("Access Denied", "You don't have permission to access this page."),
|
||||
404: ("Page Not Found", "The page you're looking for doesn't exist."),
|
||||
500: ("Server Error", "Something went wrong on our end. Please try again later."),
|
||||
}
|
||||
|
||||
error_title, error_message = error_pages.get(
|
||||
exc.status_code, ("Error", exc.detail or "An unexpected error occurred.")
|
||||
)
|
||||
|
||||
context = get_template_context(request)
|
||||
context.update(
|
||||
{
|
||||
"error_code": exc.status_code,
|
||||
"error_title": error_title,
|
||||
"error_message": error_message,
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/error.html",
|
||||
context,
|
||||
status_code=exc.status_code,
|
||||
)
|
||||
|
||||
|
||||
async def not_found_handler(request: Request, exc: HTTPException) -> HTMLResponse:
|
||||
"""Handle 404 Not Found errors.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
exc: HTTPException instance.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with 404 page.
|
||||
"""
|
||||
context = get_template_context(request)
|
||||
context.update(
|
||||
{
|
||||
"error_code": 404,
|
||||
"error_title": "Page Not Found",
|
||||
"error_message": "The page you're looking for doesn't exist or has been moved.",
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/error.html",
|
||||
context,
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
async def forbidden_handler(request: Request, exc: HTTPException) -> HTMLResponse:
|
||||
"""Handle 403 Forbidden errors.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
exc: HTTPException instance.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with 403 page.
|
||||
"""
|
||||
context = get_template_context(request)
|
||||
context.update(
|
||||
{
|
||||
"error_code": 403,
|
||||
"error_title": "Access Denied",
|
||||
"error_message": "You don't have permission to access this resource. Please sign in or contact an administrator.",
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/error.html",
|
||||
context,
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
|
||||
async def server_error_handler(request: Request, exc: Exception) -> HTMLResponse:
|
||||
"""Handle 500 Internal Server Error.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
exc: Exception instance.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with 500 page.
|
||||
"""
|
||||
context = get_template_context(request)
|
||||
context.update(
|
||||
{
|
||||
"error_code": 500,
|
||||
"error_title": "Server Error",
|
||||
"error_message": "Something went wrong on our end. Please try again later or contact support if the problem persists.",
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/error.html",
|
||||
context,
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
def register_error_handlers(app) -> None:
|
||||
"""Register error handlers with FastAPI app.
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance.
|
||||
"""
|
||||
app.add_exception_handler(404, not_found_handler)
|
||||
app.add_exception_handler(403, forbidden_handler)
|
||||
app.add_exception_handler(500, server_error_handler)
|
||||
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||
160
app/presentation/web/flash.py
Normal file
160
app/presentation/web/flash.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Flash messages middleware for web UI.
|
||||
|
||||
This module provides flash message functionality for the web interface,
|
||||
allowing temporary messages to be passed between requests (e.g., after redirect).
|
||||
Uses signed cookies for security.
|
||||
"""
|
||||
|
||||
from fastapi import Request, Response
|
||||
from itsdangerous import URLSafeSerializer
|
||||
|
||||
from app.infrastructure.config.settings import settings
|
||||
|
||||
FLASH_COOKIE_NAME = "flash_messages"
|
||||
SERIALIZER = URLSafeSerializer(settings.security.secret_key.get_secret_value()) # type: ignore[union-attr]
|
||||
|
||||
|
||||
class FlashMessage:
|
||||
"""Flash message model.
|
||||
|
||||
Represents a single flash message with type and content.
|
||||
|
||||
Attributes:
|
||||
message: The message text.
|
||||
category: Message category (success, error, warning, info).
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, category: str = "info") -> None:
|
||||
"""Initialize flash message.
|
||||
|
||||
Args:
|
||||
message: The message text.
|
||||
category: Message category (success, error, warning, info).
|
||||
"""
|
||||
self.message = message
|
||||
self.category = category
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
"""Convert to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary with message and category.
|
||||
"""
|
||||
return {"message": self.message, "category": self.category}
|
||||
|
||||
|
||||
class FlashManager:
|
||||
"""Manager for flash messages.
|
||||
|
||||
Handles storing and retrieving flash messages from cookies.
|
||||
Messages are cleared after being read.
|
||||
|
||||
Attributes:
|
||||
request: FastAPI request object.
|
||||
messages: List of current messages.
|
||||
"""
|
||||
|
||||
CATEGORIES = {
|
||||
"success": "success",
|
||||
"error": "error",
|
||||
"warning": "warning",
|
||||
"info": "info",
|
||||
}
|
||||
|
||||
def __init__(self, request: Request) -> None:
|
||||
"""Initialize flash manager.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
"""
|
||||
self.request = request
|
||||
self.messages: list[FlashMessage] = []
|
||||
self._load_messages()
|
||||
|
||||
def _load_messages(self) -> None:
|
||||
"""Load messages from cookie."""
|
||||
cookie_value = self.request.cookies.get(FLASH_COOKIE_NAME)
|
||||
if cookie_value:
|
||||
try:
|
||||
data = SERIALIZER.loads(cookie_value)
|
||||
if isinstance(data, list):
|
||||
self.messages = [FlashMessage(msg["message"], msg["category"]) for msg in data]
|
||||
except Exception:
|
||||
self.messages = []
|
||||
|
||||
def add(self, message: str, category: str = "info") -> None:
|
||||
"""Add a flash message.
|
||||
|
||||
Args:
|
||||
message: The message text.
|
||||
category: Message category (success, error, warning, info).
|
||||
"""
|
||||
self.messages.append(FlashMessage(message, category))
|
||||
|
||||
def get_messages(self) -> list[dict[str, str]]:
|
||||
"""Get all messages and clear them.
|
||||
|
||||
Returns:
|
||||
List of message dictionaries.
|
||||
"""
|
||||
result = [msg.to_dict() for msg in self.messages]
|
||||
self.messages = []
|
||||
return result
|
||||
|
||||
def has_messages(self) -> bool:
|
||||
"""Check if there are any messages.
|
||||
|
||||
Returns:
|
||||
True if there are messages.
|
||||
"""
|
||||
return len(self.messages) > 0
|
||||
|
||||
def set_cookie(self, response: Response) -> None:
|
||||
"""Set flash cookie on response.
|
||||
|
||||
Args:
|
||||
response: FastAPI response object.
|
||||
"""
|
||||
if self.messages:
|
||||
data = [msg.to_dict() for msg in self.messages]
|
||||
cookie_value = SERIALIZER.dumps(data)
|
||||
response.set_cookie(
|
||||
key=FLASH_COOKIE_NAME,
|
||||
value=cookie_value,
|
||||
httponly=True,
|
||||
secure=not settings.is_dev,
|
||||
samesite="lax",
|
||||
max_age=300, # 5 minutes
|
||||
)
|
||||
else:
|
||||
response.delete_cookie(key=FLASH_COOKIE_NAME)
|
||||
|
||||
|
||||
def flash(request: Request, message: str, category: str = "info") -> None:
|
||||
"""Add flash message to request state.
|
||||
|
||||
Convenience function to add flash message.
|
||||
Must be called before response is created.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
message: The message text.
|
||||
category: Message category.
|
||||
"""
|
||||
if not hasattr(request.state, "flash_manager"):
|
||||
request.state.flash_manager = FlashManager(request)
|
||||
request.state.flash_manager.add(message, category)
|
||||
|
||||
|
||||
def get_flash_messages(request: Request) -> list[dict[str, str]]:
|
||||
"""Get flash messages from request.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
|
||||
Returns:
|
||||
List of flash message dictionaries.
|
||||
"""
|
||||
if hasattr(request.state, "flash_manager"):
|
||||
return request.state.flash_manager.get_messages()
|
||||
return []
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.infrastructure.auth import TokenInfo
|
||||
@@ -22,6 +22,7 @@ from app.presentation.web.deps import (
|
||||
can_see_draft,
|
||||
get_user_role,
|
||||
)
|
||||
from app.presentation.web.flash import flash
|
||||
|
||||
router = APIRouter(prefix="/web", tags=["web"])
|
||||
templates = Jinja2Templates(directory="app/presentation/templates")
|
||||
@@ -259,11 +260,11 @@ async def new_post_form(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/posts/new", response_class=HTMLResponse)
|
||||
@router.post("/posts/new")
|
||||
async def create_post(
|
||||
request: Request,
|
||||
user: RequireUserDep,
|
||||
) -> HTMLResponse:
|
||||
) -> RedirectResponse:
|
||||
"""Handle new post creation form submission.
|
||||
|
||||
Args:
|
||||
@@ -271,29 +272,17 @@ async def create_post(
|
||||
user: Current user (required).
|
||||
|
||||
Returns:
|
||||
HTMLResponse redirecting to the home page.
|
||||
RedirectResponse to the new post or home page.
|
||||
"""
|
||||
context = get_base_context(user)
|
||||
visible_posts = filter_visible_posts(MOCK_POSTS, user)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"pages/index.html",
|
||||
{
|
||||
**context,
|
||||
"posts": visible_posts,
|
||||
"active_page": "home",
|
||||
"current_page": 1,
|
||||
"has_prev": False,
|
||||
"has_next": False,
|
||||
},
|
||||
)
|
||||
flash(request, "Post created successfully!", "success")
|
||||
response = RedirectResponse(url="/web/", status_code=303)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}", response_class=HTMLResponse)
|
||||
@router.get("/posts/{post_slug}", response_class=HTMLResponse)
|
||||
async def post_detail(
|
||||
request: Request,
|
||||
post_id: str,
|
||||
post_slug: str,
|
||||
user: OptionalUserDep,
|
||||
) -> HTMLResponse:
|
||||
"""Render a single post detail page.
|
||||
@@ -309,7 +298,7 @@ async def post_detail(
|
||||
Raises:
|
||||
HTTPException: If post not found or not visible to user.
|
||||
"""
|
||||
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
|
||||
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
|
||||
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
@@ -332,10 +321,10 @@ async def post_detail(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}/edit", response_class=HTMLResponse)
|
||||
@router.get("/posts/{post_slug}/edit", response_class=HTMLResponse)
|
||||
async def edit_post_form(
|
||||
request: Request,
|
||||
post_id: str,
|
||||
post_slug: str,
|
||||
user: RequireUserDep,
|
||||
) -> HTMLResponse:
|
||||
"""Render the post edit form.
|
||||
@@ -351,7 +340,7 @@ async def edit_post_form(
|
||||
Raises:
|
||||
HTTPException: If post not found or user cannot edit it.
|
||||
"""
|
||||
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
|
||||
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
|
||||
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
@@ -373,10 +362,10 @@ async def edit_post_form(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/edit", response_class=HTMLResponse)
|
||||
@router.post("/posts/{post_slug}/edit", response_class=HTMLResponse)
|
||||
async def update_post(
|
||||
request: Request,
|
||||
post_id: str,
|
||||
post_slug: str,
|
||||
user: RequireUserDep,
|
||||
) -> HTMLResponse:
|
||||
"""Handle post update form submission.
|
||||
@@ -392,7 +381,7 @@ async def update_post(
|
||||
Raises:
|
||||
HTTPException: If post not found or user cannot edit it.
|
||||
"""
|
||||
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
|
||||
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
|
||||
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
@@ -415,10 +404,10 @@ async def update_post(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/delete", response_class=HTMLResponse)
|
||||
@router.post("/posts/{post_slug}/delete", response_class=HTMLResponse)
|
||||
async def delete_post(
|
||||
request: Request,
|
||||
post_id: str,
|
||||
post_slug: str,
|
||||
user: RequireUserDep,
|
||||
) -> HTMLResponse:
|
||||
"""Handle post deletion.
|
||||
@@ -434,7 +423,7 @@ async def delete_post(
|
||||
Raises:
|
||||
HTTPException: If post not found or user cannot delete it.
|
||||
"""
|
||||
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
|
||||
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
|
||||
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
Reference in New Issue
Block a user