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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2Routes for the Zotero integration (FastAPI).
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.
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"""
16import threading
18from fastapi import APIRouter, Depends, Request
19from fastapi.responses import JSONResponse
20from loguru import logger
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
36router = APIRouter(prefix="/library", tags=["zotero"])
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
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 )
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 )
69def _session_expired_response() -> JSONResponse:
70 """401 for a logged-in session whose cached DB password has expired.
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 )
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 )
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)
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)
150def _not_configured_response() -> JSONResponse:
151 """400 when Zotero credentials aren't set.
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 )
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)
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)
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.
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()
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 )
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 }
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")
258 threading.Thread(
259 target=_run, name=f"zotero-sync-{username}", daemon=True
260 ).start()
261 return {"success": True, "message": "Zotero sync started."}
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)