from __future__ import annotations import asyncio import os import secrets from pathlib import Path from typing import Callable, Awaitable, Sequence from quart import Quart, request, g, redirect, send_from_directory from shared.config import init_config, config, pretty from shared.models import KV # ensure shared models imported # Register all app model classes with SQLAlchemy so cross-domain # relationship() string references resolve correctly. for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models", "account.models", "relations.models", "likes.models", "orders.models"): try: __import__(_mod) except ImportError: pass from shared.log_config import configure_logging from shared.events import EventProcessor from shared.db.session import register_db from shared.browser.app.middleware import register as register_middleware from shared.browser.app.redis_cacher import register as register_redis from shared.browser.app.csrf import protect from shared.browser.app.errors import errors from .jinja_setup import setup_jinja from .user_loader import load_current_user # Async init of config (runs once at import) asyncio.run(init_config()) BASE_DIR = Path(__file__).resolve().parent.parent STATIC_DIR = str(BASE_DIR / "static") TEMPLATE_DIR = str(BASE_DIR / "browser" / "templates") def create_base_app( name: str, *, context_fn: Callable[[], Awaitable[dict]] | None = None, before_request_fns: Sequence[Callable[[], Awaitable[None]]] | None = None, domain_services_fn: Callable[[], None] | None = None, ) -> Quart: """ Create a Quart app with shared infrastructure. Parameters ---------- name: Application name (also used as CACHE_APP_PREFIX). context_fn: Async function returning a dict for template context. Each app provides its own — the cart app queries locally, while blog/market apps fetch via internal API. If not provided, a minimal default context is used. before_request_fns: Extra before-request hooks (e.g. cart_loader for the cart app). domain_services_fn: Callable that registers domain services on the shared registry. Each app provides its own — registering real impls for owned domains and stubs (or real impls) for others. """ if domain_services_fn is not None: domain_services_fn() app = Quart( name, static_folder=STATIC_DIR, static_url_path="/static", template_folder=TEMPLATE_DIR, root_path=str(BASE_DIR), ) configure_logging(name) secret_key = os.getenv("SECRET_KEY") if not secret_key: env = os.getenv("ENVIRONMENT", "development") if env in ("production", "staging"): raise RuntimeError("SECRET_KEY environment variable must be set in production") secret_key = "dev-secret-key-change-me-777" app.secret_key = secret_key # Per-app first-party session cookie (no shared domain — avoids Safari ITP) app.config["SESSION_COOKIE_NAME"] = f"{name}_session" app.config["SESSION_COOKIE_SAMESITE"] = "Lax" app.config["SESSION_COOKIE_SECURE"] = True # Ghost / Redis config app.config["GHOST_API_URL"] = os.getenv("GHOST_API_URL") app.config["GHOST_PUBLIC_URL"] = os.getenv("GHOST_PUBLIC_URL") app.config["GHOST_CONTENT_KEY"] = os.getenv("GHOST_CONTENT_API_KEY") app.config["REDIS_URL"] = os.getenv("REDIS_URL") # Cache app prefix for key namespacing app.config["CACHE_APP_PREFIX"] = name # --- infrastructure --- register_middleware(app) register_db(app) register_redis(app) setup_jinja(app) errors(app) # Auto-register OAuth client blueprint for non-account apps # (account is the OAuth authorization server) if name != "account": from shared.infrastructure.oauth import create_oauth_blueprint app.register_blueprint(create_oauth_blueprint(name)) # Auto-register ActivityPub blueprint for AP-enabled apps from shared.infrastructure.activitypub import AP_APPS if name in AP_APPS: from shared.infrastructure.activitypub import create_activitypub_blueprint app.register_blueprint(create_activitypub_blueprint(name)) # Auto-register per-app social blueprint (not federation — it has its own) if name in AP_APPS and name != "federation": from shared.infrastructure.ap_social import create_ap_social_blueprint app.register_blueprint(create_ap_social_blueprint(name)) # --- device id (all apps, including account) --- _did_cookie = f"{name}_did" @app.before_request async def _init_device_id(): did = request.cookies.get(_did_cookie) if did: g.device_id = did g._new_device_id = False else: g.device_id = secrets.token_urlsafe(32) g._new_device_id = True @app.after_request async def _set_device_cookie(response): if getattr(g, "_new_device_id", False): response.set_cookie( _did_cookie, g.device_id, max_age=30 * 24 * 3600, secure=True, samesite="Lax", httponly=True, ) return response # --- before-request hooks --- @app.before_request async def _route_log(): g.root = request.headers.get("x-forwarded-prefix", "/") g.scheme = request.scheme g.host = request.host # Auth state check via grant verification + silent OAuth handshake # MUST run before _load_user so stale sessions are cleared first if name != "account": @app.before_request async def _check_auth_state(): from quart import session as qs from urllib.parse import quote as _quote if request.path.startswith(("/auth/", "/static/", "/.well-known/", "/users/", "/nodeinfo/", "/internal/")): return uid = qs.get("uid") grant_token = qs.get("grant_token") from shared.infrastructure.auth_redis import get_auth_redis try: auth_redis = await get_auth_redis() except Exception: auth_redis = None # Case 1: logged in — verify grant still valid (direct DB, cached) if uid and not grant_token: # Legacy session without grant token — clear it qs.pop("uid", None) qs.pop("cart_sid", None) g.user = None uid = None if uid and grant_token: cache_key = f"grant:{grant_token}" if auth_redis: # Quick check: if did_auth was cleared (logout), skip cache device_id = g.device_id did_auth_present = await auth_redis.get(f"did_auth:{device_id}") if device_id else True cached = await auth_redis.get(cache_key) if cached == b"ok" and did_auth_present: return if cached == b"revoked": qs.pop("uid", None) qs.pop("grant_token", None) qs.pop("cart_sid", None) g.user = None return from sqlalchemy import select from shared.db.session import get_account_session from shared.models.oauth_grant import OAuthGrant, hash_token try: token_h = hash_token(grant_token) async with get_account_session() as s: grant = await s.scalar( select(OAuthGrant).where( (OAuthGrant.token_hash == token_h) | (OAuthGrant.token == grant_token) ) ) valid = grant is not None and grant.revoked_at is None except Exception: valid = False # DB error — treat as invalid if auth_redis: await auth_redis.set(cache_key, b"ok" if valid else b"revoked", ex=60) if not valid: qs.pop("uid", None) qs.pop("grant_token", None) qs.pop("cart_sid", None) g.user = None return # Case 2: not logged in — prompt=none OAuth (GET, non-HTMX only) if not uid and request.method == "GET": if request.headers.get("HX-Request"): return import time as _time now = _time.time() pnone_at = qs.get("_pnone_at") device_id = g.device_id # Check if account signalled a login after we cached "not logged in" # (blog_did == account_did — same value set during OAuth callback) if device_id and auth_redis and pnone_at: auth_ts = await auth_redis.get(f"did_auth:{device_id}") if auth_ts: try: if float(auth_ts) > pnone_at: qs.pop("_pnone_at", None) return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}") except (ValueError, TypeError): pass if pnone_at and (now - pnone_at) < 300: return if device_id and auth_redis: cached = await auth_redis.get(f"prompt:{name}:{device_id}") if cached == b"none": return return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}") @app.before_request async def _load_user(): await load_current_user() # Register any app-specific before-request hooks (e.g. cart loader) if before_request_fns: for fn in before_request_fns: app.before_request(fn) @app.before_request async def _csrf_protect(): await protect() # --- after-request hooks --- # Clear old shared-domain session cookie (migration from .rose-ash.com) @app.after_request async def _clear_old_shared_cookie(response): if request.cookies.get("blog_session"): response.delete_cookie("blog_session", domain=".rose-ash.com", path="/") return response @app.after_request async def _add_hx_preserve_search_header(response): value = request.headers.get("X-Search") if value is not None: response.headers["HX-Preserve-Search"] = value return response # --- context processor --- if context_fn is not None: @app.context_processor async def _inject_base(): return await context_fn() else: # Minimal fallback (no cart, no menu_items) from .context import base_context @app.context_processor async def _inject_base(): return await base_context() # --- event processor --- _event_processor = EventProcessor(app_name=name) # --- startup --- @app.before_serving async def _startup(): from shared.events.handlers import register_shared_handlers register_shared_handlers() await init_config() print(pretty()) await _event_processor.start() @app.after_serving async def _stop_event_processor(): await _event_processor.stop() from shared.infrastructure.auth_redis import close_auth_redis await close_auth_redis() # --- favicon --- @app.get("/favicon.ico") async def favicon(): return await send_from_directory(STATIC_DIR, "favicon.ico") return app