Coverage for src/local_deep_research/web/routes/settings_routes.py: 91%

939 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +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 

41import platform 

42import time 

43from typing import Any, Optional 

44from datetime import UTC, datetime, timedelta, timezone 

45 

46import requests 

47from flask import ( 

48 Blueprint, 

49 flash, 

50 jsonify, 

51 redirect, 

52 request, 

53 session, 

54 url_for, 

55) 

56from flask_wtf.csrf import generate_csrf 

57from loguru import logger 

58from sqlalchemy.orm import Session 

59 

60from ...config.constants import DEFAULT_OLLAMA_URL 

61from ...llm.providers.base import normalize_provider 

62from ...config.paths import get_data_directory, get_encrypted_database_path 

63from ...database.models import RateLimitEstimate, Setting, SettingType 

64from ...database.session_context import get_user_db_session 

65from ...database.encrypted_db import db_manager 

66from ...utilities.db_utils import get_settings_manager 

67from ...utilities.url_utils import normalize_url 

68from ...security.decorators import require_json_body 

69from ...security.egress.policy import DEFAULT_EGRESS_SCOPE 

70from ...security.egress.validators import ( 

71 first_egress_validation_error, 

72) 

73from ..auth.decorators import login_required 

74from ..utils.request_helpers import parse_bool_arg 

75from ...security.rate_limiter import settings_limit 

76from ...settings.manager import ( 

77 get_typed_setting_value, 

78 is_valid_setting_key, 

79 parse_boolean, 

80) 

81from ..services.settings_service import ( 

82 DYNAMIC_SETTINGS, # noqa: F401 — re-exported for tests that import from routes 

83 create_or_update_setting, 

84 invalidate_settings_caches, 

85 reschedule_document_jobs_if_needed, 

86 reschedule_zotero_jobs_if_needed, 

87 set_setting, 

88 validate_setting, 

89) 

90from ..utils.route_decorators import with_user_session 

91from ..utils.templates import render_template_with_defaults 

92 

93 

94from ...security import safe_get 

95from ..warning_checks import calculate_warnings 

96from ...constants import DEFAULT_SEARCH_TOOL 

97 

98# Settings whose changes trigger a warning recalculation. 

99WARNING_AFFECTING_KEYS = frozenset( 

100 [ 

101 "llm.provider", 

102 "search.tool", 

103 "search.iterations", 

104 "search.questions_per_iteration", 

105 "llm.local_context_window_size", 

106 "llm.context_window_unrestricted", 

107 "llm.context_window_size", 

108 "policy.egress_scope", 

109 "llm.require_local_endpoint", 

110 "embeddings.require_local", 

111 ] 

112) 

113 

114# Create a Blueprint for settings 

115settings_bp = Blueprint("settings", __name__, url_prefix="/settings") 

116 

117 

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

119 """Remove non-editable keys from *form_data* in place. 

120 

121 Returns a dict of *all* ``{key: Setting}`` records from the database 

122 so callers can reuse it for further validation (e.g. egress-policy checks). 

123 """ 

124 all_db_settings = { 

125 setting.key: setting for setting in db_session.query(Setting).all() 

126 } 

127 

128 non_editable_keys = [ 

129 key 

130 for key in form_data.keys() 

131 if key in all_db_settings and not all_db_settings[key].editable 

132 ] 

133 if non_editable_keys: 

134 logger.warning("Skipping non-editable settings: {}", non_editable_keys) 

135 for key in non_editable_keys: 

136 del form_data[key] 

137 

138 return all_db_settings 

139 

140 

141# NOTE: Routes use session["username"] (not .get()) intentionally. 

142# @login_required guarantees the key exists; direct access fails fast 

143# if the decorator is ever removed. 

144 

145# Namespace validation for new setting creation via the web API. 

146# Keys starting with any ALLOWED prefix may be created; any prefix in 

147# BLOCKED takes precedence and is rejected even if it also matches an 

148# allowed prefix. Existing keys (updates) bypass this check — it only 

149# applies to creation of new DB rows through the three write routes. 

150ALLOWED_SETTING_PREFIXES = frozenset( 

151 { 

152 "app.", 

153 "backup.", 

154 "benchmark.", 

155 "chat.", 

156 "database.", 

157 "document_scheduler.", 

158 "embeddings.", 

159 "focused_iteration.", 

160 "general.", 

161 "langgraph_agent.", 

162 "llm.", 

163 "local_search_", 

164 "news.", 

165 "notifications.", 

166 "rag.", 

167 "rate_limiting.", 

168 "report.", 

169 "research_library.", 

170 "search.", 

171 "ui.", 

172 "web.", 

173 "zotero.", 

174 } 

175) 

176BLOCKED_SETTING_PREFIXES = frozenset( 

177 { 

178 "auth.", 

179 "bootstrap.", 

180 "db_config.", 

181 "security.", 

182 "server.", 

183 "testing.", 

184 } 

185) 

186 

187 

188def _is_allowed_new_setting_key(key: str) -> bool: 

189 """Return True if *key* is permitted to be created via the web API.""" 

190 # Reject malformed keys (blank, trailing/leading dot, empty ".." segment, 

191 # stray whitespace) before the namespace check — a trailing-dot key such 

192 # as ``local_search_chunk_size.`` otherwise passes the prefix allow-list 

193 # and corrupts prefix lookups (see #4840). 

194 if not is_valid_setting_key(key): 

195 return False 

196 key = key.lower() 

197 for prefix in BLOCKED_SETTING_PREFIXES: 

198 if key.startswith(prefix): 

199 return False 

200 for prefix in ALLOWED_SETTING_PREFIXES: 

201 if key.startswith(prefix): 

202 return True 

203 return False 

204 

205 

206def _new_key_rejection_reason(key) -> str: 

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

208 

209 Distinguishes a malformed key (bad syntax) from an allowed-namespace 

