Coverage for src/local_deep_research/web/routers/settings.py: 94%
1284 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2Settings Routes Module
4This module handles all settings-related HTTP endpoints for the application.
6CHECKBOX HANDLING PATTERN:
7--------------------------
8This module supports TWO submission modes to handle checkboxes correctly:
10**MODE 1: AJAX/JSON Submission (Primary - /save_all_settings)**
11- JavaScript intercepts form submission with e.preventDefault()
12- Checkbox values read directly from DOM via checkbox.checked
13- Data sent as JSON: {"setting.key": true/false}
14- Hidden fallback inputs are managed but NOT used in this mode
15- Provides better UX with instant feedback and validation
17**MODE 2: Traditional POST Submission (Fallback - /save_settings)**
18- Used when JavaScript is disabled (accessibility/no-JS environments)
19- Browser submits form data naturally via request.form
20- Hidden fallback pattern CRITICAL here:
21 * Checked checkbox: Submits checkbox value, hidden input disabled
22 * Unchecked checkbox: Submits hidden input value "false"
23- Ensures unchecked checkboxes are captured (HTML limitation workaround)
25**Implementation Details:**
261. Each checkbox has `data-hidden-fallback` attribute → hidden input ID
272. checkbox_handler.js manages hidden input disabled state
283. AJAX mode: settings.js reads checkbox.checked directly (lines 2233-2240)
294. POST mode: Flask reads request.form including enabled hidden inputs
305. Both modes use convert_setting_value() for consistent boolean conversion
32**Why Both Patterns?**
33- AJAX: Better UX, immediate validation, no page reload
34- Traditional POST: Accessibility, progressive enhancement, JavaScript-free operation
35- Hidden inputs: Only meaningful for traditional POST, ignored in AJAX mode
37This dual-mode approach ensures the app works for all users while providing
38optimal experience when JavaScript is available.
39"""
41from fastapi import APIRouter, Depends, Request
42from fastapi.responses import JSONResponse, RedirectResponse
43from ..dependencies.auth import (
44 require_auth,
45)
46from ..dependencies.rate_limit import (
47 SETTINGS_RATE_LIMIT,
48 _user_key,
49 limiter,
50 settings_limit,
51)
52from ..dependencies.threadpool import run_db_sync
53from ..template_config import templates
55import math
56import platform
57import time
58from types import SimpleNamespace
59from typing import Any, Optional, Tuple, Annotated
60from datetime import UTC, datetime, timedelta, timezone
62import requests
64from loguru import logger
65from sqlalchemy.orm import Session
67from ...config.constants import DEFAULT_OLLAMA_URL
68from ...config.paths import get_data_directory, get_encrypted_database_path
69from ...constants import DEFAULT_SEARCH_TOOL
70from ...database.models.rate_limiting import RateLimitEstimate
71from ...database.models.settings import Setting, SettingType
72from ...database.session_context import get_user_db_session
73from ...database.encrypted_db import db_manager
74from ...llm.providers.base import normalize_provider
75from ...security.egress.policy import (
76 DEFAULT_EGRESS_SCOPE,
77 Decision,
78 EgressContext,
79 EgressScope,
80 PolicyDeniedError,
81 context_from_snapshot,
82 effective_scope_for_display,
83 evaluate_llm_endpoint,
84 parse_user_egress_scope,
85 resolve_run_primary_engine,
86 unprotected_egress_allowed,
87)
88from ...security.egress.validators import (
89 first_egress_validation_error,
90)
91from ...utilities.db_utils import get_settings_manager
92from ...utilities.url_utils import normalize_url
94from ...settings.manager import (
95 check_env_setting,
96 get_typed_setting_value,
97 is_valid_setting_key,
98 parse_boolean,
99)
100from ..services.settings_service import (
101 create_or_update_setting,
102 invalidate_settings_caches,
103 reschedule_document_jobs_if_needed,
104 reschedule_zotero_jobs_if_needed,
105 set_setting,
106)
108from ...security import safe_get
109from ...security.data_sanitizer import DataSanitizer
110from ..warning_checks import calculate_warnings
111from ..dependencies.json_body import json_body_error
113# Create the router for settings
114router = APIRouter(prefix="/settings", tags=["settings"])
116# NOTE: Routes use username (not .get()) intentionally.
117# Depends(require_auth) guarantees the key exists; direct access fails
118# fast if the dependency is ever removed.
120# Settings with dynamically populated options (excluded from validation)
121DYNAMIC_SETTINGS = ["llm.provider", "llm.model", "search.tool"]
123# Settings whose changes trigger a warning recalculation.
124WARNING_AFFECTING_KEYS = frozenset(
125 [
126 "llm.provider",
127 "search.tool",
128 "search.iterations",
129 "search.questions_per_iteration",
130 "llm.local_context_window_size",
131 "llm.context_window_unrestricted",
132 "llm.context_window_size",
133 "policy.egress_scope",
134 "llm.require_local_endpoint",
135 "embeddings.require_local",
136 ]
137)
140def _shape_egress_scope_setting(key: str, metadata: Any) -> Any:
141 """Gate the operator-disabled "unprotected" escape hatch out of the
142 egress-scope setting's ``options`` and normalise its displayed
143 ``value`` wherever ``policy.egress_scope`` metadata is served to the
144 settings UI.
146 Ports the option-filtering / display-normalisation half of main's
147 ``web/routes/settings_routes.py::_shape_egress_scope_metadata``
148 (87537d9ec, "fix(security): operator-gate unprotected egress and
149 harden policy-sensitive consumers", #5148). Without this, the select's
150 ``options`` list (sourced from ``defaults/default_settings.json``,
151 which unconditionally lists "unprotected") is served to the browser
152 as-is, so the settings dashboard would offer the escape hatch even
153 when the operator never set ``LDR_POLICY_ALLOW_UNPROTECTED_EGRESS``.
155 NOTE: main's ``_shape_egress_scope_metadata`` also overlays an env-var
156 lock onto ``value``/``editable`` for the setting (a presentation cue
157 for *any* environment-overridden setting). That is a separate, much
158 broader feature — this branch's ``SettingsManager.get_all_settings()``
159 (see ``settings/manager.py``, ~line 977) already overlays
160 ``LDR_*`` env values onto ``value``/``editable`` uniformly for every
161 setting key, including ``policy.egress_scope`` — so it is intentionally
162 NOT duplicated here; doing so would just re-apply the same override a
163 second time.
164 """
165 if key != "policy.egress_scope" or not isinstance(metadata, dict):
166 return metadata
167 shaped = dict(metadata)
168 options = shaped.get("options")
169 if isinstance(options, list) and not unprotected_egress_allowed():
170 shaped["options"] = [
171 option
172 for option in options
173 if str(option.get("value") if isinstance(option, dict) else option)
174 .strip()
175 .lower()
176 != EgressScope.UNPROTECTED.value
177 ]
178 shaped["value"] = effective_scope_for_display(shaped.get("value"))
179 return shaped
182def _shape_pdf_storage_mode_setting(key: str, metadata: Any) -> Any:
183 """Hide the operator-gated unencrypted 'filesystem' PDF storage option.
185 Ports main's ``_shape_pdf_storage_mode_metadata``
186 (web/routes/settings_routes.py, fb49985aa, "operator-gate unprotected
187 egress and harden policy-sensitive consumers", #5148). Mirrors
188 ``_shape_egress_scope_setting``: when the operator gate
189 ``research_library.allow_filesystem_pdf_storage`` is off (the default),
190 the ``filesystem`` choice is stripped from the
191 ``research_library.pdf_storage_mode`` options served to the settings UI,
192 leaving the encrypted ``database`` default and ``none``.
193 Consumption-site coercion (``resolve_pdf_storage_mode``) is the actual
194 protection; this just keeps a disabled option out of the dropdown so a
195 user doesn't pick a value that is silently coerced away later.
196 """
197 if key != "research_library.pdf_storage_mode" or not isinstance(
198 metadata, dict
199 ):
200 return metadata
201 options = metadata.get("options")
202 if not isinstance(options, list): 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
203 return metadata
204 # Lazy import to avoid pulling the research_library package in at
205 # settings-router module load time.
206 from ...research_library.services.pdf_storage_manager import (
207 filesystem_pdf_storage_allowed,
208 )
210 if filesystem_pdf_storage_allowed():
211 return metadata
212 shaped = dict(metadata)
213 shaped["options"] = [
214 option
215 for option in options
216 if str(option.get("value") if isinstance(option, dict) else option)
217 .strip()
218 .lower()
219 != "filesystem"
220 ]
221 return shaped
224def _apply_env_override(
225 settings_manager, key: str, value: Any, editable: bool
226) -> Tuple[Any, bool]:
227 """Overlay the LDR_* env-var value/editable state onto a single-key
228 lookup.
230 Reuses ``SettingsManager.get_all_settings()`` — the exact overlay
231 ``GET /settings/api`` and ``GET /settings/api/bulk`` already apply via
232 ``check_env_setting``/``get_typed_setting_value`` (see
233 ``settings/manager.py``, ~line 977) — instead of reimplementing that
234 logic here. Without this, ``GET /settings/api/{key}`` (single-key)
235 returned the stale DB value with ``editable=True`` for a setting an
236 operator had pinned via an LDR_* env var, while the bulk endpoints
237 correctly reported the effective value and ``editable=False`` for the
238 same key. This is a read/display fix only: writes to an env-locked
239 setting were already rejected server-side by
240 ``SettingsManager._is_environment_locked`` regardless of this bug.
241 """
242 effective = settings_manager.get_all_settings().get(key)
243 if effective is None:
244 return value, editable
245 return (
246 effective.get("value", value),
247 effective.get("editable", editable),
248 )
251def _filter_editable_settings(form_data: dict, db_session: Session) -> dict:
252 """Remove operator-locked and non-editable keys from *form_data* in place.
254 Operator-locked means an ``LDR_*`` environment variable pins the key
255 (``check_env_setting``). ``SettingsManager.set_setting`` refuses those
256 writes anyway, but it reports the refusal as a plain ``False``, which
257 the bulk write paths count as a *failed* save — so any ``LDR_*``
258 variable made every no-JS form POST flash "Saved with N setting(s)
259 failing" even though every editable key saved fine. Main dropped them
260 here instead; this restores that (#5978).
262 Returns a dict of *all* ``{key: Setting}`` records from the database
263 so callers can reuse it for further validation (e.g. egress-policy checks).
264 """
265 all_db_settings = {
266 setting.key: setting for setting in db_session.query(Setting).all()
267 }
269 non_editable_keys = [
270 key
271 for key in form_data.keys()
272 if check_env_setting(key) is not None
273 or (key in all_db_settings and not all_db_settings[key].editable)
274 ]
275 if non_editable_keys:
276 logger.bind(policy_audit=True).warning(
277 "Skipping operator-locked or non-editable settings: {}",
278 non_editable_keys,
279 )
280 for key in non_editable_keys:
281 del form_data[key]
283 return all_db_settings
286def _resolve_model_discovery_policy(
287 username: str,
288) -> tuple[EgressContext, dict[str, Any]]:
289 """Resolve the current egress policy before model cache or network access.
291 Ported from main's ``web/routes/settings_routes.py`` (87537d9ec,
292 "fix(security): operator-gate unprotected egress and harden
293 policy-sensitive consumers", #5148); the Flask module was deleted by
294 the FastAPI migration and this gate came across as a failing test only.
296 Fails CLOSED: any settings failure (or a non-dict snapshot) raises
297 ``PolicyDeniedError`` instead of falling back to "allow". The previous
298 ``_model_list_local_only`` helper returned False (allow) on error, so a
299 settings outage silently downgraded a local-only user to "list every
300 cloud provider" — which reads their stored API key and sends it to the
301 provider's model-listing endpoint.
302 """
303 settings_snapshot = None
304 try:
305 with get_user_db_session(username) as db_session:
306 if db_session: 306 ↛ 322line 306 didn't jump to line 322
307 settings_manager = get_settings_manager(db_session, username)
308 settings_snapshot = settings_manager.get_settings_snapshot(
309 strict=True
310 )
311 except PolicyDeniedError:
312 raise
313 except Exception:
314 logger.bind(policy_audit=True).warning(
315 "available-model policy settings unavailable"
316 )
317 raise PolicyDeniedError(
318 Decision(False, "settings_unavailable"),
319 target="available_models",
320 ) from None
322 if not isinstance(settings_snapshot, dict): 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true
323 raise PolicyDeniedError(
324 Decision(False, "settings_unavailable"),
325 target="available_models",
326 )
328 scope_raw = check_env_setting("policy.egress_scope")
329 if scope_raw is None:
330 scope_raw = settings_snapshot.get(
331 "policy.egress_scope", DEFAULT_EGRESS_SCOPE
332 )
333 if str(scope_raw).strip().lower() == EgressScope.BOTH.value: 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true
334 scope_raw = EgressScope.ADAPTIVE.value
335 parse_user_egress_scope(scope_raw)
336 primary = resolve_run_primary_engine(
337 settings_snapshot, default=DEFAULT_SEARCH_TOOL
338 )
339 return (
340 context_from_snapshot(
341 settings_snapshot,
342 primary,
343 username=username,
344 ),
345 settings_snapshot,
346 )
349def _model_discovery_provider_allowed(
350 provider: str,
351 policy_context: EgressContext,
352 settings_snapshot: dict[str, Any],
353) -> bool:
354 """Return whether a provider's configured endpoint is allowed to list models."""
355 if not policy_context.require_local_llm: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true
356 return True
357 decision = evaluate_llm_endpoint(
358 normalize_provider(provider),
359 policy_context,
360 settings_snapshot=settings_snapshot,
361 )
362 if not decision.allowed:
363 logger.bind(policy_audit=True).info(
364 "available-model provider denied by egress policy",
365 provider=normalize_provider(provider),
366 reason=decision.reason,
367 )
368 return decision.allowed
371def _get_setting_from_session(key: str | None, username: str, default=None):
372 """Helper to get a setting using the current session context.
374 A ``None`` key returns ``default``. ``SettingsManager.get_setting``
375 treats ``key=None`` as "return all settings"; this route helper fetches
376 a single named setting and must not inherit that bulk-read semantic.
377 Without the guard, callers iterating providers that declare
378 ``api_key_setting = None`` (LM Studio, Llama.cpp) would receive a dict
379 of every setting — leaking other providers' API keys.
380 """
381 if key is None:
382 return default
383 with get_user_db_session(username) as db_session:
384 if db_session:
385 settings_manager = get_settings_manager(db_session, username)
386 return settings_manager.get_setting(key, default)
387 return default
390def validate_setting(
391 setting: Setting, value: Any
392) -> Tuple[bool, Optional[str]]:
393 """
394 Validate a setting value based on its type and constraints.
396 Args:
397 setting: The Setting object to validate against
398 value: The value to validate
400 Returns:
401 Tuple of (is_valid, error_message)
402 """
403 # Keep the submitted value so a failed conversion cannot be confused with
404 # an intentionally-unset optional numeric. The converter uses ``None`` for
405 # both outcomes when its default is None.
406 raw_value = value
408 # Convert value to appropriate type first using SettingsManager's logic
409 value = get_typed_setting_value(
410 key=str(setting.key),
411 value=value,
412 ui_element=str(setting.ui_element),
413 default=None,
414 check_env=False,
415 )
417 # Validate based on UI element type
418 if setting.ui_element == "checkbox":
419 # After conversion, should be boolean
420 if not isinstance(value, bool):
421 return False, "Value must be a boolean"
423 elif setting.ui_element in ("number", "slider", "range"):
424 # None and blank HTML numeric inputs represent an intentionally-unset
425 # optional value. A nonblank value that failed conversion also becomes
426 # None, but must be rejected instead of silently erasing the setting.
427 if raw_value is None or (
428 isinstance(raw_value, str) and not raw_value.strip()
429 ):
430 return True, None
432 # After conversion, should be numeric
433 if (
434 isinstance(raw_value, bool)
435 or isinstance(value, bool)
436 or not isinstance(value, (int, float))
437 or (isinstance(value, float) and not math.isfinite(value))
438 ):
439 return False, "Value must be a number"
441 # Check min/max constraints if defined
442 if setting.min_value is not None and value < setting.min_value:
443 return False, f"Value must be at least {setting.min_value}"
444 if setting.max_value is not None and value > setting.max_value:
445 return False, f"Value must be at most {setting.max_value}"
447 elif setting.ui_element == "select":
448 # Check if value is in the allowed options
449 if setting.options:
450 # Skip options validation for dynamically populated dropdowns
451 if setting.key not in DYNAMIC_SETTINGS:
452 allowed_values = [
453 opt.get("value") if isinstance(opt, dict) else opt
454 for opt in list(setting.options) # type: ignore[arg-type]
455 ]
456 if value not in allowed_values:
457 return (
458 False,
459 f"Value must be one of: {', '.join(str(v) for v in allowed_values)}",
460 )
462 # All checks passed
463 return True, None
466def coerce_setting_for_write(key: str, value: Any, ui_element: str) -> Any:
467 """Coerce an incoming value to the correct type before writing to the DB.
469 All web routes that save settings should use this function to ensure
470 consistent type conversion.
472 No JSON pre-parsing (``json.loads``) is needed here because:
473 - ``get_typed_setting_value`` already parses JSON strings internally
474 via ``_parse_json_value`` (for ``ui_element="json"``) and
475 ``_parse_multiselect`` (for ``ui_element="multiselect"``).
476 - For JSON API endpoints, ``await request.json()`` already delivers
477 dicts/lists as native Python objects.
478 - For ``ui_element="text"``, pre-parsing would corrupt data: a JSON
479 string like ``'{"k": "v"}'`` would become a dict, then ``str()``
480 would produce ``"{'k': 'v'}"`` (Python repr, not valid JSON).
481 """
482 # check_env=False: we are persisting a user-supplied value, not reading
483 # from an environment variable override. check_env=True (the default)
484 # would silently replace the user's value with an env var, which is
485 # incorrect on the write path.
486 numeric_ui = ui_element in ("number", "slider", "range")
487 if numeric_ui and isinstance(value, str) and not value.strip():
488 return None
489 if numeric_ui and isinstance(value, bool):
490 # bool is an int subclass, so float(True) would otherwise become 1 and
491 # erase the evidence validate_setting needs to reject type confusion.
492 return value
494 coerced = get_typed_setting_value(
495 key=key,
496 value=value,
497 ui_element=ui_element,
498 default=None,
499 check_env=False,
500 )
501 if numeric_ui and value is not None and coerced is None:
502 # Preserve a nonblank conversion failure for validate_setting. Returning
503 # None here would make every caller treat malformed input as an
504 # intentionally-unset optional numeric (several skip validation for
505 # None entirely).
506 return value
507 # Canonicalise the egress scope on the write path: the select options
508 # are exact lowercase strings, so a value arriving with surrounding
509 # whitespace or different casing (" STRICT ") would otherwise be
510 # rejected by validate_setting instead of being stored as "strict".
511 if key == "policy.egress_scope" and isinstance(coerced, str):
512 return coerced.strip().lower()
513 return coerced
516# Namespace validation for new setting creation via the web API (ported
517# from main's Flask settings_routes). Keys starting with any ALLOWED
518# prefix may be created; any prefix in BLOCKED takes precedence and is
519# rejected even if it also matches an allowed prefix. Existing keys
520# (updates) bypass this check — it only applies to creation of new DB
521# rows through the three write routes.
522ALLOWED_SETTING_PREFIXES = frozenset(
523 {
524 "app.",
525 "backup.",
526 "benchmark.",
527 "chat.",
528 "database.",
529 "document_scheduler.",
530 "embeddings.",
531 "focused_iteration.",
532 "general.",
533 "langgraph_agent.",
534 "llm.",
535 "local_search_",
536 "news.",
537 "notifications.",
538 "rag.",
539 "rate_limiting.",
540 "report.",
541 "research_library.",
542 "search.",
543 "ui.",
544 "web.",
545 "zotero.",
546 }
547)
548BLOCKED_SETTING_PREFIXES = frozenset(
549 {
550 "auth.",
551 "bootstrap.",
552 "db_config.",
553 "security.",
554 "server.",
555 "testing.",
556 }
557)
560def _container_embeds_sentinel(value: object, redaction_text: str) -> bool:
561 """True when a dict/list contains the redaction sentinel as a substring
562 of ANY string leaf, exact-leaf-match or partially spliced into one.
564 Recursion counterpart of the ``str`` guards' ``redaction_text in value``
565 check, used for a container value under a setting that only matches the
566 broadened suffix arm (see ``_force_redact_strings``, which is what
567 produces such a container on the read side: every non-empty string leaf
568 becomes the bare sentinel, regardless of its own sub-key name). bool/int/
569 None leaves are never masked and so can never embed it.
570 """
571 if isinstance(value, str):
572 return redaction_text in value
573 if isinstance(value, dict):
574 return any(
575 _container_embeds_sentinel(sub_val, redaction_text)
576 for sub_val in value.values()
577 )
578 if isinstance(value, list):
579 return any(
580 _container_embeds_sentinel(item, redaction_text) for item in value
581 )
582 return False
585def _container_all_leaves_are_sentinel(
586 value: object, redaction_text: str
587) -> bool:
588 """True when every non-empty string leaf inside a dict/list equals the
589 redaction sentinel EXACTLY -- i.e. the container is indistinguishable
590 from what ``_force_redact_strings`` would emit for it, so it can only be
591 an untouched GET round-trip rather than an edit.
593 An empty/whitespace-only string leaf is not something
594 ``_force_redact_strings`` would have masked (it mirrors
595 ``_is_empty_value``'s carve-out), so it is not considered here either
596 and does not break purity. A single non-sentinel, non-empty string leaf
597 (a legitimately edited sibling field, e.g. a hostname retyped next to an
598 untouched ``"[REDACTED]"`` token) fails this and the container is then
599 handled as an edit by ``_container_embeds_sentinel`` instead, exactly
600 like a partially edited ``notifications.service_url`` string does.
601 """
602 if isinstance(value, str):
603 return not value.strip() or value == redaction_text
604 if isinstance(value, dict):
605 return all(
606 _container_all_leaves_are_sentinel(sub_val, redaction_text)
607 for sub_val in value.values()
608 )
609 if isinstance(value, list):
610 return all(
611 _container_all_leaves_are_sentinel(item, redaction_text)
612 for item in value
613 )
614 return True
617def _container_matches_stored_shape(
618 value: object, stored_value: object, redaction_text: str
619) -> bool:
620 """True when every NON-maskable leaf of *value* -- anything that is not
621 a non-empty string, plus any empty/whitespace-only string leaf -- is
622 identical to the corresponding leaf of *stored_value*, with matching
623 dict keys / list lengths at every level of nesting.
625 Maskable (non-empty string) leaves are exempt from the equality check:
626 those are expected to hold the redaction sentinel rather than the real
627 secret, and ``_container_all_leaves_are_sentinel`` already verifies
628 they are exactly that. What this function catches is the case that
629 slipped past round 4: a container round-trip where every string leaf
630 is untouched (still the sentinel) but a NON-string sibling was edited
631 -- e.g. ``{"token": "[REDACTED]", "port": 19530}`` submitted back as
632 ``{"token": "[REDACTED]", "port": 19531}``. Comparing only string
633 leaves against the sentinel can never see that edit; comparing the
634 non-maskable leaves against the stored row can.
636 A maskable leaf whose stored counterpart was NOT itself a non-empty
637 string (or a structural mismatch -- different dict keys, different
638 list length, a dict submitted where the stored value is a list, etc.)
639 also fails this, since that cannot be an untouched round-trip either.
640 """
641 if isinstance(value, dict):
642 if not isinstance(stored_value, dict) or set(value.keys()) != set( 642 ↛ 645line 642 didn't jump to line 645 because the condition on line 642 was never true
643 stored_value.keys()
644 ):
645 return False
646 return all(
647 _container_matches_stored_shape(
648 value[k], stored_value[k], redaction_text
649 )
650 for k in value
651 )
652 if isinstance(value, list):
653 if not isinstance(stored_value, list) or len(value) != len( 653 ↛ 656line 653 didn't jump to line 656 because the condition on line 653 was never true
654 stored_value
655 ):
656 return False
657 return all(
658 _container_matches_stored_shape(v, s, redaction_text)
659 for v, s in zip(value, stored_value)
660 )
661 if isinstance(value, str) and value.strip():
662 # Maskable leaf: its own exactness is checked elsewhere. Just
663 # confirm the stored counterpart was maskable too, so a non-string
664 # leaf can't silently "become" a string one under this exemption.
665 return isinstance(stored_value, str) and bool(stored_value.strip())
666 return type(value) is type(stored_value) and value == stored_value
669def _is_secret_empty_noop(
670 key: str,
671 ui_element: str | None,
672 value: object,
673 stored_value: object = None,
674) -> bool:
675 """True when a secret write must be ignored: the redaction sentinel
676 for any sensitive setting, or an empty string for password inputs
677 (which render blank, so an untouched field must not wipe the secret).
679 The ``ui_element == "password"`` narrowing matters:
680 ``notifications.service_url`` is a sensitive setting on a ``textarea``
681 whose control renders its real value, so an empty write there is a
682 deliberate "clear it" gesture and must reach the database (#5960).
684 A dict/list value goes through the container arm: ``redact_value`` can
685 mask a container's string leaves via ``_force_redact_strings`` rather
686 than replacing the whole value with the bare sentinel (see its
687 docstring), so the exact-string check above never sees a container's
688 round-tripped sentinel. Without this arm, a GET-then-save-untouched
689 container -- e.g. ``{"uri": "[REDACTED]", "token": "[REDACTED]", "port":
690 19530}`` -- would sail past every guard here (all are ``isinstance(value,
691 str)``-gated) and persist the sentinel over the real credential. Every
692 maskable (non-empty string) leaf must be exactly the sentinel for this
693 to count as untouched; a container that merely embeds it somewhere while
694 another leaf was edited is a corrupted edit, handled by
695 ``_embeds_redaction_sentinel`` instead.
697 Checking string leaves alone is not enough, though: a container edit
698 that touches only a NON-string leaf (a port number, a bool toggle)
699 while every string leaf stays exactly the sentinel would still look
700 like a pure round-trip by that check alone, and the edit would be
701 silently discarded. ``stored_value`` -- the setting's current value in
702 the DB, threaded in by every call site that has a prior row to compare
703 against -- lets ``_container_matches_stored_shape`` catch that: the
704 container is only a genuine no-op if its non-maskable leaves are
705 byte-for-byte identical to what's already stored. If the caller has no
706 stored value to compare against (``stored_value is None``, e.g. no
707 prior row), a sentinel-bearing container can never be verified as an
708 untouched round-trip and is therefore never treated as this kind of
709 no-op -- it falls through to ``_embeds_redaction_sentinel``, which
710 rejects it with a 400 instead of risking a silent drop or a silent
711 sentinel write.
712 """
713 if not DataSanitizer.is_sensitive_setting(key, ui_element):
714 return False
715 if isinstance(value, str):
716 return value == DataSanitizer.REDACTION_TEXT or (
717 value == "" and ui_element == "password"
718 )
719 if isinstance(value, (dict, list)): 719 ↛ 730line 719 didn't jump to line 730 because the condition on line 719 was always true
720 return (
721 stored_value is not None
722 and _container_embeds_sentinel(value, DataSanitizer.REDACTION_TEXT)
723 and _container_all_leaves_are_sentinel(
724 value, DataSanitizer.REDACTION_TEXT
725 )
726 and _container_matches_stored_shape(
727 value, stored_value, DataSanitizer.REDACTION_TEXT
728 )
729 )
730 return False
733def _redaction_sentinel_error(ui_element: str | None) -> str:
734 """Explain the 400 raised for a value that embeds the redaction sentinel.
736 Shared by every write route so the message is identical wherever it
737 fires, but the "submit an empty value to clear it" hint only holds for
738 non-password sensitive settings such as the ``notifications.service_url``
739 textarea. ``_is_secret_empty_noop`` makes an empty write to a
740 ``password`` input a deliberate no-op (those fields render blank, so an
741 untouched form must not wipe the secret), and
742 ``_embeds_redaction_sentinel`` gates on sensitivity alone, so a
743 password-backed setting can reach this error through a direct API call
744 even though the UI cannot produce it. Advertising the empty-value
745 escape there would tell the caller to do something that provably
746 cannot work, so the hint is dropped for password inputs.
747 """
748 message = (
749 "Value contains the redaction placeholder "
750 f"{DataSanitizer.REDACTION_TEXT!r}. The stored value is hidden, so "
751 "it cannot be edited in place — retype the whole value"
752 )
753 if ui_element == "password":
754 return (
755 f"{message}. To clear a password setting, clear the source "
756 "environment variable or use settings import."
757 )
758 return f"{message}, or submit an empty value to clear it."
761def _embeds_redaction_sentinel(
762 key: str,
763 ui_element: str | None,
764 value: object,
765 stored_value: object = None,
766) -> bool:
767 """True when a submitted sensitive value *embeds* the redaction sentinel.
769 ``_is_secret_empty_noop`` covers the exact sentinel: an untouched field
770 round-tripped from a settings API read, which is benign and silently
771 ignored. This covers the *edited* field. Password inputs render blank,
772 so they cannot produce this, but ``notifications.service_url`` is a
773 sensitive setting on a ``textarea`` (the first non-password sensitive
774 setting in the codebase), and editing its comma-separated URL list is
775 the normal workflow. A stale client that rendered the sentinel yields
776 values like ``"[REDACTED],discord://webhook/tok"``, which is not an
777 exact match and would otherwise persist verbatim and silently break
778 every notification. No legitimate secret contains the sentinel, so this
779 is a hard 400 rather than a no-op: unlike the exact-match case the user
780 made an edit, and silently dropping it would look like a successful save.
782 A dict/list value takes the container arm. There are two ways a
783 container can embed the sentinel without being the pure round-trip
784 ``_is_secret_empty_noop`` claims:
786 1. Not every maskable (non-empty string) leaf is exactly the
787 sentinel (``_container_all_leaves_are_sentinel`` is False) --
788 at least one string leaf was edited while another still carries
789 "[REDACTED]" verbatim, e.g. a hostname retyped next to an
790 untouched token field.
791 2. Every maskable leaf IS exactly the sentinel, but a non-maskable
792 leaf (a port number, a bool toggle) does not match what's
793 currently stored -- ``_container_matches_stored_shape`` is False,
794 or there is no ``stored_value`` to compare against at all. This
795 is the case round 4 missed: a container that looks like a pure
796 string round-trip but actually carries an edit to a non-string
797 sibling. Silently persisting it would splice the sentinel into
798 the stored credential's string leaves; silently treating it as a
799 no-op (what ``_is_secret_empty_noop`` used to do) would discard
800 the non-string edit instead. Neither is acceptable, so both
801 shapes get the same 400 as the plain string case.
803 Without a ``stored_value`` to check case 2 against, a sentinel-bearing
804 container can never be proven to be an untouched round-trip, so it is
805 conservatively treated as case 2 (400) rather than risking a silent
806 drop or a silent sentinel write.
807 """
808 if not DataSanitizer.is_sensitive_setting(key, ui_element):
809 return False
810 if isinstance(value, str):
811 return (
812 DataSanitizer.REDACTION_TEXT in value
813 and value != DataSanitizer.REDACTION_TEXT
814 )
815 if isinstance(value, (dict, list)):
816 if not _container_embeds_sentinel(value, DataSanitizer.REDACTION_TEXT):
817 return False
818 if not _container_all_leaves_are_sentinel(
819 value, DataSanitizer.REDACTION_TEXT
820 ):
821 return True
822 return stored_value is None or not _container_matches_stored_shape(
823 value, stored_value, DataSanitizer.REDACTION_TEXT
824 )
825 return False
828def _embeds_sentinel_on_create(
829 key: str, ui_element: object, value: object
830) -> bool:
831 """Sentinel check for the CREATE paths.
833 Creation has no prior value, so the sentinel cannot mean "keep the
834 stored secret" the way it does on the update path — every occurrence
835 of it, exact match included, is a corrupted client value that would be
836 stored verbatim as the credential. A dict/list value gets the same
837 treatment via ``_container_embeds_sentinel``: there is no "pure
838 round-trip" exemption here (unlike ``_is_secret_empty_noop`` on the
839 update path) because creation has no prior stored value for an untouched
840 field to round-trip from.
841 """
842 if not DataSanitizer.is_sensitive_setting(
843 key, ui_element if isinstance(ui_element, str) else None
844 ):
845 return False
846 if isinstance(value, str):
847 return DataSanitizer.REDACTION_TEXT in value
848 if isinstance(value, (dict, list)): 848 ↛ 850line 848 didn't jump to line 850 because the condition on line 848 was always true
849 return _container_embeds_sentinel(value, DataSanitizer.REDACTION_TEXT)
850 return False
853def _is_allowed_new_setting_key(key: str) -> bool:
854 """Return True if *key* is permitted to be created via the web API."""
855 # Reject malformed keys (blank, trailing/leading dot, empty ".." segment,
856 # stray whitespace) before the namespace check — a trailing-dot key such
857 # as ``local_search_chunk_size.`` otherwise passes the prefix allow-list
858 # and corrupts prefix lookups (see #4840).
859 if not is_valid_setting_key(key):
860 return False
861 key = key.lower()
862 for prefix in BLOCKED_SETTING_PREFIXES:
863 if key.startswith(prefix):
864 return False
865 for prefix in ALLOWED_SETTING_PREFIXES:
866 if key.startswith(prefix):
867 return True
868 return False
871def _new_key_rejection_reason(key) -> str:
872 """Explain why ``_is_allowed_new_setting_key`` rejected *key*.
874 Distinguishes a malformed key (bad syntax) from an allowed-namespace
875 violation so an API consumer with, say, a trailing-dot key (#4840) is
876 pointed at the real problem instead of being told it's a namespace issue.
877 """
878 if not is_valid_setting_key(key):
879 return f"Setting key is malformed: {key!r}"
880 return f"Creating settings under this namespace is not allowed: {key}"
883@router.get("/")
884def settings_page(
885 request: Request, username: Annotated[str, Depends(require_auth)]
886):
887 """Main settings dashboard with links to specialized config pages"""
888 return templates.TemplateResponse(
889 request=request,
890 name="settings_dashboard.html",
891 context={"request": request},
892 )
895def _save_all_settings_sync(form_data: dict, username: str):
896 """Synchronous body of save_all_settings.
898 Separated so the `async def` handler can offload the entire
899 SQLAlchemy + validation + re-read block to a thread via
900 asyncio.to_thread, freeing the event loop during a bulk save.
901 """
902 try:
903 from ...security.data_sanitizer import DataSanitizer
905 with get_user_db_session(username) as db_session:
906 settings_manager = get_settings_manager(db_session, username)
907 if settings_manager.settings_locked:
908 return JSONResponse(
909 {
910 "status": "error",
911 "message": "Settings are locked and cannot be changed",
912 },
913 status_code=403,
914 )
916 # Track validation errors
917 validation_errors = []
918 settings_by_type: dict[str, Any] = {}
920 # Track changes for logging
921 updated_settings = []
922 created_settings = []
924 # Store original values for better messaging
925 original_values = {}
927 # Fetch all settings and remove non-editable keys
928 all_db_settings = _filter_editable_settings(form_data, db_session)
930 # Reject public hostnames being added to the local-hosts allowlist,
931 # and inherently-public engines being added to the trusted-engines
932 # list.
933 _hosts_err = first_egress_validation_error(
934 form_data, all_db_settings
935 )
936 if _hosts_err is not None:
937 validation_errors.append(_hosts_err)
938 return JSONResponse(
939 {
940 "status": "error",
941 "message": "Validation errors",
942 "errors": validation_errors,
943 },
944 status_code=400,
945 )
947 # Update each setting
948 for key, value in form_data.items():
949 # Skip corrupted keys or empty strings as keys
950 if not key or not isinstance(key, str) or key.strip() == "":
951 continue
953 # Get the setting metadata from pre-fetched dict
954 current_setting = all_db_settings.get(key)
956 # SAFETY NET: the redaction sentinel is never a "clear the
957 # value" request, and neither is an empty string on a
958 # password input. Reasons:
959 # 1. The form templates (Jinja2 + JS-rendered) deliberately
960 # render password inputs empty so the saved value never
961 # enters the HTML source. A user who blurs the field
962 # without typing must not wipe their stored API key.
963 # 2. /settings/api redacts secret values to the redaction
964 # sentinel ("[REDACTED]"). A stale browser tab could
965 # submit it back — that must be idempotent on the DB, or
966 # the round-trip would persist the literal sentinel over
967 # the real secret.
968 # 3. Defense-in-depth against direct cURL or automation
969 # mistakes that POST an empty/sentinel value.
970 # The empty-string half is narrowed to password inputs:
971 # non-password sensitive controls (the
972 # notifications.service_url textarea) render their real value
973 # and must stay clearable (#5960). To unset a password
974 # setting, clear the source env var or use settings import.
975 if current_setting and _is_secret_empty_noop(
976 key,
977 current_setting.ui_element,
978 value,
979 current_setting.value,
980 ):
981 logger.debug(
982 f"Skipping empty secret write for {key} (no-op)"
983 )
984 continue
986 # A value that merely *embeds* the sentinel is a corrupted
987 # edit, not an untouched round-trip: reject it loudly instead
988 # of persisting a secret with "[REDACTED]" spliced into it
989 # (#5947).
990 if current_setting and _embeds_redaction_sentinel(
991 key,
992 current_setting.ui_element,
993 value,
994 current_setting.value,
995 ):
996 logger.warning(
997 "Rejected redaction-sentinel value for {!r} via "
998 "save_all_settings (user={!r})",
999 key,
1000 username,
1001 )
1002 validation_errors.append(
1003 {
1004 "key": key,
1005 "name": current_setting.name,
1006 "error": _redaction_sentinel_error(
1007 current_setting.ui_element
1008 ),
1009 }
1010 )
1011 continue
1013 # EARLY VALIDATION: Convert checkbox values BEFORE any other processing
1014 # This prevents incorrect triggering of corrupted value detection
1015 if current_setting and current_setting.ui_element == "checkbox":
1016 if not isinstance(value, bool):
1017 logger.debug(
1018 f"Converting checkbox {key} from {type(value).__name__} to bool: {value}"
1019 )
1020 value = parse_boolean(value)
1021 form_data[key] = (
1022 value # Update the form_data with converted value
1023 )
1025 # Store original value for messaging
1026 if current_setting:
1027 original_values[key] = current_setting.value
1029 # Determine setting type and category
1030 if key.startswith("llm."):
1031 setting_type = SettingType.LLM
1032 category = "llm_general"
1033 if (
1034 "temperature" in key
1035 or "max_tokens" in key
1036 or "batch" in key
1037 or "layers" in key
1038 ):
1039 category = "llm_parameters"
1040 elif key.startswith("search."):
1041 setting_type = SettingType.SEARCH
1042 category = "search_general"
1043 if (
1044 "iterations" in key
1045 or "results" in key
1046 or "region" in key
1047 or "questions" in key
1048 or "section" in key
1049 ):
1050 category = "search_parameters"
1051 elif key.startswith("report."):
1052 setting_type = SettingType.REPORT
1053 category = "report_parameters"
1054 elif key.startswith("database."):
1055 setting_type = SettingType.DATABASE
1056 category = "database_parameters"
1057 elif key.startswith("app."):
1058 setting_type = SettingType.APP
1059 category = "app_interface"
1060 elif key.startswith("chat."): 1060 ↛ 1061line 1060 didn't jump to line 1061 because the condition on line 1060 was never true
1061 setting_type = SettingType.CHAT
1062 category = "chat"
1063 else:
1064 setting_type = None
1065 category = None
1067 # Special handling for corrupted or empty values
1068 if value == "[object Object]" or (
1069 isinstance(value, str)
1070 and value.strip() in ["{}", "[]", "{", "["]
1071 ):
1072 if key.startswith("report."):
1073 value = {}
1074 else:
1075 # Use default or null for other types
1076 if key == "llm.model":
1077 # Repair defaults must match main (#3348): an empty
1078 # model lets the provider pick, "ollama" keeps a
1079 # local-only install local instead of silently
1080 # switching it to a cloud provider, and "auto" is
1081 # NOT a registered engine — the factory fails
1082 # closed on it, so a repaired install could no
1083 # longer search at all.
1084 value = ""
1085 elif key == "llm.provider":
1086 value = "ollama"
1087 elif key == "search.tool":
1088 value = DEFAULT_SEARCH_TOOL
1089 elif key in ["app.theme", "app.default_theme"]:
1090 # Must stay a value the theme registry actually
1091 # serves; "dark" was reset here for a long time
1092 # after the registry stopped having it.
1093 value = "system"
1094 else:
1095 value = None
1097 logger.warning(
1098 f"Corrected corrupted value for {key}: {value}"
1099 )
1100 # NOTE: No JSON pre-parsing is done here. After the
1101 # corruption replacement above, values are Python dicts
1102 # (e.g. {}), hardcoded strings, or None — none are JSON
1103 # strings that need parsing. Type conversion below via
1104 # coerce_setting_for_write() handles everything; that
1105 # function delegates to get_typed_setting_value() which
1106 # already parses JSON internally for "json" and
1107 # "multiselect" ui_elements.
1109 if current_setting:
1110 # Coerce to correct Python type (e.g. str "5" → int 5
1111 # for number settings, str "true" → bool for checkboxes).
1112 converted_value = coerce_setting_for_write(
1113 key=current_setting.key,
1114 value=value,
1115 ui_element=current_setting.ui_element,
1116 )
1118 # Validate the setting
1119 is_valid, error_message = validate_setting(
1120 current_setting, converted_value
1121 )
1123 if is_valid:
1124 # Save WITHOUT committing — one final commit runs
1125 # below after the validation pass, so a later
1126 # validation error rolls back every preceding
1127 # write instead of leaving the DB half-saved.
1128 success = set_setting(
1129 key,
1130 converted_value,
1131 commit=False,
1132 db_session=db_session,
1133 )
1134 if success: 1134 ↛ 1138line 1134 didn't jump to line 1138 because the condition on line 1134 was always true
1135 updated_settings.append(key)
1137 # Track settings by type for exporting
1138 if current_setting.type not in settings_by_type:
1139 settings_by_type[current_setting.type] = []
1140 settings_by_type[current_setting.type].append(
1141 current_setting
1142 )
1143 else:
1144 # Add to validation errors
1145 validation_errors.append(
1146 {
1147 "key": key,
1148 "name": current_setting.name,
1149 "error": error_message,
1150 }
1151 )
1152 else:
1153 # Namespace validation: reject new keys outside allowed
1154 # prefixes (existing keys above bypass — updates only).
1155 if not _is_allowed_new_setting_key(key):
1156 logger.warning(
1157 "Security: Rejected setting outside allowed "
1158 "namespaces: {!r} (user={!r})",
1159 key,
1160 username,
1161 )
1162 validation_errors.append(
1163 {
1164 "key": key,
1165 "name": key,
1166 "error": _new_key_rejection_reason(key),
1167 }
1168 )
1169 continue
1171 # Creation has no prior value, so the sentinel cannot
1172 # mean "keep the stored secret" — every occurrence of it,
1173 # exact match included, would be stored verbatim as the
1174 # credential. ui_element is not yet known here, so
1175 # sensitivity is decided by the key's leaf name (#5947).
1176 if _embeds_sentinel_on_create(key, None, value): 1176 ↛ 1177line 1176 didn't jump to line 1177 because the condition on line 1176 was never true
1177 logger.warning(
1178 "Rejected redaction-sentinel value for new key "
1179 "{!r} via save_all_settings (user={!r})",
1180 key,
1181 username,
1182 )
1183 validation_errors.append(
1184 {
1185 "key": key,
1186 "name": key.split(".")[-1]
1187 .replace("_", " ")
1188 .title(),
1189 "error": _redaction_sentinel_error(None),
1190 }
1191 )
1192 continue
1194 # Create a new setting
1195 new_setting = {
1196 "key": key,
1197 "value": value,
1198 "type": setting_type.value.lower()
1199 if setting_type is not None
1200 else "app",
1201 "name": key.split(".")[-1].replace("_", " ").title(),
1202 "description": f"Setting for {key}",
1203 "category": category,
1204 "ui_element": "text", # Default UI element
1205 }
1207 # Determine better UI element based on value type
1208 if isinstance(value, bool):
1209 new_setting["ui_element"] = "checkbox"
1210 elif isinstance(value, (int, float)) and not isinstance(
1211 value, bool
1212 ):
1213 new_setting["ui_element"] = "number"
1214 elif isinstance(value, (dict, list)):
1215 new_setting["ui_element"] = "textarea"
1217 # Create the setting without committing yet — see above
1218 db_setting = create_or_update_setting(
1219 new_setting, commit=False, db_session=db_session
1220 )
1222 if db_setting:
1223 created_settings.append(key)
1224 # Track settings by type for exporting
1225 if db_setting.type not in settings_by_type: 1225 ↛ 1227line 1225 didn't jump to line 1227 because the condition on line 1225 was always true
1226 settings_by_type[db_setting.type] = []
1227 settings_by_type[db_setting.type].append(db_setting)
1228 else:
1229 validation_errors.append(
1230 {
1231 "key": key,
1232 "name": new_setting["name"],
1233 "error": "Failed to create setting",
1234 }
1235 )
1237 # Report validation errors if any — roll back the whole batch
1238 # so a partial save isn't visible to the next request.
1239 if validation_errors:
1240 db_session.rollback()
1241 return JSONResponse(
1242 {
1243 "status": "error",
1244 "message": "Validation errors",
1245 "errors": validation_errors,
1246 },
1247 status_code=400,
1248 )
1250 # All settings validated: commit the batch atomically.
1251 db_session.commit()
1253 invalidate_settings_caches(username)
1254 reschedule_document_jobs_if_needed(
1255 username, updated_settings + created_settings
1256 )
1257 reschedule_zotero_jobs_if_needed(
1258 username, updated_settings + created_settings
1259 )
1261 # Get all settings to return to the client for proper state update
1262 all_settings = {}
1263 for setting in db_session.query(Setting).all():
1264 # Convert enum to string if present
1265 setting_type = setting.type
1266 if hasattr(setting_type, "value"):
1267 setting_type = setting_type.value
1269 all_settings[setting.key] = {
1270 "value": setting.value,
1271 "name": setting.name,
1272 "description": setting.description,
1273 "type": setting_type,
1274 "category": setting.category,
1275 "ui_element": setting.ui_element,
1276 "editable": setting.editable,
1277 "options": setting.options,
1278 "visible": setting.visible,
1279 "min_value": setting.min_value,
1280 "max_value": setting.max_value,
1281 "step": setting.step,
1282 }
1284 # Overlay operator-locked (LDR_*) settings onto the echo, the
1285 # way SettingsManager.get_all_settings() already does for
1286 # GET /settings/api. The settings form re-renders from this
1287 # payload, so without the overlay an env-pinned field comes back
1288 # showing the stale DB value and marked editable until a full
1289 # page reload (#5978). Port of main's
1290 # _shape_effective_setting_metadata.
1291 for _echo_key, _echo_meta in all_settings.items():
1292 if check_env_setting(_echo_key) is None:
1293 continue
1294 _echo_meta["value"] = get_typed_setting_value(
1295 key=_echo_key,
1296 value=_echo_meta.get("value"),
1297 ui_element=str(_echo_meta.get("ui_element", "text")),
1298 default=_echo_meta.get("value"),
1299 check_env=True,
1300 )
1301 _echo_meta["editable"] = False
1303 # Operator-gate the "unprotected" escape hatch out of the
1304 # egress-scope select's options unless explicitly enabled, and
1305 # normalise the displayed value (#5148 / 87537d9ec). Without
1306 # this, a save-and-refresh cycle would re-render the dropdown
1307 # from this echoed payload with the escape hatch back on offer.
1308 if "policy.egress_scope" in all_settings:
1309 all_settings["policy.egress_scope"] = (
1310 _shape_egress_scope_setting(
1311 "policy.egress_scope",
1312 all_settings["policy.egress_scope"],
1313 )
1314 )
1316 # Hide the operator-gated unencrypted "filesystem" PDF-storage
1317 # option from a save-and-refresh echo, same rationale as the
1318 # egress-scope shaping above (#5148 / fb49985aa).
1319 if "research_library.pdf_storage_mode" in all_settings:
1320 all_settings["research_library.pdf_storage_mode"] = (
1321 _shape_pdf_storage_mode_setting(
1322 "research_library.pdf_storage_mode",
1323 all_settings["research_library.pdf_storage_mode"],
1324 )
1325 )
1327 # Customize the success message based on what changed
1328 success_message = ""
1329 if len(updated_settings) == 1:
1330 # For a single update, provide more specific info about what changed
1331 key = updated_settings[0]
1332 # Reuse the already-fetched setting from our pre-fetched dict
1333 updated_setting = all_db_settings.get(key)
1334 name = (
1335 updated_setting.name
1336 if updated_setting
1337 else key.split(".")[-1].replace("_", " ").title()
1338 )
1340 # Format the message
1341 if key in original_values: 1341 ↛ 1359line 1341 didn't jump to line 1359 because the condition on line 1341 was always true
1342 # Get original value but comment out if not used
1343 # old_value = original_values[key]
1344 new_value = (
1345 updated_setting.value if updated_setting else None
1346 )
1348 # If it's a boolean, use "enabled/disabled" language
1349 if isinstance(new_value, bool):
1350 state = "enabled" if new_value else "disabled"
1351 success_message = f"{name} {state}"
1352 else:
1353 # For non-boolean values
1354 if isinstance(new_value, (dict, list)):
1355 success_message = f"{name} updated"
1356 else:
1357 success_message = f"{name} updated"
1358 else:
1359 success_message = f"{name} updated"
1360 else:
1361 # Multiple settings or generic message
1362 success_message = f"Settings saved successfully ({len(updated_settings)} updated, {len(created_settings)} created)"
1364 # Check if any warning-affecting settings were changed and include
1365 # warnings. Redact secret values in the echoed settings so a POST
1366 # response never ships plaintext API keys back to the browser —
1367 # matching the redaction the GET /settings/api endpoint applies.
1368 response_data = {
1369 "status": "success",
1370 "message": success_message,
1371 "updated": updated_settings,
1372 "created": created_settings,
1373 "settings": DataSanitizer.redact_settings_snapshot(
1374 all_settings
1375 ),
1376 }
1378 warning_affecting_keys = WARNING_AFFECTING_KEYS
1380 # Check if any warning-affecting settings were changed
1381 if any(
1382 key in warning_affecting_keys
1383 for key in updated_settings + created_settings
1384 ):
1385 warnings = calculate_warnings(username=username)
1386 response_data["warnings"] = warnings
1387 logger.info(
1388 f"Bulk settings update affected warning keys, calculated {len(warnings)} warnings"
1389 )
1391 return response_data
1393 except Exception:
1394 logger.exception("Error saving settings")
1395 return JSONResponse(
1396 {
1397 "status": "error",
1398 "message": "An internal error occurred while saving settings.",
1399 },
1400 status_code=500,
1401 )
1404@router.post("/save_all_settings")
1405@settings_limit
1406async def save_all_settings(
1407 request: Request,
1408 username: Annotated[str, Depends(require_auth)],
1409):
1410 """Handle saving all settings at once from the unified settings page.
1412 Thin async wrapper that reads the JSON body, then offloads the
1413 entire sync save to a thread so we don't block the event loop.
1414 """
1415 form_data = await request.json()
1416 if not isinstance(form_data, dict): 1416 ↛ 1417line 1416 didn't jump to line 1417 because the condition on line 1416 was never true
1417 return json_body_error("status", "No settings data provided")
1418 if not form_data:
1419 return JSONResponse(
1420 {"status": "error", "message": "No settings data provided"},
1421 status_code=400,
1422 )
1423 return await run_db_sync(_save_all_settings_sync, form_data, username)
1426@router.post("/reset_to_defaults")
1427@settings_limit
1428def reset_to_defaults(
1429 request: Request,
1430 username: Annotated[str, Depends(require_auth)],
1431):
1432 """Reset all settings to their default values.
1434 Preserves API keys and other password-type settings — overwriting
1435 these with empty defaults from the bundled JSON would silently
1436 erase the user's credentials and force them to re-enter every key.
1437 """
1438 try:
1439 with get_user_db_session(username) as db_session:
1440 settings_manager = get_settings_manager(db_session, username)
1442 # Settings lock (app.lock_settings) -- ported from main's
1443 # reset_to_defaults (#5659, "enforce the settings lock on delete,
1444 # import and reset"). SettingsManager already refuses when
1445 # locked, so the write cannot happen either way; this repeats
1446 # the check at the route so a locked instance answers 403
1447 # rather than 200 with nothing written, which is what main's
1448 # own comment gives as the reason. Without it the merge that
1449 # brought #5659 in would have silently dropped the fix, since
1450 # it landed in a Flask file this migration deletes.
1451 if settings_manager.settings_locked:
1452 return JSONResponse(
1453 {
1454 "status": "error",
1455 "message": "Settings are locked and cannot be reset",
1456 },
1457 status_code=403,
1458 )
1460 # Snapshot password/api-key settings before reset so we can
1461 # restore them. We use the DB row's ui_element to detect
1462 # password fields rather than a name pattern.
1463 preserved: dict[str, Any] = {}
1464 password_rows = (
1465 db_session.query(Setting)
1466 .filter(Setting.ui_element == "password")
1467 .all()
1468 )
1469 for row in password_rows:
1470 if row.value not in (None, ""):
1471 preserved[row.key] = row.value
1473 # preserve_environment_locked=True is load-bearing and was lost in
1474 # the port: it defaults to False, and import_settings writes rows
1475 # in BULK rather than through the _is_environment_locked-guarded
1476 # setters, so without it a reset overwrites the stored rows of
1477 # settings an operator has locked via LDR_* env vars — and drops
1478 # the policy_audit warning that records the attempt. The damage is
1479 # latent while the env var is set (reads prefer env) and surfaces
1480 # the moment the operator removes it.
1481 settings_manager.load_from_defaults_file(
1482 preserve_environment_locked=True
1483 )
1484 reset_setting_keys = tuple(settings_manager.default_settings)
1486 # Restore preserved values
1487 for key, value in preserved.items():
1488 try:
1489 settings_manager.set_setting(key, value, commit=False)
1490 except Exception:
1491 logger.exception(
1492 f"Could not restore preserved setting {key}"
1493 )
1494 if preserved:
1495 db_session.commit()
1497 logger.info(
1498 "Successfully reset settings to defaults "
1499 f"(preserved {len(preserved)} password-type setting(s))"
1500 )
1501 invalidate_settings_caches(username)
1502 reschedule_document_jobs_if_needed(username, reset_setting_keys)
1503 reschedule_zotero_jobs_if_needed(username, reset_setting_keys)
1505 except Exception:
1506 logger.exception("Error importing default settings")
1507 return JSONResponse(
1508 {
1509 "status": "error",
1510 "message": "Failed to reset settings to defaults",
1511 },
1512 status_code=500,
1513 )
1515 return {
1516 "status": "success",
1517 "message": "All settings have been reset to default values",
1518 }
1521def _save_settings_sync(form_data: dict, username: str) -> dict:
1522 """Sync helper for save_settings — runs the bulk setting writes.
1524 Returns an outcome dict so the caller can flash user-visible feedback
1525 on the no-JS fallback path (``ok``, ``policy_error``, ``failed``,
1526 ``rejected``) — Flask's flash()-based feedback was otherwise lost in
1527 the migration, leaving the no-JS form a silent redirect regardless of
1528 success, partial rejection, or failure.
1529 """
1530 with get_user_db_session(username) as db_session:
1531 settings_manager = get_settings_manager(db_session, username)
1533 # Fetch all settings and remove non-editable keys
1534 all_db_settings = _filter_editable_settings(form_data, db_session)
1536 # Egress-policy validators — the JSON route (save_all_settings) runs
1537 # these; the POST fallback must too, or a JS-disabled client could
1538 # whitelist a public hostname as "local", which the JSON route does
1539 # not permit.
1540 _policy_err = first_egress_validation_error(form_data, all_db_settings)
1541 if _policy_err is not None: 1541 ↛ 1542line 1541 didn't jump to line 1542 because the condition on line 1541 was never true
1542 logger.warning(
1543 "Rejected settings POST: {}",
1544 _policy_err.get("error", "Invalid policy setting"),
1545 )
1546 return {
1547 "ok": False,
1548 "policy_error": _policy_err.get(
1549 "error", "Invalid policy setting"
1550 ),
1551 "failed": 0,
1552 "rejected": 0,
1553 }
1555 failed_count = 0
1556 rejected_count = 0
1557 changed_settings: list[str] = []
1558 sentinel_rejected_ui_elements: list[str | None] = []
1559 for key, value in form_data.items():
1560 try:
1561 db_setting = all_db_settings.get(key)
1563 # Namespace validation: reject new keys outside allowed
1564 # prefixes. Existing keys (updates) bypass this check — it
1565 # only applies to creation of brand-new rows through this
1566 # form-POST route.
1567 if db_setting is None and not _is_allowed_new_setting_key(key):
1568 logger.warning(
1569 "Security: Rejected setting outside allowed "
1570 "namespaces: {!r} (user={!r})",
1571 key,
1572 username,
1573 )
1574 rejected_count += 1
1575 continue
1577 # SAFETY NET: the redaction sentinel is a no-op for every
1578 # sensitive setting, and an empty string is a no-op only for
1579 # password inputs — never "clear my key". The no-JS form
1580 # renders password inputs empty (and GET redacts them to the
1581 # sentinel), so a plain form submit must not wipe the stored
1582 # secret. Non-password sensitive controls (the
1583 # notifications.service_url textarea) stay clearable (#5960).
1584 # Matches the guards in save_all_settings +
1585 # api_update_setting.
1586 if db_setting and _is_secret_empty_noop(
1587 key, db_setting.ui_element, value, db_setting.value
1588 ):
1589 logger.debug(
1590 f"Skipping empty secret write for {key} via "
1591 "save_settings (no-op)"
1592 )
1593 continue
1595 # An embedded sentinel is a corrupted edit, not a round-trip.
1596 # This route reports per-setting outcomes by flash message,
1597 # so collect it rather than saving a secret with "[REDACTED]"
1598 # spliced into it (#5947).
1599 if db_setting and _embeds_redaction_sentinel(
1600 key, db_setting.ui_element, value, db_setting.value
1601 ):
1602 logger.warning(
1603 "Rejected redaction-sentinel value for {!r} via "
1604 "save_settings (user={!r})",
1605 key,
1606 username,
1607 )
1608 sentinel_rejected_ui_elements.append(db_setting.ui_element)
1609 continue
1611 if db_setting:
1612 # An HTML form cannot express None. A <select> whose
1613 # options include a null value (e.g. "All Time" for
1614 # search.engine.web.serper.default_params.time_period)
1615 # posts back "" for that choice, and "" is not in the
1616 # allowed values, so validate_setting below rejects a
1617 # value the user never changed. The JSON route is
1618 # unaffected because it sends a real null.
1619 #
1620 # Only "" is mapped, and only when null is genuinely an
1621 # allowed option — so this cannot turn a typo into a
1622 # silent null on a select that does not permit one.
1623 if (
1624 value == ""
1625 and db_setting.ui_element == "select"
1626 and db_setting.options
1627 and any(
1628 (opt.get("value") if isinstance(opt, dict) else opt)
1629 is None
1630 for opt in list(db_setting.options)
1631 )
1632 ):
1633 value = None
1635 value = coerce_setting_for_write(
1636 key=db_setting.key,
1637 value=value,
1638 ui_element=db_setting.ui_element,
1639 )
1641 # Validate against the setting's constraints (options
1642 # membership, numeric bounds, checkbox type) — the same
1643 # check save_all_settings (~line 655) and
1644 # api_update_setting (~line 3125) run. Main added this
1645 # exact call to the Flask route this function replaces
1646 # (fb49985aa8: "this JS-disabled POST fallback wrote
1647 # values unchecked"); the FastAPI rewrite dropped it.
1648 # Best-effort semantics preserved: an invalid key is
1649 # skipped and counted in failed_count, the rest of the
1650 # batch still validates, saves, and commits.
1651 if value is not None:
1652 is_valid, error_message = validate_setting(
1653 db_setting, value
1654 )
1655 if not is_valid:
1656 logger.warning(
1657 f"Validation failed for setting {key}: "
1658 f"{error_message}"
1659 )
1660 failed_count += 1
1661 continue
1663 if not settings_manager.set_setting(key, value, commit=False):
1664 failed_count += 1
1665 logger.warning(f"Failed to save setting {key}")
1666 else:
1667 changed_settings.append(key)
1668 except Exception:
1669 logger.exception(f"Error saving setting {key}")
1670 failed_count += 1
1672 if rejected_count:
1673 logger.warning(
1674 f"Rejected {rejected_count} new setting(s) outside "
1675 "allowed namespaces"
1676 )
1678 try:
1679 db_session.commit()
1680 invalidate_settings_caches(username)
1681 reschedule_document_jobs_if_needed(username, changed_settings)
1682 reschedule_zotero_jobs_if_needed(username, changed_settings)
1683 except Exception:
1684 db_session.rollback()
1685 logger.exception("Failed to commit settings")
1686 return {
1687 "ok": False,
1688 "policy_error": None,
1689 "failed": failed_count + 1,
1690 "rejected": rejected_count,
1691 "sentinel_rejected": sentinel_rejected_ui_elements,
1692 }
1694 return {
1695 "ok": failed_count == 0 and not sentinel_rejected_ui_elements,
1696 "policy_error": None,
1697 "failed": failed_count,
1698 "rejected": rejected_count,
1699 "sentinel_rejected": sentinel_rejected_ui_elements,
1700 }
1703@router.post("/save_settings")
1704@settings_limit
1705async def save_settings(
1706 request: Request,
1707 username: Annotated[str, Depends(require_auth)],
1708):
1709 """Save all settings from the form using POST method — fallback when
1710 JavaScript is disabled. Body work offloaded to threadpool."""
1711 from ..dependencies.flash import flash
1713 outcome = None
1714 try:
1715 form_data = dict(await request.form())
1716 form_data.pop("csrf_token", None)
1717 outcome = await run_db_sync(_save_settings_sync, form_data, username)
1718 except Exception:
1719 logger.exception("Error in save_settings")
1721 # Give the no-JS user visible feedback via a flash message (rendered on
1722 # the /settings/ page) instead of a silent redirect. Flask flashed the
1723 # same success/error; the migration had dropped it.
1724 if outcome is None:
1725 flash(request, "Failed to save settings. Please try again.", "error")
1726 elif outcome.get("policy_error"): 1726 ↛ 1727line 1726 didn't jump to line 1727 because the condition on line 1726 was never true
1727 flash(request, outcome["policy_error"], "error")
1728 elif outcome.get("sentinel_rejected"):
1729 # Drop the empty-value hint if any rejected setting is a password
1730 # input, where an empty write is a no-op rather than a clear (see
1731 # ``_redaction_sentinel_error``).
1732 _rejected_uis = outcome["sentinel_rejected"]
1733 _hint_ui = (
1734 "password"
1735 if any(ui == "password" for ui in _rejected_uis)
1736 else None
1737 )
1738 flash(
1739 request,
1740 f"Rejected {len(_rejected_uis)} settings: "
1741 + _redaction_sentinel_error(_hint_ui),
1742 "error",
1743 )
1744 elif outcome.get("failed"):
1745 flash(
1746 request,
1747 f"Saved with {outcome['failed']} setting(s) failing. "
1748 "Check the values and try again.",
1749 "warning",
1750 )
1751 elif outcome.get("rejected"):
1752 flash(
1753 request,
1754 f"Settings saved; {outcome['rejected']} unrecognized key(s) "
1755 "were ignored.",
1756 "warning",
1757 )
1758 else:
1759 flash(request, "Settings saved.", "success")
1761 return RedirectResponse(url="/settings/", status_code=302)
1764# API Routes
1765@router.get("/api")
1766def api_get_all_settings(
1767 request: Request,
1768 username: Annotated[str, Depends(require_auth)],
1769):
1770 """Get all settings"""
1771 try:
1772 # Get query parameters
1773 category = request.query_params.get("category")
1775 with get_user_db_session(username) as db_session:
1776 settings_manager = get_settings_manager(db_session, username)
1778 # Get settings
1779 settings = settings_manager.get_all_settings()
1781 # Filter by category if requested
1782 if category:
1783 filtered_settings = {}
1784 # Need to get all setting details to check category
1785 db_settings = db_session.query(Setting).all()
1786 category_keys = [
1787 s.key for s in db_settings if s.category == category
1788 ]
1790 # Filter settings by keys
1791 filtered_settings = {
1792 key: value
1793 for key, value in settings.items()
1794 if key in category_keys
1795 }
1797 settings = filtered_settings
1799 # Operator-gate the "unprotected" escape hatch out of the
1800 # egress-scope select's options unless explicitly enabled, and
1801 # normalise the displayed value (#5148 / 87537d9ec).
1802 if "policy.egress_scope" in settings:
1803 settings["policy.egress_scope"] = _shape_egress_scope_setting(
1804 "policy.egress_scope", settings["policy.egress_scope"]
1805 )
1807 # Hide the operator-gated unencrypted "filesystem" PDF-storage
1808 # option, same rationale as the egress-scope shaping above
1809 # (#5148 / fb49985aa).
1810 if "research_library.pdf_storage_mode" in settings:
1811 settings["research_library.pdf_storage_mode"] = (
1812 _shape_pdf_storage_mode_setting(
1813 "research_library.pdf_storage_mode",
1814 settings["research_library.pdf_storage_mode"],
1815 )
1816 )
1818 # Redact secret values (API keys, passwords, OAuth tokens) — main
1819 # does this (DataSanitizer.redact_settings_snapshot) as defense in
1820 # depth: this JSON gets cached by clients, logged by proxies, and
1821 # pasted into bug reports. Safe to round-trip: the save path treats
1822 # the '[REDACTED]' sentinel as a no-op (see the write-back guards in
1823 # _save_*_settings_sync), so the dashboard re-saving a redacted dump
1824 # never overwrites stored credentials, and settings.js seeds the
1825 # password baselines from this sentinel (PR #3947).
1826 from ...security.data_sanitizer import DataSanitizer
1828 settings = DataSanitizer.redact_settings_snapshot(settings)
1829 return {"status": "success", "settings": settings}
1830 except Exception:
1831 logger.exception("Error getting settings")
1832 return JSONResponse(
1833 {"error": "Failed to retrieve settings"}, status_code=500
1834 )
1837@router.post("/api/import")
1838@settings_limit
1839def api_import_settings(
1840 request: Request,
1841 username: Annotated[str, Depends(require_auth)],
1842):
1843 """Import settings from defaults file"""
1844 try:
1845 with get_user_db_session(username) as db_session:
1846 settings_manager = get_settings_manager(db_session, username)
1848 # Settings lock (app.lock_settings) -- ported from main's
1849 # api_import_settings (#5659, "enforce the settings lock on delete,
1850 # import and reset"). SettingsManager already refuses when
1851 # locked, so the write cannot happen either way; this repeats
1852 # the check at the route so a locked instance answers 403
1853 # rather than 200 with nothing written, which is what main's
1854 # own comment gives as the reason. Without it the merge that
1855 # brought #5659 in would have silently dropped the fix, since
1856 # it landed in a Flask file this migration deletes.
1857 if settings_manager.settings_locked:
1858 return JSONResponse(
1859 {"error": "Settings are locked and cannot be imported"},
1860 status_code=403,
1861 )
1863 # See the identical call in reset_to_defaults: this flag is
1864 # required so a bulk import cannot overwrite env-locked settings,
1865 # and it defaults to False.
1866 settings_manager.load_from_defaults_file(
1867 preserve_environment_locked=True
1868 )
1869 imported_setting_keys = tuple(settings_manager.default_settings)
1871 invalidate_settings_caches(username)
1872 reschedule_document_jobs_if_needed(username, imported_setting_keys)
1873 reschedule_zotero_jobs_if_needed(username, imported_setting_keys)
1874 return {"message": "Settings imported successfully"}
1875 except Exception:
1876 logger.exception("Error importing settings")
1877 return JSONResponse(
1878 {"error": "Failed to import settings"}, status_code=500
1879 )
1882@router.get("/api/categories")
1883def api_get_categories(
1884 request: Request,
1885 username: Annotated[str, Depends(require_auth)],
1886):
1887 """Get all setting categories"""
1888 try:
1889 with get_user_db_session(username) as db_session:
1890 # Get all distinct categories
1891 categories = db_session.query(Setting.category).distinct().all()
1892 category_list = [c[0] for c in categories if c[0] is not None]
1894 return {"categories": category_list}
1895 except Exception:
1896 logger.exception("Error getting categories")
1897 return JSONResponse(
1898 {"error": "Failed to retrieve settings"}, status_code=500
1899 )
1902@router.get("/api/types")
1903def api_get_types(
1904 request: Request, username: Annotated[str, Depends(require_auth)]
1905):
1906 """Get all setting types"""
1907 try:
1908 # Get all setting types
1909 types = [t.value for t in SettingType]
1910 return {"types": types}
1911 except Exception:
1912 logger.exception("Error getting types")
1913 return JSONResponse(
1914 {"error": "Failed to retrieve settings"}, status_code=500
1915 )
1918@router.get("/api/ui_elements")
1919def api_get_ui_elements(
1920 request: Request, username: Annotated[str, Depends(require_auth)]
1921):
1922 """Get all UI element types"""
1923 try:
1924 # Define supported UI element types
1925 ui_elements = [
1926 "text",
1927 "select",
1928 "checkbox",
1929 "slider",
1930 "number",
1931 "textarea",
1932 "color",
1933 "date",
1934 "file",
1935 "password",
1936 ]
1938 return {"ui_elements": ui_elements}
1939 except Exception:
1940 logger.exception("Error getting UI elements")
1941 return JSONResponse(
1942 {"error": "Failed to retrieve settings"}, status_code=500
1943 )
1946@router.get("/api/available-models")
1947def api_get_available_models(
1948 request: Request, username: Annotated[str, Depends(require_auth)]
1949):
1950 """Get available LLM models from various providers"""
1951 endpoint_start = time.perf_counter()
1952 try:
1953 # request comes from FastAPI parameter
1954 from ...database.models.providers import ProviderModel
1956 # Parse as bool — a raw string (non-empty) is always truthy, so
1957 # the old code always took the force-refresh path, deleting
1958 # every cached model on every request.
1959 force_refresh = (
1960 request.query_params.get("force_refresh", "false").lower() == "true"
1961 )
1963 # Resolve the egress policy BEFORE the model cache is read or any
1964 # provider is discovered/contacted. Fails closed (PolicyDeniedError).
1965 policy_context, policy_snapshot = _resolve_model_discovery_policy(
1966 username
1967 )
1969 # Get all auto-discovered providers. Every provider is advertised to
1970 # the UI; the egress policy marks blocked ones as disabled (with a
1971 # reason) instead of removing them. Removing them silently made the
1972 # "Model Provider" dropdown look short after configuring a cloud API
1973 # key, leading users to assume the key wasn't picked up — and left
1974 # the front-end code that renders the reason
1975 # (static/js/components/custom_dropdown.js, research.js, settings.js)
1976 # unreachable. The per-provider MODELS list below is still filtered
1977 # so we never actually call a blocked provider (#5922 / #5662).
1978 from ...llm.providers import get_discovered_provider_options
1980 provider_options = []
1981 for option in get_discovered_provider_options():
1982 entry = dict(option) # shallow copy
1983 if (
1984 policy_context.require_local_llm
1985 and not _model_discovery_provider_allowed(
1986 option["value"], policy_context, policy_snapshot
1987 )
1988 ):
1989 entry["disabled"] = True
1990 entry["disabled_reason"] = (
1991 'Blocked by "Require Local LLM Endpoint"'
1992 )
1993 else:
1994 entry["disabled"] = False
1995 entry["disabled_reason"] = None
1996 provider_options.append(entry)
1998 # Add remaining hardcoded providers (complex local providers not yet migrated)
2000 # Available models by provider
2001 providers: dict[str, Any] = {}
2003 # Check database cache first (unless force_refresh is True)
2004 if not force_refresh:
2005 try:
2006 # Define cache expiration (24 hours)
2007 cache_expiry = datetime.now(UTC) - timedelta(hours=24)
2009 # Get cached models from database
2010 with get_user_db_session(username) as db_session:
2011 cached_models = (
2012 db_session.query(ProviderModel)
2013 .filter(ProviderModel.last_updated > cache_expiry)
2014 .all()
2015 )
2017 if cached_models:
2018 logger.info(
2019 f"Found {len(cached_models)} cached models in database"
2020 )
2022 # Group models by provider
2023 for model in cached_models:
2024 if (
2025 policy_context.require_local_llm
2026 and not _model_discovery_provider_allowed(
2027 model.provider, policy_context, policy_snapshot
2028 )
2029 ):
2030 continue
2031 provider_key = (
2032 f"{normalize_provider(model.provider)}_models"
2033 )
2034 if provider_key not in providers:
2035 providers[provider_key] = []
2037 providers[provider_key].append(
2038 {
2039 "value": model.model_key,
2040 "label": model.model_label,
2041 "provider": model.provider.upper(),
2042 }
2043 )
2045 # If we have cached data for all providers, return it
2046 if providers:
2047 _log_available_models_duration(
2048 endpoint_start, cache_hit=True
2049 )
2050 logger.info("Returning cached models from database")
2051 return {
2052 "provider_options": provider_options,
2053 "providers": providers,
2054 }
2056 except PolicyDeniedError:
2057 raise
2058 except Exception:
2059 logger.warning("Error reading cached models from database")
2060 # Continue to fetch fresh data
2062 # Ollama / OpenAI / Anthropic model listing is handled by the
2063 # auto-discovery loop below (their provider classes' list_models_for_api),
2064 # leaving one provider-class fetch path. Those classes own base-URL
2065 # validation and authentication, and the router does no duplicate network
2066 # work. The removed Ollama probe used safe_get's request-time SSRF checks;
2067 # its problem here was duplicate fetching, not missing validation.
2069 # Fetch models from auto-discovered providers
2070 from ...llm.providers import discover_providers
2072 discovered_providers = discover_providers()
2074 # If the effective egress posture is local-only, the user (or policy)
2075 # has opted into local-only inference, so don't list cloud providers
2076 # (OpenRouter, Google, XAI, IonOS, OpenAI, Anthropic, ...). We still
2077 # list the local providers via their provider classes — which validate
2078 # the URL (SSRF) and support auth headers — by filtering the discovered
2079 # set rather than skipping discovery entirely. The filter classifies
2080 # each provider's configured ENDPOINT (evaluate_llm_endpoint), so a
2081 # nominally-local provider pointed at a public URL is dropped too; a
2082 # static LOCAL_PROVIDERS name check would have kept it.
2083 if policy_context.require_local_llm:
2084 all_discovered_providers = discovered_providers
2085 discovered_providers = {
2086 key: info
2087 for key, info in all_discovered_providers.items()
2088 if _model_discovery_provider_allowed(
2089 key, policy_context, policy_snapshot
2090 )
2091 }
2092 logger.bind(policy_audit=True).info(
2093 "local-only egress posture: limiting discovered model lists "
2094 "to endpoint-approved local providers",
2095 kept=list(discovered_providers.keys()),
2096 skipped=[
2097 key
2098 for key in all_discovered_providers
2099 if key not in discovered_providers
2100 ],
2101 )
2103 for provider_key, provider_info in discovered_providers.items():
2104 provider_models = []
2105 try:
2106 logger.info(
2107 f"Fetching models from {provider_info.provider_name}"
2108 )
2110 # Get the provider class
2111 provider_class = provider_info.provider_class
2113 # Get API key if configured
2114 api_key = _get_setting_from_session(
2115 provider_class.api_key_setting, username, ""
2116 )
2118 # Get base URL if provider has configurable URL
2119 provider_base_url: str | None = None
2120 url_setting = getattr(provider_class, "url_setting", None)
2121 if url_setting:
2122 provider_base_url = _get_setting_from_session(
2123 url_setting, username, ""
2124 )
2126 if policy_context.require_local_llm:
2127 provider_snapshot = dict(policy_snapshot)
2128 if url_setting:
2129 provider_snapshot[url_setting] = provider_base_url
2130 if not _model_discovery_provider_allowed( 2130 ↛ 2133line 2130 didn't jump to line 2133 because the condition on line 2130 was never true
2131 provider_key, policy_context, provider_snapshot
2132 ):
2133 continue
2135 # Use the provider's list_models_for_api method. This is the
2136 # single remaining cloud fetch path (OpenAI's models.list()
2137 # among them), so it carries PR #3483's provider-fetch timer.
2138 provider_fetch_start = time.perf_counter()
2139 models = provider_class.list_models_for_api(
2140 api_key, provider_base_url
2141 )
2142 provider_fetch_ms = (
2143 time.perf_counter() - provider_fetch_start
2144 ) * 1000
2145 if provider_fetch_ms > 1000:
2146 logger.info(
2147 f"{provider_key} list_models_for_api took "
2148 f"{provider_fetch_ms:.0f}ms"
2149 )
2150 else:
2151 logger.debug(
2152 f"{provider_key} list_models_for_api took "
2153 f"{provider_fetch_ms:.0f}ms"
2154 )
2156 # Format models for the API response
2157 for model in models:
2158 provider_models.append(
2159 {
2160 "value": model["value"],
2161 "label": model[
2162 "label"
2163 ], # Use provider's label as-is
2164 "provider": provider_key,
2165 }
2166 )
2168 logger.info(
2169 f"Successfully fetched {len(provider_models)} models from {provider_info.provider_name}"
2170 )
2172 except PolicyDeniedError:
2173 raise
2174 except Exception:
2175 logger.exception(
2176 f"Error getting {provider_info.provider_name} models"
2177 )
2179 # Set models in providers dict using lowercase key
2180 providers[f"{normalize_provider(provider_key)}_models"] = (
2181 provider_models
2182 )
2183 logger.info(
2184 f"Final {provider_key} models count: {len(provider_models)}"
2185 )
2187 # Save fetched models to database cache
2188 if force_refresh or providers:
2189 # We fetched fresh data, save it to database
2190 with get_user_db_session(username) as db_session:
2191 try:
2192 if force_refresh:
2193 # When force refresh, clear ALL cached models to remove any stale data
2194 # from old code versions or deleted providers
2195 deleted_count = db_session.query(ProviderModel).delete()
2196 logger.info(
2197 f"Force refresh: cleared all {deleted_count} cached models"
2198 )
2199 else:
2200 # Clear old cache entries only for providers we're updating
2201 for provider_key in providers:
2202 provider_name = provider_key.replace(
2203 "_models", ""
2204 ).upper()
2205 db_session.query(ProviderModel).filter(
2206 ProviderModel.provider == provider_name
2207 ).delete()
2209 # Insert new models
2210 for provider_key, models in providers.items():
2211 provider_name = provider_key.replace(
2212 "_models", ""
2213 ).upper()
2214 for model in models:
2215 if ( 2215 ↛ 2214line 2215 didn't jump to line 2214 because the condition on line 2215 was always true
2216 isinstance(model, dict)
2217 and "value" in model
2218 and "label" in model
2219 ):
2220 new_model = ProviderModel(
2221 provider=provider_name,
2222 model_key=model["value"],
2223 model_label=model["label"],
2224 last_updated=datetime.now(UTC),
2225 )
2226 db_session.add(new_model)
2228 db_session.commit()
2229 logger.info("Successfully cached models to database")
2231 except Exception:
2232 logger.exception("Error saving models to database cache")
2233 db_session.rollback()
2235 # Return all options
2236 _log_available_models_duration(endpoint_start, cache_hit=False)
2237 return {"provider_options": provider_options, "providers": providers}
2239 except PolicyDeniedError as exc:
2240 reason = exc.decision.reason
2241 status_code = 503 if reason == "settings_unavailable" else 400
2242 logger.bind(policy_audit=True).warning(
2243 "available-model discovery denied by egress policy", reason=reason
2244 )
2245 _log_available_models_duration(
2246 endpoint_start, cache_hit=False, error=True
2247 )
2248 return JSONResponse(
2249 {
2250 "status": "error",
2251 "message": f"Egress policy refused this request: {reason}",
2252 },
2253 status_code=status_code,
2254 )
2255 except Exception:
2256 logger.exception("Error getting available models")
2257 _log_available_models_duration(
2258 endpoint_start, cache_hit=False, error=True
2259 )
2260 return JSONResponse(
2261 {
2262 "status": "error",
2263 "message": "Failed to retrieve available models",
2264 },
2265 status_code=500,
2266 )
2269def _log_available_models_duration(
2270 start: float, cache_hit: bool, error: bool = False
2271) -> None:
2272 """Log /api/available-models endpoint duration.
2274 Uses INFO when the endpoint took > 1s (indicating a real provider fetch
2275 latency worth flagging), DEBUG otherwise. This is the likely culprit for
2276 Path C (LLM provider timeout masquerading as backend hang) in the
2277 login-hang investigation (PR #3483 / #5961).
2278 """
2279 elapsed_ms = (time.perf_counter() - start) * 1000
2280 path = (
2281 "error"
2282 if error
2283 else ("cache hit" if cache_hit else "full provider fetch")
2284 )
2285 if elapsed_ms > 1000:
2286 logger.info(f"/api/available-models ({path}) took {elapsed_ms:.0f}ms")
2287 else:
2288 logger.debug(f"/api/available-models ({path}) took {elapsed_ms:.0f}ms")
2291def _get_engine_icon_and_category(
2292 engine_data: dict, engine_class=None
2293) -> tuple:
2294 """
2295 Get icon emoji and category label for a search engine based on its attributes.
2297 Args:
2298 engine_data: Engine configuration dictionary
2299 engine_class: Optional loaded engine class to check attributes
2301 Returns:
2302 Tuple of (icon, category) strings
2303 """
2304 # Check attributes from either the class or the engine data
2305 if engine_class:
2306 is_scientific = getattr(engine_class, "is_scientific", False)
2307 is_generic = getattr(engine_class, "is_generic", False)
2308 is_local = getattr(engine_class, "is_local", False)
2309 is_news = getattr(engine_class, "is_news", False)
2310 is_code = getattr(engine_class, "is_code", False)
2311 else:
2312 is_scientific = engine_data.get("is_scientific", False)
2313 is_generic = engine_data.get("is_generic", False)
2314 is_local = engine_data.get("is_local", False)
2315 is_news = engine_data.get("is_news", False)
2316 is_code = engine_data.get("is_code", False)
2318 # Check books attribute
2319 if engine_class:
2320 is_books = getattr(engine_class, "is_books", False)
2321 else:
2322 is_books = engine_data.get("is_books", False)
2324 # Return icon and category based on engine type
2325 # Priority: local > scientific > news > code > books > generic > default
2326 if is_local:
2327 return "📁", "Local RAG"
2328 if is_scientific:
2329 return "🔬", "Scientific"
2330 if is_news:
2331 return "📰", "News"
2332 if is_code:
2333 return "💻", "Code"
2334 if is_books:
2335 return "📚", "Books"
2336 if is_generic:
2337 return "🌐", "Web Search"
2338 return "🔍", "Search"
2341# Ported verbatim from the Flask ``settings_routes`` (#5221 / issue
2342# #5204). The body is framework-agnostic — it only consults the egress
2343# PDP and the logger — so nothing needed adapting; only its caller did.
2344# This landed on main against a module this branch had already deleted,
2345# so the merge dropped it while keeping the frontend that depends on it.
2346def _classify_options_for_egress(
2347 engine_options: list,
2348 *,
2349 egress_scope: str,
2350 primary_engine: str,
2351 settings_snapshot: dict,
2352 username: Optional[str],
2353 search_engines: Optional[dict] = None,
2354) -> None:
2355 """Stamp each engine option with an ``egress: {allowed, reason}`` field.
2357 Used by ``api_get_available_search_engines`` when the caller passes
2358 ``?egress_scope=…&primary=…``: the frontend needs to know which
2359 options are selectable under the active scope so it can disable
2360 (or hide) the rest in the dropdown. Issue #5204.
2362 The decision is taken through the same PDP the request-boundary
2363 precheck uses (``evaluate_engine``) so the dropdown's disabled set
2364 and the server's 400 denials stay perfectly aligned. Dropdown filtering
2365 strictly reflects PDP allowed decisions; frontend selection reconciliation
2366 handles updating any invalid primary selection when scope changes. The factory PEP
2367 still enforces at instantiation time; this is a UX filter, not a security boundary.
2369 Operates in place; safe to call on a freshly built option list.
2370 Failures are swallowed per option (logged) — a single bad engine
2371 must not blank the whole dropdown.
2372 """
2373 try:
2374 from ...security.egress.policy import (
2375 context_from_snapshot,
2376 evaluate_engine,
2377 resolve_run_primary_engine,
2378 )
2379 except Exception: # pragma: no cover - defensive
2380 logger.exception(
2381 "egress policy unavailable; emitting unfiltered options"
2382 )
2383 return
2385 if not isinstance(settings_snapshot, dict):
2386 # Programming error: the route always passes a snapshot. Log it
2387 # loudly so a future caller-side change is debuggable instead of
2388 # silently falling back to the unfiltered list (PR #5221 review).
2389 logger.error(
2390 "_classify_options_for_egress received a non-dict "
2391 "settings_snapshot (type={}); emitting unfiltered options",
2392 type(settings_snapshot).__name__,
2393 )
2394 return
2396 # Build an evaluation snapshot that reflects the requested scope and
2397 # primary engine parameter overrides sent by the frontend (issue #5204).
2398 eval_snapshot = dict(settings_snapshot)
2399 if egress_scope:
2400 eval_snapshot["policy.egress_scope"] = egress_scope
2401 if primary_engine:
2402 eval_snapshot["search.tool"] = primary_engine
2404 # The precheck builds an EgressContext via context_from_snapshot; the
2405 # adaptive resolution requires a primary, so fall back to the supplied
2406 # primary and ultimately the saved search.tool.
2407 try:
2408 try:
2409 primary = primary_engine or resolve_run_primary_engine(
2410 eval_snapshot
2411 )
2412 except ValueError:
2413 # ``resolve_run_primary_engine`` raises ValueError when
2414 # ``search.tool`` is missing/blank/non-string and no
2415 # ``default`` is given. That's the only documented failure
2416 # mode — narrow the catch so unrelated bugs (e.g. an
2417 # AttributeError on a future code path) surface instead of
2418 # being silently swallowed (PR #5221 review).
2419 primary = primary_engine or eval_snapshot.get("search.tool", "")
2420 try:
2421 ctx = context_from_snapshot(
2422 eval_snapshot,
2423 primary,
2424 username=username,
2425 )
2426 except Exception:
2427 # ``context_from_snapshot`` raises ``PolicyDeniedError`` for
2428 # an unknown scope (``unknown_egress_scope``) and ``ValueError``
2429 # for a malformed snapshot; either way the precheck would
2430 # 400 the run. Stamp a permissive decision so the frontend
2431 # falls back to the precheck instead of a half-blanked
2432 # dropdown. Use logger.exception (dev-only traceback, no
2433 # exc_info kwarg on logger.warning which would expose a
2434 # full traceback at WARNING level in production — PR #5221
2435 # review).
2436 logger.exception(
2437 "_classify_options_for_egress: context build failed; "
2438 "emitting permissive policy_unavailable decisions"
2439 )
2440 for opt in engine_options:
2441 opt["egress"] = {
2442 "allowed": True,
2443 "reason": "policy_unavailable",
2444 }
2445 return
2446 except Exception: # pragma: no cover - defensive
2447 logger.exception(
2448 "egress context build failed; emitting unfiltered options"
2449 )
2450 return
2452 for opt in engine_options:
2453 engine_id = opt.get("value")
2454 try:
2455 metadata = search_engines.get(engine_id) if search_engines else None
2456 decision = evaluate_engine(
2457 engine_id,
2458 ctx,
2459 settings_snapshot=eval_snapshot,
2460 metadata=metadata,
2461 )
2462 opt["egress"] = {
2463 "allowed": bool(decision.allowed),
2464 "reason": decision.reason,
2465 }
2466 except Exception: # pragma: no cover - defensive
2467 logger.exception(f"egress decision failed for engine {engine_id}")
2468 opt["egress"] = {"allowed": True, "reason": "decision_error"}
2471@router.get("/api/available-search-engines")
2472def api_get_available_search_engines(
2473 request: Request,
2474 username: Annotated[str, Depends(require_auth)],
2475):
2476 """Get available search engines.
2478 Optional query params (issue #5204):
2479 ``egress_scope`` — when set, each option gets an
2480 ``egress: {allowed, reason}`` field matching the same PDP the
2481 request-boundary precheck uses. The frontend uses this to disable
2482 (or hide) options that would be refused under the active scope.
2483 ``primary`` — the user's saved primary search tool, used by the
2484 PDP for strict / adaptive scope resolution.
2486 When ``egress_scope`` is absent the response shape is unchanged
2487 (zero behavior impact on existing callers — the settings page,
2488 the news form, etc.).
2489 """
2490 try:
2491 # Issue #5204: optional egress-scope filter. The scope/primary
2492 # query params opt INTO a per-option egress decision; without
2493 # them the response is the historical, unfiltered list.
2494 # Flask read these from request.args; the FastAPI equivalent is
2495 # request.query_params.
2496 requested_scope = (
2497 (request.query_params.get("egress_scope") or "").strip().lower()
2498 )
2499 requested_primary = (request.query_params.get("primary") or "").strip()
2500 apply_egress_filter = requested_scope in (
2501 "private_only",
2502 "public_only",
2503 )
2505 with get_user_db_session(username) as db_session:
2506 settings_manager = get_settings_manager(db_session, username)
2508 # Get search engines using the same approach as search_engines_config.py
2509 from ...web_search_engines.search_engines_config import (
2510 search_config,
2511 )
2512 from ...web_search_engines.engine_groups import (
2513 classify_engine_group,
2514 effective_group,
2515 group_label,
2516 group_order,
2517 )
2519 search_engines = search_config(
2520 username=username, db_session=db_session
2521 )
2523 # Get user's favorites using SettingsManager
2524 favorites = settings_manager.get_setting("search.favorites", [])
2525 if not isinstance(favorites, list): 2525 ↛ 2526line 2525 didn't jump to line 2526 because the condition on line 2525 was never true
2526 favorites = []
2528 # Extract search engines from config
2529 engines_dict = {}
2530 engine_options = []
2532 if search_engines: 2532 ↛ 2630line 2532 didn't jump to line 2630 because the condition on line 2532 was always true
2533 # Format engines for API response with metadata
2534 from ...security.module_whitelist import (
2535 get_safe_module_class,
2536 SecurityError,
2537 )
2539 for engine_id, engine_data in search_engines.items():
2540 # Try to load the engine class to get metadata
2541 engine_class = None
2542 try:
2543 module_path = engine_data.get("module_path")
2544 class_name = engine_data.get("class_name")
2545 if module_path and class_name: 2545 ↛ 2560line 2545 didn't jump to line 2560 because the condition on line 2545 was always true
2546 # Use secure whitelist-validated import
2547 engine_class = get_safe_module_class(
2548 module_path, class_name
2549 )
2550 except SecurityError:
2551 logger.warning(
2552 f"Security: Blocked unsafe module for {engine_id}"
2553 )
2554 except Exception as e:
2555 logger.debug(
2556 f"Could not load engine class for {engine_id}: {e}"
2557 )
2559 # Get icon and category from engine attributes
2560 icon, category = _get_engine_icon_and_category(
2561 engine_data, engine_class
2562 )
2564 # Check if engine requires an API key
2565 requires_api_key = engine_data.get(
2566 "requires_api_key", False
2567 )
2569 # Build display name with icon, category, and API key status
2570 base_name = engine_data.get("display_name", engine_id)
2571 if requires_api_key:
2572 label = f"{icon} {base_name} ({category}, API key)"
2573 else:
2574 label = f"{icon} {base_name} ({category}, Free)"
2576 # Check if engine is a favorite
2577 is_favorite = engine_id in favorites
2579 # Classify the engine into a selector band. base_group
2580 # ignores favorite status (used by the frontend to move an
2581 # engine back to its category when un-starred); the
2582 # effective band is the Favorites overlay when starred.
2583 # See engine_groups.py.
2584 base_group = classify_engine_group(
2585 engine_id, category, requires_api_key
2586 )
2587 shown_group = effective_group(base_group, is_favorite)
2589 engines_dict[engine_id] = {
2590 "display_name": base_name,
2591 "description": engine_data.get("description", ""),
2592 "strengths": engine_data.get("strengths", []),
2593 "icon": icon,
2594 "category": category,
2595 "requires_api_key": requires_api_key,
2596 "is_favorite": is_favorite,
2597 }
2599 engine_options.append(
2600 {
2601 "value": engine_id,
2602 "label": label,
2603 "icon": icon,
2604 "category": category,
2605 "requires_api_key": requires_api_key,
2606 "is_favorite": is_favorite,
2607 "group": shown_group,
2608 "group_label": group_label(shown_group),
2609 "group_order": group_order(shown_group),
2610 "base_group": base_group,
2611 "base_group_label": group_label(base_group),
2612 "base_group_order": group_order(base_group),
2613 # Surface the per-engine ``agent_enabled`` flag so
2614 # the frontend can disable engines the LangGraph
2615 # research agent hides from its specialized tool
2616 # list. Defaults to True so engines that don't
2617 # carry the flag (only ``collection_*`` sets it
2618 # explicitly) stay selectable — the frontend's
2619 # LangGraph-strategy check is the only consumer
2620 # and short-circuits for other strategies, so a
2621 # True default is safe everywhere.
2622 "agent_enabled": engine_data.get(
2623 "agent_enabled", True
2624 ),
2625 }
2626 )
2628 # Sort engine_options by band order (favorites band first), then
2629 # alphabetically by label within each band.
2630 engine_options.sort(
2631 key=lambda x: (
2632 x.get("group_order", 999),
2633 x.get("label", "").lower(),
2634 )
2635 )
2637 # If no engines found, log the issue but return empty list
2638 if not engine_options: 2638 ↛ 2639line 2638 didn't jump to line 2639 because the condition on line 2638 was never true
2639 logger.warning("No search engines found in configuration")
2641 # Issue #5204: when the caller opts in via ?egress_scope=&primary=,
2642 # stamp each option with the PDP's per-engine decision so the
2643 # frontend can disable/hide the ones that would be refused at
2644 # submit time. UNPROTECTED and missing-scope callers get the
2645 # unfiltered list (the historical shape).
2646 if apply_egress_filter:
2647 try:
2648 policy_snapshot = settings_manager.get_settings_snapshot()
2649 except Exception: # pragma: no cover - defensive
2650 logger.exception(
2651 "settings snapshot unavailable for egress filter"
2652 )
2653 policy_snapshot = {}
2654 _classify_options_for_egress(
2655 engine_options,
2656 egress_scope=requested_scope,
2657 primary_engine=requested_primary,
2658 settings_snapshot=policy_snapshot or {},
2659 username=username,
2660 search_engines=search_engines,
2661 )
2663 return {
2664 "engines": engines_dict,
2665 "engine_options": engine_options,
2666 "favorites": favorites,
2667 }
2669 except Exception:
2670 logger.exception("Error getting available search engines")
2671 return JSONResponse(
2672 {"error": "Failed to retrieve search engines"}, status_code=500
2673 )
2676@router.get("/api/search-favorites")
2677def api_get_search_favorites(
2678 request: Request,
2679 username: Annotated[str, Depends(require_auth)],
2680):
2681 """Get the list of favorite search engines for the current user"""
2682 try:
2683 with get_user_db_session(username) as db_session:
2684 settings_manager = get_settings_manager(db_session, username)
2685 favorites = settings_manager.get_setting("search.favorites", [])
2686 if not isinstance(favorites, list):
2687 favorites = []
2688 return {"favorites": favorites}
2690 except Exception:
2691 logger.exception("Error getting search favorites")
2692 return JSONResponse(
2693 {"error": "Failed to retrieve favorites"}, status_code=500
2694 )
2697@router.put("/api/search-favorites")
2698@settings_limit
2699async def api_update_search_favorites(
2700 request: Request,
2701 username: Annotated[str, Depends(require_auth)],
2702):
2703 """Update the list of favorite search engines for the current user"""
2704 data = await request.json()
2705 if not isinstance(data, dict):
2706 return json_body_error("simple", "No data provided")
2708 def _impl():
2709 try:
2710 favorites = data.get("favorites")
2711 if favorites is None:
2712 return JSONResponse(
2713 {"error": "No favorites provided"}, status_code=400
2714 )
2716 if not isinstance(favorites, list):
2717 return JSONResponse(
2718 {"error": "Favorites must be a list"}, status_code=400
2719 )
2721 with get_user_db_session(username) as db_session:
2722 settings_manager = get_settings_manager(db_session, username)
2723 if settings_manager.settings_locked:
2724 return JSONResponse(
2725 {"error": "Settings are locked"}, status_code=403
2726 )
2727 if settings_manager.set_setting("search.favorites", favorites):
2728 invalidate_settings_caches(username)
2729 return {
2730 "message": "Favorites updated successfully",
2731 "favorites": favorites,
2732 }
2734 return JSONResponse(
2735 {"error": "Failed to update favorites"}, status_code=500
2736 )
2738 except Exception:
2739 logger.exception("Error updating search favorites")
2740 return JSONResponse(
2741 {"error": "Failed to update favorites"}, status_code=500
2742 )
2744 # SQLCipher PBKDF2 key derivation in get_user_db_session blocks the
2745 # event loop for hundreds of ms on first call after login. Offload
2746 # the entire DB-touching body so concurrent requests don't serialise.
2747 return await run_db_sync(_impl)
2750@router.post("/api/search-favorites/toggle")
2751@settings_limit
2752async def api_toggle_search_favorite(
2753 request: Request,
2754 username: Annotated[str, Depends(require_auth)],
2755):
2756 """Toggle a search engine as favorite"""
2757 data = await request.json()
2758 if not isinstance(data, dict):
2759 return json_body_error("simple", "No data provided")
2761 def _impl():
2762 try:
2763 engine_id = data.get("engine_id")
2764 if not engine_id:
2765 return JSONResponse(
2766 {"error": "No engine_id provided"}, status_code=400
2767 )
2769 with get_user_db_session(username) as db_session:
2770 settings_manager = get_settings_manager(db_session, username)
2771 if settings_manager.settings_locked:
2772 return JSONResponse(
2773 {"error": "Settings are locked"}, status_code=403
2774 )
2776 # Get current favorites
2777 favorites = settings_manager.get_setting("search.favorites", [])
2778 if not isinstance(favorites, list):
2779 favorites = []
2780 else:
2781 # Make a copy to avoid modifying the original
2782 favorites = list(favorites)
2784 # Toggle the engine
2785 is_favorite = engine_id in favorites
2786 if is_favorite:
2787 favorites.remove(engine_id)
2788 is_favorite = False
2789 else:
2790 favorites.append(engine_id)
2791 is_favorite = True
2793 # Update the setting
2794 if settings_manager.set_setting("search.favorites", favorites):
2795 invalidate_settings_caches(username)
2796 return {
2797 "message": "Favorite toggled successfully",
2798 "engine_id": engine_id,
2799 "is_favorite": is_favorite,
2800 "favorites": favorites,
2801 }
2803 return JSONResponse(
2804 {"error": "Failed to toggle favorite"}, status_code=500
2805 )
2807 except Exception:
2808 logger.exception("Error toggling search favorite")
2809 return JSONResponse(
2810 {"error": "Failed to toggle favorite"}, status_code=500
2811 )
2813 return await run_db_sync(_impl)
2816# Legacy routes for backward compatibility - these will redirect to the new routes
2817@router.get("/main")
2818def main_config_page(
2819 request: Request, username: Annotated[str, Depends(require_auth)]
2820):
2821 """Redirect to app settings page"""
2822 return RedirectResponse(url="/settings/", status_code=302)
2825@router.get("/collections")
2826def collections_config_page(
2827 request: Request, username: Annotated[str, Depends(require_auth)]
2828):
2829 """Redirect to app settings page"""
2830 return RedirectResponse(url="/settings/", status_code=302)
2833@router.get("/api_keys")
2834def api_keys_config_page(
2835 request: Request, username: Annotated[str, Depends(require_auth)]
2836):
2837 """Redirect to LLM settings page"""
2838 return RedirectResponse(url="/settings/", status_code=302)
2841@router.get("/search_engines")
2842def search_engines_config_page(
2843 request: Request, username: Annotated[str, Depends(require_auth)]
2844):
2845 """Redirect to search settings page"""
2846 return RedirectResponse(url="/settings/", status_code=302)
2849@router.get("/llm")
2850def llm_config_page(
2851 request: Request, username: Annotated[str, Depends(require_auth)]
2852):
2853 """Redirect to LLM settings page"""
2854 return RedirectResponse(url="/settings/", status_code=302)
2857@router.post("/open_file_location")
2858def open_file_location(
2859 request: Request, username: Annotated[str, Depends(require_auth)]
2860):
2861 """Open the location of a configuration file.
2863 Security: This endpoint is disabled for server deployments.
2864 It only makes sense for desktop usage where the server and client are on the same machine.
2865 """
2866 return JSONResponse(
2867 {
2868 "status": "error",
2869 "message": "This feature is disabled. It is only available in desktop mode.",
2870 },
2871 status_code=403,
2872 )
2875# CSRF token is injected globally via fastapi_app template globals
2878@router.post("/fix_corrupted_settings")
2879@settings_limit
2880def fix_corrupted_settings(
2881 request: Request,
2882 username: Annotated[str, Depends(require_auth)],
2883):
2884 """Fix corrupted settings in the database"""
2885 try:
2886 with get_user_db_session(username) as db_session:
2887 # Track fixed and removed settings
2888 fixed_settings = []
2889 removed_duplicate_settings = []
2890 # First, find and remove duplicate settings with the same key
2891 # This happens because of errors in settings import/export
2892 from sqlalchemy import func as sql_func
2894 # Find keys with duplicates
2895 duplicate_keys = (
2896 db_session.query(Setting.key)
2897 .group_by(Setting.key)
2898 .having(sql_func.count(Setting.key) > 1)
2899 .all()
2900 )
2901 duplicate_keys = [key[0] for key in duplicate_keys]
2903 # For each duplicate key, keep the latest updated one and remove others
2904 for key in duplicate_keys:
2905 dupe_settings = (
2906 db_session.query(Setting)
2907 .filter(Setting.key == key)
2908 .order_by(Setting.updated_at.desc())
2909 .all()
2910 )
2912 # Keep the first one (most recently updated) and delete the rest
2913 for i, setting in enumerate(dupe_settings):
2914 if i > 0: # Skip the first one (keep it)
2915 db_session.delete(setting)
2916 removed_duplicate_settings.append(key)
2918 # Check for settings with corrupted values
2919 all_settings = db_session.query(Setting).all()
2920 for setting in all_settings:
2921 # Check different types of corruption
2922 is_corrupted = False
2924 if (
2925 setting.value is None
2926 or (
2927 isinstance(setting.value, str)
2928 and setting.value
2929 in [
2930 "{",
2931 "[",
2932 "{}",
2933 "[]",
2934 "[object Object]",
2935 "null",
2936 "undefined",
2937 ]
2938 )
2939 or (
2940 isinstance(setting.value, dict)
2941 and len(setting.value) == 0
2942 )
2943 ):
2944 is_corrupted = True
2946 # Skip if not corrupted
2947 if not is_corrupted:
2948 continue
2950 # Get default value from migrations
2951 # Import commented out as it's not directly used
2952 # from ...database.migrations import setup_predefined_settings
2954 default_value: Any = None
2956 # Try to find a matching default setting based on key
2957 if setting.key.startswith("llm."):
2958 if setting.key == "llm.model":
2959 default_value = ""
2960 elif setting.key == "llm.provider":
2961 default_value = "ollama"
2962 elif setting.key == "llm.temperature":
2963 default_value = 0.7
2964 elif setting.key == "llm.max_tokens":
2965 default_value = 1024
2966 elif setting.key.startswith("search."):
2967 if setting.key == "search.tool":
2968 default_value = DEFAULT_SEARCH_TOOL
2969 elif setting.key == "search.max_results":
2970 default_value = 10
2971 elif setting.key == "search.region":
2972 default_value = "us"
2973 elif setting.key == "search.questions_per_iteration":
2974 default_value = 3
2975 elif setting.key == "search.searches_per_section":
2976 default_value = 2
2977 elif setting.key == "search.skip_relevance_filter":
2978 default_value = False
2979 elif setting.key == "search.safe_search":
2980 default_value = True
2981 elif setting.key == "search.search_language":
2982 default_value = "English"
2983 elif setting.key.startswith("report."):
2984 if setting.key == "report.searches_per_section":
2985 default_value = 2
2986 elif setting.key.startswith("app."):
2987 if (
2988 setting.key == "app.theme"
2989 or setting.key == "app.default_theme"
2990 ):
2991 # Keep in step with default_settings.json and the
2992 # reset path above — three copies of this default
2993 # exist and they drifted from the theme registry
2994 # together.
2995 default_value = "system"
2996 elif setting.key == "app.enable_notifications" or (
2997 setting.key == "app.enable_web"
2998 or setting.key == "app.web_interface"
2999 ):
3000 default_value = True
3001 elif setting.key == "app.host":
3002 # noqa justified: this is the DEFAULT VALUE of a
3003 # host setting being rendered, not a bind address.
3004 default_value = "0.0.0.0" # noqa: S104
3005 elif setting.key == "app.port":
3006 default_value = 5000
3007 elif setting.key == "app.debug": 3007 ↛ 3011line 3007 didn't jump to line 3011 because the condition on line 3007 was always true
3008 default_value = True
3010 # Update the setting with the default value if found
3011 if default_value is not None:
3012 setting.value = default_value
3013 fixed_settings.append(setting.key)
3014 else:
3015 # If no default found but it's a corrupted JSON, set to empty object
3016 if setting.key.startswith("report."):
3017 setting.value = {}
3018 fixed_settings.append(setting.key)
3020 # Commit changes
3021 if fixed_settings or removed_duplicate_settings:
3022 try:
3023 db_session.commit()
3024 logger.info(
3025 f"Fixed {len(fixed_settings)} corrupted settings: {', '.join(fixed_settings)}"
3026 )
3027 if removed_duplicate_settings:
3028 logger.info(
3029 f"Removed {len(removed_duplicate_settings)} duplicate settings"
3030 )
3031 except Exception:
3032 db_session.rollback()
3033 raise
3034 invalidate_settings_caches(username)
3035 changed_settings = list(
3036 dict.fromkeys(fixed_settings + removed_duplicate_settings)
3037 )
3038 reschedule_document_jobs_if_needed(username, changed_settings)
3039 reschedule_zotero_jobs_if_needed(username, changed_settings)
3041 # Return success
3042 return {
3043 "status": "success",
3044 "message": f"Fixed {len(fixed_settings)} corrupted settings, removed {len(removed_duplicate_settings)} duplicates",
3045 "fixed_settings": fixed_settings,
3046 "removed_duplicates": removed_duplicate_settings,
3047 }
3049 except Exception:
3050 logger.exception("Error fixing corrupted settings")
3051 return JSONResponse(
3052 {
3053 "status": "error",
3054 "message": "An internal error occurred while fixing corrupted settings. Please try again later.",
3055 },
3056 status_code=500,
3057 )
3060@router.get("/api/warnings")
3061def api_get_warnings(
3062 request: Request, username: Annotated[str, Depends(require_auth)]
3063):
3064 """Get current warnings based on settings"""
3065 try:
3066 warnings = calculate_warnings(username=username)
3067 return {"warnings": warnings}
3068 except Exception:
3069 logger.exception("Error getting warnings")
3070 return JSONResponse(
3071 {"error": "Failed to retrieve warnings"}, status_code=500
3072 )
3075@router.get("/api/backup-status")
3076def api_get_backup_status(
3077 request: Request, username: Annotated[str, Depends(require_auth)]
3078):
3079 """Get backup status for the current user."""
3080 try:
3081 from ...config.paths import get_user_backup_directory
3083 if not username: 3083 ↛ 3084line 3083 didn't jump to line 3084 because the condition on line 3083 was never true
3084 return JSONResponse({"error": "Not authenticated"}, status_code=401)
3086 from ...utilities.formatting import human_size
3088 backup_dir = get_user_backup_directory(username)
3090 # Sort by modification time (not filename) for robustness
3091 backup_list = []
3092 total_size = 0
3093 from ...database.backup.backup_service import is_safe_glob_result
3095 for b in backup_dir.glob("ldr_backup_*.db"):
3096 # Symlink/traversal guard (main #4663): skip a planted symlink whose
3097 # target escapes the per-user backup dir, so its external file's
3098 # name/size/mtime can't be exfiltrated through this endpoint.
3099 if not is_safe_glob_result(b, backup_dir):
3100 continue
3101 try:
3102 stat = b.stat()
3103 total_size += stat.st_size
3104 backup_list.append(
3105 {
3106 "filename": b.name,
3107 "size_bytes": stat.st_size,
3108 "size_human": human_size(stat.st_size),
3109 "created_at": datetime.fromtimestamp(
3110 stat.st_mtime, tz=timezone.utc
3111 ).isoformat(),
3112 "_mtime": stat.st_mtime,
3113 }
3114 )
3115 except FileNotFoundError:
3116 continue
3118 # Sort newest first by mtime, then remove internal field
3119 backup_list.sort(key=lambda x: x["_mtime"], reverse=True)
3120 for entry in backup_list:
3121 del entry["_mtime"]
3123 backup_enabled = _get_setting_from_session(
3124 "backup.enabled", username, True
3125 )
3127 return {
3128 "enabled": bool(backup_enabled),
3129 "count": len(backup_list),
3130 "backups": backup_list,
3131 "total_size_bytes": total_size,
3132 "total_size_human": human_size(total_size),
3133 }
3135 except Exception:
3136 logger.exception("Error getting backup status")
3137 return JSONResponse(
3138 {"error": "Failed to retrieve backup status"}, status_code=500
3139 )
3142@router.get("/api/ollama-status")
3143def check_ollama_status(
3144 request: Request, username: Annotated[str, Depends(require_auth)]
3145):
3146 """Check if Ollama is running and available"""
3147 try:
3148 # Get Ollama URL from settings
3149 raw_base_url = _get_setting_from_session(
3150 "llm.ollama.url", username, DEFAULT_OLLAMA_URL
3151 )
3152 base_url = (
3153 normalize_url(raw_base_url) if raw_base_url else DEFAULT_OLLAMA_URL
3154 )
3156 response = safe_get(
3157 f"{base_url}/api/version",
3158 timeout=2,
3159 allow_localhost=True,
3160 allow_private_ips=True,
3161 )
3163 if response.status_code == 200:
3164 return {
3165 "running": True,
3166 "version": response.json().get("version", "unknown"),
3167 }
3168 return {
3169 "running": False,
3170 "error": f"Ollama returned status code {response.status_code}",
3171 }
3173 except requests.exceptions.RequestException:
3174 logger.exception("Ollama check failed")
3175 return {
3176 "running": False,
3177 "error": "Failed to check search engine status",
3178 }
3181@router.get("/api/rate-limiting/status")
3182def api_get_rate_limiting_status(
3183 request: Request, username: Annotated[str, Depends(require_auth)]
3184):
3185 """Get current rate limiting status and statistics"""
3186 try:
3187 # exploration_rate / learning_rate are the *configured* settings, not
3188 # the effective operating values: AdaptiveRateLimitTracker._apply_profile
3189 # scales them by the active profile (conservative ~0.5x/0.7x, aggressive
3190 # ~1.5x/1.3x, each capped). The profile is reported alongside so a
3191 # consumer can tell which transform the running tracker applies; we
3192 # deliberately don't duplicate that scaling math here.
3193 status = {
3194 # Default True to match the schema default (default_settings.json)
3195 # and the tracker's web-mode default; the prior endpoint reported
3196 # the tracker's effective enabled state, which was on by default.
3197 "enabled": _get_setting_from_session(
3198 "rate_limiting.enabled", username, True
3199 ),
3200 "profile": _get_setting_from_session(
3201 "rate_limiting.profile", username, "balanced"
3202 ),
3203 "exploration_rate": _get_setting_from_session(
3204 "rate_limiting.exploration_rate", username, 0.1
3205 ),
3206 "learning_rate": _get_setting_from_session(
3207 "rate_limiting.learning_rate", username, 0.45
3208 ),
3209 "memory_window": _get_setting_from_session(
3210 "rate_limiting.memory_window", username, 100
3211 ),
3212 }
3214 with get_user_db_session(username) as db_session:
3215 estimates = (
3216 db_session.query(RateLimitEstimate)
3217 .order_by(RateLimitEstimate.engine_type)
3218 .all()
3219 )
3221 engines = []
3222 for est in estimates:
3223 engines.append(
3224 {
3225 "engine_type": est.engine_type,
3226 "base_wait_seconds": round(est.base_wait_seconds, 2),
3227 "min_wait_seconds": round(est.min_wait_seconds, 2),
3228 "max_wait_seconds": round(est.max_wait_seconds, 2),
3229 "last_updated": est.last_updated,
3230 "total_attempts": est.total_attempts,
3231 "success_rate": round(est.success_rate * 100, 1),
3232 }
3233 )
3235 return {"status": status, "engines": engines}
3237 except Exception:
3238 logger.exception("Error getting rate limiting status")
3239 return JSONResponse(
3240 {"error": "An internal error occurred"}, status_code=500
3241 )
3244@router.post("/api/rate-limiting/engines/{engine_type}/reset")
3245def api_reset_engine_rate_limiting(
3246 request: Request,
3247 engine_type,
3248 username: Annotated[str, Depends(require_auth)],
3249):
3250 """Reset (forget) the learned rate-limit estimate for a specific engine.
3252 Deletes the engine's persisted ``RateLimitEstimate`` row from the user's
3253 database so the adaptive tracker re-learns it from scratch. The previous
3254 implementation called the per-request ``get_tracker()``, whose mutation
3255 path is gated on a research-session context that is absent in an analytics
3256 HTTP request — so it was a silent no-op that never cleared the persisted
3257 estimate the ``/status`` and ``/current`` endpoints display (#4721).
3258 """
3259 try:
3260 with get_user_db_session(username) as db_session:
3261 db_session.query(RateLimitEstimate).filter_by(
3262 engine_type=engine_type
3263 ).delete(synchronize_session=False)
3264 db_session.commit()
3266 return {"message": f"Rate limiting data reset for {engine_type}"}
3268 except Exception:
3269 logger.exception(f"Error resetting rate limiting for {engine_type}")
3270 return JSONResponse(
3271 {"error": "An internal error occurred"}, status_code=500
3272 )
3275def _cleanup_rate_limit_estimates_sync(username: str, cutoff: float) -> None:
3276 """Delete persisted rate-limit estimates last updated before *cutoff*."""
3277 with get_user_db_session(username) as db_session:
3278 db_session.query(RateLimitEstimate).filter(
3279 RateLimitEstimate.last_updated < cutoff
3280 ).delete(synchronize_session=False)
3281 db_session.commit()
3284@router.post("/api/rate-limiting/cleanup")
3285async def api_cleanup_rate_limiting(
3286 request: Request, username: Annotated[str, Depends(require_auth)]
3287):
3288 """Clean up old rate limiting data.
3290 Note: not using @require_json_body because the JSON body is optional
3291 here — the endpoint works with or without a payload (defaults to 30 days).
3293 ``await request.json()`` is deliberately called outside any broad
3294 ``except Exception`` so a malformed body reaches the app's registered
3295 ``json.JSONDecodeError`` -> 400 handler instead of being swallowed here
3296 and reported as a 500 (see ``web/dependencies/json_body.py``). The
3297 isinstance check below is the same reasoning as ``@require_json_body``:
3298 a *valid but truthy non-dict* JSON body (e.g. ``[]`` or a bare number)
3299 would otherwise reach ``data.get(...)`` and raise ``AttributeError``.
3300 """
3301 content_type = request.headers.get("content-type", "")
3302 is_json = "application/json" in content_type
3303 data = await request.json() if is_json else None
3304 if data is not None and not isinstance(data, dict):
3305 return json_body_error("simple", "Request body must be a JSON object")
3306 days = data.get("days", 30) if data is not None else 30
3308 try:
3309 days = int(days)
3310 except (TypeError, ValueError):
3311 return JSONResponse(
3312 {"error": "'days' must be an integer"}, status_code=400
3313 )
3314 if days < 1 or days > 365:
3315 return JSONResponse(
3316 {"error": "'days' must be between 1 and 365"}, status_code=400
3317 )
3319 try:
3320 # Delete persisted estimates not updated within the window. Mirrors the
3321 # read endpoints (#4721): operate on RateLimitEstimate rather than the
3322 # per-request get_tracker(), whose cleanup path is a no-op outside a
3323 # research-session context. last_updated is a unix timestamp (Float).
3324 cutoff = time.time() - days * 86400
3325 await run_db_sync(_cleanup_rate_limit_estimates_sync, username, cutoff)
3327 return {
3328 "message": f"Cleaned up rate limiting data older than {days} days"
3329 }
3331 except Exception:
3332 logger.exception("Error cleaning up rate limiting data")
3333 return JSONResponse(
3334 {"error": "An internal error occurred"}, status_code=500
3335 )
3338@router.get("/api/bulk")
3339def get_bulk_settings(
3340 request: Request, username: Annotated[str, Depends(require_auth)]
3341):
3342 """Get multiple settings at once for performance."""
3343 try:
3344 # Get requested settings from query parameters
3345 requested = request.query_params.getlist("keys[]")
3346 if not requested:
3347 # Default to common settings if none specified
3348 requested = [
3349 "llm.provider",
3350 "llm.model",
3351 "search.tool",
3352 "search.iterations",
3353 "search.questions_per_iteration",
3354 "search.search_strategy",
3355 "benchmark.evaluation.provider",
3356 "benchmark.evaluation.model",
3357 "benchmark.evaluation.temperature",
3358 "benchmark.evaluation.endpoint_url",
3359 ]
3361 # Fetch all settings at once
3362 from ...security.data_sanitizer import DataSanitizer
3364 result = {}
3365 for key in requested:
3366 try:
3367 value = _get_setting_from_session(key, username)
3368 # Redact secret values (main does this — this endpoint is an
3369 # exfiltration channel for plaintext API keys/tokens via
3370 # ?keys[]=...). 'exists' reflects the RAW value, not the sentinel.
3371 result[key] = {
3372 "value": DataSanitizer.redact_value(key, None, value),
3373 "exists": value is not None,
3374 }
3375 except Exception:
3376 logger.warning(f"Error getting setting {key}")
3377 result[key] = {
3378 "value": None,
3379 "exists": False,
3380 "error": "Failed to retrieve setting",
3381 }
3383 return {"success": True, "settings": result}
3385 except Exception:
3386 logger.exception("Error getting bulk settings")
3387 return JSONResponse(
3388 {"success": False, "error": "An internal error occurred"},
3389 status_code=500,
3390 )
3393@router.get("/api/data-location")
3394def api_get_data_location(
3395 request: Request, username: Annotated[str, Depends(require_auth)]
3396):
3397 """Get information about data storage location and security"""
3398 try:
3399 # Get the data directory path
3400 data_dir = get_data_directory()
3401 # Get the encrypted databases path
3402 encrypted_db_path = get_encrypted_database_path()
3404 # Check if LDR_DATA_DIR environment variable is set
3405 from local_deep_research.settings.manager import SettingsManager
3407 settings_manager = SettingsManager()
3408 custom_data_dir = settings_manager.get_setting("bootstrap.data_dir")
3410 # Get platform-specific default location info
3411 platform_info = {
3412 "Windows": "C:\\Users\\Username\\AppData\\Local\\local-deep-research",
3413 "macOS": "~/Library/Application Support/local-deep-research",
3414 "Linux": "~/.local/share/local-deep-research",
3415 }
3417 # Current platform
3418 current_platform = platform.system()
3419 if current_platform == "Darwin":
3420 current_platform = "macOS"
3422 # Get SQLCipher settings from environment
3423 from ...database.sqlcipher_utils import get_sqlcipher_settings
3425 # Debug logging
3426 logger.info(f"db_manager type: {type(db_manager)}")
3427 logger.info(
3428 f"db_manager.has_encryption: {getattr(db_manager, 'has_encryption', 'ATTRIBUTE NOT FOUND')}"
3429 )
3431 cipher_settings = (
3432 get_sqlcipher_settings() if db_manager.has_encryption else {}
3433 )
3435 return {
3436 "data_directory": str(data_dir),
3437 "database_path": str(encrypted_db_path),
3438 "encrypted_database_path": str(encrypted_db_path),
3439 "is_custom": custom_data_dir is not None,
3440 "custom_env_var": "LDR_DATA_DIR",
3441 "custom_env_value": custom_data_dir,
3442 "platform": current_platform,
3443 "platform_default": platform_info.get(
3444 current_platform, str(data_dir)
3445 ),
3446 "platform_info": platform_info,
3447 "security_notice": {
3448 "encrypted": db_manager.has_encryption,
3449 "warning": "All data including API keys stored in the database are securely encrypted."
3450 if db_manager.has_encryption
3451 else "All data including API keys stored in the database are currently unencrypted. Please ensure appropriate file system permissions are set.",
3452 "recommendation": "Your data is protected with database encryption."
3453 if db_manager.has_encryption
3454 else "Consider using environment variables for sensitive API keys instead of storing them in the database.",
3455 },
3456 "encryption_settings": cipher_settings,
3457 }
3459 except Exception:
3460 logger.exception("Error getting data location information")
3461 return JSONResponse(
3462 {"error": "Failed to retrieve data location"}, status_code=500
3463 )
3466def _is_blank_service_url(value) -> bool:
3467 """True when a notification URL counts as unset.
3469 ``DataSanitizer._is_empty_value`` and ``NotificationManager`` both treat
3470 a whitespace-only ``notifications.service_url`` as unconfigured, so the
3471 test endpoint has to agree -- a stored ``" "`` is truthy, and without
3472 this it would be handed to Apprise verbatim.
3473 """
3474 if isinstance(value, str):
3475 return not value.strip()
3476 return not value
3479async def _notification_test_body(request: Request) -> dict:
3480 """Parse the test-url body and stash it for the rate-limit predicate.
3482 Runs as a route dependency, i.e. *before* slowapi's decorator wrapper,
3483 which is the only way ``_caller_supplied_notification_url`` (a
3484 synchronous callback) can see the request body at all.
3486 ``await request.json()`` is deliberately called here rather than inside
3487 the handler's broad ``except Exception`` so a malformed body reaches the
3488 app's registered ``json.JSONDecodeError`` -> 400 handler instead of
3489 being swallowed and reported as a 500 (see
3490 ``web/dependencies/json_body.py``). A valid but non-dict body (e.g. a
3491 bare number) is normalised to ``{}`` rather than raising, matching
3492 main's ``request.get_json(silent=True)`` shape.
3493 """
3494 data = await request.json()
3495 if not isinstance(data, dict):
3496 data = {}
3497 request.state.notification_test_payload = data
3498 return data
3501def _caller_supplied_notification_url(request: Request) -> bool:
3502 """True when the test-url request names its own destination.
3504 Rate-limit exemption predicate. Testing a URL the caller just typed is
3505 the case the endpoint exists to serve and stays unlimited. Falling back
3506 to the caller's STORED URL does not: that path is a zero-argument send
3507 trigger, so it is the one that gets a bucket.
3508 """
3509 payload = getattr(request.state, "notification_test_payload", None)
3510 if not isinstance(payload, dict):
3511 return False
3512 submitted = payload.get("service_url")
3513 if _is_blank_service_url(submitted):
3514 return False
3515 return submitted != DataSanitizer.REDACTION_TEXT
3518# Own bucket, not the shared "settings" one: this caps the stored-URL
3519# fallback without spending the quota a user needs for saving settings.
3520# Keyed per authenticated user (the branch convention for settings routes)
3521# rather than main's per-IP default -- the destination being spammed is the
3522# caller's own configured webhook.
3523notification_test_limit = limiter.shared_limit(
3524 SETTINGS_RATE_LIMIT,
3525 scope="notification_test",
3526 key_func=_user_key,
3527 exempt_when=_caller_supplied_notification_url,
3528)
3531@router.post("/api/notifications/test-url")
3532@notification_test_limit
3533async def api_test_notification_url(
3534 request: Request,
3535 data: Annotated[dict, Depends(_notification_test_body)],
3536 username: Annotated[str, Depends(require_auth)],
3537):
3538 """
3539 Test a submitted notification URL or the calling user's stored URL.
3541 When ``service_url`` is missing, blank, or the redaction sentinel, the
3542 authenticated user's stored ``notifications.service_url`` is used
3543 instead. Blank includes whitespace-only, matching
3544 ``DataSanitizer._is_empty_value`` and the notification manager, which
3545 both treat ``" "`` as unconfigured -- otherwise Apprise is handed
3546 literal whitespace. An unconfigured stored URL returns 400. Test
3547 notifications still use a temporary Apprise instance.
3549 Security note: this endpoint was deliberately unlimited, on the grounds
3550 that users need to test URLs while configuring notifications. That
3551 reasoning covers a caller who submits a URL, and that path is still
3552 exempt. It does not cover the stored-URL fallback: with no body, this
3553 becomes a zero-argument trigger that sends to a destination the caller
3554 never has to name, so an authenticated caller could loop on an empty
3555 body to spam their configured service. That path consumes a dedicated
3556 rate-limit bucket. ``require_auth`` still bounds the blast radius to the
3557 caller's own notification services.
3559 A wrong-typed or hostile ``service_url`` *value* flows unchanged into
3560 ``NotificationService.test_service`` / ``NotificationURLValidator``,
3561 which already reject it cleanly (non-string, unparsable, or
3562 private/loopback targets all return ``{"success": False, "error": ...}``
3563 rather than raising).
3564 """
3565 service_url = data.get("service_url")
3566 if _is_blank_service_url(service_url) or (
3567 service_url == DataSanitizer.REDACTION_TEXT
3568 ):
3569 # Off-loop: ``_get_setting_from_session`` opens a SQLCipher session
3570 # (PBKDF2 key derivation + disk I/O) and constructs a
3571 # SettingsManager, both synchronous. Called inline from this
3572 # ``async def`` it stalls the event loop, and the server runs
3573 # single-worker, so every other in-flight request stalls with it.
3574 service_url = await run_db_sync(
3575 _get_setting_from_session,
3576 "notifications.service_url",
3577 username,
3578 default="",
3579 )
3580 if _is_blank_service_url(service_url):
3581 return JSONResponse(
3582 {"success": False, "error": "No notification URL configured"},
3583 status_code=400,
3584 )
3586 try:
3587 from ...notifications.service import NotificationService
3589 # Create notification service instance and test the URL.
3590 # No password/session needed - URL provided directly, no DB access.
3591 # test_service performs a synchronous Apprise network send (can
3592 # block for the full connect/read timeout against a slow or
3593 # unreachable endpoint) — run it off the event loop.
3594 import asyncio
3596 from ...settings.env_registry import get_env_setting
3598 # Honour the operator's env gating (main does this) — otherwise the
3599 # service defaults to outbound_allowed=False and the URL test can never
3600 # succeed even when the operator set LDR_NOTIFICATIONS_ALLOW_OUTBOUND.
3601 notification_service = NotificationService(
3602 allow_private_ips=bool(
3603 get_env_setting("notifications.allow_private_ips", False)
3604 ),
3605 outbound_allowed=bool(
3606 get_env_setting("notifications.allow_outbound", False)
3607 ),
3608 )
3609 result = await asyncio.to_thread(
3610 notification_service.test_service, service_url
3611 )
3613 # Only return expected fields to prevent information leakage
3614 return {
3615 "success": result.get("success", False),
3616 "message": result.get("message", ""),
3617 "error": result.get("error", ""),
3618 }
3620 except Exception:
3621 logger.exception("Error testing notification URL")
3622 return JSONResponse(
3623 {
3624 "success": False,
3625 "error": "Failed to test notification service. Check logs for details.",
3626 },
3627 status_code=500,
3628 )
3631# =============================================================================
3632# Catch-all {key} routes — MUST be last so they don't shadow specific routes
3633# =============================================================================
3636@router.get("/api/{key}")
3637def api_get_db_setting(
3638 request: Request,
3639 key,
3640 username: Annotated[str, Depends(require_auth)],
3641):
3642 """Get a specific setting by key from DB, falling back to defaults.
3644 Secret values (API keys, passwords, OAuth tokens) are redacted with the
3645 '[REDACTED]' sentinel — main does this (DataSanitizer.redact_value) so a
3646 single authenticated GET can't exfiltrate a plaintext credential. Safe to
3647 round-trip: the save path treats the sentinel as a no-op, so re-saving a
3648 redacted value never overwrites the stored credential.
3649 """
3650 try:
3651 with get_user_db_session(username) as db_session:
3652 settings_manager = get_settings_manager(db_session, username)
3654 db_setting = (
3655 db_session.query(Setting).filter(Setting.key == key).first()
3656 )
3658 if db_setting:
3659 from ...security.data_sanitizer import DataSanitizer
3661 # Overlay any LDR_* env-var override BEFORE redaction, so a
3662 # redacted secret still reflects the effective (env) value
3663 # rather than the stale DB row — same order main used
3664 # (_shape_single_effective_metadata, then redact once).
3665 effective_value, effective_editable = _apply_env_override(
3666 settings_manager,
3667 key,
3668 db_setting.value,
3669 db_setting.editable,
3670 )
3672 value = DataSanitizer.redact_value(
3673 db_setting.key, db_setting.ui_element, effective_value
3674 )
3675 setting_data = {
3676 "key": db_setting.key,
3677 "value": value,
3678 "type": db_setting.type
3679 if isinstance(db_setting.type, str)
3680 else db_setting.type.value,
3681 "name": db_setting.name,
3682 "description": db_setting.description,
3683 "category": db_setting.category,
3684 "ui_element": db_setting.ui_element,
3685 "options": db_setting.options,
3686 "min_value": db_setting.min_value,
3687 "max_value": db_setting.max_value,
3688 "step": db_setting.step,
3689 "visible": db_setting.visible,
3690 "editable": effective_editable,
3691 }
3692 # Operator-gate the "unprotected" egress escape hatch and
3693 # the "filesystem" PDF-storage option out of their
3694 # respective options lists unless explicitly enabled, and
3695 # normalise egress-scope's displayed value (#5148 /
3696 # 87537d9ec / fb49985aa).
3697 return _shape_pdf_storage_mode_setting(
3698 key, _shape_egress_scope_setting(key, setting_data)
3699 )
3701 default_meta = settings_manager.default_settings.get(key)
3702 if default_meta:
3703 from ...security.data_sanitizer import DataSanitizer
3705 # Same env-var overlay as the DB branch above — a default-
3706 # only key (no DB row yet) can still be pinned via LDR_*.
3707 effective_value, effective_editable = _apply_env_override(
3708 settings_manager,
3709 key,
3710 default_meta.get("value"),
3711 default_meta.get("editable", True),
3712 )
3714 default_value = DataSanitizer.redact_value(
3715 key,
3716 default_meta.get("ui_element", "text"),
3717 effective_value,
3718 )
3719 setting_data = {
3720 "key": key,
3721 "value": default_value,
3722 "type": default_meta.get("type", "APP"),
3723 "name": default_meta.get("name", key),
3724 "description": default_meta.get("description"),
3725 "category": default_meta.get("category"),
3726 "ui_element": default_meta.get("ui_element", "text"),
3727 "options": default_meta.get("options"),
3728 "min_value": default_meta.get("min_value"),
3729 "max_value": default_meta.get("max_value"),
3730 "step": default_meta.get("step"),
3731 "visible": default_meta.get("visible", True),
3732 "editable": effective_editable,
3733 }
3734 return _shape_pdf_storage_mode_setting(
3735 key, _shape_egress_scope_setting(key, setting_data)
3736 )
3738 return JSONResponse(
3739 {"error": f"Setting not found: {key}"}, status_code=404
3740 )
3741 except Exception:
3742 logger.exception(f"Error getting setting {key}")
3743 return JSONResponse(
3744 {"error": "Failed to retrieve settings"}, status_code=500
3745 )
3748@router.put("/api/{key}")
3749@settings_limit
3750async def api_update_setting(
3751 request: Request,
3752 key,
3753 username: Annotated[str, Depends(require_auth)],
3754):
3755 """Update a setting"""
3756 data = await request.json()
3757 if not isinstance(data, dict): 3757 ↛ 3758line 3757 didn't jump to line 3758 because the condition on line 3757 was never true
3758 return json_body_error("simple", "No data provided")
3759 return await run_db_sync(_api_update_setting_sync, data, key, username)
3762def _api_update_setting_sync(data, key, username):
3763 try:
3764 # Key PRESENCE, not truthiness. An *absent* "value" is still a 400,
3765 # but an explicit JSON null must stay distinguishable from it:
3766 # embeddings.openai.chunk_size is the one registered setting whose
3767 # default IS null ("use the provider default"), and the read side
3768 # already supports it (#5963).
3769 if "value" not in data:
3770 return JSONResponse({"error": "No value provided"}, status_code=400)
3771 value = data["value"]
3772 is_openai_chunk_size = key == "embeddings.openai.chunk_size"
3774 with get_user_db_session(username) as db_session:
3775 # Environment-locked settings (LDR_* env var override) are
3776 # rejected up front, before any DB lookup. Ported from Flask's
3777 # api_update_setting early guard (web/routes/settings_routes.py).
3778 # Without this, the write is still correctly blocked further
3779 # down by set_setting()/create_or_update_setting()'s own
3780 # _is_environment_locked() check — but that failure was
3781 # indistinguishable from any other and fell through to a
3782 # generic 500 "Failed to update setting {key}" instead of the
3783 # 403 naming the lock. Lost diagnostic + wrong status code, not
3784 # a data-exposure bug (main's contract restored here).
3785 settings_manager = get_settings_manager(db_session, username)
3786 if settings_manager.settings_locked:
3787 return JSONResponse(
3788 {"error": "Settings are locked"}, status_code=403
3789 )
3790 if settings_manager._is_environment_locked(
3791 key, "api_update_setting"
3792 ):
3793 return JSONResponse(
3794 {"error": f"Setting {key} is environment-locked"},
3795 status_code=403,
3796 )
3798 # embeddings.openai.chunk_size is a whole-number batch size. The
3799 # generic numeric validation below only checks type + min/max, so
3800 # a boolean (coerced to 1/0) or a non-integer float (5.7) would
3801 # silently persist and then fail inside the embedding provider at
3802 # request time. Validate ONCE, up front, against the REGISTERED
3803 # metadata — before the db_setting lookup, so the create path
3804 # (row missing after a DELETE) is guarded too, and so a drifted
3805 # row ui_element cannot change how the value is coerced (#5979).
3806 # A null is allowed through unvalidated: it is this setting's
3807 # registered default (#5963).
3808 if is_openai_chunk_size:
3809 registered_metadata = settings_manager.default_settings[key]
3810 if value is not None:
3811 raw_value = value
3812 value = coerce_setting_for_write(
3813 key=key,
3814 value=value,
3815 ui_element=str(registered_metadata["ui_element"]),
3816 )
3817 if (
3818 isinstance(raw_value, bool)
3819 or not isinstance(value, (int, float))
3820 or (isinstance(value, float) and not value.is_integer())
3821 or value < registered_metadata["min_value"]
3822 ):
3823 logger.warning(
3824 f"Validation failed for setting {key}: "
3825 "value must be a whole number at or above "
3826 f"{registered_metadata['min_value']}"
3827 )
3828 return JSONResponse(
3829 {"error": f"Invalid value for setting {key}"},
3830 status_code=400,
3831 )
3832 value = int(value)
3833 elif value is None:
3834 # Every other key keeps the pre-#5174 contract: an explicit
3835 # null is rejected exactly like an absent one.
3836 return JSONResponse(
3837 {"error": "No value provided"}, status_code=400
3838 )
3840 db_setting = (
3841 db_session.query(Setting).filter(Setting.key == key).first()
3842 )
3844 if db_setting:
3845 if not db_setting.editable:
3846 return JSONResponse(
3847 {"error": f"Setting {key} is not editable"},
3848 status_code=403,
3849 )
3851 # The redaction sentinel is a no-op for any sensitive
3852 # setting, and an empty string is a no-op only for password
3853 # inputs (which render blank, so an untouched field must not
3854 # wipe the secret). Companion to the same guard in
3855 # save_all_settings/save_settings. The idempotent 200 keeps
3856 # client-side save indicators from erroring (#5960).
3857 if _is_secret_empty_noop(
3858 key, db_setting.ui_element, value, db_setting.value
3859 ):
3860 logger.debug(
3861 f"Skipping sensitive value write for {key} via "
3862 "api_update_setting (no-op)"
3863 )
3864 return {
3865 "message": (
3866 f"Setting {key} unchanged "
3867 "(sensitive value not overwritten)"
3868 )
3869 }
3871 # An embedded sentinel is a corrupted edit rather than an
3872 # untouched round-trip, so it is a hard error here (400)
3873 # while the exact-match case above stays an idempotent 200
3874 # no-op (#5947).
3875 if _embeds_redaction_sentinel(
3876 key, db_setting.ui_element, value, db_setting.value
3877 ):
3878 logger.warning(
3879 "Rejected redaction-sentinel value for {!r} via "
3880 "api_update_setting (user={!r})",
3881 key,
3882 username,
3883 )
3884 return JSONResponse(
3885 {
3886 "error": _redaction_sentinel_error(
3887 db_setting.ui_element
3888 )
3889 },
3890 status_code=400,
3891 )
3893 if value is not None and not is_openai_chunk_size:
3894 # Coerce to the correct Python type before saving (e.g.
3895 # string "5" -> int 5 for a number setting). chunk_size
3896 # was already coerced and bounds-checked above against
3897 # the registry, which is authoritative for a setting's
3898 # type; the row is only data.
3899 value = coerce_setting_for_write(
3900 key=db_setting.key,
3901 value=value,
3902 ui_element=db_setting.ui_element,
3903 )
3905 is_valid, error_message = validate_setting(
3906 db_setting, value
3907 )
3908 if not is_valid:
3909 logger.warning(
3910 f"Validation failed for setting {key}: "
3911 f"{error_message}"
3912 )
3913 return JSONResponse(
3914 {"error": f"Invalid value for setting {key}"},
3915 status_code=400,
3916 )
3918 # Cross-field egress-policy validation. The full-form saves run
3919 # these guards unconditionally for every key; this single-key
3920 # PUT previously restricted the call to a 4-key allowlist
3921 # (policy.egress_scope / search.tool /
3922 # llm.allowed_local_hostnames / policy.trusted_search_engines),
3923 # which let an SSRF-shaped value through on any key the
3924 # allowlist omitted — notably
3925 # search.engine.web.searxng.default_params.instance_url. Run
3926 # it for every key, matching main
3927 # (web/routes/settings_routes.py, 87537d9ec).
3928 _all_db_settings = {
3929 s.key: s for s in db_session.query(Setting).all()
3930 }
3931 _err = first_egress_validation_error(
3932 {key: value}, _all_db_settings
3933 )
3934 if _err is not None:
3935 logger.bind(policy_audit=True).warning(
3936 "egress-policy setting rejected at api_update_setting",
3937 key=key,
3938 reason=_err.get("error"),
3939 )
3940 return JSONResponse(
3941 {"error": _err["error"]}, status_code=400
3942 )
3944 success = set_setting(key, value, db_session=db_session)
3945 if success:
3946 invalidate_settings_caches(username)
3947 # A document_scheduler.* toggle (e.g.
3948 # sweep_library_collections or generate_rag) must take
3949 # effect without a re-login.
3950 reschedule_document_jobs_if_needed(username, [key])
3951 reschedule_zotero_jobs_if_needed(username, [key])
3952 response_data: dict[str, Any] = {
3953 "message": f"Setting {key} updated successfully"
3954 }
3956 if key in WARNING_AFFECTING_KEYS:
3957 warnings = calculate_warnings(username=username)
3958 response_data["warnings"] = warnings
3959 logger.debug(
3960 f"Setting {key} changed to {value}, calculated {len(warnings)} warnings"
3961 )
3963 return response_data
3964 return JSONResponse(
3965 {"error": f"Failed to update setting {key}"},
3966 status_code=500,
3967 )
3968 # Registered settings may be absent after DELETE. Recreate them
3969 # from trusted default metadata (type/options/min_value/
3970 # max_value/step/ui_element/editable) instead of the
3971 # caller-supplied request body, so callers cannot replace those
3972 # by racing a delete+recreate. Building setting_dict from
3973 # data[...] alone (the prior behaviour here) let a DELETE+PUT
3974 # round trip silently drop min/max bounds and degrade a
3975 # "number" setting to "text", after which even the
3976 # properly-validating bulk path accepted out-of-range values.
3977 # Genuinely custom keys (no registered default) keep the
3978 # namespace-prefix contract below. Ported from main
3979 # (web/routes/settings_routes.py, 87537d9ec).
3980 default_meta = settings_manager.default_settings.get(key)
3981 if default_meta is not None:
3982 if not default_meta.get("editable", True): 3982 ↛ 3983line 3982 didn't jump to line 3983 because the condition on line 3982 was never true
3983 return JSONResponse(
3984 {"error": f"Setting {key} is not editable"},
3985 status_code=403,
3986 )
3987 default_ui = str(default_meta.get("ui_element", "text"))
3988 if value is not None:
3989 value = coerce_setting_for_write(
3990 key=key, value=value, ui_element=default_ui
3991 )
3992 _validation_setting = SimpleNamespace(
3993 key=key,
3994 ui_element=default_ui,
3995 options=default_meta.get("options"),
3996 min_value=default_meta.get("min_value"),
3997 max_value=default_meta.get("max_value"),
3998 )
3999 is_valid, error_message = validate_setting(
4000 _validation_setting, value
4001 )
4002 if not is_valid: 4002 ↛ 4003line 4002 didn't jump to line 4003 because the condition on line 4002 was never true
4003 logger.warning(
4004 "Validation failed for recreated setting {}: {}",
4005 key,
4006 error_message,
4007 )
4008 return JSONResponse(
4009 {"error": f"Invalid value for setting {key}"},
4010 status_code=400,
4011 )
4012 setting_dict = dict(default_meta)
4013 setting_dict.update({"key": key, "value": value})
4014 default_type = setting_dict.get("type")
4015 if ( 4015 ↛ 4064line 4015 didn't jump to line 4064 because the condition on line 4015 was always true
4016 isinstance(default_type, str)
4017 and default_type in SettingType.__members__
4018 ):
4019 setting_dict["type"] = SettingType[default_type]
4020 else:
4021 # Namespace validation: reject new keys outside allowed
4022 # prefixes.
4023 if not _is_allowed_new_setting_key(key):
4024 logger.warning(
4025 "Security: Rejected setting outside allowed "
4026 "namespaces: {!r} (user={!r})",
4027 key,
4028 username,
4029 )
4030 return JSONResponse(
4031 {"error": _new_key_rejection_reason(key)},
4032 status_code=400,
4033 )
4035 setting_dict = {
4036 "key": key,
4037 "value": value,
4038 "name": key.split(".")[-1].replace("_", " ").title(),
4039 "description": f"Setting for {key}",
4040 }
4042 # Add additional metadata if provided.
4043 # 'visible' and 'editable' are system-controlled — not
4044 # accepted from callers.
4045 for field in [
4046 "type",
4047 "name",
4048 "description",
4049 "category",
4050 "ui_element",
4051 "options",
4052 "min_value",
4053 "max_value",
4054 "step",
4055 ]:
4056 if field in data:
4057 setting_dict[field] = data[field]
4059 # Creation has no prior value, so the sentinel cannot mean
4060 # "keep the stored secret" the way it does on the update path —
4061 # every occurrence of it, exact match included, is a corrupted
4062 # client value that would be stored verbatim as the credential
4063 # (#5947).
4064 _create_ui = setting_dict.get("ui_element")
4065 if _embeds_sentinel_on_create(key, _create_ui, value):
4066 logger.warning(
4067 "Rejected redaction-sentinel value for {!r} via "
4068 "api_update_setting create (user={!r})",
4069 key,
4070 username,
4071 )
4072 return JSONResponse(
4073 {
4074 "error": _redaction_sentinel_error(
4075 _create_ui if isinstance(_create_ui, str) else None
4076 )
4077 },
4078 status_code=400,
4079 )
4081 # Apply egress validation to creation as well as updates.
4082 # Otherwise DELETE followed by PUT recreates a governed key with
4083 # no guards: `llm.allowed_local_hostnames` is `editable`, so the
4084 # delete endpoint accepts it, and `llm.` is an allowed prefix, so
4085 # re-creation passes the namespace check. A public host smuggled
4086 # into that key is then read into EgressContext.local_hostnames
4087 # and classified LOCAL, laundering it past private_only and
4088 # require_local_llm.
4089 #
4090 # Ported from main (web/routes/settings_routes.py), which runs
4091 # this at FOUR sites; this port had three. The update branch ~40
4092 # lines above already carries the guard and its comment claims
4093 # the hole is closed — it was closed for update only.
4094 _all_db_settings = {
4095 s.key: s for s in db_session.query(Setting).all()
4096 }
4097 _err = first_egress_validation_error({key: value}, _all_db_settings)
4098 if _err is not None:
4099 logger.bind(policy_audit=True).warning(
4100 "egress-policy setting rejected at api_update_setting create",
4101 key=key,
4102 reason=_err.get("error"),
4103 )
4104 return JSONResponse({"error": _err["error"]}, status_code=400)
4106 db_setting = create_or_update_setting(
4107 setting_dict, db_session=db_session
4108 )
4110 if db_setting:
4111 invalidate_settings_caches(username)
4112 reschedule_document_jobs_if_needed(username, [key])
4113 reschedule_zotero_jobs_if_needed(username, [key])
4114 from ...security.data_sanitizer import DataSanitizer
4116 return JSONResponse(
4117 {
4118 "message": f"Setting {key} created successfully",
4119 "setting": {
4120 "key": db_setting.key,
4121 # Don't echo a freshly-created password back in
4122 # plaintext — redact like every other settings
4123 # response that ships to the browser.
4124 "value": (
4125 DataSanitizer.REDACTION_TEXT
4126 if DataSanitizer.is_sensitive_setting(
4127 key, db_setting.ui_element
4128 )
4129 else db_setting.value
4130 ),
4131 "type": db_setting.type.value,
4132 "name": db_setting.name,
4133 },
4134 },
4135 status_code=201,
4136 )
4137 return JSONResponse(
4138 {"error": f"Failed to create setting {key}"},
4139 status_code=500,
4140 )
4141 except Exception:
4142 logger.exception(f"Error updating setting {key}")
4143 return JSONResponse(
4144 {"error": "Failed to update setting"}, status_code=500
4145 )
4148@router.delete("/api/{key}")
4149@settings_limit
4150def api_delete_setting(
4151 request: Request,
4152 key,
4153 username: Annotated[str, Depends(require_auth)],
4154):
4155 """Delete a setting"""
4156 try:
4157 with get_user_db_session(username) as db_session:
4158 settings_manager = get_settings_manager(db_session, username)
4160 # Settings lock (app.lock_settings) -- ported from main's
4161 # api_delete_setting (#5659, "enforce the settings lock on delete,
4162 # import and reset"). SettingsManager already refuses when
4163 # locked, so the write cannot happen either way; this repeats
4164 # the check at the route so a locked instance answers 403
4165 # rather than 200 with nothing written, which is what main's
4166 # own comment gives as the reason. Without it the merge that
4167 # brought #5659 in would have silently dropped the fix, since
4168 # it landed in a Flask file this migration deletes.
4169 if settings_manager.settings_locked:
4170 return JSONResponse(
4171 {"error": "Settings are locked"},
4172 status_code=403,
4173 )
4175 # Environment-locked settings (LDR_* env var override) are
4176 # rejected up front — see the matching guard in
4177 # _api_update_setting_sync above for the full rationale. Checked
4178 # before the existence lookup so an env-locked key still reports
4179 # 403 (not 404) even if a client races a delete against a key
4180 # that hasn't been materialized into a DB row yet, matching
4181 # main's contract (web/routes/settings_routes.py).
4182 if settings_manager._is_environment_locked(
4183 key, "api_delete_setting"
4184 ):
4185 return JSONResponse(
4186 {"error": f"Setting {key} is environment-locked"},
4187 status_code=403,
4188 )
4190 db_setting = (
4191 db_session.query(Setting).filter(Setting.key == key).first()
4192 )
4193 if not db_setting:
4194 return JSONResponse(
4195 {"error": f"Setting not found: {key}"}, status_code=404
4196 )
4198 if not db_setting.editable:
4199 return JSONResponse(
4200 {"error": f"Setting {key} is not editable"},
4201 status_code=403,
4202 )
4204 success = settings_manager.delete_setting(key)
4205 if success:
4206 invalidate_settings_caches(username)
4207 reschedule_document_jobs_if_needed(username, [key])
4208 reschedule_zotero_jobs_if_needed(username, [key])
4209 return {"message": f"Setting {key} deleted successfully"}
4210 return JSONResponse(
4211 {"error": f"Failed to delete setting {key}"}, status_code=500
4212 )
4213 except Exception:
4214 logger.exception(f"Error deleting setting {key}")
4215 return JSONResponse(
4216 {"error": "Failed to delete setting"}, status_code=500
4217 )