6 files changed:
- db.py: migrate favorites table FK from collection_pages(id) to pages(id)
- board.py: add favorites API (POST/DELETE /board/api/favorites/{id}, GET list)
- board.py: _sidebar_data() now loads favorite_pages from DB via JOIN
- dashboard.py: library_page loads lib_favorites from DB (not parent_section)
- csrf.py: exclude /board/api/favorites from CSRF checks
- base.html: context menu toggles Add/Remove Favorites based on state
- base.html: favoriteIds Alpine set initialized from server-rendered favorites
- test_app.py: test_favorites_crud rewritten for new page-based favorites API
Favorites now work end-to-end:
- Right-click → Add to Favorites (or Remove if already favorited)
- Sidebar Favorites section shows favorited pages
- Library Favorites tab shows the same pages
- API: POST/DELETE /board/api/favorites/{page_id}, GET /board/api/favorites
50 lines
1.8 KiB
Python
50 lines
1.8 KiB
Python
"""FlowDeck — CSRF protection middleware."""
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.responses import JSONResponse
|
|
from starlette.requests import Request
|
|
|
|
|
|
class CSRFMiddleware(BaseHTTPMiddleware):
|
|
"""Lightweight CSRF protection for state-changing requests.
|
|
|
|
All POST/PUT/PATCH/DELETE requests must include X-CSRF-Token
|
|
header matching the csrf_token cookie.
|
|
"""
|
|
|
|
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
|
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/board/api/pages", "/board/api/favorites", "/db/", "/workspace"}
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
# Webhook receiver, OAuth callback, and internal API are exempt
|
|
if any(request.url.path.startswith(p) for p in self.EXCLUDED_PATHS):
|
|
return await call_next(request)
|
|
|
|
if request.method in self.SAFE_METHODS:
|
|
response = await call_next(request)
|
|
# Set CSRF cookie if not present
|
|
if "csrf_token" not in request.cookies:
|
|
response.set_cookie(
|
|
"csrf_token",
|
|
secrets.token_hex(32),
|
|
httponly=False, # Must be readable by JS
|
|
samesite="lax",
|
|
max_age=86400,
|
|
)
|
|
return response
|
|
|
|
# Validate CSRF for state-changing methods
|
|
csrf_cookie = request.cookies.get("csrf_token", "")
|
|
csrf_header = request.headers.get("X-CSRF-Token", "")
|
|
|
|
if not csrf_cookie or not csrf_header or not secrets.compare_digest(csrf_cookie, csrf_header):
|
|
return JSONResponse(
|
|
{"detail": "CSRF validation failed"},
|
|
status_code=403,
|
|
)
|
|
|
|
return await call_next(request)
|