forked from ChelseaKR/queer-the-stacks
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathkosync.py
More file actions
133 lines (103 loc) · 4.9 KB
/
Copy pathkosync.py
File metadata and controls
133 lines (103 loc) · 4.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
"""KOReader cross-device progress, via the KOReader sync protocol.
Two implementations of :class:`ProgressSource`:
* :class:`KosyncClient` — the live HTTP client for a KOReader sync server
(``sync.koreader.rocks`` or a self-hosted one). It sends only the user's own
auth header and reads back the user's *own* progress — a round-trip of the
user's data to the user's server, never a third party. Request-building and
status handling are covered by recorded-cassette contract tests (network
stubbed); real connectivity is integration-verified.
* :class:`FixtureKosync` — an offline, deterministic source built from a dict.
Used by every test and by demo mode, so the whole system runs with no network.
Privacy note: this is the *only* place reading-progress data touches the network,
and it goes to the user's own sync endpoint. ``tests/test_no_egress.py`` asserts
that this module and the catalog client are the only two that can reach the
network at all, and asserts request-by-request what this one actually sends.
Redirects are refused rather than followed (:class:`SyncNotAllowed`). ``requests``
drops an ``Authorization`` header when a redirect changes host, but it does *not*
drop arbitrary headers — so following one would have handed ``x-auth-user``,
``x-auth-key`` (the derived credential) and the document key to whatever host the
sync endpoint named. A sync server has no legitimate reason to redirect, so the
first hop is the last one.
"""
from __future__ import annotations
from typing import Optional, Protocol, runtime_checkable
from urllib.parse import quote
from ingest.models import DeviceProgress
DEFAULT_SYNC_HOST = "https://sync.koreader.rocks"
class SyncNotAllowed(Exception):
"""Raised when a sync request would leave the endpoint the user configured."""
@runtime_checkable
class ProgressSource(Protocol):
"""The cross-device-progress interface the unifier depends on."""
def progress_for(self, document: str) -> Optional[DeviceProgress]: ...
class FixtureKosync:
"""A deterministic, offline :class:`ProgressSource` built from plain data."""
def __init__(self, progress: dict[str, DeviceProgress]) -> None:
self._progress = dict(progress)
def progress_for(self, document: str) -> Optional[DeviceProgress]:
return self._progress.get(document)
def parse_progress(payload: object) -> Optional[DeviceProgress]:
"""Parse a KOReader sync ``/syncs/progress`` response, validating shape.
Returns ``None`` for an empty / "no progress yet" response.
"""
if not isinstance(payload, dict):
raise ValueError("progress payload must be an object")
document = str(payload.get("document", "")).strip()
if not document:
return None
try:
pct = float(payload.get("percentage", 0.0))
except TypeError, ValueError:
pct = 0.0
pct = max(0.0, min(1.0, pct))
try:
ts = int(payload.get("timestamp", 0))
except TypeError, ValueError:
ts = 0
return DeviceProgress(
document=document,
percentage=pct,
device=str(payload.get("device", "unknown")).strip() or "unknown",
timestamp=ts,
)
class KosyncClient:
"""Live KOReader sync client, exercised via recorded-cassette contract tests."""
def __init__(
self,
username: str,
userkey_md5: str,
host: str = DEFAULT_SYNC_HOST,
timeout: int = 15,
) -> None:
if not username or not userkey_md5:
raise ValueError("a kosync username and key are required")
self.username = username
self.userkey_md5 = userkey_md5
self.host = host.rstrip("/")
self.timeout = timeout
def progress_for(self, document: str) -> Optional[DeviceProgress]:
import json
import requests
# One path segment, percent-encoded: a document key is opaque data from
# the KOReader statistics DB, never a URL fragment we let reshape the path.
url = f"{self.host}/syncs/progress/{quote(document, safe='')}"
headers = {
"x-auth-user": self.username,
"x-auth-key": self.userkey_md5,
"accept": "application/vnd.koreader.v1+json",
}
resp = requests.get(url, headers=headers, timeout=self.timeout, allow_redirects=False)
if 300 <= resp.status_code < 400:
raise SyncNotAllowed("kosync redirects are disabled")
if resp.status_code == 404:
return None
resp.raise_for_status()
return parse_progress(json.loads(resp.text))
def userkey(password: str) -> str:
"""Derive the kosync auth key (md5 of the password), as KOReader does.
md5 here is the sync protocol's transport convention, not a security control;
the connection itself is TLS. The user's password is never stored — only this
derived key, held in the environment.
"""
import hashlib
return hashlib.md5(password.encode("utf-8")).hexdigest() # noqa: S324