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

1""" 

2Settings Routes Module 

3 

4This module handles all settings-related HTTP endpoints for the application. 

5 

6CHECKBOX HANDLING PATTERN: 

7-------------------------- 

8This module supports TWO submission modes to handle checkboxes correctly: 

9 

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 

16 

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) 

24 

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 

31 

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 

36 

37This dual-mode approach ensures the app works for all users while providing 

38optimal experience when JavaScript is available. 

39""" 

40 

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 

54 

55import math 

56import platform 

57import time 

58from types import SimpleNamespace 

59from typing import Any, Optional, Tuple, Annotated 

60from datetime import UTC, datetime, timedelta, timezone 

61 

62import requests 

63 

64from loguru import logger 

65from sqlalchemy.orm import Session 

66 

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 

93 

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) 

107 

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 

112 

113# Create the router for settings 

114router = APIRouter(prefix="/settings", tags=["settings"]) 

115 

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. 

119 

120# Settings with dynamically populated options (excluded from validation) 

121DYNAMIC_SETTINGS = ["llm.provider", "llm.model", "search.tool"] 

122 

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) 

138 

139 

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. 

145 

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``. 

154 

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 

180 

181 

182def _shape_pdf_storage_mode_setting(key: str, metadata: Any) -> Any: 

183 """Hide the operator-gated unencrypted 'filesystem' PDF storage option. 

184 

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 ) 

209 

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 

222 

223 

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. 

229 

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 ) 

249 

250 

251def _filter_editable_settings(form_data: dict, db_session: Session) -> dict: 

252 """Remove operator-locked and non-editable keys from *form_data* in place. 

253 

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). 

261 

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 } 

268 

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] 

282 

283 return all_db_settings 

284 

285 

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. 

290 

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. 

295 

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 

321 

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 ) 

327 

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 ) 

347 

348 

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 

369 

370 

371def _get_setting_from_session(key: str | None, username: str, default=None): 

372 """Helper to get a setting using the current session context. 

373 

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 

388 

389 

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. 

395 

396 Args: 

397 setting: The Setting object to validate against 

398 value: The value to validate 

399 

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 

407 

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 ) 

416 

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" 

422 

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 

431 

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" 

440 

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}" 

446 

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 ) 

461 

462 # All checks passed 

463 return True, None 

464 

465 

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. 

468 

469 All web routes that save settings should use this function to ensure 

470 consistent type conversion. 

471 

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 

493 

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 

514 

515 

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) 

558 

559 

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. 

563 

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 

583 

584 

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. 

592 

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 

615 

616 

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. 

624 

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. 

635 

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 

667 

668 

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). 

678 

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). 

683 

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. 

696 

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 

731 

732 

733def _redaction_sentinel_error(ui_element: str | None) -> str: 

734 """Explain the 400 raised for a value that embeds the redaction sentinel. 

735 

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." 

759 

760 

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. 

768 

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. 

781 

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: 

785 

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. 

802 

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 

826 

827 

828def _embeds_sentinel_on_create( 

829 key: str, ui_element: object, value: object 

830) -> bool: 

831 """Sentinel check for the CREATE paths. 

832 

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 

851 

852 

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 

869 

870 

871def _new_key_rejection_reason(key) -> str: 

872 """Explain why ``_is_allowed_new_setting_key`` rejected *key*. 

873 

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}" 

881 

882 

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 ) 

893 

894 

895def _save_all_settings_sync(form_data: dict, username: str): 

896 """Synchronous body of save_all_settings. 

897 

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 

904 

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 ) 

915 

916 # Track validation errors 

917 validation_errors = [] 

918 settings_by_type: dict[str, Any] = {} 

919 

920 # Track changes for logging 

921 updated_settings = [] 

922 created_settings = [] 

923 

924 # Store original values for better messaging 

925 original_values = {} 

926 

927 # Fetch all settings and remove non-editable keys 

928 all_db_settings = _filter_editable_settings(form_data, db_session) 

929 

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 ) 

946 

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 

952 

953 # Get the setting metadata from pre-fetched dict 

954 current_setting = all_db_settings.get(key) 

955 

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 

985 

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 

1012 

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 ) 

1024 

1025 # Store original value for messaging 

1026 if current_setting: 

1027 original_values[key] = current_setting.value 

1028 

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 

1066 

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 

1096 

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. 

1108 

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 ) 

1117 

1118 # Validate the setting 

1119 is_valid, error_message = validate_setting( 

1120 current_setting, converted_value 

1121 ) 

1122 

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) 

1136 

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 

1170 

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 

1193 

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 } 

1206 

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" 

1216 

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 ) 

1221 

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 ) 

1236 

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 ) 

1249 

1250 # All settings validated: commit the batch atomically. 

1251 db_session.commit() 

1252 

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 ) 

1260 

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 

1268 

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 } 

1283 

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 

1302 

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 ) 

1315 

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 ) 

1326 

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 ) 

1339 

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 ) 

1347 

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)" 

1363 

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 } 

1377 

1378 warning_affecting_keys = WARNING_AFFECTING_KEYS 

1379 

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 ) 

1390 

1391 return response_data 

1392 

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 ) 

1402 

1403 

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. 

1411 

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) 

1424 

1425 

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. 

1433 

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) 

1441 

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 ) 

1459 

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 

1472 

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) 

1485 

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() 

1496 

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) 

1504 

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 ) 

1514 

1515 return { 

1516 "status": "success", 

1517 "message": "All settings have been reset to default values", 

1518 } 

1519 

1520 

1521def _save_settings_sync(form_data: dict, username: str) -> dict: 

1522 """Sync helper for save_settings — runs the bulk setting writes. 

