Coverage for src/local_deep_research/web/routers/library.py: 96%

652 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Routes for Research Library and Download Manager 

3 

4Provides web endpoints for: 

5- Library browsing and management 

6- Download manager interface 

7- API endpoints for downloads and queries 

8""" 

9 

10from fastapi import APIRouter, Depends, Request 

11from fastapi.responses import ( 

12 HTMLResponse, 

13 JSONResponse, 

14 Response, 

15) 

16from ..dependencies.auth import require_auth 

17from ..dependencies.threadpool import ( 

18 WorkerCleanupStreamingResponse, 

19 run_db_sync, 

20) 

21from ..utils.range_response import in_memory_range_response 

22from ..template_config import templates 

23from ..utils.download_headers import build_content_disposition 

24from ..utils.pagination import OFFSET_LIMIT_ERROR, parse_bounded_offset 

25 

26import os 

27import json 

28from datetime import UTC, datetime 

29from pathlib import Path 

30 

31from loguru import logger 

32 

33from ...database.session_context import get_user_db_session 

34from ...database.models.research import ResearchResource 

35from ...database.models.library import ( 

36 Document as Document, 

37 DocumentStatus, 

38 DownloadQueue as LibraryDownloadQueue, 

39 Collection, 

40) 

41from ...library.download_management import ResourceFilter 

42from ...security.url_validator import URLValidator 

43from ...research_library.services.download_service import DownloadService 

44from ...research_library.services.library_service import LibraryService 

45from ...research_library.services.pdf_storage_manager import ( 

46 PDFStorageManager, 

47 resolve_pdf_storage_mode, 

48) 

49from ...security import failure_reason_for_log, redact_url_for_log 

50from ...research_library.utils import ( 

51 apply_user_subdir, 

52 document_awaits_pdf_clause, 

53 document_counts_as_downloaded, 

54 download_claim_is_live, 

55 get_document_for_resource, 

56 resource_document_clause, 

57 handle_api_error, 

58 is_downloadable_domain, 

59 is_downloadable_url, 

60) 

61from ...utilities.db_utils import get_settings_manager 

62from ...config.paths import get_library_directory 

63from ...security.log_sanitizer import redact_and_bound_for_log 

64from ...settings.exceptions import SettingsValueError 

65from ...web.exceptions import AuthenticationRequiredError 

66from ..dependencies.json_body import read_json_dict 

67from .notes import _log_value_preview 

68from typing import Annotated 

69 

70# Create the router 

71router = APIRouter(prefix="/library", tags=["library"]) 

72 

73#: Upper bound for user-supplied id/url lists on the manual-selection 

74#: routes (check-downloads, mark-redownload). Far above any real bulk 

75#: selection and a safe margin under SQLite's 250,000 bind-parameter 

76#: ceiling, so an oversized list can never reach the .in_() filters. 

77_MAX_STRING_LIST_ITEMS = 1000 

78#: Higher bound for download-bulk only: the locked download-all 

79#: contract (test_library_api_response_shapes) requires lists above 

80#: one thousand to stream, so the functional floor sits above the 

81#: manual cap. download_bulk never binds these ids into a SQL query — 

82#: it loops per id, opening a session and queueing downloads for each 

83#: — so the real cost axis is per-item work, not a bind-parameter 

84#: ceiling. 100,000 bounds that per-item work to a size the "queue 

85#: everything undownloaded" flow (queue-all-undownloaded's 

86#: research_ids) can legitimately reach. 

87_MAX_BULK_LIST_ITEMS = 100_000 

88#: Upper bound per item; 2048 characters covers any URL or id the 

89#: UI ever sends while bounding per-request memory. 

90_MAX_STRING_LIST_ITEM_LENGTH = 2048 

91 

92 

93def _string_list_error( 

94 value, field_name: str, max_items: int = _MAX_STRING_LIST_ITEMS 

95) -> JSONResponse | None: 

96 """Return a 400 response unless *value* is a bounded non-empty-string list. 

97 

98 Caps both the item count and per-item length. Only 

99 ``check-downloads`` feeds the list into a SQLAlchemy ``.in_()`` 

100 filter, whose bind-parameter ceiling this helper's default 

101 ``max_items`` stays a safe margin under; ``download-bulk`` and 

102 ``mark-redownload`` loop over the list per item instead, so their 

103 exposure is unbounded per-item work rather than a bind-parameter 

104 crash. Routes that must preserve larger lists (download-all) pass 

105 a higher ``max_items`` sized to that per-item cost instead of the 

106 bind ceiling. 

107 

108 The count check runs before the per-item type/length sweeps so an 

109 oversized list 400s in O(1) instead of paying an O(n) scan first. 

110 """ 

111 if not isinstance(value, list): 

112 return JSONResponse( 

113 {"error": f"{field_name} must be a list of non-empty strings"}, 

114 status_code=400, 

115 ) 

116 if len(value) > max_items: 

117 return JSONResponse( 

118 {"error": (f"{field_name} must contain at most {max_items} items")}, 

119 status_code=400, 

120 ) 

121 if any(not isinstance(item, str) or not item.strip() for item in value): 

122 return JSONResponse( 

123 {"error": f"{field_name} must be a list of non-empty strings"}, 

124 status_code=400, 

125 ) 

126 if any(len(item) > _MAX_STRING_LIST_ITEM_LENGTH for item in value): 

127 return JSONResponse( 

128 { 

129 "error": ( 

130 f"{field_name} items must be at most" 

131 f" {_MAX_STRING_LIST_ITEM_LENGTH} characters" 

132 ) 

133 }, 

134 status_code=400, 

135 ) 

136 return None 

137 

138 

139# NOTE: Routes use username (not .get()) intentionally. 

140# Depends(require_auth) guarantees the key exists; direct access fails 

141# fast if the dependency is ever removed. 

142 

143 

144# Error handler for authentication errors 

145def handle_web_api_exception(error): 

146 """Handle WebAPIException and its subclasses.""" 

147 from ...web.exceptions import WebAPIException 

148 

149 if isinstance(error, WebAPIException): 

150 return error.to_dict(), error.status_code 

151 # Re-raise other exceptions 

152 raise error 

153 

154 

155def get_authenticated_user_password( 

156 username: str, session_id: str | None = None 

157) -> str: 

158 """ 

159 Get authenticated user password from session store. 

160 

161 Args: 

162 username: The username to get password for 

163 session_id: Optional session ID for direct lookup. 

164 

165 Returns: 

166 str: The user's password 

167 

168 Raises: 

169 AuthenticationRequiredError: If no password is available for the user 

