Coverage for src/local_deep_research/web/routers/zotero.py: 93%

110 statements  

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

1""" 

2Routes for the Zotero integration (FastAPI). 

3 

4Exposes a small page plus JSON endpoints to test the connection, list the 

5user's Zotero collections, trigger a manual sync, and read sync status. 

6Credentials and options live in user settings (category ``zotero``); these 

7routes never accept or return the API key. 

8 

9Ported from the Flask ``research_library/routes/zotero_routes.py`` blueprint 

10(feature #4723). ``ZoteroSyncService`` is framework-free (opens the user's 

11encrypted DB via ``get_user_db_session(username, password)``), so the manual 

12sync runs in a daemon thread that needs only ``thread_cleanup()`` — no Flask 

13app context. 

14""" 

15 

16import threading 

17 

18from fastapi import APIRouter, Depends, Request 

19from fastapi.responses import JSONResponse 

20from loguru import logger 

21 

22from ...database.session_passwords import session_password_store 

23from ...database.thread_local_session import thread_cleanup 

24from ...research_library.utils import handle_api_error 

25from ...research_library.zotero import ( 

26 ZoteroAuthError, 

27 ZoteroError, 

28 ZoteroSyncService, 

29 ZoteroTransientError, 

30) 

31from ...security import sanitize_error_for_client 

32from ..dependencies.auth import require_auth 

33from ..template_config import templates 

34from typing import Annotated 

35 

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

37 

38 

39def _zotero_error_status(exc: ZoteroError) -> int: 

40 """Map a Zotero client error to the matching HTTP status.""" 

41 if isinstance(exc, ZoteroAuthError): 

42 return 401 

43 if isinstance(exc, ZoteroTransientError): 

44 return 503 

45 return 400 

46 

47 

48def _zotero_error_response(exc: ZoteroError) -> JSONResponse: 

49 """Zotero-side rejections carry actionable, static messages (bad library 

50 ID, revoked key, …) — surface them instead of a 500, with a status code 

51 matching the failure class.""" 

52 # CWE-209 (CodeQL "Information exposure through an exception"): 

53 # ``str(exc)`` is exception-derived, but sanitize_error_for_client() 

54 # (credential redaction + control-char strip + length cap) is applied 

55 # before it leaves this function — a deliberate choice, not a leak. 

56 return JSONResponse( 

57 {"success": False, "error": sanitize_error_for_client(str(exc))}, 

58 status_code=_zotero_error_status(exc), 

59 ) 

60 

61 

62def _db_password(request: Request, username: str) -> str | None: 

63 """Resolve the current session's database password.""" 

64 return session_password_store.get_session_password( 

65 username, request.session.get("session_id") 

66 ) 

67 

68 

69def _session_expired_response() -> JSONResponse: 

70 """401 for a logged-in session whose cached DB password has expired. 

71 

72 Every endpoint below opens the user's encrypted database, so without the 

73 password the request cannot succeed — tell the client to re-authenticate 

74 instead of failing with a generic 500. 

75 """ 

76 return JSONResponse( 

77 { 

78 "success": False, 

79 "error": "Session expired — please sign in again.", 

80 }, 

81 status_code=401, 

82 ) 

83 

84 

85@router.get("/zotero") 

86def zotero_page( 

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

88): 

89 """Render the Zotero integration page.""" 

90 return templates.TemplateResponse( 

91 request=request, 

92 name="pages/zotero.html", 

93 context={"active_page": "zotero"}, 

94 ) 

95 

96 

97@router.get("/api/zotero/config") 

98def get_config( 

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

100): 

101 """Return a non-secret summary of the Zotero configuration.""" 

102 password = _db_password(request, username) # gitleaks:allow 

103 if not password: 

104 return _session_expired_response() 

105 try: 

106 cfg = ZoteroSyncService(username, password).get_config() 

107 return { 

108 "success": True, 

109 "enabled": cfg.enabled, 

110 "configured": cfg.is_configured, 

111 "library_type": cfg.library_type, 

112 "library_id": cfg.library_id, 

113 "collection_keys": cfg.collection_keys, 

114 "import_tags": cfg.import_tags, 

115 "import_items_without_pdf": cfg.import_items_without_pdf, 

116 "import_annotations": cfg.import_annotations, 

117 "pdf_storage_mode": cfg.pdf_storage_mode, 

118 "auto_sync_enabled": cfg.auto_sync_enabled, 

119 "sync_interval_minutes": cfg.sync_interval_minutes, 

120 "use_local_api": cfg.use_local_api, 

121 "has_api_key": bool(cfg.api_key), 

122 } 

123 except Exception as e: 

124 return handle_api_error("getting Zotero config", e) 

125 

126 

127@router.post("/api/zotero/test") 

128def test_connection( 

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

130): 

131 """Validate the configured Zotero credentials.""" 

132 password = _db_password(request, username) # gitleaks:allow 

133 if not password: 

134 return _session_expired_response() 

135 try: 

136 result = ZoteroSyncService(username, password).test_connection() 

137 # CWE-209 (CodeQL "Information exposure through an exception"): 

138 # ``result`` can carry an exception-derived ``error`` string, but 

