"""Quart app factory.""" import logging from pathlib import Path from quart import Quart, request from .api import all_blueprints from .config import get_config from .frontend import frontend_bp from .services.credential_crypto import CredentialCrypto # Browser-extension origins. The FabledCurator extension fetches from # moz-extension:/// on Firefox and chrome-extension:/// on # Chromium-based browsers. Operator-flagged 2026-05-26: extension's # 'Test connection' returned `NetworkError` because the X-Extension-Key # header on /api/credentials triggers a CORS preflight that our routes # don't handle. Whitelisting only these two schemes (not opening CORS # up generally) lets the extension talk to a plain-HTTP self-hosted FC # without weakening the no-CORS posture for normal browser usage. _EXTENSION_ORIGIN_SCHEMES = ("moz-extension://", "chrome-extension://") _CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64") def create_app() -> Quart: cfg = get_config() logging.basicConfig(level=cfg.log_level) # Bootstrap the credential encryption key file (mode 0600) before # any request can land. FC-3b: file-based system-generated key. CredentialCrypto(_CREDENTIAL_KEY_PATH) app = Quart(__name__) app.secret_key = cfg.secret_key # Stream files in 4 MiB chunks instead of Quart's 8 KiB default. The image # library lives on a CIFS/SMB share (mounted rsize=4 MiB), so 8 KiB reads # meant ~19k network round-trips for one large original — 30–58s downloads # that starved both the GPU agent and the browser (operator-flagged # 2026-07-01). 4 MiB matches the mount's read size → one round-trip per read, # ~500× fewer. buffer_size is the MAX read, so small thumbnails still read in # a single gulp, and Range/mime/ETag/conditional handling lives on Response, # so this keeps all of it. Guarded so a future Quart-internal change can't # break boot — worst case we fall back to the slow default. try: from quart.wrappers.response import FileBody FileBody.buffer_size = 4 * 1024 * 1024 except Exception: logging.getLogger(__name__).warning( "could not raise FileBody.buffer_size — file serving stays on 8 KiB chunks" ) for bp in all_blueprints(): app.register_blueprint(bp) # Registered last so /api/* routes win over the SPA catch-all. app.register_blueprint(frontend_bp) @app.before_request async def _extension_cors_preflight(): # Short-circuit OPTIONS preflight from the browser extension with a # 204 + CORS headers (the after_request hook below adds them). # Without this, OPTIONS lands on routes that only declared POST/GET # methods and 405s before the after_request gets a chance. if request.method != "OPTIONS": return None origin = request.headers.get("Origin", "") if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES): return "", 204 return None @app.after_request async def _extension_cors_headers(response): origin = request.headers.get("Origin", "") if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES): response.headers["Access-Control-Allow-Origin"] = origin response.headers["Access-Control-Allow-Methods"] = ( "GET, POST, PATCH, DELETE, OPTIONS" ) response.headers["Access-Control-Allow-Headers"] = ( "Content-Type, X-Extension-Key" ) response.headers["Access-Control-Max-Age"] = "86400" return response @app.after_serving async def _dispose_db_engine() -> None: from .extensions import dispose_engine await dispose_engine() return app