CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 36s
Step 4 of milestone 288 (decision #2686). services/forge.py defines the contract steps 5-7 consume — read_file (content + last_commit_sha, the provenance stamp), default_branch, resolve_repo, check — with GiteaForge as the first implementation over the REST contents/repo/version/user endpoints. Repo identity reuses normalize_repo_key: the host segment selects whether this forge serves a recorded repo, the remainder is the API path, so no new identity scheme exists. Read-only by construction; errors never carry the token; first outbound-HTTP timeout convention (5s total, no retries — the consumer's fallback is the retry policy). OPTIONAL per instance (rule #115): get_forge() returns None when unconfigured and every consumer treats None as today's behavior. Config lives in admin settings (Settings → Config → Git Forge: kind/base URL/token, save + test-connection probe reporting version + identity), with FORGE_* env / Docker-secret fallbacks; DB wins so a UI edit can't silently lose to an env var. Token treatment follows the smtp_password convention (masked on read, mask-sentinel skipped on write, absent from audit details) — and wiring it surfaced that the generic GET/PUT /api/settings dump bypassed that masking for the owning admin's raw KV rows, so secret keys are now masked there too (fixes the same exposure for smtp_password). Contract tests run against httpx.MockTransport as the fake forge — the reference behaviors the GitHub adapter (step 8) must reproduce — plus the off-by-default gate, partial-config-is-off, env-vs-DB precedence, and route/mask structural checks. Also: the step-2 definition detector learned to skip dunders after flagging __init__ as 'already defined in 4 files' on this step's own build — guaranteed noise for a hint that must stay trustworthy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
76 lines
2.5 KiB
Python
76 lines
2.5 KiB
Python
"""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 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.
|
|
_SECRET_KEYS = frozenset({"smtp_password", "forge_token"})
|
|
_SECRET_MASK = "********"
|
|
|
|
|
|
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})
|