Coverage for src/local_deep_research/web/app_factory.py: 87%

442 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1# import logging - replaced with loguru 

2import os 

3from pathlib import Path 

4from importlib import resources as importlib_resources 

5 

6from flask import ( 

7 Flask, 

8 Request, 

9 abort, 

10 jsonify, 

11 make_response, 

12 request, 

13 send_from_directory, 

14) 

15from flask_wtf.csrf import CSRFProtect 

16from werkzeug.middleware.proxy_fix import ProxyFix 

17from loguru import logger 

18from local_deep_research.settings.logger import log_settings 

19 

20from ..utilities.log_utils import InterceptHandler 

21from ..security import SecurityHeaders, get_security_default 

22from ..security.egress.policy import DEFAULT_EGRESS_SCOPE 

23from ..security.rate_limiter import limiter 

24from ..security.file_upload_validator import FileUploadValidator 

25from ..security.web_middleware import ( 

26 SecureCookieMiddleware, 

27 ServerHeaderMiddleware, 

28) 

29 

30# Removed DB_PATH import - using per-user databases now 

31from .services.socket_service import SocketIOService 

32 

33 

34class DiskSpoolingRequest(Request): 

35 """Custom Request class that spools large file uploads to disk. 

36 

37 This prevents memory exhaustion from large multipart uploads by writing 

38 files larger than max_form_memory_size to temporary files on disk instead 

39 of keeping them in memory. 

40 

41 Security fix for issue #1176: a request right up against the per-file × 

42 per-request limits could otherwise consume tens of GB of memory in 

43 one go. The spool-to-disk threshold keeps memory bounded regardless 

44 of how high the per-file cap (FileUploadValidator.MAX_FILE_SIZE) is set. 

45 """ 

46 

47 # Files larger than 5MB are spooled to disk instead of memory 

48 max_form_memory_size = 5 * 1024 * 1024 # 5MB threshold 

49 

50 

51def create_app(): 

52 """ 

53 Create and configure the Flask application. 

54 

55 Returns: 

56 tuple: (app, socketio) - The configured Flask app and SocketIO instance 

57 """ 

58 # Route stdlib loggers through loguru via InterceptHandler. 

59 # Guard against handler duplication when create_app() is called multiple 

60 # times (e.g. in tests). 

61 import logging 

62 

63 werkzeug_logger = logging.getLogger("werkzeug") 

64 werkzeug_logger.setLevel( 

65 logging.WARNING 

66 ) # Suppress verbose per-request logs 

67 if not any( 

68 isinstance(h, InterceptHandler) for h in werkzeug_logger.handlers 

69 ): 

70 werkzeug_logger.addHandler(InterceptHandler()) 

71 

72 # APScheduler logs job execution results (success/failure) to its own 

73 # logger hierarchy. Without an InterceptHandler the WARNING+ messages 

74 # only reach Python's lastResort handler as unformatted stderr. 

75 # Level is WARNING (not INFO) because job functions already log their 

76 # own progress via loguru — APScheduler's INFO messages would be redundant. 

77 apscheduler_logger = logging.getLogger("apscheduler") 

78 apscheduler_logger.setLevel(logging.WARNING) 

79 if not any( 

80 isinstance(h, InterceptHandler) for h in apscheduler_logger.handlers 

81 ): 

82 apscheduler_logger.addHandler(InterceptHandler()) 

83 

84 logger.info("Initializing Local Deep Research application...") 

85 

86 try: 

87 # Get directories based on package installation 

88 PACKAGE_DIR = importlib_resources.files("local_deep_research") / "web" 

89 with importlib_resources.as_file(PACKAGE_DIR) as package_dir: 

90 STATIC_DIR = (package_dir / "static").as_posix() 

91 TEMPLATE_DIR = (package_dir / "templates").as_posix() 

92 

93 # Initialize Flask app with package directories 

94 # Set static_folder to None to disable Flask's built-in static handling 

95 # We'll use our custom static route instead to handle dist folder 

96 app = Flask(__name__, static_folder=None, template_folder=TEMPLATE_DIR) 

97 # Store static dir for custom handling 

98 app.config["STATIC_DIR"] = STATIC_DIR 

99 logger.debug(f"Using package static path: {STATIC_DIR}") 

100 logger.debug(f"Using package template path: {TEMPLATE_DIR}") 

101 except Exception: 

102 # Fallback for development 

103 logger.exception("Package directories not found, using fallback paths") 

104 # Set static_folder to None to disable Flask's built-in static handling 

105 app = Flask( 

106 __name__, 

107 static_folder=None, 

108 template_folder=str(Path("templates").resolve()), 

109 ) 

110 # Store static dir for custom handling 

111 app.config["STATIC_DIR"] = str(Path("static").resolve()) 

112 

113 # Use custom Request class that spools large uploads to disk 

114 # This prevents memory exhaustion from large file uploads (issue #1176) 

115 app.request_class = DiskSpoolingRequest 

116 

117 # Middleware stack (wrapped innermost -> outermost; runs in reverse at 

118 # request time): 

119 # 1. SecureCookieMiddleware: adds Secure flag iff wsgi.url_scheme=https. 

120 # Wrapped INSIDE ProxyFix so it reads the post-rewrite scheme. 

121 # 2. ProxyFix: translates X-Forwarded-* into REMOTE_ADDR / wsgi.url_scheme. 

