All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m37s
- Add /health endpoint (returns 200, skips auth middleware) - Healthcheck now hits /health instead of / (which 302s to OAuth) - Advisory lock in db.init_pool() prevents deadlock when 4 uvicorn workers race to run schema DDL - CI: --resolve-image always on docker stack deploy to force re-pull Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
202 lines
6.8 KiB
Python
202 lines
6.8 KiB
Python
"""
|
|
Art-DAG L2 Server Application Factory.
|
|
|
|
Creates and configures the FastAPI application with all routers and middleware.
|
|
"""
|
|
|
|
import secrets
|
|
import time
|
|
from pathlib import Path
|
|
from contextlib import asynccontextmanager
|
|
from urllib.parse import quote
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
|
|
|
|
from artdag_common import create_jinja_env
|
|
from artdag_common.middleware.auth import get_user_from_cookie
|
|
|
|
from .config import settings
|
|
|
|
# Paths that should never trigger a silent auth check
|
|
_SKIP_PREFIXES = ("/auth/", "/.well-known/", "/health",
|
|
"/internal/", "/static/", "/inbox")
|
|
_SILENT_CHECK_COOLDOWN = 300 # 5 minutes
|
|
_DEVICE_COOKIE = "artdag_did"
|
|
_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
|
|
|
|
# Derive external base URL from oauth_redirect_uri (e.g. https://artdag.rose-ash.com)
|
|
_EXTERNAL_BASE = settings.oauth_redirect_uri.rsplit("/auth/callback", 1)[0]
|
|
|
|
|
|
def _external_url(request: Request) -> str:
|
|
"""Build external URL from request path + query, using configured base domain."""
|
|
url = f"{_EXTERNAL_BASE}{request.url.path}"
|
|
if request.url.query:
|
|
url += f"?{request.url.query}"
|
|
return url
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Manage database connection pool lifecycle."""
|
|
import db
|
|
await db.init_pool()
|
|
yield
|
|
await db.close_pool()
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
"""
|
|
Create and configure the L2 FastAPI application.
|
|
|
|
Returns:
|
|
Configured FastAPI instance
|
|
"""
|
|
app = FastAPI(
|
|
title="Art-DAG L2 Server",
|
|
description="ActivityPub server for Art-DAG ownership and federation",
|
|
version="1.0.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# Silent auth check — auto-login via prompt=none OAuth
|
|
# NOTE: registered BEFORE device_id so device_id is outermost (runs first)
|
|
@app.middleware("http")
|
|
async def silent_auth_check(request: Request, call_next):
|
|
path = request.url.path
|
|
if (
|
|
request.method != "GET"
|
|
or any(path.startswith(p) for p in _SKIP_PREFIXES)
|
|
or request.headers.get("hx-request") # skip HTMX
|
|
):
|
|
return await call_next(request)
|
|
|
|
# Already logged in — pass through
|
|
if get_user_from_cookie(request):
|
|
return await call_next(request)
|
|
|
|
# Check cooldown — don't re-check within 5 minutes
|
|
pnone_at = request.cookies.get("pnone_at")
|
|
if pnone_at:
|
|
try:
|
|
pnone_ts = float(pnone_at)
|
|
if (time.time() - pnone_ts) < _SILENT_CHECK_COOLDOWN:
|
|
return await call_next(request)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Redirect to silent OAuth check
|
|
current_url = _external_url(request)
|
|
return RedirectResponse(
|
|
url=f"/auth/login?prompt=none&next={quote(current_url, safe='')}",
|
|
status_code=302,
|
|
)
|
|
|
|
# Device ID middleware — track browser identity across domains
|
|
# Registered AFTER silent_auth_check so it's outermost (always runs)
|
|
@app.middleware("http")
|
|
async def device_id_middleware(request: Request, call_next):
|
|
did = request.cookies.get(_DEVICE_COOKIE)
|
|
if did:
|
|
request.state.device_id = did
|
|
request.state._new_device_id = False
|
|
else:
|
|
request.state.device_id = secrets.token_urlsafe(32)
|
|
request.state._new_device_id = True
|
|
|
|
response = await call_next(request)
|
|
|
|
if getattr(request.state, "_new_device_id", False):
|
|
response.set_cookie(
|
|
key=_DEVICE_COOKIE,
|
|
value=request.state.device_id,
|
|
max_age=_DEVICE_COOKIE_MAX_AGE,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=True,
|
|
)
|
|
return response
|
|
|
|
# Coop fragment pre-fetch — inject nav-tree, auth-menu, cart-mini
|
|
_FRAG_SKIP = ("/auth/", "/.well-known/", "/health",
|
|
"/internal/", "/static/", "/inbox")
|
|
|
|
@app.middleware("http")
|
|
async def coop_fragments_middleware(request: Request, call_next):
|
|
path = request.url.path
|
|
if (
|
|
request.method != "GET"
|
|
or any(path.startswith(p) for p in _FRAG_SKIP)
|
|
or request.headers.get("hx-request")
|
|
):
|
|
request.state.nav_tree_html = ""
|
|
request.state.auth_menu_html = ""
|
|
request.state.cart_mini_html = ""
|
|
return await call_next(request)
|
|
|
|
from artdag_common.fragments import fetch_fragments as _fetch_frags
|
|
|
|
user = get_user_from_cookie(request)
|
|
auth_params = {"email": user.email or user.username} if user else {}
|
|
nav_params = {"app_name": "artdag", "path": path}
|
|
|
|
try:
|
|
nav_tree_html, auth_menu_html, cart_mini_html = await _fetch_frags([
|
|
("blog", "nav-tree", nav_params),
|
|
("account", "auth-menu", auth_params or None),
|
|
("cart", "cart-mini", None),
|
|
])
|
|
except Exception:
|
|
nav_tree_html = auth_menu_html = cart_mini_html = ""
|
|
|
|
request.state.nav_tree_html = nav_tree_html
|
|
request.state.auth_menu_html = auth_menu_html
|
|
request.state.cart_mini_html = cart_mini_html
|
|
|
|
return await call_next(request)
|
|
|
|
# Initialize Jinja2 templates
|
|
template_dir = Path(__file__).parent / "templates"
|
|
app.state.templates = create_jinja_env(template_dir)
|
|
|
|
# Health check (skips auth middleware via _SKIP_PREFIXES)
|
|
@app.get("/health")
|
|
async def health():
|
|
return JSONResponse({"status": "ok"})
|
|
|
|
# Custom 404 handler
|
|
@app.exception_handler(404)
|
|
async def not_found_handler(request: Request, exc):
|
|
from artdag_common.middleware import wants_html
|
|
if wants_html(request):
|
|
from artdag_common import render
|
|
return render(app.state.templates, "404.html", request,
|
|
user=None,
|
|
)
|
|
return JSONResponse({"detail": "Not found"}, status_code=404)
|
|
|
|
# Include routers
|
|
from .routers import auth, assets, activities, anchors, storage, users, renderers
|
|
|
|
# Root routes
|
|
app.include_router(auth.router, prefix="/auth", tags=["auth"])
|
|
app.include_router(users.router, tags=["users"])
|
|
|
|
# Feature routers
|
|
app.include_router(assets.router, prefix="/assets", tags=["assets"])
|
|
app.include_router(activities.router, prefix="/activities", tags=["activities"])
|
|
app.include_router(anchors.router, prefix="/anchors", tags=["anchors"])
|
|
app.include_router(storage.router, prefix="/storage", tags=["storage"])
|
|
app.include_router(renderers.router, prefix="/renderers", tags=["renderers"])
|
|
|
|
# WebFinger and ActivityPub discovery
|
|
from .routers import federation
|
|
app.include_router(federation.router, tags=["federation"])
|
|
|
|
return app
|
|
|
|
|
|
# Create the default app instance
|
|
app = create_app()
|