170 """ 

171 from ...database.session_passwords import session_password_store 

172 

173 # Try specific session first 

174 if session_id: 

175 try: 

176 user_password = session_password_store.get_session_password( 

177 username, session_id 

178 ) 

179 if user_password: 

180 return user_password 

181 except Exception: 

182 logger.exception("Failed to get user password from session store") 

183 

184 # Try any session for this user (works without Flask) 

185 user_password = session_password_store.get_any_session_password(username) 

186 if user_password: 

187 return user_password 

188 

189 # No password available 

190 logger.error(f"No user password available for user {username}") 

191 raise AuthenticationRequiredError( 

192 message="Authentication required: Please refresh the page and log in again to access encrypted database features.", 

193 ) 

194 

195 

196# ============= Page Routes ============= 

197 

198 

199@router.get("/") 

200def library_page( 

201 request: Request, 

202 username: Annotated[str, Depends(require_auth)], 

203): 

204 """Main library page showing downloaded documents.""" 

205 import math 

206 

207 try: 

208 with get_user_db_session(username) as db_session: 

209 settings = get_settings_manager(db_session, username) 

210 pdf_storage_mode = resolve_pdf_storage_mode( 

211 settings.get_setting( 

212 "research_library.pdf_storage_mode", "database" 

213 ) 

214 ) 

215 shared_library = settings.get_setting( 

216 "research_library.shared_library", False 

217 ) 

218 except SettingsValueError: 

219 # An invalid configured value must not render the page as if the 

220 # defaults were configured. The app's handler shows the diagnostic. 

221 raise 

222 except Exception: 

223 pdf_storage_mode = "database" 

224 shared_library = False 

225 

226 enable_pdf_storage = pdf_storage_mode != "none" 

227 

228 # Filters 

229 domain_filter = request.query_params.get("domain") 

230 research_filter = request.query_params.get("research") 

231 collection_filter = request.query_params.get("collection") 

232 date_filter = request.query_params.get("date") 

233 

234 # Build context with graceful degradation 

235 stats = {} 

236 documents = [] 

237 unique_domains = [] 

238 research_list = [] 

239 collections = [] 

240 page = 1 

241 total_pages = 1 

242 # Degrading gracefully is deliberate here (main let the exception 

243 # reach Flask's 500 handler), but an empty `documents` list is 

244 # ambiguous: the template's empty state would tell a user whose 

245 # library failed to load that they have no documents yet, and invite 

246 # them to go download some. Track *why* the list is empty so the page 

247 # can say "we could not load this" instead of asserting something 

248 # false about their data. 

249 load_error = False 

250 

251 try: 

252 service = LibraryService(username) 

253 stats = service.get_library_stats() 

254 

255 from ...database.library_init import get_default_library_id 

256 

257 resolved_collection = collection_filter or get_default_library_id( 

258 username 

259 ) 

260 

261 # Pagination 

262 per_page = 100 

263 total_docs = service.count_documents( 

264 research_id=research_filter, 

265 domain=domain_filter, 

266 collection_id=resolved_collection, 

267 date_filter=date_filter, 

268 ) 

269 total_pages = max(1, math.ceil(total_docs / per_page)) 

270 try: 

271 page = int(request.query_params.get("page", "1")) 

272 except ValueError: 

273 page = 1 

274 page = max(1, min(page, total_pages)) 

275 offset = (page - 1) * per_page 

276 

277 documents = service.get_documents( 

278 research_id=research_filter, 

279 domain=domain_filter, 

280 collection_id=resolved_collection, 

281 date_filter=date_filter, 

282 limit=per_page, 

283 offset=offset, 

284 ) 

285 unique_domains = service.get_unique_domains() 

286 research_list = service.get_research_list_for_dropdown() 

287 collections = service.get_all_collections() 

288 except Exception: 

289 logger.exception("Error loading library data") 

290 load_error = True 

291 

292 default_collection_id = next( 

293 (c["id"] for c in collections if c.get("is_default")), None 

294 ) 

295 

296 return templates.TemplateResponse( 

297 request=request, 

298 name="pages/library.html", 

299 context={ 

300 "stats": stats, 

301 "documents": documents, 

302 "load_error": load_error, 

303 "unique_domains": unique_domains, 

304 "research_list": research_list, 

305 "collections": collections, 

306 "selected_collection": collection_filter, 

307 "default_collection_id": default_collection_id, 

308 "storage_path": stats.get("storage_path", ""), 

309 "enable_pdf_storage": enable_pdf_storage, 

310 "pdf_storage_mode": pdf_storage_mode, 

311 "shared_library": shared_library, 

312 "page": page, 

313 "total_pages": total_pages, 

314 "selected_date": date_filter, 

315 "selected_research": research_filter, 

316 "selected_domain": domain_filter, 

317 }, 

318 ) 

319 

320 

321@router.get("/document/{document_id}") 

322def document_details_page( 

323 request: Request, 

324 document_id, 

325 username: Annotated[str, Depends(require_auth)], 

326): 

327 """Document details page showing all metadata and links.""" 

328 service = LibraryService(username) 

329 

330 # Get document details 

331 document = service.get_document_by_id(document_id) 

332 

333 if not document: 

334 # Browser navigation: return text/html, not JSON. These four page 

335 # routes are reached from library.html as ordinary <a href> links, so a 

336 # stale link showed the user a raw {"error": ...} body in the browser's 

337 # JSON viewer. main returned `"Document not found", 404` (text/html) 

338 # here; the sibling /api/ routes keep JSON. 

339 return HTMLResponse("Document not found", status_code=404) 

340 

341 # HTML escaping does not make a stored javascript:/data: URL safe to 

342 # navigate. Keep rejected values visible as text, but never as links. 

343 original_url = document.get("original_url") 

344 original_source_url = ( 

345 original_url if URLValidator.is_safe_http_link(original_url) else None 

346 ) 

347 

348 return templates.TemplateResponse( 

349 request=request, 

350 name="pages/document_details.html", 

351 context={ 

352 "request": request, 

353 "document": document, 

354 "original_source_url": original_source_url, 

355 }, 

356 ) 

357 

358 

359@router.get("/download-manager") 

360def download_manager_page( 

361 request: Request, 

362 username: Annotated[str, Depends(require_auth)], 

363): 

364 """Download manager page for selecting and downloading research PDFs.""" 

365 import math 

366 

367 service = LibraryService(username) 

368 with get_user_db_session(username) as db_session: 

369 settings = get_settings_manager(db_session, username) 

370 pdf_storage_mode = resolve_pdf_storage_mode( 

371 settings.get_setting( 

372 "research_library.pdf_storage_mode", "database" 

373 ) 

374 ) 

375 shared_library = settings.get_setting( 

376 "research_library.shared_library", False 

377 ) 

378 enable_pdf_storage = pdf_storage_mode != "none" 

379 

380 # Summary stats over ALL sessions (also used for page count) 

381 per_page = 50 

382 summary = service.get_download_manager_summary_stats() 

383 total_pages = max(1, math.ceil(summary["total_researches"] / per_page)) 

384 

385 # Pagination with upper-bound clamp 

386 try: 

387 page = int(request.query_params.get("page", "1")) 

388 except ValueError: 

389 page = 1 

390 page = max(1, min(page, total_pages)) 

391 offset = (page - 1) * per_page 

392 

393 # Get paginated research sessions 

394 research_list = service.get_research_list_with_stats( 

395 limit=per_page, offset=offset 

396 ) 

397 

398 # Batch-fetch PDF previews and domain breakdowns (single query) 

399 research_ids = [r["id"] for r in research_list] 

400 previews = service.get_pdf_previews_batch(research_ids) 

401 for research in research_list: 

402 rid = research["id"] 

403 data = previews.get(rid, {"pdf_sources": [], "domains": {}}) 

404 research["pdf_sources"] = data["pdf_sources"] 

405 research["domains"] = data["domains"] 

406 

407 return templates.TemplateResponse( 

408 request=request, 

409 name="pages/download_manager.html", 

410 context={ 

411 "request": request, 

412 "research_list": research_list, 

413 "total_researches": summary["total_researches"], 

414 "total_resources": summary["total_resources"], 

415 "already_downloaded": summary["already_downloaded"], 

416 "available_to_download": summary["available_to_download"], 

417 "enable_pdf_storage": enable_pdf_storage, 

418 "pdf_storage_mode": pdf_storage_mode, 

419 "shared_library": shared_library, 

420 "page": page, 

421 "total_pages": total_pages, 

422 }, 

423 ) 

424 

425 

426# ============= API Routes ============= 

427 

428 

429@router.get("/api/stats") 

430def get_library_stats( 

431 request: Request, username: Annotated[str, Depends(require_auth)] 

432): 

433 """Get library statistics.""" 

434 service = LibraryService(username) 

435 return service.get_library_stats() 

436 

437 

438@router.get("/api/collections/list") 

439def get_collections_list( 

440 request: Request, username: Annotated[str, Depends(require_auth)] 

441): 

442 """Get list of all collections for dropdown selection.""" 

443 

444 with get_user_db_session(username) as db_session: 

445 collections = ( 

446 db_session.query(Collection).order_by(Collection.name).all() 

447 ) 

448 

449 return { 

450 "success": True, 

451 "collections": [ 

452 { 

453 "id": col.id, 

454 "name": col.name, 

455 "description": col.description, 

456 } 

457 for col in collections 

458 ], 

459 } 

460 

461 

462@router.get("/api/documents") 

463def get_documents( 

464 request: Request, username: Annotated[str, Depends(require_auth)] 

465): 

466 """Get documents with filtering.""" 

467 service = LibraryService(username) 

468 

469 # Get filter parameters 

470 research_id = request.query_params.get("research_id") 

471 domain = request.query_params.get("domain") 

472 file_type = request.query_params.get("file_type") 

473 favorites_only = request.query_params.get("favorites") == "true" 

474 search_query = request.query_params.get("search") 

475 # Clamp pagination. SQLite treats LIMIT -1 as "no limit", so an 

476 # unclamped negative limit (e.g. ?limit=-1) would bypass pagination and 

477 # load the whole collection — including large Document.text_content 

478 # bodies — into memory (#4560). Mirrors the clamp in history_routes. 

479 try: 

480 limit = max(1, min(int(request.query_params.get("limit", 100)), 1000)) 

481 except (TypeError, ValueError): 

482 limit = 100 

483 try: 

484 offset = parse_bounded_offset(request.query_params.get("offset")) 

485 except ValueError: 

486 return JSONResponse({"error": OFFSET_LIMIT_ERROR}, status_code=400) 

487 

488 documents = service.get_documents( 

489 research_id=research_id, 

490 domain=domain, 

491 file_type=file_type, 

492 favorites_only=favorites_only, 

493 search_query=search_query, 

494 limit=limit, 

495 offset=offset, 

496 ) 

497 

498 return {"documents": documents} 

499 

500 

501@router.post("/api/document/{document_id}/favorite") 

502def toggle_favorite( 

503 request: Request, 

504 document_id, 

505 username: Annotated[str, Depends(require_auth)], 

506): 

507 """Toggle favorite status of a document.""" 

508 service = LibraryService(username) 

509 is_favorite = service.toggle_favorite(document_id) 

510 return {"favorite": is_favorite} 

511 

512 

513@router.get("/api/document/{document_id}/pdf-url") 

514def get_pdf_url( 

515 request: Request, 

516 document_id, 

517 username: Annotated[str, Depends(require_auth)], 

518): 

519 """Get URL for viewing PDF.""" 

520 # Return URL that will serve the PDF 

521 return { 

522 "url": f"/library/api/document/{document_id}/pdf", 

523 "title": "Document", # Could fetch actual title 

524 } 

525 

526 

527def _serve_pdf( 

528 request: Request, document_id, username: str, *, html_errors: bool 

529): 

530 """Shared PDF-loading core for the page route and its /api/ sibling. 

531 

532 ``html_errors`` picks the not-found/not-available error shape: the 

533 browser-navigation page route wants text/html (a stale <a href> link 

534 should not show a raw JSON body), while the /api/ sibling keeps the 

535 JSON contract every other /api/ route has -- same rule its /text 

536 sibling (serve_text_api) already follows. 

