Coverage for src/local_deep_research/web/fastapi_app.py: 92%

760 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2FastAPI application factory for Local Deep Research. 

3 

4This replaces Flask's app_factory.py as the primary web application entry point. 

5It mounts Socket.IO as an ASGI sub-app, configures middleware, templates, 

6static files, and background services. 

7""" 

8 

9import ipaddress 

10import logging 

11import os 

12import re 

13import secrets 

14import sys 

15import time 

16from contextlib import asynccontextmanager 

17from importlib import resources as importlib_resources 

18from pathlib import Path 

19 

20from fastapi import FastAPI, Request 

21from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse 

22from loguru import logger 

23from starlette.middleware.sessions import SessionMiddleware 

24 

25from ..__version__ import __version__ 

26from ..security import get_security_default 

27from ..utilities.type_utils import to_bool 

28from ..utilities.log_utils import InterceptHandler 

29 

30# --------------------------------------------------------------------------- 

31# Directories 

32# --------------------------------------------------------------------------- 

33 

34try: 

35 _PACKAGE_DIR = importlib_resources.files("local_deep_research") / "web" 

36 with importlib_resources.as_file(_PACKAGE_DIR) as _pkg: 

37 STATIC_DIR = (_pkg / "static").as_posix() 

38 TEMPLATE_DIR = (_pkg / "templates").as_posix() 

39except Exception: 

40 STATIC_DIR = str(Path("static").resolve()) 

41 TEMPLATE_DIR = str(Path("templates").resolve()) 

42 

43# Templates shared across all routers (defined in template_config to avoid circular imports) 

44from .template_config import ( 

45 templates, 

46) 

47 

48# --------------------------------------------------------------------------- 

49# Secret key (same logic as Flask app_factory) 

50# --------------------------------------------------------------------------- 

51 

52 

53def _load_secret_key() -> str: 

54 """Load or generate a persistent SECRET_KEY for session signing. 

55 

56 Falling back to an in-memory key on read errors silently invalidates 

57 every existing session on restart and produces no operator alert, 

58 so this raises a hard error instead. 

59 """ 

60 from ..config.paths import get_data_directory 

61 

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

63 

64 from ..security.directory_creation import create_directory 

65 

66 create_directory( 

67 secret_key_file.parent, context="secret key file directory" 

68 ) 

69 new_key = secrets.token_hex(32) 

70 try: 

71 fd = os.open( 

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

73 ) 

74 try: 

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

76 finally: 

77 os.close(fd) 

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

79 return new_key 

80 except FileExistsError: 

81 # File exists — read it. Any error here is fatal: an ephemeral 

82 # in-memory key would silently invalidate every existing session 

83 # on restart and prevent any future restart from being consistent. 

84 try: 

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

86 key = f.read().strip() 

87 except Exception as e: 

88 raise RuntimeError( 

89 f"Cannot read SECRET_KEY file at {secret_key_file}: {e}. " 

90 "Fix the file permissions/contents or remove the file to " 

91 "regenerate (this will invalidate all sessions)." 

92 ) from e 

93 # Empty / whitespace-only file is also fatal — itsdangerous would 

94 # accept it and derive a deterministic signing key from no secret, 

95 # silently breaking session integrity. 

96 if not key: 

97 raise RuntimeError( 

98 f"SECRET_KEY file at {secret_key_file} is empty. " 

99 "Delete the file to regenerate a new key (this will " 

100 "invalidate all existing sessions)." 

101 ) 

102 return key 

103 except OSError as e: 

104 # Cannot write the key file. Same reasoning — refuse rather than 

105 # silently fall back to a non-persistent key. 

106 raise RuntimeError( 

107 f"Cannot write SECRET_KEY file at {secret_key_file}: {e}. " 

108 "Fix the data directory permissions and retry." 

109 ) from e 

110 

111 

112SECRET_KEY = _load_secret_key() 

113 

114 

115def warn_if_threadpool_exceeds_db_pool(worker_threads: int) -> bool: 

116 """Warn when the AnyIO worker pool is larger than a user's DB pool. 

117 

118 Raising ``web.threadpool_max_threads`` above the per-user pool capacity is 

119 not merely a tuning choice. ``WorkerCleanupAPIRoute`` returns each sync 

120 route's connections when that request finishes, but every concurrently 

121 active DB-touching worker can still hold one pool slot. More workers than 

122 slots therefore permit a burst from one user to exhaust the pool before 

123 any request reaches its cleanup boundary. 

124 

125 Below the pool capacity that is harmless, which is why this warns instead 

126 of failing. Above it, one user's concurrent requests can exhaust their own 

127 pool, after which every further request waits out ``pool_timeout``. 

128 

129 Returns True when the warning fired, so it can be tested directly rather 

130 than only through a lifespan startup. 

131 """ 

132 from ..database.pool_config import MAX_OVERFLOW, POOL_SIZE 

133 

134 pool_capacity = POOL_SIZE + MAX_OVERFLOW 

135 if worker_threads <= pool_capacity: 

136 return False 

137 logger.warning( 

138 "web.threadpool_max_threads ({}) exceeds the per-user database pool " 

139 "capacity ({} = pool_size {} + max_overflow {}). Concurrent " 

140 "synchronous routes can each hold one connection, so a single user's " 

141 "concurrent requests can exhaust their pool and then block for " 

142 "pool_timeout. Keep this at or below {}.", 

143 worker_threads, 

144 pool_capacity, 

145 POOL_SIZE, 

146 MAX_OVERFLOW, 

147 pool_capacity, 

148 ) 

149 return True 

150 

151 

152# --------------------------------------------------------------------------- 

153# Lifespan: startup / shutdown 

154# --------------------------------------------------------------------------- 

155 

156 

157@asynccontextmanager 

158async def lifespan(app: FastAPI): 

159 """Manage application lifecycle — start/stop background services.""" 

160 # --- Startup --- 

161 logger.info(f"Starting Local Deep Research v{__version__}") 

162 

163 # Capture uvicorn's running event loop so background threads can 

164 # dispatch Socket.IO emits via run_coroutine_threadsafe. Without 

165 # this, emits from research workers / log queue silently no-op 

166 # (asyncio.get_event_loop() in a worker thread doesn't return uvicorn's loop). 

167 import asyncio as _asyncio 

168 from .services.socketio_asgi import init_lock, set_main_loop 

169 

170 set_main_loop(_asyncio.get_running_loop()) 

171 # Eagerly create the Socket.IO subscription lock now that the loop is 

172 # running, so the first connect/subscribe events don't race on lazy init. 

173 init_lock() 

174 

175 # Size the AnyIO worker pool that serves every plain `def` route. 

176 # 

177 # Starlette runs sync handlers via `anyio.to_thread.run_sync`, whose 

178 # default CapacityLimiter is 40 threads — and that pool is SHARED with 

179 # async dependency solving and response validation, so exhausting it 

180 # degrades async routes too. This app has ~248 sync routes against 65 

181 # async ones, each sync request holding a worker for its full duration 

182 # (including a first-call SQLCipher open), and it runs with workers=1, 

183 # so there is no second process to absorb the overflow. Measured 

184 # symptom: /api/v1/health went 0.8ms -> 9.8s at 80 concurrent requests, 

185 # past the 8s Docker healthcheck timeout — i.e. the container reports 

186 # unhealthy under load rather than merely slow. 

187 # 

188 # Left at AnyIO's default unless an operator opts in, so this changes 

189 # nothing by default: raising it is not free (context-switch overhead, 

190 # and more threads contend for the same per-user QueuePool), and the 

191 # right value depends on the deployment's core count and workload. The 

192 # point is to make an invisible framework default into a deliberate, 

193 # tunable decision — the migration hazard here is not the number, it is 

194 # that nobody knows the number exists. 

195 try: 

196 from ..settings.env_registry import ( 

197 get_env_setting as _get_env_setting, 

198 ) 

199 

200 configured = _get_env_setting("web.threadpool_max_threads") 

201 if configured: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true

202 import anyio.to_thread 

203 

204 limiter = anyio.to_thread.current_default_thread_limiter() 

205 previous = limiter.total_tokens 

206 limiter.total_tokens = int(configured) 

207 logger.info( 

208 "AnyIO worker pool resized: {} -> {} threads " 

209 "(LDR_WEB_THREADPOOL_MAX_THREADS)", 

210 previous, 

211 limiter.total_tokens, 

212 ) 

213 

214 warn_if_threadpool_exceeds_db_pool(limiter.total_tokens) 

215 except Exception: 

216 # Never let a tuning knob stop the server from starting. 

217 logger.exception( 

218 "Could not apply web.threadpool_max_threads; " 

219 "continuing with the AnyIO default" 

220 ) 

221 

222 # Route stdlib loggers through loguru 

223 for name in ("uvicorn", "uvicorn.access", "uvicorn.error"): 

224 uv_logger = logging.getLogger(name) 

225 uv_logger.setLevel(logging.WARNING) 

226 if not any(isinstance(h, InterceptHandler) for h in uv_logger.handlers): 

227 uv_logger.addHandler(InterceptHandler()) 

228 

229 ap_logger = logging.getLogger("apscheduler") 

230 ap_logger.setLevel(logging.WARNING) 

231 if not any(isinstance(h, InterceptHandler) for h in ap_logger.handlers): 

232 ap_logger.addHandler(InterceptHandler()) 

233 

234 # Log data location 

235 from ..config.paths import get_data_directory 

236 from ..database.encrypted_db import db_manager 

237 

238 data_dir = get_data_directory() 

239 logger.info("=" * 60) 

240 logger.info("DATA STORAGE INFORMATION") 

241 logger.info("=" * 60) 

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

243 logger.info( 

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

245 ) 

246 if db_manager.has_encryption: 246 ↛ 249line 246 didn't jump to line 249 because the condition on line 246 was always true

247 logger.info("SECURITY: Databases are encrypted with SQLCipher.") 

248 else: 

249 logger.warning( 

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

251 ) 

252 logger.info("=" * 60) 

253 

254 # Surface a cipher misconfiguration that otherwise only shows up as 

255 # affected users getting "Invalid username or password": a relaxed 

256 # SQLCipher KDF (test mode) on a deployment that already holds real user 

257 # databases. No-op on fresh installs and when the effective KDF is at the 

258 # production floor. Wrapped so a check failure can never block server boot. 

259 try: 

260 from ..database.sqlcipher_utils import ( 

261 warn_if_weak_kdf_with_existing_databases, 

262 ) 

263 

264 if db_manager.has_encryption: 264 ↛ 270line 264 didn't jump to line 270 because the condition on line 264 was always true

265 warn_if_weak_kdf_with_existing_databases(db_manager.data_dir) 

266 except Exception: 

267 logger.exception("Weak-KDF startup configuration check failed") 

268 

269 # Initialize Theme helper 

270 from .themes import theme_registry 

271 

272 try: 

273 static_dir = Path(STATIC_DIR) 

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

275 combined_css = theme_registry.get_combined_css() 

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

277 logger.debug( 

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

279 ) 

280 except Exception: 

281 logger.warning("Error generating combined themes.css") 

282 

283 # Start log queue processor (drains background-thread DB log entries). 

284 # 

285 # Wrapped like every other optional startup step in this lifespan (the 

286 # AnyIO threadpool knob, the weak-KDF check, themes.css). The lifespan is 

287 # two-tier and an UNGUARDED step that raises produces 

288 # `lifespan.startup.failed`, so uvicorn never serves at all -- pinned by 

289 # tests/web/test_lifespan_startup_shutdown.py. 

290 # `start_log_queue_processor` ends in `threading.Thread(...).start()`, 

291 # which raises `RuntimeError: can't start new thread` under a container 

292 # pids / RLIMIT_NPROC ceiling; a best-effort logging daemon must never be 

293 # the reason the server fails to boot. main guards the same call in 

294 # `web/app.py::main()` (PR #3488) and the guard was lost in the port. 

295 # The matching `stop_log_queue_processor()` on the shutdown path is 

296 # itself wrapped and is a no-op when no daemon ever started, so it stays 

297 # reachable either way; logging degrades to the shutdown flush. 

298 from ..utilities.log_utils import start_log_queue_processor 

299 

300 try: 

301 start_log_queue_processor() 

302 except Exception: 

303 logger.exception( 

304 "Failed to start log queue processor; continuing without the " 

305 "background log drain (queued entries flush at shutdown instead)" 

306 ) 

307 

308 # Start research queue processor — enabled by default in normal app runs, 

309 # but default-off under pytest so app fixtures don't each spawn a daemon 

310 # thread. An explicit web.queue_processor.enabled env override still wins 

311 # (tests that need the processor opt in). Ports #5055 from the retired 

312 # Flask app_factory to the FastAPI lifespan. The singleton is imported 

313 # unconditionally (cheap — only .start() spawns the thread) so the 

314 # shutdown path below can always call .stop(). 

315 from ..settings.env_registry import get_env_setting, registry 

316 from .queue.processor_v2 import queue_processor 

317 

318 queue_processor_enabled = get_env_setting( 

319 "web.queue_processor.enabled", True 

320 ) 

321 _qp_setting = registry.get_setting_object("web.queue_processor.enabled") 

322 _qp_env_set = bool(_qp_setting and _qp_setting.is_set) 

323 if os.getenv("PYTEST_CURRENT_TEST") and not _qp_env_set: 323 ↛ 326line 323 didn't jump to line 326 because the condition on line 323 was always true

324 queue_processor_enabled = False 

325 

326 if queue_processor_enabled: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true

327 queue_processor.start() 

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

329 else: 

330 # Disabling the processor also disables the ONLY drain path for 

331 # ``pending_operations``: research worker threads that cannot reach 

332 # the DB directly (e.g. password lookup failed) fall back to 

333 # ``queue_progress_update`` / ``queue_error_update``, and the only 

334 # consumer of that queue is ``_drain_pending_operations``, which runs 

335 # inside the loop started just above. Under Flask a second, always-on 

336 # ``before_request`` hook drained it too; the FastAPI port has no 

337 # equivalent. Directly-dispatched research still runs while the 

338 # processor is off, so a terminal FAILED status raised on that 

339 # fallback path would be queued and then silently dropped by TTL 

340 # eviction. Warn rather than start a thread the operator turned off. 

341 logger.warning( 

342 "Queue processor v2 disabled — not starting. Queued researches " 

343 "will not be dispatched, and progress/terminal-status updates " 

344 "that fall back to the pending-operations queue will not be " 

345 "persisted (they expire via TTL)." 

346 ) 

347 

348 # Start news scheduler 

349 news_scheduler = None 

350 try: 

351 from ..settings.env_registry import get_env_setting 

352 

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

354 if scheduler_enabled: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true

355 from ..scheduler.background import get_background_job_scheduler 

356 from ..settings.manager import SettingsManager 

357 

358 # Startup-only: lifespan runs once before the server accepts 

359 # traffic, so blocking here cannot stall in-flight requests. 

360 settings_manager = SettingsManager() # allow: sync-in-async 

361 news_scheduler = get_background_job_scheduler() 

362 news_scheduler.initialize_with_settings(settings_manager) 

363 news_scheduler.start() 

364 logger.info("News scheduler started") 

365 except Exception: 

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

367 

368 # Start connection cleanup scheduler 

369 cleanup_scheduler = None 

370 try: 

371 from .auth.connection_cleanup import start_connection_cleanup_scheduler 

372 from .auth.session_manager import session_manager 

373 

374 cleanup_scheduler = start_connection_cleanup_scheduler( 

375 session_manager, db_manager 

376 ) 

377 except Exception: 

378 logger.warning("Failed to start cleanup scheduler") 

379 

380 # Shutdown is the code after `yield` below. A parallel atexit handler 

381 # would double-close the DB on normal exit (lifespan runs first, then 

382 # atexit fires on interpreter teardown), which is why there isn't one. 

383 # 

384 # CORRECTION (pre-merge readiness audit): this comment previously said 

385 # "the lifespan `finally` below owns shutdown". There is no `finally` — 

386 # the body is flat, `yield` then shutdown statements. That matters 

387 # because the sentence was doing real work: it justified having no 

388 # atexit handler by appealing to a guarantee the code does not make. 

389 # 

390 # What actually holds. uvicorn's graceful path stops accepting, drains 

391 # connections, then drives the ASGI lifespan shutdown, which resumes 

392 # this generator through a normal `__aexit__` — so on SIGTERM the code 

393 # below DOES run. What is NOT covered is cancellation: if 

394 # `timeout_graceful_shutdown` (10s, see web/app.py) expires or the 

395 # process is killed harder, CancelledError is thrown in at the `yield` 

396 # and everything below is skipped. Wrapping this in try/finally would 

397 # close that, and is deliberately NOT done in the migration PR: it 

398 # reindents ~48 lines on the shutdown path, and running full DB cleanup 

399 # while already past the force-kill deadline is a judgement call for 

400 # whoever owns deployment, not a mechanical fix. 

401 # 

402 # Consequence to know when reasoning about the atexit decision above: 

403 # on a forced kill neither runs. 

404 

405 yield # --- App is running --- 

406 

407 # --- Shutdown --- 

408 logger.info("Shutting down Local Deep Research...") 

409 

410 if news_scheduler: 410 ↛ 411line 410 didn't jump to line 411 because the condition on line 410 was never true

411 try: 

412 news_scheduler.stop() 

413 logger.info("News scheduler stopped") 

414 except Exception: 

415 logger.exception("Error stopping news scheduler") 

416 

417 if cleanup_scheduler: 417 ↛ 427line 417 didn't jump to line 427 because the condition on line 417 was always true

418 try: 

419 # wait=True so in-flight cleanup jobs finish before we close 

420 # the DB below — otherwise they hit a closed pool. 

421 cleanup_scheduler.shutdown(wait=True) 

422 except Exception: 

423 logger.debug("Error stopping cleanup scheduler") 

424 

425 # Stop the research queue processor so a uvicorn reload doesn't leave 

426 # an old daemon thread holding locks while the new process starts. 

427 try: 

428 queue_processor.stop() 

429 except Exception: 

430 logger.debug("Error stopping queue processor") 

431 

432 # Flush pending DB log writes BEFORE closing databases; `database_sink` 

433 # swallows errors silently, so anything flushed after close is just 

434 # dropped. 

435 from ..utilities.log_utils import ( 

436 flush_log_queue, 

437 stop_log_queue_processor, 

438 ) 

439 

440 try: 

441 flush_log_queue() 

442 stop_log_queue_processor() 

443 except Exception: 

444 logger.debug("Error flushing log queue during shutdown") 

445 

446 try: 

447 db_manager.close_all_databases() 

448 logger.info("Database connections closed") 

449 except Exception: 

450 logger.exception("Error closing databases") 

451 

452 

453# --------------------------------------------------------------------------- 

454# ASGI Middleware (pure ASGI — NOT BaseHTTPMiddleware) 

455# --------------------------------------------------------------------------- 

456 

457 

458def _is_private_ip(ip_str: str) -> bool: 

459 """Check if IP is a private/local network address. 