1523 

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) 

1532 

1533 # Fetch all settings and remove non-editable keys 

1534 all_db_settings = _filter_editable_settings(form_data, db_session) 

1535 

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 } 

1554 

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) 

1562 

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 

1576 

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 

1594 

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 

1610 

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 

1634 

1635 value = coerce_setting_for_write( 

1636 key=db_setting.key, 

1637 value=value, 

1638 ui_element=db_setting.ui_element, 

1639 ) 

1640 

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 

1662 

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 

1671 

1672 if rejected_count: 

1673 logger.warning( 

1674 f"Rejected {rejected_count} new setting(s) outside " 

1675 "allowed namespaces" 

1676 ) 

1677 

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 } 

1693 

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 } 

1701 

1702 

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 

1712 

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") 

1720 

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") 

1760 

1761 return RedirectResponse(url="/settings/", status_code=302) 

1762 

1763 

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") 

1774 

1775 with get_user_db_session(username) as db_session: 

1776 settings_manager = get_settings_manager(db_session, username) 

1777 

1778 # Get settings 

1779 settings = settings_manager.get_all_settings() 

1780 

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 ] 

1789 

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 } 

1796 

1797 settings = filtered_settings 

1798 

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 ) 

1806 

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 ) 

1817 

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 

1827 

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 ) 

1835 

1836 

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) 

1847 

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 ) 

1862 

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) 

1870 

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 ) 

1880 

1881 

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] 

1893 

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 ) 

1900 

1901 

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 ) 

1916 

1917 

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 ] 

1937 

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 ) 

1944 

1945 

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 

1955 

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 ) 

1962 

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 ) 

1968 

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 

1979 

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) 

1997 

1998 # Add remaining hardcoded providers (complex local providers not yet migrated) 

1999 

2000 # Available models by provider 

2001 providers: dict[str, Any] = {} 

2002 

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) 

2008 

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 ) 

2016 

2017 if cached_models: 

2018 logger.info( 

2019 f"Found {len(cached_models)} cached models in database" 

2020 ) 

2021 

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] = [] 

2036 

2037 providers[provider_key].append( 

2038 { 

2039 "value": model.model_key, 

2040 "label": model.model_label, 

2041 "provider": model.provider.upper(), 

2042 } 

2043 ) 

2044 

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 } 

2055 

2056 except PolicyDeniedError: 

2057 raise 

2058 except Exception: 

2059 logger.warning("Error reading cached models from database") 

2060 # Continue to fetch fresh data 

2061 

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. 

2068 

2069 # Fetch models from auto-discovered providers 

2070 from ...llm.providers import discover_providers 

2071 

2072 discovered_providers = discover_providers() 

2073 

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 ) 

2102 

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 ) 

2109 

2110 # Get the provider class 

2111 provider_class = provider_info.provider_class 

2112 

2113 # Get API key if configured 

2114 api_key = _get_setting_from_session( 

2115 provider_class.api_key_setting, username, "" 

2116 ) 

2117 

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 ) 

2125 

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 

2134 

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 ) 

2155 

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 ) 

2167 

2168 logger.info( 

2169 f"Successfully fetched {len(provider_models)} models from {provider_info.provider_name}" 

2170 ) 

2171 

2172 except PolicyDeniedError: 

2173 raise 

2174 except Exception: 

2175 logger.exception( 

2176 f"Error getting {provider_info.provider_name} models" 

2177 ) 

2178 

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 ) 

2186 

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() 

2208 

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) 

2227 

2228 db_session.commit() 

2229 logger.info("Successfully cached models to database") 

2230 

2231 except Exception: 

2232 logger.exception("Error saving models to database cache") 

2233 db_session.rollback() 

2234 

2235 # Return all options 

2236 _log_available_models_duration(endpoint_start, cache_hit=False) 

2237 return {"provider_options": provider_options, "providers": providers} 

2238 

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 ) 

2267 

2268 