537 """ 

538 

539 def _error(message: str) -> Response: 

540 if html_errors: 

541 return HTMLResponse(message, status_code=404) 

542 return JSONResponse({"error": message}, status_code=404) 

543 

544 with get_user_db_session(username) as db_session: 

545 # Get document from database 

546 document = db_session.query(Document).filter_by(id=document_id).first() 

547 

548 if not document: 

549 logger.warning( 

550 f"Document ID {_log_value_preview(document_id)} not found in database for user {username}" 

551 ) 

552 # Browser navigation: return text/html, not JSON. These four page 

553 # routes are reached from library.html as ordinary <a href> links, so a 

554 # stale link showed the user a raw {"error": ...} body in the browser's 

555 # JSON viewer. main returned `"Document not found", 404` (text/html) 

556 # here; the sibling /api/ routes keep JSON. 

557 return _error("Document not found") 

558 

559 logger.info( 

560 f"Document {document_id}: title='{document.title}', " 

561 f"file_path={document.file_path}" 

562 ) 

563 

564 # Get settings for PDF storage manager 

565 settings = get_settings_manager(db_session) 

566 storage_mode = settings.get_setting( 

567 "research_library.pdf_storage_mode", "none" 

568 ) 

569 # Expand configured environment variables before choosing the user's 

570 # library directory, matching the download/storage path resolution. 

571 base_root = ( 

572 Path( 

573 os.path.expandvars( 

574 settings.get_setting( 

575 "research_library.storage_path", 

576 str(get_library_directory()), 

577 ) 

578 ) 

579 ) 

580 .expanduser() 

581 .resolve() 

582 ) 

583 shared_library = settings.get_setting( 

584 "research_library.shared_library", False 

585 ) 

586 # Serve only from the requesting user's root. A matching relative 

587 # filename in the shared legacy directory does not prove ownership. 

588 library_root = apply_user_subdir(base_root, username, shared_library) 

589 

590 # Use PDFStorageManager to load PDF (handles database and filesystem) 

591 pdf_manager = PDFStorageManager(library_root, storage_mode) 

592 pdf_bytes = pdf_manager.load_pdf(document, db_session) 

593 

594 if pdf_bytes: 

595 logger.info( 

596 f"Serving PDF for document {document_id} ({len(pdf_bytes)} bytes)" 

597 ) 

598 filename = document.filename or "document.pdf" 

599 # Plain Response, not StreamingResponse: the bytes are already 

600 # fully in memory, and iterating a BytesIO yields *lines* — it 

601 # splits on every 0x0A, which in binary PDF data occurs roughly 

602 # every 256 bytes, so a single view became tens of thousands of 

603 # threadpool dispatches and sent no Content-Length. 

604 return in_memory_range_response( 

605 content=pdf_bytes, 

606 request=request, 

607 media_type="application/pdf", 

608 headers={ 

609 "Content-Disposition": build_content_disposition( 

610 filename, inline=True 

611 ) 

612 }, 

613 ) 

614 

615 # No PDF found anywhere 

616 logger.warning(f"No PDF available for document {document_id}") 

617 # Browser navigation, same rule as the not-found branch above: main 

618 # returned `"PDF not available", 404` (text/html) here. Reached 

619 # routinely, not just via stale links -- "Remove the PDF file to save 

620 # space" deletes the blob while leaving the row, so an open tab or a 

621 # bookmark lands here. The /api/ sibling keeps JSON. 

622 return _error("PDF not available") 

623 

624 

625@router.get("/document/{document_id}/pdf") 

626def view_pdf_page( 

627 request: Request, 

628 document_id, 

629 username: Annotated[str, Depends(require_auth)], 

630): 

631 """Page for viewing PDF file - uses PDFStorageManager for retrieval.""" 

632 return _serve_pdf(request, document_id, username, html_errors=True) 

633 

634 

635@router.get("/api/document/{document_id}/pdf") 

636def serve_pdf_api( 

637 request: Request, 

638 document_id, 

639 username: Annotated[str, Depends(require_auth)], 

640): 

641 """API endpoint for serving PDF file (kept for backward compatibility). 

642 

643 Unlike the page route above, a missing document or missing PDF blob 

644 here answers JSON, not text/html -- this is an /api/ route, and its 

645 /api/document/{id}/text sibling (serve_text_api) already keeps that 

646 contract on the same two error branches. 

647 """ 

648 return _serve_pdf(request, document_id, username, html_errors=False) 

649 

650 

651@router.get("/document/{document_id}/txt") 

652def view_text_page( 

653 request: Request, 

654 document_id, 

655 username: Annotated[str, Depends(require_auth)], 

656): 

657 """Page for viewing text content.""" 

658 

659 with get_user_db_session(username) as db_session: 

660 # Get document by ID (text now stored in Document.text_content) 

661 document = db_session.query(Document).filter_by(id=document_id).first() 

662 

663 if not document: 

664 logger.warning( 

665 f"Document not found for document ID {_log_value_preview(document_id)}" 

666 ) 

667 # Browser navigation: return text/html, not JSON. These four page 

668 # routes are reached from library.html as ordinary <a href> links, so a 

669 # stale link showed the user a raw {"error": ...} body in the browser's 

670 # JSON viewer. main returned `"Document not found", 404` (text/html) 

671 # here; the sibling /api/ routes keep JSON. 

672 return HTMLResponse("Document not found", status_code=404) 

673 

674 if not document.text_content: 

675 logger.warning(f"Document {document_id} has no text content") 

676 # Browser navigation, same rule as the not-found branch above: 

677 # main returned `"Text content not available", 404` (text/html) 

678 # here. serve_text_api below keeps main's jsonify() shape. 

679 return HTMLResponse("Text content not available", status_code=404) 

680 

681 logger.info( 

682 f"Serving text content for document {document_id}: {len(document.text_content)} characters" 

683 ) 

684 

685 # Render as HTML page 

686 return templates.TemplateResponse( 

687 request=request, 

688 name="pages/document_text.html", 

689 context={ 

690 "document_id": document_id, 

691 "title": document.title or "Document Text", 

692 "text_content": document.text_content, 

693 "extraction_method": document.extraction_method, 

694 "word_count": document.word_count, 

695 }, 

696 ) 

697 

698 

699@router.get("/api/document/{document_id}/text") 

700def serve_text_api( 

701 request: Request, 

702 document_id, 

703 username: Annotated[str, Depends(require_auth)], 

704): 

705 """API endpoint for serving text content (kept for backward compatibility).""" 

706 

707 with get_user_db_session(username) as db_session: 

708 # Get document by ID (text now stored in Document.text_content) 

709 document = db_session.query(Document).filter_by(id=document_id).first() 

710 

711 if not document: 

712 logger.warning( 

713 f"Document not found for document ID {_log_value_preview(document_id)}" 

714 ) 

715 return JSONResponse( 

716 {"error": "Document not found"}, status_code=404 

717 ) 

718 

719 if not document.text_content: 

720 logger.warning(f"Document {document_id} has no text content") 

721 return JSONResponse( 

722 {"error": "Text content not available"}, status_code=404 

723 ) 

724 

725 logger.info( 

726 f"Serving text content for document {document_id}: {len(document.text_content)} characters" 

727 ) 

728 

729 return { 

730 "text_content": document.text_content, 

731 "title": document.title or "Document", 

732 "extraction_method": document.extraction_method, 

733 "word_count": document.word_count, 

734 } 

735 

736 

737@router.post("/api/open-folder") 

738def open_folder( 

739 request: Request, username: Annotated[str, Depends(require_auth)] 

740): 

741 """Open folder containing a document. 

742 

743 Security: This endpoint is disabled for server deployments. 

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