460 

461 Returns True for private IPs (RFC 1918), localhost, and non-parseable 

462 strings (e.g. 'testclient' from TestClient). Non-parseable strings 

463 are treated as private to avoid adding Secure flag in test/dev contexts. 

464 """ 

465 if not ip_str: 

466 return True 

467 try: 

468 ip = ipaddress.ip_address(ip_str) 

469 return ip.is_private or ip.is_loopback 

470 except ValueError: 

471 return True # Non-IP strings (e.g. "testclient") treated as private 

472 

473 

474class SecurityHeadersMiddleware: 

475 """Pure ASGI middleware for security headers. 

476 

477 Ports Flask's SecurityHeaders + ServerHeaderMiddleware to ASGI. 

478 """ 

479 

480 # Pre-compute static header values 

481 CSP = ( 

482 "default-src 'self'; " 

483 "connect-src 'self'; " 

484 "script-src 'self' 'unsafe-inline'; " 

485 "style-src 'self' 'unsafe-inline'; " 

486 "font-src 'self' data:; " 

487 "img-src 'self' data:; " 

488 "media-src 'self'; " 

489 "worker-src blob:; " 

490 "child-src 'self' blob:; " 

491 "frame-src 'self'; " 

492 "frame-ancestors 'self'; " 

493 "manifest-src 'self'; " 

494 "object-src 'none'; " 

495 "base-uri 'self'; " 

496 "form-action 'self';" 

497 ) 

498 PERMISSIONS = ( 

499 "geolocation=(), midi=(), camera=(), usb=(), " 

500 "magnetometer=(), accelerometer=(), gyroscope=(), " 

501 "microphone=(), payment=(), sync-xhr=(), document-domain=()" 

502 ) 

503 

504 @classmethod 

505 def unconditional_headers(cls) -> list[tuple[bytes, bytes]]: 

506 """The security headers stamped on every HTTP response. 

507 

508 Exposed separately because this middleware cannot cover every 

509 response. Starlette's ``ServerErrorMiddleware`` sits OUTSIDE every 

510 middleware added via ``app.add_middleware`` — it is installed by 

511 ``build_middleware_stack`` itself — and when an exception handler 

512 is registered for the bare ``Exception`` class, Starlette wires it 

513 in as that middleware's handler. Its response is written to the raw 

514 ASGI ``send``, bypassing this middleware entirely, so a 500 from an 

515 unregistered exception type would otherwise carry NO security 

516 headers at all. The catch-all handler reuses this list to stamp 

517 them itself; keeping one source stops the two copies drifting. 

518 

519 Excludes the two conditional headers (HSTS, which needs a secure 

520 scheme, and cache-control, which is skipped for /static/). 

521 """ 

522 return [ 

523 (b"content-security-policy", cls.CSP.encode()), 

524 (b"x-frame-options", b"SAMEORIGIN"), 

525 (b"x-content-type-options", b"nosniff"), 

526 (b"cross-origin-opener-policy", b"same-origin"), 

527 (b"cross-origin-embedder-policy", b"credentialless"), 

528 (b"cross-origin-resource-policy", b"same-origin"), 

529 (b"permissions-policy", cls.PERMISSIONS.encode()), 

530 (b"referrer-policy", b"strict-origin-when-cross-origin"), 

531 ] 

532 

533 @classmethod 

534 def cache_headers(cls) -> list[tuple[bytes, bytes]]: 

535 """No-store cache headers stamped on every non-``/static/`` response. 

536 

537 Split out from ``unconditional_headers()`` (Cache-Control/Pragma/ 

538 Expires are conditional on path, not truly unconditional) so the 

539 catch-all 500 handler below can reuse the exact same values 

540 instead of hand-copying them — main's Flask ``after_request`` 

541 (``security/security_headers.py``) set all three: 

542 ``Cache-Control``, ``Pragma`` (HTTP/1.0 compatibility), and 

543 ``Expires: 0``. The ASGI port originally dropped ``Expires``; 

544 restored here for parity. Impact of the omission was low 

545 (``Cache-Control: no-store`` already dominates for any HTTP/1.1 

546 cache) but it's a free one-line fix once the two are split out. 

547 """ 

548 return [ 

549 ( 

550 b"cache-control", 

551 b"no-store, no-cache, must-revalidate, max-age=0", 

552 ), 

553 (b"pragma", b"no-cache"), 

554 (b"expires", b"0"), 

555 ] 

556 

557 def __init__(self, app): 

558 self.app = app 

559 

560 async def __call__(self, scope, receive, send): 

561 if scope["type"] != "http": 

562 await self.app(scope, receive, send) 

563 return 

564 

565 path = scope.get("path", "") 

566 is_secure = scope.get("scheme") == "https" 

567 

568 async def send_wrapper(message): 

569 if message["type"] == "http.response.start": 

570 headers = list(message.get("headers", [])) 

571 

572 # Security headers 

573 headers.extend(self.unconditional_headers()) 

574 

575 # HSTS for secure connections 

576 if is_secure: 

577 headers.append( 

578 ( 

579 b"strict-transport-security", 

580 b"max-age=31536000; includeSubDomains", 

581 ) 

582 ) 

583 

584 # Cache control for non-static routes 

585 if not path.startswith("/static/"): 

586 headers.extend(self.cache_headers()) 

587 

588 # Remove server header 

589 headers = [(k, v) for k, v in headers if k.lower() != b"server"] 

590 

591 message = {**message, "headers": headers} 

592 

593 await send(message) 

594 

595 await self.app(scope, receive, send_wrapper) 

596 

597 

598class _RequestBodyTooLarge(Exception): 

599 """Raised by BodySizeLimitMiddleware's receive wrapper on overflow.""" 

600 

601 

602#: Separate, much smaller cap applied to non-multipart bodies (JSON and 

603#: everything else that isn't a file upload) by ``BodySizeLimitMiddleware``. 

604#: 

605#: ``await request.json()`` -> ``json.loads`` is synchronous and runs on 

606#: the single uvicorn event loop this app is served with (workers=1), so 

607#: one caller's oversized body stalls every concurrent user. Measured on 

608#: this branch: a 104 MB JSON body stalled the loop for 637 ms, during 

609#: which 12 concurrent GETs spiked from ~3 ms to 184 ms latency. The only 

610#: prior bound was the general ``max_body_size`` cap below 

611#: (MAX_FILES_PER_REQUEST(200) x MAX_FILE_SIZE(3GB) ~= 600 GB) — sized for 

612#: multipart file uploads, not a practical limit for a single JSON body. 

613#: 

614#: 100 MB matches the existing precedent in 

615#: ``web/routers/notes.py::_MAX_JSON_BODY_BYTES`` 

616#: (``2 * NOTE_CONTENT_MAX_BYTES``, i.e. 2x the 50 MB note-content cap), 

617#: which already accepts this order-of-magnitude stall for its own 

618#: pre-parse JSON body gate. No other JSON endpoint in the app needs 

619#: anywhere near this: research/chat/benchmark/settings JSON bodies are 

620#: small query/config/message payloads (KB, not MB) — see PR review notes 

621#: for the survey of ``await request.json()`` call sites. The two routes 

622#: that genuinely need multi-hundred-MB bodies (``/api/upload/pdf`` and 

623#: ``/library/api/collections/{id}/upload``) are multipart file uploads, 

624#: not JSON, and keep the large ``max_body_size`` cap below. 

625_DEFAULT_MAX_JSON_BODY_SIZE = 100 * 1024 * 1024 # 100 MB 

626 

627#: The cap every OTHER JSON route gets. 100 MB above is what the notes 

628#: route needs, but it is a weak mitigation for the defect this cap exists 

629#: to address: ``json.loads`` runs synchronously on the single uvicorn 

630#: event loop (workers=1), so body size translates directly into a stall 

631#: that freezes EVERY concurrent user, not just the caller. Measured on 

632#: this branch: 104 MB -> 498-637 ms, 34 MB -> ~128 ms, 8 MB -> ~38 ms. 

633#: A 100 MB cap therefore still permits a ~600 ms full-service stall, 

634#: repeatable at will by any authenticated caller. 

635#: 

636#: 16 MB holds the worst case to roughly 60 ms while leaving ~4x headroom 

637#: over the largest realistic legitimate body found in a survey of every 

638#: ``await request.json()`` call site (~4 MB). Routes that genuinely need 

639#: more are listed in _LARGE_JSON_BODY_PREFIXES and keep the 100 MB cap. 

640_DEFAULT_MAX_SMALL_JSON_BODY_SIZE = 16 * 1024 * 1024 # 16 MB 

641 

642#: Path prefixes allowed the full ``_DEFAULT_MAX_JSON_BODY_SIZE``. Keep 

643#: this list as short as possible: every entry is a route that can stall 

644#: the event loop for hundreds of ms. ``notes`` earns it because 

645#: NOTE_CONTENT_MAX_BYTES is 50 MB and its handler already does its own 

646#: bounded pre-parse read (notes.py::_notes_json_body). 

647_LARGE_JSON_BODY_PREFIXES = ("/notes/",) 

648 

649#: The only two routes that genuinely consume a multipart upload body 

650#: (both do ``await request.form()`` and pull ``UploadFile``s out of it). 

651#: The large ``max_body_size`` cap is granted on PATH, never on the 

652#: client's declared Content-Type. Starlette's ``Request.json()`` is 

653#: ``json.loads(await self.body())`` -- it never inspects Content-Type -- 

654#: so a body labelled ``multipart/form-data`` is still parsed as JSON by 

655#: any route that asks for JSON. Choosing the cap from the header alone 

656#: therefore handed every one of the app's ``request.json()`` routes the 

657#: ~600 GB upload cap (200 files x 3 GB) for the cost of one mislabelled 

658#: header, while the route went on parsing the body as JSON. 

659_MULTIPART_UPLOAD_PATHS = frozenset({"/api/upload/pdf"}) 

660_MULTIPART_UPLOAD_RE = re.compile(r"^/library/api/collections/[^/]+/upload$") 

661 

662 

663def _is_multipart_upload_path(path: str) -> bool: 

664 """True for the two routes allowed to carry an upload-sized body.""" 

665 return path in _MULTIPART_UPLOAD_PATHS or bool( 

666 _MULTIPART_UPLOAD_RE.match(path) 

667 ) 

668 

669 

670class BodySizeLimitMiddleware: 

671 """Pure ASGI middleware enforcing a global request-body size cap. 

672 

673 Ports Flask's ``MAX_CONTENT_LENGTH`` (app_factory set it to 

674 ``MAX_FILES_PER_REQUEST * MAX_FILE_SIZE``; Werkzeug rejected any 

675 larger body with 413) plus the ``@app.errorhandler(413)`` content 

676 negotiation. Without it every body-reading code path — including the 

677 CSRF middleware, which buffers form bodies to extract the token — 

678 would buffer arbitrarily large request bodies into memory. 

679 

680 The declared Content-Length is checked before the app runs; bodies 

681 streamed without one (chunked transfer) are counted chunk by chunk so 

682 the cap cannot be bypassed. 

683 

684 On top of that global cap, non-multipart requests (JSON bodies, but 

685 also anything with a missing/spoofed Content-Type — a route can call 

686 ``await request.json()`` regardless of what Content-Type the client 

687 sent, so gating strictly on ``application/json`` would leave that as 

688 a bypass) are additionally capped at the much smaller 

689 ``max_json_body_size``. Multipart requests (the two real upload 

690 routes) are exempt from that smaller cap and still enforce only 

691 ``max_body_size`` — see ``_DEFAULT_MAX_JSON_BODY_SIZE`` above for the 

692 justification and measurements. 

693 """ 

694 

695 def __init__( 

696 self, 

697 app, 

698 max_body_size: int | None = None, 

699 max_json_body_size: int | None = None, 

700 max_large_json_body_size: int | None = None, 

701 ): 