210 violation so an API consumer with, say, a trailing-dot key (#4840) is 

211 pointed at the real problem instead of being told it's a namespace issue. 

212 """ 

213 if not is_valid_setting_key(key): 

214 return f"Setting key is malformed: {key!r}" 

215 return f"Creating settings under this namespace is not allowed: {key}" 

216 

217 

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

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

220 

221 A ``None`` key returns ``default``. ``SettingsManager.get_setting`` 

222 treats ``key=None`` as "return all settings" (a real feature used by 

223 ``get_all_settings`` for enumeration); this route helper is for 

224 fetching one named setting and must not inherit that bulk-read 

225 semantic. Without the guard, callers would receive a dict of every 

226 setting (including other providers' API keys) when a provider 

227 declares ``api_key_setting = None``. 

228 

229 NOTE: As of the LLM construction collapse (PR follow-up to #3957), 

230 LM Studio and Llama.cpp now declare real ``api_key_setting`` values 

231 with ``api_key_optional = True``, so this None guard fires only for 

232 truly key-less providers (custom subclasses that don't use settings 

233 at all). 

234 """ 

235 if key is None: 

236 return default 

237 username = session.get("username") 

238 with get_user_db_session(username) as db_session: 

239 if db_session: 

240 settings_manager = get_settings_manager(db_session, username) 

241 return settings_manager.get_setting(key, default) 

242 return default 

243 

244 

245def _model_list_local_only() -> bool: 

246 """True when the model-list endpoints must NOT call cloud provider APIs 

247 (doing so sends the API key off-machine). 

248 

249 The effective local-only posture = the ``llm.require_local_endpoint`` 

250 toggle OR a private egress scope (``private_only``, or ``adaptive`` 

251 resolving to private). Checking only the raw toggle missed a PRIVATE_ONLY 

252 user who never ticked it — the scope forces local at run time but the 

253 stored toggle is still False. Best-effort: returns False (allow) on error 

254 so a transient settings hiccup doesn't break the model dropdown. 

255 """ 

256 username = session.get("username") 

257 try: 

258 with get_user_db_session(username) as db_session: 

259 if not db_session: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true

260 return False 

261 sm = get_settings_manager(db_session, username) 

262 if bool(sm.get_setting("llm.require_local_endpoint", False)): 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true

263 return True 

264 scope = str( 

265 sm.get_setting("policy.egress_scope", DEFAULT_EGRESS_SCOPE) 

266 ).lower() 

267 if scope == "private_only": 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true

268 return True 

269 if scope == "adaptive": 269 ↛ 282line 269 didn't jump to line 282 because the condition on line 269 was always true

270 from ...security.egress.policy import context_from_snapshot 

271 

272 snap = sm.get_settings_snapshot() 

273 if isinstance(snap, dict): 

274 primary = sm.get_setting("search.tool", DEFAULT_SEARCH_TOOL) 

275 return bool( 

276 context_from_snapshot( 

277 snap, 

278 primary or DEFAULT_SEARCH_TOOL, 

279 username=username, 

280 ).require_local_llm 

281 ) 

282 return False 

283 except Exception: 

284 logger.debug( 

285 "model-list local-only check failed; allowing", exc_info=True 

286 ) 

287 return False 

288 

289 

290def coerce_setting_for_write(key: str, value: Any, ui_element: str) -> Any: 

291 """Coerce an incoming value to the correct type before writing to the DB. 

292 

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

294 consistent type conversion. 

295 

296 No JSON pre-parsing (``json.loads``) is needed here because: 

297 - ``get_typed_setting_value`` already parses JSON strings internally 

298 via ``_parse_json_value`` (for ``ui_element="json"``) and 

299 ``_parse_multiselect`` (for ``ui_element="multiselect"``). 

300 - For JSON API endpoints, ``request.get_json()`` already delivers 

301 dicts/lists as native Python objects. 

302 - For ``ui_element="text"``, pre-parsing would corrupt data: a JSON 

303 string like ``'{"k": "v"}'`` would become a dict, then ``str()`` 

304 would produce ``"{'k': 'v'}"`` (Python repr, not valid JSON). 

305 """ 

306 # check_env=False: we are persisting a user-supplied value, not reading 

307 # from an environment variable override. check_env=True (the default) 

308 # would silently replace the user's value with an env var, which is 

309 # incorrect on the write path. 

310 return get_typed_setting_value( 

311 key=key, 

312 value=value, 

313 ui_element=ui_element, 

314 default=None, 

315 check_env=False, 

316 ) 

317 

318 

319@settings_bp.route("/", methods=["GET"]) 

320@login_required 

321def settings_page(): 

322 """Main settings dashboard with links to specialized config pages""" 

323 return render_template_with_defaults("settings_dashboard.html") 

324 

325 

326@settings_bp.route("/save_all_settings", methods=["POST"]) 

327@login_required 

328@settings_limit 

329@require_json_body( 

330 error_format="status", error_message="No settings data provided" 

331) 

332@with_user_session() 

333def save_all_settings( 

334 db_session: Optional[Session] = None, settings_manager=None 

335): 

336 """Handle saving all settings at once from the unified settings page""" 

337 try: 

338 from ...security.data_sanitizer import DataSanitizer 

339 

340 # Process JSON data 

341 form_data = request.get_json() 

342 if not form_data: 

343 return ( 

344 jsonify( 

345 { 

346 "status": "error", 

347 "message": "No settings data provided", 

348 } 

349 ), 

350 400, 

351 ) 

352 

353 # Track validation errors 

354 validation_errors = [] 

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

356 

357 # Track changes for logging 

358 updated_settings = [] 

359 created_settings = [] 

360 

361 # Store original values for better messaging 

362 original_values = {} 

363 

364 # Fetch all settings and remove non-editable keys 

365 all_db_settings = _filter_editable_settings(form_data, db_session) 

366 

367 # Reject public hostnames being added to the local-hosts allowlist, and 

368 # inherently-public engines being added to the trusted-engines list. 

369 _egress_err = first_egress_validation_error(form_data, all_db_settings) 

370 if _egress_err is not None: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true

371 validation_errors.append(_egress_err) 

372 return ( 

373 jsonify( 

374 { 

375 "status": "error", 

376 "message": "Validation errors", 

377 "errors": validation_errors, 

378 } 

379 ), 

380 400, 

381 ) 

382 

383 # Update each setting 

384 for key, value in form_data.items(): 

385 # Skip corrupted keys or empty strings as keys 

386 if not key or not isinstance(key, str) or key.strip() == "": 

387 continue 

388 

389 # Get the setting metadata from pre-fetched dict 

390 current_setting = all_db_settings.get(key) 

391 

392 # SAFETY NET: an empty string OR the redaction sentinel for a 

393 # password-typed setting is a no-op, never a "clear the value" 

394 # request. Reasons: 

395 # 1. The form templates (Jinja2 + JS-rendered) deliberately 

396 # render password inputs empty so the saved value never 

397 # enters the HTML source. A user who blurs the field 

398 # without typing must not wipe their stored API key. 

399 # 2. /settings/api redacts password values to the redaction 

400 # sentinel ("[REDACTED]"). A stale browser tab could submit 

401 # either "" or the sentinel — both must be idempotent on 

402 # the DB, or the round-trip would persist the literal 

403 # sentinel over the real secret. 

404 # 3. Defense-in-depth against direct cURL or automation 

405 # mistakes that POST an empty/sentinel value. 

406 # To unset a password setting, clear the source env var or 

407 # use settings import. 

408 if ( 

409 current_setting 

410 and DataSanitizer.is_sensitive_setting( 

411 key, current_setting.ui_element 

412 ) 

413 and isinstance(value, str) 

414 and value in ("", DataSanitizer.REDACTION_TEXT) 

415 ): 

416 logger.debug(f"Skipping empty secret write for {key} (no-op)") 

417 continue 

418 

419 # EARLY VALIDATION: Convert checkbox values BEFORE any other processing 

420 # This prevents incorrect triggering of corrupted value detection 

421 if current_setting and current_setting.ui_element == "checkbox": 

422 if not isinstance(value, bool): 

423 logger.debug( 

424 f"Converting checkbox {key} from {type(value).__name__} to bool: {value}" 

425 ) 

426 value = parse_boolean(value) 

427 form_data[key] = ( 

428 value # Update the form_data with converted value 

429 ) 

430 

431 # Store original value for messaging 

432 if current_setting: 

433 original_values[key] = current_setting.value 

434 

435 # Determine setting type and category 

436 if key.startswith("llm."): 

437 setting_type = SettingType.LLM 

438 category = "llm_general" 

439 if ( 

440 "temperature" in key 

441 or "max_tokens" in key 

442 or "batch" in key 

443 or "layers" in key 

444 ): 

445 category = "llm_parameters" 

446 elif key.startswith("search."): 

447 setting_type = SettingType.SEARCH 

448 category = "search_general" 

449 if ( 

450 "iterations" in key 

451 or "results" in key 

452 or "region" in key 

453 or "questions" in key 

454 or "section" in key 

455 ): 

456 category = "search_parameters" 

457 elif key.startswith("report."): 

458 setting_type = SettingType.REPORT 

459 category = "report_parameters" 

460 elif key.startswith("database."): 

461 setting_type = SettingType.DATABASE 

462 category = "database_parameters" 

463 elif key.startswith("app."): 

464 setting_type = SettingType.APP 

465 category = "app_interface" 

466 elif key.startswith("chat."): 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true

467 setting_type = SettingType.CHAT 

468 category = "chat" 

469 else: 

470 setting_type = None 

471 category = None 

472 

473 # Special handling for corrupted or empty values 

474 if value == "[object Object]" or ( 

475 isinstance(value, str) 

476 and value.strip() in ["{}", "[]", "{", "["] 

477 ): 

478 if key.startswith("report."): 

479 value = {} 

480 else: 

481 # Use default or null for other types 

482 if key == "llm.model": 

483 value = "" 

484 elif key == "llm.provider": 

485 value = "ollama" 

486 elif key == "search.tool": 

487 value = DEFAULT_SEARCH_TOOL 

488 elif key in ["app.theme", "app.default_theme"]: 

489 value = "dark" 

490 else: 

491 value = None 

492 

493 logger.warning(f"Corrected corrupted value for {key}: {value}") 

494 # NOTE: No JSON pre-parsing is done here. After the 

495 # corruption replacement above, values are Python dicts 

496 # (e.g. {}), hardcoded strings, or None — none are JSON 

497 # strings that need parsing. Type conversion below via 

498 # coerce_setting_for_write() handles everything; that 

499 # function delegates to get_typed_setting_value() which 

500 # already parses JSON internally for "json" and 

501 # "multiselect" ui_elements. 

502 

503 if current_setting: 

504 # Coerce to correct Python type (e.g. str "5" → int 5 

505 # for number settings, str "true" → bool for checkboxes). 

506 converted_value = coerce_setting_for_write( 

507 key=current_setting.key, 

508 value=value, 

509 ui_element=current_setting.ui_element, 

510 ) 

511 

512 # Validate the setting 

513 is_valid, error_message = validate_setting( 

514 current_setting, converted_value 

515 ) 

516 

517 if is_valid: 

518 # Save the converted setting using the same session 

519 success = set_setting( 

520 key, converted_value, db_session=db_session 

521 ) 

522 if success: 522 ↛ 526line 522 didn't jump to line 526 because the condition on line 522 was always true

523 updated_settings.append(key) 

524 

525 # Track settings by type for exporting 

526 if current_setting.type not in settings_by_type: 

527 settings_by_type[current_setting.type] = [] 

528 settings_by_type[current_setting.type].append( 

529 current_setting 

530 ) 

531 else: 

532 # Add to validation errors 

533 validation_errors.append( 

534 { 

535 "key": key, 

536 "name": current_setting.name, 

537 "error": error_message, 

538 } 

539 ) 

540 else: 

541 # Namespace validation: reject new keys outside allowed prefixes. 

542 if not _is_allowed_new_setting_key(key): 

543 logger.warning( 

544 "Security: Rejected setting outside allowed namespaces: {!r} (user={!r})", 

545 key, 

546 session["username"], 

547 ) 

548 validation_errors.append( 

549 { 

550 "key": key, 

551 "name": key, 

552 "error": _new_key_rejection_reason(key), 

553 } 

554 ) 

555 continue 

556 

557 # Create a new setting 

558 new_setting = { 

559 "key": key, 

560 "value": value, 

561 "type": setting_type.value.lower() 

562 if setting_type is not None 

563 else "app", 

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

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

566 "category": category, 

567 "ui_element": "text", # Default UI element 

568 } 

569 

570 # Determine better UI element based on value type 

571 if isinstance(value, bool): 

572 new_setting["ui_element"] = "checkbox" 

573 elif isinstance(value, (int, float)) and not isinstance( 

574 value, bool 

575 ): 

576 new_setting["ui_element"] = "number" 

577 elif isinstance(value, (dict, list)): 

578 new_setting["ui_element"] = "textarea" 

579 

580 # Create the setting 

581 db_setting = create_or_update_setting( 

582 new_setting, db_session=db_session 

583 ) 

584 

585 if db_setting: 

586 created_settings.append(key) 

587 # Track settings by type for exporting 

588 if db_setting.type not in settings_by_type: 588 ↛ 590line 588 didn't jump to line 590 because the condition on line 588 was always true

589 settings_by_type[db_setting.type] = [] 

590 settings_by_type[db_setting.type].append(db_setting) 

591 else: 

592 validation_errors.append( 

593 { 

594 "key": key, 

595 "name": new_setting["name"], 

596 "error": "Failed to create setting", 

597 } 

598 ) 

599 

600 # Report validation errors if any 

601 if validation_errors: 

602 return ( 

603 jsonify( 

604 { 

605 "status": "error", 

606 "message": "Validation errors", 

607 "errors": validation_errors, 

608 } 

609 ), 

610 400, 

611 ) 

612 

613 # Get all settings to return to the client for proper state update 

614 all_settings = {} 

615 for setting in db_session.query(Setting).all(): 

616 # Convert enum to string if present 

617 setting_type = setting.type 

618 if hasattr(setting_type, "value"): 

619 setting_type = setting_type.value 

620 

621 all_settings[setting.key] = { 

622 "value": setting.value, 

623 "name": setting.name, 

624 "description": setting.description, 

625 "type": setting_type, 

626 "category": setting.category, 

627 "ui_element": setting.ui_element, 

628 "editable": setting.editable, 

629 "options": setting.options, 

630 "visible": setting.visible, 

631 "min_value": setting.min_value, 

632 "max_value": setting.max_value, 

633 "step": setting.step, 

634 } 

635 

636 # Customize the success message based on what changed 

637 success_message = "" 

638 if len(updated_settings) == 1: 

639 # For a single update, provide more specific info about what changed 

640 key = updated_settings[0] 

641 # Reuse the already-fetched setting from our pre-fetched dict 

642 updated_setting = all_db_settings.get(key) 

643 name = ( 

644 updated_setting.name 

645 if updated_setting 

646 else key.split(".")[-1].replace("_", " ").title() 

647 ) 

648 

649 # Format the message 

650 if key in original_values: 650 ↛ 664line 650 didn't jump to line 664 because the condition on line 650 was always true

651 new_value = updated_setting.value if updated_setting else None 

652 

653 # If it's a boolean, use "enabled/disabled" language 

654 if isinstance(new_value, bool): 

655 state = "enabled" if new_value else "disabled" 

656 success_message = f"{name} {state}" 

657 else: 

658 # For non-boolean values 

659 if isinstance(new_value, (dict, list)): 

660 success_message = f"{name} updated" 

661 else: 

662 success_message = f"{name} updated" 

663 else: 

664 success_message = f"{name} updated" 

665 else: 

666 # Multiple settings or generic message 

667 success_message = f"Settings saved successfully ({len(updated_settings)} updated, {len(created_settings)} created)" 

668 

669 # Check if any warning-affecting settings were changed and include warnings. 

670 # Redact secret values in the echoed settings so a POST response never 

671 # ships plaintext API keys back to the browser — matching the 

672 # redaction the GET /settings/api endpoint already applies. 

673 response_data = { 

674 "status": "success", 

675 "message": success_message, 

676 "updated": updated_settings, 

677 "created": created_settings, 

678 "settings": DataSanitizer.redact_settings_snapshot(all_settings), 

679 } 

680 

681 warning_affecting_keys = WARNING_AFFECTING_KEYS 

682 

683 # Check if any warning-affecting settings were changed 

684 if any( 

685 key in warning_affecting_keys 

686 for key in updated_settings + created_settings 

687 ): 

688 warnings = calculate_warnings() 

689 response_data["warnings"] = warnings 

690 logger.info( 

691 f"Bulk settings update affected warning keys, calculated {len(warnings)} warnings" 

692 ) 

693 

694 invalidate_settings_caches(session["username"]) 

695 reschedule_document_jobs_if_needed( 

696 session["username"], updated_settings + created_settings 

697 ) 

698 reschedule_zotero_jobs_if_needed( 

699 session["username"], updated_settings + created_settings 

700 ) 

701 return jsonify(response_data) 

702 

703 except Exception: 

704 logger.exception("Error saving settings") 

705 return ( 

706 jsonify( 

707 { 

708 "status": "error", 

709 "message": "An internal error occurred while saving settings.", 

710 } 

711 ), 

712 500, 

713 ) 

714 

715 

716@settings_bp.route("/reset_to_defaults", methods=["POST"]) 

717@login_required 

718@settings_limit 

719@with_user_session() 

720def reset_to_defaults( 

721 db_session: Optional[Session] = None, settings_manager=None 

722): 

723 """Reset all settings to their default values""" 

724 try: 

725 settings_manager.load_from_defaults_file() 

726 

727 logger.info("Successfully imported settings from default files") 

728 

729 except Exception: 

730 logger.exception("Error importing default settings") 

731 return jsonify( 

732 { 

733 "status": "error", 

734 "message": "Failed to reset settings to defaults", 

735 } 

736 ), 500 

737 

738 invalidate_settings_caches(session["username"]) 

739 return jsonify( 

740 { 

741 "status": "success", 

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

743 } 

744 ) 

745 

746 

747@settings_bp.route("/save_settings", methods=["POST"]) 

748@login_required 

749@settings_limit 

750@with_user_session() 

751def save_settings(db_session: Optional[Session] = None, settings_manager=None): 

752 """Save all settings from the form using POST method - fallback when JavaScript is disabled""" 

753 try: 

754 from ...security.data_sanitizer import DataSanitizer 

755 

756 # Get form data 

757 form_data = request.form.to_dict() 

758 

759 # Remove CSRF token from the data 

760 form_data.pop("csrf_token", None) 

761 

762 updated_count = 0 

763 failed_count = 0 

764 rejected_count = 0 

765 

766 # Fetch all settings and remove non-editable keys 

767 all_db_settings = _filter_editable_settings(form_data, db_session) 

768 

769 # Egress-policy validators — the JSON route (save_all_settings) runs 

770 # these; the POST fallback must too, or a JS-disabled client could 

771 # whitelist a public hostname as "local", which the JSON route does 

772 # not permit. 

773 _policy_err = first_egress_validation_error(form_data, all_db_settings) 

774 if _policy_err is not None: 774 ↛ 775line 774 didn't jump to line 775 because the condition on line 774 was never true

775 flash(_policy_err.get("error", "Invalid policy setting"), "error") 

776 return redirect(url_for("settings.settings_page")) 

777 

778 # Process each setting 

779 for key, value in form_data.items(): 

780 try: 

781 # Get the setting from pre-fetched dict 

782 db_setting = all_db_settings.get(key) 

783 

784 # Namespace validation: reject new keys outside allowed prefixes. 

785 # Existing keys (updates) bypass this check — it only applies 

786 # to creation of brand-new rows through this form-POST route. 

787 if db_setting is None and not _is_allowed_new_setting_key(key): 

788 logger.warning( 

789 "Security: Rejected setting outside allowed namespaces: {!r} (user={!r})", 

790 key, 

791 session["username"], 

792 ) 

793 rejected_count += 1 

794 continue 

795 

796 # SAFETY NET: empty string OR the redaction sentinel for a 

797 # password-typed setting is a no-op — never "clear my key". 

798 # The no-JS form renders password inputs empty (and GET 

799 # redacts them to the sentinel), so a plain form submit 

800 # must not wipe the stored secret. Matches the guards in 

801 # save_all_settings + api_update_setting. 

802 if ( 

803 db_setting 

804 and DataSanitizer.is_sensitive_setting( 

805 key, db_setting.ui_element 

806 ) 

807 and isinstance(value, str) 

808 and value in ("", DataSanitizer.REDACTION_TEXT) 

809 ): 

810 logger.debug( 

811 f"Skipping empty secret write for {key} via " 

812 "save_settings (no-op)" 

813 ) 

814 continue 

815 

816 # Coerce form POST string to correct Python type. 

817 if db_setting: 

818 value = coerce_setting_for_write( 

819 key=db_setting.key, 

820 value=value, 

821 ui_element=db_setting.ui_element, 

822 ) 

823 

824 # Save the setting 

825 if settings_manager.set_setting(key, value, commit=False): 

826 updated_count += 1 

827 else: 

828 failed_count += 1 

829 logger.warning(f"Failed to save setting {key}") 

830 

831 except Exception: 

832 logger.exception(f"Error saving setting {key}") 

833 failed_count += 1 

834 

835 # Commit all changes at once 

836 try: 

837 db_session.commit() 

838 

839 flash( 

840 f"Settings saved successfully! Updated {updated_count} settings.", 

841 "success", 

842 ) 

843 if failed_count > 0: 

844 flash( 

845 f"Warning: {failed_count} settings failed to save.", 

846 "warning", 

847 ) 

848 if rejected_count > 0: 

849 flash( 

850 f"Rejected {rejected_count} settings (unknown namespace). " 

851 "This may indicate a bug or an attempted injection.", 

852 "error", 

853 ) 

854 invalidate_settings_caches(session["username"]) 

855 

856 except Exception: 

857 db_session.rollback() 

858 logger.exception("Failed to commit settings") 

859 flash("Error saving settings. Please try again.", "error") 

860 

861 return redirect(url_for("settings.settings_page")) 

862 

863 except Exception: 

864 logger.exception("Error in save_settings") 

865 flash("An internal error occurred while saving settings.", "error") 

866 return redirect(url_for("settings.settings_page")) 

867 

868 

869# API Routes 

870@settings_bp.route("/api", methods=["GET"]) 

871@login_required 

872@with_user_session() 

873def api_get_all_settings( 

874 db_session: Optional[Session] = None, settings_manager=None 

875): 

876 """Get all settings. 

877 

878 The response is redacted via ``DataSanitizer.redact_settings_snapshot`` 

879 so password-typed values (API keys, OAuth tokens, etc.) come back as 

880 ``"[REDACTED]"`` instead of plaintext. The previous implementation 

881 leaked env-overridden API keys to anyone who could authenticate as a 

882 user — even though the values render unmasked into the server-side 

883 settings form too (a separate template-level concern), the JSON API 

884 is its own surface area: it gets cached by clients, logged by 

885 proxies, and copy/pasted into bug reports. Redaction here is 

886 defense-in-depth. 

887 

888 No JS caller round-trips this endpoint's values back into a form 

889 (the settings form is server-rendered from the template, which uses 

890 the model directly), so redacting the response does not break the UI. 

891 """ 

892 try: 

893 from ...security.data_sanitizer import DataSanitizer 

894 

895 # Get query parameters 

896 category = request.args.get("category") 

897 

898 # Get settings (nested-with-metadata shape: 

899 # {key: {value, ui_element, type, ...}}) 

900 settings = settings_manager.get_all_settings() 

901 

902 # Filter by category if requested 

903 if category: 

904 # Need to get all setting details to check category 

905 db_settings = db_session.query(Setting).all() 

906 category_keys = [ 

907 s.key for s in db_settings if s.category == category 

908 ] 

909 

910 # Filter settings by keys 

911 settings = { 

912 key: value 

913 for key, value in settings.items() 

914 if key in category_keys 

915 } 

916 

917 settings = DataSanitizer.redact_settings_snapshot(settings) 

918 

919 return jsonify({"status": "success", "settings": settings}) 

920 except Exception: 

921 logger.exception("Error getting settings") 

922 return jsonify({"error": "Failed to retrieve settings"}), 500 

923 

924 

925@settings_bp.route("/api/<path:key>", methods=["GET"]) 

926@login_required 

927@with_user_session() 

928def api_get_db_setting( 

929 key, db_session: Optional[Session] = None, settings_manager=None 

930): 

931 """Get a specific setting by key from DB, falling back to defaults. 

932 

933 Password-typed values are redacted in the response so this endpoint 

934 matches the redaction contract enforced on ``/settings/api`` (the bulk 

935 GET) and ``/settings/api/bulk``. The same threat applies: any 

936 authenticated caller could otherwise grab a plaintext API key with 

937 a single GET. Metadata fields (``ui_element``, ``type``, ...) stay 

938 intact so the front-end can still render the correct input control. 

939 """ 

940 from ...security.data_sanitizer import DataSanitizer 

941 

942 try: 

943 # Get setting from database using the same session 

944 db_setting = ( 

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

946 ) 

947 

948 if db_setting: 

949 # Return full setting details from DB 

950 setting_data = { 

951 "key": db_setting.key, 

952 "value": DataSanitizer.redact_value( 

953 db_setting.key, db_setting.ui_element, db_setting.value 

954 ), 

955 "type": db_setting.type 

956 if isinstance(db_setting.type, str) 

957 else db_setting.type.value, 

958 "name": db_setting.name, 

959 "description": db_setting.description, 

960 "category": db_setting.category, 

961 "ui_element": db_setting.ui_element, 

962 "options": db_setting.options, 

963 "min_value": db_setting.min_value, 

964 "max_value": db_setting.max_value, 

965 "step": db_setting.step, 

966 "visible": db_setting.visible, 

967 "editable": db_setting.editable, 

968 } 

969 return jsonify(setting_data) 

970 

971 # Not in DB — check defaults so this endpoint is consistent 

972 # with GET /settings/api which includes default settings 

973 default_meta = settings_manager.default_settings.get(key) 

974 if default_meta: 974 ↛ 975line 974 didn't jump to line 975 because the condition on line 974 was never true

975 default_ui = default_meta.get("ui_element", "text") 

976 setting_data = { 

977 "key": key, 

978 "value": DataSanitizer.redact_value( 

979 key, default_ui, default_meta.get("value") 

980 ), 

981 "type": default_meta.get("type", "APP"), 

982 "name": default_meta.get("name", key), 

983 "description": default_meta.get("description"), 

984 "category": default_meta.get("category"), 

985 "ui_element": default_ui, 

986 "options": default_meta.get("options"), 

987 "min_value": default_meta.get("min_value"), 

988 "max_value": default_meta.get("max_value"), 

989 "step": default_meta.get("step"), 

990 "visible": default_meta.get("visible", True), 

991 "editable": default_meta.get("editable", True), 

992 } 

993 return jsonify(setting_data) 

994 

995 return jsonify({"error": f"Setting not found: {key}"}), 404 

996 except Exception: 

997 logger.exception(f"Error getting setting {key}") 

998 return jsonify({"error": "Failed to retrieve settings"}), 500 

999 

1000 

1001@settings_bp.route("/api/<path:key>", methods=["PUT"]) 

1002@login_required 

1003@settings_limit 

1004@require_json_body(error_message="No data provided") 

1005@with_user_session(include_settings_manager=False) 

1006def api_update_setting(key, db_session: Optional[Session] = None): 

1007 """Update a setting""" 

1008 try: 

1009 from ...security.data_sanitizer import DataSanitizer 

1010 

1011 # Get request data 

1012 data = request.get_json() 

1013 value = data.get("value") 

1014 if value is None: 

1015 return jsonify({"error": "No value provided"}), 400 

1016 

1017 # Check if setting exists 

1018 db_setting = ( 

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

1020 ) 

1021 

1022 if db_setting: 

1023 # Check if setting is editable 

1024 if not db_setting.editable: 

1025 return jsonify({"error": f"Setting {key} is not editable"}), 403 

1026 

1027 # SAFETY NET: an empty string OR the redaction sentinel on a 

1028 # password-typed setting is a no-op (never "clear my key"). 

1029 # Companion guard to the same check in save_all_settings — see 

1030 # the longer rationale there. Returning 200 with a message keeps 

1031 # the endpoint idempotent so client-side save indicators don't 

1032 # error. 

1033 if ( 

1034 DataSanitizer.is_sensitive_setting(key, db_setting.ui_element) 

1035 and isinstance(value, str) 

1036 and value in ("", DataSanitizer.REDACTION_TEXT) 

1037 ): 

1038 logger.debug( 

1039 f"Skipping empty secret write for {key} via " 

1040 "api_update_setting (no-op)" 

1041 ) 

1042 return jsonify( 

1043 { 

1044 "message": ( 

1045 f"Setting {key} unchanged (empty password ignored)" 

1046 ) 

1047 } 

1048 ), 200 

1049 

1050 # Coerce to correct Python type before saving. 

1051 # Without this, values from JSON API requests are stored 

1052 # as-is (e.g. string "5" instead of int 5 for number 

1053 # settings, string "true" instead of bool for checkboxes). 

1054 value = coerce_setting_for_write( 

1055 key=db_setting.key, 

1056 value=value, 

1057 ui_element=db_setting.ui_element, 

1058 ) 

1059 

1060 # Validate the setting (matches save_all_settings pattern) 

1061 is_valid, error_message = validate_setting(db_setting, value) 

1062 if not is_valid: 

1063 logger.warning( 

1064 f"Validation failed for setting {key}: {error_message}" 

1065 ) 

1066 return jsonify( 

1067 {"error": f"Invalid value for setting {key}"} 

1068 ), 400 

1069 

1070 # Cross-field egress-policy validation. The full-form saves 

1071 # (save_all_settings / save_settings) run these guards, but this 

1072 # single-key PUT endpoint did not — letting a client smuggle a 

1073 # public hostname into llm.allowed_local_hostnames (which the host 

1074 # classifier then trusts as local), one key at a time. Run the 

1075 # same validators here for the keys they govern. 

1076 if key in ( 

1077 "policy.egress_scope", 

1078 "search.tool", 

1079 "llm.allowed_local_hostnames", 

1080 "policy.trusted_search_engines", 

1081 ): 

1082 _all_db_settings = { 

1083 s.key: s for s in db_session.query(Setting).all() 

1084 } 

1085 _err = first_egress_validation_error( 

1086 {key: value}, _all_db_settings 

1087 ) 

1088 if _err is not None: 

1089 logger.bind(policy_audit=True).warning( 

1090 "egress-policy setting rejected at api_update_setting", 

1091 key=key, 

1092 reason=_err.get("error"), 

1093 ) 

1094 return jsonify({"error": _err["error"]}), 400 

1095 

1096 # Update setting 

1097 # Pass the db_session to avoid session lookup issues 

1098 success = set_setting(key, value, db_session=db_session) 

1099 if success: 

1100 response_data: dict[str, Any] = { 

1101 "message": f"Setting {key} updated successfully" 

1102 } 

1103 

1104 # If this is a key that affects warnings, include warning calculations 

1105 warning_affecting_keys = WARNING_AFFECTING_KEYS 

1106 

1107 if key in warning_affecting_keys: 

1108 warnings = calculate_warnings() 

1109 response_data["warnings"] = warnings 

1110 logger.debug( 

1111 f"Setting {key} changed to {value}, calculated {len(warnings)} warnings" 

1112 ) 

1113 

1114 invalidate_settings_caches(session["username"]) 

1115 # A document_scheduler.* toggle (e.g. sweep_library_collections 

1116 # or generate_rag) must take effect without a re-login. 

1117 reschedule_document_jobs_if_needed(session["username"], [key]) 

1118 reschedule_zotero_jobs_if_needed(session["username"], [key]) 

1119 return jsonify(response_data) 

1120 return jsonify({"error": f"Failed to update setting {key}"}), 500 

1121 

1122 # Namespace validation: reject new keys outside allowed prefixes. 

1123 if not _is_allowed_new_setting_key(key): 

1124 logger.warning( 

1125 "Security: Rejected setting outside allowed namespaces: {!r} (user={!r})", 

1126 key, 

1127 session["username"], 

1128 ) 

1129 return jsonify({"error": _new_key_rejection_reason(key)}), 400 

1130 

1131 # Create new setting with default metadata 

1132 setting_dict = { 

1133 "key": key, 

1134 "value": value, 

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

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

1137 } 

1138 

1139 # Add additional metadata if provided. 

1140 # 'visible' and 'editable' are system-controlled — not accepted from callers. 

1141 for field in [ 

1142 "type", 

1143 "name", 

1144 "description", 

1145 "category", 

1146 "ui_element", 

1147 "options", 

1148 "min_value", 

1149 "max_value", 

1150 "step", 

1151 ]: 

1152 if field in data: 

1153 setting_dict[field] = data[field] 

1154 

1155 # Create setting 

1156 db_setting = create_or_update_setting( 

1157 setting_dict, db_session=db_session 

1158 ) 

1159 

1160 if db_setting: 

1161 invalidate_settings_caches(session["username"]) 

1162 reschedule_document_jobs_if_needed(session["username"], [key]) 

1163 reschedule_zotero_jobs_if_needed(session["username"], [key]) 

1164 return ( 

1165 jsonify( 

1166 { 

1167 "message": f"Setting {key} created successfully", 

1168 "setting": { 

1169 "key": db_setting.key, 

1170 # Don't echo a freshly-created password value 

1171 # back in plaintext — redact like every other 

1172 # response that ships settings to the browser. 

1173 "value": ( 

1174 DataSanitizer.REDACTION_TEXT 

1175 if DataSanitizer.is_sensitive_setting( 

1176 key, db_setting.ui_element 

1177 ) 

1178 else db_setting.value 

1179 ), 

1180 "type": db_setting.type.value, 

1181 "name": db_setting.name, 

1182 }, 

1183 } 

1184 ), 

1185 201, 

1186 ) 

1187 return jsonify({"error": f"Failed to create setting {key}"}), 500 

1188 except Exception: 

1189 logger.exception(f"Error updating setting {key}") 

1190 return jsonify({"error": "Failed to update setting"}), 500 

1191 

1192 

1193@settings_bp.route("/api/<path:key>", methods=["DELETE"]) 

1194@login_required 

1195@with_user_session() 

1196def api_delete_setting( 

1197 key, db_session: Optional[Session] = None, settings_manager=None 

1198): 

1199 """Delete a setting""" 

1200 try: 

1201 # Check if setting exists 

1202 db_setting = ( 

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

1204 ) 

1205 if not db_setting: 

1206 return jsonify({"error": f"Setting not found: {key}"}), 404 

1207 

1208 # Check if setting is editable 

1209 if not db_setting.editable: 

1210 return jsonify({"error": f"Setting {key} is not editable"}), 403 

1211 

1212 # Delete setting 

1213 success = settings_manager.delete_setting(key) 

1214 if success: 

1215 invalidate_settings_caches(session["username"]) 

1216 return jsonify({"message": f"Setting {key} deleted successfully"}) 

1217 return jsonify({"error": f"Failed to delete setting {key}"}), 500 

1218 except Exception: 

1219 logger.exception(f"Error deleting setting {key}") 

1220 return jsonify({"error": "Failed to delete setting"}), 500 

1221 

1222 

1223@settings_bp.route("/api/import", methods=["POST"]) 

1224@login_required 

1225@settings_limit 

1226@with_user_session() 

1227def api_import_settings( 

1228 db_session: Optional[Session] = None, settings_manager=None 

1229): 

1230 """Import settings from defaults file""" 

1231 try: 

1232 settings_manager.load_from_defaults_file() 

1233 

1234 invalidate_settings_caches(session["username"]) 

1235 return jsonify({"message": "Settings imported successfully"}) 

1236 except Exception: 

1237 logger.exception("Error importing settings") 

1238 return jsonify({"error": "Failed to import settings"}), 500 

1239 

1240 

1241@settings_bp.route("/api/categories", methods=["GET"]) 

1242@login_required 

1243@with_user_session(include_settings_manager=False) 

1244def api_get_categories(db_session: Optional[Session] = None): 

1245 """Get all setting categories""" 

1246 try: 

1247 # Get all distinct categories 

1248 categories = db_session.query(Setting.category).distinct().all() 

1249 category_list = [c[0] for c in categories if c[0] is not None] 

1250 

1251 return jsonify({"categories": category_list}) 

1252 except Exception: 

1253 logger.exception("Error getting categories") 

1254 return jsonify({"error": "Failed to retrieve settings"}), 500 

1255 

1256 

1257@settings_bp.route("/api/types", methods=["GET"]) 

1258@login_required 

1259def api_get_types(): 

1260 """Get all setting types""" 

1261 try: 

1262 # Get all setting types 

1263 types = [t.value for t in SettingType] 

1264 return jsonify({"types": types}) 

1265 except Exception: 

1266 logger.exception("Error getting types") 

1267 return jsonify({"error": "Failed to retrieve settings"}), 500 

1268 

1269 

1270@settings_bp.route("/api/ui_elements", methods=["GET"]) 

1271@login_required 

1272def api_get_ui_elements(): 

1273 """Get all UI element types""" 

1274 try: 

1275 # Define supported UI element types 

1276 ui_elements = [ 

1277 "text", 

1278 "select", 

1279 "checkbox", 

1280 "slider", 

1281 "number", 

1282 "textarea", 

1283 "color", 

1284 "date", 

1285 "file", 

1286 "password", 

1287 ] 

1288 

1289 return jsonify({"ui_elements": ui_elements}) 

1290 except Exception: 

1291 logger.exception("Error getting UI elements") 

1292 return jsonify({"error": "Failed to retrieve settings"}), 500 

1293 

1294 

1295@settings_bp.route("/api/available-models", methods=["GET"]) 

1296@login_required 

1297def api_get_available_models(): 

1298 """Get available LLM models from various providers""" 

1299 endpoint_start = time.perf_counter() 

1300 try: 

1301 from ...database.models import ProviderModel 

1302 

1303 # Check if force_refresh is requested 

1304 force_refresh = parse_bool_arg("force_refresh") 

1305 

1306 # Get all auto-discovered providers (show all so users can discover 

1307 # and configure providers they haven't set up yet) 

1308 from ...llm.providers import get_discovered_provider_options 

1309 

1310 # LlamaCppProvider is auto-discovered (provider_name="llama.cpp"), so 

1311 # it is already included here. The previous hardcoded LLAMACPP append 

1312 # produced a duplicate entry in the dropdown. 

1313 provider_options = get_discovered_provider_options() 

1314 

1315 # Available models by provider 

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

1317 

1318 # Check database cache first (unless force_refresh is True) 

1319 if not force_refresh: 

1320 try: 

1321 # Define cache expiration (24 hours) 

1322 cache_expiry = datetime.now(UTC) - timedelta(hours=24) 

1323 

1324 # Get cached models from database 

1325 username = session["username"] 

1326 with get_user_db_session(username) as db_session: 

1327 cached_models = ( 

1328 db_session.query(ProviderModel) 

1329 .filter(ProviderModel.last_updated > cache_expiry) 

1330 .all() 

1331 ) 

1332 

1333 if cached_models: 1333 ↛ 1334line 1333 didn't jump to line 1334 because the condition on line 1333 was never true

1334 logger.info( 

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

1336 ) 

1337 

1338 # Group models by provider 

1339 for model in cached_models: 

1340 provider_key = ( 

1341 f"{normalize_provider(model.provider)}_models" 

1342 ) 

1343 if provider_key not in providers: 

1344 providers[provider_key] = [] 

1345 

1346 providers[provider_key].append( 

1347 { 

1348 "value": model.model_key, 

1349 "label": model.model_label, 

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

1351 } 

1352 ) 

1353 

1354 # If we have cached data for all providers, return it 

1355 if providers: 

1356 _log_available_models_duration( 

1357 endpoint_start, cache_hit=True 

1358 ) 

1359 logger.info("Returning cached models from database") 

1360 return jsonify( 

1361 { 

1362 "provider_options": provider_options, 

1363 "providers": providers, 

1364 } 

1365 ) 

1366 

1367 except Exception: 

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

1369 # Continue to fetch fresh data 

1370 

1371 # Ollama / OpenAI / Anthropic model listing is handled by the 

1372 # auto-discovery loop below (their provider classes' list_models_for_api), 

1373 # which is the single fetch path. The previous hand-rolled per-provider 

1374 # blocks here were dead in normal mode (overwritten by discovery) and an 

1375 # inferior duplicate (no SSRF validation, no auth headers); local-only mode 

1376 # now keeps the local providers via the LOCAL_PROVIDERS filter below. 

1377 

1378 # Fetch models from auto-discovered providers 

1379 from ...llm.providers import discover_providers 

1380 

1381 discovered_providers = discover_providers() 

1382 

1383 # Egress policy: when the effective posture is local-only the user 

1384 # has opted into local-only inference, so don't list cloud providers 

1385 # (OpenRouter, Google, XAI, IonOS, OpenAI, Anthropic, ...). We still 

1386 # list the known-local providers (ollama/llamacpp/lmstudio) via their 

1387 # provider classes — which validate the URL (SSRF) and support auth 

1388 # headers — by filtering the discovered set to LOCAL_PROVIDERS rather 

1389 # than skipping discovery entirely. Use the scope-aware helper so a 

1390 # PRIVATE_ONLY / adaptive-private user is covered even if the raw 

1391 # toggle is False. 

1392 require_local_for_discovered = _model_list_local_only() 

1393 if require_local_for_discovered: 

1394 from ...llm.providers._helpers import LOCAL_PROVIDERS 

1395 

1396 local_only_providers = { 

1397 key: info 

1398 for key, info in discovered_providers.items() 

1399 if normalize_provider(key) in LOCAL_PROVIDERS 

1400 } 

1401 logger.bind(policy_audit=True).info( 

1402 "local-only egress posture: limiting discovered model lists " 

1403 "to local providers", 

1404 kept=list(local_only_providers.keys()), 

1405 skipped=[ 

1406 key 

1407 for key in discovered_providers 

1408 if key not in local_only_providers 

1409 ], 

1410 ) 

1411 discovered_providers = local_only_providers 

1412 

1413 for provider_key, provider_info in discovered_providers.items(): 

1414 provider_models = [] 

1415 try: 

1416 logger.info( 

1417 f"Fetching models from {provider_info.provider_name}" 

1418 ) 

1419 

1420 # Get the provider class 

1421 provider_class = provider_info.provider_class 

1422 

1423 # Get API key if configured 

1424 api_key = _get_setting_from_session( 

1425 provider_class.api_key_setting, "" 

1426 ) 

1427 

1428 # Get base URL if provider has configurable URL 

1429 provider_base_url: str | None = None 

1430 if ( 

1431 hasattr(provider_class, "url_setting") 

1432 and provider_class.url_setting 

1433 ): 

1434 provider_base_url = _get_setting_from_session( 

1435 provider_class.url_setting, "" 

1436 ) 

1437 

1438 # Use the provider's list_models_for_api method 

1439 models = provider_class.list_models_for_api( 

1440 api_key, provider_base_url 

1441 ) 

1442 

1443 # Format models for the API response 

1444 for model in models: 

1445 provider_models.append( 

1446 { 

1447 "value": model["value"], 

1448 "label": model[ 

1449 "label" 

1450 ], # Use provider's label as-is 

1451 "provider": provider_key, 

1452 } 

1453 ) 

1454 

1455 logger.info( 

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

1457 ) 

1458 

1459 except Exception: 

1460 logger.exception( 

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

1462 ) 

1463 

1464 # Set models in providers dict using lowercase key 

1465 providers[f"{normalize_provider(provider_key)}_models"] = ( 

1466 provider_models 

1467 ) 

1468 logger.info( 

1469 f"Final {provider_key} models count: {len(provider_models)}" 

1470 ) 

1471 

1472 # Save fetched models to database cache 

1473 if force_refresh or providers: 1473 ↛ 1522line 1473 didn't jump to line 1522 because the condition on line 1473 was always true

1474 # We fetched fresh data, save it to database 

1475 username = session["username"] 

1476 with get_user_db_session(username) as db_session: 

1477 try: 

1478 if force_refresh: 

1479 # When force refresh, clear ALL cached models to remove any stale data 

1480 # from old code versions or deleted providers 

1481 deleted_count = db_session.query(ProviderModel).delete() 

1482 logger.info( 

1483 f"Force refresh: cleared all {deleted_count} cached models" 

1484 ) 

1485 else: 

1486 # Clear old cache entries only for providers we're updating 

1487 for provider_key in providers: 

1488 provider_name = provider_key.replace( 

1489 "_models", "" 

1490 ).upper() 

1491 db_session.query(ProviderModel).filter( 

1492 ProviderModel.provider == provider_name 

1493 ).delete() 

1494 

1495 # Insert new models 

1496 for provider_key, models in providers.items(): 

1497 provider_name = provider_key.replace( 

1498 "_models", "" 

1499 ).upper() 

1500 for model in models: 

1501 if ( 1501 ↛ 1500line 1501 didn't jump to line 1500 because the condition on line 1501 was always true

1502 isinstance(model, dict) 

1503 and "value" in model 

1504 and "label" in model 

1505 ): 

1506 new_model = ProviderModel( 

1507 provider=provider_name, 

1508 model_key=model["value"], 

1509 model_label=model["label"], 

1510 last_updated=datetime.now(UTC), 

1511 ) 

1512 db_session.add(new_model) 

1513 

1514 db_session.commit() 

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

1516 

1517 except Exception: 

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

1519 db_session.rollback() 

1520 

1521 # Return all options 

1522 _log_available_models_duration(endpoint_start, cache_hit=False) 

1523 return jsonify( 

1524 {"provider_options": provider_options, "providers": providers} 

1525 ) 

1526 

1527 except Exception: 

1528 logger.exception("Error getting available models") 

1529 _log_available_models_duration( 

1530 endpoint_start, cache_hit=False, error=True 

1531 ) 

1532 return jsonify( 

1533 { 

1534 "status": "error", 

1535 "message": "Failed to retrieve available models", 

1536 } 

1537 ), 500 

1538 

1539 

1540def _log_available_models_duration( 

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

1542) -> None: 

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

1544 

1545 Uses INFO when the endpoint took > 1s (indicating a real provider fetch 

1546 latency worth flagging), DEBUG otherwise. This is the likely culprit for 

1547 Path C (LLM provider timeout masquerading as backend hang). 

1548 """ 

1549 elapsed_ms = (time.perf_counter() - start) * 1000 

1550 path = ( 

1551 "error" 

1552 if error 

1553 else ("cache hit" if cache_hit else "full provider fetch") 

1554 ) 

1555 if elapsed_ms > 1000: 

1556 logger.info(f"/api/available-models ({path}) took {elapsed_ms:.0f}ms") 

1557 else: 

1558 logger.debug(f"/api/available-models ({path}) took {elapsed_ms:.0f}ms") 

1559 

1560 

1561def _get_engine_icon_and_category( 

1562 engine_data: dict, engine_class=None 

1563) -> tuple: 

1564 """ 

1565 Get icon emoji and category label for a search engine based on its attributes. 

1566 

1567 Args: 

1568 engine_data: Engine configuration dictionary 

1569 engine_class: Optional loaded engine class to check attributes 

1570 

1571 Returns: 

1572 Tuple of (icon, category) strings 

1573 """ 

1574 # Check attributes from either the class or the engine data 

1575 if engine_class: 

1576 is_scientific = getattr(engine_class, "is_scientific", False) 

1577 is_generic = getattr(engine_class, "is_generic", False) 

1578 is_local = getattr(engine_class, "is_local", False) 

1579 is_news = getattr(engine_class, "is_news", False) 

1580 is_code = getattr(engine_class, "is_code", False) 

1581 else: 

1582 is_scientific = engine_data.get("is_scientific", False) 

1583 is_generic = engine_data.get("is_generic", False) 

1584 is_local = engine_data.get("is_local", False) 

1585 is_news = engine_data.get("is_news", False) 

1586 is_code = engine_data.get("is_code", False) 

1587 

1588 # Check books attribute 

1589 if engine_class: 

1590 is_books = getattr(engine_class, "is_books", False) 

1591 else: 

1592 is_books = engine_data.get("is_books", False) 

1593 

1594 # Return icon and category based on engine type 

1595 # Priority: local > scientific > news > code > books > generic > default 

1596 if is_local: 

1597 return "📁", "Local RAG" 

1598 if is_scientific: 

1599 return "🔬", "Scientific" 

1600 if is_news: 

1601 return "📰", "News" 

1602 if is_code: 

1603 return "💻", "Code" 

1604 if is_books: 

1605 return "📚", "Books" 

1606 if is_generic: 

1607 return "🌐", "Web Search" 

1608 return "🔍", "Search" 

1609 

1610 

1611@settings_bp.route("/api/available-search-engines", methods=["GET"]) 

1612@login_required 

1613@with_user_session() 

1614def api_get_available_search_engines( 

1615 db_session: Optional[Session] = None, settings_manager=None 

1616): 

1617 """Get available search engines""" 

1618 try: 

1619 # Get search engines using the same approach as search_engines_config.py 

1620 from ...web_search_engines.search_engines_config import search_config 

1621 from ...web_search_engines.engine_groups import ( 

1622 classify_engine_group, 

1623 effective_group, 

1624 group_label, 

1625 group_order, 

1626 ) 

1627 

1628 username = session["username"] 

1629 search_engines = search_config(username=username, db_session=db_session) 

1630 

1631 # Get user's favorites using SettingsManager 

1632 favorites = settings_manager.get_setting("search.favorites", []) 

1633 if not isinstance(favorites, list): 1633 ↛ 1634line 1633 didn't jump to line 1634 because the condition on line 1633 was never true

1634 favorites = [] 

1635 

1636 # Extract search engines from config 

1637 engines_dict = {} 

1638 engine_options = [] 

1639 

1640 if search_engines: 1640 ↛ 1723line 1640 didn't jump to line 1723 because the condition on line 1640 was always true

1641 # Format engines for API response with metadata 

1642 from ...security.module_whitelist import ( 

1643 get_safe_module_class, 

1644 SecurityError, 

1645 ) 

1646 

1647 for engine_id, engine_data in search_engines.items(): 

1648 # Try to load the engine class to get metadata 

1649 engine_class = None 

1650 try: 

1651 module_path = engine_data.get("module_path") 

1652 class_name = engine_data.get("class_name") 

1653 if module_path and class_name: 1653 ↛ 1668line 1653 didn't jump to line 1668 because the condition on line 1653 was always true

1654 # Use secure whitelist-validated import 

1655 engine_class = get_safe_module_class( 

1656 module_path, class_name 

1657 ) 

1658 except SecurityError: 

1659 logger.warning( 

1660 f"Security: Blocked unsafe module for {engine_id}" 

1661 ) 

1662 except Exception as e: 

1663 logger.debug( 

1664 f"Could not load engine class for {engine_id}: {e}" 

1665 ) 

1666 

1667 # Get icon and category from engine attributes 

1668 icon, category = _get_engine_icon_and_category( 

1669 engine_data, engine_class 

1670 ) 

1671 

1672 # Check if engine requires an API key 

1673 requires_api_key = engine_data.get("requires_api_key", False) 

1674 

1675 # Build display name with icon, category, and API key status 

1676 base_name = engine_data.get("display_name", engine_id) 

1677 if requires_api_key: 

1678 label = f"{icon} {base_name} ({category}, API key)" 

1679 else: 

1680 label = f"{icon} {base_name} ({category}, Free)" 

1681 

1682 # Check if engine is a favorite 

1683 is_favorite = engine_id in favorites 

1684 

1685 # Classify the engine into a selector band. base_group ignores 

1686 # favorite status (used by the frontend to move an engine back 

1687 # to its category when un-starred); the effective band is the 

1688 # Favorites overlay when starred. See engine_groups.py. 

1689 base_group = classify_engine_group( 

1690 engine_id, category, requires_api_key 

1691 ) 

1692 shown_group = effective_group(base_group, is_favorite) 

1693 

1694 engines_dict[engine_id] = { 

1695 "display_name": base_name, 

1696 "description": engine_data.get("description", ""), 

1697 "strengths": engine_data.get("strengths", []), 

1698 "icon": icon, 

1699 "category": category, 

1700 "requires_api_key": requires_api_key, 

1701 "is_favorite": is_favorite, 

1702 } 

1703 

1704 engine_options.append( 

1705 { 

1706 "value": engine_id, 

1707 "label": label, 

1708 "icon": icon, 

1709 "category": category, 

1710 "requires_api_key": requires_api_key, 

1711 "is_favorite": is_favorite, 

1712 "group": shown_group, 

1713 "group_label": group_label(shown_group), 

1714 "group_order": group_order(shown_group), 

1715 "base_group": base_group, 

1716 "base_group_label": group_label(base_group), 

1717 "base_group_order": group_order(base_group), 

1718 } 

1719 ) 

1720 

1721 # Sort engine_options by band order (favorites band first), then 

1722 # alphabetically by label within each band. 

1723 engine_options.sort( 

1724 key=lambda x: ( 

1725 x.get("group_order", 999), 

1726 x.get("label", "").lower(), 

1727 ) 

1728 ) 

1729 

1730 # If no engines found, log the issue but return empty list 

1731 if not engine_options: 1731 ↛ 1732line 1731 didn't jump to line 1732 because the condition on line 1731 was never true

1732 logger.warning("No search engines found in configuration") 

1733 

1734 return jsonify( 

1735 { 

1736 "engines": engines_dict, 

1737 "engine_options": engine_options, 

1738 "favorites": favorites, 

1739 } 

1740 ) 

1741 

1742 except Exception: 

1743 logger.exception("Error getting available search engines") 

1744 return jsonify({"error": "Failed to retrieve search engines"}), 500 

1745 

1746 

1747@settings_bp.route("/api/search-favorites", methods=["GET"]) 

1748@login_required 

1749@with_user_session() 

1750def api_get_search_favorites( 

1751 db_session: Optional[Session] = None, settings_manager=None 

1752): 

1753 """Get the list of favorite search engines for the current user""" 

1754 try: 

1755 favorites = settings_manager.get_setting("search.favorites", []) 

1756 if not isinstance(favorites, list): 

1757 favorites = [] 

1758 return jsonify({"favorites": favorites}) 

1759 

1760 except Exception: 

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

1762 return jsonify({"error": "Failed to retrieve favorites"}), 500 

1763 

1764 

1765@settings_bp.route("/api/search-favorites", methods=["PUT"]) 

1766@login_required 

1767@require_json_body(error_message="No data provided") 

1768@with_user_session() 

1769def api_update_search_favorites( 

1770 db_session: Optional[Session] = None, settings_manager=None 

1771): 

1772 """Update the list of favorite search engines for the current user""" 

1773 try: 

1774 data = request.get_json() 

1775 favorites = data.get("favorites") 

1776 if favorites is None: 

1777 return jsonify({"error": "No favorites provided"}), 400 

1778 

1779 if not isinstance(favorites, list): 

1780 return jsonify({"error": "Favorites must be a list"}), 400 

1781 

1782 if settings_manager.set_setting("search.favorites", favorites): 

1783 invalidate_settings_caches(session["username"]) 

1784 return jsonify( 

1785 { 

1786 "message": "Favorites updated successfully", 

1787 "favorites": favorites, 

1788 } 

1789 ) 

1790 return jsonify({"error": "Failed to update favorites"}), 500 

1791 

1792 except Exception: 

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

1794 return jsonify({"error": "Failed to update favorites"}), 500 

1795 

1796 

1797@settings_bp.route("/api/search-favorites/toggle", methods=["POST"]) 

1798@login_required 

1799@require_json_body(error_message="No data provided") 

1800@with_user_session() 

1801def api_toggle_search_favorite( 

1802 db_session: Optional[Session] = None, settings_manager=None 

1803): 

1804 """Toggle a search engine as favorite""" 

1805 try: 

1806 data = request.get_json() 

1807 engine_id = data.get("engine_id") 

1808 if not engine_id: 

1809 return jsonify({"error": "No engine_id provided"}), 400 

1810 

1811 # Get current favorites 

1812 favorites = settings_manager.get_setting("search.favorites", []) 

1813 if not isinstance(favorites, list): 

1814 favorites = [] 

1815 else: 

1816 # Make a copy to avoid modifying the original 

1817 favorites = list(favorites) 

1818 

1819 # Toggle the engine 

1820 is_favorite = engine_id in favorites 

1821 if is_favorite: 

1822 favorites.remove(engine_id) 

1823 is_favorite = False 

1824 else: 

1825 favorites.append(engine_id) 

1826 is_favorite = True 

1827 

1828 # Update the setting 

1829 if settings_manager.set_setting("search.favorites", favorites): 

1830 invalidate_settings_caches(session["username"]) 

1831 return jsonify( 

1832 { 

1833 "message": "Favorite toggled successfully", 

1834 "engine_id": engine_id, 

1835 "is_favorite": is_favorite, 

1836 "favorites": favorites, 

1837 } 

1838 ) 

1839 return jsonify({"error": "Failed to toggle favorite"}), 500 

1840 

1841 except Exception: 

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

1843 return jsonify({"error": "Failed to toggle favorite"}), 500 

1844 

1845 

1846# Legacy routes for backward compatibility - these will redirect to the new routes 

1847@settings_bp.route("/main", methods=["GET"]) 

1848@login_required 

1849def main_config_page(): 

1850 """Redirect to app settings page""" 

1851 return redirect(url_for("settings.settings_page")) 

1852 

1853 

1854@settings_bp.route("/collections", methods=["GET"]) 

1855@login_required 

1856def collections_config_page(): 

1857 """Redirect to app settings page""" 

1858 return redirect(url_for("settings.settings_page")) 

1859 

1860 

1861@settings_bp.route("/api_keys", methods=["GET"]) 

1862@login_required 

1863def api_keys_config_page(): 

1864 """Redirect to LLM settings page""" 

1865 return redirect(url_for("settings.settings_page")) 

1866 

1867 

1868@settings_bp.route("/search_engines", methods=["GET"]) 

1869@login_required 

1870def search_engines_config_page(): 

1871 """Redirect to search settings page""" 

1872 return redirect(url_for("settings.settings_page")) 

1873 

1874 

1875@settings_bp.route("/llm", methods=["GET"]) 

1876@login_required 

1877def llm_config_page(): 

1878 """Redirect to LLM settings page""" 

1879 return redirect(url_for("settings.settings_page")) 

1880 

1881 

1882@settings_bp.route("/open_file_location", methods=["POST"]) 

1883@login_required 

1884def open_file_location(): 

1885 """Open the location of a configuration file. 

1886 

1887 Security: This endpoint is disabled for server deployments. 

1888 It only makes sense for desktop usage where the server and client are on the same machine. 

1889 """ 

1890 return jsonify( 

1891 { 

1892 "status": "error", 

1893 "message": "This feature is disabled. It is only available in desktop mode.", 

1894 } 

1895 ), 403 

1896 

1897 

1898@settings_bp.context_processor 

1899def inject_csrf_token(): 

1900 """Inject CSRF token into the template context for all settings routes.""" 

1901 return {"csrf_token": generate_csrf} 

1902 

1903 

1904@settings_bp.route("/fix_corrupted_settings", methods=["POST"]) 

1905@login_required 

1906@settings_limit 

1907@with_user_session(include_settings_manager=False) 

1908def fix_corrupted_settings(db_session: Optional[Session] = None): 

1909 """Fix corrupted settings in the database""" 

1910 try: 

1911 # Track fixed and removed settings 

1912 fixed_settings = [] 

1913 removed_duplicate_settings = [] 

1914 # First, find and remove duplicate settings with the same key 

1915 # This happens because of errors in settings import/export 

1916 from sqlalchemy import func as sql_func 

1917 

1918 # Find keys with duplicates 

1919 duplicate_keys = ( 

1920 db_session.query(Setting.key) 

1921 .group_by(Setting.key) 

1922 .having(sql_func.count(Setting.key) > 1) 

1923 .all() 

1924 ) 

1925 duplicate_keys = [key[0] for key in duplicate_keys] 

1926 

1927 # For each duplicate key, keep the latest updated one and remove others 

1928 for key in duplicate_keys: 

1929 dupe_settings = ( 

1930 db_session.query(Setting) 

1931 .filter(Setting.key == key) 

1932 .order_by(Setting.updated_at.desc()) 

1933 .all() 

1934 ) 

1935 

1936 # Keep the first one (most recently updated) and delete the rest 

1937 for i, setting in enumerate(dupe_settings): 

1938 if i > 0: # Skip the first one (keep it) 

1939 db_session.delete(setting) 

1940 removed_duplicate_settings.append(key) 

1941 

1942 # Check for settings with corrupted values 

1943 all_settings = db_session.query(Setting).all() 

1944 for setting in all_settings: 

1945 # Check different types of corruption 

1946 is_corrupted = False 

1947 

1948 if ( 

1949 setting.value is None 

1950 or ( 

1951 isinstance(setting.value, str) 

1952 and setting.value 

1953 in [ 

1954 "{", 

1955 "[", 

1956 "{}", 

1957 "[]", 

1958 "[object Object]", 

1959 "null", 

1960 "undefined", 

1961 ] 

1962 ) 

1963 or (isinstance(setting.value, dict) and len(setting.value) == 0) 

1964 ): 

1965 is_corrupted = True 

1966 

1967 # Skip if not corrupted 

1968 if not is_corrupted: 

1969 continue 

1970 

1971 default_value: Any = None 

1972 

1973 # Try to find a matching default setting based on key 

1974 if setting.key.startswith("llm."): 

1975 if setting.key == "llm.model": 

1976 default_value = "" 

1977 elif setting.key == "llm.provider": 

1978 default_value = "ollama" 

1979 elif setting.key == "llm.temperature": 

1980 default_value = 0.7 

1981 elif setting.key == "llm.max_tokens": 1981 ↛ 2022line 1981 didn't jump to line 2022 because the condition on line 1981 was always true

1982 default_value = 1024 

1983 elif setting.key.startswith("search."): 

1984 if setting.key == "search.tool": 

1985 default_value = DEFAULT_SEARCH_TOOL 

1986 elif setting.key == "search.max_results": 

1987 default_value = 10 

1988 elif setting.key == "search.region": 

1989 default_value = "us" 

1990 elif setting.key == "search.questions_per_iteration": 

1991 default_value = 3 

1992 elif setting.key == "search.searches_per_section": 

1993 default_value = 2 

1994 elif setting.key == "search.skip_relevance_filter": 

1995 default_value = False 

1996 elif setting.key == "search.safe_search": 

1997 default_value = True 

1998 elif setting.key == "search.search_language": 1998 ↛ 2022line 1998 didn't jump to line 2022 because the condition on line 1998 was always true

1999 default_value = "English" 

2000 elif setting.key.startswith("report."): 

2001 if setting.key == "report.searches_per_section": 

2002 default_value = 2 

2003 elif setting.key.startswith("app."): 2003 ↛ 2022line 2003 didn't jump to line 2022 because the condition on line 2003 was always true

2004 if ( 

2005 setting.key == "app.theme" 

2006 or setting.key == "app.default_theme" 

2007 ): 

2008 default_value = "dark" 

2009 elif setting.key == "app.enable_notifications" or ( 

2010 setting.key == "app.enable_web" 

2011 or setting.key == "app.web_interface" 

2012 ): 

2013 default_value = True 

2014 elif setting.key == "app.host": 

2015 default_value = "0.0.0.0" 

2016 elif setting.key == "app.port": 

2017 default_value = 5000 

2018 elif setting.key == "app.debug": 2018 ↛ 2022line 2018 didn't jump to line 2022 because the condition on line 2018 was always true

2019 default_value = True 

2020 

2021 # Update the setting with the default value if found 

2022 if default_value is not None: 

2023 setting.value = default_value 

2024 fixed_settings.append(setting.key) 

2025 else: 

2026 # If no default found but it's a corrupted JSON, set to empty object 

2027 if setting.key.startswith("report."): 2027 ↛ 1944line 2027 didn't jump to line 1944 because the condition on line 2027 was always true

2028 setting.value = {} 

2029 fixed_settings.append(setting.key) 

2030 

2031 # Commit changes 

2032 if fixed_settings or removed_duplicate_settings: 

2033 db_session.commit() 

2034 logger.info( 

2035 f"Fixed {len(fixed_settings)} corrupted settings: {', '.join(fixed_settings)}" 

2036 ) 

2037 if removed_duplicate_settings: 

2038 logger.info( 

2039 f"Removed {len(removed_duplicate_settings)} duplicate settings" 

2040 ) 

2041 invalidate_settings_caches(session["username"]) 

2042 

2043 # Return success 

2044 return jsonify( 

2045 { 

2046 "status": "success", 

2047 "message": f"Fixed {len(fixed_settings)} corrupted settings, removed {len(removed_duplicate_settings)} duplicates", 

2048 "fixed_settings": fixed_settings, 

2049 "removed_duplicates": removed_duplicate_settings, 

2050 } 

2051 ) 

2052 

2053 except Exception: 

2054 logger.exception("Error fixing corrupted settings") 

2055 db_session.rollback() 

2056 return ( 

2057 jsonify( 

2058 { 

2059 "status": "error", 

2060 "message": "An internal error occurred while fixing corrupted settings. Please try again later.", 

2061 } 

2062 ), 

2063 500, 

2064 ) 

2065 

2066 

2067@settings_bp.route("/api/warnings", methods=["GET"]) 

2068@login_required 

2069def api_get_warnings(): 

2070 """Get current warnings based on settings""" 

2071 try: 

2072 warnings = calculate_warnings() 

2073 return jsonify({"warnings": warnings}) 

2074 except Exception: 

2075 logger.exception("Error getting warnings") 

2076 return jsonify({"error": "Failed to retrieve warnings"}), 500 

2077 

2078 

2079@settings_bp.route("/api/backup-status", methods=["GET"]) 

2080@login_required 

2081def api_get_backup_status(): 

2082 """Get backup status for the current user.""" 

2083 try: 

2084 from ...config.paths import get_user_backup_directory 

2085 

2086 username = session.get("username") 

2087 if not username: 2087 ↛ 2088line 2087 didn't jump to line 2088 because the condition on line 2087 was never true

2088 return jsonify({"error": "Not authenticated"}), 401 

2089 

2090 from ...utilities.formatting import human_size 

2091 from ...database.backup.backup_service import is_safe_glob_result 

2092 

2093 backup_dir = get_user_backup_directory(username) 

2094 

2095 # Sort by modification time (not filename) for robustness 

2096 backup_list = [] 

2097 total_size = 0 

2098 for b in backup_dir.glob("ldr_backup_*.db"): 

2099 # Skip symlinks / entries resolving outside backup_dir so a 

2100 # planted symlink can't have its (external) target's metadata 

2101 # reported here — same glob-hardening applied in BackupService. 

2102 if not is_safe_glob_result(b, backup_dir): 

2103 continue 

2104 try: 

2105 stat = b.stat() 

2106 total_size += stat.st_size 

2107 backup_list.append( 

2108 { 

2109 "filename": b.name, 

2110 "size_bytes": stat.st_size, 

2111 "size_human": human_size(stat.st_size), 

2112 "created_at": datetime.fromtimestamp( 

2113 stat.st_mtime, tz=timezone.utc 

2114 ).isoformat(), 

2115 "_mtime": stat.st_mtime, 

2116 } 

2117 ) 

2118 except FileNotFoundError: 

2119 continue 

2120 

2121 # Sort newest first by mtime, then remove internal field 

2122 backup_list.sort(key=lambda x: x["_mtime"], reverse=True) 

2123 for entry in backup_list: 

2124 del entry["_mtime"] 

2125 

2126 backup_enabled = _get_setting_from_session("backup.enabled", True) 

2127 

2128 return jsonify( 

2129 { 

2130 "enabled": bool(backup_enabled), 

2131 "count": len(backup_list), 

2132 "backups": backup_list, 

2133 "total_size_bytes": total_size, 

2134 "total_size_human": human_size(total_size), 

2135 } 

2136 ) 

2137 

2138 except Exception: 

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

2140 return jsonify({"error": "Failed to retrieve backup status"}), 500 

2141 

2142 

2143@settings_bp.route("/api/ollama-status", methods=["GET"]) 

2144@login_required 

2145def check_ollama_status(): 

2146 """Check if Ollama is running and available""" 

2147 try: 

2148 # Get Ollama URL from settings 

2149 raw_base_url = _get_setting_from_session( 

2150 "llm.ollama.url", DEFAULT_OLLAMA_URL 

2151 ) 

2152 base_url = ( 

2153 normalize_url(raw_base_url) if raw_base_url else DEFAULT_OLLAMA_URL 

2154 ) 

2155 

2156 response = safe_get( 

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

2158 timeout=2, 

2159 allow_localhost=True, 

2160 allow_private_ips=True, 

2161 ) 

2162 

2163 if response.status_code == 200: 

2164 return jsonify( 

2165 { 

2166 "running": True, 

2167 "version": response.json().get("version", "unknown"), 

2168 } 

2169 ) 

2170 return jsonify( 

2171 { 

2172 "running": False, 

2173 "error": f"Ollama returned status code {response.status_code}", 

2174 } 

2175 ) 

2176 except requests.exceptions.RequestException: 

2177 logger.exception("Ollama check failed") 

2178 return jsonify( 

2179 {"running": False, "error": "Failed to check search engine status"} 

2180 ) 

2181 

2182 

2183@settings_bp.route("/api/rate-limiting/status", methods=["GET"]) 

2184@login_required 

2185def api_get_rate_limiting_status(): 

2186 """Get current rate limiting status and statistics""" 

2187 try: 

2188 username = session["username"] 

2189 

2190 # exploration_rate / learning_rate are the *configured* settings, not 

2191 # the effective operating values: AdaptiveRateLimitTracker._apply_profile 

2192 # scales them by the active profile (conservative ~0.5x/0.7x, aggressive 

2193 # ~1.5x/1.3x, each capped). The profile is reported alongside so a 

2194 # consumer can tell which transform the running tracker applies; we 

2195 # deliberately don't duplicate that scaling math here. 

2196 status = { 

2197 # Default True to match the schema default (default_settings.json) 

2198 # and the tracker's web-mode default; the prior endpoint reported 

2199 # the tracker's effective enabled state, which was on by default. 

2200 "enabled": _get_setting_from_session("rate_limiting.enabled", True), 

2201 "profile": _get_setting_from_session( 

2202 "rate_limiting.profile", "balanced" 

2203 ), 

2204 "exploration_rate": _get_setting_from_session( 

2205 "rate_limiting.exploration_rate", 0.1 

2206 ), 

2207 "learning_rate": _get_setting_from_session( 

2208 "rate_limiting.learning_rate", 0.45 

2209 ), 

2210 "memory_window": _get_setting_from_session( 

2211 "rate_limiting.memory_window", 100 

2212 ), 

2213 } 

2214 

2215 with get_user_db_session(username) as db_session: 

2216 estimates = ( 

2217 db_session.query(RateLimitEstimate) 

2218 .order_by(RateLimitEstimate.engine_type) 

2219 .all() 

2220 ) 

2221 

2222 engines = [] 

2223 for est in estimates: 

2224 engines.append( 

2225 { 

2226 "engine_type": est.engine_type, 

2227 "base_wait_seconds": round(est.base_wait_seconds, 2), 

2228 "min_wait_seconds": round(est.min_wait_seconds, 2), 

2229 "max_wait_seconds": round(est.max_wait_seconds, 2), 

2230 "last_updated": est.last_updated, 

2231 "total_attempts": est.total_attempts, 

2232 "success_rate": round(est.success_rate * 100, 1), 

2233 } 

2234 ) 

2235 

2236 return jsonify({"status": status, "engines": engines}) 

2237 

2238 except Exception: 

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

2240 return jsonify({"error": "An internal error occurred"}), 500 

2241 

2242 

2243@settings_bp.route( 

2244 "/api/rate-limiting/engines/<engine_type>/reset", methods=["POST"] 

2245) 

2246@login_required 

2247def api_reset_engine_rate_limiting(engine_type): 

2248 """Reset (forget) the learned rate-limit estimate for a specific engine. 

2249 

2250 Deletes the engine's persisted ``RateLimitEstimate`` row from the user's 

2251 database so the adaptive tracker re-learns it from scratch. The previous 

2252 implementation called the per-request ``get_tracker()``, whose mutation 

2253 path is gated on a research-session context that is absent in an analytics 

2254 HTTP request — so it was a silent no-op that never cleared the persisted 

2255 estimate the ``/status`` and ``/current`` endpoints display (#4721). 

2256 """ 

2257 try: 

2258 username = session["username"] 

2259 with get_user_db_session(username) as db_session: 

2260 db_session.query(RateLimitEstimate).filter_by( 

2261 engine_type=engine_type 

2262 ).delete(synchronize_session=False) 

2263 db_session.commit() 

2264 

2265 return jsonify( 

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

2267 ) 

2268 

2269 except Exception: 

2270 logger.exception(f"Error resetting rate limiting for {engine_type}") 

2271 return jsonify({"error": "An internal error occurred"}), 500 

2272 

2273 

2274@settings_bp.route("/api/rate-limiting/cleanup", methods=["POST"]) 

2275@login_required 

2276def api_cleanup_rate_limiting(): 

2277 """Clean up old rate limiting data. 

2278 

2279 Note: not using @require_json_body because the JSON body is optional 

2280 here — the endpoint works with or without a payload (defaults to 30 days). 

2281 """ 

2282 try: 

2283 data = request.get_json() if request.is_json else None 

2284 days = data.get("days", 30) if data is not None else 30 

2285 

2286 try: 

2287 days = int(days) 

2288 except (TypeError, ValueError): 

2289 return jsonify({"error": "'days' must be an integer"}), 400 

2290 if days < 1 or days > 365: 

2291 return jsonify({"error": "'days' must be between 1 and 365"}), 400 

2292 

2293 # Delete persisted estimates not updated within the window. Mirrors the 

2294 # read endpoints (#4721): operate on RateLimitEstimate rather than the 

2295 # per-request get_tracker(), whose cleanup path is a no-op outside a 

2296 # research-session context. last_updated is a unix timestamp (Float). 

2297 username = session["username"] 

2298 cutoff = time.time() - days * 86400 

2299 with get_user_db_session(username) as db_session: 

2300 db_session.query(RateLimitEstimate).filter( 

2301 RateLimitEstimate.last_updated < cutoff 

2302 ).delete(synchronize_session=False) 

2303 db_session.commit() 

2304 

2305 return jsonify( 

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

2307 ) 

2308 

2309 except Exception: 

2310 logger.exception("Error cleaning up rate limiting data") 

2311 return jsonify({"error": "An internal error occurred"}), 500 

2312 

2313 

2314@settings_bp.route("/api/bulk", methods=["GET"]) 

2315@login_required 

2316def get_bulk_settings(): 

2317 """Get multiple settings at once for performance. 

2318 

2319 The caller-supplied ``keys[]`` is unrestricted, so this endpoint is a 

2320 candidate channel to exfiltrate password-typed settings — any 

2321 authenticated client can request ``keys[]=llm.openai.api_key`` and 

2322 would otherwise receive the plaintext value. Redact password fields 

2323 by checking the leaf segment of the dotted key against 

2324 ``DataSanitizer.DEFAULT_SENSITIVE_KEYS`` (e.g. ``api_key``, 

2325 ``password``, ``access_token``, …). Same defense-in-depth predicate 

2326 used in ``redact_settings_snapshot``; here we use the suffix-only 

2327 check because the response shape is ``{value, exists}`` — no 

2328 ``ui_element`` is fetched. ``exists`` is preserved so callers can 

2329 still tell whether the key is configured. 

2330 """ 

2331 try: 

2332 from ...security.data_sanitizer import DataSanitizer 

2333 

2334 # Get requested settings from query parameters 

2335 requested = request.args.getlist("keys[]") 

2336 if not requested: 

2337 # Default to common settings if none specified 

2338 requested = [ 

2339 "llm.provider", 

2340 "llm.model", 

2341 "search.tool", 

2342 "search.iterations", 

2343 "search.questions_per_iteration", 

2344 "search.search_strategy", 

2345 "benchmark.evaluation.provider", 

2346 "benchmark.evaluation.model", 

2347 "benchmark.evaluation.temperature", 

2348 "benchmark.evaluation.endpoint_url", 

2349 ] 

2350 

2351 # Fetch all settings at once 

2352 result = {} 

2353 for key in requested: 

2354 try: 

2355 value = _get_setting_from_session(key) 

2356 exists = value is not None 

2357 # No ui_element metadata in the bulk path, so redact_value 

2358 # falls back to the suffix-only arm of the shared predicate. 

2359 value = DataSanitizer.redact_value(key, None, value) 

2360 result[key] = {"value": value, "exists": exists} 

2361 except Exception: 

2362 logger.warning(f"Error getting setting {key}") 

2363 result[key] = { 

2364 "value": None, 

2365 "exists": False, 

2366 "error": "Failed to retrieve setting", 

2367 } 

2368 

2369 return jsonify({"success": True, "settings": result}) 

2370 

2371 except Exception: 

2372 logger.exception("Error getting bulk settings") 

2373 return jsonify( 

2374 {"success": False, "error": "An internal error occurred"} 

2375 ), 500 

2376 

2377 

2378@settings_bp.route("/api/data-location", methods=["GET"]) 

2379@login_required 

2380def api_get_data_location(): 

2381 """Get information about data storage location and security""" 

2382 try: 

2383 # Get the data directory path 

2384 data_dir = get_data_directory() 

2385 # Get the encrypted databases path 

2386 encrypted_db_path = get_encrypted_database_path() 

2387 

2388 # Check if LDR_DATA_DIR environment variable is set 

2389 from local_deep_research.settings.manager import SettingsManager 

2390 

2391 settings_manager = SettingsManager() 

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

2393 

2394 # Get platform-specific default location info 

2395 platform_info = { 

2396 "Windows": "C:\\Users\\Username\\AppData\\Local\\local-deep-research", 

2397 "macOS": "~/Library/Application Support/local-deep-research", 

2398 "Linux": "~/.local/share/local-deep-research", 

2399 } 

2400 

2401 # Current platform 

2402 current_platform = platform.system() 

2403 if current_platform == "Darwin": 

2404 current_platform = "macOS" 

2405 

2406 # Get SQLCipher settings from environment 

2407 from ...database.sqlcipher_utils import get_sqlcipher_settings 

2408 

2409 # Debug logging 

2410 logger.info(f"db_manager type: {type(db_manager)}") 

2411 logger.info( 

2412 f"db_manager.has_encryption: {getattr(db_manager, 'has_encryption', 'ATTRIBUTE NOT FOUND')}" 

2413 ) 

2414 

2415 cipher_settings = ( 

2416 get_sqlcipher_settings() if db_manager.has_encryption else {} 

2417 ) 

2418 

2419 return jsonify( 

2420 { 

2421 "data_directory": str(data_dir), 

2422 "database_path": str(encrypted_db_path), 

2423 "encrypted_database_path": str(encrypted_db_path), 

2424 "is_custom": custom_data_dir is not None, 

2425 "custom_env_var": "LDR_DATA_DIR", 

2426 "custom_env_value": custom_data_dir, 

2427 "platform": current_platform, 

2428 "platform_default": platform_info.get( 

2429 current_platform, str(data_dir) 

2430 ), 

2431 "platform_info": platform_info, 

2432 "security_notice": { 

2433 "encrypted": db_manager.has_encryption, 

2434 "warning": "All data including API keys stored in the database are securely encrypted." 

2435 if db_manager.has_encryption 

2436 else "All data including API keys stored in the database are currently unencrypted. Please ensure appropriate file system permissions are set.", 

2437 "recommendation": "Your data is protected with database encryption." 

2438 if db_manager.has_encryption 

2439 else "Consider using environment variables for sensitive API keys instead of storing them in the database.", 

2440 }, 

2441 "encryption_settings": cipher_settings, 

2442 } 

2443 ) 

2444 

2445 except Exception: 

2446 logger.exception("Error getting data location information") 

2447 return jsonify({"error": "Failed to retrieve data location"}), 500 

2448 

2449 

2450@settings_bp.route("/api/notifications/test-url", methods=["POST"]) 

2451@login_required 

2452def api_test_notification_url(): 

2453 """ 

2454 Test a notification service URL. 

2455 

2456 This endpoint creates a temporary NotificationService instance to test 

2457 the provided URL. No database session or password is required because: 

2458 - The service URL is provided directly in the request body 

2459 - Test notifications use a temporary Apprise instance 

2460 - No user settings or database queries are performed 

2461 

2462 Security note: Rate limiting is not applied here because users need to 

2463 test URLs when configuring notifications. Abuse is mitigated by the 

2464 @login_required decorator and the fact that users can only spam their 

2465 own notification services. 

2466 """ 

2467 try: 

2468 from ...notifications.service import NotificationService 

2469 from ...settings.env_registry import get_env_setting 

2470 

2471 data = request.get_json() 

2472 if not data or "service_url" not in data: 

2473 return jsonify( 

2474 {"success": False, "error": "service_url is required"} 

2475 ), 400 

2476 

2477 service_url = data["service_url"] 

2478 

2479 # Create notification service instance and test the URL. 

2480 # Gate by the env-only master switch so the test endpoint cannot 

2481 # bypass the operator's risk-acceptance decision (see SECURITY.md 

2482 # "Notification Webhook SSRF"). 

2483 notification_service = NotificationService( 

2484 allow_private_ips=bool( 

2485 get_env_setting("notifications.allow_private_ips", False) 

2486 ), 

2487 outbound_allowed=bool( 

2488 get_env_setting("notifications.allow_outbound", False) 

2489 ), 

2490 ) 

2491 result = notification_service.test_service(service_url) 

2492 

2493 # Only return expected fields to prevent information leakage 

2494 safe_response = { 

2495 "success": result.get("success", False), 

2496 "message": result.get("message", ""), 

2497 "error": result.get("error", ""), 

2498 } 

2499 return jsonify(safe_response) 

2500 

2501 except Exception: 

2502 logger.exception("Error testing notification URL") 

2503 return jsonify( 

2504 { 

2505 "success": False, 

2506 "error": "Failed to test notification service. Check logs for details.", 

2507 } 

2508 ), 500