"""User settings + integrations (SearXNG status). Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule hooks were removed in Phase 8 alongside the chat/journal subsystems. """ import logging from quart import Blueprint, jsonify, request from scribe.auth import login_required, get_current_user_id from scribe.config import Config from scribe.services.settings import ( SECRET_MASK, delete_setting, get_all_settings, get_setting, set_settings_batch, ) logger = logging.getLogger(__name__) settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings") # Keys whose values are credentials. The admin endpoints that own them mask on # read and skip the mask on write; this generic KV surface has to apply the # same treatment, or it silently un-masks what those endpoints masked — the # rows live on the admin's own user_id, so the plain GET returned them raw. # (forge_token left with 0078: forge credentials are keyring rows now, #2778.) _SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"}) def _masked(settings: dict) -> dict: return { k: (SECRET_MASK if k in _SECRET_KEYS and v else v) for k, v in settings.items() } @settings_bp.route("", methods=["GET"]) @login_required async def get_settings_route(): uid = get_current_user_id() settings = await get_all_settings(uid) return jsonify(_masked(settings)) @settings_bp.route("", methods=["PUT"]) @login_required async def update_settings_route(): uid = get_current_user_id() data = await request.get_json() if not isinstance(data, dict): return jsonify({"error": "Expected a JSON object"}), 400 to_save = {} for k, v in data.items(): str_v = str(v) # A masked secret round-tripping through a client is "unchanged", not # a request to store the mask over the real credential. if k in _SECRET_KEYS and str_v == SECRET_MASK: continue if not str_v: await delete_setting(uid, k) else: to_save[k] = str_v if to_save: await set_settings_batch(uid, to_save) settings = await get_all_settings(uid) return jsonify(_masked(settings)) @settings_bp.route("/search", methods=["GET"]) @login_required async def test_search(): """Report SearXNG configuration status (used by the Integrations tab).""" if not Config.searxng_enabled(): return jsonify({"configured": False, "results": [], "searxng_url": ""}) return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL}) # --- forge connections (#2778) ------------------------------------------------ # The user's keyring: read-only forge credentials, one per host, resolved by # repo host for every server-side forge read on the user's projects. Strictly # own-rows — a connection is a credential, and there is no admin view of # another user's keyring. Tokens never leave the server: the model's to_dict # omits them, and the routes never echo the submitted value back. @settings_bp.route("/forge-connections", methods=["GET"]) @login_required async def list_forge_connections_route(): from scribe.services.forge import FORGE_KINDS from scribe.services.forge_connections import list_connections uid = get_current_user_id() rows = await list_connections(uid) return jsonify({ "connections": [r.to_dict() for r in rows], "kinds": list(FORGE_KINDS), }) @settings_bp.route("/forge-connections", methods=["POST"]) @login_required async def create_forge_connection_route(): from scribe.services.forge_connections import create_connection uid = get_current_user_id() data = await request.get_json() or {} try: row = await create_connection( uid, kind=str(data.get("kind", "")), base_url=str(data.get("base_url", "")), token=str(data.get("token", "")), ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 return jsonify(row.to_dict()), 201 @settings_bp.route("/forge-connections/", methods=["PUT"]) @login_required async def update_forge_connection_route(connection_id: int): from scribe.services.forge_connections import update_connection uid = get_current_user_id() data = await request.get_json() or {} token = str(data.get("token", "")) # The mask coming back means "unchanged" — the form round-trips what the # list showed, and storing the mask would silently break the connection. if token == SECRET_MASK: token = "" try: row = await update_connection( uid, connection_id, kind=str(data.get("kind", "")), base_url=str(data.get("base_url", "")), token=token, ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 if row is None: return jsonify({"error": "Connection not found"}), 404 return jsonify(row.to_dict()) @settings_bp.route("/forge-connections/", methods=["DELETE"]) @login_required async def delete_forge_connection_route(connection_id: int): from scribe.services.forge_connections import delete_connection uid = get_current_user_id() if not await delete_connection(uid, connection_id): return jsonify({"error": "Connection not found"}), 404 return "", 204 @settings_bp.route("/forge-connections//test", methods=["POST"]) @login_required async def test_forge_connection_route(connection_id: int): """Probe the SAVED connection: reachability and token acceptance in one press, so a misconfiguration is visible now rather than as silent fallbacks later (#2663's lesson, applied per keyring row).""" from scribe.services.forge import ForgeError, build_adapter from scribe.services.forge_connections import get_connection uid = get_current_user_id() row = await get_connection(uid, connection_id) if row is None: return jsonify({"error": "Connection not found"}), 404 adapter = build_adapter(row.kind, row.base_url, row.token) if adapter is None: return jsonify({"error": "Connection is not usable — check kind and base URL"}), 400 try: return jsonify(await adapter.check()) except ForgeError as exc: return jsonify({"error": str(exc)}), 502