702 self.app = app 

703 if max_body_size is None: 

704 from ..security.file_upload_validator import FileUploadValidator 

705 

706 max_body_size = ( 

707 FileUploadValidator.MAX_FILES_PER_REQUEST 

708 * FileUploadValidator.MAX_FILE_SIZE 

709 ) 

710 self.max_body_size = max_body_size 

711 # `max_json_body_size` is the cap for ORDINARY JSON routes, i.e. 

712 # almost everything. `max_large_json_body_size` applies only to the 

713 # few prefixes in _LARGE_JSON_BODY_PREFIXES that legitimately carry 

714 # a big body. Keeping the ordinary case as the injectable default 

715 # means a caller passing a small cap gets it applied where it 

716 # matters, rather than silently only affecting notes. 

717 if max_json_body_size is None: 

718 max_json_body_size = _DEFAULT_MAX_SMALL_JSON_BODY_SIZE 

719 self.max_json_body_size = max_json_body_size 

720 if max_large_json_body_size is None: 

721 max_large_json_body_size = _DEFAULT_MAX_JSON_BODY_SIZE 

722 self.max_large_json_body_size = max( 

723 max_large_json_body_size, max_json_body_size 

724 ) 

725 

726 async def _send_413(self, scope, send): 

727 path = scope.get("path", "") 

728 # Same negotiation as main's 413 errorhandler (_is_api_path). 

729 if "/api/" in path or path.endswith("/api"): 

730 body = b'{"error": "Request too large"}' 

731 content_type = b"application/json" 

732 else: 

733 body = b"Request too large" 

734 content_type = b"text/plain; charset=utf-8" 

735 await send( 

736 { 

737 "type": "http.response.start", 

738 "status": 413, 

739 "headers": [ 

740 (b"content-type", content_type), 

741 (b"content-length", str(len(body)).encode()), 

742 ], 

743 } 

744 ) 

745 await send({"type": "http.response.body", "body": body}) 

746 

747 async def __call__(self, scope, receive, send): 

748 if scope["type"] != "http": 

749 await self.app(scope, receive, send) 

750 return 

751 

752 # Multipart ON one of the two real upload paths keeps the large 

753 # cap; everything else — JSON, a mislabelled multipart body, and 

754 # anything with a missing/other Content-Type, since a route can 

755 # call `request.json()` no matter what Content-Type was sent — is 

756 # additionally bounded by 

757 # the much smaller JSON cap. `min()` also means an explicit, 

758 # smaller `max_body_size` passed to the constructor (as tests 

759 # do) still wins, so this never widens an existing caller's cap. 

760 content_type = b"" 

761 for name, value in scope.get("headers", []): 

762 if name == b"content-type": 

763 content_type = value 

764 break 

765 # Both conditions are required: a multipart *label* is not enough, 

766 # because it is client-supplied and costs nothing to forge (see 

767 # _MULTIPART_UPLOAD_PATHS). Only the real upload routes get the 

768 # upload-sized cap; a mislabelled body on any other path falls 

769 # through to the JSON cap below, exactly as an honestly-labelled 

770 # one does. 

771 is_multipart = content_type.lower().startswith( 

772 b"multipart/" 

773 ) and _is_multipart_upload_path(scope.get("path", "")) 

774 if is_multipart: 

775 effective_max = self.max_body_size 

776 else: 

777 # Routes that legitimately carry a large JSON body keep the 

778 # larger cap. Only notes qualifies: NOTE_CONTENT_MAX_BYTES is 

779 # 50 MB, and notes.py::_MAX_JSON_BODY_BYTES doubles that to 

780 # allow for JSON escaping. Every other `request.json()` call 

781 # site in the app carries a small query/config/message payload 

782 # — the largest realistic one is library_delete's bulk 

783 # `document_ids`, ~4 MB even at 100k UUIDs. 

784 json_cap = ( 

785 self.max_large_json_body_size 

786 if scope.get("path", "").startswith(_LARGE_JSON_BODY_PREFIXES) 

787 else self.max_json_body_size 

788 ) 

789 effective_max = min(self.max_body_size, json_cap) 

790 

791 # Fast path: client declared an over-cap Content-Length. 

792 for name, value in scope.get("headers", []): 

793 if name == b"content-length": 

794 try: 

795 declared = int(value) 

796 except ValueError: 

797 break 

798 if declared > effective_max: 

799 await self._send_413(scope, send) 

800 return 

801 break 

802 

803 received = 0 

804 response_started = False 

805 

806 async def recv_limited(): 

807 nonlocal received 

808 message = await receive() 

809 if message["type"] == "http.request": 809 ↛ 813line 809 didn't jump to line 813 because the condition on line 809 was always true

810 received += len(message.get("body", b"")) 

811 if received > effective_max: 

812 raise _RequestBodyTooLarge() 

813 return message 

814 

815 async def send_tracking(message): 

816 nonlocal response_started 

817 if message["type"] == "http.response.start": 

818 response_started = True 

819 await send(message) 

820 

821 try: 

822 await self.app(scope, recv_limited, send_tracking) 

823 except _RequestBodyTooLarge: 

824 if response_started: 

825 # Too late for a 413 — let the server tear the 

826 # connection down. 

827 raise 

828 logger.warning( 

829 f"Rejected over-limit request body on {scope.get('path', '')} " 

830 f"(> {effective_max} bytes)" 

831 ) 

832 await self._send_413(scope, send) 

833 

834 

835class SecureCookieMiddleware: 

836 """Pure ASGI middleware to add Secure flag to cookies based on client IP. 

837 

838 Ports Flask's SecureCookieMiddleware to ASGI. 

839 """ 

840 

841 def __init__(self, app, testing: bool = False): 

842 self.app = app 

843 self.testing = testing 

844 self._warned_insecure_public = False 

845 self._warned_untrusted_forwarded_proto = False 

846 

847 def _maybe_warn_untrusted_forwarded_proto( 

848 self, scope, remote_addr: str 

849 ) -> None: 

850 """One-shot warning for the reverse-proxy misconfiguration that is 

851 otherwise completely silent. 

852 

853 ``_maybe_warn_insecure_public`` below only fires for a NON-private 

854 client IP, so the most common broken upgrade never trips it: nginx (or 

855 caddy/traefik) terminating TLS on 127.0.0.1 with TRUST_PROXY_HEADERS 

856 unset. The peer address is loopback, so that warning takes the 

857 private-IP branch and says nothing, while uvicorn — not being told to 

858 honour forwarded headers — reports scheme "http". The session cookie 

859 then silently loses ``Secure`` and HSTS is silently withheld, on a 

860 deployment the operator believes is HTTPS. 

861 

862 The tell is a request that carries ``X-Forwarded-Proto: https`` while 

863 the connection is not being treated as HTTPS. Cheap: this only runs on 

864 the not-https path (a correctly configured deployment never reaches 

865 it), and short-circuits on a bool before touching headers, so it scans 

866 them at most once per process. 

867 """ 

868 if self._warned_untrusted_forwarded_proto: 

869 return 

870 # Set BEFORE deciding whether to warn, not after. The diagnosis is a 

871 # per-process property, so scanning once is enough either way. Setting 

872 # it only on the warn path (the original shape) meant the common 

873 # deployment — plain HTTP with no proxy, so the header is absent — 

874 # never set it and re-scanned every header of every request for the 

875 # life of the process, which is the opposite of what this method's 

876 # docstring promised. 

877 self._warned_untrusted_forwarded_proto = True 

878 

879 # Only meaningful from a proxy on the loopback/private network. This 

880 # header is untrusted client input: any remote client could otherwise 

881 # send `X-Forwarded-Proto: https` once and plant a log line telling 

882 # the operator to set TRUST_PROXY_HEADERS=true — advice that, followed 

883 # on a host NOT behind a proxy, would hand that same client control of 

884 # the scheme and therefore of the Secure-cookie and HSTS decisions. 

885 if not remote_addr or not _is_private_ip(remote_addr): 

886 return 

887 

888 for name, value in scope.get("headers", []): 

889 if name != b"x-forwarded-proto": 

890 continue 

891 # Left-most value is the original client protocol when a chain of 

892 # proxies has appended to the header. 

893 if value.split(b",")[0].strip().lower() == b"https": 

894 logger.warning( 

895 "Received X-Forwarded-Proto: https but this connection is " 

896 "not being treated as HTTPS, so session cookies will NOT " 

897 "get the Secure flag and HSTS will NOT be sent. If this " 

898 "server sits behind a TLS-terminating reverse proxy, set " 

899 "TRUST_PROXY_HEADERS=true so forwarded headers are " 

900 "honoured." 

901 ) 

902 break 

903 

904 def _maybe_warn_insecure_public(self, remote_addr: str) -> None: 

905 """One-shot warning when serving HTTP to a non-private client IP — 

906 a likely missing HTTPS proxy. Mirrors main's operator signal.""" 

907 if self._warned_insecure_public: 

908 return 

909 if remote_addr and not _is_private_ip(remote_addr): 

910 self._warned_insecure_public = True 

911 logger.warning( 

912 "Serving HTTP to a public client ({}). Session/CSRF cookies " 

913 "are NOT marked Secure (marking them Secure over HTTP would " 

914 "make the browser drop them). Put LDR behind HTTPS, and if " 

915 "using a TLS proxy set TRUST_PROXY_HEADERS=true.", 

916 remote_addr, 

917 ) 

918 

919 async def __call__(self, scope, receive, send): 

920 if scope["type"] != "http": 

921 await self.app(scope, receive, send) 

922 return 

923 

924 # Add the Secure flag ONLY when the connection is actually HTTPS. 

925 # Matches Flask's SecureCookieMiddleware (#3849): setting Secure on a 

926 # cookie served over plain HTTP makes the browser DROP the cookie 

927 # (RFC6265bis), so a deployment reachable over HTTP from a public IP 

928 # would hit an unbreakable login loop. A previous version here added 

929 # Secure for "public IP over HTTP" too, reintroducing exactly that 

930 # bug. For a deployment behind a TLS-terminating proxy, honor 

931 # X-Forwarded-Proto by starting uvicorn with TRUST_PROXY_HEADERS=true 

932 # so scope["scheme"] reflects https (see web/app.py). We still warn 

933 # once when serving HTTP to a non-private client so a missing HTTPS 

934 # proxy is visible. 

935 client = scope.get("client") 

936 remote_addr = client[0] if client else "" 

937 is_https = scope.get("scheme") == "https" 

938 if not is_https and not self.testing: 

939 self._maybe_warn_insecure_public(remote_addr) 

940 self._maybe_warn_untrusted_forwarded_proto(scope, remote_addr) 

941 should_add_secure = not self.testing and is_https 

942 

943 if not should_add_secure: 

944 await self.app(scope, receive, send) 

945 return 

946 

947 async def send_wrapper(message): 

948 if message["type"] == "http.response.start": 

949 headers = [] 

950 for k, v in message.get("headers", []): 

951 if k.lower() == b"set-cookie": 

952 v_str = v.decode("latin-1") 

953 if "; Secure" not in v_str and "; secure" not in v_str: 

954 v_str += "; Secure" 

955 v = v_str.encode("latin-1") 

956 headers.append((k, v)) 

957 message = {**message, "headers": headers} 

958 await send(message) 

959 

960 await self.app(scope, receive, send_wrapper) 

961 

962 

963class RememberMeMiddleware: 

964 """Strip session-cookie Max-Age/Expires unless `_remember_me` is True. 

965 

966 Starlette's SessionMiddleware accepts a single `max_age` applied to 

967 every session cookie. Without this middleware, every login gets a 

968 30-day persistent cookie regardless of whether the user ticked 

969 "Remember me" — which is the usual UX complaint, and would be a 

970 privacy regression vs. the Flask app that honored the flag (Flask 

971 defaulted `session.permanent = False`, i.e. a browser-session 

972 cookie, and only made it persistent when "remember me" was 

973 checked). 

974 

975 The login handler stores `_remember_me` inside the session dict. 

976 On every response we inspect it and, unless it is explicitly 

977 `True`, strip `Max-Age` and `Expires` attributes from the outbound 

978 `session=...` Set-Cookie so the browser treats it as a session 

979 cookie (discarded on browser close). Anonymous visitors (no login 

980 yet) and requests right after logout also read back as "not True" 

981 here — `_remember_me` is either absent (never logged in) or wiped 

982 by `request.session.clear()` at logout (routers/auth.py), so it 

983 reads back as `None`, not `False`. Both must get the same 

984 non-persistent cookie as an explicit `_remember_me=False` login; 

985 checking `remember_me is False` instead of `is not True` would miss 

986 both cases and hand every anonymous visitor and every just-logged-out 

987 user a persistent 30-day cookie. 

988 

989 This ONLY relaxes the client-side (browser) cap. The itsdangerous 

990 signature backing the cookie is still valid for the full 

991 `security.session_remember_me_days` window (SessionMiddleware has a 

992 single `max_age` shared by every session — see its construction 

993 below) regardless of this flag, so a raw non-remember-me cookie value 

994 copied out of the browser (XSS exfiltration, proxy log, devtools) 

995 would replay successfully for up to 30 days even though the browser 

996 itself was told to discard it on close. `_enforce_session_expiry` / 

997 `_stamp_session_expiry` (used by `DatabaseMiddleware` below) close 

998 that gap by enforcing the shorter `security.session_timeout_hours` 

999 window server-side, independent of the browser-facing cookie 

1000 attributes this middleware controls. 

1001 

1002 EXCEPTION — the deletion cookie must survive untouched. When a session 

1003 is cleared server-side (idle-timeout revocation in 

1004 `_enforce_session_revocation`, or logout), Starlette's SessionMiddleware 

1005 does not write a normal data cookie: it writes the literal 

1006 `session=null; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; ...` 

1007 (see `starlette.middleware.sessions.SessionMiddleware.send_wrapper`, 

1008 the `session.modified and not initial_session_was_empty` branch) — an 

1009 explicit "delete this cookie now" instruction, not persistent session 

1010 data. Stripping `Expires`/`Max-Age` from THAT header, the same as any 

1011 other non-remember-me cookie, downgrades it to `session=null; path=/; 

1012 httponly; samesite=strict`: no expiry in the past, so the browser does 

1013 not delete the cookie at all — it just keeps sending the literal string 

1014 "null" back as the session value for the rest of the browser session. 

1015 A revoked/logged-out session would then never actually leave the 

1016 client. `session=null;` is the exact, fixed marker Starlette emits for 

1017 a cleared session (never a signed payload), so matching it precisely 

1018 identifies the deletion cookie without needing to duplicate 

1019 SessionMiddleware's own emptiness bookkeeping. 

1020 """ 

1021 

1022 # Name of the cookie Starlette's SessionMiddleware sets. 

1023 _SESSION_COOKIE_NAME = b"session" 

1024 

1025 # The literal value Starlette's SessionMiddleware writes when a session 

1026 # is cleared (see the class docstring's EXCEPTION section). Distinct by 

1027 # construction from every real session cookie, which carries a base64 

1028 # itsdangerous-signed payload, never the bare string "null". 

1029 _DELETED_COOKIE_PREFIX = _SESSION_COOKIE_NAME + b"=null;" 

1030 

1031 def __init__(self, app) -> None: 

1032 self.app = app 

1033 

1034 async def __call__(self, scope, receive, send): 

1035 if scope["type"] != "http": 