139 # ZoteroSyncService.test_connection() already ran it through 

140 # sanitize_error_for_client() (or it's one of its own static 

141 # messages, e.g. from _resolve_library_id) before returning — 

142 # nothing raw reaches this JSONResponse. 

143 return JSONResponse( 

144 result, status_code=(200 if result.get("success") else 400) 

145 ) 

146 except Exception as e: 

147 return handle_api_error("testing Zotero connection", e) 

148 

149 

150def _not_configured_response() -> JSONResponse: 

151 """400 when Zotero credentials aren't set. 

152 

153 Endpoints that reach out to the Zotero API (rather than just reading local 

154 settings/state) must not surface an opaque 500 when the integration is 

155 simply unconfigured — that is a client-state problem, not a server error. 

156 Mirrors the ``is_configured`` guard in ``sync_now``. 

157 """ 

158 return JSONResponse( 

159 { 

160 "success": False, 

161 "error": "Zotero is not enabled/configured. Set your API key " 

162 "and library ID in Settings first.", 

163 }, 

164 status_code=400, 

165 ) 

166 

167 

168@router.get("/api/zotero/collections") 

169def list_collections( 

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

171): 

172 """List the user's Zotero collections (key + name).""" 

173 password = _db_password(request, username) # gitleaks:allow 

174 if not password: 

175 return _session_expired_response() 

176 try: 

177 service = ZoteroSyncService(username, password) 

178 if not service.get_config().is_configured: 

179 return _not_configured_response() 

180 return {"success": True, "collections": service.list_collections()} 

181 except ZoteroError as e: 

182 return _zotero_error_response(e) 

183 except Exception as e: 

184 return handle_api_error("listing Zotero collections", e) 

185 

186 

187@router.get("/api/zotero/groups") 

188def list_groups( 

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

190): 

191 """List the groups the configured API key can access (id + name).""" 

192 password = _db_password(request, username) # gitleaks:allow 

193 if not password: 

194 return _session_expired_response() 

195 try: 

196 service = ZoteroSyncService(username, password) 

197 if not service.get_config().is_configured: 

198 return _not_configured_response() 

199 return {"success": True, "groups": service.list_groups()} 

200 except ZoteroError as e: 

201 return _zotero_error_response(e) 

202 except Exception as e: 

203 return handle_api_error("listing Zotero groups", e) 

204 

205 

206@router.post("/api/zotero/sync") 

207def sync_now(request: Request, username: Annotated[str, Depends(require_auth)]): 

208 """Trigger a background sync of the configured collections. 

209 

210 Runs in a daemon thread using the current session's password and returns 

211 immediately. Progress is observable via the ``/api/zotero/status`` 

212 endpoint. 

213 """ 

214 password = _db_password(request, username) # gitleaks:allow 

215 if not password: 

216 return _session_expired_response() 

217 

218 try: 

219 cfg = ZoteroSyncService(username, password).get_config() 

220 except Exception as e: 

221 return handle_api_error("starting Zotero sync", e) 

222 if not cfg.is_configured: 

223 return JSONResponse( 

224 { 

225 "success": False, 

226 "error": "Zotero is not enabled/configured. Set your API key " 

227 "and library ID in Settings first.", 

228 }, 

229 status_code=400, 

230 ) 

231 

232 # Fast feedback if a sync (manual OR scheduled) is already in flight. 

233 # The authoritative guard is the per-user lock inside sync_all(), which 

234 # serialises both entry points; this is just a best-effort early exit. 

235 if ZoteroSyncService.is_user_syncing(username): 

236 return { 

237 "success": True, 

238 "message": "A Zotero sync is already running.", 

239 "already_running": True, 

240 } 

241 

242 def _run(): 

243 # thread_cleanup() releases the thread-local DB session + cached 

244 # credentials when this daemon thread exits — without it each manual 

245 # sync would strand a pooled connection and the plaintext password. 

246 with thread_cleanup(): 

247 try: 

248 # Manual syncs re-examine previously skipped items so settings 

249 # changes apply without waiting for the items to change in 

250 # Zotero (scheduled syncs stay cheap). 

251 result = ZoteroSyncService(username, password).sync_all( 

252 reprocess_skipped=True 

253 ) 

254 logger.info(f"Zotero manual sync finished: {result}") 

255 except Exception: 

256 logger.exception("Zotero manual sync failed") 

257 

258 threading.Thread( 

259 target=_run, name=f"zotero-sync-{username}", daemon=True 

260 ).start() 

261 return {"success": True, "message": "Zotero sync started."} 

262 

263 

264@router.get("/api/zotero/status") 

265def get_status( 

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

267): 

268 """Return stored sync state for all configured collections.""" 

269 password = _db_password(request, username) # gitleaks:allow 

270 if not password: 

271 return _session_expired_response() 

272 try: 

273 status = ZoteroSyncService(username, password).get_status() 

274 return { 

275 "success": True, 

276 "collections": status, 

277 # Live counters while a sync runs (None when idle) — lets the 

278 # page render a real progress bar during long imports. 

279 "progress": ZoteroSyncService.get_sync_progress(username), 

280 } 

281 except Exception as e: 

282 return handle_api_error("getting Zotero status", e)