122 # 3. ServerHeaderMiddleware: strips Server header (outermost). 

123 app.wsgi_app = SecureCookieMiddleware(app.wsgi_app, app) # type: ignore[method-assign] 

124 app.wsgi_app = ProxyFix( # type: ignore[method-assign] 

125 app.wsgi_app, 

126 x_for=1, # Trust 1 proxy for X-Forwarded-For 

127 x_proto=1, # Trust 1 proxy for X-Forwarded-Proto (http/https) 

128 x_host=0, # Don't trust X-Forwarded-Host (security) 

129 x_port=0, # Don't trust X-Forwarded-Port (security) 

130 x_prefix=0, # Don't trust X-Forwarded-Prefix (security) 

131 ) 

132 app.wsgi_app = ServerHeaderMiddleware(app.wsgi_app) # type: ignore[method-assign] 

133 

134 # CI/test-only request forensics for the #4431 navigation-stall hunts: 

135 # logs every request's arrival + duration so a silent log window can 

136 # be attributed to "request never arrived" vs "request hung in app". 

137 if os.environ.get("CI") or os.environ.get("TESTING"): 

138 from .utils.request_timing import RequestTimingMiddleware 

139 

140 app.wsgi_app = RequestTimingMiddleware(app.wsgi_app) # type: ignore[method-assign] 

141 logger.info("Request-timing forensics middleware enabled (CI/TESTING)") 

142 

143 # App configuration 

144 # Generate or load a unique SECRET_KEY per installation 

145 import secrets 

146 from ..config.paths import get_data_directory 

147 

148 secret_key_file = Path(get_data_directory()) / ".secret_key" 

149 secret_key_file.parent.mkdir(parents=True, exist_ok=True) 

150 new_key = secrets.token_hex(32) 

151 try: 

152 fd = os.open( 

153 str(secret_key_file), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 

154 ) 

155 try: 

156 os.write(fd, new_key.encode()) 

157 finally: 

158 os.close(fd) 

159 app.config["SECRET_KEY"] = new_key 

160 logger.info("Generated new SECRET_KEY for this installation") 

161 except FileExistsError: 

162 try: 

163 with open(secret_key_file, "r", encoding="utf-8") as f: 

164 app.config["SECRET_KEY"] = f.read().strip() 

165 except Exception: 

166 logger.warning("Could not read secret key file") 

167 app.config["SECRET_KEY"] = new_key 

168 except OSError: 

169 logger.warning("Could not save secret key file") 

170 app.config["SECRET_KEY"] = new_key 

171 # Session cookie security settings 

172 # SECURE flag is added dynamically based on request context (see after_request below) 

173 # This allows localhost HTTP to work for development while keeping production secure 

174 # 

175 # Check if explicitly in testing mode (for backwards compatibility) 

176 is_testing = ( 

177 os.getenv("CI") 

178 or os.getenv("TESTING") 

179 or os.getenv("PYTEST_CURRENT_TEST") 

180 or app.debug 

181 ) 

182 # Set to False - we add Secure flag dynamically in after_request handler 

183 # Exception: if TESTING mode is active, we never add Secure flag 

184 app.config["SESSION_COOKIE_SECURE"] = False 

185 app.config["LDR_TESTING_MODE"] = bool(is_testing) # Store for after_request 

186 app.config["SESSION_COOKIE_HTTPONLY"] = ( 

187 True # Prevent JavaScript access (XSS mitigation) 

188 ) 

189 app.config["SESSION_COOKIE_SAMESITE"] = "Lax" # CSRF protection 

190 # Set max cookie lifetime for permanent sessions (when session.permanent=True). 

191 # This applies to "remember me" sessions; non-permanent sessions expire on browser close. 

192 remember_me_days = get_security_default( 

193 "security.session_remember_me_days", 30 

194 ) 

195 app.config["PERMANENT_SESSION_LIFETIME"] = remember_me_days * 24 * 3600 

196 # PREFERRED_URL_SCHEME affects URL generation (url_for), not request.is_secure 

197 app.config["PREFERRED_URL_SCHEME"] = "https" 

198 

199 # File upload security limits - calculated from FileUploadValidator constants. 

200 # Cap the request body at the largest *legitimate* upload (max files × 

201 # max per-file size). This admits every upload the validator would accept — 

202 # a single max-size file AND a full multi-file batch — while still rejecting 

203 # bodies larger than any legitimate request. (Per-file size and per-request 

204 # file count are re-checked in the upload route / validator.) 

205 app.config["MAX_CONTENT_LENGTH"] = ( 

206 FileUploadValidator.MAX_FILES_PER_REQUEST 

207 * FileUploadValidator.MAX_FILE_SIZE 

208 ) 

209 

210 # Arm the notes JSON body cap BEFORE CSRF protection. Flask-WTF's CSRF 

211 # check reads request.form on mutating requests, caching request.stream 

212 # with the max_content_length then in effect; app-level before_request 

213 # hooks run in registration order, so this must precede CSRFProtect so a 

214 # chunked (no Content-Length) notes body is capped before its stream is 

215 # cached under the app-wide multi-file-upload MAX_CONTENT_LENGTH. See 

216 # notes_routes.arm_notes_body_cap. 

217 from .routes.notes_routes import arm_notes_body_cap 

218 

219 app.before_request(arm_notes_body_cap) 