1036 await self.app(scope, receive, send) 

1037 return 

1038 

1039 async def send_wrapper(message): 

1040 if message["type"] != "http.response.start": 

1041 await send(message) 

1042 return 

1043 

1044 # Resolve remember_me preference from the session. Treat 

1045 # anything other than an explicit True (missing/None for 

1046 # anonymous or just-logged-out sessions, or False for an 

1047 # explicit non-remember-me login) as "strip the persistent 

1048 # cookie attributes" — only an explicit opt-in keeps them. 

1049 session = scope.get("session", {}) or {} 

1050 remember_me = session.get("_remember_me") 

1051 if remember_me is not True: 

1052 rewritten = [] 

1053 for k, v in message.get("headers", []): 

1054 if ( 

1055 k.lower() == b"set-cookie" 

1056 and v.lower().startswith( 

1057 self._SESSION_COOKIE_NAME + b"=" 

1058 ) 

1059 and not v.lower().startswith( 

1060 self._DELETED_COOKIE_PREFIX 

1061 ) 

1062 ): 

1063 v_str = v.decode("latin-1") 

1064 # Drop Max-Age=... and Expires=... attributes. 

1065 parts = [ 

1066 part 

1067 for part in v_str.split(";") 

1068 if not part.strip() 

1069 .lower() 

1070 .startswith(("max-age=", "expires=")) 

1071 ] 

1072 v = ";".join(parts).encode("latin-1") 

1073 rewritten.append((k, v)) 

1074 message = {**message, "headers": rewritten} 

1075 await send(message) 

1076 

1077 await self.app(scope, receive, send_wrapper) 

1078 

1079 

1080# --------------------------------------------------------------------------- 

1081# Exception handlers 

1082# --------------------------------------------------------------------------- 

1083 

1084 

1085def _is_api_request(request: Request) -> bool: 

1086 """Check if this is an API/JSON request (vs browser HTML request).""" 

1087 path = request.url.path 

1088 # Match paths containing /api/ segment or ending with /api 

1089 if "/api/" in path or path.endswith("/api"): 

1090 return True 

1091 accept = request.headers.get("accept", "") 

1092 if "application/json" in accept and "text/html" not in accept: 

1093 return True 

1094 return False 

1095 

1096 

1097def _register_exception_handlers(app: FastAPI) -> None: 

1098 """Register custom exception handlers (ports Flask's error handlers).""" 

1099 

1100 from fastapi.exceptions import HTTPException 

1101 

1102 from .exceptions import WebAPIException 

1103 

1104 @app.exception_handler(HTTPException) 

1105 async def handle_http_exception(request: Request, exc: HTTPException): 

1106 """Redirect 401s for browser requests to login page (Flask parity).""" 

1107 if exc.status_code == 401 and not _is_api_request(request): 

1108 login_url = "/auth/login" 

1109 # Path AND query. main used Werkzeug's `request.url` (the full 

1110 # URL) as `next`, so a deep link survived the login bounce; the 

1111 # port truncated it to the path, silently dropping the query. 

1112 # That is not cosmetic: /chat/?q=... auto-submits the query as a 

1113 # research run (static/js/components/chat.js routed-query bootstrap), so a 

1114 # signed-out user following a shared link landed on an empty 

1115 # chat box with their question gone. The consumer side already 

1116 # handles this -- URLValidator.get_safe_redirect_path() 

1117 # explicitly re-appends query and fragment, and is byte-identical 

1118 # to main. quote(safe="/") below encodes the `?` and `&` so the 

1119 # embedded value stays a single well-formed query parameter. 

1120 next_url = str(request.url.path) 

1121 if request.url.query: 

1122 next_url += "?" + request.url.query 

1123 if next_url and next_url != "/": 

1124 # URL-encode the path before embedding as a query value. 

1125 # Without quote(), characters like `?` or `&` in the path 

1126 # break the resulting URL; downstream code that treats 

1127 # ?next= as untrusted input must still validate it's a 

1128 # local path, but at minimum the encoding here keeps the 

1129 # redirect target well-formed. 

1130 from urllib.parse import quote 

1131 

1132 login_url += f"?next={quote(next_url, safe='/')}" 

1133 return RedirectResponse( 

1134 url=login_url, 

1135 status_code=302, 

1136 headers=exc.headers, 

1137 ) 

1138 path = request.url.path 

1139 if path == "/api/v1" or path.startswith("/api/v1/"): 

1140 # /api/v1 is the documented programmatic API for external clients, 

1141 # and main returned {"error": ...} for its auth failures 

1142 # (web/api.py's `jsonify({"error": "Authentication required"})` / 

1143 # `{"error": "API access is disabled"}`). Routing those through 

1144 # HTTPException here would silently change the envelope to 

1145 # {"detail": ...} for every existing script. 

1146 # 

1147 # Both keys are emitted rather than swapping: the frontend's 

1148 # api.js reads `detail`, and tests/web/test_exception_handler_ 

1149 # contract.py deliberately pins the {"detail": ...} shape for the 

1150 # rest of the app. This restores main's contract for the external 

1151 # API without disturbing either. 

1152 return JSONResponse( 

1153 {"error": exc.detail, "detail": exc.detail}, 

1154 status_code=exc.status_code, 

1155 headers=exc.headers, 

1156 ) 

1157 return JSONResponse( 

1158 {"detail": exc.detail}, 

1159 status_code=exc.status_code, 

1160 headers=exc.headers, 

1161 ) 

1162 

1163 @app.exception_handler(404) 

1164 async def not_found(request: Request, exc): 

1165 # Browser navigations get HTML; only API callers get JSON. Without 

1166 # the branch, a user who mistypes a URL is shown a raw 

1167 # ``{"error": "Not found"}`` body in the browser's JSON viewer. 

1168 # Flask branched the same way — app_factory.py's 

1169 # ``@app.errorhandler(404)`` returned ``make_response("Not found", 

1170 # 404)``, which Flask serves as text/html — and the 401 handler 

1171 # directly above already makes this distinction. This one did not. 

1172 if _is_api_request(request): 

1173 return JSONResponse({"error": "Not found"}, status_code=404) 

1174 return HTMLResponse("Not found", status_code=404) 

1175 

1176 # Catch-all so unhandled exceptions get logged with traceback rather than 

1177 # silently disappearing into Starlette's default 500 path. (The Exception 

1178 # handler takes precedence over status-code 500 via MRO, so a separate 

1179 # 500 handler would be unreachable.) 

1180 @app.exception_handler(Exception) 

1181 async def unhandled_exception(request: Request, exc): 

1182 from fastapi import HTTPException 

1183 

1184 # Let HTTPException pass through to FastAPI's default handler. 

1185 if isinstance(exc, HTTPException): 1185 ↛ 1186line 1185 didn't jump to line 1186 because the condition on line 1185 was never true

1186 raise exc from None 

1187 logger.opt(exception=exc).error( 

1188 "Unhandled exception: {} {}", request.method, request.url.path 

1189 ) 

1190 # Stamp the security headers here rather than relying on 

1191 # SecurityHeadersMiddleware. Registering a handler for the bare 

1192 # ``Exception`` class makes Starlette wire it into 

1193 # ``ServerErrorMiddleware``, which is installed OUTSIDE every 

1194 # ``add_middleware`` layer and writes to the raw ASGI ``send`` — 

1195 # so this response never passes through that middleware and would 

1196 # otherwise ship with no CSP, no nosniff, no frame-options — and, 

1197 # same reasoning, no Cache-Control/Pragma/Expires either (main's 

1198 # Flask ``after_request`` covered every response including 

1199 # unhandled 500s; this reuses SecurityHeadersMiddleware. 

1200 # cache_headers() so the two can't drift apart). 

1201 # NOTE: unlike the 404 handler above, this deliberately does NOT 

1202 # branch on _is_api_request(). Flask's @app.errorhandler(500) did 

1203 # (returning "Server error" as text/html for non-API paths), but the 

1204 # JSON body here is a pinned contract — tests/web/ 

1205 # test_exception_handler_contract.py::Test500Contract and 

1206 # test_middleware_order_and_headers.py both assert it — and a raw 

1207 # JSON 500 is far less user-visible than a raw JSON 404, which users 

1208 # hit routinely by mistyping a URL or following a stale link. 

1209 # Changing it buys little and churns a contract two suites depend 

1210 # on. Revisit alongside a real styled error page. 

1211 return JSONResponse( 

1212 {"error": "Server error"}, 

1213 status_code=500, 

1214 headers={ 

1215 name.decode(): value.decode() 

1216 for name, value in ( 

1217 SecurityHeadersMiddleware.unconditional_headers() 

1218 + SecurityHeadersMiddleware.cache_headers() 

1219 ) 

1220 }, 

1221 ) 

1222 

1223 # Flask parity: request.get_json() returned 400 on malformed JSON and on 

1224 # request bodies that could not be decoded as UTF-8. Without these two 

1225 # registrations a bare `await request.json()` surfaced either exception 

1226 # as a 500 via the catch-all above. 

1227 import json 

1228 

1229 @app.exception_handler(UnicodeDecodeError) 

1230 @app.exception_handler(json.JSONDecodeError) 

1231 async def handle_json_decode_error(request: Request, exc): 

1232 # Log it: this handler also catches a JSONDecodeError raised deeper 

1233 # in a handler (e.g. parsing a downstream response), which is a 

1234 # server-side fault wearing a 400. Without this line those would 

1235 # vanish silently — the catch-all above logs everything it takes. 

1236 # 

1237 # Bounded fields only, never `exc` itself and never `exc.doc`. 

1238 # JSONDecodeError carries the ENTIRE offending document on `.doc`, 

1239 # so interpolating the exception is one __str__ change away from 

1240 # putting a request body — or a downstream provider's response — in 

1241 # the log. `.msg` is the parser's own text ("Expecting value"), 

1242 # `.lineno`/`.colno` are ints. This also matches how every sibling 

1243 # handler here logs (error_code / status_code / reason), and closes 

1244 # a real blind spot in check-sensitive-logging, which tracks 

1245 # exception variables via `except ... as e` and therefore cannot 

1246 # see one arriving as an exception-handler parameter. 

1247 logger.warning( 

1248 "JSON decode error handling {} {}: {} (line {} column {})", 

1249 request.method, 

1250 request.url.path, 

1251 getattr(exc, "msg", "invalid JSON"), 

1252 getattr(exc, "lineno", "?"), 

1253 getattr(exc, "colno", "?"), 

1254 ) 

1255 return JSONResponse({"error": "Invalid JSON body"}, status_code=400) 

1256 

1257 @app.exception_handler(WebAPIException) 

1258 async def handle_web_api_exception(request: Request, exc: WebAPIException): 

1259 logger.error( 

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

1261 ) 

1262 return JSONResponse(exc.to_dict(), status_code=exc.status_code) 

1263 

1264 try: 

1265 from ..news.exceptions import NewsAPIException 

1266 

1267 @app.exception_handler(NewsAPIException) 

1268 async def handle_news_api_exception( 

1269 request: Request, exc: NewsAPIException 

1270 ): 

1271 logger.error( 

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

1273 exc.error_code, 

1274 exc.status_code, 

1275 ) 

1276 return JSONResponse(exc.to_dict(), status_code=exc.status_code) 

1277 except ImportError: 

1278 pass 

1279 

1280 # PolicyDeniedError can escape any synchronous request-path PEP 

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

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

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

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

1285 try: 

1286 from ..security.egress.policy import PolicyDeniedError 

1287 

1288 @app.exception_handler(PolicyDeniedError) 

1289 async def handle_policy_denied( 

1290 request: Request, exc: PolicyDeniedError 

1291 ): 

1292 reason = getattr(getattr(exc, "decision", None), "reason", "denied") 

1293 target = getattr(exc, "target", "") 

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

1295 "PolicyDeniedError surfaced at FastAPI exception handler", 

1296 reason=reason, 

1297 target=target, 

1298 ) 

1299 return JSONResponse( 

1300 { 

1301 "status": "error", 

1302 "message": ( 

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

1304 ), 

1305 }, 

1306 status_code=400, 

1307 ) 

1308 except ImportError: 

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

1310 # for partial test builds without the security package. 

1311 pass 

1312 

1313 

1314# --------------------------------------------------------------------------- 

1315# Database middleware (before each request) 

1316# --------------------------------------------------------------------------- 

1317 

1318# Regression #1 fix — see _enforce_session_expiry's docstring for the full 

1319# mechanism. Read once at import time, matching how SessionMiddleware's own 

1320# `max_age` below is computed: an operator change to 

1321# security.session_timeout_hours takes effect on the next restart, not live. 

1322_NON_REMEMBER_ME_SESSION_SECONDS = ( 

1323 get_security_default("security.session_timeout_hours", 2) * 3600 

1324) 

1325 

1326_SESSION_EXPIRES_AT_KEY = "_session_expires_at" 

1327 

1328 

1329def _now_ts() -> float: 

1330 """Wall-clock now, as epoch seconds. 

1331 

1332 A thin wrapper around ``time.time()`` purely so tests can monkeypatch 

1333 session-expiry time (``local_deep_research.web.fastapi_app._now_ts``) 

1334 without touching the process-global stdlib clock. 

1335 """ 

1336 return time.time() 

1337 

1338 

1339def _enforce_session_expiry(session) -> None: 

1340 """Clear `session` in place if its server-side deadline has passed. 

1341 

1342 Regression #1: Starlette's ``SessionMiddleware`` verifies the 

1343 itsdangerous signature with a single ``max_age`` shared by every 

1344 session (see its construction below) — the signer has no notion of 

1345 "this particular session should expire sooner." That means a 

1346 non-remember-me login's raw cookie value stays independently 

1347 replayable for the FULL ``security.session_remember_me_days`` window 

1348 (30 days) purely because that's what SessionMiddleware was built 

1349 with, even though the browser is told (by ``RememberMeMiddleware``) 

1350 to discard the cookie on close. The intended lifetime for a 

1351 non-remember-me session is the much shorter 

1352 ``security.session_timeout_hours`` (default 2h). 

1353 

1354 Mechanism chosen: stamp an expiry deadline into the session payload 

1355 itself (`_stamp_session_expiry` below) and enforce it here, in 

1356 application code, independent of itsdangerous's own signature check. 

1357 

1358 Alternatives considered and rejected: 

1359 * Two ``SessionMiddleware`` instances (short/long ``max_age``), 

1360 dispatched by ``_remember_me`` — the dispatch decision itself 

1361 requires reading the session, i.e. doing the same "peek before you 

1362 verify" work this function already does, for substantially more 

1363 code (two secrets or cookie names to keep in sync, or a 

1364 request-splitting wrapper ASGI app in front of both). 

1365 * A custom itsdangerous signer with a variable ``max_age`` — ``max_age`` 

1366 is a verification-time argument to ``signer.unsign()``, not part of 

1367 the signed payload, so "vary the signer" still needs some per-session 

1368 signal to pick which ``max_age`` to verify with BEFORE verification 

1369 succeeds. That signal is exactly the deadline stamped here — no 

1370 simpler, and itsdangerous's own timestamp isn't readable without a 

1371 successful unsign first (chicken-and-egg). 

1372 * Enforce the same shorter deadline on remember-me sessions too 

1373 (uniform handling, less code) — rejected: their itsdangerous-level 

1374 cap already matches ``security.session_remember_me_days`` exactly, 

1375 so adding a second, redundant deadline only risks the two drifting 

1376 apart if an operator changes one setting and not the other. 

1377 

1378 Called from ``DatabaseMiddleware.__call__`` — the innermost 

1379 app-level middleware, run immediately before Starlette's router — 

1380 specifically so this runs BEFORE CSRFMiddleware's own session read 

1381 and before any route handler (including ``require_auth`` / 

1382 ``/auth/check``) can act on a session whose signature is still valid 

1383 but whose intended lifetime has passed. Clearing the dict here makes 

1384 an expired session indistinguishable from "never logged in" to every 

1385 downstream consumer, without touching any of those call sites. 

1386 

1387 Existing sessions: does NOT force-log-out non-remember-me sessions 

1388 that predate this fix. Their cookie has no stamped deadline yet 

1389 (`_session_expires_at` absent is treated as "not yet due", not 

1390 "expired"), so this is a no-op for them on their first request after 

1391 deploy — `_stamp_session_expiry` gives them a fresh deadline on that 

1392 same response instead. The one residual gap this leaves: a 

1393 non-remember-me cookie captured (XSS/log/devtools) in the narrow 

1394 window between deploy and that session's next request keeps its old, 

1395 unbounded-until-30-days replay property for one more grant of 

1396 ``security.session_timeout_hours``. A hard cutover (immediate mass 

1397 logout of every non-remember-me session) would close that but was 

1398 rejected per this task's explicit instruction not to log everyone out 

1399 unnecessarily. 

1400 """ 