2269def _log_available_models_duration( 

2270 start: float, cache_hit: bool, error: bool = False 

2271) -> None: 

2272 """Log /api/available-models endpoint duration. 

2273 

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") 

2289 

2290 

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. 

2296 

2297 Args: 

2298 engine_data: Engine configuration dictionary 

2299 engine_class: Optional loaded engine class to check attributes 

2300 

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) 

2317 

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) 

2323 

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" 

2339 

2340 

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. 

2356 

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. 

2361 

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. 

2368 

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 

2384 

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 

2395 

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 

2403 

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 

2451 

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"} 

2469 

2470 

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. 

2477 

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. 

2485 

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 ) 

2504 

2505 with get_user_db_session(username) as db_session: 

2506 settings_manager = get_settings_manager(db_session, username) 

2507 

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 ) 

2518 

2519 search_engines = search_config( 

2520 username=username, db_session=db_session 

2521 ) 

2522 

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 = [] 

2527 

2528 # Extract search engines from config 

2529 engines_dict = {} 

2530 engine_options = [] 

2531 

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 ) 

2538 

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 ) 

2558 

2559 # Get icon and category from engine attributes 

2560 icon, category = _get_engine_icon_and_category( 

2561 engine_data, engine_class 

2562 ) 

2563 

2564 # Check if engine requires an API key 

2565 requires_api_key = engine_data.get( 

2566 "requires_api_key", False 

2567 ) 

2568 

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)" 

2575 

2576 # Check if engine is a favorite 

2577 is_favorite = engine_id in favorites 

2578 

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) 

2588 

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 } 

2598 

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 ) 

2627 

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 ) 

2636 

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") 

2640 

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 ) 

2662 

2663 return { 

2664 "engines": engines_dict, 

2665 "engine_options": engine_options, 

2666 "favorites": favorites, 

2667 } 

2668 

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 ) 

2674 

2675 

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} 

2689 

2690 except Exception: 

2691 logger.exception("Error getting search favorites") 

2692 return JSONResponse( 

2693 {"error": "Failed to retrieve favorites"}, status_code=500 

2694 ) 

2695 

2696 

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") 

2707 

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 ) 

2715 

2716 if not isinstance(favorites, list): 

2717 return JSONResponse( 

2718 {"error": "Favorites must be a list"}, status_code=400 

2719 ) 

2720 

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 } 

2733 

2734 return JSONResponse( 

2735 {"error": "Failed to update favorites"}, status_code=500 

2736 ) 

2737 

2738 except Exception: 

2739 logger.exception("Error updating search favorites") 

2740 return JSONResponse( 

2741 {"error": "Failed to update favorites"}, status_code=500 

2742 ) 

2743 

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) 

2748 

2749 

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") 

2760 

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 ) 

2768 

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 ) 

2775 

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) 

2783 

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 

2792 

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 } 

2802 

2803 return JSONResponse( 

2804 {"error": "Failed to toggle favorite"}, status_code=500 

2805 ) 

2806 

2807 except Exception: 

2808 logger.exception("Error toggling search favorite") 

2809 return JSONResponse( 

2810 {"error": "Failed to toggle favorite"}, status_code=500 

2811 ) 

2812 

2813 return await run_db_sync(_impl) 

2814 

2815 

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) 

2823 

2824 

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) 

2831 

2832 

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) 

2839 

2840 

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) 

2847 

2848 

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) 

2855 

2856 

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. 

2862 

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 ) 

2873 

2874 

2875# CSRF token is injected globally via fastapi_app template globals 

2876 

2877 

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 

2893 

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] 

2902 

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 ) 

2911 

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) 

2917 

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 

2923 

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 

2945 

2946 # Skip if not corrupted 

2947 if not is_corrupted: 

2948 continue 

2949 

2950 # Get default value from migrations 

2951 # Import commented out as it's not directly used 

2952 # from ...database.migrations import setup_predefined_settings 

2953 

2954 default_value: Any = None 

2955 

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 

3009 

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) 

3019 

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) 

3040 

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 } 

3048 

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 ) 

3058 

3059 

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 ) 

3073 

3074 

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 

3082 

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) 

3085 

3086 from ...utilities.formatting import human_size 

3087 

3088 backup_dir = get_user_backup_directory(username) 

3089 

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 

3094 

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 

3117 

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"] 

3122 

3123 backup_enabled = _get_setting_from_session( 

3124 "backup.enabled", username, True 

3125 ) 

3126 

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 } 

3134 

3135 except Exception: 

3136 logger.exception("Error getting backup status") 

3137 return JSONResponse( 

3138 {"error": "Failed to retrieve backup status"}, status_code=500 

3139 ) 

3140 

3141 

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 ) 

3155 

3156 response = safe_get( 

3157 f"{base_url}/api/version", 

3158 timeout=2, 

3159 allow_localhost=True, 

3160 allow_private_ips=True, 

3161 ) 

3162 

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 } 

3172 

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 } 

3179 

3180 

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 } 

3213 

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 ) 

3220 

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 ) 

3234 

3235 return {"status": status, "engines": engines} 

3236 

3237 except Exception: 

3238 logger.exception("Error getting rate limiting status") 

3239 return JSONResponse( 

3240 {"error": "An internal error occurred"}, status_code=500 

3241 ) 

3242 

3243 

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. 

3251 

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() 

3265 

3266 return {"message": f"Rate limiting data reset for {engine_type}"} 

3267 

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 ) 

3273 

3274 

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() 

3282 

3283 

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. 

3289 

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). 

3292 

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 

3307 

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 ) 

3318 

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) 

3326 

3327 return { 

3328 "message": f"Cleaned up rate limiting data older than {days} days" 

3329 } 

3330 

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 ) 

3336 

3337 

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 ] 

3360 

3361 # Fetch all settings at once 

3362 from ...security.data_sanitizer import DataSanitizer 

3363 

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 } 

3382 

3383 return {"success": True, "settings": result} 

3384 

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 ) 

3391 

3392 

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() 

3403 

3404 # Check if LDR_DATA_DIR environment variable is set 

3405 from local_deep_research.settings.manager import SettingsManager 

3406 

3407 settings_manager = SettingsManager() 

3408 custom_data_dir = settings_manager.get_setting("bootstrap.data_dir") 

3409 

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 } 

3416 

3417 # Current platform 

3418 current_platform = platform.system() 

3419 if current_platform == "Darwin": 

3420 current_platform = "macOS" 

3421 

3422 # Get SQLCipher settings from environment 

3423 from ...database.sqlcipher_utils import get_sqlcipher_settings 

3424 

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 ) 

3430 

3431 cipher_settings = ( 

3432 get_sqlcipher_settings() if db_manager.has_encryption else {} 

3433 ) 

3434 

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 } 

3458 

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 ) 

3464 

3465 

3466def _is_blank_service_url(value) -> bool: 

3467 """True when a notification URL counts as unset. 