220 

221 # Initialize CSRF protection 

222 # Explicitly enable CSRF protection (don't rely on implicit Flask-WTF behavior) 

223 app.config["WTF_CSRF_ENABLED"] = True 

224 CSRFProtect(app) 

225 # Exempt Socket.IO from CSRF protection 

226 # Note: Flask-SocketIO handles CSRF internally, so we don't need to exempt specific views 

227 

228 # Initialize security headers middleware 

229 SecurityHeaders(app) 

230 

231 # Initialize rate limiting for security (brute force protection) 

232 # Uses imported limiter from security.rate_limiter module 

233 # Rate limiting is disabled in CI via enabled callable in rate_limiter.py 

234 # Also set app config to ensure Flask-Limiter respects our settings 

235 from ..settings.env_registry import is_rate_limiting_enabled 

236 from ..security.rate_limiter import validate_rate_limit_storage 

237 

238 rate_limiting_enabled = is_rate_limiting_enabled() 

239 # Fail fast HERE (server startup) on an unusable RATELIMIT_STORAGE_URL 

240 # backend, with an actionable message — not at module import (which 

241 # would break CLI tools/migrations/tests) and not on the first request 

242 # (a bare ConfigurationError naming neither variable nor remedy). 

243 # Only when rate limiting is actually enabled: with it disabled the 

244 # limiter never touches the backend (enabled=False short-circuits it), 

245 # so a stale/broken RATELIMIT_STORAGE_URL left over from a prior 

246 # deployment must not abort startup for an operator who has explicitly 

247 # turned rate limiting off. 

248 if rate_limiting_enabled: 

249 validate_rate_limit_storage() 

250 app.config["RATELIMIT_ENABLED"] = rate_limiting_enabled 

251 app.config["RATELIMIT_STRATEGY"] = "moving-window" 

252 limiter.init_app(app) 

253 

254 # Custom error handler for rate limit exceeded (429) 

255 @app.errorhandler(429) 

256 def ratelimit_handler(e): 

257 # Import here to avoid circular imports 

258 from ..security.rate_limiter import get_client_ip 

259 

260 # Audit logging for security monitoring 

261 # Use get_client_ip() to get the real IP behind proxies 

262 logger.warning( 

263 f"Rate limit exceeded: endpoint={request.endpoint} " 

264 f"ip={get_client_ip()} " 

265 f"user_agent={request.headers.get('User-Agent', 'unknown')}" 

266 ) 

267 return jsonify( 

268 error="Too many requests", 

269 message="Too many attempts. Please try again later.", 

270 ), 429 

271 

272 # Note: Dynamic cookie security is handled by SecureCookieMiddleware (WSGI level) 

273 # This is necessary because Flask's session cookies are set AFTER after_request handlers 

274 # The middleware wrapping happens below near ProxyFix 

275 

276 # Note: CSRF exemptions for API blueprints are applied after blueprint 

277 # registration below (search for "CSRF exemptions" in this file). 

278 

279 # Database configuration - Using per-user databases now 

280 # No shared database configuration needed 

281 app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 

282 app.config["SQLALCHEMY_ECHO"] = False 

283 

284 # Per-user databases are created automatically via encrypted_db.py 

285 

286 # Log data location and security information 

287 from ..config.paths import get_data_directory 

288 from ..database.encrypted_db import db_manager 

289 

290 data_dir = get_data_directory() 

291 logger.info("=" * 60) 

292 logger.info("DATA STORAGE INFORMATION") 

293 logger.info("=" * 60) 

294 logger.info(f"Data directory: {data_dir}") 

295 logger.info( 

296 "Databases: Per-user encrypted databases in encrypted_databases/" 

297 ) 

298 

299 # Check if using custom location 

300 from local_deep_research.settings.manager import SettingsManager 

301 

302 settings_manager = SettingsManager() 

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

304 if custom_data_dir: 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true

305 logger.info( 

306 f"Using custom data location via LDR_DATA_DIR: {custom_data_dir}" 

307 ) 

308 else: 

309 logger.info("Using default platform-specific data location") 

310 

311 # Display security status based on actual SQLCipher availability 

312 if db_manager.has_encryption: 

313 logger.info( 

314 "SECURITY: Databases are encrypted with SQLCipher. Ensure appropriate file system permissions are set on the data directory." 

315 ) 

316 else: 

317 logger.warning( 

318 "SECURITY NOTICE: SQLCipher is not available - databases are NOT encrypted. " 

319 "Install SQLCipher for database encryption. Ensure appropriate file system permissions are set on the data directory." 

320 ) 

321 

322 logger.info( 

323 "TIP: You can change the data location by setting the LDR_DATA_DIR environment variable." 

324 ) 

325 logger.info("=" * 60) 

326 

327 # Initialize Vite helper for asset management 

328 from .utils.vite_helper import vite 

329 

330 vite.init_app(app) 

331 

332 # Initialize Theme helper for auto-detecting themes from CSS 

333 from .utils.theme_helper import theme_helper 

334 

335 theme_helper.init_app(app) 

336 

337 # Generate combined themes.css from individual theme files 

338 from .themes import theme_registry 

339 

340 try: 

341 static_dir = Path(app.config.get("STATIC_DIR", "static")) 

342 themes_css_path = static_dir / "css" / "themes.css" 

343 combined_css = theme_registry.get_combined_css() 

