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
« 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
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
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)
30# Removed DB_PATH import - using per-user databases now
31from .services.socket_service import SocketIOService
34class DiskSpoolingRequest(Request):
35 """Custom Request class that spools large file uploads to disk.
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.
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 """
47 # Files larger than 5MB are spooled to disk instead of memory
48 max_form_memory_size = 5 * 1024 * 1024 # 5MB threshold
51def create_app():
52 """
53 Create and configure the Flask application.
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
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())
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())
84 logger.info("Initializing Local Deep Research application...")
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()
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())
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
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]
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
140 app.wsgi_app = RequestTimingMiddleware(app.wsgi_app) # type: ignore[method-assign]
141 logger.info("Request-timing forensics middleware enabled (CI/TESTING)")
143 # App configuration
144 # Generate or load a unique SECRET_KEY per installation
145 import secrets
146 from ..config.paths import get_data_directory
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"
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 )
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
219 app.before_request(arm_notes_body_cap)
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
228 # Initialize security headers middleware
229 SecurityHeaders(app)
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
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)
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
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
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
276 # Note: CSRF exemptions for API blueprints are applied after blueprint
277 # registration below (search for "CSRF exemptions" in this file).
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
284 # Per-user databases are created automatically via encrypted_db.py
286 # Log data location and security information
287 from ..config.paths import get_data_directory
288 from ..database.encrypted_db import db_manager
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 )
299 # Check if using custom location
300 from local_deep_research.settings.manager import SettingsManager
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")
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 )
322 logger.info(
323 "TIP: You can change the data location by setting the LDR_DATA_DIR environment variable."
324 )
325 logger.info("=" * 60)
327 # Initialize Vite helper for asset management
328 from .utils.vite_helper import vite
330 vite.init_app(app)
332 # Initialize Theme helper for auto-detecting themes from CSS
333 from .utils.theme_helper import theme_helper
335 theme_helper.init_app(app)
337 # Generate combined themes.css from individual theme files
338 from .themes import theme_registry
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")
356 # Register socket service
357 socket_service = SocketIOService(app=app)
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 )
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
370 scheduler_enabled = get_env_setting("news.scheduler.enabled", True)
371 logger.info(f"News scheduler enabled: {scheduler_enabled}")
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
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()
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]
399 # Apply middleware
400 logger.info("Applying middleware...")
401 apply_middleware(app)
402 logger.info("Middleware applied successfully")
404 # Register blueprints
405 logger.info("Registering blueprints...")
406 register_blueprints(app)
407 logger.info("Blueprints registered successfully")
409 # Register error handlers
410 logger.info("Registering error handlers...")
411 register_error_handlers(app)
412 logger.info("Error handlers registered successfully")
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
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
433 logger.info(f"Queue processor v2 enabled: {queue_processor_enabled}")
434 if queue_processor_enabled:
435 from .queue.processor_v2 import queue_processor
437 queue_processor.start()
438 logger.info("Started research queue processor v2")
439 else:
440 logger.info("Queue processor v2 disabled - not starting")
442 logger.info("App factory completed successfully")
444 return app, socket_service
447def apply_middleware(app):
448 """Apply middleware to the Flask app."""
450 # Import auth decorators and middleware
451 logger.info("Importing cleanup_middleware...")
452 from .auth.cleanup_middleware import cleanup_completed_research
454 logger.info("Importing database_middleware...")
455 from .auth.database_middleware import ensure_user_database
457 logger.info("Importing decorators...")
458 from .auth.decorators import inject_current_user
460 logger.info("Importing queue_middleware...")
461 from .auth.queue_middleware import process_pending_queue_operations
463 logger.info("Importing queue_middleware_v2...")
464 from .auth.queue_middleware_v2 import notify_queue_processor
466 logger.info("Importing session_cleanup...")
467 from .auth.session_cleanup import cleanup_stale_sessions
469 logger.info("All middleware imports completed")
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)
485 logger.info("All middleware registered")
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.
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 )
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
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 )
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 }
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
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")
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
579 cleanup_dead_threads()
580 except Exception:
581 logger.debug("Error during dead thread sweep", exc_info=True)
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
589 cleanup_current_thread()
590 except Exception:
591 logger.debug(
592 "Error during thread-local session cleanup", exc_info=True
593 )
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
608 # Note: CORS headers for API routes are now handled by SecurityHeaders middleware
609 # (see src/local_deep_research/security/security_headers.py)
612def register_blueprints(app):
613 """Register blueprints with the Flask app."""
615 # Import blueprints
616 logger.info("Importing blueprints...")
618 # Import benchmark blueprint
619 from ..benchmarks.web_api.benchmark_routes import benchmark_bp
621 logger.info("Importing API blueprint...")
622 from .api import api_blueprint # Import the API blueprint
624 logger.info("Importing auth blueprint...")
625 from .auth import auth_bp # Import the auth blueprint
627 logger.info("Importing API routes blueprint...")
628 from .routes.api_routes import api_bp # Import the API blueprint
630 logger.info("Importing context overflow API...")
631 from .routes.context_overflow_api import (
632 context_overflow_bp,
633 ) # Import context overflow API
635 logger.info("Importing history routes...")
636 from .routes.history_routes import history_bp
638 logger.info("Importing metrics routes...")
639 from .routes.metrics_routes import metrics_bp
641 logger.info("Importing research routes...")
642 from .routes.research_routes import research_bp
644 logger.info("Importing settings routes...")
645 from .routes.settings_routes import settings_bp
647 logger.info("All core blueprints imported successfully")
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
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
660 # Check if user is authenticated
661 if "username" not in session:
662 return redirect(url_for("auth.login"))
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 }
711 # Debug logging
712 log_settings(settings, "Research page settings loaded")
714 return render_template_with_defaults(
715 "pages/research.html",
716 settings=settings,
717 strategies=get_available_strategies(),
718 )
720 # Register auth blueprint FIRST (so login page is accessible)
721 app.register_blueprint(auth_bp) # Already has url_prefix="/auth"
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
736 # Register news API routes
737 from .routes import news_routes
739 app.register_blueprint(news_routes.bp)
740 logger.info("News API routes registered successfully")
742 # Register chat routes
743 from ..chat.routes import chat_bp
745 app.register_blueprint(chat_bp)
746 logger.info("Chat routes registered successfully")
748 # Register follow-up research routes
749 from ..followup_research.routes import followup_bp
751 app.register_blueprint(followup_bp)
752 logger.info("Follow-up research routes registered successfully")
754 # Register news page blueprint
755 from ..news.web import create_news_blueprint
757 news_bp = create_news_blueprint()
758 app.register_blueprint(news_bp, url_prefix="/news")
759 logger.info("News page routes registered successfully")
761 # Register API v1 blueprint
762 app.register_blueprint(api_blueprint) # Already has url_prefix='/api/v1'
764 # Register Research Library blueprint
765 from ..research_library import library_bp, rag_bp, delete_bp
767 app.register_blueprint(library_bp) # Already has url_prefix='/library'
768 logger.info("Research Library routes registered successfully")
770 # Register RAG Management blueprint
771 app.register_blueprint(rag_bp) # Already has url_prefix='/library'
772 logger.info("RAG Management routes registered successfully")
774 # Register Zotero integration blueprint
775 from ..research_library import zotero_bp
777 app.register_blueprint(zotero_bp) # Already has url_prefix='/library'
778 logger.info("Zotero integration routes registered successfully")
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")
784 # Register Semantic Search blueprint
785 from ..research_library.search import search_bp
787 app.register_blueprint(search_bp) # url_prefix='/library'
788 logger.info("Semantic Search routes registered successfully")
790 # Register Document Scheduler blueprint
791 from ..research_scheduler.routes import scheduler_bp
793 app.register_blueprint(scheduler_bp)
794 logger.info("Document Scheduler routes registered successfully")
796 # Register Notes blueprint
797 from .routes.notes_routes import notes_bp
799 app.register_blueprint(notes_bp)
800 logger.info("Notes blueprint registered")
802 # Register Unified Search blueprint ("Search everything")
803 from .routes.unified_search_routes import unified_search_bp
805 app.register_blueprint(unified_search_bp)
806 logger.info("Unified Search blueprint registered")
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)
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 )
832 # Add static route at the app level for compatibility
833 # Exempt static files from rate limiting
834 import re
836 _HASHED_FILENAME_RE = re.compile(r"\.[A-Za-z0-9_-]{8,}\.")
838 @app.route("/static/<path:path>")
839 @limiter.exempt
840 def app_serve_static(path):
841 from ..security.path_validator import PathValidator
843 static_dir = Path(app.config.get("STATIC_DIR", "static"))
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 )
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
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 )
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
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 )
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
921 abort(404)
922 return None
925def register_error_handlers(app):
926 """Register error handlers with the Flask app."""
927 from .auth.decorators import _is_api_path
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)
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)
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
950 return _safe_redirect_to_login()
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)
961 from .exceptions import WebAPIException
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
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
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
1006 # Handle CSRF validation errors as JSON
1007 try:
1008 from flask_wtf.csrf import CSRFError
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
1018 # Handle News API exceptions globally
1019 try:
1020 from ..news.exceptions import NewsAPIException
1022 @app.errorhandler(NewsAPIException)
1023 def handle_news_api_exception(error):
1024 """Handle NewsAPIException and convert to JSON response."""
1025 from loguru import logger
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
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