745 """ 

746 return JSONResponse( 

747 { 

748 "status": "error", 

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

750 }, 

751 status_code=403, 

752 ) 

753 

754 

755@router.post("/api/download/{resource_id}") 

756def download_single_resource( 

757 request: Request, 

758 # Typed int, matching Flask's <int:resource_id> converter: without it a 

759 # non-numeric segment is passed straight through to the service layer 

760 # instead of being rejected as a 422 by the router. 

761 resource_id: int, 

762 username: Annotated[str, Depends(require_auth)], 

763): 

764 """Download a single resource.""" 

765 user_password = get_authenticated_user_password(username) 

766 

767 with DownloadService(username, user_password) as service: 

768 success, error = service.download_resource(resource_id) 

769 if success: 

770 return {"success": True} 

771 if error == "Resource not found": 

772 # download_resource() returns this exact string (never raises) 

773 # when resource_id doesn't exist — that's a 404, not a generic 

774 # download failure. Without this branch every nonexistent 

775 # resource_id collapsed to the 500 below. 

776 return JSONResponse( 

777 {"success": False, "error": "Resource not found"}, 

778 status_code=404, 

779 ) 

780 # The reason may be exception text that keeps the URL's path and 

781 # query; download_service logs the cause itself where it is caught. 

782 logger.warning( 

783 f"Download failed for resource {resource_id}: " 

784 f"{failure_reason_for_log(error)}" 

785 ) 

786 return JSONResponse( 

787 { 

788 "success": False, 

789 "error": "Download failed. Please try again or contact support.", 

790 }, 

791 status_code=500, 

792 ) 

793 

794 

795@router.post("/api/download-text/{resource_id}") 

796def download_text_single( 

797 request: Request, 

798 # Typed int, matching Flask's <int:resource_id> converter: without it a 

799 # non-numeric segment is passed straight through to the service layer 

800 # instead of being rejected as a 422 by the router. 

801 resource_id: int, 

802 username: Annotated[str, Depends(require_auth)], 

803): 

804 """Download a single resource as text file.""" 

805 try: 

806 user_password = get_authenticated_user_password(username) 

807 

808 with DownloadService(username, user_password) as service: 

809 success, error = service.download_as_text(resource_id) 

810 

811 # Sanitize error message - don't expose internal details 

812 if not success: 

813 if error: 

814 logger.warning( 

815 f"Download as text failed for resource {resource_id}: " 

816 f"{failure_reason_for_log(error)}" 

817 ) 

818 return { 

819 "success": False, 

820 "error": "Failed to download resource", 

821 } 

822 

823 return {"success": True, "error": None} 

824 except AuthenticationRequiredError: 

825 raise # Let the global WebAPIException handler return 401 

826 except Exception as e: 

827 return handle_api_error( 

828 f"downloading resource {resource_id} as text", e 

829 ) 

830 

831 

832@router.post("/api/download-all-text") 

833def download_all_text( 

834 request: Request, username: Annotated[str, Depends(require_auth)] 

835): 

836 """Download all undownloaded resources as text files.""" 

837 # Capture Flask session ID to avoid scoping issues in nested function 

838 session_id = request.session.get("session_id") 

839 

840 def generate(): 

841 # Get user password for database operations 

842 try: 

843 user_password = get_authenticated_user_password( 

844 username, session_id 

845 ) 

846 except AuthenticationRequiredError: 

847 logger.warning( 

848 f"Authentication unavailable for user {username} - password not in session store" 

849 ) 

850 yield f"data: {json.dumps({'progress': 0, 'current': 0, 'total': 0, 'error': 'Authentication required', 'complete': True})}\n\n" 

851 return 

852 

853 # This DownloadService (and the SettingsManager session inside 

854 # download_service.settings) is built ONCE, before the loop, and 

855 # lives across every ``yield`` below. That crosses a cleanup 

856 # boundary the wrapper's per-chunk cleanup does NOT know about: 

857 # ``WorkerCleanupStreamingResponse`` wraps this generator in 

858 # ``iterate_sync_with_cleanup``, whose ``thread_cleanup()`` runs 

859 # after EVERY ``next()`` (i.e. after every chunk, not just at the 

860 # end) and closes every session cached or tracked on this thread 

861 # (``utilities/db_utils.py``). The first chunk that touches 

862 # ``download_service.settings`` therefore gets its underlying 

863 # SQLAlchemy session closed by that per-chunk cleanup the moment the 

864 # chunk's ``yield`` returns -- but the ``SettingsManager``/``Session`` 

865 # OBJECTS themselves are untouched, only their pooled connection is 

866 # released, so the next chunk's use of ``download_service.settings`` 

867 # transparently reopens a fresh connection (ordinary SQLAlchemy 

868 # session behavior after ``close()``). That connection then sits in 

869 # neither the LRU cache nor the owner-thread registry until stream 

870 # end, when ``safe_close(download_service, ...)`` below closes it 

871 # via ``SettingsManager.close()`` -- exactly where it was released 

872 # pre-#6095, so this is NOT a leak, just a "cleanup at every 

873 # boundary" invariant violated for the longest-lived object in this 

874 # stream. Documented rather than restructured: giving this loop its 

875 # own re-created-per-chunk settings manager would fix the invariant 

876 # but changes this route's session lifetime shape for no behavioral 

877 # gain, and isn't warranted without a demonstrated leak. 

878 download_service = DownloadService(username, user_password) 

879 try: 

880 # Fetch the resource rows in a SHORT-LIVED session, snapshotting 

881 # only the columns the loop uses into plain tuples. The download 

882 # loop below holds NO session while it yields — the held session is 

883 # only needed for this query, and keeping a get_user_db_session 

884 # scope open across a yield risks the Starlette/anyio thread- 

885 # affinity corruption fixed in download_bulk (its __enter__ and 

886 # __exit__ can land on different pooled threads). Project only 

887 # id/url/title so the content_preview Text column doesn't 

888 # materialize on this whole-table scan (#4560). 

889 with get_user_db_session(username) as session: 

890 all_resources = [ 

891 (r.id, r.url, r.title) 

892 for r in session.query( 

893 ResearchResource.id, 

894 ResearchResource.url, 

895 ResearchResource.title, 

896 ).all() 

897 ] 

898 

899 # Filter to only downloadable resources (academic/PDF) 

900 resources = [ 

901 (rid, url, title) 

902 for (rid, url, title) in all_resources 

903 if is_downloadable_url(url) 

904 ] 

905 

906 # Pre-scan directory once to get all existing resource IDs 

907 txt_path = Path(download_service.library_root) / "txt" 

908 existing_resource_ids = set() 

909 if txt_path.exists(): 

910 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Only enumerate fixed txt names under this authenticated user's resolved library directory 

911 for txt_file in txt_path.glob("*.txt"): 

912 # Extract resource ID from filename pattern *_{id}.txt 

913 parts = txt_file.stem.rsplit("_", 1) 

914 if len(parts) == 2: 914 ↛ 911line 914 didn't jump to line 911 because the condition on line 914 was always true

915 try: 

916 existing_resource_ids.add(int(parts[1])) 

917 except ValueError: 

918 pass 

919 

920 # Filter resources that need text extraction 

921 resources_to_process = [ 

922 (rid, url, title) 

923 for (rid, url, title) in resources 

924 if rid not in existing_resource_ids 

925 ] 

926 

927 total = len(resources_to_process) 

928 current = 0 

929 

930 logger.info(f"Found {total} resources needing text extraction") 

931 

932 for rid, url, title in resources_to_process: 

933 current += 1 

934 progress = int((current / total) * 100) if total > 0 else 100 

935 

936 file_name = title[:50] if title else f"document_{current}.txt" 

937 

938 try: 

939 # download_as_text opens its own session internally. 

940 # Its every error-return path (see download_service.py 

941 # download_as_text/_try_api_text_extraction/ 

942 # _fallback_pdf_extraction) is either a static string or 

943 # a fixed token from _client_safe_download_message() 

944 # (never str(e) — sanitize_error_for_client() scrubs 

945 # credentials only and leaves SQL text intact) — no raw 

946 # exception text reaches `error` here. 

947 success, error = download_service.download_as_text(rid) 

948 

949 if success: 

950 status = "success" 

951 error_msg = None 

952 else: 

953 status = "failed" 

954 error_msg = error or "Text extraction failed" 

955 

956 except Exception as e: 

957 # Type name only, no traceback: an exception escaping 

958 # the fetch can embed the raw resource URL in its text. 

959 logger.warning( 

960 f"Error extracting text for resource {rid} " 

961 f"({type(e).__name__})" 

962 ) 

963 status = "failed" 

964 # CWE-209: only the exception CLASS NAME is surfaced, 

965 # never str(e) — same "class name only" mitigation as 

966 # journal_quality/downloader.py (CodeQL alerts 7650/7684). 

967 error_msg = f"Text extraction failed - {type(e).__name__}" 

968 

969 # Send update 

970 update = { 

971 "progress": progress, 

972 "current": current, 

973 "total": total, 

974 "file": file_name, 

975 "url": url, # Add the URL for UI display 

976 "status": status, 

977 "error": error_msg, 

978 } 

979 yield f"data: {json.dumps(update)}\n\n" 

980 

981 # Send completion 

982 yield f"data: {json.dumps({'complete': True, 'total': total})}\n\n" 

983 finally: 

984 from ...utilities.resource_utils import safe_close 

985 

986 safe_close(download_service, "download service") 

987 

988 # CWE-209 (CodeQL "Information exposure through an exception"): the SSE 

989 # body streamed here never carries a raw exception message — see the 

990 # `except Exception as e:` block in generate() above, which only ever 

991 # surfaces `type(e).__name__` or a fixed token from 

992 # download_service._client_safe_download_message (sanitize_error_for_client 

993 # alone would not suffice — it scrubs credentials only, not SQL/paths). 

994 return WorkerCleanupStreamingResponse( 

995 generate(), 

996 media_type="text/event-stream", 

997 headers={ 

998 "Cache-Control": "no-cache, no-transform", 

999 "X-Accel-Buffering": "no", 

1000 }, 

1001 ) 

1002 

1003 

1004@router.post("/api/download-research/{research_id}") 

1005async def download_research_pdfs( 

1006 request: Request, 

1007 research_id, 

1008 username: Annotated[str, Depends(require_auth)], 

1009): 

1010 """Queue all PDFs from a research session for download.""" 

1011 user_password = get_authenticated_user_password(username) 

1012 data, body_error = await read_json_dict( 

1013 request, "simple", "Request body must be valid JSON" 

1014 ) 

1015 if body_error is not None: 

1016 return body_error 

1017 collection_id = data.get("collection_id") 

1018 

1019 # `queue_research_downloads` does sync SQLAlchemy work. Keep it off 

1020 # the uvicorn event loop. 

1021 def _queue_sync(): 

1022 with DownloadService(username, user_password) as service: 

1023 return service.queue_research_downloads(research_id, collection_id) 

1024 

1025 queued = await run_db_sync(_queue_sync) 

1026 return {"success": True, "queued": queued} 

1027 

1028 

1029def _claim_download_queue_item(db_session, queue_item_id): 

1030 """Atomically claim a PENDING download-queue row before processing it. 

1031 

1032 Flip the row from PENDING to PROCESSING in a single conditional UPDATE and 

1033 return True only when this caller won the claim. Two concurrent 

1034 ``download_bulk`` streams for the same user (the user clicks Download 

1035 twice, or a second bulk run starts before the first finishes) both read 

1036 the same PENDING rows and, with no claim, both call ``download_resource`` 