344 themes_css_path.write_text(combined_css, encoding="utf-8") 

345 logger.debug( 

346 f"Generated themes.css with {len(theme_registry.themes)} themes" 

347 ) 

348 except PermissionError: 

349 logger.warning( 

350 f"Cannot write themes.css to {themes_css_path}. " 

351 "Theme CSS will need to be pre-generated." 

352 ) 

353 except Exception: 

354 logger.exception("Error generating combined themes.css") 

355 

356 # Register socket service 

357 socket_service = SocketIOService(app=app) 

358 

359 # Initialize news subscription scheduler 

360 try: 

361 # News tables are now created per-user in their encrypted databases 

362 logger.info( 

363 "News tables will be created in per-user encrypted databases" 

364 ) 

365 

366 # Check if scheduler is enabled BEFORE importing/initializing 

367 # Use env registry which handles both env vars and settings 

368 from ..settings.env_registry import get_env_setting 

369 

370 scheduler_enabled = get_env_setting("news.scheduler.enabled", True) 

371 logger.info(f"News scheduler enabled: {scheduler_enabled}") 

372 

373 if scheduler_enabled: 

374 # Only import and initialize if enabled 

375 from ..scheduler.background import ( 

376 get_background_job_scheduler, 

377 ) 

378 from ..settings.manager import SettingsManager 

379 

380 # Get system settings for scheduler configuration (if not already loaded) 

381 if "settings_manager" not in locals(): 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true

382 settings_manager = SettingsManager() 

383 

384 # Get scheduler instance and initialize with settings 

385 scheduler = get_background_job_scheduler() 

386 scheduler.initialize_with_settings(settings_manager) 

387 scheduler.set_app(app) 

388 scheduler.start() 

389 app.background_job_scheduler = scheduler # type: ignore[attr-defined] 

390 logger.info("News scheduler started with activity-based tracking") 

391 else: 

392 # Don't initialize scheduler if disabled 

393 app.background_job_scheduler = None # type: ignore[attr-defined] 

394 logger.info("News scheduler disabled - not initializing") 

395 except Exception: 

396 logger.exception("Failed to initialize news scheduler") 

397 app.background_job_scheduler = None # type: ignore[attr-defined] 

398 

399 # Apply middleware 

400 logger.info("Applying middleware...") 

401 apply_middleware(app) 

402 logger.info("Middleware applied successfully") 

403 

404 # Register blueprints 

405 logger.info("Registering blueprints...") 

406 register_blueprints(app) 

407 logger.info("Blueprints registered successfully") 

408 

409 # Register error handlers 

410 logger.info("Registering error handlers...") 

411 register_error_handlers(app) 

412 logger.info("Error handlers registered successfully") 

413 

414 # Start the queue processor v2 (uses encrypted databases) 

415 # Enabled by default in normal app runs, but default-off under tests so 

416 # function-scoped app fixtures don't spawn a daemon thread per test. 

417 logger.info("Checking queue processor v2 startup...") 

418 from ..settings.env_registry import get_env_setting, registry 

419 

420 queue_processor_enabled = get_env_setting( 

421 "web.queue_processor.enabled", True 

422 ) 

423 queue_processor_setting = registry.get_setting_object( 

424 "web.queue_processor.enabled" 

425 ) 

426 queue_processor_env_set = bool( 

427 queue_processor_setting and queue_processor_setting.is_set 

428 ) 

429 test_env_default_off = bool(os.getenv("PYTEST_CURRENT_TEST")) 

430 if test_env_default_off and not queue_processor_env_set: 

431 queue_processor_enabled = False 

432 

433 logger.info(f"Queue processor v2 enabled: {queue_processor_enabled}") 

434 if queue_processor_enabled: 

435 from .queue.processor_v2 import queue_processor 

436 

437 queue_processor.start() 

438 logger.info("Started research queue processor v2") 

439 else: 

440 logger.info("Queue processor v2 disabled - not starting") 

441 

442 logger.info("App factory completed successfully") 

443 

444 return app, socket_service 

445 

446 

447def apply_middleware(app): 

448 """Apply middleware to the Flask app.""" 

449 

450 # Import auth decorators and middleware 

451 logger.info("Importing cleanup_middleware...") 

452 from .auth.cleanup_middleware import cleanup_completed_research 

453 

454 logger.info("Importing database_middleware...") 

455 from .auth.database_middleware import ensure_user_database 

456 

457 logger.info("Importing decorators...") 

458 from .auth.decorators import inject_current_user 

459 

460 logger.info("Importing queue_middleware...") 

461 from .auth.queue_middleware import process_pending_queue_operations 

462 

463 logger.info("Importing queue_middleware_v2...") 

464 from .auth.queue_middleware_v2 import notify_queue_processor 

465 

466 logger.info("Importing session_cleanup...") 

467 from .auth.session_cleanup import cleanup_stale_sessions 

468 

469 logger.info("All middleware imports completed") 

470 

471 # Register authentication middleware 

472 # First clean up stale sessions 

473 app.before_request(cleanup_stale_sessions) 

474 # Then ensure database is open for authenticated users 

475 app.before_request(ensure_user_database) 

476 # Then inject current user into g 

477 app.before_request(inject_current_user) 

478 # Clean up completed research records 

479 app.before_request(cleanup_completed_research) 

