Coverage for src/local_deep_research/research_library/utils/__init__.py: 91%

116 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1"""Shared utility functions for the Research Library.""" 

2 

3import hashlib 

4import os 

5import subprocess 

6import sys 

7from pathlib import Path 

8from typing import Optional 

9from urllib.parse import urlparse 

10 

11from flask import jsonify 

12from loguru import logger 

13 

14from ...config.paths import get_library_directory 

15from ...database.models.library import Document, DocumentCollection 

16from ...security.path_validator import PathValidator 

17 

18 

19def escape_like(text: str) -> str: 

20 """Escape SQL LIKE/ILIKE wildcards so user input matches literally. 

21 

22 ``%`` and ``_`` are LIKE wildcards and ``\\`` is the escape character; 

23 without escaping, a query like ``my_note`` would treat ``_`` as "any 

24 character" and ``%`` would match anything. Use the result with 

25 ``.like(pattern, escape="\\\\")`` / ``.ilike(pattern, escape="\\\\")``. 

26 

27 This is the single source of truth for LIKE-escaping across the library 

28 and notes query paths (previously copy-pasted in NoteService, 

29 LibraryService and unified_search_routes). 

30 """ 

31 return ( 

32 (text or "") 

33 .replace("\\", "\\\\") 

34 .replace("%", "\\%") 

35 .replace("_", "\\_") 

36 ) 

37 

38 

39def is_downloadable_domain(url: str) -> bool: 

40 """Check if URL is from a downloadable academic domain using proper URL parsing.""" 

41 try: 

42 if not url: 

43 return False 

44 

45 parsed = urlparse(url.lower()) 

46 hostname = parsed.hostname or "" 

47 path = parsed.path or "" 

48 query = parsed.query or "" 

49 

50 # Check for direct PDF files 

51 if path.endswith(".pdf") or ".pdf?" in url.lower(): 

52 return True 

53 

54 # List of downloadable academic domains 

55 downloadable_domains = [ 

56 "arxiv.org", 

57 "biorxiv.org", 

58 "medrxiv.org", 

59 "ncbi.nlm.nih.gov", 

60 "pubmed.ncbi.nlm.nih.gov", 

61 "europepmc.org", 

62 "semanticscholar.org", 

63 "researchgate.net", 

64 "academia.edu", 

65 "sciencedirect.com", 

66 "springer.com", 

67 "nature.com", 

68 "wiley.com", 

69 "ieee.org", 

70 "acm.org", 

71 "plos.org", 

72 "frontiersin.org", 

73 "mdpi.com", 

74 "acs.org", 

75 "rsc.org", 

76 "tandfonline.com", 

77 "sagepub.com", 

78 "oxford.com", 

79 "cambridge.org", 

80 "bmj.com", 

81 "nejm.org", 

82 "thelancet.com", 

83 "jamanetwork.com", 

84 "annals.org", 

85 "ahajournals.org", 

86 "cell.com", 

87 "science.org", 

88 "pnas.org", 

89 "elifesciences.org", 

90 "embopress.org", 

91 "journals.asm.org", 

92 "microbiologyresearch.org", 

93 "jvi.asm.org", 

94 "genome.cshlp.org", 

95 "genetics.org", 

96 "g3journal.org", 

97 "plantphysiol.org", 

98 "plantcell.org", 

99 "aspb.org", 

100 "bioone.org", 

101 "company-of-biologists.org", 

102 "biologists.org", 

103 "jeb.biologists.org", 

104 "dmm.biologists.org", 

105 "bio.biologists.org", 

106 "doi.org", 

107 "ssrn.com", 

108 "openreview.net", 

109 ] 

110 

111 # Check if hostname matches any downloadable domain 

112 for domain in downloadable_domains: 

113 if hostname == domain or hostname.endswith("." + domain): 

114 return True 

115 

116 # Special case for PubMed which might appear in path 

117 if "pubmed" in hostname or "/pubmed/" in path: 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true

118 return True 

119 

120 # Check for PDF in path or query parameters 