1401 if session.get("_remember_me") is not False: 

1402 return 

1403 expires_at = session.get(_SESSION_EXPIRES_AT_KEY) 

1404 if expires_at is not None and _now_ts() >= expires_at: 

1405 session.clear() 

1406 

1407 

1408def _stamp_session_expiry(session) -> None: 

1409 """(Re)stamp the non-remember-me deadline `_enforce_session_expiry` 

1410 checks, sliding it forward on every response for an authenticated, 

1411 non-remember-me session. No-op for anonymous or remember-me sessions. 

1412 

1413 Runs from `DatabaseMiddleware`'s response-phase `send_wrapper` — i.e. 

1414 after any route handler (including the login handler itself) has 

1415 already run — so the very first Set-Cookie a non-remember-me login 

1416 produces already carries a deadline, not just its second response 

1417 onward. Sliding (refreshed on every authenticated response, like 

1418 ``session_manager.SessionManager``'s ``last_access``-based timeout) 

1419 rather than a fixed deadline from login time, matching 

1420 ``security.session_timeout_hours``'s documented meaning: "Session 

1421 timeout ... for sessions without 'Remember Me' checked" — an idle 

1422 timeout, not a hard cap on total session length. 

1423 """ 

1424 if session.get("username") and session.get("_remember_me") is False: 

1425 session[_SESSION_EXPIRES_AT_KEY] = ( 

1426 _now_ts() + _NON_REMEMBER_ME_SESSION_SECONDS 

1427 ) 

1428 

1429 

1430def _enforce_session_revocation(session) -> None: 

1431 """Clear `session` in place if its server-side record is gone. 

1432 

1433 `require_auth` rejects a cookie whose `session_id` no longer resolves 

1434 to the claimed username, which is what makes logout actually revoke a 

1435 session rather than merely ask the browser to forget it. But two GET 

1436 routes deliberately bypass `require_auth` — `/` and `/auth/check` — 

1437 because they must render/answer for anonymous visitors too, and both 

1438 read `request.session["username"]` directly. A sweep of all 110 GET 

1439 routes found exactly those two granting a revoked session strictly 

1440 more than an anonymous one: after logout, a replayed cookie still 

1441 rendered the authenticated index and still got `authenticated: true`, 

1442 for the full remaining itsdangerous window. `/` additionally opens 

1443 `get_user_db_session(username)` without a `session_id`, so the page 

1444 renders with the user's real saved settings. 

1445 

1446 Enforcing it here rather than patching those two handlers means the 

1447 property holds for every route by construction — including any future 

1448 handler that reads the session without going through `require_auth`, 

1449 which is precisely how these two got missed. Same placement rationale 

1450 as `_enforce_session_expiry`: clearing the dict makes a revoked 

1451 session indistinguishable from "never logged in" to everything 

1452 downstream, so no call site needs to know about revocation. 

1453 

1454 Anonymous sessions return immediately, so the cost on the hot path is 

1455 one dict lookup; for authenticated ones it is `validate_session`'s 

1456 in-memory dict read under a lock. That call also refreshes 

1457 `last_access`, which is why this runs unconditionally rather than 

1458 behind `_skip_prefixes` — same reasoning as the expiry check above. 

1459 

1460 Note `session_manager.sessions` is in-memory, so a server restart 

1461 invalidates every session. That is pre-existing `require_auth` 

1462 behaviour, not something this widens: it already applied to every 

1463 authenticated route. The only change is that `/` and `/auth/check` 

1464 now agree with the rest of the app about who is logged in. 

1465 """ 

1466 username = session.get("username") 

1467 if not username: 

1468 return 

1469 

1470 from .auth.session_manager import session_manager 

1471 

1472 session_id = session.get("session_id") 

1473 if not session_id or session_manager.validate_session(session_id) != ( 

1474 username 

1475 ): 

1476 session.clear() 

1477 

1478 

1479class DatabaseMiddleware: 

1480 """Pure ASGI middleware to ensure user database is open for authenticated requests. 

1481 

1482 Runs INSIDE SessionMiddleware so request.session is available. Also 

1483 enforces/refreshes the non-remember-me session deadline (regression 

1484 #1 — see `_enforce_session_expiry` / `_stamp_session_expiry` above) 

1485 since this is the innermost app-level middleware: the last point to 

1486 act on the session before CSRF's own read of it and before any route 

1487 handler runs. 

1488 """ 

1489 

1490 _skip_prefixes = ( 

1491 "/static/", 

1492 "/favicon.ico", 

1493 "/api/v1/health", 

1494 "/auth/login", 

1495 "/auth/register", 

1496 "/auth/csrf-token", 

1497 "/ws/", 

1498 ) 

1499 

1500 def __init__(self, app): 

1501 self.app = app 

1502 

1503 async def __call__(self, scope, receive, send): 

1504 if scope["type"] != "http": 

1505 await self.app(scope, receive, send) 

1506 return 

1507 

1508 path = scope.get("path", "") 

1509 method = scope.get("method", "GET") 

1510 

1511 from ..utilities.request_context import ( 

1512 reset_request_user, 

1513 set_request_user, 

1514 ) 

1515 

1516 # Regression #1: enforce the shorter non-remember-me deadline 

1517 # BEFORE anything downstream (CSRF's session read, the route 

1518 # handler) can act on a session whose itsdangerous signature is 

1519 # still valid but whose intended lifetime has passed. Runs 

1520 # unconditionally (not gated by _skip_prefixes below) — it's a 

1521 # cheap in-memory check/clear, not a DB open, and skipping it for 

1522 # e.g. static-asset requests would let an idle-but-signature-valid 

1523 # session's deadline silently stop advancing/enforcing whenever 

1524 # the user's tab is only pulling static assets. 

1525 session = scope.get("session") 

1526 if session is not None: 

1527 _enforce_session_expiry(session) 

1528 # Same placement, same reason, different property: expiry is 

1529 # "this session has been idle too long", revocation is "this 

1530 # session was destroyed server-side (logout, password change) 

1531 # but its signed cookie is still replayable". `require_auth` 

1532 # covers the latter for the routes that use it; this covers 

1533 # the ones that deliberately don't. See the docstring. 

1534 _enforce_session_revocation(session) 

1535 

1536 ctx_tokens = None 

1537 # Run the inner app first to let SessionMiddleware populate scope 

1538 # Then ensure database is open 

1539 if method != "OPTIONS" and not any( 

1540 path.startswith(p) for p in self._skip_prefixes 

1541 ): 

1542 # Session data is in scope["session"] after SessionMiddleware 

1543 session_data = scope.get("session", {}) 

1544 if session_data: 

1545 from .dependencies.auth import ensure_user_database 

1546 from .dependencies.threadpool import run_db_sync 

1547 

1548 # Create a minimal request-like object for ensure_user_database 

1549 class _MinimalRequest: 

1550 def __init__(self, session): 

1551 self.session = session 

1552 

1553 # ensure_user_database opens a SQLCipher connection (PBKDF2 

1554 # key derivation, file I/O) which would block the event loop 

1555 # for hundreds of ms on first call after login. Offload to a 

1556 # threadpool so concurrent requests don't serialize behind it. 

1557 # 

1558 # Use run_db_sync, not bare asyncio.to_thread: a cold 

1559 # open_user_database() call runs the phase-2 rekey path 

1560 # (_run_phase2_rekey -> rekey_user_indexes), which opens a 

1561 # get_user_db_session() unconditionally, before its own 

1562 # marker check (vector_stores/legacy_rekey.py). That leaves a 

1563 # thread-local session on whichever asyncio default-executor 

1564 # thread served this call, with no cleanup boundary -- 

1565 # asyncio.to_thread's executor is not drained by 

1566 # DatabaseMiddleware (event-loop thread only) or by any 

1567 # per-worker cleanup, only ever replaced when that pooled 

1568 # thread later serves a different username. run_db_sync 

1569 # exists for exactly this: it runs the sync call on the same 

1570 # default executor, then cleans up the thread-local DB 

1571 # session (and other ambient thread-local state) on that 

1572 # worker before returning. 

1573 await run_db_sync( 

1574 ensure_user_database, _MinimalRequest(session_data) 

1575 ) 

1576 

1577 # Publish username/session_id to a contextvar so service-layer 

1578 # code that historically read flask_session can fall back 

1579 # without requiring a Flask request context. 

1580 ctx_tokens = set_request_user( 

1581 session_data.get("username"), 

1582 session_data.get("session_id"), 

1583 ) 

1584 

1585 async def send_wrapper(message): 

1586 if message["type"] == "http.response.start": 

1587 # Regression #1: (re)stamp the non-remember-me deadline on 

1588 # the way out, after the route handler (including a fresh 

1589 # login) has had a chance to set username/_remember_me. 

1590 # See _stamp_session_expiry's docstring. 

1591 current_session = scope.get("session") 

1592 if current_session is not None: 

1593 _stamp_session_expiry(current_session) 

1594 await send(message) 

1595 

1596 try: 

1597 await self.app(scope, receive, send_wrapper) 

1598 finally: 

1599 if ctx_tokens is not None: 

1600 reset_request_user(ctx_tokens) 

1601 

1602 # Cleanup after request. 

1603 # 

1604 # Keep the two sweeps independent so one failure cannot skip the 

1605 # other. This async middleware can clean event-loop-owned state; 

1606 # WorkerCleanupAPIRoute already reclaimed a synchronous 

1607 # endpoint's AnyIO-worker state before control returned here. 

1608 # Pinned by tests/web/test_teardown_cleanup_asgi.py and 

1609 # tests/web/test_db_session_lifecycle_asgi.py. 

1610 try: 

1611 from ..database.thread_local_session import ( 

1612 cleanup_dead_threads, 

1613 ) 

1614 

1615 cleanup_dead_threads() 

1616 except Exception: 

1617 logger.debug("Error during post-request dead-thread cleanup") 

1618 

1619 try: 

1620 from ..database.thread_local_session import ( 

1621 cleanup_current_thread, 

1622 ) 

1623 

1624 cleanup_current_thread() 

1625 except Exception: 

1626 logger.debug("Error during post-request current-thread cleanup") 

1627 

1628 

1629# --------------------------------------------------------------------------- 

1630# Request-arrival/duration forensics (CI/TESTING only) — issue #4431 

1631# --------------------------------------------------------------------------- 

1632 

1633 

1634class RequestTimingASGIMiddleware: 

1635 """Pure ASGI port of ``web/utils/request_timing.RequestTimingMiddleware``. 

1636 

1637 Named ``...ASGIMiddleware`` rather than reusing the original's name on 

1638 purpose: both classes live in the same package, and one of them is a 

1639 WSGI callable that would fail silently and confusingly if handed to 

1640 ``add_middleware``. 

1641 

1642 That module is a plain WSGI callable (``__call__(environ, 

1643 start_response)``) — main installed it by wrapping ``app.wsgi_app`` in 

1644 ``app_factory.create_app`` under CI/TESTING (#4536), and the FastAPI 

1645 migration deleted ``app_factory.py`` and lost the wiring with it (#5959). 

1646 A WSGI callable cannot be handed to ``app.add_middleware``, so the 

1647 transport half is re-expressed here while the *policy* half — the log 

1648 format the CI log-grep keys on, the slow-completion threshold, and the 

1649 faulthandler freeze dead-man's switch — is imported from the original 

1650 module so there is exactly one definition of each. 

1651 

1652 Pure ASGI rather than ``BaseHTTPMiddleware`` for the same reason as 

1653 ``BodySizeLimitMiddleware`` / ``_PathScopedCORSMiddleware``: this sits 

1654 outside the streaming SSE routes and ``BaseHTTPMiddleware`` buffers 

1655 ``StreamingResponse`` bodies. 

1656 

1657 One deliberate difference from the WSGI original: the duration here 

1658 covers the whole downstream ASGI call, i.e. arrival through the final 

1659 response body — which is what #4431 actually wants to measure — rather 

1660 than WSGI view execution with response streaming excluded. The arrival 

1661 line, whose *absence* during a navigation timeout is the real signal, 

1662 is byte-identical. 

1663 """ 

1664 

1665 def __init__(self, app): 

1666 self.app = app 

1667 from .utils.request_timing import _arm_freeze_dump 

1668 

1669 # Arm the freeze thread-dump dead-man's switch (no-op under pytest). 

1670 _arm_freeze_dump() 

1671 

1672 async def __call__(self, scope, receive, send): 

1673 if scope["type"] != "http": 

1674 # CORS-style scoping: the forensics are about HTTP request 

1675 # arrival. lifespan/websocket scopes pass straight through. 

1676 await self.app(scope, receive, send) 

1677 return 

1678 

1679 from .utils.request_timing import ( 

1680 SLOW_REQUEST_SECONDS, 

1681 _arm_freeze_dump, 

1682 ) 

1683 

1684 # Re-arm the dead-man's switch: as long as requests keep arriving the 

1685 # dump never fires; a freeze (no arrivals) lets it fire and capture 

1686 # the stuck thread stacks. 

1687 _arm_freeze_dump() 

1688 

1689 method = scope.get("method", "-") 

1690 path = scope.get("path", "-") 

1691 # engine.io transport/sid make poll churn correlatable. (sid is 

1692 # logged on purpose for correlation; logs are CI-only artifacts.) 

1693 if path.startswith("/socket.io"): 

1694 query = scope.get("query_string", b"").decode("latin-1", "replace") 

1695 path = f"{path}?{query}" if query else path 

1696 # Strip CR/LF so a crafted path/query cannot inject fake log lines 

1697 # (the forensics output is grep'd downstream). 

1698 path = path.replace("\r", "\\r").replace("\n", "\\n") 

1699 logger.info(f"[req] > {method} {path}") 

1700 start = time.monotonic() 

1701 try: 

1702 await self.app(scope, receive, send) 

1703 finally: 

1704 elapsed = time.monotonic() - start 

1705 if elapsed >= SLOW_REQUEST_SECONDS: 

1706 logger.warning(f"[req] < {method} {path} {elapsed:.1f}s SLOW") 