1037 for every row (issue #4691). The row-count guard makes the 

1038 PENDING -> PROCESSING transition the single point where exactly one stream 

1039 wins each row; the loser sees 0 updated rows and skips it. 

1040 

1041 The claim also records its time in ``last_attempt_at``. Nothing reclaims 

1042 a row left PROCESSING, e.g. by a restart during the download, and the 

1043 queueing of a document that awaits its PDF (see ``document_awaits_pdf``) 

1044 lets another resource refill it only while no download holds one of its 

1045 resources. ``download_claim_is_live`` tells a held row from one a restart 

1046 left behind by this time. 

1047 """ 

1048 claimed = ( 

1049 db_session.query(LibraryDownloadQueue) 

1050 .filter_by(id=queue_item_id, status=DocumentStatus.PENDING) 

1051 .update( 

1052 { 

1053 LibraryDownloadQueue.status: DocumentStatus.PROCESSING, 

1054 LibraryDownloadQueue.last_attempt_at: datetime.now(UTC), 

1055 }, 

1056 synchronize_session=False, 

1057 ) 

1058 ) 

1059 db_session.commit() 

1060 return claimed == 1 

1061 

1062 

1063def _release_download_queue_item(db_session, queue_item_id): 

1064 """Return a claimed row from PROCESSING back to PENDING so it stays 

1065 retryable when the download raised before a terminal COMPLETED/FAILED 

1066 status was recorded (issue #4691), matching the pre-fix behaviour where an 

1067 errored row was left PENDING. Scoped to PROCESSING so it only ever undoes 

1068 this stream's own claim. 

1069 """ 

1070 db_session.query(LibraryDownloadQueue).filter_by( 

1071 id=queue_item_id, status=DocumentStatus.PROCESSING 

1072 ).update( 

1073 {LibraryDownloadQueue.status: DocumentStatus.PENDING}, 

1074 synchronize_session=False, 

1075 ) 

1076 db_session.commit() 

1077 

1078 

1079def _finalize_download_queue_item(db_session, queue_item_id, success): 

1080 """Record the outcome of a claimed row that returned without raising. 

1081 

1082 ``download_resource`` writes a terminal COMPLETED/FAILED status itself for 

1083 the rows it actually downloads, and this is a no-op for those: the row is 

1084 no longer PROCESSING, so the guard matches nothing and pdf-mode behaviour 

1085 is unchanged. It fires only on the paths that otherwise strand a claim 

1086 (issue #4691): 

1087 

1088 - ``mode="text_only"``: ``download_as_text`` and its call tree never touch 

1089 ``LibraryDownloadQueue``, so a successful extraction would leave the row 

1090 PROCESSING forever. Nothing in ``src/`` reaps PROCESSING rows, and a 

1091 successful extraction also writes a COMPLETED Document, which blocks both 

1092 the pre-pass reset and ``queue_all_undownloaded``'s outer-join filter. 

1093 - ``download_resource``'s already-downloaded early return, which reports 

1094 success before reaching its own queue update. 

1095 

1096 Pre-fix, both paths left the row PENDING and merely re-scanned it, so 

1097 failure returns to PENDING rather than FAILED to keep that retryability. 

1098 """ 

1099 db_session.query(LibraryDownloadQueue).filter_by( 

1100 id=queue_item_id, status=DocumentStatus.PROCESSING 

1101 ).update( 

1102 { 

1103 LibraryDownloadQueue.status: ( 

1104 DocumentStatus.COMPLETED if success else DocumentStatus.PENDING 

1105 ), 

1106 LibraryDownloadQueue.completed_at: ( 

1107 datetime.now(UTC) if success else None 

1108 ), 

1109 }, 

1110 synchronize_session=False, 

1111 ) 

1112 db_session.commit() 

1113 

1114 

1115@router.post("/api/download-bulk") 

1116async def download_bulk( 

1117 request: Request, username: Annotated[str, Depends(require_auth)] 

1118): 

1119 """Download PDFs or extract text from multiple research sessions.""" 

1120 data, body_error = await read_json_dict( 

1121 request, "simple", "Request body must be valid JSON" 

1122 ) 

1123 if body_error is not None: 

1124 return body_error 

1125 research_ids = data.get("research_ids", []) 

1126 mode = data.get("mode", "pdf") # pdf or text_only 

1127 collection_id = data.get( 

1128 "collection_id" 

1129 ) # Optional: target collection for downloads 

1130 

1131 if not research_ids: 

1132 return JSONResponse( 

1133 {"error": "No research IDs provided"}, status_code=400 

1134 ) 

1135 # Download-all selections legitimately exceed the manual-selection 

1136 # cap (locked contract: lists above one thousand must stream), so 

1137 # this route uses the higher bulk ceiling — bounded to a size the 

1138 # "queue everything undownloaded" flow can legitimately reach, not 

1139 # to a SQL bind-parameter limit (this route loops per id rather 

1140 # than binding the list into a query). 

1141 if error := _string_list_error( 

1142 research_ids, "research_ids", max_items=_MAX_BULK_LIST_ITEMS 

1143 ): 

1144 return error 

1145 if not isinstance(mode, str) or mode not in {"pdf", "text_only"}: 

1146 return JSONResponse( 

1147 {"error": "mode must be either 'pdf' or 'text_only'"}, 

1148 status_code=400, 

1149 ) 

1150 

1151 # Capture Flask session ID to avoid scoping issues in nested function 

1152 session_id = request.session.get("session_id") 

1153 

1154 def generate(): 

1155 """Generate progress updates as Server-Sent Events.""" 

1156 # Get user password for database operations 

1157 try: 

1158 user_password = get_authenticated_user_password( 

1159 username, session_id 

1160 ) 

1161 except AuthenticationRequiredError: 

1162 logger.warning( 

1163 f"Authentication unavailable for user {username} - password not in session store" 

1164 ) 

1165 yield f"data: {json.dumps({'progress': 0, 'current': 0, 'total': 0, 'error': 'Authentication required', 'complete': True})}\n\n" 

1166 return 

1167 

1168 # Same per-chunk-cleanup caveat as download_all_text() above: 

1169 # download_service.settings' session gets closed (connection 

1170 # released, object untouched) by iterate_sync_with_cleanup's 

1171 # per-chunk thread_cleanup() after the first chunk that uses it, and 

1172 # transparently reopens a fresh connection on later chunks. Not a 

1173 # leak -- safe_close(download_service, ...) below releases it at 

1174 # stream end exactly as pre-#6095 -- just documented here rather 

1175 # than restructured; see the fuller note there. 

1176 download_service = DownloadService(username, user_password) 

1177 try: 

1178 total = 0 

1179 current = 0 

1180 queue_failures = 0 

1181 

1182 # Pre-queue and count in one merged loop. queue_research_downloads 

1183 # opens its own session, commits, and handles dedup internally: 

1184 # it skips resources that are already PENDING or already backed by 

1185 # a COMPLETED Document, and re-queues any remaining row (e.g. a 

1186 # prior FAILED attempt) back to PENDING. Calling it BEFORE counting 

1187 # ensures the total reflects what will actually be processed 

1188 # (issue #4660). In PDF mode it also queues a COMPLETED Document 

1189 # that still needs its PDF (see document_awaits_pdf), through 

1190 # one resource per such document across all the research 

1191 # sessions of this run (run_research_ids); a text extraction 

1192 # already has that document's text, so text_only mode skips it. 

1193 # The set is built once for the run: each call only looks it up. 

1194 run_research_ids = frozenset(research_ids) 

1195 for research_id in research_ids: 

1196 try: 

1197 download_service.queue_research_downloads( 

1198 research_id, 

1199 collection_id, 

1200 text_only=mode == "text_only", 

1201 run_research_ids=run_research_ids, 

1202 ) 

1203 except Exception: 

1204 queue_failures += 1 

1205 logger.exception( 

1206 "Error queueing downloads for " 

1207 "user={} research={} collection={}", 

1208 username, 

1209 research_id, 

1210 _log_value_preview(collection_id), 

1211 ) 

1212 

1213 with get_user_db_session(username) as session: 

1214 count = ( 

1215 session.query(LibraryDownloadQueue) 

1216 .filter_by( 

1217 research_id=research_id, 

1218 status=DocumentStatus.PENDING, 

1219 ) 

1220 .count() 

1221 ) 

1222 total += count 

1223 logger.debug( 

1224 f"Research {research_id}: {count} pending items in queue" 

1225 ) 

1226 

1227 logger.info(f"Total pending downloads across all research: {total}") 

1228 yield f"data: {json.dumps({'progress': 0, 'current': 0, 'total': total})}\n\n" 

1229 

1230 # If the pre-pass yielded nothing to process, surface it as an 

1231 # error so the UI alerts instead of silently completing with 

1232 # "0 / 0 files" success. Distinguish queue failure from a 

1233 # legitimate "nothing left to download" state. 

1234 if total == 0: 

1235 if queue_failures > 0: 

1236 error_msg = ( 

1237 f"Download failed to start: queueing failed for " 

1238 f"{queue_failures} of {len(research_ids)} " 

1239 f"research session(s). Check server logs." 

1240 ) 

1241 else: 

1242 error_msg = ( 

1243 "No new papers to download for the selected " 

1244 "research session(s)." 

1245 ) 

1246 yield f"data: {json.dumps({'progress': 100, 'current': 0, 'total': 0, 'complete': True, 'error': error_msg})}\n\n" 

1247 return 

1248 

1249 # Process each research 

1250 for research_id in research_ids: 

1251 # Get queued downloads for this research in a SHORT-LIVED 

1252 # session, snapshotting only the ids we need. The per-item work 

1253 # below opens its own short-lived sessions so that NO 

1254 # get_user_db_session scope is ever held across a ``yield``: 

1255 # generate() is a sync generator that Starlette drives via 

1256 # iterate_in_threadpool (one anyio dispatch per next()), so a 

1257 # session whose __enter__/__exit__ straddled a yield could run 

1258 # on two different pooled OS threads and leave the thread-local 

1259 # scope_depth counter permanently corrupted. 

1260 with get_user_db_session(username) as session: 

1261 queue_items = [ 

1262 (qi.id, qi.resource_id) 

1263 for qi in ( 

1264 session.query(LibraryDownloadQueue) 

1265 .filter_by( 

1266 research_id=research_id, 

1267 status=DocumentStatus.PENDING, 

1268 ) 

1269 .all() 

1270 ) 

1271 ] 

1272 

1273 # Process each queued item 

1274 for queue_item_id, resource_id in queue_items: 

1275 # Atomically claim this row before downloading so a 

1276 # second concurrent download_bulk stream can't also 

1277 # process it (issue #4691). A row already claimed by 

1278 # another stream returns 0 updated rows; skip it. 

1279 with get_user_db_session(username) as session: 

1280 claimed = _claim_download_queue_item( 

1281 session, queue_item_id 

1282 ) 

1283 if not claimed: 1283 ↛ 1284line 1283 didn't jump to line 1284 because the condition on line 1283 was never true

1284 continue 

1285 

1286 current += 1 

1287 

1288 progress = ( 

1289 int((current / total) * 100) if total > 0 else 100 

1290 ) 

1291 

1292 # Attempt actual download with error handling 

1293 skip_reason = None 

1294 status = "skipped" # Default to skipped 

1295 success = False 

1296 error_msg = None 

1297 error_type = None 

1298 # Everything from the resource lookup onward runs 

1299 # inside the try below, so file_name needs a value 

1300 # even if that block raises before setting one. 

1301 file_name = f"document_{current}.pdf" 

1302 

1303 try: 

1304 # Get resource info in its own short-lived session. 

1305 # This sits INSIDE the try because the claim above is 

1306 # already committed as PROCESSING: any raise between 

1307 # the claim and this handler would leave the row that 

1308 # way forever, since nothing in src/ reaps PROCESSING 

1309 # rows and every reset path now refuses to touch them 

1310 # (issue #4691). ResearchResource.title is nullable, so 

1311 # resource.title[:50] raises TypeError on a resource 

1312 # whose title was never populated. 

1313 with get_user_db_session(username) as session: 

1314 resource = session.get( 

1315 ResearchResource, resource_id 

1316 ) 

1317 if resource and resource.title: 

1318 file_name = resource.title[:50] 

1319 

1320 logger.debug( 

1321 f"Attempting {'PDF download' if mode == 'pdf' else 'text extraction'} for resource {resource_id}" 

1322 ) 

1323 

1324 # Call appropriate service method based on mode. 

1325 # DownloadService opens its own session internally; no 

1326 # session is held here across the blocking download. 

1327 if mode == "pdf": 

1328 result = download_service.download_resource( 

1329 resource_id 

1330 ) 

1331 else: # text_only 

1332 result = download_service.download_as_text( 

1333 resource_id 

1334 ) 

1335 

1336 # Handle new tuple return format 

1337 if isinstance(result, tuple): 1337 ↛ 1340line 1337 didn't jump to line 1340 because the condition on line 1337 was always true

1338 success, skip_reason = result 

1339 else: 

1340 success = result 

1341 skip_reason = None 

1342 

1343 # Finalize the claim for the paths that don't 

1344 # record a terminal status themselves (text_only, 

1345 # and download_resource's already-downloaded early 

1346 # return); a no-op when they already did (#4691). 

1347 with get_user_db_session(username) as session: 

1348 _finalize_download_queue_item( 

1349 session, queue_item_id, success 

1350 ) 

1351 

1352 status = "success" if success else "skipped" 

1353 if skip_reason and not success: 

1354 error_msg = skip_reason 

1355 logger.info( 

1356 f"{'Download' if mode == 'pdf' else 'Text extraction'} skipped for resource {resource_id}: {failure_reason_for_log(skip_reason)}" 

1357 ) 

1358 

1359 logger.debug( 

1360 f"{'Download' if mode == 'pdf' else 'Text extraction'} result: success={success}, status={status}, skip_reason={failure_reason_for_log(skip_reason)}" 

1361 ) 

1362 except Exception as e: 

1363 # Release the claim so a later run can retry this row: 

1364 # the download raised before download_resource recorded 

1365 # a terminal status (issue #4691). A fresh short-lived 

1366 # session — the download's own session is unrelated and 

1367 # each op above already closed its scope, so there is no 

1368 # held session to roll back. 

1369 try: 

1370 with get_user_db_session(username) as session: 

1371 _release_download_queue_item( 

1372 session, queue_item_id 

1373 ) 

1374 except Exception: 

1375 logger.exception( 

1376 "Failed to release download-queue claim" 

1377 ) 

1378 # Log error but continue processing. `error_msg` 

1379 # (str(e)) is used ONLY for the keyword classification 

1380 # below (`.lower()` checks) — it is never logged 

1381 # (an exception escaping download_resource / 

1382 # download_as_text can embed the raw resource URL: 

1383 # urllib3 "with url: ...", SQLAlchemy 

1384 # "[parameters: ...]") and never written into 

1385 # `skip_reason`, which the yield below sends to the 

1386 # client. Only `error_type` (the exception CLASS NAME) 

1387 # reaches the log and skip_reason, same "class name 

1388 # only" mitigation as journal_quality/downloader.py 

1389 # (CodeQL alerts 7650/7684) — CWE-209. 

1390 error_msg = str(e) 

1391 error_type = type(e).__name__ 

1392 logger.warning( 

1393 f"CAUGHT Download exception for resource {resource_id}: {error_type}" 

1394 ) 

1395 # Check if this is a skip reason (not a real error) 

1396 # Use error category + categorized message for user display 

1397 if any( 

1398 phrase in error_msg.lower() 

1399 for phrase in [ 

1400 "paywall", 

1401 "subscription", 

1402 "not available", 

1403 "not found", 

1404 "no free", 

1405 "embargoed", 

1406 "forbidden", 

1407 "not accessible", 

1408 ] 

1409 ): 

1410 status = "skipped" 

1411 skip_reason = f"Document not accessible (paywall or access restriction) - {error_type}" 

1412 elif any( 

1413 phrase in error_msg.lower() 

1414 for phrase in [ 

1415 "failed to download", 

1416 "could not", 

1417 "invalid", 

1418 "server", 

1419 ] 

1420 ): 

1421 status = "failed" 

1422 skip_reason = f"Download failed - {error_type}" 

1423 else: 

1424 status = "failed" 

1425 skip_reason = f"Processing failed - {error_type}" 

1426 success = False 

1427 

1428 # Ensure skip_reason is set if we have an error message 

1429 if error_msg and not skip_reason: 1429 ↛ 1430line 1429 didn't jump to line 1430 because the condition on line 1429 was never true

1430 skip_reason = f"Processing failed - {error_type}" 

1431 logger.debug( 

1432 f"Setting skip_reason from error type: {error_type}" 

1433 ) 

1434 

1435 # Send progress update 

1436 update_data = { 

1437 "progress": progress, 

1438 "current": current, 

1439 "total": total, 

1440 "file": file_name, 

1441 "status": status, 

1442 } 

1443 # Add skip reason if available 

1444 if skip_reason: 

1445 update_data["error"] = skip_reason 

1446 logger.info( 

1447 "Sending skip reason to UI: " 

1448 f"{failure_reason_for_log(skip_reason)}" 

1449 ) 

1450 

1451 yield f"data: {json.dumps(update_data)}\n\n" 

1452 

1453 yield f"data: {json.dumps({'progress': 100, 'current': current, 'total': total, 'complete': True})}\n\n" 

1454 finally: 

1455 from ...utilities.resource_utils import safe_close 

1456 

1457 safe_close(download_service, "download service") 

1458 

1459 # CWE-209 (CodeQL "Information exposure through an exception"): the SSE 

1460 # body streamed here never carries a raw exception message. `skip_reason` 

1461 # either comes straight from download_service (already a static string or 

1462 # a fixed token from _client_safe_download_message() — see 

1463 # download_resource / download_as_text and their helpers in 

1464 # download_service.py; sanitize_error_for_client() alone would not 

1465 # suffice since it scrubs credentials only, not SQL/paths) or, on the 

1466 # `except Exception as e:` path above, is built from `type(e).__name__` 

1467 # only — never the raw `error_msg = str(e)`. 

1468 return WorkerCleanupStreamingResponse( 

1469 generate(), 

1470 media_type="text/event-stream", 

1471 headers={ 

1472 "Cache-Control": "no-cache, no-transform", 

1473 "X-Accel-Buffering": "no", 

1474 }, 

1475 ) 

1476 

1477 

1478@router.get("/api/research-list") 

1479def get_research_list( 

1480 request: Request, username: Annotated[str, Depends(require_auth)] 

1481): 

1482 """Get the lightweight research list used to populate a dropdown. 

1483 

1484 Deliberately NOT ``get_research_list_with_stats``: that runs a 3-table 

1485 GROUP BY aggregate plus follow-up ratings and domain-breakdown queries, 

1486 which a <select> needing only id/title/query does not use. Main replaced 

1487 this call for exactly that reason. 

1488 """ 

1489 service = LibraryService(username) 

1490 research_list = service.get_research_list_for_dropdown() 

1491 return {"research": research_list} 

1492 

1493 

1494@router.post("/api/sync-library") 

1495def sync_library( 

1496 request: Request, username: Annotated[str, Depends(require_auth)] 

1497): 

1498 """Sync library database with filesystem.""" 

1499 service = LibraryService(username) 

1500 return service.sync_library_with_filesystem() 

1501 

1502 

1503@router.post("/api/mark-redownload") 

1504async def mark_for_redownload( 

1505 request: Request, username: Annotated[str, Depends(require_auth)] 

1506): 

1507 """Mark documents for re-download.""" 

1508 data, body_error = await read_json_dict( 

1509 request, "simple", "Request body must be valid JSON" 

1510 ) 

1511 if body_error is not None: 

1512 return body_error 

1513 document_ids = data.get("document_ids", []) 

1514 

1515 if not document_ids: 

1516 return JSONResponse( 

1517 {"error": "No document IDs provided"}, status_code=400 

1518 ) 

1519 if error := _string_list_error(document_ids, "document_ids"): 

1520 return error 

1521 

1522 def _mark_sync(): 

1523 service = LibraryService(username) 

1524 return service.mark_for_redownload(document_ids) 

1525 

1526 count = await run_db_sync(_mark_sync) 

1527 return {"success": True, "marked": count} 

1528 

1529 

1530def _one_resource_per_awaiting_document( 

1531 db_session, resources, username, password 

1532): 

1533 """Keep one of the resources that would refill the same document. 

1534 

1535 A document that awaits its PDF (see ``document_awaits_pdf``) does not 

1536 count as a download, so "Download All New" finds its own resource 

1537 (``Document.resource_id``) and every resource linked to it by content 

1538 (``ResearchResource.document_id``). Each of them downloads into that 

1539 document (``get_document_for_resource``), and one download refills it. 

1540 So of the ``resources`` that would refill the same document only one is 

1541 kept, the one ``queue_research_downloads`` would leave the document to 

1542 (see ``DownloadService._refilled_by_another_resource``): one that a 

1543 download holds (a PROCESSING queue entry, see 

1544 ``download_claim_is_live``), else a viable one that is already PENDING 

1545 in the download queue, else the one that ranks first in 

1546 ``DownloadService.refill_rank``. So a resource that cannot succeed (its 

1547 URL cannot be queued, the retry manager holds it back, or its latest 

1548 attempt failed) is kept only when it is being downloaded or no viable 

1549 one is there. A resource whose PROCESSING entry a restart left behind 

1550 comes last, since no reset touches that entry. The others count as 

1551 skipped. (Once the document holds its PDF again, 

1552 ``DownloadService.download_resource`` reports the document's own 

1553 resource as already downloaded, and for a linked one whose URL the 

1554 egress policy allows, ``DownloadService._download_pdf`` reuses that 

1555 stored PDF instead of fetching it.) 

1556 

1557 ``resources`` have passed the route's retry-policy and downloadability 

1558 checks. The ranking also asks the egress policy and the resources' 

1559 retry records, through a ``DownloadService`` for ``username``, which is 

1560 built only when some document has more than one of them. 

1561 

1562 Returns the kept resources in their order, and how many were left out. 

1563 """ 

1564 awaiting = { 

1565 resource_id: (document_id, owner_id) 

1566 for resource_id, document_id, owner_id in ( 

1567 db_session.query( 

1568 ResearchResource.id, Document.id, Document.resource_id 

1569 ) 

1570 .join( 

1571 Document, 

1572 resource_document_clause(), 

1573 ) 

1574 .filter(document_awaits_pdf_clause()) 

1575 .all() 

1576 ) 

1577 } 

1578 candidates = {} 

1579 for resource in resources: 

1580 if resource.id in awaiting: 

1581 document_id = awaiting[resource.id][0] 

1582 candidates.setdefault(document_id, []).append(resource) 

1583 shared = [rows for rows in candidates.values() if len(rows) > 1] 

1584 if not shared: 

1585 return resources, 0 

1586 

1587 entries = { 

1588 entry.resource_id: entry 

1589 for entry in db_session.query( 

1590 LibraryDownloadQueue.resource_id, 

1591 LibraryDownloadQueue.status, 

1592 LibraryDownloadQueue.last_attempt_at, 

1593 ) 

1594 .filter( 

1595 LibraryDownloadQueue.resource_id.in_( 

1596 [row.id for rows in shared for row in rows] 

1597 ), 

1598 LibraryDownloadQueue.status.in_( 

1599 (DocumentStatus.PENDING, DocumentStatus.PROCESSING) 

1600 ), 

1601 ) 

1602 .all() 

1603 } 

1604 left_out = set() 

1605 with DownloadService(username, password) as download_service: 

1606 for rows in shared: 

1607 owner_id = awaiting[rows[0].id][1] 

1608 choices = [] 

1609 for row in rows: 

1610 rank = download_service.refill_rank(row.id, row.url, owner_id) 

1611 entry = entries.get(row.id) 

1612 if entry is None: 

1613 tier = 2 

1614 elif entry.status == DocumentStatus.PROCESSING: 

1615 tier = ( 

1616 0 

1617 if download_claim_is_live(entry.last_attempt_at) 

1618 else 3 

1619 ) 

1620 else: 

1621 tier = 1 if rank.viable else 2 

1622 choices.append(((tier, rank), row.id)) 

1623 kept_id = min(choices)[1] 

1624 left_out.update(row.id for row in rows if row.id != kept_id) 

1625 for resource_id in sorted(left_out): 

1626 logger.debug( 

1627 f"Skipping resource {resource_id}: another resource of its " 

1628 "document is queued to download its PDF" 

1629 ) 

1630 kept = [resource for resource in resources if resource.id not in left_out] 

1631 return kept, len(left_out) 

1632 

1633 

1634@router.post("/api/queue-all-undownloaded") 

1635def queue_all_undownloaded( 

1636 request: Request, username: Annotated[str, Depends(require_auth)] 

1637): 

1638 """Queue all articles that haven't been downloaded yet.""" 

1639 

1640 logger.info(f"queue_all_undownloaded called for user {username}") 

1641 

1642 with get_user_db_session(username) as db_session: 

1643 # Find all resources that don't have a completed download. Project only 

1644 # the columns the filter/loop use (id/url/research_id) instead of full 

1645 # ``ResearchResource`` entities so the ``content_preview`` Text column 

1646 # isn't materialized for every row on this whole-table scan (#4560). 

1647 # A completed document that still needs its PDF (the library sync 

1648 # kept its text after its PDF file went missing) is not counted as 

1649 # a download, see document_awaits_pdf. Only one of the resources 

1650 # that would refill such a document is queued, see 

1651 # _one_resource_per_awaiting_document. 

1652 undownloaded = ( 

1653 db_session.query( 

1654 ResearchResource.id, 

1655 ResearchResource.url, 

1656 ResearchResource.research_id, 

1657 ) 

1658 .outerjoin( 

1659 Document, 

1660 resource_document_clause() 

1661 & (Document.status == "completed") 

1662 & ~document_awaits_pdf_clause(), 

1663 ) 

1664 .filter(Document.id.is_(None)) 

1665 .all() 

1666 ) 

1667 

1668 logger.info(f"Found {len(undownloaded)} total undownloaded resources") 

1669 

1670 # Get user password for encrypted database access 

1671 user_password = get_authenticated_user_password(username) 

1672 

1673 resource_filter = ResourceFilter(username, user_password) 

1674 filter_results = resource_filter.filter_downloadable_resources( 

1675 undownloaded 

1676 ) 

1677 

1678 # Get detailed filtering summary 

1679 filter_summary = resource_filter.get_filter_summary(undownloaded) 

1680 skipped_info = resource_filter.get_skipped_resources_info(undownloaded) 

1681 

1682 logger.info(f"Filter results: {filter_summary.to_dict()}") 

1683 

1684 queued_count = 0 

1685 research_ids = set() 

1686 skipped_count = 0 

1687 

1688 # Convert filter_results to dict for O(1) lookup instead of O(n²) 

1689 filter_results_by_id = {r.resource_id: r for r in filter_results} 

1690 

1691 # The resources that pass every check below, in scan order. 

1692 eligible = [] 

1693 for resource in undownloaded: 

1694 # Check if resource passed the smart filter 

1695 filter_result = filter_results_by_id.get(resource.id) 

1696 

1697 if not filter_result or not filter_result.can_retry: 

1698 skipped_count += 1 

1699 if filter_result: 

1700 # A permanently failed row's reason embeds its stored 

1701 # failure_message, which rows written before the 

1702 # domain fix may hold with URL userinfo. 

1703 logger.debug( 

1704 f"Skipping resource {resource.id} due to retry policy: " 

1705 f"{failure_reason_for_log(filter_result.reason)}" 

1706 ) 

1707 else: 

1708 logger.debug( 

1709 f"Skipping resource {resource.id} - no filter result available" 

1710 ) 

1711 continue 

1712 

1713 # Check if it's downloadable using proper URL parsing 

1714 if not resource.url: 

1715 skipped_count += 1 

1716 continue 

1717 

1718 is_downloadable = is_downloadable_domain(resource.url) 

1719 

1720 # Log what we're checking 

1721 if resource.url and "pubmed" in resource.url.lower(): 1721 ↛ 1722line 1721 didn't jump to line 1722 because the condition on line 1721 was never true

1722 logger.info( 

1723 "Found PubMed URL: {}", 

1724 # (#6938: redacted before it is cut.) 

1725 redact_and_bound_for_log( 

1726 redact_url_for_log(resource.url), 100 

1727 ), 

1728 ) 

1729 

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

1731 skipped_count += 1 

1732 logger.opt(lazy=True).debug( 

1733 "Skipping non-downloadable URL: {}", 

1734 lambda: redact_and_bound_for_log( 

1735 redact_url_for_log(resource.url), 100 

1736 ), 

1737 ) 

1738 continue 

1739 

1740 eligible.append(resource) 

1741 

1742 # One download refills a document that awaits its PDF, so only one 

1743 # of the resources that would refill the same document is queued. 

1744 eligible, covered = _one_resource_per_awaiting_document( 

1745 db_session, eligible, username, user_password 

1746 ) 

1747 skipped_count += covered 

1748 

1749 for resource in eligible: 

1750 # Check if already in queue (any status) 

1751 existing_queue = ( 

1752 db_session.query(LibraryDownloadQueue) 

1753 .filter_by(resource_id=resource.id) 

1754 .first() 

1755 ) 

1756 

1757 if existing_queue: 

1758 # Reset a non-pending row back to PENDING so it is retried, 

1759 # but never one a concurrent download stream has claimed: 

1760 # PROCESSING satisfies `!= PENDING`, so an unguarded reset 

1761 # un-claims an in-flight row and lets a second stream 

1762 # re-download the same resource (issue #4691). The query above 

1763 # selects in-flight rows, since a download that has not 

1764 # finished has no completed Document yet. The guard lives 

1765 # inside the UPDATE, not in an `if` above it, so the check and 

1766 # the write cannot straddle another stream's claim. 

1767 reset = ( 

1768 db_session.query(LibraryDownloadQueue) 

1769 .filter( 

1770 LibraryDownloadQueue.id == existing_queue.id, 

1771 LibraryDownloadQueue.status != DocumentStatus.PENDING, 

1772 LibraryDownloadQueue.status 

1773 != DocumentStatus.PROCESSING, 

1774 ) 

1775 .update( 

1776 { 

1777 LibraryDownloadQueue.status: DocumentStatus.PENDING, 

1778 LibraryDownloadQueue.completed_at: None, 

1779 }, 

1780 synchronize_session=False, 

1781 ) 

1782 ) 

1783 # Counted either way, matching the pre-fix behaviour: a row 

1784 # already PENDING, a row just reset, and a row another stream 

1785 # is downloading are all "queued" to the caller. 

1786 queued_count += 1 

1787 research_ids.add(resource.research_id) 

1788 if reset: 

1789 logger.debug( 

1790 f"Reset queue entry for resource {resource.id} to pending" 

1791 ) 

1792 else: 

1793 logger.debug( 

1794 f"Resource {resource.id} already pending or in flight" 

1795 ) 

1796 else: 

1797 # Add new entry to queue 

1798 queue_entry = LibraryDownloadQueue( 

1799 resource_id=resource.id, 

1800 research_id=resource.research_id, 

1801 priority=0, 

1802 status=DocumentStatus.PENDING, 

1803 ) 

1804 db_session.add(queue_entry) 

1805 queued_count += 1 

1806 research_ids.add(resource.research_id) 

1807 logger.debug( 

1808 f"Added new queue entry for resource {resource.id}" 

1809 ) 

1810 

1811 db_session.commit() 

1812 

1813 logger.info( 

1814 f"Queued {queued_count} articles for download, skipped {skipped_count} resources (including {filter_summary.permanently_failed_count} permanently failed and {filter_summary.temporarily_failed_count} temporarily failed)" 

1815 ) 

1816 

1817 # Note: Removed synchronous download processing here to avoid blocking the HTTP request 

1818 # Downloads will be processed via the SSE streaming endpoint or background tasks 

1819 

1820 return { 

1821 "success": True, 

1822 "queued": queued_count, 

1823 "research_ids": list(research_ids), 

1824 "total_undownloaded": len(undownloaded), 

1825 "skipped": skipped_count, 

1826 "filter_summary": filter_summary.to_dict(), 

1827 "skipped_details": skipped_info, 

1828 } 

1829 

1830 

1831@router.get("/api/get-research-sources/{research_id}") 

1832def get_research_sources( 

1833 request: Request, 

1834 research_id, 

1835 username: Annotated[str, Depends(require_auth)], 

1836): 

1837 """Get all sources for a research with snippets.""" 

1838 

1839 sources = [] 

1840 with get_user_db_session(username) as db_session: 

1841 # Get all resources for this research 

1842 resources = ( 

1843 db_session.query(ResearchResource) 

1844 .filter_by(research_id=research_id) 

1845 .order_by(ResearchResource.created_at) 

1846 .all() 

1847 ) 

1848 

1849 for idx, resource in enumerate(resources, 1): 

1850 # Check if document exists 

1851 document = get_document_for_resource(db_session, resource) 

1852 

1853 # Get domain from URL 

1854 domain = "" 

1855 if resource.url: 

1856 try: 

1857 from urllib.parse import urlparse 

1858 

1859 domain = urlparse(resource.url).hostname or "" 

1860 except (ValueError, AttributeError): 

1861 # urlparse can raise ValueError for malformed URLs 

1862 pass 

1863 

1864 source_data = { 

1865 "number": idx, 

1866 "resource_id": resource.id, 

1867 "url": resource.url, 

1868 "title": resource.title or f"Source {idx}", 

1869 "snippet": resource.content_preview or "", 

1870 "domain": domain, 

1871 "relevance_score": getattr(resource, "relevance_score", None), 

1872 "downloaded": False, 

1873 "document_id": None, 

1874 "file_type": None, 

1875 } 

1876 

1877 # The Download Manager's rule: a completed document, unless it 

1878 # awaits its PDF (see document_counts_as_downloaded). A loaded 

1879 # status never equals the string "completed". 

1880 if document and document_counts_as_downloaded(document): 

1881 source_data.update( 

1882 { 

1883 "downloaded": True, 

1884 "document_id": document.id, 

1885 "file_type": document.file_type, 

1886 "download_date": document.created_at.isoformat() 

1887 if document.created_at 

1888 else None, 

1889 } 

1890 ) 

1891 

1892 sources.append(source_data) 

1893 

1894 return {"success": True, "sources": sources, "total": len(sources)} 

1895 

1896 

1897@router.post("/api/check-downloads") 

1898async def check_downloads( 

1899 request: Request, username: Annotated[str, Depends(require_auth)] 

1900): 

1901 """Check download status for a list of URLs.""" 

1902 data, body_error = await read_json_dict( 

1903 request, "simple", "Request body must be valid JSON" 

1904 ) 

1905 if body_error is not None: 

1906 return body_error 

1907 research_id = data.get("research_id") 

1908 urls = data.get("urls", []) 

1909 

1910 if not isinstance(research_id, str) or not research_id.strip() or not urls: 

1911 return JSONResponse( 

1912 {"error": "Missing research_id or urls"}, status_code=400 

1913 ) 

1914 if error := _string_list_error(urls, "urls"): 

1915 return error 

1916 

1917 def _query_sync(): 

1918 download_status = {} 

1919 with get_user_db_session(username) as db_session: 

1920 resources = ( 

1921 db_session.query(ResearchResource) 

1922 .filter_by(research_id=research_id) 

1923 .filter(ResearchResource.url.in_(urls)) 

1924 .all() 

1925 ) 

1926 for resource in resources: 

1927 document = get_document_for_resource(db_session, resource) 

1928 # A document that awaits its PDF is reported not downloaded, 

1929 # with the resource_id to download it by (see 

1930 # document_counts_as_downloaded). 

1931 if document and document_counts_as_downloaded(document): 

1932 # Do NOT return document.file_path — it's the absolute 

1933 # server path, which leaks directory layout to any 

1934 # authenticated user. The client fetches via 

1935 # `/document/{id}/pdf` or `/document/{id}/text` using 

1936 # document_id, so the path is never needed client-side. 

1937 download_status[resource.url] = { 

1938 "downloaded": True, 

1939 "document_id": document.id, 

1940 "file_type": document.file_type, 

1941 "title": document.title or resource.title, 

1942 } 

1943 else: 

1944 download_status[resource.url] = { 

1945 "downloaded": False, 

1946 "resource_id": resource.id, 

1947 } 

1948 return download_status 

1949 

1950 download_status = await run_db_sync(_query_sync) 

1951 return {"download_status": download_status} 

1952 

1953 

1954@router.post("/api/download-source") 

1955async def download_source( 

1956 request: Request, username: Annotated[str, Depends(require_auth)] 

1957): 

1958 """Download a single source from a research.""" 

1959 user_password = get_authenticated_user_password(username) 

1960 data, body_error = await read_json_dict( 

1961 request, "simple", "Request body must be valid JSON" 

1962 ) 

1963 if body_error is not None: 

1964 return body_error 

1965 research_id = data.get("research_id") 

1966 url = data.get("url") 

1967 

1968 if not research_id or not url: 

1969 return JSONResponse( 

1970 {"error": "Missing research_id or url"}, status_code=400 

1971 ) 

1972 

1973 # Check if URL is downloadable 

1974 if not is_downloadable_domain(url): 

1975 return JSONResponse( 

1976 {"error": "URL is not from a downloadable domain"}, status_code=400 

1977 ) 

1978 

1979 def _download_sync(): 

1980 """The DB + network work — all blocking, run in a threadpool.""" 

1981 with get_user_db_session(username) as db_session: 

1982 # Find the resource 

1983 resource = ( 

1984 db_session.query(ResearchResource) 

1985 .filter_by(research_id=research_id, url=url) 

1986 .first() 

1987 ) 

1988 

1989 if not resource: 

1990 return JSONResponse( 

1991 {"error": "Resource not found"}, status_code=404 

1992 ) 

1993 

1994 # Check if already downloaded, by the Download Manager's rule 

1995 # (see document_counts_as_downloaded). A document that awaits 

1996 # its PDF is downloaded again, into the same document. 

1997 existing = get_document_for_resource(db_session, resource) 

1998 

1999 if existing and document_counts_as_downloaded(existing): 

2000 return { 

2001 "success": True, 

2002 "message": "Already downloaded", 

2003 "document_id": existing.id, 

2004 } 

2005 

2006 # Add to download queue 

2007 queue_entry = ( 

2008 db_session.query(LibraryDownloadQueue) 

2009 .filter_by(resource_id=resource.id) 

2010 .first() 

2011 ) 

2012 

2013 if not queue_entry: 

2014 queue_entry = LibraryDownloadQueue( 

2015 resource_id=resource.id, 

2016 research_id=resource.research_id, 

2017 priority=1, # Higher priority for manual downloads 

2018 status=DocumentStatus.PENDING, 

2019 ) 

2020 db_session.add(queue_entry) 

2021 db_session.commit() 

2022 else: 

2023 # Reset the row so this manual download can claim it, but never 

2024 # one a concurrent stream is already downloading: the reset is 

2025 # followed immediately by a download in this request thread, so 

2026 # un-claiming an in-flight row starts a second download of the 

2027 # same resource (issue #4691). The already-downloaded early 

2028 # return above cannot prevent that, because a download still in 

2029 # flight has not written a completed Document yet. 

2030 reset = ( 

2031 db_session.query(LibraryDownloadQueue) 

2032 .filter( 

2033 LibraryDownloadQueue.id == queue_entry.id, 

2034 LibraryDownloadQueue.status 

2035 != DocumentStatus.PROCESSING, 

2036 ) 

2037 .update( 

2038 { 

2039 LibraryDownloadQueue.status: DocumentStatus.PENDING, 

2040 LibraryDownloadQueue.priority: 1, 

2041 }, 

2042 synchronize_session=False, 

2043 ) 

2044 ) 

2045 db_session.commit() 

2046 if not reset: 

2047 return JSONResponse( 

2048 { 

2049 "success": False, 

2050 "message": "Download already in progress", 

2051 }, 

2052 status_code=409, 

2053 ) 

2054 

2055 queue_item_id = queue_entry.id 

2056 # The claim commits and expires ORM rows. Snapshot the resource 

2057 # ID first so no attribute access can fail between claiming and 

2058 # the download. Base re-read resource.id after claiming, inside 

2059 # the try: that refresh raised, and although the handler caught 

2060 # it and released the claim, the download itself never ran. 

2061 resource_id = resource.id 

2062 

2063 # Claim the row before downloading, exactly as download_bulk does, 

2064 # so a bulk stream that reaches this row between the reset above and 

2065 # the download below cannot also process it (issue #4691). 

2066 if not _claim_download_queue_item(db_session, queue_item_id): 

2067 return JSONResponse( 

2068 { 

2069 "success": False, 

2070 "message": "Download already in progress", 

2071 }, 

2072 status_code=409, 

2073 ) 

2074 

2075 # The claim above is committed. Leave the thread-local read scope 

2076 # before the remote download, which may occupy this worker for minutes. 

2077 try: 

2078 with DownloadService(username, user_password) as service: 

2079 success, message = service.download_resource(resource_id) 

2080 except Exception: 

2081 # A fresh short-lived session — the download ran outside the read 

2082 # scope above, so there is no held session to roll back. Pass the 

2083 # password captured at request start: by the time a multi-minute 

2084 # transfer ends, logout / password change / the idle sweep may 

2085 # have cleared the session password store, and the store-less 

2086 # lookup would raise DatabaseSessionError before the release, 

2087 # stranding the row in PROCESSING (issue #4691). Guard the 

2088 # release so a failure here cannot mask the original exception. 

2089 try: 

2090 with get_user_db_session(username, user_password) as db_session: 

2091 # Release the claim so a later run can retry this row: 

2092 # the download raised before download_resource recorded a 

2093 # terminal status (issue #4691). 

2094 _release_download_queue_item(db_session, queue_item_id) 

2095 except Exception: 

2096 logger.exception("Failed to release download-queue claim") 

2097 raise 

2098 

2099 # download_resource writes a terminal status for the rows it actually 

2100 # downloads, so this is a no-op for those; it fires on early returns, 

2101 # which would otherwise strand the claim in PROCESSING. The password 

2102 # must be passed for the same reason as the release above. 

2103 with get_user_db_session(username, user_password) as db_session: 

2104 _finalize_download_queue_item(db_session, queue_item_id, success) 

2105 

2106 if success: 

2107 return {"success": True, "message": "Download completed"} 

2108 # Log internal message, but show only generic message to user 

2109 return {"success": False, "message": "Download failed"} 

2110 

2111 # Offload the blocking DB + HTTP work so we don't stall the event loop. 

2112 return await run_db_sync(_download_sync)