3468 

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 

3477 

3478 

3479async def _notification_test_body(request: Request) -> dict: 

3480 """Parse the test-url body and stash it for the rate-limit predicate. 

3481 

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. 

3485 

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 

3499 

3500 

3501def _caller_supplied_notification_url(request: Request) -> bool: 

3502 """True when the test-url request names its own destination. 

3503 

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 

3516 

3517 

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) 

3529 

3530 

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. 

3540 

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. 

3548 

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. 

3558 

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 ) 

3585 

3586 try: 

3587 from ...notifications.service import NotificationService 

3588 

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 

3595 

3596 from ...settings.env_registry import get_env_setting 

3597 

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 ) 

3612 

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 } 

3619 

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 ) 

3629 

3630 

3631# ============================================================================= 

3632# Catch-all {key} routes — MUST be last so they don't shadow specific routes 

3633# ============================================================================= 

3634 

3635 

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. 

3643 

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) 

3653 

3654 db_setting = ( 

3655 db_session.query(Setting).filter(Setting.key == key).first() 

3656 ) 

3657 

3658 if db_setting: 

3659 from ...security.data_sanitizer import DataSanitizer 

3660 

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 ) 

3671 

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 ) 

3700 

3701 default_meta = settings_manager.default_settings.get(key) 

3702 if default_meta: 

3703 from ...security.data_sanitizer import DataSanitizer 

3704 

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 ) 

3713 

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 ) 

3737 

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 ) 

3746 

3747 

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) 

3760 

3761 

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" 

3773 

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 ) 

3797 

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 ) 

3839 

3840 db_setting = ( 

3841 db_session.query(Setting).filter(Setting.key == key).first() 

3842 ) 

3843 

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 ) 

3850 

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 } 

3870 

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 ) 

3892 

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 ) 

3904 

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 ) 

3917 

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 ) 

3943 

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 } 

3955 

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 ) 

3962 

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 ) 

4034 

4035 setting_dict = { 

4036 "key": key, 

4037 "value": value, 

4038 "name": key.split(".")[-1].replace("_", " ").title(), 

4039 "description": f"Setting for {key}", 

4040 } 

4041 

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] 

4058 

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 ) 

4080 

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) 

4105 

4106 db_setting = create_or_update_setting( 

4107 setting_dict, db_session=db_session 

4108 ) 

4109 

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 

4115 

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 ) 

4146 

4147 

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) 

4159 

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 ) 

4174 

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 ) 

4189 

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 ) 

4197 

4198 if not db_setting.editable: 

4199 return JSONResponse( 

4200 {"error": f"Setting {key} is not editable"}, 

4201 status_code=403, 

4202 ) 

4203 

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 )