1707 else: 

1708 logger.info(f"[req] < {method} {path} {elapsed:.2f}s") 

1709 

1710 

1711def _request_timing_enabled() -> bool: 

1712 """Whether to install :class:`RequestTimingASGIMiddleware` on this process. 

1713 

1714 main's gate was ``os.environ.get("CI") or os.environ.get("TESTING")``, 

1715 kept verbatim, plus one addition: never inside a pytest process. 

1716 

1717 The diagnostic exists for the long-running UI-shard server, which CI 

1718 starts as its own process (``python -m local_deep_research.web.app``) 

1719 with ``CI`` set — pytest is not imported there, so the gate still arms 

1720 exactly where #4431 needs it. The pytest exclusion mirrors 

1721 ``request_timing._should_arm_freeze_dump``'s own reasoning (that module 

1722 already refuses to arm the faulthandler timer under pytest) and keeps 

1723 the in-process middleware stack that 

1724 ``tests/web/test_middleware_order_and_headers.py`` pins byte-identical 

1725 on a CI runner, where ``CI=true`` is set for the unit-test job too. 

1726 """ 

1727 if "pytest" in sys.modules: 

1728 return False 

1729 return bool(os.environ.get("CI") or os.environ.get("TESTING")) 

1730 

1731 

1732# --------------------------------------------------------------------------- 

1733# Rate limiting 

1734# --------------------------------------------------------------------------- 

1735 

1736 

1737def _setup_rate_limiting(app: FastAPI) -> None: 

1738 """Configure slowapi rate limiting on the app.""" 

1739 from slowapi.errors import RateLimitExceeded 

1740 from slowapi.middleware import SlowAPIMiddleware 

1741 

1742 from .dependencies.rate_limit import _get_client_ip, limiter 

1743 

1744 app.state.limiter = limiter 

1745 

1746 # Deliberately `def`, not `async def`. slowapi's SlowAPIMiddleware handles 

1747 # the global default limits through `sync_check_limits`, which cannot await 

1748 # and therefore DISCARDS an async handler outright: 

1749 # 

1750 # # cannot execute asynchronous code in a synchronous middleware, 

1751 # # -> fallback on default exception handler 

1752 # if inspect.iscoroutinefunction(exception_handler): 

1753 # exception_handler = _rate_limit_exceeded_handler 

1754 # (slowapi/middleware.py) 

1755 # 

1756 # While this was `async def`, every 429 raised by the middleware path — i.e. 

1757 # every undecorated route hitting the global default — silently used 

1758 # slowapi's stock handler instead of this one, losing the audit warning 

1759 # below, the Retry-After headers, and main's {"error", "message"} body 

1760 # contract. Only routes carrying an explicit @limiter.limit decorator 

1761 # (the async path) ever reached this function. Nothing here awaits, so 

1762 # making it sync costs nothing and covers both paths. 

1763 def _rate_limit_exceeded(request: Request, exc: RateLimitExceeded): 

1764 # Audit logging for security monitoring — port of main's 429 

1765 # errorhandler. _get_client_ip resolves the real IP behind 

1766 # trusted proxies (raw request.client would log the proxy). 

1767 logger.warning( 

1768 f"Rate limit exceeded: endpoint={request.url.path} " 

1769 f"ip={_get_client_ip(request)} " 

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

1771 ) 

1772 response = JSONResponse( 

1773 { 

1774 "error": "Too many requests", 

1775 "message": "Too many attempts. Please try again later.", 

1776 }, 

1777 status_code=429, 

1778 # Stamp the security headers here, the same way the catch-all 500 

1779 # handler does. `_setup_rate_limiting` runs AFTER 

1780 # `add_middleware(SecurityHeadersMiddleware)`, and Starlette's 

1781 # add_middleware is LIFO, so SlowAPIMiddleware ends up OUTSIDE 

1782 # SecurityHeadersMiddleware — a 429 it generates never passes 

1783 # through it and would otherwise ship with no CSP, no nosniff, no 

1784 # X-Frame-Options and no Cache-Control. Reordering the stack would 

1785 # be the alternative, but the order is deliberately pinned by 

1786 # tests/web/test_middleware_order_and_headers.py. 

1787 headers={ 

1788 name.decode(): value.decode() 

1789 for name, value in ( 

1790 SecurityHeadersMiddleware.unconditional_headers() 

1791 + SecurityHeadersMiddleware.cache_headers() 

1792 ) 

1793 }, 

1794 ) 

1795 # Restore Retry-After / X-RateLimit-* on 429s. `headers_enabled=True` 

1796 # on the Limiter (dependencies/rate_limit.py) alone does not add 

1797 # them — slowapi only injects headers when a caller invokes 

1798 # `_inject_headers` itself, which its own default 

1799 # `_rate_limit_exceeded_handler` (slowapi/extension.py) does; this 

1800 # app registers a custom handler instead (for the audit log line 

1801 # above and main's ``{"error", "message"}`` body contract) and, 

1802 # before this fix, never called it, so live 429s carried none of 

1803 # these headers even with the flag on. Flask-Limiter added them on 

1804 # main via its own ``after_request`` hook — no direct FastAPI 

1805 # equivalent exists, so it has to happen here, per-handler. 

1806 # 

1807 # Uses the `limiter` closed over above (same object as 

1808 # `app.state.limiter`) rather than going through 

1809 # `request.app.state.limiter` — the two are identical for the real 

1810 # app, but the latter forces every caller of this handler to have 

1811 # a real `request.app`, and tests/web/test_rate_limit_coverage.py 

1812 # ::TestRateLimitExceededHandler calls this handler directly with 

1813 # a bare ``Mock()`` request (pinning the audit-log/body contract in 

1814 # isolation, unrelated to this fix). Going through `request.app` 

1815 # there silently replaces `response` with an auto-generated Mock 

1816 # instead of exercising real header-injection logic. 

1817 # 

1818 # `request.state.view_rate_limit` is set by slowapi's 

1819 # `Limiter.__evaluate_limits` (private method) immediately before 

1820 # it raises `RateLimitExceeded` — verified against the installed 

1821 # slowapi source (`.venv/lib/*/site-packages/slowapi/__init__.py`) 

1822 # rather than assumed, since both the attribute and 

1823 # `_inject_headers` are private slowapi API that could rename 

1824 # across versions. The whole call is wrapped in a try/except 

1825 # (rather than trusting `_inject_headers`'s own internal 

1826 # swallow_errors — this Limiter is built with 

1827 # `swallow_errors=False`, i.e. it re-raises) so nothing about this 

1828 # best-effort header injection — a missing `view_rate_limit`, a 

1829 # storage hiccup, or (as in the Mock-request test above) a 

1830 # request.state that doesn't behave like a real one — can ever 

1831 # turn a 429 into a 500. On any failure `response` simply keeps 

1832 # its original value, assigned before the `try`. 

1833 # 

1834 # tests/web/test_rate_limit_headers_on_429.py pins this against a 

1835 # real 429 from a real rate-limited route so a future slowapi 

1836 # rename of either private symbol fails loudly here instead of 

1837 # silently dropping the headers again. 

1838 # Set the headers directly rather than calling 

1839 # `limiter._inject_headers`. That method is gated on the Limiter's 

1840 # `_headers_enabled`, and this app deliberately leaves that flag OFF 

1841 # — turning it on makes slowapi wrap EVERY rate-limited route and 

1842 # raise on any handler that returns a plain dict instead of a 

1843 # Response, turning ordinary 200s into 500s (see the comment on 

1844 # `_limiter_kwargs` in dependencies/rate_limit.py). So calling 

1845 # `_inject_headers` here would silently no-op; the values have to be 

1846 # computed here instead. 

1847 try: 

1848 current_limit = getattr(request.state, "view_rate_limit", None) 

1849 if current_limit is not None: 

1850 item, key_parts = current_limit 

1851 reset_at, remaining = limiter.limiter.get_window_stats( 

1852 item, *key_parts 

1853 ) 

1854 response.headers["Retry-After"] = str( 

1855 max(0, int(reset_at - time.time())) 

1856 ) 

1857 response.headers["X-RateLimit-Limit"] = str(item.amount) 

1858 response.headers["X-RateLimit-Remaining"] = str(remaining) 

1859 response.headers["X-RateLimit-Reset"] = str(int(reset_at)) 

1860 except Exception: 

1861 logger.debug( 

1862 "Could not attach Retry-After/X-RateLimit-* headers to the " 

1863 "429 response; returning it without them." 

1864 ) 

1865 return response 

1866 

1867 app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded) 

1868 

1869 # Enforce the global DEFAULT_RATE_LIMIT on every route without an 

1870 # explicit limit decorator (decorated routes are exempt here — their 

1871 # decorators handle their own checks; mounts like /ws have no route 

1872 # handler and are skipped). Flask-Limiter's default_limits behaved 

1873 # the same way on main. 

1874 app.add_middleware(SlowAPIMiddleware) 

1875 

1876 

1877# --------------------------------------------------------------------------- 

1878# Static file serving 

1879# --------------------------------------------------------------------------- 

1880 

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

1882 

1883 

1884def _add_static_routes(app: FastAPI) -> None: 

1885 """Add static file serving routes with cache control.""" 

1886 from ..security.path_validator import PathValidator 

1887 

1888 static_dir = Path(STATIC_DIR) 

1889 dist_dir = static_dir / "dist" 

1890 

1891 # Static assets must not share the API's default per-IP bucket: base.html 

1892 # alone pulls ~29 assets, served must-revalidate, so a few page loads 

1893 # could exhaust a user's whole hourly budget. main exempted these. 

1894 from .dependencies.rate_limit import limiter as _limiter 

1895 

1896 @app.get("/favicon.ico", include_in_schema=False) 

1897 @_limiter.exempt 

1898 async def favicon(): 

1899 from fastapi.responses import FileResponse 

1900 

1901 favicon_path = static_dir / "favicon.ico" 

1902 if favicon_path.exists(): 

1903 return FileResponse(str(favicon_path), media_type="image/x-icon") 

1904 return JSONResponse({"error": "Not found"}, status_code=404) 

1905 

1906 @app.get("/static/{path:path}", include_in_schema=False) 

1907 @_limiter.exempt 

1908 async def serve_static(path: str): 

1909 from fastapi.responses import FileResponse 

1910 

1911 # Try dist directory first (Vite-built assets) 

1912 dist_prefix = "dist/" 

1913 if path.startswith(dist_prefix): 

1914 rel_path = path[len(dist_prefix) :] 

1915 try: 

1916 validated = PathValidator.validate_safe_path( 

1917 rel_path, dist_dir, allow_absolute=False 

1918 ) 

1919 if validated and validated.is_file(): 1919 ↛ 1939line 1919 didn't jump to line 1939 because the condition on line 1919 was always true

1920 headers = {} 

1921 if _HASHED_FILENAME_RE.search(rel_path): 

1922 headers["Cache-Control"] = ( 

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

1924 ) 

1925 else: 

1926 headers["Cache-Control"] = ( 

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

1928 ) 

1929 return FileResponse(str(validated), headers=headers) 

1930 except (ValueError, OSError): 

1931 # OSError too: is_file() stats the path, so a segment longer 

1932 # than the filesystem's NAME_MAX raises ENAMETOOLONG (errno 

1933 # 36), which `except ValueError` does not catch. This route 

1934 # is unauthenticated AND rate-limit exempt, so an uncaught 

1935 # one is an anonymous 500 for any long path. 

1936 pass 

1937 

1938 # Try dist directory for Vite assets (fonts, etc.) 

1939 try: 

1940 validated = PathValidator.validate_safe_path( 

1941 path, dist_dir, allow_absolute=False 

1942 ) 

1943 if validated and validated.is_file(): 

1944 headers = {} 

1945 if _HASHED_FILENAME_RE.search(path): 1945 ↛ 1946line 1945 didn't jump to line 1946 because the condition on line 1945 was never true

1946 headers["Cache-Control"] = ( 

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

1948 ) 

1949 else: 

1950 headers["Cache-Control"] = ( 

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

1952 ) 

1953 return FileResponse(str(validated), headers=headers) 

1954 except (ValueError, OSError): 

1955 # is_file() can raise OSError (ENAMETOOLONG), not just 

1956 # ValueError -- see the note on the first handler above. 

1957 pass 

1958 

1959 # Fall back to regular static directory 

1960 try: 

1961 validated = PathValidator.validate_safe_path( 

1962 path, static_dir, allow_absolute=False 

1963 ) 

1964 if validated and validated.is_file(): 

1965 return FileResponse( 

1966 str(validated), 

1967 headers={ 

1968 "Cache-Control": "public, max-age=0, must-revalidate" 

1969 }, 

1970 ) 

1971 except (ValueError, OSError): 

1972 # is_file() can raise OSError (ENAMETOOLONG), not just 

1973 # ValueError -- see the note on the first handler above. 

1974 pass 

1975 

1976 return JSONResponse({"error": "Not found"}, status_code=404) 

1977 

1978 

1979# --------------------------------------------------------------------------- 

1980# Template helpers (replaces Flask's context_processor / jinja_env.globals) 

1981# --------------------------------------------------------------------------- 

1982 

1983# Matches url_for('name') / url_for("name") usages in templates. Shared by 

1984# _validate_url_for_bindings (below) and 

1985# tests/web/templates/test_url_for_links.py, which independently extracts 

1986# the same names to fence dead links. 

1987_URL_FOR_NAME_RE = re.compile(r"url_for\(\s*['\"]([^'\"]+)['\"]") 

1988 

1989 

1990def _setup_template_globals() -> None: 

1991 """Register template globals for Jinja2 (CSRF, url_for, flash, vite, themes, etc.).""" 

1992 env = templates.env 

1993 

1994 # Vite asset helpers (must be registered at module level, not in lifespan) 

1995 from .utils.vite_helper import vite 

1996 

1997 vite.init_for_fastapi(STATIC_DIR, templates) 

1998 

1999 # CSRF token — needs request, so we provide a callable that reads from 

2000 # the template context (where request is passed per-render). 

2001 # Templates call {{ csrf_token() }} which reads _csrf_request from context. 

2002 

2003 def csrf_token_factory(): 

2004 """Returns empty string by default. Overridden per-request via context.""" 

2005 return "" 

2006 

2007 env.globals["csrf_token"] = csrf_token_factory 

2008 

2009 # Flash messages — same pattern: needs request. 

2010 env.globals["get_flashed_messages"] = lambda with_categories=False: [] 

2011 

2012 # Theme helpers 

2013 from .themes import get_theme_metadata, get_themes, get_themes_json 

2014 

2015 env.globals["get_themes"] = get_themes 

2016 env.globals["get_themes_json"] = get_themes_json 

2017 env.globals["get_theme_metadata"] = get_theme_metadata 

2018 

2019 # Session — default empty dict, overridden per-request via context 

2020 env.globals["session"] = {} 

2021 

2022 # Egress scope — default to the registered fallback so templates 

2023 # rendered WITHOUT render_template() (e.g. the index route's direct 

2024 # TemplateResponse) can still reference egress_scope on 

2025 # <body data-scope=…> without raising an UndefinedError. render_template() 

2026 # overrides this per-render with the user's resolved scope. 

2027 from ..security.egress.policy import DEFAULT_EGRESS_SCOPE 

2028 

2029 env.globals["egress_scope"] = DEFAULT_EGRESS_SCOPE 

2030 