480 # Process any pending queue operations for this user (direct mode) 

481 app.before_request(process_pending_queue_operations) 

482 # Notify queue processor of user activity (queue mode) 

483 app.before_request(notify_queue_processor) 

484 

485 logger.info("All middleware registered") 

486 

487 # Note: log-queue flushing is handled asynchronously by the 

488 # background daemon started in web/app.py::main() (see 

489 # start_log_queue_processor). We deliberately do NOT register 

490 # flush_log_queue as a before_request handler: doing so makes 

491 # every request (including GET /auth/login) synchronously wait 

492 # on _write_log_to_database, which opens a user DB session and 

493 # blocks if the shared connection pool is wedged. At-exit 

494 # draining is still handled via atexit in web/app.py. 

495 

496 # Inject backend constants into Jinja2 templates for frontend JS. 

497 # This is the Flask-documented pattern for sharing Python enums with JavaScript. 

498 # Source of truth: src/local_deep_research/constants.py::ResearchStatus 

499 # Frontend helpers: src/local_deep_research/web/static/js/config/constants.js 

500 # Template injection: src/local_deep_research/web/templates/base.html 

501 from ..constants import ( 

502 HISTORY_LOGS_DEFAULT_LIMIT, 

503 HISTORY_LOGS_HARD_CAP, 

504 ResearchStatus, 

505 ) 

506 

507 @app.context_processor 

508 def inject_frontend_constants(): 

509 terminal = [ 

510 ResearchStatus.COMPLETED, 

511 ResearchStatus.SUSPENDED, 

512 ResearchStatus.FAILED, 

513 ResearchStatus.ERROR, 

514 ResearchStatus.CANCELLED, 

515 ] 

516 # Make the active egress scope available to every template so base.html 

517 # can render it onto <body data-scope=…>. Scope-aware CSS in styles.css 

518 # picks it up to color the research card, chat input, etc. Falls back 

519 # to the registered default scope if anything goes wrong — fail open 

520 # visually, never crash a page render over a styling cue. 

521 scope = DEFAULT_EGRESS_SCOPE 

522 try: 

523 from flask import session as flask_session 

524 from ..database.session_context import get_user_db_session 

525 from ..utilities.db_utils import get_settings_manager 

526 

527 username = flask_session.get("username") if flask_session else None 

528 if username: 

529 with get_user_db_session(username) as db_session: 

530 if db_session: 530 ↛ 544line 530 didn't jump to line 544

531 sm = get_settings_manager(db_session, username) 

532 scope = ( 

533 sm.get_setting( 

534 "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

535 ) 

536 or DEFAULT_EGRESS_SCOPE 

537 ) 

538 except Exception: 

539 logger.debug( 

540 "Failed to read egress scope for template context", 

541 exc_info=True, 

542 ) 

543 

544 return { 

545 "research_status_enum": {m.name: m.value for m in ResearchStatus}, 

546 "research_terminal_states": [str(s) for s in terminal], 

547 "log_limits": { 

548 "default": HISTORY_LOGS_DEFAULT_LIMIT, 

549 "hard_cap": HISTORY_LOGS_HARD_CAP, 

550 }, 

551 "egress_scope": scope, 

552 } 

553 

554 # Clean up database sessions after each request 

555 @app.teardown_appcontext 

556 def cleanup_db_session(exception=None): 

557 """Clean up database session after each request to avoid cross-thread issues.""" 

558 from flask import g 

559 

560 session = g.pop("db_session", None) 

561 if session is not None: 

562 try: 

563 session.rollback() 

564 except Exception: 

565 logger.warning( 

566 "Error rolling back request session during cleanup" 

567 ) 

568 try: 

569 session.close() 

570 except Exception: 

571 logger.warning("Error closing request session during cleanup") 

572 

573 # Sweep credential entries for dead threads. Multiple trigger 

574 # points (here, processor_v2, and connection_cleanup scheduler) 

575 # ensure sweeps happen regardless of traffic patterns. 

576 try: 

577 from ..database.thread_local_session import cleanup_dead_threads 

578 

579 cleanup_dead_threads() 

580 except Exception: 

581 logger.debug("Error during dead thread sweep", exc_info=True) 

582 

583 # Clean up any thread-local database session that may have been created 

584 # via get_metrics_session() fallback in session_context.py (e.g. background 

585 # threads or error paths where g.db_session was unavailable). 

586 try: 

587 from ..database.thread_local_session import cleanup_current_thread 

588 

589 cleanup_current_thread() 

590 except Exception: 

591 logger.debug( 

592 "Error during thread-local session cleanup", exc_info=True 

593 ) 

594 

595 # Add a middleware layer to handle abrupt disconnections 

596 @app.before_request 

597 def handle_websocket_requests(): 

598 if request.path.startswith("/socket.io"): 

599 try: 

600 if not request.environ.get("werkzeug.socket"): 600 ↛ 606line 600 didn't jump to line 606 because the condition on line 600 was always true

601 return None 

602 except Exception: 

603 logger.exception("WebSocket preprocessing error") 

604 # Return empty response to prevent further processing 

605 return "", 200 

606 return None 

607 

608 # Note: CORS headers for API routes are now handled by SecurityHeaders middleware 

609 # (see src/local_deep_research/security/security_headers.py) 

610 

611 

612def register_blueprints(app): 

613 """Register blueprints with the Flask app.""" 

