newapi-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- newapi/__init__.py +75 -0
- newapi/_client.py +620 -0
- newapi/_crypto.py +100 -0
- newapi/_exceptions.py +76 -0
- newapi/_models.py +244 -0
- newapi/_resources.py +775 -0
- newapi/py.typed +0 -0
- newapi_python-0.1.0.dist-info/METADATA +267 -0
- newapi_python-0.1.0.dist-info/RECORD +11 -0
- newapi_python-0.1.0.dist-info/WHEEL +4 -0
- newapi_python-0.1.0.dist-info/licenses/LICENSE +21 -0
newapi/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from ._client import NewAPI
|
|
2
|
+
from ._exceptions import (
|
|
3
|
+
APIError,
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
ConfigurationError,
|
|
6
|
+
ConflictError,
|
|
7
|
+
NewAPIError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PermissionDeniedError,
|
|
10
|
+
ProtocolError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
TransportError,
|
|
13
|
+
TwoFactorRequired,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
from ._models import (
|
|
17
|
+
Announcement,
|
|
18
|
+
APIKey,
|
|
19
|
+
Balance,
|
|
20
|
+
CheckinResult,
|
|
21
|
+
CheckinStatus,
|
|
22
|
+
Group,
|
|
23
|
+
Log,
|
|
24
|
+
LoginSession,
|
|
25
|
+
Page,
|
|
26
|
+
PaymentConfig,
|
|
27
|
+
PaymentLink,
|
|
28
|
+
Redemption,
|
|
29
|
+
Resource,
|
|
30
|
+
SessionTokens,
|
|
31
|
+
Subscription,
|
|
32
|
+
SubscriptionPlan,
|
|
33
|
+
Token,
|
|
34
|
+
TopUpOrder,
|
|
35
|
+
UsageRecord,
|
|
36
|
+
User,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"APIError",
|
|
41
|
+
"APIKey",
|
|
42
|
+
"Announcement",
|
|
43
|
+
"AuthenticationError",
|
|
44
|
+
"Balance",
|
|
45
|
+
"CheckinResult",
|
|
46
|
+
"CheckinStatus",
|
|
47
|
+
"ConfigurationError",
|
|
48
|
+
"ConflictError",
|
|
49
|
+
"Group",
|
|
50
|
+
"Log",
|
|
51
|
+
"LoginSession",
|
|
52
|
+
"NewAPI",
|
|
53
|
+
"NewAPIError",
|
|
54
|
+
"NotFoundError",
|
|
55
|
+
"Page",
|
|
56
|
+
"PaymentConfig",
|
|
57
|
+
"PaymentLink",
|
|
58
|
+
"PermissionDeniedError",
|
|
59
|
+
"ProtocolError",
|
|
60
|
+
"RateLimitError",
|
|
61
|
+
"Redemption",
|
|
62
|
+
"Resource",
|
|
63
|
+
"SessionTokens",
|
|
64
|
+
"Subscription",
|
|
65
|
+
"SubscriptionPlan",
|
|
66
|
+
"Token",
|
|
67
|
+
"TopUpOrder",
|
|
68
|
+
"TransportError",
|
|
69
|
+
"TwoFactorRequired",
|
|
70
|
+
"UsageRecord",
|
|
71
|
+
"User",
|
|
72
|
+
"ValidationError",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
__version__ = "0.1.0"
|
newapi/_client.py
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
from urllib.parse import unquote, urlsplit, urlunsplit
|
|
8
|
+
|
|
9
|
+
from curl_cffi import requests as curl_requests
|
|
10
|
+
|
|
11
|
+
from ._crypto import rsa_oaep_sha256_encrypt
|
|
12
|
+
from ._exceptions import (
|
|
13
|
+
APIError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
PermissionDeniedError,
|
|
19
|
+
ProtocolError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
TransportError,
|
|
22
|
+
TwoFactorRequired,
|
|
23
|
+
ValidationError,
|
|
24
|
+
)
|
|
25
|
+
from ._models import Balance, Resource, SessionTokens, User
|
|
26
|
+
from ._resources import (
|
|
27
|
+
AccountResource,
|
|
28
|
+
DashboardResource,
|
|
29
|
+
GroupsResource,
|
|
30
|
+
LogsResource,
|
|
31
|
+
PaymentResource,
|
|
32
|
+
SubscriptionResource,
|
|
33
|
+
TokensResource,
|
|
34
|
+
TopUpResource,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_ERROR_TYPES: dict[int, type[APIError]] = {
|
|
38
|
+
400: ValidationError,
|
|
39
|
+
401: AuthenticationError,
|
|
40
|
+
403: PermissionDeniedError,
|
|
41
|
+
404: NotFoundError,
|
|
42
|
+
409: ConflictError,
|
|
43
|
+
422: ValidationError,
|
|
44
|
+
429: RateLimitError,
|
|
45
|
+
}
|
|
46
|
+
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
47
|
+
_REFRESH_COOKIE = "new_api_refresh"
|
|
48
|
+
_REFRESH_COOKIE_PATTERN = re.compile(rf"(?:^|[;,\s]){_REFRESH_COOKIE}=([^;\s]+)")
|
|
49
|
+
_DEFAULT_QUOTA_PER_UNIT = 500000.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _api_base_url(value: str, allow_insecure: bool) -> str:
|
|
53
|
+
candidate = value.strip()
|
|
54
|
+
if not candidate:
|
|
55
|
+
raise ConfigurationError("base_url must not be empty")
|
|
56
|
+
if "://" not in candidate:
|
|
57
|
+
candidate = f"https://{candidate}"
|
|
58
|
+
parsed = urlsplit(candidate)
|
|
59
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
60
|
+
raise ConfigurationError("base_url must be an HTTP or HTTPS instance URL")
|
|
61
|
+
if parsed.username or parsed.password:
|
|
62
|
+
raise ConfigurationError("base_url must not contain credentials")
|
|
63
|
+
if parsed.query or parsed.fragment:
|
|
64
|
+
raise ConfigurationError("base_url must not contain a query string or fragment")
|
|
65
|
+
local = parsed.hostname in _LOCAL_HOSTS or parsed.hostname.endswith(".localhost")
|
|
66
|
+
if parsed.scheme == "http" and not local and not allow_insecure:
|
|
67
|
+
raise ConfigurationError(
|
|
68
|
+
"remote HTTP would expose credentials; use HTTPS or set allow_insecure=True"
|
|
69
|
+
)
|
|
70
|
+
path = parsed.path.rstrip("/")
|
|
71
|
+
if not path.endswith("/api"):
|
|
72
|
+
path = f"{path}/api"
|
|
73
|
+
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _request_path(value: str) -> str:
|
|
77
|
+
parsed = urlsplit(value)
|
|
78
|
+
if parsed.scheme or parsed.netloc or parsed.fragment:
|
|
79
|
+
raise ConfigurationError("request path must be relative to the configured instance")
|
|
80
|
+
if parsed.query:
|
|
81
|
+
raise ConfigurationError("request path must not contain a query string; use params instead")
|
|
82
|
+
path = unquote(parsed.path).lstrip("/")
|
|
83
|
+
if path == "api":
|
|
84
|
+
path = ""
|
|
85
|
+
elif path.startswith("api/"):
|
|
86
|
+
path = path[len("api/") :]
|
|
87
|
+
if any(segment in {"..", "."} for segment in path.split("/")):
|
|
88
|
+
raise ConfigurationError("request path must not contain parent traversal")
|
|
89
|
+
if not path:
|
|
90
|
+
raise ConfigurationError("request path must identify an endpoint")
|
|
91
|
+
return path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _query_value(value: Any) -> Any:
|
|
95
|
+
if isinstance(value, bool):
|
|
96
|
+
return "true" if value else "false"
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _query(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
101
|
+
if params is None:
|
|
102
|
+
return None
|
|
103
|
+
return {
|
|
104
|
+
str(key): _query_value(value)
|
|
105
|
+
for key, value in params.items()
|
|
106
|
+
if value is not None and value != ""
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _success(response: Any) -> bool:
|
|
111
|
+
return 200 <= int(response.status_code) < 300
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _retry_after(response: Any) -> float | None:
|
|
115
|
+
value = response.headers.get("Retry-After")
|
|
116
|
+
if value is None:
|
|
117
|
+
return None
|
|
118
|
+
try:
|
|
119
|
+
return max(0.0, float(value))
|
|
120
|
+
except ValueError:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _set_cookie_values(response: Any) -> list[str]:
|
|
125
|
+
headers = response.headers
|
|
126
|
+
get_list = getattr(headers, "get_list", None)
|
|
127
|
+
if callable(get_list):
|
|
128
|
+
return [str(value) for value in get_list("set-cookie")]
|
|
129
|
+
raw = headers.get("set-cookie")
|
|
130
|
+
if raw is None:
|
|
131
|
+
return []
|
|
132
|
+
if isinstance(raw, str):
|
|
133
|
+
return [raw]
|
|
134
|
+
return [str(value) for value in raw]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class NewAPI:
|
|
138
|
+
"""An authenticated user session for one new-api instance."""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
base_url: str,
|
|
143
|
+
*,
|
|
144
|
+
access_token: str | None = None,
|
|
145
|
+
refresh_token: str | None = None,
|
|
146
|
+
expires_at: float | None = None,
|
|
147
|
+
session_id: str | None = None,
|
|
148
|
+
timeout: float | tuple[float, float] = 30.0,
|
|
149
|
+
language: str = "en",
|
|
150
|
+
allow_insecure: bool = False,
|
|
151
|
+
impersonate: str | None = "chrome",
|
|
152
|
+
session: Any | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
self.base_url = _api_base_url(base_url, allow_insecure)
|
|
155
|
+
self.language = language
|
|
156
|
+
self._tokens = SessionTokens(
|
|
157
|
+
access_token=access_token,
|
|
158
|
+
refresh_token=refresh_token,
|
|
159
|
+
expires_at=expires_at,
|
|
160
|
+
session_id=session_id,
|
|
161
|
+
)
|
|
162
|
+
self._refresh_lock = threading.RLock()
|
|
163
|
+
self._pending_2fa_token: str | None = None
|
|
164
|
+
self.timeout = timeout
|
|
165
|
+
self.impersonate = impersonate
|
|
166
|
+
self._quota_per_unit: float | None = None
|
|
167
|
+
self._owns_http_client = session is None
|
|
168
|
+
if session is None:
|
|
169
|
+
if impersonate:
|
|
170
|
+
self._http: Any = cast(Any, curl_requests.Session)(impersonate=impersonate)
|
|
171
|
+
else:
|
|
172
|
+
self._http = cast(Any, curl_requests.Session)()
|
|
173
|
+
else:
|
|
174
|
+
self._http = session
|
|
175
|
+
self.account = AccountResource(self)
|
|
176
|
+
self.tokens = TokensResource(self)
|
|
177
|
+
self.keys = self.tokens
|
|
178
|
+
self.logs = LogsResource(self)
|
|
179
|
+
self.usage = self.logs
|
|
180
|
+
self.history = self.logs
|
|
181
|
+
self.groups = GroupsResource(self)
|
|
182
|
+
self.dashboard = DashboardResource(self)
|
|
183
|
+
self.topup = TopUpResource(self)
|
|
184
|
+
self.payment = PaymentResource(self)
|
|
185
|
+
self.subscriptions = SubscriptionResource(self)
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def session(self) -> SessionTokens:
|
|
189
|
+
return self._tokens
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def is_authenticated(self) -> bool:
|
|
193
|
+
return self._tokens.authenticated
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def quota_per_unit(self) -> float | None:
|
|
197
|
+
return self._quota_per_unit
|
|
198
|
+
|
|
199
|
+
def login(
|
|
200
|
+
self,
|
|
201
|
+
username: str,
|
|
202
|
+
password: str,
|
|
203
|
+
*,
|
|
204
|
+
turnstile_token: str | None = None,
|
|
205
|
+
encrypt_password: bool = True,
|
|
206
|
+
) -> User:
|
|
207
|
+
if not username.strip() or not password:
|
|
208
|
+
raise ValueError("username and password are required")
|
|
209
|
+
payload: dict[str, Any] = {"username": username}
|
|
210
|
+
key = self._password_encryption_key() if encrypt_password else None
|
|
211
|
+
if key is not None:
|
|
212
|
+
payload["password_encrypted"] = rsa_oaep_sha256_encrypt(
|
|
213
|
+
password.encode(), key["public_key"]
|
|
214
|
+
)
|
|
215
|
+
payload["encryption_key_id"] = key["kid"]
|
|
216
|
+
else:
|
|
217
|
+
payload["password"] = password
|
|
218
|
+
params = {"turnstile": turnstile_token} if turnstile_token else None
|
|
219
|
+
data, response = self._request_with_response(
|
|
220
|
+
"POST",
|
|
221
|
+
"user/login",
|
|
222
|
+
params=params,
|
|
223
|
+
json=payload,
|
|
224
|
+
authenticated=False,
|
|
225
|
+
allow_refresh=False,
|
|
226
|
+
)
|
|
227
|
+
auth = self._auth_mapping(data, "login")
|
|
228
|
+
if auth.get("require_2fa") is True:
|
|
229
|
+
flow_token = auth.get("flow_token")
|
|
230
|
+
if not isinstance(flow_token, str) or not flow_token:
|
|
231
|
+
raise ProtocolError("2FA login response did not contain a flow token")
|
|
232
|
+
self._pending_2fa_token = flow_token
|
|
233
|
+
expires_at = auth.get("expires_at")
|
|
234
|
+
raise TwoFactorRequired(
|
|
235
|
+
flow_token,
|
|
236
|
+
float(expires_at) if isinstance(expires_at, (int, float)) else None,
|
|
237
|
+
)
|
|
238
|
+
return self._accept_auth(auth, response)
|
|
239
|
+
|
|
240
|
+
def complete_2fa(self, code: str, *, flow_token: str | None = None) -> User:
|
|
241
|
+
token = flow_token or self._pending_2fa_token
|
|
242
|
+
if not token:
|
|
243
|
+
raise ValueError("flow_token is required when no 2FA login is pending")
|
|
244
|
+
if not code.strip():
|
|
245
|
+
raise ValueError("code must not be empty")
|
|
246
|
+
data, response = self._request_with_response(
|
|
247
|
+
"POST",
|
|
248
|
+
"user/login/2fa",
|
|
249
|
+
json={"code": code, "flow_token": token},
|
|
250
|
+
authenticated=False,
|
|
251
|
+
allow_refresh=False,
|
|
252
|
+
)
|
|
253
|
+
user = self._accept_auth(self._auth_mapping(data, "2FA login"), response)
|
|
254
|
+
self._pending_2fa_token = None
|
|
255
|
+
return user
|
|
256
|
+
|
|
257
|
+
def refresh(self) -> SessionTokens:
|
|
258
|
+
with self._refresh_lock:
|
|
259
|
+
refresh_token = self._tokens.refresh_token
|
|
260
|
+
if not refresh_token:
|
|
261
|
+
raise AuthenticationError(
|
|
262
|
+
"No refresh token is available",
|
|
263
|
+
status_code=401,
|
|
264
|
+
code="NO_REFRESH_TOKEN",
|
|
265
|
+
)
|
|
266
|
+
try:
|
|
267
|
+
data, response = self._request_with_response(
|
|
268
|
+
"POST",
|
|
269
|
+
"user/auth/refresh",
|
|
270
|
+
refresh_cookie=refresh_token,
|
|
271
|
+
authenticated=False,
|
|
272
|
+
allow_refresh=False,
|
|
273
|
+
)
|
|
274
|
+
auth = self._auth_mapping(data, "token refresh")
|
|
275
|
+
access_token = auth.get("access_token")
|
|
276
|
+
if not isinstance(access_token, str) or not access_token:
|
|
277
|
+
raise ProtocolError("token refresh did not return an access token")
|
|
278
|
+
rotated = self._harvest_refresh_token(response) or refresh_token
|
|
279
|
+
self._tokens = self._tokens_from_auth(auth, rotated)
|
|
280
|
+
return self._tokens
|
|
281
|
+
except APIError as error:
|
|
282
|
+
if 400 <= error.status_code < 500:
|
|
283
|
+
self.clear_auth()
|
|
284
|
+
raise
|
|
285
|
+
|
|
286
|
+
def logout(self) -> None:
|
|
287
|
+
refresh_token = self._tokens.refresh_token
|
|
288
|
+
headers: dict[str, str] = {"Origin": self._origin()}
|
|
289
|
+
if self._tokens.access_token:
|
|
290
|
+
headers["Authorization"] = f"{self._tokens.token_type} {self._tokens.access_token}"
|
|
291
|
+
try:
|
|
292
|
+
self._request(
|
|
293
|
+
"POST",
|
|
294
|
+
"user/auth/logout",
|
|
295
|
+
refresh_cookie=refresh_token,
|
|
296
|
+
headers=headers,
|
|
297
|
+
authenticated=False,
|
|
298
|
+
allow_refresh=False,
|
|
299
|
+
)
|
|
300
|
+
finally:
|
|
301
|
+
self.clear_auth()
|
|
302
|
+
|
|
303
|
+
def clear_auth(self) -> None:
|
|
304
|
+
self._tokens = SessionTokens()
|
|
305
|
+
self._pending_2fa_token = None
|
|
306
|
+
|
|
307
|
+
def me(self) -> User:
|
|
308
|
+
return self.account.profile()
|
|
309
|
+
|
|
310
|
+
def balance(self) -> Balance:
|
|
311
|
+
profile = self.account.profile()
|
|
312
|
+
quota_per_unit = self._resolve_quota_per_unit()
|
|
313
|
+
try:
|
|
314
|
+
return Balance(
|
|
315
|
+
quota=int(profile.get("quota", 0)),
|
|
316
|
+
used_quota=int(profile.get("used_quota", 0)),
|
|
317
|
+
aff_quota=int(profile.get("aff_quota", 0)),
|
|
318
|
+
quota_per_unit=quota_per_unit,
|
|
319
|
+
)
|
|
320
|
+
except (TypeError, ValueError) as error:
|
|
321
|
+
raise ProtocolError("profile response did not contain valid quota values") from error
|
|
322
|
+
|
|
323
|
+
def status(self) -> Resource:
|
|
324
|
+
data = self._request(
|
|
325
|
+
"GET",
|
|
326
|
+
"status",
|
|
327
|
+
authenticated=False,
|
|
328
|
+
allow_refresh=False,
|
|
329
|
+
)
|
|
330
|
+
if not isinstance(data, Mapping):
|
|
331
|
+
raise ProtocolError("status returned a non-object response")
|
|
332
|
+
quota_per_unit = data.get("quota_per_unit")
|
|
333
|
+
if isinstance(quota_per_unit, (int, float)) and quota_per_unit > 0:
|
|
334
|
+
self._quota_per_unit = float(quota_per_unit)
|
|
335
|
+
return Resource(data)
|
|
336
|
+
|
|
337
|
+
def notice(self) -> Any:
|
|
338
|
+
return self._request("GET", "notice", authenticated=False, allow_refresh=False)
|
|
339
|
+
|
|
340
|
+
def about(self) -> Any:
|
|
341
|
+
return self._request("GET", "about", authenticated=False, allow_refresh=False)
|
|
342
|
+
|
|
343
|
+
def request(
|
|
344
|
+
self,
|
|
345
|
+
method: str,
|
|
346
|
+
path: str,
|
|
347
|
+
*,
|
|
348
|
+
params: Mapping[str, Any] | None = None,
|
|
349
|
+
json: Any = None,
|
|
350
|
+
headers: Mapping[str, str] | None = None,
|
|
351
|
+
authenticated: bool = True,
|
|
352
|
+
) -> Any:
|
|
353
|
+
return self._request(
|
|
354
|
+
method,
|
|
355
|
+
path,
|
|
356
|
+
params=params,
|
|
357
|
+
json=json,
|
|
358
|
+
headers=headers,
|
|
359
|
+
authenticated=authenticated,
|
|
360
|
+
allow_refresh=True,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def close(self) -> None:
|
|
364
|
+
if self._owns_http_client:
|
|
365
|
+
self._http.close()
|
|
366
|
+
|
|
367
|
+
def __enter__(self) -> NewAPI:
|
|
368
|
+
return self
|
|
369
|
+
|
|
370
|
+
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
|
|
371
|
+
self.close()
|
|
372
|
+
|
|
373
|
+
def _request(
|
|
374
|
+
self,
|
|
375
|
+
method: str,
|
|
376
|
+
path: str,
|
|
377
|
+
*,
|
|
378
|
+
params: Mapping[str, Any] | None = None,
|
|
379
|
+
json: Any = None,
|
|
380
|
+
headers: Mapping[str, str] | None = None,
|
|
381
|
+
authenticated: bool,
|
|
382
|
+
allow_refresh: bool,
|
|
383
|
+
refresh_cookie: str | None = None,
|
|
384
|
+
) -> Any:
|
|
385
|
+
return self._request_with_response(
|
|
386
|
+
method,
|
|
387
|
+
path,
|
|
388
|
+
params=params,
|
|
389
|
+
json=json,
|
|
390
|
+
headers=headers,
|
|
391
|
+
authenticated=authenticated,
|
|
392
|
+
allow_refresh=allow_refresh,
|
|
393
|
+
refresh_cookie=refresh_cookie,
|
|
394
|
+
)[0]
|
|
395
|
+
|
|
396
|
+
def _request_with_response(
|
|
397
|
+
self,
|
|
398
|
+
method: str,
|
|
399
|
+
path: str,
|
|
400
|
+
*,
|
|
401
|
+
params: Mapping[str, Any] | None = None,
|
|
402
|
+
json: Any = None,
|
|
403
|
+
headers: Mapping[str, str] | None = None,
|
|
404
|
+
authenticated: bool,
|
|
405
|
+
allow_refresh: bool,
|
|
406
|
+
refresh_cookie: str | None = None,
|
|
407
|
+
) -> tuple[Any, Any]:
|
|
408
|
+
normalized_method = method.upper()
|
|
409
|
+
if authenticated and allow_refresh:
|
|
410
|
+
self._refresh_if_expiring()
|
|
411
|
+
failed_token = self._tokens.access_token
|
|
412
|
+
response = self._send(
|
|
413
|
+
normalized_method,
|
|
414
|
+
path,
|
|
415
|
+
params=params,
|
|
416
|
+
json=json,
|
|
417
|
+
headers=headers,
|
|
418
|
+
authenticated=authenticated,
|
|
419
|
+
refresh_cookie=refresh_cookie,
|
|
420
|
+
)
|
|
421
|
+
if (
|
|
422
|
+
response.status_code == 401
|
|
423
|
+
and authenticated
|
|
424
|
+
and allow_refresh
|
|
425
|
+
and self._tokens.refresh_token
|
|
426
|
+
):
|
|
427
|
+
self._refresh_after_unauthorized(failed_token)
|
|
428
|
+
response = self._send(
|
|
429
|
+
normalized_method,
|
|
430
|
+
path,
|
|
431
|
+
params=params,
|
|
432
|
+
json=json,
|
|
433
|
+
headers=headers,
|
|
434
|
+
authenticated=authenticated,
|
|
435
|
+
)
|
|
436
|
+
return self._decode(response), response
|
|
437
|
+
|
|
438
|
+
def _send(
|
|
439
|
+
self,
|
|
440
|
+
method: str,
|
|
441
|
+
path: str,
|
|
442
|
+
*,
|
|
443
|
+
params: Mapping[str, Any] | None,
|
|
444
|
+
json: Any,
|
|
445
|
+
headers: Mapping[str, str] | None,
|
|
446
|
+
authenticated: bool,
|
|
447
|
+
refresh_cookie: str | None = None,
|
|
448
|
+
) -> Any:
|
|
449
|
+
relative_path = _request_path(path)
|
|
450
|
+
request_headers = {
|
|
451
|
+
"Accept": "application/json",
|
|
452
|
+
"Accept-Language": self.language,
|
|
453
|
+
"User-Agent": "newapi-python/0.1.0",
|
|
454
|
+
}
|
|
455
|
+
if authenticated and self._tokens.access_token:
|
|
456
|
+
request_headers["Authorization"] = (
|
|
457
|
+
f"{self._tokens.token_type} {self._tokens.access_token}"
|
|
458
|
+
)
|
|
459
|
+
if refresh_cookie and not self._session_has_refresh_cookie():
|
|
460
|
+
request_headers["Cookie"] = f"{_REFRESH_COOKIE}={refresh_cookie}"
|
|
461
|
+
if headers:
|
|
462
|
+
request_headers.update(headers)
|
|
463
|
+
try:
|
|
464
|
+
return cast(Any, self._http).request(
|
|
465
|
+
method,
|
|
466
|
+
f"{self.base_url}/{relative_path}",
|
|
467
|
+
params=_query(params),
|
|
468
|
+
json=json,
|
|
469
|
+
headers=request_headers,
|
|
470
|
+
timeout=self.timeout,
|
|
471
|
+
allow_redirects=False,
|
|
472
|
+
)
|
|
473
|
+
except curl_requests.RequestsError as error:
|
|
474
|
+
raise TransportError(f"{method} request to the new-api instance failed") from error
|
|
475
|
+
|
|
476
|
+
def _decode(self, response: Any) -> Any:
|
|
477
|
+
if not response.content:
|
|
478
|
+
if _success(response):
|
|
479
|
+
return None
|
|
480
|
+
self._raise_api_error(response, {})
|
|
481
|
+
try:
|
|
482
|
+
payload = response.json()
|
|
483
|
+
except ValueError as error:
|
|
484
|
+
if not _success(response):
|
|
485
|
+
raise ProtocolError(
|
|
486
|
+
f"new-api returned non-JSON HTTP {response.status_code}"
|
|
487
|
+
) from error
|
|
488
|
+
raise ProtocolError("new-api returned a non-JSON success response") from error
|
|
489
|
+
if not _success(response):
|
|
490
|
+
self._raise_api_error(response, payload)
|
|
491
|
+
if isinstance(payload, Mapping):
|
|
492
|
+
if "success" in payload:
|
|
493
|
+
if payload.get("success") is not True:
|
|
494
|
+
self._raise_api_error(response, payload)
|
|
495
|
+
return payload.get("data")
|
|
496
|
+
if "message" in payload:
|
|
497
|
+
if payload.get("message") != "success":
|
|
498
|
+
self._raise_api_error(response, payload)
|
|
499
|
+
return payload
|
|
500
|
+
return payload
|
|
501
|
+
|
|
502
|
+
def _raise_api_error(self, response: Any, payload: Any) -> None:
|
|
503
|
+
data = payload if isinstance(payload, Mapping) else {}
|
|
504
|
+
code = data.get("code")
|
|
505
|
+
fallback_message = (
|
|
506
|
+
getattr(response, "reason_phrase", None)
|
|
507
|
+
or getattr(response, "reason", None)
|
|
508
|
+
or f"HTTP {response.status_code}"
|
|
509
|
+
)
|
|
510
|
+
message: Any = data.get("message")
|
|
511
|
+
detail = data.get("data")
|
|
512
|
+
if not isinstance(message, str) or message == "" or message == "error":
|
|
513
|
+
message = detail if isinstance(detail, str) and detail else fallback_message
|
|
514
|
+
status_code = response.status_code
|
|
515
|
+
error_type = _ERROR_TYPES.get(status_code, APIError)
|
|
516
|
+
raise error_type(
|
|
517
|
+
str(message),
|
|
518
|
+
status_code=status_code,
|
|
519
|
+
code=code if isinstance(code, (int, str)) else None,
|
|
520
|
+
retry_after=_retry_after(response),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
def _origin(self) -> str:
|
|
524
|
+
parsed = urlsplit(self.base_url)
|
|
525
|
+
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
526
|
+
|
|
527
|
+
def _password_encryption_key(self) -> Resource | None:
|
|
528
|
+
data = self._request(
|
|
529
|
+
"GET",
|
|
530
|
+
"user/login/encryption-key",
|
|
531
|
+
authenticated=False,
|
|
532
|
+
allow_refresh=False,
|
|
533
|
+
)
|
|
534
|
+
if not isinstance(data, Mapping):
|
|
535
|
+
raise ProtocolError("login encryption key response was not an object")
|
|
536
|
+
key = Resource(data)
|
|
537
|
+
if not key.get("enabled"):
|
|
538
|
+
return None
|
|
539
|
+
if not key.get("kid") or not key.get("public_key"):
|
|
540
|
+
raise ProtocolError("login encryption key response was incomplete")
|
|
541
|
+
return key
|
|
542
|
+
|
|
543
|
+
def _harvest_refresh_token(self, response: Any) -> str | None:
|
|
544
|
+
for value in _set_cookie_values(response):
|
|
545
|
+
match = _REFRESH_COOKIE_PATTERN.search(value)
|
|
546
|
+
if match:
|
|
547
|
+
return match.group(1)
|
|
548
|
+
jar = getattr(self._http, "cookies", None)
|
|
549
|
+
if jar is not None:
|
|
550
|
+
try:
|
|
551
|
+
stored = jar.get(_REFRESH_COOKIE)
|
|
552
|
+
except Exception:
|
|
553
|
+
stored = None
|
|
554
|
+
if isinstance(stored, str) and stored:
|
|
555
|
+
return stored
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
def _session_has_refresh_cookie(self) -> bool:
|
|
559
|
+
jar = getattr(self._http, "cookies", None)
|
|
560
|
+
if jar is None:
|
|
561
|
+
return False
|
|
562
|
+
try:
|
|
563
|
+
stored = jar.get(_REFRESH_COOKIE)
|
|
564
|
+
except Exception:
|
|
565
|
+
return False
|
|
566
|
+
return isinstance(stored, str) and bool(stored)
|
|
567
|
+
|
|
568
|
+
def _refresh_if_expiring(self) -> None:
|
|
569
|
+
with self._refresh_lock:
|
|
570
|
+
if self._tokens.needs_refresh():
|
|
571
|
+
self.refresh()
|
|
572
|
+
|
|
573
|
+
def _refresh_after_unauthorized(self, failed_token: str | None) -> None:
|
|
574
|
+
with self._refresh_lock:
|
|
575
|
+
if self._tokens.access_token and self._tokens.access_token != failed_token:
|
|
576
|
+
return
|
|
577
|
+
self.refresh()
|
|
578
|
+
|
|
579
|
+
def _auth_mapping(self, data: Any, operation: str) -> Mapping[str, Any]:
|
|
580
|
+
if not isinstance(data, Mapping):
|
|
581
|
+
raise ProtocolError(f"{operation} returned a non-object response")
|
|
582
|
+
return data
|
|
583
|
+
|
|
584
|
+
def _accept_auth(self, auth: Mapping[str, Any], response: Any) -> User:
|
|
585
|
+
access_token = auth.get("access_token")
|
|
586
|
+
if not isinstance(access_token, str) or not access_token:
|
|
587
|
+
raise ProtocolError("login did not return an access token")
|
|
588
|
+
user_data = auth.get("user")
|
|
589
|
+
if not isinstance(user_data, Mapping):
|
|
590
|
+
raise ProtocolError("login did not return a user profile")
|
|
591
|
+
self._tokens = self._tokens_from_auth(auth, self._harvest_refresh_token(response))
|
|
592
|
+
return User(user_data)
|
|
593
|
+
|
|
594
|
+
def _tokens_from_auth(
|
|
595
|
+
self,
|
|
596
|
+
auth: Mapping[str, Any],
|
|
597
|
+
refresh_token: str | None,
|
|
598
|
+
) -> SessionTokens:
|
|
599
|
+
expires_at = None
|
|
600
|
+
raw_expires_at = auth.get("access_expires_at")
|
|
601
|
+
if isinstance(raw_expires_at, (int, float)) and not isinstance(raw_expires_at, bool):
|
|
602
|
+
expires_at = float(raw_expires_at)
|
|
603
|
+
token_type = auth.get("token_type")
|
|
604
|
+
session = auth.get("session")
|
|
605
|
+
session_id = None
|
|
606
|
+
if isinstance(session, Mapping):
|
|
607
|
+
raw_session_id = session.get("sid")
|
|
608
|
+
session_id = raw_session_id if isinstance(raw_session_id, str) else None
|
|
609
|
+
return SessionTokens(
|
|
610
|
+
access_token=str(auth["access_token"]),
|
|
611
|
+
refresh_token=refresh_token,
|
|
612
|
+
expires_at=expires_at,
|
|
613
|
+
token_type=str(token_type) if token_type else "Bearer",
|
|
614
|
+
session_id=session_id,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
def _resolve_quota_per_unit(self) -> float:
|
|
618
|
+
if self._quota_per_unit is None:
|
|
619
|
+
self.status()
|
|
620
|
+
return self._quota_per_unit if self._quota_per_unit is not None else _DEFAULT_QUOTA_PER_UNIT
|