Ctrl K
ComunidadGuías…

flask_proxy.py

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

Archivo original ↗Preguntar a KiBot
"""Legacy Flask proxy compatibility surface.

This module keeps the root-level ``flask_proxy`` import available for the
legacy proxy/search/contracts API documented in ``docs/UI_SURFACES.md``.
The Electron app and ``rex-gui`` are the primary supported interfaces; this
surface remains intentionally small and compatibility-focused.
"""

from __future__ import annotations

import importlib
import importlib.util
import json
import os
from types import SimpleNamespace
from typing import Any

from flask import Flask, abort, jsonify, redirect, request
from flask import g as flask_g
from flask_cors import CORS

import utils.env_loader  # noqa: F401  # Auto-loads .env on import
from rex.contracts import CONTRACT_VERSION
from rex.contracts.core import ALL_MODELS
from rex.health import check_config, create_health_blueprint
from rex.http_errors import (
    BAD_REQUEST,
    INTERNAL_ERROR,
    SERVICE_UNAVAILABLE,
    error_response,
    install_error_envelope_handler,
)
from rex.memory_utils import load_memory_profile, load_users_map, resolve_user_key
from rex.rate_limiter import install_rate_limiter
from rex.request_logging import install_request_logging
from rex.startup import log_service_ready, run_startup_sequence

_TESTING_MODE = os.getenv("REX_TESTING", "").lower() in {"1", "true", "yes"}

# Import dashboard blueprint if the compatibility dashboard package exposes one.
dashboard_bp = getattr(importlib.import_module("rex.dashboard"), "dashboard_bp", None)
_DASHBOARD_AVAILABLE = dashboard_bp is not None

_CONTRACTS_AVAILABLE = True

g: Any = SimpleNamespace() if _TESTING_MODE else flask_g

# --- Startup sequence — config → database → migration → service init ---
if not _TESTING_MODE:
    run_startup_sequence()

# --- Flask Setup ---
app = Flask(__name__)
install_request_logging(app)
install_error_envelope_handler(app)
install_rate_limiter(app)
app.register_blueprint(create_health_blueprint(checks=[check_config]))

# CORS Configuration: Restrict origins based on environment.
ALLOWED_ORIGINS = os.getenv(
    "REX_ALLOWED_ORIGINS",
    "http://localhost:3000,http://localhost:5000,http://127.0.0.1:3000,http://127.0.0.1:5000",
)
_CORS_ORIGINS = [origin.strip() for origin in ALLOWED_ORIGINS.split(",") if origin.strip()]

CORS(
    app,
    resources={
        r"/*": {
            "origins": _CORS_ORIGINS,
            "methods": ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
            "allow_headers": [
                "Content-Type",
                "Authorization",
                "X-Rex-Proxy-Token",
                "X-Dashboard-Token",
                "Cf-Access-Authenticated-User-Email",
            ],
            "supports_credentials": True,
            "max_age": 600,
        }
    },
)

if _DASHBOARD_AVAILABLE:
    app.register_blueprint(dashboard_bp)

# Register inbound SMS webhook blueprint (config-driven; disabled by default).
try:
    register_inbound_sms_webhook = importlib.import_module(
        "rex.messaging_backends.webhook_wiring"
    ).register_inbound_sms_webhook
    _INBOUND_SMS_REGISTERED = register_inbound_sms_webhook(app)
except Exception as _inbound_exc:
    app.logger.debug("Inbound SMS webhook wiring skipped: %s", _inbound_exc)
    _INBOUND_SMS_REGISTERED = False

# Wire retention cleanup scheduler jobs (config-driven; safe to skip on failure).
try:
    from rex.retention import wire_retention_cleanup

    _RETENTION_CLEANUP_WIRED = wire_retention_cleanup()
except Exception as _retention_exc:
    app.logger.debug("Retention cleanup wiring skipped: %s", _retention_exc)
    _RETENTION_CLEANUP_WIRED = False

# --- Optional Plugin: Web Search ---
search_web = None
try:
    spec = importlib.util.find_spec("plugins.web_search")
    if spec:
        mod = importlib.import_module("plugins.web_search")
        search_web = getattr(mod, "search_web", None)
except Exception as exc:
    app.logger.warning("Failed to load search plugin: %s", exc)

# --- Constants ---
USERS_MAP = load_users_map()
PROXY_TOKEN = os.getenv("REX_PROXY_TOKEN")
ALLOW_LOCAL = os.getenv("REX_PROXY_ALLOW_LOCAL") == "1"

if not _TESTING_MODE:
    log_service_ready()


def _summarize_memory(profile: dict[str, Any]) -> dict[str, Any]:
    summary: dict[str, Any] = {}
    if not isinstance(profile, dict):
        return summary

    if isinstance(name := profile.get("name"), str):
        summary["name"] = name

    if isinstance(role := profile.get("role"), str):
        summary["role"] = role

    prefs = profile.get("preferences")
    if isinstance(prefs, dict):
        summary["preferences"] = {
            "tone": prefs.get("tone"),
            "topics": [topic for topic in prefs.get("topics", []) if isinstance(topic, str)],
        }

    return summary