2031 # Kept INSIDE this function as a single direct dict literal, not hoisted 

2032 # to module scope: .pre-commit-hooks/check-url-for-targets.py parses this 

2033 # function statically and requires exactly one literal map here ("Expected 

2034 # one direct literal map"). _validate_url_for_bindings does not need the 

2035 # dict — it resolves names through templates.env.globals["url_for"], the 

2036 # same closure the templates use. 

2037 # url_for wrapper map (Flask-style blueprint.endpoint -> path). 

2038 _URL_MAP = { 

2039 # Auth 

2040 "auth.login": "/auth/login", 

2041 "auth.login_page": "/auth/login", 

2042 "auth.register": "/auth/register", 

2043 "auth.register_page": "/auth/register", 

2044 "auth.logout": "/auth/logout", 

2045 "auth.change_password": "/auth/change-password", 

2046 "auth.change_password_page": "/auth/change-password", 

2047 # Pages 

2048 "index": "/", 

2049 "static": "/static", 

2050 "history.history_page": "/history/", 

2051 "chat.chat_page": "/chat/", 

2052 # Library / RAG 

2053 "library.library_page": "/library/", 

2054 "library.download_manager_page": "/library/download-manager", 

2055 "rag.collections_page": "/library/collections", 

2056 "rag.embedding_settings_page": "/library/embedding-settings", 

2057 # Zotero page lives under the /library prefix; without this the 

2058 # dot-to-slash fallback yields a dead /zotero/zotero_page link. 

2059 "zotero.zotero_page": "/library/zotero", 

2060 # Notes + unified-search pages (the dot-to-slash fallback would 

2061 # otherwise produce /notes/notes_page and 

2062 # /unified_search/unified_search_page dead links). 

2063 "notes.notes_page": "/notes/", 

2064 "unified_search.unified_search_page": "/library/search/", 

2065 # News 

2066 "news.news_page": "/news/", 

2067 "news.subscriptions_page": "/news/subscriptions", 

2068 # Metrics 

2069 "metrics.metrics_dashboard": "/metrics/", 

2070 "metrics.journal_quality": "/metrics/journals", 

2071 # Benchmark 

2072 "benchmark.index": "/benchmark/", 

2073 # Currently also produced by the dot-to-slash fallback, but pin it 

2074 # so a rename of the route function can't silently break the 

2075 # sidebar link. 

2076 "benchmark.results": "/benchmark/results", 

2077 # Settings (page + form action) 

2078 "settings.settings_page": "/settings/", 

2079 "settings.save_settings": "/settings/save_settings", 

2080 "settings.save_all_settings": "/settings/save_all_settings", 

2081 "settings.main_config_page": "/settings/main", 

2082 "settings.collections_config_page": "/settings/collections", 

2083 "settings.api_keys_config_page": "/settings/api_keys", 

2084 "settings.llm_config_page": "/settings/llm", 

2085 "settings.search_engines_config_page": "/settings/search_engines", 

2086 } 

2087 

2088 def url_for(name, **kwargs): 

2089 path = _URL_MAP.get(name, f"/{name.replace('.', '/')}") 

2090 if name == "static" and "filename" in kwargs: 

2091 path = f"/static/{kwargs['filename']}" 

2092 elif name == "static" and "path" in kwargs: 2092 ↛ 2093line 2092 didn't jump to line 2093 because the condition on line 2092 was never true

2093 path = f"/static/{kwargs['path']}" 

2094 # Append query params 

2095 query_params = { 

2096 k: v for k, v in kwargs.items() if k not in ("filename", "path") 

2097 } 

2098 if query_params: 

2099 from urllib.parse import urlencode 

2100 

2101 path += "?" + urlencode(query_params) 

2102 return path 

2103 

2104 env.globals["url_for"] = url_for 

2105 

2106 

2107# --------------------------------------------------------------------------- 

2108# Create the FastAPI application 

2109# --------------------------------------------------------------------------- 

2110 

2111# Deliberately does NOT check the generic `CI` / `TESTING` variables, which an 

2112# earlier version did. This flag suppresses the `Secure` attribute on session 

2113# cookies (SecureCookieMiddleware) *and* both operator warnings about serving 

2114# a public instance over plain HTTP, so any CD pipeline or PaaS that exports 

2115# CI=true would have shipped session cookies without `Secure` over HTTPS, and 

2116# silenced the warning that would have said so. 

2117# 

2118# This is the same reasoning already written down in 

2119# database/sqlcipher_utils.py::_get_min_kdf_iterations, which refuses to relax 

2120# the KDF floor on CI/TESTING for exactly this reason. The cookie path had not 

2121# been given that treatment. 

2122# 

2123# PYTEST_CURRENT_TEST is presence-based (pytest sets it to a descriptive 

2124# string); LDR_TEST_MODE is parsed as a real boolean so `LDR_TEST_MODE=0` does 

2125# not enable test mode. 

2126# 

2127# CAVEAT, measured rather than assumed: this is evaluated at IMPORT time, and 

2128# pytest sets PYTEST_CURRENT_TEST only while a test body runs -- not during 

2129# collection, which is when a test module's top-level `import fastapi_app` 

2130# actually happens. So under a normal run this half is False and 

2131# LDR_TEST_MODE is the only switch that reaches here. Contrast 

2132# sqlcipher_utils::_get_min_kdf_iterations, which reads the same variable 

2133# INSIDE a function and therefore does see it. 

2134# 

2135# That is deliberate rather than a gap worth "fixing": the effect of False is 

2136# that tests exercise the production cookie path, which is the stricter and 

2137# more realistic behaviour. Do not switch this to a lazy/per-request lookup 

2138# expecting tests to relax -- it would re-open exactly the hole the paragraph 

2139# above describes, just keyed on a different variable. 

2140is_testing = bool(os.getenv("PYTEST_CURRENT_TEST")) or to_bool( 

2141 os.getenv("LDR_TEST_MODE") 

2142) 

2143 

2144# Public API docs are disabled by default — a multi-user deployment 

2145# shouldn't expose the full schema unauthenticated. Set LDR_EXPOSE_DOCS=true 

2146# to re-enable (useful for internal/dev instances). 

2147_expose_docs = os.getenv("LDR_EXPOSE_DOCS", "").lower() in ("true", "1", "yes") 

2148_docs_enabled = _expose_docs and not is_testing 

2149 

2150app = FastAPI( 

2151 title="Local Deep Research", 

2152 version=__version__, 

2153 docs_url="/api/docs" if _docs_enabled else None, 

2154 # Gate the OpenAPI schema endpoint together with the Swagger UI: 

2155 # /openapi.json IS the full API schema, so leaving it on its default 

2156 # "/openapi.json" while disabling docs_url still exposes the entire 

2157 # surface unauthenticated. app.openapi() remains callable in-process. 

2158 openapi_url="/openapi.json" if _docs_enabled else None, 

2159 redoc_url=None, 

2160 lifespan=lifespan, 

2161) 

2162 

2163# FastAPI runs plain ``def`` endpoints on AnyIO workers, whereas ASGI 

2164# middleware teardown runs on the event-loop thread. Install an endpoint 

2165# wrapper so thread-local DB sessions are released on the worker that owns 

2166# them. Populated module routers receive the same dispatch wrapper in 

2167# ``_mount_all`` without replacing their registered endpoint objects. 

2168from .dependencies.threadpool import WorkerCleanupAPIRoute # noqa: E402 

2169 

2170app.router.route_class = WorkerCleanupAPIRoute 

2171 

2172# ASGI middleware stack. 

2173# Starlette add_middleware is LIFO: last added = outermost. 

2174# Desired order (outer→inner): SecureCookie → SecurityHeaders → Session → CSRF → Database → app 

2175# 

2176# So we add in reverse: Database first, then CSRF, then Session, etc. 

2177 

2178app.add_middleware(DatabaseMiddleware) 

2179 

2180# CSRF runs after Session populates scope["session"] and rejects state-changing 

2181# requests with a missing/invalid X-CSRFToken header before they reach handlers. 

2182from .dependencies.csrf import CSRFMiddleware # noqa: E402 

2183 

2184app.add_middleware(CSRFMiddleware) 

2185 

2186app.add_middleware( 

2187 SessionMiddleware, 

2188 secret_key=SECRET_KEY, 

2189 session_cookie="session", 

2190 max_age=get_security_default("security.session_remember_me_days", 30) 

2191 * 24 

2192 * 3600, 

2193 # "strict" is safe here because LDR has no OAuth redirects or 

2194 # external flows that require cross-site cookie delivery. 

2195 same_site="strict", 

2196 https_only=False, # Handled dynamically by SecureCookieMiddleware 

2197) 

2198 

2199# Runs just outside SessionMiddleware so it sees the Set-Cookie 

2200# header SessionMiddleware just wrote, and can strip Max-Age/Expires 

2201# for non-remember-me logins. See the class docstring. 

2202app.add_middleware(RememberMeMiddleware) 

2203 

2204# Body cap sits inside SecurityHeaders so its 413 responses still get 

2205# security headers, but outside Session/CSRF so over-limit bodies are 

2206# rejected before anything buffers them. 

2207app.add_middleware(BodySizeLimitMiddleware) 

2208 

2209app.add_middleware(SecurityHeadersMiddleware) 

2210app.add_middleware(SecureCookieMiddleware, testing=is_testing) 

2211 

2212 

2213# Prefixes that carried CORS headers on main. `SecurityHeaders._is_api_route` 

2214# (security/security_headers.py, deleted by the FastAPI migration) gated CORS to 

2215# exactly these three, so an operator who whitelisted an origin for API access 

2216# did not thereby grant it cross-origin reads of every HTML page. Restored here. 

2217_CORS_API_PREFIXES = ("/api/", "/research/api/", "/history/api") 

2218 

2219 

2220class _PathScopedCORSMiddleware: 

2221 """Apply Starlette's CORSMiddleware only to API paths. 

2222 

2223 Starlette's CORSMiddleware has no path predicate: mounted app-wide it 

2224 answers preflights and stamps `Access-Control-Allow-Origin` on every route, 

2225 HTML pages included. main scoped that to three prefixes and the migration 

2226 dropped the scoping along with `_is_api_route`. 

2227 

2228 Pure ASGI rather than BaseHTTPMiddleware, deliberately: this sits in front 

2229 of the streaming SSE routes, and BaseHTTPMiddleware buffers 

2230 StreamingResponse bodies (the defect already tracked for 

2231 SlowAPIMiddleware). Non-matching paths are handed to the inner app 

2232 untouched, so the pass-through case adds one string comparison and no 

2233 wrapping at all. 

2234 

2235 Non-HTTP scopes (websocket, lifespan) bypass CORS entirely — the CORS 

2236 protocol is HTTP-only, and the Socket.IO mount does its own origin check. 

2237 """ 

2238 

2239 def __init__(self, app, prefixes, cors_factory): 

2240 self.app = app 

2241 self.prefixes = tuple(prefixes) 

2242 # Build the real CORSMiddleware once, wrapping the inner app, so a 

2243 # matching request gets Starlette's full preflight/origin handling. 

2244 self.cors_app = cors_factory(app) 

2245 

2246 async def __call__(self, scope, receive, send): 

2247 if scope["type"] != "http": 

2248 await self.app(scope, receive, send) 

2249 return 

2250 path = scope.get("path", "") 

2251 if path.startswith(self.prefixes): 

2252 await self.cors_app(scope, receive, send) 

2253 else: 

2254 await self.app(scope, receive, send) 

2255 

2256 

2257def _configure_cors(app: FastAPI) -> None: 

2258 """Restore configurable cross-origin access for API clients. 

2259 

2260 Ports the CORS support main applied in security_headers.py from 

2261 ``security.cors.allowed_origins`` (env: LDR_SECURITY_CORS_ALLOWED_ORIGINS), 

2262 which the FastAPI migration dropped — the setting was live config on 

2263 main but reached no code on this branch. Uses Starlette's 

2264 CORSMiddleware (correct preflight/OPTIONS handling, origin matching) 

2265 rather than hand-injecting headers. 

2266 

2267 Fail closed: with the setting empty (the default) no CORS middleware 

2268 is registered, so the app stays same-origin only — exactly main's 

2269 empty-default behavior. Same-origin requests carry no Origin header 

2270 and never receive CORS headers regardless, so this is effectively 

2271 scoped to genuine cross-origin API calls. 

2272 """ 

2273 from ..settings.env_registry import get_env_setting 

2274 

2275 configured = get_env_setting("security.cors.allowed_origins") 

2276 if not configured: 

2277 return 

2278 

2279 if configured.strip() == "*": 

2280 # Credentials with a wildcard origin is forbidden by the CORS spec 

2281 # (and was a startup error on main). Allow the wildcard for 

2282 # non-credentialed access only. 

2283 origins = ["*"] 

2284 allow_credentials = False 

2285 else: 

2286 origins = [o.strip() for o in configured.split(",") if o.strip()] 

2287 allow_credentials = False 

2288 

2289 from starlette.middleware.cors import CORSMiddleware 

2290 

2291 app.add_middleware( 

2292 _PathScopedCORSMiddleware, 

2293 prefixes=_CORS_API_PREFIXES, 

2294 cors_factory=lambda inner: CORSMiddleware( 

2295 inner, 

2296 allow_origins=origins, 

2297 allow_credentials=allow_credentials, 

2298 allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], 

2299 allow_headers=[ 

2300 "Content-Type", 

2301 "Authorization", 

2302 "X-Requested-With", 

2303 "X-HTTP-Method-Override", 

2304 "X-CSRFToken", 

2305 ], 

2306 max_age=3600, 

2307 ), 

2308 ) 

2309 logger.info( 

2310 f"CORS enabled for API origins: {origins} " 

2311 f"(scoped to {', '.join(_CORS_API_PREFIXES)})" 

2312 ) 

2313 

2314 

2315# Rate limiting via slowapi + exception handlers 

2316_setup_rate_limiting(app) 

2317_register_exception_handlers(app) 

2318 

2319# Registered last of the request-handling middleware, so it is the outermost 

2320# of them: a preflight OPTIONS is answered before auth, CSRF, or rate limiting 

2321# ever run. (The CI-only request-timing layer below is added after it and so 

2322# sits further out still, but it only logs and passes every scope through, so 

2323# it changes nothing about that ordering.) 

2324_configure_cors(app) 

2325 

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

2327# every request's arrival + duration so a silent log window can be attributed 

2328# to "request never arrived" vs "request hung in the app". Ports #4536, which 

2329# merge 5ad5f5a1b reverted along with app_factory.py (#5959). Added LAST of 

2330# all `add_middleware` calls because that list is LIFO — last added is 

2331# outermost — which is the only position from which it measures true 

2332# arrival-to-response time. 

2333if _request_timing_enabled(): 2333 ↛ 2334line 2333 didn't jump to line 2334 because the condition on line 2333 was never true

2334 app.add_middleware(RequestTimingASGIMiddleware) 

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

2336 

2337# Static file routes 

2338_add_static_routes(app) 

2339 

2340# Template globals 

2341_setup_template_globals() 

2342 

2343# --------------------------------------------------------------------------- 

2344# Mount routers and Socket.IO (deferred imports to avoid E402) 

2345# --------------------------------------------------------------------------- 

2346 

2347 

2348def _mount_all(app: FastAPI) -> None: 

2349 """Mount all routers and Socket.IO on the app.""" 

2350 from ..database.session_context import get_user_db_session 