614 

615 # Import blueprints 

616 logger.info("Importing blueprints...") 

617 

618 # Import benchmark blueprint 

619 from ..benchmarks.web_api.benchmark_routes import benchmark_bp 

620 

621 logger.info("Importing API blueprint...") 

622 from .api import api_blueprint # Import the API blueprint 

623 

624 logger.info("Importing auth blueprint...") 

625 from .auth import auth_bp # Import the auth blueprint 

626 

627 logger.info("Importing API routes blueprint...") 

628 from .routes.api_routes import api_bp # Import the API blueprint 

629 

630 logger.info("Importing context overflow API...") 

631 from .routes.context_overflow_api import ( 

632 context_overflow_bp, 

633 ) # Import context overflow API 

634 

635 logger.info("Importing history routes...") 

636 from .routes.history_routes import history_bp 

637 

638 logger.info("Importing metrics routes...") 

639 from .routes.metrics_routes import metrics_bp 

640 

641 logger.info("Importing research routes...") 

642 from .routes.research_routes import research_bp 

643 

644 logger.info("Importing settings routes...") 

645 from .routes.settings_routes import settings_bp 

646 

647 logger.info("All core blueprints imported successfully") 

648 

649 # Add root route 

650 @app.route("/") 

651 def index(): 

652 """Root route - redirect to login if not authenticated""" 

653 from flask import redirect, session, url_for 

654 

655 from ..constants import get_available_strategies 

656 from ..database.session_context import get_user_db_session 

657 from ..utilities.db_utils import get_settings_manager 

658 from .utils.templates import render_template_with_defaults 

659 

660 # Check if user is authenticated 

661 if "username" not in session: 

662 return redirect(url_for("auth.login")) 

663 

664 # Load current settings from database using proper session context 

665 username = session.get("username") 

666 settings = {} 

667 with get_user_db_session(username) as db_session: 

668 if db_session: 668 ↛ 712line 668 didn't jump to line 712

669 settings_manager = get_settings_manager(db_session, username) 

670 settings = { 

671 "llm_provider": settings_manager.get_setting( 

672 "llm.provider", "ollama" 

673 ), 

674 "llm_model": settings_manager.get_setting("llm.model", ""), 

675 "llm_openai_endpoint_url": settings_manager.get_setting( 

676 "llm.openai_endpoint.url", "" 

677 ), 

678 "llm_ollama_url": settings_manager.get_setting( 

679 "llm.ollama.url" 

680 ), 

681 "llm_lmstudio_url": settings_manager.get_setting( 

682 "llm.lmstudio.url" 

683 ), 

684 "llm_local_context_window_size": settings_manager.get_setting( 

685 "llm.local_context_window_size" 

686 ), 

687 "search_tool": settings_manager.get_setting( 

688 "search.tool", "" 

689 ), 

690 "search_iterations": settings_manager.get_setting( 

691 "search.iterations", 3 

692 ), 

693 "search_questions_per_iteration": settings_manager.get_setting( 

694 "search.questions_per_iteration", 2 

695 ), 

696 "search_strategy": settings_manager.get_setting( 

697 "search.search_strategy", "source-based" 

698 ), 

699 # Egress policy controls (Stage 1c UI). 

700 "policy_egress_scope": settings_manager.get_setting( 

701 "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

702 ), 

703 "llm_require_local_endpoint": settings_manager.get_setting( 

704 "llm.require_local_endpoint", False 

705 ), 

706 "embeddings_require_local": settings_manager.get_setting( 

707 "embeddings.require_local", False 

708 ), 

709 } 

710 

711 # Debug logging 

712 log_settings(settings, "Research page settings loaded") 

713 

714 return render_template_with_defaults( 

715 "pages/research.html", 

716 settings=settings, 

717 strategies=get_available_strategies(), 

718 ) 

719 

720 # Register auth blueprint FIRST (so login page is accessible) 

721 app.register_blueprint(auth_bp) # Already has url_prefix="/auth" 

722 

723 # Register other blueprints 

724 app.register_blueprint(research_bp) 

725 app.register_blueprint(history_bp) # Already has url_prefix="/history" 

726 app.register_blueprint(metrics_bp) 

727 app.register_blueprint(settings_bp) # Already has url_prefix="/settings" 

728 app.register_blueprint( 

729 api_bp, url_prefix="/research/api" 

730 ) # Register API blueprint with prefix 

731 app.register_blueprint(benchmark_bp) # Register benchmark blueprint 

732 app.register_blueprint( 

733 context_overflow_bp, url_prefix="/metrics" 

734 ) # Register context overflow API 

735 

736 # Register news API routes 

737 from .routes import news_routes 

738 

739 app.register_blueprint(news_routes.bp) 

740 logger.info("News API routes registered successfully") 

741 

742 # Register chat routes 

743 from ..chat.routes import chat_bp 

744 

745 app.register_blueprint(chat_bp) 

746 logger.info("Chat routes registered successfully") 

747 

748 # Register follow-up research routes 

749 from ..followup_research.routes import followup_bp 

750 

751 app.register_blueprint(followup_bp) 

752 logger.info("Follow-up research routes registered successfully") 

753 

754 # Register news page blueprint 

755 from ..news.web import create_news_blueprint 

756 

757 news_bp = create_news_blueprint() 

758 app.register_blueprint(news_bp, url_prefix="/news") 

