Coverage for src/local_deep_research/research_library/zotero/client.py: 83%
363 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1"""
2Thin client for the Zotero Web API (https://www.zotero.org/support/dev/web_api/v3).
4Uses the project's :class:`SafeSession` so every request is SSRF-validated
5and response sizes are bounded, rather than pulling in ``pyzotero`` (which
6would bypass those protections). Only the small slice of the API needed for
7import / incremental sync is implemented:
9- list collections (for the UI picker)
10- list item *versions* for a collection or the whole library
11- fetch full item data for a batch of keys
12- fetch an item's child attachments
13- download an attachment's file bytes
15The sync service uses the ``/items/top?format=versions`` endpoint, which
16returns the FULL ``{itemKey: version}`` map of the current top-level items.
17Reconciling that map against its mapping table lets the sync detect additions,
18metadata changes (version bump) and removals (key gone) — and, unlike a
19``?since=`` delta, removals *from the collection* too. Full item data is then
20fetched only for the keys that actually changed. Pure attachment edits that
21don't bump the parent item's version are not detected by this approach —
22acceptable for v1.
23"""
25import re
26import time
27from dataclasses import dataclass, field
28from html import unescape
29from typing import Dict, List, Optional, Tuple
30from urllib.parse import quote
32import requests
33from loguru import logger
35from ...security import SafeSession
38def _html_to_text(html: str) -> str:
39 """Minimal HTML→text for Zotero note bodies (which are stored as HTML)."""
40 if not html: 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true
41 return ""
42 # Turn block-ish tags into newlines, drop the rest, unescape entities.
43 text = re.sub(r"(?i)<(br|/p|/div|/li|/h[1-6])\s*>", "\n", html)
44 text = re.sub(r"<[^>]+>", "", text)
45 text = unescape(text)
46 # Collapse runs of blank lines / trailing spaces.
47 lines = [ln.strip() for ln in text.splitlines()]
48 return "\n".join(ln for ln in lines if ln).strip()
51ZOTERO_API_BASE = "https://api.zotero.org"
52# Zotero 7 desktop's read-only local API (no API key; can serve local files).
53ZOTERO_LOCAL_API_BASE = "http://localhost:23119/api"
54ZOTERO_API_VERSION = "3"
56# Zotero caps multi-key item requests at 50 keys.
57_MAX_KEYS_PER_REQUEST = 50
58# Pagination page size for list endpoints.
59_PAGE_LIMIT = 100
60# Hard cap on paginated requests per call. Bounds memory and guards against a
61# server that returns full pages forever (ignoring `start`) — without it the
62# while-True paginators below could loop unbounded. 10k pages * 100 = 1M items.
63_MAX_PAGES = 10_000
64# Cap retries on transient errors / explicit Backoff|Retry-After headers.
65_MAX_RETRIES = 4
66_MAX_BACKOFF_SECONDS = 60
69class ZoteroError(Exception):
70 """Base error for Zotero API interactions."""
73class ZoteroAuthError(ZoteroError):
74 """Raised on authentication / authorization failures (HTTP 403)."""
77class ZoteroTransientError(ZoteroError):
78 """Raised when a request fails for a transient reason (network error, or
79 HTTP 429/5xx) after retries are exhausted.
81 Distinct from a plain :class:`ZoteroError` so per-item callers
82 (``download_attachment`` / ``get_attachment_fulltext``) can tell a genuine
83 "this item has no file / no indexed text" (404 → ``None``) apart from a
84 temporary failure. A transient failure must NOT be recorded as "no PDF",
85 otherwise a single rate-limit blip during a bulk sync would permanently
86 downgrade the item to metadata-only until its Zotero version next changes.
87 """
90@dataclass
91class ZoteroAttachment:
92 """A child attachment of a Zotero item."""
94 key: str
95 version: int
96 content_type: Optional[str]
97 link_mode: Optional[str]
98 filename: Optional[str]
99 title: Optional[str]
101 @property
102 def is_downloadable_pdf(self) -> bool:
103 """True if this is a stored PDF we can fetch via the API.
105 ``linked_file`` attachments point at a file on the user's local disk
106 and are not retrievable through the Web API.
107 """
108 return (self.content_type or "").lower() == "application/pdf" and (
109 self.link_mode or ""
110 ) in ("imported_file", "imported_url")
113@dataclass
114class ZoteroItem:
115 """A top-level Zotero item plus its (lazily attached) children."""
117 key: str
118 version: int
119 item_type: str
120 data: Dict = field(default_factory=dict)
121 attachments: List[ZoteroAttachment] = field(default_factory=list)
123 @property
124 def title(self) -> str:
125 return (self.data.get("title") or "").strip()
127 def best_pdf_attachment(self) -> Optional[ZoteroAttachment]:
128 """Return the first downloadable PDF attachment, if any."""
129 for att in self.attachments:
130 if att.is_downloadable_pdf: 130 ↛ 129line 130 didn't jump to line 129 because the condition on line 130 was always true
131 return att
132 return None
134 @property
135 def is_standalone_pdf_attachment(self) -> bool:
136 """A top-level stored PDF with no bibliographic parent.
138 This is what Zotero creates when a PDF is dropped straight into the
139 library without "Retrieve Metadata" / "Create Parent Item" — very
140 common in real libraries. It carries no metadata beyond a filename,
141 but the PDF itself is fully retrievable, so it is importable as its
142 own document. ``linked_file`` attachments are excluded (the Web API
143 cannot serve them).
144 """
145 return (
146 self.item_type == "attachment"
147 and not self.data.get("parentItem")
148 and (self.data.get("contentType") or "").lower()
149 == "application/pdf"
150 and (self.data.get("linkMode") or "").lower()
151 in ("imported_file", "imported_url")
152 )
154 def as_attachment(self) -> ZoteroAttachment:
155 """View this standalone attachment item as its own attachment."""
156 return ZoteroAttachment(
157 key=self.key,
158 version=self.version,
159 content_type=self.data.get("contentType"),
160 link_mode=self.data.get("linkMode"),
161 filename=self.data.get("filename"),
162 title=self.data.get("title") or self.data.get("filename"),
163 )
166def _sleep_for_backoff(headers, attempt: int) -> None:
167 """Honor Zotero ``Backoff`` / ``Retry-After`` headers (capped)."""
168 raw = headers.get("Backoff") or headers.get("Retry-After")
169 delay: float
170 if raw:
171 try:
172 delay = float(raw)
173 except (TypeError, ValueError):
174 delay = 2.0 * attempt
175 else:
176 delay = min(2.0 * attempt, _MAX_BACKOFF_SECONDS)
177 delay = min(max(delay, 0.0), _MAX_BACKOFF_SECONDS)
178 if delay > 0:
179 logger.debug(f"Zotero: backing off {delay:.1f}s (attempt {attempt})")
180 time.sleep(delay)
183class ZoteroClient:
184 """Minimal Zotero Web API client scoped to one library."""
186 def __init__(
187 self,
188 api_key: str,
189 library_type: str,
190 library_id: str,
191 timeout: int = 30,
192 local: bool = False,
193 ):
194 """Initialize the client.
196 Args:
197 api_key: Zotero API key with read access (not needed when local).
198 library_type: ``"user"`` or ``"group"``.
199 library_id: Numeric user or group id.
200 timeout: Per-request timeout in seconds.
201 local: Talk to the desktop Zotero local API on localhost:23119
202 (no API key; can serve local/linked files) instead of the
203 cloud Web API.
204 """
205 if library_type not in ("user", "group"):
206 raise ValueError(f"Invalid library_type: {library_type!r}")
207 if not api_key and not local:
208 raise ValueError("Zotero API key is required")
209 if not str(library_id).strip():
210 raise ValueError("Zotero library_id is required")
212 self.api_key = api_key
213 self.library_type = library_type
214 self.library_id = str(library_id).strip()
215 self.timeout = timeout
216 self.local = local
217 self._base = ZOTERO_LOCAL_API_BASE if local else ZOTERO_API_BASE
218 self._prefix = (
219 f"/{'users' if library_type == 'user' else 'groups'}"
220 f"/{quote(self.library_id, safe='')}"
221 )
222 # The local API is on the loopback interface; allow it explicitly.
223 self.session = SafeSession(allow_localhost=local)
224 headers = {"Zotero-API-Version": ZOTERO_API_VERSION}
225 # The local desktop API needs no key and is reached over plaintext HTTP
226 # on the loopback; never attach the key there (it would put a real
227 # credential on the wire for no benefit).
228 if api_key and not local:
229 headers["Zotero-API-Key"] = self.api_key
230 self.session.headers.update(headers)
232 # -- lifecycle ---------------------------------------------------------
234 def close(self) -> None:
235 if getattr(self, "session", None) is not None:
236 try:
237 self.session.close()
238 except Exception:
239 logger.debug("Error closing Zotero session", exc_info=True)
240 finally:
241 self.session = None # type: ignore[assignment]
243 def __enter__(self) -> "ZoteroClient":
244 return self
246 def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
247 self.close()
248 return False
250 # -- low-level request -------------------------------------------------
252 def _request(
253 self,
254 path: str,
255 params: Optional[Dict] = None,
256 *,
257 stream: bool = False,
258 allow_redirects: bool = True,
259 prefixed: bool = True,
260 ):
261 """Perform a GET against the Zotero API with retry/backoff.
263 ``path`` is appended to the library prefix (e.g. ``/collections``)
264 unless ``prefixed=False`` (for non-library endpoints like
265 ``/keys/current`` or ``/users/{id}/groups``).
266 Returns the ``requests.Response``. Raises :class:`ZoteroAuthError`
267 on 403 and :class:`ZoteroError` on other non-2xx responses.
269 When ``allow_redirects=False``, a 3xx response is returned as-is
270 (not raised) so the caller can follow the redirect itself — used by
271 ``download_attachment`` to avoid forwarding the API key header to the
272 storage host.
273 """
274 prefix = self._prefix if prefixed else ""
275 url = f"{self._base}{prefix}{path}"
276 last_exc: Optional[Exception] = None
277 for attempt in range(1, _MAX_RETRIES + 1): 277 ↛ 352line 277 didn't jump to line 352 because the loop on line 277 didn't complete
278 try:
279 response = self.session.get(
280 url,
281 params=params,
282 timeout=self.timeout,
283 stream=stream,
284 allow_redirects=allow_redirects,
285 )
286 except (
287 requests.exceptions.Timeout,
288 requests.exceptions.ConnectionError,
289 ) as exc:
290 # Transient network errors — worth retrying.
291 last_exc = exc
292 logger.warning(
293 f"Zotero request error (attempt {attempt}): "
294 f"{type(exc).__name__}"
295 )
296 if attempt == _MAX_RETRIES:
297 raise ZoteroTransientError(
298 f"Zotero request failed: {type(exc).__name__}"
299 ) from exc
300 time.sleep(min(2.0 * attempt, _MAX_BACKOFF_SECONDS))
301 continue
302 except Exception as exc:
303 # Non-transient (e.g. SafeSession SSRF/oversize ValueError, or
304 # a malformed-URL error) — do NOT retry; fail fast.
305 raise ZoteroError(
306 f"Zotero request failed: {type(exc).__name__}"
307 ) from exc
309 status = response.status_code
310 if status == 403:
311 raise ZoteroAuthError(
312 "Zotero rejected the API key (HTTP 403). Check the key "
313 "and that it has read access to this library."
314 )
315 if status == 404:
316 raise ZoteroError(
317 "Zotero resource not found (HTTP 404). Check the library "
318 "id / collection key."
319 )
320 if status in (429, 500, 502, 503, 504):
321 logger.warning(
322 f"Zotero transient HTTP {status} (attempt {attempt})"
323 )
324 if attempt == _MAX_RETRIES:
325 raise ZoteroTransientError(
326 f"Zotero request failed after {attempt} attempts "
327 f"(HTTP {status})"
328 )
329 _sleep_for_backoff(response.headers, attempt)
330 continue
331 # When the caller manages redirects itself, hand back the 3xx.
332 if not allow_redirects and 300 <= status < 400:
333 return response
334 if status == 400:
335 # The most common cause by far: a username (or other
336 # non-numeric value) in the library-ID setting.
337 raise ZoteroError(
338 "Zotero rejected the request (HTTP 400). This usually "
339 "means the library ID is not the NUMERIC userID / group "
340 "ID from zotero.org/settings/keys (e.g. a username was "
341 "entered instead)."
342 )
343 if not (200 <= status < 300): 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true
344 raise ZoteroError(f"Zotero request failed (HTTP {status})")
346 # Honor a Backoff header even on success (server under load).
347 if response.headers.get("Backoff"): 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true
348 _sleep_for_backoff(response.headers, attempt)
349 return response
351 # Unreachable, but keeps type-checkers happy.
352 raise ZoteroError(
353 f"Zotero request failed: {type(last_exc).__name__ if last_exc else 'unknown'}"
354 )
356 @staticmethod
357 def _library_version(response) -> int:
358 try:
359 return int(response.headers.get("Last-Modified-Version", "0"))
360 except (TypeError, ValueError):
361 return 0
363 # -- public API --------------------------------------------------------
365 def test_connection(self) -> int:
366 """Validate credentials by reading the library version.
368 Returns the current library version. Raises on failure.
369 """
370 response = self._request(
371 "/items/top", params={"format": "versions", "limit": 1}
372 )
373 return self._library_version(response)
375 def get_current_key_info(self) -> Dict:
376 """Return ``/keys/current``: the key's owner ``userID`` + access map.
378 Cloud API only (the local API has no key); returns ``{}`` in local
379 mode or when the response isn't JSON. Used to resolve the numeric
380 userID when the user pasted a username into the library-ID field.
381 """
382 if self.local:
383 return {}
384 response = self._request("/keys/current", prefixed=False)
385 try:
386 return response.json() or {}
387 except ValueError:
388 return {}
390 def get_collections(self) -> List[Dict]:
391 """List all collections in the library (paginated).
393 Returns a list of ``{"key", "name", "parentCollection"}`` dicts.
394 """
395 collections: List[Dict] = []
396 start = 0
397 pages = 0
398 while True:
399 pages += 1
400 if pages > _MAX_PAGES:
401 raise ZoteroError(
402 f"Zotero: pagination exceeded safety cap ({_MAX_PAGES} "
403 "pages) — aborting to avoid a runaway loop"
404 )
405 response = self._request(
406 "/collections",
407 params={
408 "format": "json",
409 "limit": _PAGE_LIMIT,
410 "start": start,
411 },
412 )
413 page = response.json()
414 if not page:
415 break
416 for entry in page:
417 data = entry.get("data", {})
418 collections.append(
419 {
420 "key": entry.get("key"),
421 "name": data.get("name", ""),
422 "parentCollection": data.get("parentCollection", False),
423 }
424 )
425 if len(page) < _PAGE_LIMIT:
426 break
427 start += _PAGE_LIMIT
428 return collections
430 def get_groups(self) -> List[Dict]:
431 """List the groups this API key can access (id + name + type).
433 Resolves the key's owner via ``/keys/current`` then reads
434 ``/users/{userID}/groups`` — both non-library-scoped endpoints. Lets
435 the UI offer a group picker instead of making the user hunt for a
436 numeric group id on zotero.org.
437 """
438 if self.local:
439 # The local API has no key endpoint / auth; group discovery isn't
440 # applicable.
441 return []
442 info = self._request("/keys/current", prefixed=False).json()
443 user_id = info.get("userID") if isinstance(info, dict) else None
444 if not user_id:
445 return []
447 groups: List[Dict] = []
448 start = 0
449 pages = 0
450 while True:
451 pages += 1
452 if pages > _MAX_PAGES: 452 ↛ 453line 452 didn't jump to line 453 because the condition on line 452 was never true
453 raise ZoteroError(
454 f"Zotero: pagination exceeded safety cap ({_MAX_PAGES} "
455 "pages) — aborting to avoid a runaway loop"
456 )
457 response = self._request(
458 f"/users/{quote(str(user_id), safe='')}/groups",
459 params={
460 "format": "json",
461 "limit": _PAGE_LIMIT,
462 "start": start,
463 },
464 prefixed=False,
465 )
466 page = response.json()
467 if not page: 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true
468 break
469 for entry in page:
470 data = entry.get("data", {})
471 groups.append(
472 {
473 "id": str(entry.get("id") or data.get("id") or ""),
474 "name": data.get("name", ""),
475 "type": data.get("type", ""),
476 }
477 )
478 if len(page) < _PAGE_LIMIT: 478 ↛ 480line 478 didn't jump to line 480 because the condition on line 478 was always true
479 break
480 start += _PAGE_LIMIT
481 return groups
483 def get_collection_name(self, collection_key: str) -> Optional[str]:
484 """Return a single collection's display name, or None if missing."""
485 try:
486 response = self._request(
487 f"/collections/{quote(collection_key, safe='')}",
488 params={"format": "json"},
489 )
490 except ZoteroError:
491 return None
492 data = response.json().get("data", {})
493 return data.get("name")
495 def get_top_item_versions(
496 self,
497 collection_key: Optional[str] = None, # gitleaks:allow
498 ) -> Tuple[Dict[str, int], int]:
499 """Get ``{itemKey: version}`` for current top-level items.
501 Scoped to ``collection_key`` when given, else the whole library.
502 Also returns the library version (from ``Last-Modified-Version``).
504 This is the FULL current set (not a ``?since=`` delta) on purpose: the
505 sync service reconciles it against its mapping table to detect both
506 deletions AND items removed from the collection (which a library-level
507 ``/deleted`` feed would miss). To make that reconciliation safe, this
508 method raises if the paginated result is INCOMPLETE relative to the
509 ``Total-Results`` header — otherwise a truncated response would look
510 like a batch of deletions to the caller.
511 """
512 if collection_key:
513 path = f"/collections/{quote(collection_key, safe='')}/items/top"
514 else:
515 path = "/items/top"
517 versions: Dict[str, int] = {}
518 library_version = 0
519 start = 0
520 pages = 0
521 expected_total: Optional[int] = None
522 while True:
523 pages += 1
524 if pages > _MAX_PAGES: 524 ↛ 525line 524 didn't jump to line 525 because the condition on line 524 was never true
525 raise ZoteroError(
526 f"Zotero: pagination exceeded safety cap ({_MAX_PAGES} "
527 "pages) — aborting to avoid a runaway loop"
528 )
529 response = self._request(
530 path,
531 params={
532 "format": "versions",
533 "limit": _PAGE_LIMIT,
534 "start": start,
535 },
536 )
537 library_version = max(
538 library_version, self._library_version(response)
539 )
540 # Total-Results is the authoritative item count; capture it once.
541 if expected_total is None: 541 ↛ 547line 541 didn't jump to line 547 because the condition on line 541 was always true
542 total = response.headers.get("Total-Results")
543 try:
544 expected_total = int(total) if total is not None else None
545 except (TypeError, ValueError):
546 expected_total = None
547 page = response.json() or {}
548 # ``format=versions`` returns a JSON object {key: version}.
549 for key, version in page.items():
550 try:
551 versions[key] = int(version)
552 except (TypeError, ValueError):
553 versions[key] = 0
554 # A short page is the authoritative end-of-data signal for both
555 # list and object (``format=versions``) responses.
556 if len(page) < _PAGE_LIMIT:
557 break
558 # When Total-Results is known, stop one round-trip early. Do NOT
559 # fall back to len(versions) when the header is absent — that makes
560 # the check a tautology and would stop after a single full page.
561 if expected_total is not None and len(versions) >= expected_total: 561 ↛ 562line 561 didn't jump to line 562 because the condition on line 561 was never true
562 break
563 start += _PAGE_LIMIT
565 # Completeness guard: if the server told us how many items exist and we
566 # ended up with fewer (a truncated / short-circuited page), refuse to
567 # return a partial set — the caller treats "absent" keys as deletions,
568 # so a partial map would silently delete real documents.
569 if expected_total is not None and len(versions) < expected_total:
570 raise ZoteroError(
571 "Zotero returned an incomplete version map "
572 f"({len(versions)}/{expected_total} items); aborting so "
573 "missing items are not mistaken for deletions"
574 )
575 return versions, library_version
577 def get_items(self, item_keys: List[str]) -> List[ZoteroItem]:
578 """Fetch full data for the given top-level item keys (batched)."""
579 items: List[ZoteroItem] = []
580 for batch_start in range(0, len(item_keys), _MAX_KEYS_PER_REQUEST):
581 batch = item_keys[batch_start : batch_start + _MAX_KEYS_PER_REQUEST]
582 if not batch: 582 ↛ 583line 582 didn't jump to line 583 because the condition on line 582 was never true
583 continue
584 response = self._request(
585 "/items",
586 params={
587 "format": "json",
588 "itemKey": ",".join(batch),
589 "limit": _MAX_KEYS_PER_REQUEST,
590 },
591 )
592 for entry in response.json() or []:
593 data = entry.get("data", {})
594 items.append(
595 ZoteroItem(
596 key=entry.get("key"),
597 version=int(entry.get("version", 0) or 0),
598 item_type=data.get("itemType", ""),
599 data=data,
600 )
601 )
602 return items
604 def _get_children_raw(self, item_key: str) -> List[Dict]:
605 """Fetch all raw child entries of an item/attachment (paginated)."""
606 entries: List[Dict] = []
607 start = 0
608 pages = 0
609 while True:
610 pages += 1
611 if pages > _MAX_PAGES: 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true
612 raise ZoteroError(
613 f"Zotero: pagination exceeded safety cap ({_MAX_PAGES} "
614 "pages) — aborting to avoid a runaway loop"
615 )
616 response = self._request(
617 f"/items/{quote(item_key, safe='')}/children",
618 params={
619 "format": "json",
620 "limit": _PAGE_LIMIT,
621 "start": start,
622 },
623 )
624 page = response.json() or []
625 entries.extend(page)
626 if len(page) < _PAGE_LIMIT: 626 ↛ 628line 626 didn't jump to line 628 because the condition on line 626 was always true
627 break
628 start += _PAGE_LIMIT
629 return entries
631 def get_children(self, item_key: str) -> List[ZoteroAttachment]:
632 """Fetch an item's child attachments."""
633 attachments: List[ZoteroAttachment] = []
634 for entry in self._get_children_raw(item_key):
635 data = entry.get("data", {})
636 if data.get("itemType") != "attachment":
637 continue
638 attachments.append(
639 ZoteroAttachment(
640 key=entry.get("key"),
641 version=int(entry.get("version", 0) or 0),
642 content_type=data.get("contentType"),
643 link_mode=data.get("linkMode"),
644 filename=data.get("filename"),
645 title=data.get("title"),
646 )
647 )
648 return attachments
650 def get_notes(self, item_key: str) -> List[str]:
651 """Return the plain text of an item's child notes (HTML stripped)."""
652 notes: List[str] = []
653 for entry in self._get_children_raw(item_key):
654 data = entry.get("data", {})
655 if data.get("itemType") != "note":
656 continue
657 text = _html_to_text(data.get("note") or "")
658 if text: 658 ↛ 653line 658 didn't jump to line 653 because the condition on line 658 was always true
659 notes.append(text)
660 return notes
662 def get_annotations(self, attachment_key: str) -> List[str]:
663 """Return an attachment's annotations as text (highlight + comment).
665 Image/area annotations carry no text (only position), so they're
666 skipped.
667 """
668 out: List[str] = []
669 for entry in self._get_children_raw(attachment_key):
670 data = entry.get("data", {})
671 if data.get("itemType") != "annotation": 671 ↛ 672line 671 didn't jump to line 672 because the condition on line 671 was never true
672 continue
673 parts = []
674 highlighted = (data.get("annotationText") or "").strip()
675 comment = (data.get("annotationComment") or "").strip()
676 if highlighted:
677 parts.append(highlighted)
678 if comment:
679 parts.append(f"[note] {comment}")
680 if parts:
681 out.append(" — ".join(parts))
682 return out
684 def download_attachment(self, attachment_key: str) -> Optional[bytes]:
685 """Download an attachment's file bytes, or None on failure.
687 Zotero's ``/items/{key}/file`` endpoint typically answers with a 302
688 to the actual storage location (Zotero's S3, or a WebDAV/external host
689 for self-hosted storage). We must NOT follow that redirect with the
690 authenticated session: ``requests`` only strips the standard
691 ``Authorization`` header on a cross-host redirect, so our custom
692 ``Zotero-API-Key`` header would otherwise be forwarded verbatim to the
693 storage host — a credential leak. Instead we fetch with
694 ``allow_redirects=False`` and follow the ``Location`` ourselves using a
695 fresh, credential-free :class:`SafeSession` (which still SSRF-validates
696 the target).
697 """
698 try:
699 response = self._request(
700 f"/items/{quote(attachment_key, safe='')}/file",
701 allow_redirects=False,
702 )
703 except ZoteroTransientError:
704 # Temporary (429/5xx/network): let the caller skip this item and
705 # retry next sync rather than silently import it metadata-only.
706 raise
707 except ZoteroError:
708 logger.warning(
709 f"Zotero: could not download attachment {attachment_key}"
710 )
711 return None
713 if 300 <= response.status_code < 400:
714 location = (response.headers.get("Location") or "").strip()
715 if not location: 715 ↛ 716line 715 didn't jump to line 716 because the condition on line 715 was never true
716 logger.warning(
717 f"Zotero: attachment {attachment_key} redirect had no "
718 "Location header"
719 )
720 return None
721 if location.lower().startswith("file:"):
722 # The local API redirects to a file:// path on disk. Only honor
723 # it in local mode (never follow a file:// redirect from the
724 # cloud API).
725 if not self.local:
726 logger.warning(
727 "Zotero: ignoring file:// redirect from the cloud API"
728 )
729 return None
730 return self._read_local_file(location)
731 return self._fetch_file_unauthenticated(location)
733 # Some configurations serve the file directly (200, no redirect).
734 content = response.content
735 return content or None
737 @staticmethod
738 def _read_local_file(file_url: str) -> Optional[bytes]:
739 """Read bytes from a ``file://`` URL (local API attachment on disk).
741 Only PDF content is honored: the redirect target comes from whatever
742 answers on the local API port, so restricting reads to files with a
743 PDF magic number keeps this from acting as a generic local-file
744 disclosure primitive (defense in depth — the sync layer discards
745 non-PDF bodies anyway).
746 """
747 from urllib.parse import urlparse
748 from urllib.request import url2pathname
750 try:
751 path = url2pathname(urlparse(file_url).path)
752 with open(path, "rb") as fh:
753 data = fh.read()
754 except OSError:
755 logger.warning("Zotero: could not read local attachment file")
756 return None
757 if not data: 757 ↛ 758line 757 didn't jump to line 758 because the condition on line 757 was never true
758 return None
759 if b"%PDF" not in data[:1024]:
760 logger.warning("Zotero: local attachment is not a PDF; ignoring it")
761 return None
762 return data
764 def _fetch_file_unauthenticated(self, url: str) -> Optional[bytes]:
765 """Fetch a storage URL with a fresh session that carries no Zotero
766 credentials. SafeSession SSRF-validates the URL (and any further
767 redirects).
769 Raises :class:`ZoteroTransientError` on a transient failure (network
770 error or HTTP 429/5xx from the storage host) so the caller retries next
771 sync rather than importing the item metadata-only; returns None only
772 for a genuine "no file here" (other non-2xx / empty body).
773 """
774 try:
775 with SafeSession() as session:
776 response = session.get(url, timeout=self.timeout)
777 except (
778 requests.exceptions.Timeout,
779 requests.exceptions.ConnectionError,
780 ) as exc:
781 raise ZoteroTransientError(
782 f"Zotero storage fetch failed: {type(exc).__name__}"
783 ) from exc
784 except Exception as exc:
785 logger.warning(
786 f"Zotero: storage download failed ({type(exc).__name__})"
787 )
788 return None
789 if response.status_code in (429, 500, 502, 503, 504):
790 raise ZoteroTransientError(
791 f"Zotero storage returned HTTP {response.status_code}"
792 )
793 if not (200 <= response.status_code < 300): 793 ↛ 794line 793 didn't jump to line 794 because the condition on line 793 was never true
794 logger.warning(
795 f"Zotero: storage download returned HTTP {response.status_code}"
796 )
797 return None
798 content = response.content
799 return content or None
801 def get_attachment_fulltext(self, attachment_key: str) -> Optional[str]:
802 """Return Zotero's already-extracted full text for an attachment.
804 Uses ``GET /items/{key}/fulltext``; returns None when Zotero has no
805 indexed text for it (HTTP 404) or the content is empty. Lets the sync
806 skip downloading + re-parsing the PDF in text-only mode.
807 """
808 try:
809 response = self._request(
810 f"/items/{quote(attachment_key, safe='')}/fulltext"
811 )
812 except ZoteroTransientError:
813 # Temporary failure — don't conflate with "no indexed text"; let
814 # the caller retry next sync.
815 raise
816 except ZoteroError:
817 return None
818 try:
819 data = response.json()
820 except Exception:
821 return None
822 if not isinstance(data, dict): 822 ↛ 823line 822 didn't jump to line 823 because the condition on line 822 was never true
823 return None
824 content = data.get("content")
825 return content or None