Client apps now do a silent OAuth round-trip (prompt=none) to account on first visit. If user is logged in on account, they get silently logged in. If not, the result is cached (5 min) to avoid repeated handshakes. Grant verification now uses direct DB query instead of aiohttp HTTP calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
243 lines
8.4 KiB
Python
243 lines
8.4 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
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"):
|
|
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()
|
|
|
|
from shared.services.widgets import register_all_widgets
|
|
register_all_widgets()
|
|
|
|
app = Quart(
|
|
name,
|
|
static_folder=STATIC_DIR,
|
|
static_url_path="/static",
|
|
template_folder=TEMPLATE_DIR,
|
|
)
|
|
|
|
configure_logging(name)
|
|
|
|
app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key-change-me-777")
|
|
|
|
# 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))
|
|
|
|
# --- 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
|
|
|
|
@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)
|
|
|
|
# Auth state check via grant verification + silent OAuth handshake
|
|
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/") or request.path.startswith("/static/"):
|
|
return
|
|
|
|
uid = qs.get("uid")
|
|
grant_token = qs.get("grant_token")
|
|
|
|
from shared.browser.app.redis_cacher import get_redis
|
|
redis = get_redis()
|
|
|
|
# Case 1: logged in — verify grant still valid (direct DB, cached)
|
|
if uid and grant_token:
|
|
cache_key = f"grant:{grant_token}"
|
|
if redis:
|
|
cached = await redis.get(cache_key)
|
|
if cached == b"ok":
|
|
return
|
|
if cached == b"revoked":
|
|
qs.pop("uid", None)
|
|
qs.pop("grant_token", None)
|
|
qs.pop("cart_sid", None)
|
|
return
|
|
|
|
from sqlalchemy import select
|
|
from shared.db.session import get_session
|
|
from shared.models.oauth_grant import OAuthGrant
|
|
try:
|
|
async with get_session() as s:
|
|
grant = await s.scalar(
|
|
select(OAuthGrant).where(OAuthGrant.token == grant_token)
|
|
)
|
|
valid = grant is not None and grant.revoked_at is None
|
|
except Exception:
|
|
return # DB error — don't log user out
|
|
|
|
if redis:
|
|
await 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)
|
|
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
|
|
pnone_at = qs.get("_pnone_at")
|
|
if pnone_at and (_time.time() - pnone_at) < 300:
|
|
return
|
|
device_id = request.cookies.get(f"{name}_did")
|
|
if device_id and redis:
|
|
cached = await 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 _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()
|
|
|
|
# --- favicon ---
|
|
@app.get("/favicon.ico")
|
|
async def favicon():
|
|
return await send_from_directory("static", "favicon.ico")
|
|
|
|
return app
|