759 logger.info("News page routes registered successfully") 

760 

761 # Register API v1 blueprint 

762 app.register_blueprint(api_blueprint) # Already has url_prefix='/api/v1' 

763 

764 # Register Research Library blueprint 

765 from ..research_library import library_bp, rag_bp, delete_bp 

766 

767 app.register_blueprint(library_bp) # Already has url_prefix='/library' 

768 logger.info("Research Library routes registered successfully") 

769 

770 # Register RAG Management blueprint 

771 app.register_blueprint(rag_bp) # Already has url_prefix='/library' 

772 logger.info("RAG Management routes registered successfully") 

773 

774 # Register Zotero integration blueprint 

775 from ..research_library import zotero_bp 

776 

777 app.register_blueprint(zotero_bp) # Already has url_prefix='/library' 

778 logger.info("Zotero integration routes registered successfully") 

779 

780 # Register Deletion Management blueprint 

781 app.register_blueprint(delete_bp) # Already has url_prefix='/library/api' 

782 logger.info("Deletion Management routes registered successfully") 

783 

784 # Register Semantic Search blueprint 

785 from ..research_library.search import search_bp 

786 

787 app.register_blueprint(search_bp) # url_prefix='/library' 

788 logger.info("Semantic Search routes registered successfully") 

789 

790 # Register Document Scheduler blueprint 

791 from ..research_scheduler.routes import scheduler_bp 

792 

793 app.register_blueprint(scheduler_bp) 

794 logger.info("Document Scheduler routes registered successfully") 

795 

796 # Register Notes blueprint 

797 from .routes.notes_routes import notes_bp 

798 

799 app.register_blueprint(notes_bp) 

800 logger.info("Notes blueprint registered") 

801 

802 # Register Unified Search blueprint ("Search everything") 

803 from .routes.unified_search_routes import unified_search_bp 

804 

805 app.register_blueprint(unified_search_bp) 

806 logger.info("Unified Search blueprint registered") 

807 

808 # CSRF exemptions — Flask-WTF requires Blueprint objects (not strings) 

809 # to populate _exempt_blueprints. Passing strings only populates 

810 # _exempt_views, which compares against module-qualified names and 

811 # silently fails to match Flask endpoint names. 

812 if hasattr(app, "extensions") and "csrf" in app.extensions: 

813 csrf = app.extensions["csrf"] 

814 # Only api_v1 is exempt: it's a programmatic REST API used by 

815 # external clients. The api, benchmark, and research blueprints 

816 # are browser-facing and the frontend already sends CSRF tokens. 

817 for bp_name in ("api_v1",): 

818 bp_obj = app.blueprints.get(bp_name) 

819 if bp_obj is not None: 819 ↛ 817line 819 didn't jump to line 817 because the condition on line 819 was always true

820 csrf.exempt(bp_obj) 

821 

822 # Add favicon route 

823 # Exempt favicon from rate limiting 

824 @app.route("/favicon.ico") 

825 @limiter.exempt 

826 def favicon(): 

827 static_dir = app.config.get("STATIC_DIR", "static") 

828 return send_from_directory( 

829 static_dir, "favicon.ico", mimetype="image/x-icon" 

830 ) 

831 

832 # Add static route at the app level for compatibility 

833 # Exempt static files from rate limiting 

834 import re 

835 

836 _HASHED_FILENAME_RE = re.compile(r"\.[A-Za-z0-9_-]{8,}\.") 

837 

838 @app.route("/static/<path:path>") 

839 @limiter.exempt 

840 def app_serve_static(path): 

841 from ..security.path_validator import PathValidator 

842 

843 static_dir = Path(app.config.get("STATIC_DIR", "static")) 

844 

845 # First try to serve from dist directory (for built assets). 

846 # Flask captures path as "dist/js/app.abc.js", so strip the 

847 # "dist/" prefix before joining with dist_dir to avoid a 

848 # double-dist path (static/dist/dist/...). 

849 dist_prefix = "dist/" 

850 dist_dir = static_dir / "dist" 

851 if path.startswith(dist_prefix): 851 ↛ 852line 851 didn't jump to line 852 because the condition on line 851 was never true

852 dist_relative = path[len(dist_prefix) :] 

853 try: 

854 validated_path = PathValidator.validate_safe_path( 

855 dist_relative, 

856 dist_dir, 

857 allow_absolute=False, 

858 required_extensions=None, 

859 ) 

860 

861 if validated_path and validated_path.exists(): 

862 response = make_response( 

863 send_from_directory(str(dist_dir), dist_relative) 

864 ) 

865 if _HASHED_FILENAME_RE.search(dist_relative): 

866 # Content-hashed files are safe for immutable caching 

867 response.headers["Cache-Control"] = ( 

868 "public, max-age=31536000, immutable" 

869 ) 

870 else: 

871 response.headers["Cache-Control"] = ( 

872 "public, max-age=0, must-revalidate" 

873 ) 

874 return response 

875 except ValueError: 

876 pass 

877 

878 # Fall back to dist directory for Vite-built assets (fonts, etc.) 

879 # Vite uses base: '/static/' so CSS references /static/fonts/... 

880 # but the files live in static/dist/fonts/... 

881 try: 

882 validated_path = PathValidator.validate_safe_path( 

883 path, dist_dir, allow_absolute=False, required_extensions=None 

884 ) 