121 if "/pdf/" in path or "type=pdf" in query or "format=pdf" in query: 

122 return True 

123 

124 return False 

125 

126 except Exception: 

127 logger.warning(f"Error parsing URL {url}") 

128 return False 

129 

130 

131def is_downloadable_url(url: str) -> bool: 

132 """Check if a URL is downloadable (academic domain or direct PDF link). 

133 

134 This is the single source of truth for downloadability checks. 

135 Combines domain checking with PDF extension/path detection. 

136 

137 Args: 

138 url: The URL to check 

139 

140 Returns: 

141 True if the URL is from a downloadable academic domain or is a direct PDF link 

142 """ 

143 return is_downloadable_domain(url) 

144 

145 

146def get_document_for_resource(session, resource): 

147 """Get Document for a ResearchResource. 

148 

149 Checks resource.document_id first (library resources point directly 

150 to existing Documents), falls back to Document.resource_id lookup 

151 (web downloads create Documents with resource_id set). 

152 """ 

153 if resource.document_id: 

154 return ( 

155 session.query(Document).filter_by(id=resource.document_id).first() 

156 ) 

157 return session.query(Document).filter_by(resource_id=resource.id).first() 

158 

159 

160def get_url_hash(url: str) -> str: 

161 """ 

162 Generate a SHA256 hash of a URL. 

163 

164 Args: 

165 url: The URL to hash 

166 

167 Returns: 

168 The SHA256 hash of the URL 

169 """ 

170 return hashlib.sha256(url.lower().encode()).hexdigest() 

171 

172 

173def ensure_in_collection( 

174 session, document_id: str, collection_id: str 

175) -> "DocumentCollection": 

176 """Get or create a DocumentCollection link between a document and a collection. 

177 

178 Args: 

179 session: SQLAlchemy session 

180 document_id: UUID of the document 

181 collection_id: UUID of the collection 

182 

183 Returns: 

184 The existing or newly created DocumentCollection row 

185 """ 

186 existing = ( 

187 session.query(DocumentCollection) 

188 .filter_by(document_id=document_id, collection_id=collection_id) 

189 .first() 

190 ) 

191 if existing: 

192 return existing 

193 

194 doc_collection = DocumentCollection( 

195 document_id=document_id, 

196 collection_id=collection_id, 

197 indexed=False, 

198 ) 

199 session.add(doc_collection) 

200 return doc_collection 

201 

202 

203def get_library_storage_path(username: str) -> Path: 

204 """ 

205 Get the storage path for a user's library. 

206 

207 Uses the settings system which respects environment variable overrides: 

208 - research_library.storage_path: Base path for library storage 

209 - research_library.shared_library: If true, all users share the same directory 

210 

211 Args: 

212 username: The username 

213 

214 Returns: 

215 Path to the library storage directory 

216 """ 

217 from ...utilities.db_utils import get_settings_manager 

218 

219 settings = get_settings_manager() 

220 

221 # Get the base path from settings (uses centralized path, respects LDR_DATA_DIR) 

222 base_path = ( 

223 Path( 

224 settings.get_setting( 

225 "research_library.storage_path", 

226 str(get_library_directory()), 

227 ) 

228 ) 

229 .expanduser() 

230 .resolve() 

231 ) 

232 

233 # Check if shared library mode is enabled 

234 shared_library = settings.get_setting( 

235 "research_library.shared_library", False 

236 ) 

237 

238 if shared_library: 

239 # Shared mode: all users use the same directory 

240 base_path.mkdir(parents=True, exist_ok=True) 

241 return base_path 

242 # Default: user isolation with subdirectories 

243 user_dir = base_path / username 

244 user_dir.mkdir(parents=True, exist_ok=True) 

245 return user_dir 

246 

247 

248def open_file_location(file_path: str) -> bool: 

249 """ 

250 Open the file location in the system file manager. 

251 

252 Args: 

253 file_path: Path to the file 

254 

255 Returns: 

256 True if successful, False otherwise 

257 """ 

258 try: 

259 # Validate path is safe (blocks system dirs, path traversal) 