2351 from ..settings.logger import log_settings 

2352 from ..utilities.db_utils import get_settings_manager 

2353 from .dependencies.threadpool import prepare_router_for_worker_cleanup 

2354 from .routers.api_v1 import router as api_v1_router 

2355 from .services.socketio_asgi import socket_app 

2356 

2357 # Mount Socket.IO 

2358 app.mount("/ws", socket_app) 

2359 

2360 # Include all routers — error-tolerant loading during migration. 

2361 # Routers with unresolved import issues will be skipped with a warning. 

2362 _routers = { 

2363 "api_v1": api_v1_router, 

2364 } 

2365 

2366 # Phase 2+ routers — Phase 2-7 is complete; an import error here is a 

2367 # production-class regression, not a migration-in-progress signal. Fail 

2368 # loud at startup instead of silently serving 404 for the whole router. 

2369 _router_modules = [ 

2370 ("auth", ".routers.auth"), 

2371 ("research", ".routers.research"), 

2372 ("history", ".routers.history"), 

2373 ("settings", ".routers.settings"), 

2374 ("metrics", ".routers.metrics"), 

2375 ("api", ".routers.api"), 

2376 ("context_overflow", ".routers.context_overflow_api"), 

2377 ("news_api", ".routers.news_flask_api"), 

2378 ("news_pages", ".routers.news_pages"), 

2379 ("benchmark", ".routers.benchmark"), 

2380 ("followup", ".routers.followup"), 

2381 ("library", ".routers.library"), 

2382 ("rag", ".routers.rag"), 

2383 ("library_delete", ".routers.library_delete"), 

2384 ("library_search", ".routers.library_search"), 

2385 ("zotero", ".routers.zotero"), 

2386 ("notes", ".routers.notes"), 

2387 ("unified_search", ".routers.unified_search"), 

2388 ("scheduler", ".routers.scheduler"), 

2389 ("chat", ".routers.chat"), 

2390 ] 

2391 

2392 import importlib 

2393 

2394 for name, module_path in _router_modules: 

2395 mod = importlib.import_module(module_path, package=__package__) 

2396 if not hasattr(mod, "router"): 

2397 raise RuntimeError( 

2398 f"Router module '{module_path}' has no 'router' attribute" 

2399 ) 

2400 _routers[name] = mod.router 

2401 

2402 for name, r in _routers.items(): 

2403 app.include_router(r) 

2404 

2405 # include_router() preserves the source APIRoute class rather than using 

2406 # app.router.route_class for already-populated module routers. Apply the 

2407 # cleanup to the copied dispatch targets after every router is mounted; 

2408 # route.endpoint remains the original handler for introspection, SlowAPI, 

2409 # and route-identity security checks. 

2410 prepare_router_for_worker_cleanup(app.router) 

2411 

2412 @app.get("/", include_in_schema=False) 

2413 def index(request: Request): 

2414 """Root route - redirect to login if not authenticated.""" 

2415 username = request.session.get("username") 

2416 if not username: 

2417 return RedirectResponse(url="/auth/login", status_code=302) 

2418 

2419 from ..constants import get_available_strategies 

2420 from ..security.egress.policy import ( 

2421 DEFAULT_EGRESS_SCOPE, 

2422 effective_scope_for_display, 

2423 unprotected_egress_allowed, 

2424 ) 

2425 from ..settings.manager import check_env_setting 

2426 

2427 settings = {} 

2428 try: 

2429 with get_user_db_session(username) as db_session: 

2430 if db_session: 2430 ↛ 2517line 2430 didn't jump to line 2517

2431 sm = get_settings_manager(db_session, username) 

2432 # Operator-gated escape hatch (#5148 / 87537d9ec): the 

2433 # raw stored scope may be a legacy/invalid value or an 

2434 # "unprotected" selection the operator has since 

2435 # disabled — normalise what we DISPLAY through the same 

2436 # helper the policy engine uses so the UI never shows a 

2437 # scope the run-time layer would reject. allow_ 

2438 # unprotected_egress gates the <option value="unprotected"> 

2439 # entry in research.html's select; the *_env_locked flags 

2440 # let it grey out controls the operator pinned via env 

2441 # vars (server-side overrides are enforced independently 

2442 # in routers/research.py::_apply_policy_overrides). 

2443 raw_egress_scope = sm.get_setting( 

2444 "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

2445 ) 

2446 effective_egress_scope = effective_scope_for_display( 

2447 raw_egress_scope 

2448 ) 

2449 settings = { 

2450 "llm_provider": sm.get_setting( 

2451 "llm.provider", "ollama" 

2452 ), 

2453 "llm_model": sm.get_setting("llm.model", ""), 

2454 "llm_openai_endpoint_url": sm.get_setting( 

2455 "llm.openai_endpoint.url", "" 

2456 ), 

2457 "llm_ollama_url": sm.get_setting("llm.ollama.url"), 

2458 "llm_lmstudio_url": sm.get_setting("llm.lmstudio.url"), 

2459 "llm_local_context_window_size": sm.get_setting( 

2460 "llm.local_context_window_size" 

2461 ), 

2462 "search_tool": sm.get_setting("search.tool", ""), 

2463 "search_iterations": sm.get_setting( 

2464 "search.iterations", 3 

2465 ), 

2466 "search_questions_per_iteration": sm.get_setting( 

2467 "search.questions_per_iteration", 2 

2468 ), 

2469 "search_strategy": sm.get_setting( 

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

2471 ), 

2472 # Egress-policy controls (Stage 1c UI) — main loads 

2473 # these so the research page reflects the saved policy 

2474 # instead of silently defaulting (fail-open). 

2475 "policy_egress_scope": effective_egress_scope, 

2476 "egress_scope_env_locked": check_env_setting( 

2477 "policy.egress_scope" 

2478 ) 

2479 is not None, 

2480 "allow_unprotected_egress": unprotected_egress_allowed(), 

2481 "llm_local_env_locked": check_env_setting( 

2482 "llm.require_local_endpoint" 

2483 ) 

2484 is not None, 

2485 "embeddings_local_env_locked": check_env_setting( 

2486 "embeddings.require_local" 

2487 ) 

2488 is not None, 

2489 "llm_require_local_endpoint": sm.get_setting( 

2490 "llm.require_local_endpoint", False 

2491 ), 

2492 "embeddings_require_local": sm.get_setting( 

2493 "embeddings.require_local", False 

2494 ), 

2495 } 

2496 except Exception: 

2497 # Password not available (e.g. server restarted) — force re-login. 

2498 # 

2499 # Log before redirecting. This handler wraps ~70 lines (session 

2500 # acquisition, settings manager, egress scope, and 18 get_setting 

2501 # calls) on the app's ROOT route, so it catches far more than the 

2502 # documented password case — a bug in any of those reads presents 

2503 # to the user as "I keep getting randomly logged out" and left no 

2504 # trace at any log level. Flask had no try/except here at all 

2505 # (web/app_factory.py's index()), so the same fault produced a 

2506 # logged 500; swallowing it silently is a diagnosability 

2507 # regression, not a behaviour improvement. The redirect itself is 

2508 # correct for the case named above and is kept. 

2509 logger.exception( 

2510 "Root route failed to load settings for user {} — clearing " 

2511 "session and redirecting to login", 

2512 request.session.get("username"), 

2513 ) 

2514 request.session.clear() 

2515 return RedirectResponse(url="/auth/login", status_code=302) 

2516 

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

2518 

2519 return templates.TemplateResponse( 

2520 request=request, 

2521 name="pages/research.html", 

2522 context={ 

2523 "settings": settings, 

2524 "strategies": get_available_strategies(), 

2525 }, 

2526 ) 

2527 

2528 

2529_mount_all(app) 

2530 

2531 

2532# --------------------------------------------------------------------------- 

2533# Startup validation: every url_for() name used by a template must resolve 

2534# to a route actually mounted on the app. 

2535# --------------------------------------------------------------------------- 

2536 

2537 

2538def _validate_url_for_bindings(app: "FastAPI") -> None: 

2539 """Fail loudly at import/startup time if the Flask-compat ``url_for`` 

2540 shim would silently hand any template a dead link. 

2541 

2542 ``url_for``'s fallback (``f"/{name.replace('.', '/')}"``) produces a 

2543 plausible-looking but wrong URL for any name missing from ``_URL_MAP`` 

2544 — that already shipped one dead link (``chat.chat_page`` -> 

2545 ``/chat/chat_page``, see ``tests/web/templates/test_url_for_links.py``). 

2546 Rather than raise inside ``url_for()`` at render time (which would turn 

2547 one cosmetic bad link into a 500 for an otherwise-working page — a worse 

2548 failure mode for a self-hosted app), this scans every template for 

2549 ``url_for("name")`` usages, resolves each one exactly as the real shim 

2550 would, and checks the RESOLVED path against the live route table. This 

2551 must run AFTER ``_mount_all(app)`` -- ``app.routes`` is empty at the 

2552 point ``_setup_template_globals()`` runs (before routers are mounted), 

2553 so validating there would trivially "pass" with an unpopulated table. 

2554 

2555 Deliberately does NOT restrict itself to `_URL_MAP` keys: the 

2556 `chat.chat_page` bug was a MISSING entry falling through to the 

2557 dot-to-slash guess, which key-only validation would never have caught. 

2558 

2559 Matching strategy: a resolved path is valid if EITHER 

2560 (a) it exactly equals the ``path`` of some plain (parameter-free) 

2561 ``Route`` — with a trailing-slash-tolerant comparison, since pages 

2562 are linked both with and without one, or 

2563 (b) it falls under the prefix of something that is *designed* to 

2564 swallow an arbitrary nested sub-path: an ``app.mount(...)`` 

2565 (``Mount`` — e.g. the Socket.IO ASGI app at ``/ws``) or a ``Route`` 

2566 using Starlette's catch-all ``{name:path}`` converter (e.g. 

2567 ``serve_static``'s ``/static/{path:path}``, which is exactly how 

2568 ``url_for("static", filename=...)`` is validated without a special 

2569 case). 

2570 

2571 Every OTHER parameterized route (e.g. ``/chat/{session_id}``, a plain 

2572 string converter bound to one specific, known value) is intentionally 

2573 EXCLUDED from matching, even though Starlette's own ``Route.matches()`` 

2574 would happily match any string there. That was tried first and 

2575 rejected: ``/chat/{session_id}`` matches literally any string under 

2576 ``/chat/``, including ``/chat/totally-wrong-path`` — the exact shape of 

2577 wrong path a missing ``_URL_MAP`` entry produces — so a 

2578 ``Route.matches()``-based check would NOT have caught the 

2579 ``chat.chat_page`` regression this validator exists to prevent. 

2580 """ 

2581 from starlette.routing import Mount, Route 

2582 

2583 url_for = templates.env.globals["url_for"] 

2584 

2585 template_dir = Path(TEMPLATE_DIR) 

2586 names: set[str] = set() 

2587 for tpl_path in template_dir.rglob("*.html"): 

2588 names.update( 

2589 _URL_FOR_NAME_RE.findall( 

2590 tpl_path.read_text(encoding="utf-8", errors="replace") 

2591 ) 

2592 ) 

2593 

2594 if not names: 

2595 # Deliberately NOT fatal. An empty scan is indistinguishable from a 

2596 # packaging shape: TEMPLATE_DIR is computed from 

2597 # importlib_resources.as_file(), whose temp directory is removed when 

2598 # its context manager exits, so a zip-imported / pex / frozen install 

2599 # can legitimately have no readable templates directory here. 

2600 # Jinja2Templates itself tolerates that (FileSystemLoader never checks 

2601 # the directory exists), so the pre-existing behaviour is "HTML pages 

2602 # fail, the API keeps serving". Raising here would escalate that to 

2603 # "the process does not import at all", which is strictly worse and is 

2604 # exactly the trade this function's own docstring rejects. 

2605 logger.warning( 

2606 "url_for startup validation found no url_for() usages under " 

2607 f"{template_dir}; skipping link validation. If this is a normal " 

2608 "source install the templates directory has moved, and HTML " 

2609 "rendering is already broken." 

2610 ) 

2611 return 

2612 

2613 static_paths: set[str] = set() 

2614 wildcard_prefixes: set[str] = set() 

2615 _catchall_re = re.compile(r"\{[^{}:]+:path\}") 

2616 for route in app.routes: 

2617 path = getattr(route, "path", None) 

2618 if not path: 2618 ↛ 2619line 2618 didn't jump to line 2619 because the condition on line 2618 was never true

2619 continue 

2620 if isinstance(route, Mount): 

2621 wildcard_prefixes.add(path.rstrip("/")) 

2622 elif isinstance(route, Route): 2622 ↛ 2616line 2622 didn't jump to line 2616 because the condition on line 2622 was always true

2623 if "{" not in path: 

2624 static_paths.add(path) 

2625 static_paths.add(path.rstrip("/") or "/") 

2626 elif _catchall_re.search(path): 

2627 wildcard_prefixes.add(path.split("{", 1)[0].rstrip("/")) 

2628 # else: parameterized on a specific value (e.g. {session_id}) -- 

2629 # not a wildcard bucket url_for is allowed to land in. 

2630 

2631 def _under_wildcard_prefix(lookup: str) -> bool: 

2632 return any( 

2633 lookup == prefix or lookup.startswith(prefix + "/") 

2634 for prefix in wildcard_prefixes 

2635 ) 

2636 

2637 dead: list[str] = [] 

2638 for name in sorted(names): 

2639 path = ( 

2640 url_for(name, filename="x") if name == "static" else url_for(name) 

2641 ) 

2642 # Normalise BOTH sides. static_paths stores stripped and 

2643 # unstripped forms, but without stripping the lookup the 

2644 # tolerance was one-directional: nine _URL_MAP values end in "/" 

2645 # and pass only because each of those routers happens to declare 

2646 # @router.get("/"). Changing one to @router.get("") — invisible 

2647 # at runtime thanks to redirect_slashes — would have bricked boot. 

2648 lookup = path.split("?", 1)[0] 

2649 lookup = lookup.rstrip("/") or "/" 

2650 if lookup in static_paths or _under_wildcard_prefix(lookup): 

2651 continue 

2652 dead.append(f"{name!r} -> {path!r}") 

2653 

2654 if dead: 

2655 detail = ( 

2656 "the following template url_for() names resolve to paths that " 

2657 "are not mounted on the app. Add/fix an entry in _URL_MAP " 

2658 "(web/fastapi_app.py) for each:\n " + "\n ".join(dead) 

2659 ) 

2660 # Strict only when asked. A dead nav link is a cosmetic defect; making 

2661 # it un-bootable turns one broken link into total unavailability, 

2662 # which is a worse failure mode than the render-time raise this 

2663 # function already rejects for the same reason. CI and developers get 

2664 # the hard failure via LDR_STRICT_TEMPLATE_LINKS and via 

2665 # tests/web/templates/test_url_for_links.py, which asserts the same 

2666 # property offline; a running deployment gets a loud log and keeps 

2667 # serving. 

2668 if os.environ.get("LDR_STRICT_TEMPLATE_LINKS", "").lower() in ( 

2669 "1", 

2670 "true", 

2671 "yes", 

2672 ): 

2673 raise RuntimeError("url_for() startup validation failed: " + detail) 

2674 logger.error("url_for() startup validation failed: " + detail) 

2675 

2676 

2677_validate_url_for_bindings(app)