885 

886 if validated_path and validated_path.exists(): 886 ↛ 887line 886 didn't jump to line 887 because the condition on line 886 was never true

887 response = make_response( 

888 send_from_directory(str(dist_dir), path) 

889 ) 

890 if _HASHED_FILENAME_RE.search(path): 

891 response.headers["Cache-Control"] = ( 

892 "public, max-age=31536000, immutable" 

893 ) 

894 else: 

895 response.headers["Cache-Control"] = ( 

896 "public, max-age=0, must-revalidate" 

897 ) 

898 return response 

899 except ValueError: 

900 pass 

901 

902 # Fall back to regular static folder 

903 try: 

904 validated_path = PathValidator.validate_safe_path( 

905 path, static_dir, allow_absolute=False, required_extensions=None 

906 ) 

907 

908 if validated_path and validated_path.exists(): 908 ↛ 909line 908 didn't jump to line 909 because the condition on line 908 was never true

909 response = make_response( 

910 send_from_directory(str(static_dir), path) 

911 ) 

912 # Non-hashed files must revalidate on each request 

913 response.headers["Cache-Control"] = ( 

914 "public, max-age=0, must-revalidate" 

915 ) 

916 return response 

917 except ValueError: 

918 # Path validation failed 

919 pass 

920 

921 abort(404) 

922 return None 

923 

924 

925def register_error_handlers(app): 

926 """Register error handlers with the Flask app.""" 

927 from .auth.decorators import _is_api_path 

928 

929 @app.errorhandler(404) 

930 def not_found(error): 

931 if _is_api_path(request.path): 

932 return make_response(jsonify({"error": "Not found"}), 404) 

933 return make_response("Not found", 404) 

934 

935 @app.errorhandler(500) 

936 def server_error(error): 

937 if _is_api_path(request.path): 

938 return make_response(jsonify({"error": "Server error"}), 500) 

939 return make_response("Server error", 500) 

940 

941 @app.errorhandler(401) 

942 def handle_unauthorized(error): 

943 if _is_api_path(request.path): 

944 return make_response( 

945 jsonify({"error": "Authentication required"}), 

946 401, 

947 ) 

948 from .auth.decorators import _safe_redirect_to_login 

949 

950 return _safe_redirect_to_login() 

951 

952 @app.errorhandler(413) 

953 def handle_request_too_large(error): 

954 if _is_api_path(request.path): 

955 return make_response( 

956 jsonify({"error": "Request too large"}), 

957 413, 

958 ) 

959 return make_response("Request too large", 413) 

960 

961 from .exceptions import WebAPIException 

962 

963 @app.errorhandler(WebAPIException) 

964 def handle_web_api_exception(error): 

965 """Handle WebAPIException and return JSON.""" 

966 logger.error( 

967 "Web API error: {} (status {})", error.error_code, error.status_code 

968 ) 

969 return jsonify(error.to_dict()), error.status_code 

970 

971 # PolicyDeniedError can escape any synchronous request-path PEP 

972 # (LLM, embeddings, URL fetch) that wasn't caught at the call site. 

973 # Without this handler it surfaces as a 500 — turn it into a clean 

974 # 400 with the decision reason so the user (and the operator 

975 # audit log) sees a policy-shaped error instead of a stack trace. 

976 try: 

977 from ..security.egress.policy import PolicyDeniedError 

978 

979 @app.errorhandler(PolicyDeniedError) 

980 def handle_policy_denied(error): 

981 reason = getattr( 

982 getattr(error, "decision", None), "reason", "denied" 

983 ) 

984 target = getattr(error, "target", "") 

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

986 "PolicyDeniedError surfaced at Flask error handler", 

987 reason=reason, 

988 target=target, 

989 ) 

990 return make_response( 

991 jsonify( 

992 { 

993 "status": "error", 

994 "message": ( 

995 f"Egress policy refused this request: {reason}" 

996 ), 

997 } 

998 ), 

999 400, 

1000 ) 

1001 except ImportError: 

1002 # egress_policy is in-tree; skipping the handler is only useful 

1003 # for stripped-down test harnesses. 

1004 pass 

1005 

1006 # Handle CSRF validation errors as JSON 

1007 try: 

1008 from flask_wtf.csrf import CSRFError 

1009 

1010 @app.errorhandler(CSRFError) 

1011 def handle_csrf_error(error): 

1012 return make_response( 

1013 jsonify({"error": str(error.description)}), 400 

1014 ) 

1015 except ImportError: 

1016 pass 

1017 

1018 # Handle News API exceptions globally 

1019 try: 

1020 from ..news.exceptions import NewsAPIException 

1021 

1022 @app.errorhandler(NewsAPIException) 

1023 def handle_news_api_exception(error): 

1024 """Handle NewsAPIException and convert to JSON response.""" 

1025 from loguru import logger 

1026 

1027 logger.error( 

1028 "News API error: {} (status {})", 

1029 error.error_code, 

1030 error.status_code, 

1031 ) 

1032 return jsonify(error.to_dict()), error.status_code 

1033 except ImportError: 

1034 # News module not available 

1035 pass 

1036 

1037 

1038def create_database(app): 

1039 """ 

1040 DEPRECATED: Database creation is now handled per-user via encrypted_db.py 

1041 This function is kept for compatibility but does nothing. 

1042 """ 

1043 pass