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/_crypto.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._exceptions import ProtocolError
|
|
11
|
+
|
|
12
|
+
_PEM_STRIP = re.compile(r"-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read_der(data: bytes, offset: int) -> tuple[int, bytes, int]:
|
|
16
|
+
if offset >= len(data):
|
|
17
|
+
raise ValueError("truncated DER")
|
|
18
|
+
tag = data[offset]
|
|
19
|
+
offset += 1
|
|
20
|
+
length = data[offset]
|
|
21
|
+
offset += 1
|
|
22
|
+
if length & 0x80:
|
|
23
|
+
size = length & 0x7F
|
|
24
|
+
if size == 0 or offset + size > len(data):
|
|
25
|
+
raise ValueError("invalid DER length")
|
|
26
|
+
length = int.from_bytes(data[offset : offset + size], "big")
|
|
27
|
+
offset += size
|
|
28
|
+
if length <= 0 or offset + length > len(data):
|
|
29
|
+
raise ValueError("invalid DER length")
|
|
30
|
+
return tag, data[offset : offset + length], offset + length
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _read_sequence(data: bytes) -> bytes:
|
|
34
|
+
tag, value, _ = _read_der(data, 0)
|
|
35
|
+
if tag != 0x30:
|
|
36
|
+
raise ValueError("expected a DER sequence")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_integer(data: bytes, offset: int) -> tuple[int, int]:
|
|
41
|
+
tag, value, end = _read_der(data, offset)
|
|
42
|
+
if tag != 0x02:
|
|
43
|
+
raise ValueError("expected a DER integer")
|
|
44
|
+
return int.from_bytes(value, "big"), end
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_spki_rsa_public_key(pem: str) -> tuple[int, int]:
|
|
48
|
+
body = _PEM_STRIP.sub("", pem)
|
|
49
|
+
try:
|
|
50
|
+
der = base64.b64decode(body, validate=True)
|
|
51
|
+
spki = _read_sequence(der)
|
|
52
|
+
tag, algorithm, offset = _read_der(spki, 0)
|
|
53
|
+
if tag != 0x30:
|
|
54
|
+
raise ValueError("expected an algorithm identifier sequence")
|
|
55
|
+
oid_tag, oid, _ = _read_der(algorithm, 0)
|
|
56
|
+
if oid_tag != 0x06 or oid != b"\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01":
|
|
57
|
+
raise ValueError("not an RSA public key")
|
|
58
|
+
tag, key_bytes, _ = _read_der(spki, offset)
|
|
59
|
+
if tag != 0x03 or not key_bytes.startswith(b"\x00"):
|
|
60
|
+
raise ValueError("invalid subject public key bit string")
|
|
61
|
+
rsa_public = _read_sequence(key_bytes[1:])
|
|
62
|
+
modulus, offset = _read_integer(rsa_public, 0)
|
|
63
|
+
exponent, _ = _read_integer(rsa_public, offset)
|
|
64
|
+
if modulus < 3 or exponent < 3:
|
|
65
|
+
raise ValueError("invalid RSA parameters")
|
|
66
|
+
return modulus, exponent
|
|
67
|
+
except (ValueError, IndexError) as error:
|
|
68
|
+
raise ProtocolError(
|
|
69
|
+
f"instance returned an unusable password encryption key: {error}"
|
|
70
|
+
) from error
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _mgf1(seed: bytes, length: int, hash_func: Callable[[bytes], Any]) -> bytes:
|
|
74
|
+
output = bytearray()
|
|
75
|
+
counter = 0
|
|
76
|
+
while len(output) < length:
|
|
77
|
+
output.extend(hash_func(seed + counter.to_bytes(4, "big")).digest())
|
|
78
|
+
counter += 1
|
|
79
|
+
return bytes(output[:length])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def rsa_oaep_sha256_encrypt(message: bytes, public_key_pem: str) -> str:
|
|
83
|
+
modulus, exponent = _parse_spki_rsa_public_key(public_key_pem)
|
|
84
|
+
key_size = (modulus.bit_length() + 7) // 8
|
|
85
|
+
hash_length = hashlib.sha256().digest_size
|
|
86
|
+
max_message = key_size - 2 * hash_length - 2
|
|
87
|
+
if len(message) > max_message:
|
|
88
|
+
raise ValueError("message is too long for the instance's password encryption key")
|
|
89
|
+
seed = os.urandom(hash_length)
|
|
90
|
+
padding_length = max_message - len(message)
|
|
91
|
+
label_hash = hashlib.sha256(b"").digest()
|
|
92
|
+
encoded = bytearray(b"\x00" + seed + label_hash + b"\x00" * padding_length + b"\x01" + message)
|
|
93
|
+
db_mask = _mgf1(seed, key_size - hash_length - 1, hashlib.sha256)
|
|
94
|
+
for index in range(key_size - hash_length - 1):
|
|
95
|
+
encoded[1 + hash_length + index] ^= db_mask[index]
|
|
96
|
+
seed_mask = _mgf1(bytes(encoded[1 + hash_length :]), hash_length, hashlib.sha256)
|
|
97
|
+
for index in range(hash_length):
|
|
98
|
+
encoded[1 + index] ^= seed_mask[index]
|
|
99
|
+
ciphertext = pow(int.from_bytes(bytes(encoded), "big"), exponent, modulus)
|
|
100
|
+
return base64.b64encode(ciphertext.to_bytes(key_size, "big")).decode()
|
newapi/_exceptions.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NewAPIError(Exception):
|
|
5
|
+
"""Base exception for the package."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(NewAPIError, ValueError):
|
|
9
|
+
"""Raised when client configuration is invalid or unsafe."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransportError(NewAPIError):
|
|
13
|
+
"""Raised when the instance cannot be reached or times out."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProtocolError(NewAPIError):
|
|
17
|
+
"""Raised when an instance returns an unsupported response shape."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIError(NewAPIError):
|
|
21
|
+
"""An error returned by a new-api instance."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
status_code: int,
|
|
28
|
+
code: int | str | None = None,
|
|
29
|
+
retry_after: float | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.message = message
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.code = code
|
|
35
|
+
self.retry_after = retry_after
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
label = str(self.code or self.status_code)
|
|
39
|
+
return f"new-api request failed ({label}): {self.message}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuthenticationError(APIError):
|
|
43
|
+
"""Raised when login or session authentication fails."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PermissionDeniedError(APIError):
|
|
47
|
+
"""Raised when the user cannot perform an operation."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NotFoundError(APIError):
|
|
51
|
+
"""Raised when a requested resource does not exist."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ValidationError(APIError):
|
|
55
|
+
"""Raised when the instance rejects request input."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ConflictError(APIError):
|
|
59
|
+
"""Raised when an operation conflicts with current server state."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RateLimitError(APIError):
|
|
63
|
+
"""Raised when the panel API rate limit is exceeded."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TwoFactorRequired(AuthenticationError):
|
|
67
|
+
"""Raised when login must be completed with a TOTP or backup code."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, flow_token: str, expires_at: float | None = None) -> None:
|
|
70
|
+
super().__init__(
|
|
71
|
+
"Two-factor authentication is required",
|
|
72
|
+
status_code=401,
|
|
73
|
+
code="TWO_FACTOR_REQUIRED",
|
|
74
|
+
)
|
|
75
|
+
self.flow_token = flow_token
|
|
76
|
+
self.expires_at = expires_at
|
newapi/_models.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Iterator, Mapping
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from math import ceil
|
|
9
|
+
from typing import Any, Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
_SENSITIVE_FIELDS = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"access_token",
|
|
14
|
+
"allow_ips",
|
|
15
|
+
"authorization",
|
|
16
|
+
"cookie",
|
|
17
|
+
"encryption_key_id",
|
|
18
|
+
"flow_token",
|
|
19
|
+
"key",
|
|
20
|
+
"password",
|
|
21
|
+
"password_encrypted",
|
|
22
|
+
"refresh_token",
|
|
23
|
+
"secret",
|
|
24
|
+
"webhook_secret",
|
|
25
|
+
"webhook_url",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _redacted(value: Any, field: str | None = None) -> Any:
|
|
31
|
+
if field is not None and field.lower() in _SENSITIVE_FIELDS and value not in (None, ""):
|
|
32
|
+
return "********"
|
|
33
|
+
if isinstance(value, Mapping):
|
|
34
|
+
return {str(key): _redacted(item, str(key)) for key, item in value.items()}
|
|
35
|
+
if isinstance(value, (list, tuple)):
|
|
36
|
+
return [_redacted(item) for item in value]
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _wrapped(value: Any) -> Any:
|
|
41
|
+
if isinstance(value, Mapping):
|
|
42
|
+
return Resource(value)
|
|
43
|
+
if isinstance(value, list):
|
|
44
|
+
return [_wrapped(item) for item in value]
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Resource(Mapping[str, Any]):
|
|
49
|
+
"""A response object supporting both mapping and attribute access."""
|
|
50
|
+
|
|
51
|
+
__slots__ = ("_data",)
|
|
52
|
+
|
|
53
|
+
def __init__(self, data: Mapping[str, Any]) -> None:
|
|
54
|
+
self._data = dict(data)
|
|
55
|
+
|
|
56
|
+
def __getitem__(self, key: str) -> Any:
|
|
57
|
+
return _wrapped(self._data[key])
|
|
58
|
+
|
|
59
|
+
def __contains__(self, key: object) -> bool:
|
|
60
|
+
return key in self._data
|
|
61
|
+
|
|
62
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
63
|
+
if key in self._data:
|
|
64
|
+
return self[key]
|
|
65
|
+
return default
|
|
66
|
+
|
|
67
|
+
def __iter__(self) -> Iterator[str]:
|
|
68
|
+
return iter(self._data)
|
|
69
|
+
|
|
70
|
+
def __len__(self) -> int:
|
|
71
|
+
return len(self._data)
|
|
72
|
+
|
|
73
|
+
def __getattr__(self, name: str) -> Any:
|
|
74
|
+
try:
|
|
75
|
+
return self[name]
|
|
76
|
+
except KeyError as error:
|
|
77
|
+
raise AttributeError(name) from error
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
return f"{type(self).__name__}({_redacted(self._data)!r})"
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> dict[str, Any]:
|
|
83
|
+
"""Return an independent dictionary containing the full response data."""
|
|
84
|
+
return deepcopy(self._data)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class User(Resource):
|
|
88
|
+
"""The authenticated user's dashboard profile."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Token(Resource):
|
|
92
|
+
"""A user-owned gateway API token."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
APIKey = Token
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Log(Resource):
|
|
99
|
+
"""One dashboard usage-log record."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
UsageRecord = Log
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TopUpOrder(Resource):
|
|
106
|
+
"""A user balance-recharge order."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Group(Resource):
|
|
110
|
+
"""A group selectable by the authenticated user."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SubscriptionPlan(Resource):
|
|
114
|
+
"""A subscription plan available for purchase."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Subscription(Resource):
|
|
118
|
+
"""A user subscription entry."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class PaymentConfig(Resource):
|
|
122
|
+
"""Payment availability and recharge configuration."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class PaymentLink(Resource):
|
|
126
|
+
"""Checkout parameters returned after starting an online payment."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class LoginSession(Resource):
|
|
130
|
+
"""A dashboard login session."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Redemption(Resource):
|
|
134
|
+
"""A redemption-code result."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class CheckinStatus(Resource):
|
|
138
|
+
"""Check-in configuration and history for one month."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class CheckinResult(Resource):
|
|
142
|
+
"""The result of one successful check-in."""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Announcement(Resource):
|
|
146
|
+
"""A user-visible announcement payload."""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
T = TypeVar("T", bound=Resource)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class Page(Generic[T]):
|
|
154
|
+
"""One page returned by a list endpoint."""
|
|
155
|
+
|
|
156
|
+
items: tuple[T, ...]
|
|
157
|
+
total: int
|
|
158
|
+
page: int
|
|
159
|
+
page_size: int
|
|
160
|
+
|
|
161
|
+
def __iter__(self) -> Iterator[T]:
|
|
162
|
+
return iter(self.items)
|
|
163
|
+
|
|
164
|
+
def __len__(self) -> int:
|
|
165
|
+
return len(self.items)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def pages(self) -> int:
|
|
169
|
+
return ceil(self.total / self.page_size) if self.page_size else 0
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def has_next(self) -> bool:
|
|
173
|
+
return self.page * self.page_size < self.total
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_data(cls, data: Mapping[str, Any], item_type: type[T]) -> Page[T]:
|
|
177
|
+
raw_items = data.get("items", [])
|
|
178
|
+
if not isinstance(raw_items, list):
|
|
179
|
+
raise TypeError("paginated response items must be a list")
|
|
180
|
+
if any(not isinstance(item, Mapping) for item in raw_items):
|
|
181
|
+
raise TypeError("paginated response items must contain objects")
|
|
182
|
+
page_size = int(data.get("page_size", len(raw_items) or 1))
|
|
183
|
+
return cls(
|
|
184
|
+
items=tuple(item_type(item) for item in raw_items),
|
|
185
|
+
total=int(data.get("total", len(raw_items))),
|
|
186
|
+
page=int(data.get("page", 1)),
|
|
187
|
+
page_size=page_size,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class Balance:
|
|
193
|
+
"""The user's quota values and their currency equivalents."""
|
|
194
|
+
|
|
195
|
+
quota: int
|
|
196
|
+
used_quota: int
|
|
197
|
+
aff_quota: int
|
|
198
|
+
quota_per_unit: float
|
|
199
|
+
|
|
200
|
+
def _amount(self, value: int) -> Decimal:
|
|
201
|
+
unit = Decimal(str(self.quota_per_unit)) if self.quota_per_unit else Decimal(0)
|
|
202
|
+
if unit == 0:
|
|
203
|
+
return Decimal(0)
|
|
204
|
+
return Decimal(value) / unit
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def amount(self) -> Decimal:
|
|
208
|
+
return self._amount(self.quota)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def used_amount(self) -> Decimal:
|
|
212
|
+
return self._amount(self.used_quota)
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def aff_amount(self) -> Decimal:
|
|
216
|
+
return self._amount(self.aff_quota)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(frozen=True)
|
|
220
|
+
class SessionTokens:
|
|
221
|
+
"""The current in-memory dashboard session credentials."""
|
|
222
|
+
|
|
223
|
+
access_token: str | None = field(default=None, repr=False)
|
|
224
|
+
refresh_token: str | None = field(default=None, repr=False)
|
|
225
|
+
expires_at: float | None = None
|
|
226
|
+
token_type: str = "Bearer"
|
|
227
|
+
session_id: str | None = field(default=None, repr=False)
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def authenticated(self) -> bool:
|
|
231
|
+
return bool(self.access_token)
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def expires_in(self) -> float | None:
|
|
235
|
+
if self.expires_at is None:
|
|
236
|
+
return None
|
|
237
|
+
return max(0.0, self.expires_at - time.time())
|
|
238
|
+
|
|
239
|
+
def needs_refresh(self, leeway: float = 30.0) -> bool:
|
|
240
|
+
return (
|
|
241
|
+
self.refresh_token is not None
|
|
242
|
+
and self.expires_at is not None
|
|
243
|
+
and self.expires_at <= time.time() + leeway
|
|
244
|
+
)
|