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/_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
+ )