b255a0f90e
Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
4.2 KiB
Python
132 lines
4.2 KiB
Python
"""User settings + integrations (CalDAV, 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 ipaddress
|
|
import logging
|
|
import socket
|
|
from urllib.parse import urlparse
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from scribe.auth import login_required, get_current_user_id
|
|
from scribe.config import Config
|
|
from scribe.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
|
|
from scribe.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _is_private_url(url: str) -> bool:
|
|
"""SSRF-blocking helper: returns True for URLs that resolve to private,
|
|
loopback, or link-local addresses. Inlined here after services/llm.py
|
|
(the original home) was removed in Phase 8."""
|
|
try:
|
|
host = urlparse(url).hostname
|
|
if not host:
|
|
return True
|
|
# Resolve to all addresses; reject if any is private/loopback/link-local.
|
|
infos = socket.getaddrinfo(host, None)
|
|
for family, *_rest, sockaddr in infos:
|
|
ip_str = sockaddr[0]
|
|
try:
|
|
ip = ipaddress.ip_address(ip_str)
|
|
except ValueError:
|
|
continue
|
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
|
return True
|
|
except Exception:
|
|
# Conservative: if we can't resolve, treat as private (reject).
|
|
return True
|
|
return False
|
|
|
|
|
|
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
|
|
|
|
|
@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(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)
|
|
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(settings)
|
|
|
|
|
|
@settings_bp.route("/caldav", methods=["GET"])
|
|
@login_required
|
|
async def get_caldav():
|
|
uid = get_current_user_id()
|
|
config = await get_caldav_config(uid)
|
|
if config.get("caldav_password"):
|
|
config["caldav_password"] = "********"
|
|
return jsonify(config)
|
|
|
|
|
|
@settings_bp.route("/caldav", methods=["PUT"])
|
|
@login_required
|
|
async def update_caldav():
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
|
|
# Validate CalDAV URL before saving — block internal/private addresses
|
|
if "caldav_url" in data:
|
|
url = str(data.get("caldav_url") or "").strip()
|
|
if url:
|
|
parsed_scheme = url.split("://")[0].lower() if "://" in url else ""
|
|
if parsed_scheme not in ("http", "https"):
|
|
return jsonify({"error": "CalDAV URL must use http or https"}), 400
|
|
if _is_private_url(url):
|
|
return jsonify({"error": "CalDAV URL must not point to an internal or private address"}), 400
|
|
|
|
settings_to_save = {}
|
|
for key in CALDAV_SETTING_KEYS:
|
|
if key in data:
|
|
if key == "caldav_password" and data[key] == "********":
|
|
continue
|
|
settings_to_save[key] = str(data[key])
|
|
|
|
if settings_to_save:
|
|
await set_settings_batch(uid, settings_to_save)
|
|
return jsonify({"status": "ok"})
|
|
|
|
|
|
@settings_bp.route("/caldav/test", methods=["POST"])
|
|
@login_required
|
|
async def test_caldav():
|
|
uid = get_current_user_id()
|
|
result = await test_connection(uid)
|
|
return jsonify(result)
|
|
|
|
|
|
@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})
|