260 validated = PathValidator.validate_local_filesystem_path(file_path) 

261 folder = str(validated.parent) 

262 if sys.platform == "win32": 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true

263 os.startfile(folder) 

264 elif sys.platform == "darwin": # macOS 

265 result = subprocess.run( 

266 ["open", folder], capture_output=True, text=True, shell=False 

267 ) 

268 if result.returncode != 0: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 logger.error(f"Failed to open folder on macOS: {result.stderr}") 

270 return False 

271 else: # Linux 

272 result = subprocess.run( 

273 ["xdg-open", folder], 

274 capture_output=True, 

275 text=True, 

276 shell=False, 

277 ) 

278 if result.returncode != 0: 

279 logger.error(f"Failed to open folder on Linux: {result.stderr}") 

280 return False 

281 return True 

282 except Exception: 

283 logger.exception("Failed to open file location") 

284 return False 

285 

286 

287def get_absolute_library_path( 

288 relative_path: str, username: str 

289) -> Optional[Path]: 

290 """ 

291 Get the absolute path from a relative library path. 

292 

293 Uses PathValidator to prevent path traversal attacks. 

294 

295 Args: 

296 relative_path: The relative path from library root 

297 username: The username 

298 

299 Returns: 

300 The absolute path, or None if the path is unsafe 

301 """ 

302 library_root = get_library_storage_path(username) 

303 try: 

304 # Use PathValidator to prevent path traversal attacks 

305 safe_path = PathValidator.validate_safe_path( 

306 relative_path, str(library_root) 

307 ) 

308 if safe_path is None: 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true

309 return None 

310 result = Path(safe_path) 

311 if result.is_symlink(): 

312 logger.warning(f"Symlink blocked: {relative_path}") 

313 return None 

314 return result 

315 except ValueError: 

316 logger.warning(f"Path traversal blocked: {relative_path}") 

317 return None 

318 

319 

320def get_absolute_path_from_settings(relative_path: str) -> Optional[Path]: 

321 """ 

322 Get absolute path using settings manager for library root. 

323 

324 Uses PathValidator to prevent path traversal attacks. 

325 

326 Args: 

327 relative_path: The relative path from library root 

328 

329 Returns: 

330 The absolute path, or None if the path is unsafe 

331 """ 

332 from ...utilities.db_utils import get_settings_manager 

333 

334 settings = get_settings_manager() 

335 library_root = ( 

336 Path( 

337 settings.get_setting( 

338 "research_library.storage_path", 

339 str(get_library_directory()), 

340 ) 

341 ) 

342 .expanduser() 

343 .resolve() 

344 ) 

345 

346 if not relative_path: 

347 return library_root 

348 

349 try: 

350 # Use PathValidator to prevent path traversal attacks 

351 safe_path = PathValidator.validate_safe_path( 

352 relative_path, str(library_root) 

353 ) 

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

355 return None 

356 result = Path(safe_path) 

357 if result.is_symlink(): 

358 logger.warning(f"Symlink blocked: {relative_path}") 

359 return None 

360 return result 

361 except ValueError: 

362 logger.warning(f"Path traversal blocked: {relative_path}") 

363 return None 

364 

365 

366def handle_api_error(operation: str, error: Exception, status_code: int = 500): 

367 """ 

368 Handle API errors consistently - log internally, return generic message to user. 

369 

370 This prevents information exposure by logging full error details internally 

371 while returning a generic message to the user. 

372 

373 Args: 

374 operation: Description of the operation that failed (for logging) 

375 error: The exception that occurred 

376 status_code: HTTP status code to return (default: 500) 

377 

378 Returns: 

379 Flask JSON response tuple (response, status_code) 

380 """ 

381 # Log the full error internally with stack trace 

382 logger.exception(f"Error during {operation}") 

383 

384 # Return generic message to user (no internal details exposed) 

385 return jsonify( 

386 { 

387 "success": False, 

388 "error": "An internal error occurred. Please try again or contact support.", 

389 } 

390 ), status_code