""" 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) # 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()