def _extract_shared_secret() -> str | None:
    auth_header = request.headers.get("Authorization", "")
    if auth_header.lower().startswith("bearer "):
        return auth_header[7:].strip() or None
    return request.headers.get("X-Rex-Proxy-Token")


def _is_loopback_address(addr: str | None) -> bool:
    if not addr:
        return False
    if addr.startswith("::ffff:"):
        addr = addr.split("::ffff:", 1)[-1]
    return addr in {"127.0.0.1", "::1"}


def _testing_memory_profile(user_key: str | None) -> dict[str, Any]:
    name = user_key or "test-user"
    return {"name": name, "role": "test user", "preferences": {"topics": []}}


# --- Public Endpoints (no auth required) ---
_PUBLIC_PATHS = frozenset({"/contracts", "/"})
_DASHBOARD_PREFIXES = (
    "/dashboard",
    "/api/dashboard",
    "/api/settings",
    "/api/chat",
    "/api/scheduler",
    "/api/notifications",
    "/api/voice",
)
_WEBHOOK_PREFIXES = ("/webhooks/",)


def _is_memory_exempt_path(path: str) -> bool:
    return (
        path in _PUBLIC_PATHS
        or any(path.startswith(prefix) for prefix in _DASHBOARD_PREFIXES)
        or any(path.startswith(prefix) for prefix in _WEBHOOK_PREFIXES)
    )


def _resolve_cf_user_key(email: str) -> str:
    resolved_user_key = resolve_user_key(email, USERS_MAP)
    if not resolved_user_key:
        abort(403, "Access denied for this user.")
    return resolved_user_key


def _resolve_token_user_key(token: str) -> str:
    if not PROXY_TOKEN or token != PROXY_TOKEN:
        abort(403, "Invalid or missing proxy token.")
    resolved_user_key = resolve_user_key(os.getenv("REX_ACTIVE_USER"), USERS_MAP)
    if not resolved_user_key:
        abort(403, "REX_ACTIVE_USER is not configured or invalid.")
    return resolved_user_key


def _resolve_local_user_key() -> str:
    active_user = os.getenv("REX_ACTIVE_USER")
    resolved_user_key = resolve_user_key(active_user, USERS_MAP)
    if not resolved_user_key and _TESTING_MODE and active_user:
        resolved_user_key = active_user.strip().lower()
    if not resolved_user_key:
        abort(403, "Local access not permitted: REX_ACTIVE_USER missing.")
    return resolved_user_key


def _resolve_request_user_key() -> str:
    email = request.headers.get("Cf-Access-Authenticated-User-Email")
    token = _extract_shared_secret()

    if email:
        return _resolve_cf_user_key(email)
    if token:
        return _resolve_token_user_key(token)
    if ALLOW_LOCAL and _is_loopback_address(request.remote_addr):
        return _resolve_local_user_key()

    abort(403, "No authenticated identity provided.")


def _load_request_memory_profile(user_key: str) -> dict[str, Any]:
    try:
        return load_memory_profile(user_key)
    except FileNotFoundError:
        if _TESTING_MODE:
            return _testing_memory_profile(user_key)
        abort(500, f"Memory file not found for user '{user_key}'.")
    except json.JSONDecodeError:
        abort(500, f"Memory file for user '{user_key}' is invalid JSON.")


@app.before_request
def load_user_memory() -> None:
    if _is_memory_exempt_path(request.path):
        return

    if _TESTING_MODE:
        g.__dict__.clear()

    g.user_key = _resolve_request_user_key()
    g.user_folder = os.path.join("Memory", g.user_key)
    g.memory = _load_request_memory_profile(g.user_key)


@app.route("/")
def index():
    return redirect("/dashboard")


@app.route("/whoami")
def whoami():
    return jsonify(
        {
            "user": g.user_key,
            "profile": _summarize_memory(g.memory),
        }
    )


@app.route("/search")
def search():
    if not search_web:
        return error_response(SERVICE_UNAVAILABLE, "Web search plugin is not installed.", 503)

    query = request.args.get("q")
    if not query:
        return error_response(BAD_REQUEST, "Missing query", 400)

    try:
        result = search_web(query)
    except Exception as exc:
        app.logger.exception("Web search failed")
        return error_response(INTERNAL_ERROR, f"Search provider error: {exc}", 502)

    return jsonify({"query": query, "result": result})


@app.route("/contracts")
def contracts():
    """Return contract schema metadata for discoverability."""
    if not _CONTRACTS_AVAILABLE:
        return error_response(SERVICE_UNAVAILABLE, "Contracts module not available", 503)

    model_names = [model.__name__ for model in ALL_MODELS]
    return jsonify(
        {
            "contract_version": CONTRACT_VERSION,
            "schema_docs_path": "docs/contracts/",
            "models": model_names,
        }
    )


if __name__ == "__main__":
    app.run(host=os.getenv("REX_FLASK_PROXY_HOST", "127.0.0.1"), port=5000)