Ctrl K
ComunidadGuías…

app.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Flask application factory for ai-eyes2.

Usage:
    from app import create_app
    app, sock = create_app()

This factory pattern allows:
- Blueprint registration (Phase 2 tasks P2-T2 through P2-T8)
- Test isolation via config_override
- Clean extension initialization

ADR-009 (simple manager pattern): factory returns app + sock tuple so
server.py module-level decorators (@app.route, @sock.route) keep working.
"""
import logging
import os

from flask import Flask, g, jsonify, redirect, request
from flask_cors import CORS
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_sock import Sock
from werkzeug.middleware.proxy_fix import ProxyFix

logger = logging.getLogger(__name__)

# Match static_files.py and nginx (100 MB) — bulk uploads need full limit
_MAX_UPLOAD_BYTES = 100 * 1024 * 1024  # 100 MB


def create_app(config_override: dict = None):
    """
    Create and configure the Flask application.

    Args:
        config_override: Optional dict of Flask config values to apply.
                         Primarily used in tests to inject TESTING=True etc.

    Returns:
        tuple: (app, sock) — configured Flask app and Flask-Sock instance.
    """
    app = Flask(
        __name__,
        # Serve static files from project root (index.html etc.) via explicit routes
        static_folder=None,
    )

    # Core Flask config
    secret_key = os.getenv('SECRET_KEY')
    if not secret_key:
        import secrets as _secrets
        secret_key = _secrets.token_hex(32)
        logger.warning(
            'No SECRET_KEY set — generated a random key for this session. '
            'Sessions will NOT persist across restarts. '
            'Set SECRET_KEY in .env for production.'
        )
    app.config['SECRET_KEY'] = secret_key
    app.config['MAX_CONTENT_LENGTH'] = _MAX_UPLOAD_BYTES

    # Apply test / caller overrides last so they take precedence
    if config_override:
        app.config.update(config_override)

    # Trust one level of X-Forwarded-* headers (nginx / reverse proxy).
    # Without this, request.remote_addr is always 127.0.0.1 behind nginx,
    # breaking per-IP rate limiting (all users share one bucket).
    app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)

    # Initialize Flask-Sock for WebSocket support
    sock = Sock(app)

    # Configure CORS — allow your production domain and any localhost port for dev
    # Anchored regex prevents partial matches like http://localhostX.evil.com
    # Add extra origins via CORS_ORIGINS env var (comma-separated, e.g. https://yourdomain.com)
    _extra_origins = [o.strip() for o in os.getenv('CORS_ORIGINS', '').split(',') if o.strip()]
    CORS(app, origins=[
        r'^http://localhost:\d+$',
        r'^chrome-extension://',   # JamBot Browser Companion extension
        *_extra_origins,
    ], supports_credentials=True)

    # ── Rate limiting ─────────────────────────────────────────────────────────
    # Per-IP limits protect expensive endpoints from abuse.
    # Override default via RATELIMIT_DEFAULT env var (e.g. "100 per minute").
    # Disable for tests: config_override={'RATELIMIT_ENABLED': False}.
    limiter = Limiter(
        get_remote_address,
        app=app,
        default_limits=[os.getenv('RATELIMIT_DEFAULT', '200 per minute')],
        storage_uri='memory://',
    )
    app.limiter = limiter

    # ── Clerk auth gate ────────────────────────────────────────────────────────
    # Auth is only active when CLERK_PUBLISHABLE_KEY is set in .env.
    # Without it, the app runs fully open (single-user / local mode).
    _clerk_key = (os.getenv('CLERK_PUBLISHABLE_KEY') or os.getenv('VITE_CLERK_PUBLISHABLE_KEY', '')).strip()
    _auth_enabled = bool(_clerk_key)
    # Route-level guards that must agree with THIS gate (not with the import-time
    # view in services.auth) read it from here — e.g. routes/vault.py
    # _platform_admin_only (SEC-069).
    app.config['CLERK_AUTH_ENABLED'] = _auth_enabled

    # Privileged surfaces — require an admin user (services.auth.ADMIN_USER_IDS),
    # not just any allowlisted tenant user. ALLOWED_USER_IDS gates the voice app;
    # these endpoints can rewrite vault credentials, openclaw.json (incl. provider
    # baseUrl/apiKey), agent workspace files, and inject into the live agent session.
    _ADMIN_ONLY_PREFIXES = (
        '/api/admin/',
        '/api/vault/',      # oauth callback is exempted via _PUBLIC_PREFIXES below
        '/api/workspace/',
        '/api/refactor/',
        '/api/server-stats',
        '/api/plugins/',    # install/uninstall/restart/config (assets exempted below)
        '/api/plugins',     # bare list endpoint
        '/api/services/',   # Service Catalog + per-service health (WO-1.2) — reads
                            # gateway/LLM/TTS/STT descriptors + vault cred status;
                            # admin-only, never public.
    )

    if not _auth_enabled:
        logger.info('No CLERK_PUBLISHABLE_KEY set — auth disabled (local mode)')
        # Hosted tenants always set CANVAS_REQUIRE_AUTH=true (JamBot .env template).
        # If the Clerk key goes missing there (broken .platform-keys.env mount),
        # the admin surface must fail CLOSED — previously it failed open and every
        # /api/admin, /api/vault and RPC-proxy endpoint became unauthenticated.
        # Local / self-hosted installs (no CANVAS_REQUIRE_AUTH) keep the documented
        # open-access behaviour.
        if os.getenv('CANVAS_REQUIRE_AUTH', '').strip().lower() == 'true':
            logger.error('CANVAS_REQUIRE_AUTH=true but no Clerk key — FAILING CLOSED (all non-public routes)')

            # F-7 (2026-07-15): fail CLOSED for the WHOLE app, not just admin. When a
            # hosted tenant requires auth (CANVAS_REQUIRE_AUTH=true) but Clerk is
            # unconfigured (missing/blank key — e.g. a broken .platform-keys.env mount),
            # `require_auth` below is never registered, so PREVIOUSLY only /admin was
            # blocked and every other protected route (voice/canvas/session RPC/vault)
            # was served unauthenticated. Nobody can log in in this state anyway, so we
            # deny every non-public path (deny-by-default) and serve only the minimal
            # shell needed to render the login/error page + health probes. This is an
            # emergency degraded posture; the real fix is restoring the Clerk key.
            _UNCONF_PUBLIC_EXACT = {
                '/', '/pi', '/health', '/health/live', '/health/ready',
                '/favicon.ico', '/sw.js', '/manifest.json',
                '/api/config', '/api/version', '/api/auth/check',
            }
            _UNCONF_PUBLIC_PREFIXES = ('/src/', '/static/', '/images/', '/plugins/')

            @app.before_request
            def fail_closed_unconfigured():
                path = request.path
                if path in _UNCONF_PUBLIC_EXACT:
                    return
                # /src/admin.html is the admin shell — never public even here
                if path != '/src/admin.html' and any(path.startswith(p) for p in _UNCONF_PUBLIC_PREFIXES):
                    return
                # everything else is denied: JSON 401 for APIs, redirect to the login shell for pages
                if path.startswith('/api/') or request.headers.get('X-Requested-With'):
                    return jsonify({
                        'error': 'Auth required but Clerk is not configured — service unavailable',
                        'code': 'auth_unconfigured',
                    }), 503
                return redirect('/')
    else:
        # Routes that never require authentication:
        _PUBLIC_PREFIXES = (
            '/src/',       # static JS/CSS (needed to render the login screen)
            '/sounds/',
            '/music/',
            '/generated_music/',  # AI-generated songs — shareable download links (texted/emailed
                                  # to recipients with no app login). Non-sensitive (client's own
                                  # jingles); the /api/music track list is already public anyway.
            '/images/',    # canvas images (individual pages check their own flag)
            '/uploads/',   # uploaded/generated files — page assets. routes/static_files.py gates it
                           # itself: always-private areas (field-dock/, sms/) are enforced, the rest
                           # follows UPLOADS_AUTH_MODE (shadow/enforce)
            '/canvas-data/',  # processed-song media (audio stems) + fixtures for the Suno Studio editor — non-sensitive, served like /uploads/ (added 2026-06-25)
            '/static/',    # PWA icons, app icons
            '/pages/',     # canvas pages — served without auth (CANVAS_REQUIRE_AUTH opt-in)
            # '/api/upload' (the POST upload) is an EXACT public path below, not a prefix: as a prefix it
            # also opened '/api/uploads' (the listing of every filename) to anyone (2026-11-09).
            '/api/plugins/assets',  # Plugin face scripts/CSS — fetched by index.html before login.
                                    # All other /api/plugins routes (install/uninstall/restart/config)
                                    # are state-changing admin operations and require admin auth below.
            '/api/vault/oauth/callback/',  # OAuth callbacks — redirected from external providers
            '/api/vault/oauth/relay/',     # single-callback OAuth relay (GET only): forwards code+state to
                                           # an allow-listed tenant callback; reads no vault, exchanges nothing
            '/plugins/',      # Plugin static assets — face scripts, CSS, previews
            '/api/theme',     # theme config — loaded before Clerk init
            '/faces/custom/', # custom face HTML — loaded in iframe by face-box
            '/registry/',     # Pinokio registry check-in — accessed by Pinokio, not logged-in user
            '/checkpoints/',  # Pinokio snapshot endpoint — called from /registry/checkin page JS
            '/openclaw-ui/',  # OpenClaw Control UI SPA + assets — proxied to internal gateway
            '/share/capture/', # Camera Capture share portal — token-gated public access for clients
            '/u/',         # Signed one-time upload links (routes/intake.py) — the TOKEN is the
                           # gate, not Clerk; GET renders the form, POST accepts the upload. Never
                           # add '/api/intake/mint' here — minting stays behind require_auth below
                           # (agent key or Clerk session), only /u/<token> itself is public.
        )
        # Public for READS ONLY (GET/HEAD/OPTIONS). These prefixes serve config
        # lists the UI loads before Clerk init, but their state-changing methods
        # (POST/PUT/PATCH/DELETE) must NOT bypass auth — a prefix opened for a
        # benign read previously exposed every write sibling under it
        # (SEC-1/2/3/6/9). Write methods fall through to the normal auth path:
        # the internal agent key already carves out its allowed non-admin paths
        # (canvas/tts/suno/music writes the openclaw agent depends on), and
        # everything else requires a Clerk session.
        _PUBLIC_READ_PREFIXES = (
            '/api/canvas/',   # GET manifest/context/pages — writes gated (SEC-2/3/8)
            '/api/tts/',      # GET provider list — clone/default-writes gated (SEC-9)
            '/api/chat',      # LLM proxy — POST gated so anon can't burn the LLM budget (SEC-6)
            '/api/music',     # GET track list — upload/delete gated
            '/api/faces',     # GET face list — enrollment writes gated
            '/api/custom-faces',  # GET manifest — HTML-face writes gated (SEC-3 stored XSS)
            '/api/icons/',    # GET icon library — generate/upload gated (SEC-9)
            '/api/suno',      # GET read actions only (status/list/...) — see _PUBLIC_READ_ACTIONS;
                              # every generation action is gated on GET and POST (SEC-9)
            '/api/profiles',  # GET profile config — activate/save writes gated (SEC-10)
            '/api/maps/config',  # GET Google-Maps JS API key — canvas iframe pages can't
                                 # authenticate (window.authFetch not injected there) and the
                                 # JS key is browser-exposed BY DESIGN (Google Maps JS loads it
                                 # client-side). Security boundary = HTTP-referrer restriction on
                                 # the key in Google Cloud (must be *.jam-bot.com). /api/maps/
                                 # directions (POST, billable server-side calls) stays gated.
        )
        # Public-read paths that also run PAID work on GET. `/api/suno` selects its
        # operation with ?action= on GET as well as POST, so the method-only test
        # below let an anonymous `GET /api/suno?action=generate` (or jingle, sfx,
        # extend, cover, stems, ...) spend Suno credits with no login. On these
        # exact paths only the listed read actions stay public; every other action
        # falls through to the agent-key / Clerk check, the same gate the POSTs use.
        # The default action when none is given is the endpoint's own default.
        _PUBLIC_READ_ACTIONS = {
            '/api/suno': ('list', frozenset({
                'list', 'list_jingles', 'jingle_styles', 'status', 'credits',
            })),
        }
        # NOTE: /api/stt/ is intentionally NOT public — the Deepgram token
        # endpoint returns a live secret (SEC-1). All /api/stt/* now requires a
        # Clerk session or the agent key.
        _PUBLIC_EXACT = {
            '/',           # main page — hosts the Clerk login gate itself
            '/pi',         # Pi-optimized page — same login gate, different entry point
            '/health/live',
            '/health/ready',
            '/api/auth/check',      # Auth check endpoint — does its own token verification
            '/api/upload',          # file upload (POST) — canvas pages lose Clerk JWT on long sessions.
                                    # EXACT match only: /api/uploads (the listing) is NOT public.
            '/api/suno/callback',   # Suno's servers POST here from external IPs (no Clerk token)
            '/api/version',         # Version check — loaded before auth to show update banner
            '/api/config',          # Public client config (Clerk publishable key) — admin.html bootstrap
            '/sw.js',           # PWA service worker — browser fetches this before auth
            '/manifest.json',   # PWA manifest — browser fetches this before auth
            '/favicon.ico',     # Browser favicon request — before auth
            '/ws/clawdbot',     # WebSocket — browsers can't send Clerk token in WS headers;
                                # handler secures itself via CLAWDBOT_AUTH_TOKEN to the gateway
            '/ws/terminal',     # Terminal PTY WebSocket — handler self-authenticates via
                                # the __session cookie or X-Agent-Key before spawning a shell
            '/openclaw-ui',     # WebSocket upgrade for OpenClaw Control UI proxy (no trailing slash)
            '/ws/browse-stream', # WebSocket — co-browsing screencast (#154); handler self-auths
                                 # via Clerk token + injects the service key server-side
        }

        # Detect whether Clerk auth is configured at startup.
        # Auth is opt-in: when no key is set, all routes are accessible (README § Authentication).
        _clerk_key = (os.getenv('CLERK_PUBLISHABLE_KEY') or os.getenv('VITE_CLERK_PUBLISHABLE_KEY', '')).strip()

        # Internal agent API key — allows openclaw agents to call Flask APIs
        # without a Clerk JWT. Set AGENT_API_KEY in the container .env.
        _agent_api_key = os.getenv('AGENT_API_KEY', '').strip()

        @app.before_request
        def require_auth():
            """Block unauthenticated requests to all non-exempt routes.

            Skipped entirely when Clerk is not configured (no CLERK_PUBLISHABLE_KEY),
            matching the documented opt-in auth behaviour.
            """
            if not _clerk_key:
                return  # No Clerk configured — open access (single-user / self-hosted)

            path = request.path

            # CSRF guard: state-changing browser requests must come from our own
            # origin. Cookie (__session) auth makes cross-site request forgery
            # possible; a mismatched Origin header is the reliable browser signal.
            # Non-browser clients (agents, server-to-server callbacks like Suno's)
            # send no Origin header and pass through untouched.
            if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
                _origin = request.headers.get('Origin', '')
                if _origin and not request.headers.get('Authorization', '').startswith('Bearer '):
                    from urllib.parse import urlparse
                    _origin_host = urlparse(_origin).netloc
                    if _origin_host and _origin_host != request.host:
                        return jsonify({'error': 'Cross-origin request blocked', 'code': 'csrf_blocked'}), 403

            # Always allow health probes and static assets
            if path in _PUBLIC_EXACT:
                return
            # /src/ is public (login-screen assets) EXCEPT the admin shell itself —
            # /src/admin.html must go through the same admin gate as /admin.
            if path != '/src/admin.html' and any(path.startswith(p) for p in _PUBLIC_PREFIXES):
                return
            # Read-only public prefixes: GET/HEAD/OPTIONS bypass auth (config lists
            # loaded before Clerk init); write methods fall through to the auth
            # path below (agent key or Clerk session required).
            if request.method in ('GET', 'HEAD', 'OPTIONS') and any(
                path.startswith(p) for p in _PUBLIC_READ_PREFIXES
            ):
                _read_actions = _PUBLIC_READ_ACTIONS.get(path.rstrip('/'))
                if _read_actions is None:
                    return
                _default_action, _allowed = _read_actions
                if request.args.get('action', _default_action) in _allowed:
                    return
                # A paid/state-changing action on a public-read path: fall through.
            # Canvas pages and images have their own auth logic (public flag)
            # handled inside canvas_bp — let them through here
            if path.startswith('/pages/') or path.startswith('/canvas-proxy') or path.startswith('/website-dev'):
                return

            # Admin-only surfaces are NEVER reachable with the internal agent key.
            # The agent key authorizes Docker-network service calls (canvas,
            # conversation, /api/session/reset, song-tagger, etc.), but a
            # prompt-injected agent must NOT be able to rewrite vault creds /
            # openclaw.json provider baseUrl / workspace files, or inject into the
            # live session via the admin RPC proxy. Without this carve-out the
            # agent-key `return` short-circuited BEFORE the admin gate below,
            # defeating the a957449 admin-authz lockdown. Same admin test as the
            # Clerk-path gate (single source of truth).
            _is_admin_path = (
                path == '/admin' or path.startswith('/admin/')
                or path == '/src/admin.html'
                or any(path.startswith(p) for p in _ADMIN_ONLY_PREFIXES)
            )

            # Internal agent API key — openclaw agents calling NON-admin Flask APIs
            # from inside the Docker network.
            if not _is_admin_path and _agent_api_key and request.headers.get('X-Agent-Key') == _agent_api_key:
                return

            from services.auth import get_token_from_request, verify_clerk_token
            token = get_token_from_request()
            user_id = verify_clerk_token(token) if token else None

            if not user_id:
                # For API calls return JSON 401; for page navigations redirect to /
                if path.startswith('/api/') or request.headers.get('X-Requested-With'):
                    # Name what IS accepted (#477): an agent that got here spent hours
                    # cycling gateway tokens / X-API-Key / X-Auth-Token, none of which
                    # this gate reads. auth_required means "no accepted credential was
                    # presented" — never an authorization state like a page lock
                    # (those come back 403 from the route with their own code).
                    return jsonify({
                        'error': 'Unauthorized',
                        'code': 'auth_required',
                        'accepted_credentials': [
                            'Authorization: Bearer <Clerk session JWT>',
                            '__session cookie (Clerk)',
                            'X-Agent-Key: <AGENT_API_KEY> (tenant agent, non-admin paths only)',
                        ],
                    }), 401
                # HTML page request — redirect to root (login gate)
                return redirect('/')

            # Stash for downstream routes (e.g. conversation.py reads g.clerk_user_id
            # to inject a [CURRENT_USER: ...] tag into the gateway message context).
            g.clerk_user_id = user_id

            # Admin authorization — being an allowlisted voice user does NOT grant
            # access to the admin dashboard or privileged APIs.
            if _is_admin_path:
                from services.auth import is_admin_user
                if not is_admin_user(user_id):
                    logger.warning('Admin authz denied: user_id=%s path=%s', user_id, path)
                    if path.startswith('/api/'):
                        return jsonify({'error': 'Admin access required', 'code': 'admin_required'}), 403
                    return redirect('/')

    # ── JSON error handler for 413 (file too large) ────────────────────────
    @app.errorhandler(413)
    def handle_413(e):
        return jsonify({'error': 'File too large (100 MB max)'}), 413

    # ── Security headers (P7-T3 security audit) ──────────────────────────────
    @app.after_request
    def add_security_headers(response):
        """Add defensive HTTP security headers to every response."""
        response.headers.setdefault('X-Content-Type-Options', 'nosniff')
        response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
        response.headers.setdefault('X-XSS-Protection', '1; mode=block')
        response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
        # Allow microphone, camera, and geolocation (site capture GPS tagging)
        response.headers.setdefault(
            'Permissions-Policy', 'camera=(self), microphone=*, geolocation=(self), pointer-lock=*'
        )
        # Hosts this deployment runs its own services on (CSP_TRUSTED_HOSTS, services/csp.py).
        # Empty on a stock install; never hard-code a deployment's domain here.
        from services import csp as _csp
        _trusted = _csp.sources()
        response.headers.setdefault(
            'Content-Security-Policy',
            "default-src 'self'; "
            # cdn.tailwindcss.com is REQUIRED — canvas pages load Tailwind via the Play CDN; without it
            # in script-src the script is blocked and pages render as raw unstyled text. Do NOT re-strip
            # (recurring regression; memory feedback_canvas_tailwind_cdn). fonts.googleapis.com (style) +
            # fonts.gstatic.com (font) are needed for the Google-font <link>s canvas pages use.
            # 'unsafe-eval' + 'wasm-unsafe-eval' are REQUIRED: cdn.tailwindcss.com is the Tailwind PLAY
            # CDN, which compiles CSS at runtime via eval()/new Function — without unsafe-eval the script
            # loads but generates NO styles → pages still render as raw text. (canvas.py CSP already had
            # these; the global one didn't — that was the real cause of the unstyled SEO dashboard.)
            # maps.googleapis.com + maps.gstatic.com are REQUIRED for the `maps` skill's
            # canvas pages (Google Maps JS API). Without them the JS is blocked and every
            # map page renders an empty box — silently, since the only signal is a console
            # CSP violation. Found 2026-07-24: the maps skill documented these as allowed
            # but they were never actually in either CSP. Do NOT re-strip.
            "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https://cdn.jsdelivr.net https://cdn.tailwindcss.com https://*.clerk.accounts.dev https://maps.googleapis.com https://maps.gstatic.com" + _trusted + "; "
            # jsdelivr serves stylesheets as well as scripts (Leaflet, etc). It was in
            # script-src but NOT style-src, so a canvas page could load a library's JS
            # and have its CSS silently blocked — which renders as "the widget is
            # missing and everything overlaps", not as an obvious error. Found 2026-07-27
            # on tenant-f's Leaflet map page.
            "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; "
            "font-src 'self' data: https://fonts.gstatic.com; "
            # Map tiles/markers come from maps.gstatic.com, *.googleapis.com (khms tile
            # shards) and *.ggpht.com (Street View); googleusercontent serves place photos.
            # (Map tiles: OpenStreetMap + CARTO for Leaflet pages. Until 2026-10-16 a stray ';'
            # cut these hosts out of img-src and the missing separator after them swallowed
            # media-src, so neither applied.)
            "img-src 'self' data: blob: https://img.clerk.com https://images.clerk.dev https://*.clerk.accounts.dev https://lh3.googleusercontent.com https://avatars.githubusercontent.com https://bhaleyart.github.io https://maps.googleapis.com https://maps.gstatic.com https://*.googleapis.com https://*.ggpht.com https://*.googleusercontent.com https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://*.basemaps.cartocdn.com; "
            "media-src 'self' blob:; "
            "connect-src 'self' wss: https:; "
            "frame-src 'self' https://*.clerk.accounts.dev https:" + _trusted + "; "
            "worker-src 'self' blob:"
        )
        return response

    # ── CDN cache cleanup (MUST run after flask_cors) ──────────────────────
    # flask_cors adds Vary:Origin + Access-Control-* to ALL responses, which
    # causes Cloudflare to mark them cf-cache-status:DYNAMIC (uncacheable).
    # Canvas media files don't need CORS — strip those headers so CDN caches them.
    # Inserted at position 0 in after_request list so it runs LAST in LIFO order.
    def _strip_cdn_blocking_headers(response):
        _media_exts = ('.mp4', '.webm', '.mp3', '.wav', '.ogg', '.png', '.jpg',
                       '.jpeg', '.gif', '.svg', '.webp', '.pdf')
        if request.path.startswith('/pages/') and any(request.path.endswith(e) for e in _media_exts):
            for h in ['Vary', 'Access-Control-Allow-Origin',
                      'Access-Control-Allow-Credentials',
                      'Content-Security-Policy', 'X-Frame-Options',
                      'Permissions-Policy', 'X-XSS-Protection',
                      'Referrer-Policy']:
                response.headers.pop(h, None)
        return response
    app.after_request_funcs.setdefault(None, []).insert(0, _strip_cdn_blocking_headers)

    return app, sock