newapi-python 0.2.0__tar.gz → 0.3.0__tar.gz
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_python-0.3.0/.mimosa/finding-ledger/v1/events/batch-posttooluse-48aef1dee53250877e9903ca7c2b5602.json +23 -0
- newapi_python-0.3.0/.mimosa/finding-ledger/v1/events/batch-pretooluse-6268e66558b9f7a250aa50afc99ccc21.json +23 -0
- newapi_python-0.3.0/.mimosa/hook-state/sess_1433cec3-1da2-46c1-88de-192e7415d559.json +1 -0
- newapi_python-0.2.0/src/newapi/_client.py → newapi_python-0.3.0/.mimosa/hook-state/sess_1433cec3-1da2-46c1-88de-192e7415d559.mtjfq1ha-24876-f1aebcd4dc.baseline/1252fc55de5f372ed4dcbb82a75b61002c7dfd9076f095c21e54449336bf6ba8.source +22 -6
- newapi_python-0.3.0/.mimosa/hook-status/sess_1433cec3-1da2-46c1-88de-192e7415d559-b601df6c7a.json +14 -0
- {newapi_python-0.2.0 → newapi_python-0.3.0}/PKG-INFO +5 -2
- {newapi_python-0.2.0 → newapi_python-0.3.0}/README.md +4 -1
- {newapi_python-0.2.0 → newapi_python-0.3.0}/pyproject.toml +1 -1
- newapi_python-0.3.0/src/newapi/__init__.py +75 -0
- newapi_python-0.3.0/src/newapi/_client.py +707 -0
- {newapi_python-0.2.0 → newapi_python-0.3.0}/tests/test_client.py +51 -3
- {newapi_python-0.2.0 → newapi_python-0.3.0}/tests/test_resources.py +1 -0
- {newapi_python-0.2.0 → newapi_python-0.3.0}/.gitignore +0 -0
- /newapi_python-0.2.0/src/newapi/__init__.py → /newapi_python-0.3.0/.mimosa/hook-state/sess_1433cec3-1da2-46c1-88de-192e7415d559.mtjfq1ha-24876-f1aebcd4dc.baseline/2c0a3ea4b52b71cebbbb4c27ee4939eeecbb634105d6a4ad0b51984b9dbfa04b.source +0 -0
- {newapi_python-0.2.0 → newapi_python-0.3.0}/LICENSE +0 -0
- {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/_crypto.py +0 -0
- {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/_exceptions.py +0 -0
- {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/_models.py +0 -0
- {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/_resources.py +0 -0
- {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/_session_blob.py +0 -0
- {newapi_python-0.2.0 → newapi_python-0.3.0}/src/newapi/py.typed +0 -0
- {newapi_python-0.2.0 → newapi_python-0.3.0}/tests/test_session_blob.py +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "mimosa-finding-ledger-batch/v1",
|
|
3
|
+
"batchId": "posttooluse-48aef1dee53250877e9903ca7c2b5602",
|
|
4
|
+
"runId": null,
|
|
5
|
+
"runStatus": "completed",
|
|
6
|
+
"coverage": {
|
|
7
|
+
"status": "complete",
|
|
8
|
+
"reasons": []
|
|
9
|
+
},
|
|
10
|
+
"source": {
|
|
11
|
+
"component": "zcode-hook",
|
|
12
|
+
"operationId": "PostToolUse"
|
|
13
|
+
},
|
|
14
|
+
"revision": null,
|
|
15
|
+
"diffHash": null,
|
|
16
|
+
"rulesVersion": null,
|
|
17
|
+
"sessionHash": "b601df6c7ab8c95836c9aa408671dade9f8500578f3b99ff004a48d719d630b4",
|
|
18
|
+
"reportRef": null,
|
|
19
|
+
"events": [],
|
|
20
|
+
"observedFindingIds": [],
|
|
21
|
+
"verifiedFiles": [],
|
|
22
|
+
"recordedAt": "2026-09-02T01:47:00.021Z"
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "mimosa-finding-ledger-batch/v1",
|
|
3
|
+
"batchId": "pretooluse-6268e66558b9f7a250aa50afc99ccc21",
|
|
4
|
+
"runId": null,
|
|
5
|
+
"runStatus": "completed",
|
|
6
|
+
"coverage": {
|
|
7
|
+
"status": "complete",
|
|
8
|
+
"reasons": []
|
|
9
|
+
},
|
|
10
|
+
"source": {
|
|
11
|
+
"component": "zcode-hook",
|
|
12
|
+
"operationId": "PreToolUse"
|
|
13
|
+
},
|
|
14
|
+
"revision": null,
|
|
15
|
+
"diffHash": null,
|
|
16
|
+
"rulesVersion": null,
|
|
17
|
+
"sessionHash": "b601df6c7ab8c95836c9aa408671dade9f8500578f3b99ff004a48d719d630b4",
|
|
18
|
+
"reportRef": null,
|
|
19
|
+
"events": [],
|
|
20
|
+
"observedFindingIds": [],
|
|
21
|
+
"verifiedFiles": [],
|
|
22
|
+
"recordedAt": "2026-09-02T01:46:45.535Z"
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"touched":["/Users/Administrator/eightlabs/newapi/src/newapi/__init__.py","/Users/Administrator/eightlabs/newapi/src/newapi/_client.py"],"bashMutation":true,"reportedFindings":["mimosa-66b5d60954ca90ad7e673501"],"findingEvents":[{"finding_id":"mimosa-66b5d60954ca90ad7e673501","file":"../../../../tmp/live_test_newapi.py","line":24,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"655080fc4deb5d7ba132e4dfa5eeff0e63181ed39fbd3b9c4a74fd5f35f100c8","source":"semgrep"},"status":"blocked","decision":"deny","at":"2026-09-02T01:46:45.217Z"},{"finding_id":"mimosa-66b5d60954ca90ad7e673501","file":"../../../../tmp/live_test_newapi.py","line":24,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"655080fc4deb5d7ba132e4dfa5eeff0e63181ed39fbd3b9c4a74fd5f35f100c8","source":"semgrep","candidate_before_line_hash":"655080fc4deb5d7ba132e4dfa5eeff0e63181ed39fbd3b9c4a74fd5f35f100c8","post_change_line_hash_at_previous_location":"67d27e371a21402ee7ba4fe1136e7650c84740034f83bec4ec36d2baf6964c0f","verification":{"method":"mimosa-posttool-static-rescan","finding_absent":true,"runtime_oracle":false}},"status":"fixed_candidate","decision":"repair_observed","at":"2026-09-02T01:46:59.695Z"},{"finding_id":"mimosa-66b5d60954ca90ad7e673501","file":"../../../../tmp/live_test_newapi.py","line":24,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"655080fc4deb5d7ba132e4dfa5eeff0e63181ed39fbd3b9c4a74fd5f35f100c8","source":"semgrep","candidate_before_line_hash":"655080fc4deb5d7ba132e4dfa5eeff0e63181ed39fbd3b9c4a74fd5f35f100c8","post_change_line_hash_at_previous_location":"67d27e371a21402ee7ba4fe1136e7650c84740034f83bec4ec36d2baf6964c0f","verification":{"method":"mimosa-posttool-static-rescan","finding_absent":true,"runtime_oracle":false}},"status":"static_verified","decision":"static_rescan_passed","at":"2026-09-02T01:46:59.695Z"}],"baseline":{"storageId":"mtjfq1ha-24876-f1aebcd4dc","createdAt":"2026-09-02T01:46:04.414Z","files":{"src/newapi/__init__.py":{"existed":true,"snapshot":"2c0a3ea4b52b71cebbbb4c27ee4939eeecbb634105d6a4ad0b51984b9dbfa04b.source"},"src/newapi/_client.py":{"existed":true,"snapshot":"1252fc55de5f372ed4dcbb82a75b61002c7dfd9076f095c21e54449336bf6ba8.source"}},"complete":false,"candidateLimit":5000,"discoveredFiles":0,"capturedFiles":2,"truncated":false,"omittedAtLeast":0,"firstOmitted":"","errors":[{"stage":"baseline-capture","target":".","reason":"global task baseline was unavailable; captured only the touched file"},{"stage":"baseline-snapshot","target":"../../../../tmp/live_test_newapi.py","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../../../../tmp/live_test_newapi.py","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../../../../tmp/live_test_newapi.py","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../../../../tmp/live_test_newapi.py","reason":"file is outside project"}]},"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-02T01:47:47.717Z"}
|
|
@@ -218,6 +218,12 @@ class NewAPI:
|
|
|
218
218
|
) -> int | None:
|
|
219
219
|
if access_token and session_token:
|
|
220
220
|
raise ConfigurationError("pass either access_token or session_token, not both")
|
|
221
|
+
if access_token and user_id is None:
|
|
222
|
+
raise ConfigurationError(
|
|
223
|
+
"user_id is required when authenticating with an access_token; "
|
|
224
|
+
"new-api instances reject such requests with 401 "
|
|
225
|
+
"'Unauthorized, New-Api-User header not provided'"
|
|
226
|
+
)
|
|
221
227
|
if not session_token or user_id is not None:
|
|
222
228
|
return user_id
|
|
223
229
|
try:
|
|
@@ -342,6 +348,8 @@ class NewAPI:
|
|
|
342
348
|
return
|
|
343
349
|
refresh_token = self._tokens.refresh_token
|
|
344
350
|
headers: dict[str, str] = {"Origin": self._origin()}
|
|
351
|
+
if self._tokens.user_id is not None:
|
|
352
|
+
headers[_USER_ID_HEADER] = str(self._tokens.user_id)
|
|
345
353
|
if self._tokens.access_token:
|
|
346
354
|
headers["Authorization"] = f"{self._tokens.token_type} {self._tokens.access_token}"
|
|
347
355
|
try:
|
|
@@ -510,14 +518,15 @@ class NewAPI:
|
|
|
510
518
|
"Accept-Language": self.language,
|
|
511
519
|
"User-Agent": "newapi-python/0.2.0",
|
|
512
520
|
}
|
|
513
|
-
if authenticated
|
|
514
|
-
request_headers["Cookie"] = f"{self._session_cookie}={self._tokens.session_token}"
|
|
521
|
+
if authenticated:
|
|
515
522
|
if self._tokens.user_id is not None:
|
|
516
523
|
request_headers[_USER_ID_HEADER] = str(self._tokens.user_id)
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
524
|
+
if self._tokens.session_token:
|
|
525
|
+
request_headers["Cookie"] = f"{self._session_cookie}={self._tokens.session_token}"
|
|
526
|
+
elif self._tokens.access_token:
|
|
527
|
+
request_headers["Authorization"] = (
|
|
528
|
+
f"{self._tokens.token_type} {self._tokens.access_token}"
|
|
529
|
+
)
|
|
521
530
|
if refresh_cookie and not self._session_has_refresh_cookie():
|
|
522
531
|
request_headers["Cookie"] = f"{_REFRESH_COOKIE}={refresh_cookie}"
|
|
523
532
|
if headers:
|
|
@@ -677,12 +686,19 @@ class NewAPI:
|
|
|
677
686
|
if isinstance(session, Mapping):
|
|
678
687
|
raw_session_id = session.get("sid")
|
|
679
688
|
session_id = raw_session_id if isinstance(raw_session_id, str) else None
|
|
689
|
+
user_id = self._tokens.user_id
|
|
690
|
+
user_data = auth.get("user")
|
|
691
|
+
if isinstance(user_data, Mapping):
|
|
692
|
+
raw_user_id = user_data.get("id")
|
|
693
|
+
if isinstance(raw_user_id, int) and not isinstance(raw_user_id, bool):
|
|
694
|
+
user_id = raw_user_id
|
|
680
695
|
return SessionTokens(
|
|
681
696
|
access_token=str(auth["access_token"]),
|
|
682
697
|
refresh_token=refresh_token,
|
|
683
698
|
expires_at=expires_at,
|
|
684
699
|
token_type=str(token_type) if token_type else "Bearer",
|
|
685
700
|
session_id=session_id,
|
|
701
|
+
user_id=user_id,
|
|
686
702
|
)
|
|
687
703
|
|
|
688
704
|
def _resolve_quota_per_unit(self) -> float:
|
newapi_python-0.3.0/.mimosa/hook-status/sess_1433cec3-1da2-46c1-88de-192e7415d559-b601df6c7a.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "mimosa-hook-status/v1",
|
|
3
|
+
"recordedAt": "2026-09-02T01:47:48.400Z",
|
|
4
|
+
"sessionId": "sess_1433cec3-1da2-46c1-88de-192e7415d559",
|
|
5
|
+
"event": "PostToolUse",
|
|
6
|
+
"toolName": "Edit",
|
|
7
|
+
"file": "/tmp/live_test_newapi.py",
|
|
8
|
+
"outcome": "inconclusive",
|
|
9
|
+
"coverage": "partial",
|
|
10
|
+
"findingCount": 0,
|
|
11
|
+
"durationMs": 4,
|
|
12
|
+
"hostState": "hook_complete",
|
|
13
|
+
"reportHint": ".mimosa/reports/"
|
|
14
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: newapi-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python client for the user-facing dashboard API of new-api instances
|
|
5
5
|
Author: Eight Labs
|
|
6
6
|
License-Expression: MIT
|
|
@@ -55,10 +55,13 @@ client = NewAPI(
|
|
|
55
55
|
"https://newapi.example.com",
|
|
56
56
|
access_token=os.environ["NEWAPI_ACCESS_TOKEN"],
|
|
57
57
|
refresh_token=os.environ.get("NEWAPI_REFRESH_TOKEN"),
|
|
58
|
+
user_id=int(os.environ["NEWAPI_USER_ID"]),
|
|
58
59
|
)
|
|
59
60
|
```
|
|
60
61
|
|
|
61
|
-
Pass either the instance origin or its full `/api` URL. Tokens are retained only in memory. If a refresh token is supplied, the client sends it as the `new_api_refresh` cookie, rotates the pair after an authenticated `401`, and refreshes proactively when `expires_at` is known.
|
|
62
|
+
Pass either the instance origin or its full `/api` URL. Tokens are retained only in memory. If a refresh token is supplied, the client sends it as the `new_api_refresh` cookie, rotates the pair after an authenticated `401`, and refreshes proactively when `expires_at` is known.
|
|
63
|
+
|
|
64
|
+
Users can also generate a long-lived system access token from the dashboard and pass it as `access_token`. Access-token authentication additionally requires `user_id`: new-api instances verify the numeric `New-Api-User` header on every authenticated request and reject requests without it with `401 Unauthorized, New-Api-User header not provided`. The client sends the header automatically from `user_id`, and `login()` and `refresh()` capture the id from the instance's responses. Such tokens cannot refresh browser sessions on their own.
|
|
62
65
|
|
|
63
66
|
The default browser fingerprint is Chrome. Choose another `curl_cffi` fingerprint or configure proxies by supplying your own `curl_cffi.requests.Session`:
|
|
64
67
|
|
|
@@ -25,10 +25,13 @@ client = NewAPI(
|
|
|
25
25
|
"https://newapi.example.com",
|
|
26
26
|
access_token=os.environ["NEWAPI_ACCESS_TOKEN"],
|
|
27
27
|
refresh_token=os.environ.get("NEWAPI_REFRESH_TOKEN"),
|
|
28
|
+
user_id=int(os.environ["NEWAPI_USER_ID"]),
|
|
28
29
|
)
|
|
29
30
|
```
|
|
30
31
|
|
|
31
|
-
Pass either the instance origin or its full `/api` URL. Tokens are retained only in memory. If a refresh token is supplied, the client sends it as the `new_api_refresh` cookie, rotates the pair after an authenticated `401`, and refreshes proactively when `expires_at` is known.
|
|
32
|
+
Pass either the instance origin or its full `/api` URL. Tokens are retained only in memory. If a refresh token is supplied, the client sends it as the `new_api_refresh` cookie, rotates the pair after an authenticated `401`, and refreshes proactively when `expires_at` is known.
|
|
33
|
+
|
|
34
|
+
Users can also generate a long-lived system access token from the dashboard and pass it as `access_token`. Access-token authentication additionally requires `user_id`: new-api instances verify the numeric `New-Api-User` header on every authenticated request and reject requests without it with `401 Unauthorized, New-Api-User header not provided`. The client sends the header automatically from `user_id`, and `login()` and `refresh()` capture the id from the instance's responses. Such tokens cannot refresh browser sessions on their own.
|
|
32
35
|
|
|
33
36
|
The default browser fingerprint is Chrome. Choose another `curl_cffi` fingerprint or configure proxies by supplying your own `curl_cffi.requests.Session`:
|
|
34
37
|
|
|
@@ -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.3.0"
|
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
from urllib.parse import unquote, urlsplit, urlunsplit
|
|
9
|
+
|
|
10
|
+
from curl_cffi import requests as curl_requests
|
|
11
|
+
|
|
12
|
+
from ._crypto import rsa_oaep_sha256_encrypt
|
|
13
|
+
from ._exceptions import (
|
|
14
|
+
APIError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
ConfigurationError,
|
|
17
|
+
ConflictError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
PermissionDeniedError,
|
|
20
|
+
ProtocolError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
TransportError,
|
|
23
|
+
TwoFactorRequired,
|
|
24
|
+
ValidationError,
|
|
25
|
+
)
|
|
26
|
+
from ._models import Balance, Resource, SessionTokens, User
|
|
27
|
+
from ._resources import (
|
|
28
|
+
AccountResource,
|
|
29
|
+
DashboardResource,
|
|
30
|
+
GroupsResource,
|
|
31
|
+
LogsResource,
|
|
32
|
+
PaymentResource,
|
|
33
|
+
SubscriptionResource,
|
|
34
|
+
TokensResource,
|
|
35
|
+
TopUpResource,
|
|
36
|
+
)
|
|
37
|
+
from ._session_blob import decode_session_blob
|
|
38
|
+
|
|
39
|
+
_ERROR_TYPES: dict[int, type[APIError]] = {
|
|
40
|
+
400: ValidationError,
|
|
41
|
+
401: AuthenticationError,
|
|
42
|
+
403: PermissionDeniedError,
|
|
43
|
+
404: NotFoundError,
|
|
44
|
+
409: ConflictError,
|
|
45
|
+
422: ValidationError,
|
|
46
|
+
429: RateLimitError,
|
|
47
|
+
}
|
|
48
|
+
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
49
|
+
_REFRESH_COOKIE = "new_api_refresh"
|
|
50
|
+
_REFRESH_COOKIE_PATTERN = re.compile(rf"(?:^|[;,\s]){_REFRESH_COOKIE}=([^;\s]+)")
|
|
51
|
+
_DEFAULT_SESSION_COOKIE = "session"
|
|
52
|
+
_USER_ID_HEADER = "New-Api-User"
|
|
53
|
+
_DEFAULT_QUOTA_PER_UNIT = 500000.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _api_base_url(value: str, allow_insecure: bool) -> str:
|
|
57
|
+
candidate = value.strip()
|
|
58
|
+
if not candidate:
|
|
59
|
+
raise ConfigurationError("base_url must not be empty")
|
|
60
|
+
if "://" not in candidate:
|
|
61
|
+
candidate = f"https://{candidate}"
|
|
62
|
+
parsed = urlsplit(candidate)
|
|
63
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
64
|
+
raise ConfigurationError("base_url must be an HTTP or HTTPS instance URL")
|
|
65
|
+
if parsed.username or parsed.password:
|
|
66
|
+
raise ConfigurationError("base_url must not contain credentials")
|
|
67
|
+
if parsed.query or parsed.fragment:
|
|
68
|
+
raise ConfigurationError("base_url must not contain a query string or fragment")
|
|
69
|
+
local = parsed.hostname in _LOCAL_HOSTS or parsed.hostname.endswith(".localhost")
|
|
70
|
+
if parsed.scheme == "http" and not local and not allow_insecure:
|
|
71
|
+
raise ConfigurationError(
|
|
72
|
+
"remote HTTP would expose credentials; use HTTPS or set allow_insecure=True"
|
|
73
|
+
)
|
|
74
|
+
path = parsed.path.rstrip("/")
|
|
75
|
+
if not path.endswith("/api"):
|
|
76
|
+
path = f"{path}/api"
|
|
77
|
+
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _request_path(value: str) -> str:
|
|
81
|
+
parsed = urlsplit(value)
|
|
82
|
+
if parsed.scheme or parsed.netloc or parsed.fragment:
|
|
83
|
+
raise ConfigurationError("request path must be relative to the configured instance")
|
|
84
|
+
if parsed.query:
|
|
85
|
+
raise ConfigurationError("request path must not contain a query string; use params instead")
|
|
86
|
+
path = unquote(parsed.path).lstrip("/")
|
|
87
|
+
if path == "api":
|
|
88
|
+
path = ""
|
|
89
|
+
elif path.startswith("api/"):
|
|
90
|
+
path = path[len("api/") :]
|
|
91
|
+
if any(segment in {"..", "."} for segment in path.split("/")):
|
|
92
|
+
raise ConfigurationError("request path must not contain parent traversal")
|
|
93
|
+
if not path:
|
|
94
|
+
raise ConfigurationError("request path must identify an endpoint")
|
|
95
|
+
return path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _query_value(value: Any) -> Any:
|
|
99
|
+
if isinstance(value, bool):
|
|
100
|
+
return "true" if value else "false"
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _query(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
105
|
+
if params is None:
|
|
106
|
+
return None
|
|
107
|
+
return {
|
|
108
|
+
str(key): _query_value(value)
|
|
109
|
+
for key, value in params.items()
|
|
110
|
+
if value is not None and value != ""
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _success(response: Any) -> bool:
|
|
115
|
+
return 200 <= int(response.status_code) < 300
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _retry_after(response: Any) -> float | None:
|
|
119
|
+
value = response.headers.get("Retry-After")
|
|
120
|
+
if value is None:
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
return max(0.0, float(value))
|
|
124
|
+
except ValueError:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _set_cookie_values(response: Any) -> list[str]:
|
|
129
|
+
headers = response.headers
|
|
130
|
+
get_list = getattr(headers, "get_list", None)
|
|
131
|
+
if callable(get_list):
|
|
132
|
+
return [str(value) for value in get_list("set-cookie")]
|
|
133
|
+
raw = headers.get("set-cookie")
|
|
134
|
+
if raw is None:
|
|
135
|
+
return []
|
|
136
|
+
if isinstance(raw, str):
|
|
137
|
+
return [raw]
|
|
138
|
+
return [str(value) for value in raw]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class NewAPI:
|
|
142
|
+
"""An authenticated user session for one new-api instance."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
base_url: str,
|
|
147
|
+
*,
|
|
148
|
+
access_token: str | None = None,
|
|
149
|
+
refresh_token: str | None = None,
|
|
150
|
+
expires_at: float | None = None,
|
|
151
|
+
session_id: str | None = None,
|
|
152
|
+
session_token: str | None = None,
|
|
153
|
+
user_id: int | None = None,
|
|
154
|
+
session_cookie: str = _DEFAULT_SESSION_COOKIE,
|
|
155
|
+
timeout: float | tuple[float, float] = 30.0,
|
|
156
|
+
language: str = "en",
|
|
157
|
+
allow_insecure: bool = False,
|
|
158
|
+
impersonate: str | None = "chrome",
|
|
159
|
+
session: Any | None = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
self.base_url = _api_base_url(base_url, allow_insecure)
|
|
162
|
+
self.language = language
|
|
163
|
+
self._session_cookie = session_cookie
|
|
164
|
+
self._session_cookie_pattern = re.compile(
|
|
165
|
+
rf"(?:^|[;,\s]){re.escape(session_cookie)}=([^;\s]+)"
|
|
166
|
+
)
|
|
167
|
+
resolved_user_id = self._resolve_session_user_id(access_token, session_token, user_id)
|
|
168
|
+
self._tokens = SessionTokens(
|
|
169
|
+
access_token=access_token,
|
|
170
|
+
refresh_token=refresh_token,
|
|
171
|
+
expires_at=expires_at,
|
|
172
|
+
session_id=session_id,
|
|
173
|
+
session_token=session_token,
|
|
174
|
+
user_id=resolved_user_id,
|
|
175
|
+
)
|
|
176
|
+
self._refresh_lock = threading.RLock()
|
|
177
|
+
self._pending_2fa_token: str | None = None
|
|
178
|
+
self.timeout = timeout
|
|
179
|
+
self.impersonate = impersonate
|
|
180
|
+
self._quota_per_unit: float | None = None
|
|
181
|
+
self._owns_http_client = session is None
|
|
182
|
+
if session is None:
|
|
183
|
+
if impersonate:
|
|
184
|
+
self._http: Any = cast(Any, curl_requests.Session)(impersonate=impersonate)
|
|
185
|
+
else:
|
|
186
|
+
self._http = cast(Any, curl_requests.Session)()
|
|
187
|
+
else:
|
|
188
|
+
self._http = session
|
|
189
|
+
self.account = AccountResource(self)
|
|
190
|
+
self.tokens = TokensResource(self)
|
|
191
|
+
self.keys = self.tokens
|
|
192
|
+
self.logs = LogsResource(self)
|
|
193
|
+
self.usage = self.logs
|
|
194
|
+
self.history = self.logs
|
|
195
|
+
self.groups = GroupsResource(self)
|
|
196
|
+
self.dashboard = DashboardResource(self)
|
|
197
|
+
self.topup = TopUpResource(self)
|
|
198
|
+
self.payment = PaymentResource(self)
|
|
199
|
+
self.subscriptions = SubscriptionResource(self)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def session(self) -> SessionTokens:
|
|
203
|
+
return self._tokens
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def is_authenticated(self) -> bool:
|
|
207
|
+
return self._tokens.authenticated
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def quota_per_unit(self) -> float | None:
|
|
211
|
+
return self._quota_per_unit
|
|
212
|
+
|
|
213
|
+
def _resolve_session_user_id(
|
|
214
|
+
self,
|
|
215
|
+
access_token: str | None,
|
|
216
|
+
session_token: str | None,
|
|
217
|
+
user_id: int | None,
|
|
218
|
+
) -> int | None:
|
|
219
|
+
if access_token and session_token:
|
|
220
|
+
raise ConfigurationError("pass either access_token or session_token, not both")
|
|
221
|
+
if access_token and user_id is None:
|
|
222
|
+
raise ConfigurationError(
|
|
223
|
+
"user_id is required when authenticating with an access_token; "
|
|
224
|
+
"new-api instances reject such requests with 401 "
|
|
225
|
+
"'Unauthorized, New-Api-User header not provided'"
|
|
226
|
+
)
|
|
227
|
+
if not session_token or user_id is not None:
|
|
228
|
+
return user_id
|
|
229
|
+
try:
|
|
230
|
+
fields = decode_session_blob(session_token)
|
|
231
|
+
except ValueError as error:
|
|
232
|
+
raise ConfigurationError(
|
|
233
|
+
"session_token is not a decodable browser session cookie; "
|
|
234
|
+
"pass user_id alongside it for instances with opaque cookies"
|
|
235
|
+
) from error
|
|
236
|
+
embedded_id = fields.get("id")
|
|
237
|
+
if not isinstance(embedded_id, int) or isinstance(embedded_id, bool):
|
|
238
|
+
raise ConfigurationError(
|
|
239
|
+
"session_token does not embed a user id; pass user_id explicitly"
|
|
240
|
+
)
|
|
241
|
+
return embedded_id
|
|
242
|
+
|
|
243
|
+
def login(
|
|
244
|
+
self,
|
|
245
|
+
username: str,
|
|
246
|
+
password: str,
|
|
247
|
+
*,
|
|
248
|
+
turnstile_token: str | None = None,
|
|
249
|
+
encrypt_password: bool = True,
|
|
250
|
+
) -> User:
|
|
251
|
+
if not username.strip() or not password:
|
|
252
|
+
raise ValueError("username and password are required")
|
|
253
|
+
payload: dict[str, Any] = {"username": username}
|
|
254
|
+
key = self._password_encryption_key() if encrypt_password else None
|
|
255
|
+
if key is not None:
|
|
256
|
+
payload["password_encrypted"] = rsa_oaep_sha256_encrypt(
|
|
257
|
+
password.encode(), key["public_key"]
|
|
258
|
+
)
|
|
259
|
+
payload["encryption_key_id"] = key["kid"]
|
|
260
|
+
else:
|
|
261
|
+
payload["password"] = password
|
|
262
|
+
params = {"turnstile": turnstile_token} if turnstile_token else None
|
|
263
|
+
data, response = self._request_with_response(
|
|
264
|
+
"POST",
|
|
265
|
+
"user/login",
|
|
266
|
+
params=params,
|
|
267
|
+
json=payload,
|
|
268
|
+
authenticated=False,
|
|
269
|
+
allow_refresh=False,
|
|
270
|
+
)
|
|
271
|
+
auth = self._auth_mapping(data, "login")
|
|
272
|
+
if auth.get("require_2fa") is True:
|
|
273
|
+
flow_token = auth.get("flow_token")
|
|
274
|
+
if not isinstance(flow_token, str) or not flow_token:
|
|
275
|
+
raise ProtocolError("2FA login response did not contain a flow token")
|
|
276
|
+
self._pending_2fa_token = flow_token
|
|
277
|
+
expires_at = auth.get("expires_at")
|
|
278
|
+
raise TwoFactorRequired(
|
|
279
|
+
flow_token,
|
|
280
|
+
float(expires_at) if isinstance(expires_at, (int, float)) else None,
|
|
281
|
+
)
|
|
282
|
+
return self._accept_auth(auth, response)
|
|
283
|
+
|
|
284
|
+
def complete_2fa(self, code: str, *, flow_token: str | None = None) -> User:
|
|
285
|
+
token = flow_token or self._pending_2fa_token
|
|
286
|
+
if not token:
|
|
287
|
+
raise ValueError("flow_token is required when no 2FA login is pending")
|
|
288
|
+
if not code.strip():
|
|
289
|
+
raise ValueError("code must not be empty")
|
|
290
|
+
data, response = self._request_with_response(
|
|
291
|
+
"POST",
|
|
292
|
+
"user/login/2fa",
|
|
293
|
+
json={"code": code, "flow_token": token},
|
|
294
|
+
authenticated=False,
|
|
295
|
+
allow_refresh=False,
|
|
296
|
+
)
|
|
297
|
+
user = self._accept_auth(self._auth_mapping(data, "2FA login"), response)
|
|
298
|
+
self._pending_2fa_token = None
|
|
299
|
+
return user
|
|
300
|
+
|
|
301
|
+
def refresh(self) -> SessionTokens:
|
|
302
|
+
if self._tokens.session_token and not self._tokens.refresh_token:
|
|
303
|
+
raise AuthenticationError(
|
|
304
|
+
"Session-cookie authentication has no refresh flow; "
|
|
305
|
+
"extract a fresh browser session cookie instead",
|
|
306
|
+
status_code=401,
|
|
307
|
+
code="SESSION_COOKIE_NO_REFRESH",
|
|
308
|
+
)
|
|
309
|
+
with self._refresh_lock:
|
|
310
|
+
refresh_token = self._tokens.refresh_token
|
|
311
|
+
if not refresh_token:
|
|
312
|
+
raise AuthenticationError(
|
|
313
|
+
"No refresh token is available",
|
|
314
|
+
status_code=401,
|
|
315
|
+
code="NO_REFRESH_TOKEN",
|
|
316
|
+
)
|
|
317
|
+
try:
|
|
318
|
+
data, response = self._request_with_response(
|
|
319
|
+
"POST",
|
|
320
|
+
"user/auth/refresh",
|
|
321
|
+
refresh_cookie=refresh_token,
|
|
322
|
+
authenticated=False,
|
|
323
|
+
allow_refresh=False,
|
|
324
|
+
)
|
|
325
|
+
auth = self._auth_mapping(data, "token refresh")
|
|
326
|
+
access_token = auth.get("access_token")
|
|
327
|
+
if not isinstance(access_token, str) or not access_token:
|
|
328
|
+
raise ProtocolError("token refresh did not return an access token")
|
|
329
|
+
rotated = self._harvest_refresh_token(response) or refresh_token
|
|
330
|
+
self._tokens = self._tokens_from_auth(auth, rotated)
|
|
331
|
+
return self._tokens
|
|
332
|
+
except APIError as error:
|
|
333
|
+
if 400 <= error.status_code < 500:
|
|
334
|
+
self.clear_auth()
|
|
335
|
+
raise
|
|
336
|
+
|
|
337
|
+
def logout(self) -> None:
|
|
338
|
+
if self._tokens.session_token and not self._tokens.access_token:
|
|
339
|
+
try:
|
|
340
|
+
self._request(
|
|
341
|
+
"POST",
|
|
342
|
+
"user/logout",
|
|
343
|
+
authenticated=True,
|
|
344
|
+
allow_refresh=False,
|
|
345
|
+
)
|
|
346
|
+
finally:
|
|
347
|
+
self.clear_auth()
|
|
348
|
+
return
|
|
349
|
+
refresh_token = self._tokens.refresh_token
|
|
350
|
+
headers: dict[str, str] = {"Origin": self._origin()}
|
|
351
|
+
if self._tokens.user_id is not None:
|
|
352
|
+
headers[_USER_ID_HEADER] = str(self._tokens.user_id)
|
|
353
|
+
if self._tokens.access_token:
|
|
354
|
+
headers["Authorization"] = f"{self._tokens.token_type} {self._tokens.access_token}"
|
|
355
|
+
try:
|
|
356
|
+
self._request(
|
|
357
|
+
"POST",
|
|
358
|
+
"user/auth/logout",
|
|
359
|
+
refresh_cookie=refresh_token,
|
|
360
|
+
headers=headers,
|
|
361
|
+
authenticated=False,
|
|
362
|
+
allow_refresh=False,
|
|
363
|
+
)
|
|
364
|
+
finally:
|
|
365
|
+
self.clear_auth()
|
|
366
|
+
|
|
367
|
+
def clear_auth(self) -> None:
|
|
368
|
+
self._tokens = SessionTokens()
|
|
369
|
+
self._pending_2fa_token = None
|
|
370
|
+
|
|
371
|
+
def me(self) -> User:
|
|
372
|
+
return self.account.profile()
|
|
373
|
+
|
|
374
|
+
def balance(self) -> Balance:
|
|
375
|
+
profile = self.account.profile()
|
|
376
|
+
quota_per_unit = self._resolve_quota_per_unit()
|
|
377
|
+
try:
|
|
378
|
+
return Balance(
|
|
379
|
+
quota=int(profile.get("quota", 0)),
|
|
380
|
+
used_quota=int(profile.get("used_quota", 0)),
|
|
381
|
+
aff_quota=int(profile.get("aff_quota", 0)),
|
|
382
|
+
quota_per_unit=quota_per_unit,
|
|
383
|
+
)
|
|
384
|
+
except (TypeError, ValueError) as error:
|
|
385
|
+
raise ProtocolError("profile response did not contain valid quota values") from error
|
|
386
|
+
|
|
387
|
+
def status(self) -> Resource:
|
|
388
|
+
data = self._request(
|
|
389
|
+
"GET",
|
|
390
|
+
"status",
|
|
391
|
+
authenticated=False,
|
|
392
|
+
allow_refresh=False,
|
|
393
|
+
)
|
|
394
|
+
if not isinstance(data, Mapping):
|
|
395
|
+
raise ProtocolError("status returned a non-object response")
|
|
396
|
+
quota_per_unit = data.get("quota_per_unit")
|
|
397
|
+
if isinstance(quota_per_unit, (int, float)) and quota_per_unit > 0:
|
|
398
|
+
self._quota_per_unit = float(quota_per_unit)
|
|
399
|
+
return Resource(data)
|
|
400
|
+
|
|
401
|
+
def notice(self) -> Any:
|
|
402
|
+
return self._request("GET", "notice", authenticated=False, allow_refresh=False)
|
|
403
|
+
|
|
404
|
+
def about(self) -> Any:
|
|
405
|
+
return self._request("GET", "about", authenticated=False, allow_refresh=False)
|
|
406
|
+
|
|
407
|
+
def request(
|
|
408
|
+
self,
|
|
409
|
+
method: str,
|
|
410
|
+
path: str,
|
|
411
|
+
*,
|
|
412
|
+
params: Mapping[str, Any] | None = None,
|
|
413
|
+
json: Any = None,
|
|
414
|
+
headers: Mapping[str, str] | None = None,
|
|
415
|
+
authenticated: bool = True,
|
|
416
|
+
) -> Any:
|
|
417
|
+
return self._request(
|
|
418
|
+
method,
|
|
419
|
+
path,
|
|
420
|
+
params=params,
|
|
421
|
+
json=json,
|
|
422
|
+
headers=headers,
|
|
423
|
+
authenticated=authenticated,
|
|
424
|
+
allow_refresh=True,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def close(self) -> None:
|
|
428
|
+
if self._owns_http_client:
|
|
429
|
+
self._http.close()
|
|
430
|
+
|
|
431
|
+
def __enter__(self) -> NewAPI:
|
|
432
|
+
return self
|
|
433
|
+
|
|
434
|
+
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
|
|
435
|
+
self.close()
|
|
436
|
+
|
|
437
|
+
def _request(
|
|
438
|
+
self,
|
|
439
|
+
method: str,
|
|
440
|
+
path: str,
|
|
441
|
+
*,
|
|
442
|
+
params: Mapping[str, Any] | None = None,
|
|
443
|
+
json: Any = None,
|
|
444
|
+
headers: Mapping[str, str] | None = None,
|
|
445
|
+
authenticated: bool,
|
|
446
|
+
allow_refresh: bool,
|
|
447
|
+
refresh_cookie: str | None = None,
|
|
448
|
+
) -> Any:
|
|
449
|
+
return self._request_with_response(
|
|
450
|
+
method,
|
|
451
|
+
path,
|
|
452
|
+
params=params,
|
|
453
|
+
json=json,
|
|
454
|
+
headers=headers,
|
|
455
|
+
authenticated=authenticated,
|
|
456
|
+
allow_refresh=allow_refresh,
|
|
457
|
+
refresh_cookie=refresh_cookie,
|
|
458
|
+
)[0]
|
|
459
|
+
|
|
460
|
+
def _request_with_response(
|
|
461
|
+
self,
|
|
462
|
+
method: str,
|
|
463
|
+
path: str,
|
|
464
|
+
*,
|
|
465
|
+
params: Mapping[str, Any] | None = None,
|
|
466
|
+
json: Any = None,
|
|
467
|
+
headers: Mapping[str, str] | None = None,
|
|
468
|
+
authenticated: bool,
|
|
469
|
+
allow_refresh: bool,
|
|
470
|
+
refresh_cookie: str | None = None,
|
|
471
|
+
) -> tuple[Any, Any]:
|
|
472
|
+
normalized_method = method.upper()
|
|
473
|
+
if authenticated and allow_refresh:
|
|
474
|
+
self._refresh_if_expiring()
|
|
475
|
+
failed_token = self._tokens.access_token
|
|
476
|
+
response = self._send(
|
|
477
|
+
normalized_method,
|
|
478
|
+
path,
|
|
479
|
+
params=params,
|
|
480
|
+
json=json,
|
|
481
|
+
headers=headers,
|
|
482
|
+
authenticated=authenticated,
|
|
483
|
+
refresh_cookie=refresh_cookie,
|
|
484
|
+
)
|
|
485
|
+
if authenticated and self._tokens.session_token:
|
|
486
|
+
self._harvest_session_cookie(response)
|
|
487
|
+
if (
|
|
488
|
+
response.status_code == 401
|
|
489
|
+
and authenticated
|
|
490
|
+
and allow_refresh
|
|
491
|
+
and self._tokens.refresh_token
|
|
492
|
+
):
|
|
493
|
+
self._refresh_after_unauthorized(failed_token)
|
|
494
|
+
response = self._send(
|
|
495
|
+
normalized_method,
|
|
496
|
+
path,
|
|
497
|
+
params=params,
|
|
498
|
+
json=json,
|
|
499
|
+
headers=headers,
|
|
500
|
+
authenticated=authenticated,
|
|
501
|
+
)
|
|
502
|
+
return self._decode(response), response
|
|
503
|
+
|
|
504
|
+
def _send(
|
|
505
|
+
self,
|
|
506
|
+
method: str,
|
|
507
|
+
path: str,
|
|
508
|
+
*,
|
|
509
|
+
params: Mapping[str, Any] | None,
|
|
510
|
+
json: Any,
|
|
511
|
+
headers: Mapping[str, str] | None,
|
|
512
|
+
authenticated: bool,
|
|
513
|
+
refresh_cookie: str | None = None,
|
|
514
|
+
) -> Any:
|
|
515
|
+
relative_path = _request_path(path)
|
|
516
|
+
request_headers = {
|
|
517
|
+
"Accept": "application/json",
|
|
518
|
+
"Accept-Language": self.language,
|
|
519
|
+
"User-Agent": "newapi-python/0.3.0",
|
|
520
|
+
}
|
|
521
|
+
if authenticated:
|
|
522
|
+
if self._tokens.user_id is not None:
|
|
523
|
+
request_headers[_USER_ID_HEADER] = str(self._tokens.user_id)
|
|
524
|
+
if self._tokens.session_token:
|
|
525
|
+
request_headers["Cookie"] = f"{self._session_cookie}={self._tokens.session_token}"
|
|
526
|
+
elif self._tokens.access_token:
|
|
527
|
+
request_headers["Authorization"] = (
|
|
528
|
+
f"{self._tokens.token_type} {self._tokens.access_token}"
|
|
529
|
+
)
|
|
530
|
+
if refresh_cookie and not self._session_has_refresh_cookie():
|
|
531
|
+
request_headers["Cookie"] = f"{_REFRESH_COOKIE}={refresh_cookie}"
|
|
532
|
+
if headers:
|
|
533
|
+
request_headers.update(headers)
|
|
534
|
+
try:
|
|
535
|
+
return cast(Any, self._http).request(
|
|
536
|
+
method,
|
|
537
|
+
f"{self.base_url}/{relative_path}",
|
|
538
|
+
params=_query(params),
|
|
539
|
+
json=json,
|
|
540
|
+
headers=request_headers,
|
|
541
|
+
timeout=self.timeout,
|
|
542
|
+
allow_redirects=False,
|
|
543
|
+
)
|
|
544
|
+
except curl_requests.RequestsError as error:
|
|
545
|
+
raise TransportError(f"{method} request to the new-api instance failed") from error
|
|
546
|
+
|
|
547
|
+
def _decode(self, response: Any) -> Any:
|
|
548
|
+
if not response.content:
|
|
549
|
+
if _success(response):
|
|
550
|
+
return None
|
|
551
|
+
self._raise_api_error(response, {})
|
|
552
|
+
try:
|
|
553
|
+
payload = response.json()
|
|
554
|
+
except ValueError as error:
|
|
555
|
+
if not _success(response):
|
|
556
|
+
raise ProtocolError(
|
|
557
|
+
f"new-api returned non-JSON HTTP {response.status_code}"
|
|
558
|
+
) from error
|
|
559
|
+
raise ProtocolError("new-api returned a non-JSON success response") from error
|
|
560
|
+
if not _success(response):
|
|
561
|
+
self._raise_api_error(response, payload)
|
|
562
|
+
if isinstance(payload, Mapping):
|
|
563
|
+
if "success" in payload:
|
|
564
|
+
if payload.get("success") is not True:
|
|
565
|
+
self._raise_api_error(response, payload)
|
|
566
|
+
return payload.get("data")
|
|
567
|
+
if "message" in payload:
|
|
568
|
+
if payload.get("message") != "success":
|
|
569
|
+
self._raise_api_error(response, payload)
|
|
570
|
+
return payload
|
|
571
|
+
return payload
|
|
572
|
+
|
|
573
|
+
def _raise_api_error(self, response: Any, payload: Any) -> None:
|
|
574
|
+
data = payload if isinstance(payload, Mapping) else {}
|
|
575
|
+
code = data.get("code")
|
|
576
|
+
fallback_message = (
|
|
577
|
+
getattr(response, "reason_phrase", None)
|
|
578
|
+
or getattr(response, "reason", None)
|
|
579
|
+
or f"HTTP {response.status_code}"
|
|
580
|
+
)
|
|
581
|
+
message: Any = data.get("message")
|
|
582
|
+
detail = data.get("data")
|
|
583
|
+
if not isinstance(message, str) or message == "" or message == "error":
|
|
584
|
+
message = detail if isinstance(detail, str) and detail else fallback_message
|
|
585
|
+
status_code = response.status_code
|
|
586
|
+
error_type = _ERROR_TYPES.get(status_code, APIError)
|
|
587
|
+
raise error_type(
|
|
588
|
+
str(message),
|
|
589
|
+
status_code=status_code,
|
|
590
|
+
code=code if isinstance(code, (int, str)) else None,
|
|
591
|
+
retry_after=_retry_after(response),
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
def _origin(self) -> str:
|
|
595
|
+
parsed = urlsplit(self.base_url)
|
|
596
|
+
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
597
|
+
|
|
598
|
+
def _password_encryption_key(self) -> Resource | None:
|
|
599
|
+
data = self._request(
|
|
600
|
+
"GET",
|
|
601
|
+
"user/login/encryption-key",
|
|
602
|
+
authenticated=False,
|
|
603
|
+
allow_refresh=False,
|
|
604
|
+
)
|
|
605
|
+
if not isinstance(data, Mapping):
|
|
606
|
+
raise ProtocolError("login encryption key response was not an object")
|
|
607
|
+
key = Resource(data)
|
|
608
|
+
if not key.get("enabled"):
|
|
609
|
+
return None
|
|
610
|
+
if not key.get("kid") or not key.get("public_key"):
|
|
611
|
+
raise ProtocolError("login encryption key response was incomplete")
|
|
612
|
+
return key
|
|
613
|
+
|
|
614
|
+
def _harvest_refresh_token(self, response: Any) -> str | None:
|
|
615
|
+
for value in _set_cookie_values(response):
|
|
616
|
+
match = _REFRESH_COOKIE_PATTERN.search(value)
|
|
617
|
+
if match:
|
|
618
|
+
return match.group(1)
|
|
619
|
+
jar = getattr(self._http, "cookies", None)
|
|
620
|
+
if jar is not None:
|
|
621
|
+
try:
|
|
622
|
+
stored = jar.get(_REFRESH_COOKIE)
|
|
623
|
+
except Exception:
|
|
624
|
+
stored = None
|
|
625
|
+
if isinstance(stored, str) and stored:
|
|
626
|
+
return stored
|
|
627
|
+
return None
|
|
628
|
+
|
|
629
|
+
def _harvest_session_cookie(self, response: Any) -> None:
|
|
630
|
+
for value in _set_cookie_values(response):
|
|
631
|
+
match = self._session_cookie_pattern.search(value)
|
|
632
|
+
if match:
|
|
633
|
+
rotated = match.group(1)
|
|
634
|
+
if rotated and rotated != self._tokens.session_token:
|
|
635
|
+
self._tokens = replace(self._tokens, session_token=rotated)
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
def _session_has_refresh_cookie(self) -> bool:
|
|
639
|
+
jar = getattr(self._http, "cookies", None)
|
|
640
|
+
if jar is None:
|
|
641
|
+
return False
|
|
642
|
+
try:
|
|
643
|
+
stored = jar.get(_REFRESH_COOKIE)
|
|
644
|
+
except Exception:
|
|
645
|
+
return False
|
|
646
|
+
return isinstance(stored, str) and bool(stored)
|
|
647
|
+
|
|
648
|
+
def _refresh_if_expiring(self) -> None:
|
|
649
|
+
with self._refresh_lock:
|
|
650
|
+
if self._tokens.needs_refresh():
|
|
651
|
+
self.refresh()
|
|
652
|
+
|
|
653
|
+
def _refresh_after_unauthorized(self, failed_token: str | None) -> None:
|
|
654
|
+
with self._refresh_lock:
|
|
655
|
+
if self._tokens.access_token and self._tokens.access_token != failed_token:
|
|
656
|
+
return
|
|
657
|
+
self.refresh()
|
|
658
|
+
|
|
659
|
+
def _auth_mapping(self, data: Any, operation: str) -> Mapping[str, Any]:
|
|
660
|
+
if not isinstance(data, Mapping):
|
|
661
|
+
raise ProtocolError(f"{operation} returned a non-object response")
|
|
662
|
+
return data
|
|
663
|
+
|
|
664
|
+
def _accept_auth(self, auth: Mapping[str, Any], response: Any) -> User:
|
|
665
|
+
access_token = auth.get("access_token")
|
|
666
|
+
if not isinstance(access_token, str) or not access_token:
|
|
667
|
+
raise ProtocolError("login did not return an access token")
|
|
668
|
+
user_data = auth.get("user")
|
|
669
|
+
if not isinstance(user_data, Mapping):
|
|
670
|
+
raise ProtocolError("login did not return a user profile")
|
|
671
|
+
self._tokens = self._tokens_from_auth(auth, self._harvest_refresh_token(response))
|
|
672
|
+
return User(user_data)
|
|
673
|
+
|
|
674
|
+
def _tokens_from_auth(
|
|
675
|
+
self,
|
|
676
|
+
auth: Mapping[str, Any],
|
|
677
|
+
refresh_token: str | None,
|
|
678
|
+
) -> SessionTokens:
|
|
679
|
+
expires_at = None
|
|
680
|
+
raw_expires_at = auth.get("access_expires_at")
|
|
681
|
+
if isinstance(raw_expires_at, (int, float)) and not isinstance(raw_expires_at, bool):
|
|
682
|
+
expires_at = float(raw_expires_at)
|
|
683
|
+
token_type = auth.get("token_type")
|
|
684
|
+
session = auth.get("session")
|
|
685
|
+
session_id = None
|
|
686
|
+
if isinstance(session, Mapping):
|
|
687
|
+
raw_session_id = session.get("sid")
|
|
688
|
+
session_id = raw_session_id if isinstance(raw_session_id, str) else None
|
|
689
|
+
user_id = self._tokens.user_id
|
|
690
|
+
user_data = auth.get("user")
|
|
691
|
+
if isinstance(user_data, Mapping):
|
|
692
|
+
raw_user_id = user_data.get("id")
|
|
693
|
+
if isinstance(raw_user_id, int) and not isinstance(raw_user_id, bool):
|
|
694
|
+
user_id = raw_user_id
|
|
695
|
+
return SessionTokens(
|
|
696
|
+
access_token=str(auth["access_token"]),
|
|
697
|
+
refresh_token=refresh_token,
|
|
698
|
+
expires_at=expires_at,
|
|
699
|
+
token_type=str(token_type) if token_type else "Bearer",
|
|
700
|
+
session_id=session_id,
|
|
701
|
+
user_id=user_id,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
def _resolve_quota_per_unit(self) -> float:
|
|
705
|
+
if self._quota_per_unit is None:
|
|
706
|
+
self.status()
|
|
707
|
+
return self._quota_per_unit if self._quota_per_unit is not None else _DEFAULT_QUOTA_PER_UNIT
|
|
@@ -232,6 +232,7 @@ def test_unauthorized_request_refreshes_once_with_cookie_and_rotates() -> None:
|
|
|
232
232
|
"https://example.test",
|
|
233
233
|
access_token="access-old",
|
|
234
234
|
refresh_token="refresh-old",
|
|
235
|
+
user_id=1,
|
|
235
236
|
session=session,
|
|
236
237
|
)
|
|
237
238
|
|
|
@@ -276,6 +277,7 @@ def test_proactive_refresh_uses_expiration_timestamp() -> None:
|
|
|
276
277
|
access_token="expired",
|
|
277
278
|
refresh_token="refresh",
|
|
278
279
|
expires_at=time.time() - 1,
|
|
280
|
+
user_id=1,
|
|
279
281
|
session=session,
|
|
280
282
|
)
|
|
281
283
|
|
|
@@ -287,7 +289,7 @@ def test_proactive_refresh_uses_expiration_timestamp() -> None:
|
|
|
287
289
|
def test_refresh_without_cookie_header_when_jar_lacks_it() -> None:
|
|
288
290
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
289
291
|
assert request.url.path.endswith("/user/auth/refresh")
|
|
290
|
-
assert request.headers.get("cookie") == "new_api_refresh=
|
|
292
|
+
assert request.headers.get("cookie") == "new_api_refresh=fixture-refresh"
|
|
291
293
|
return httpx.Response(
|
|
292
294
|
200,
|
|
293
295
|
json={
|
|
@@ -305,15 +307,16 @@ def test_refresh_without_cookie_header_when_jar_lacks_it() -> None:
|
|
|
305
307
|
client = NewAPI(
|
|
306
308
|
"https://example.test",
|
|
307
309
|
access_token="expired",
|
|
308
|
-
refresh_token="
|
|
310
|
+
refresh_token="fixture-refresh",
|
|
309
311
|
expires_at=time.time() - 1,
|
|
312
|
+
user_id=5,
|
|
310
313
|
session=TestSession(handler),
|
|
311
314
|
)
|
|
312
315
|
|
|
313
316
|
tokens = client.refresh()
|
|
314
317
|
|
|
315
318
|
assert tokens.access_token == "fresh"
|
|
316
|
-
assert tokens.refresh_token == "
|
|
319
|
+
assert tokens.refresh_token == "fixture-refresh"
|
|
317
320
|
|
|
318
321
|
|
|
319
322
|
def test_business_error_envelope_raises_api_error() -> None:
|
|
@@ -376,6 +379,7 @@ def test_remote_plaintext_and_unsafe_request_urls_are_rejected() -> None:
|
|
|
376
379
|
client = NewAPI(
|
|
377
380
|
"https://example.test/api",
|
|
378
381
|
access_token="secret",
|
|
382
|
+
user_id=1,
|
|
379
383
|
session=TestSession(lambda request: envelope({})),
|
|
380
384
|
)
|
|
381
385
|
|
|
@@ -408,6 +412,7 @@ def test_refresh_rejection_clears_authentication() -> None:
|
|
|
408
412
|
"https://example.test",
|
|
409
413
|
access_token="access",
|
|
410
414
|
refresh_token="refresh",
|
|
415
|
+
user_id=1,
|
|
411
416
|
session=TestSession(handler),
|
|
412
417
|
)
|
|
413
418
|
|
|
@@ -423,12 +428,14 @@ def test_logout_sends_bearer_and_clears_auth() -> None:
|
|
|
423
428
|
assert request.url.path.endswith("/user/auth/logout")
|
|
424
429
|
assert request.headers["Authorization"] == "Bearer access-one"
|
|
425
430
|
assert request.headers["Origin"] == "https://example.test"
|
|
431
|
+
assert request.headers["New-Api-User"] == "1"
|
|
426
432
|
return envelope({"revoked_sid": "sid-1"})
|
|
427
433
|
|
|
428
434
|
client = NewAPI(
|
|
429
435
|
"https://example.test",
|
|
430
436
|
access_token="access-one",
|
|
431
437
|
refresh_token="refresh-one",
|
|
438
|
+
user_id=1,
|
|
432
439
|
session=TestSession(handler),
|
|
433
440
|
)
|
|
434
441
|
|
|
@@ -449,6 +456,7 @@ def test_balance_uses_status_quota_per_unit() -> None:
|
|
|
449
456
|
client = NewAPI(
|
|
450
457
|
"https://example.test",
|
|
451
458
|
access_token="token",
|
|
459
|
+
user_id=1,
|
|
452
460
|
session=TestSession(handler),
|
|
453
461
|
)
|
|
454
462
|
|
|
@@ -585,3 +593,43 @@ def test_session_cookie_is_redacted_from_representations() -> None:
|
|
|
585
593
|
|
|
586
594
|
assert token not in repr(client.session)
|
|
587
595
|
assert decode_session_blob(client.session.session_token)["id"] == 42
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def test_access_token_without_user_id_is_rejected() -> None:
|
|
599
|
+
with pytest.raises(ConfigurationError) as caught:
|
|
600
|
+
NewAPI("https://example.test", access_token="access")
|
|
601
|
+
|
|
602
|
+
assert "New-Api-User" in str(caught.value)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def test_access_token_auth_sends_new_api_user_header() -> None:
|
|
606
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
607
|
+
assert request.headers["Authorization"] == "Bearer token"
|
|
608
|
+
assert request.headers["New-Api-User"] == "42"
|
|
609
|
+
return envelope({"id": 42, "username": "root"})
|
|
610
|
+
|
|
611
|
+
client = NewAPI(
|
|
612
|
+
"https://example.test",
|
|
613
|
+
access_token="token",
|
|
614
|
+
user_id=42,
|
|
615
|
+
session=TestSession(handler),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
assert client.me().id == 42
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def test_login_captures_user_id_for_new_api_user_header() -> None:
|
|
622
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
623
|
+
if request.url.path.endswith("/user/login/encryption-key"):
|
|
624
|
+
return no_encryption(request)
|
|
625
|
+
if request.url.path.endswith("/user/login"):
|
|
626
|
+
return login_response()
|
|
627
|
+
assert request.headers.get("New-Api-User") == "1"
|
|
628
|
+
return envelope({"id": 1, "quota": 0})
|
|
629
|
+
|
|
630
|
+
client = NewAPI("https://example.test", session=TestSession(handler))
|
|
631
|
+
|
|
632
|
+
client.login("root", "secret")
|
|
633
|
+
|
|
634
|
+
assert client.me().id == 1
|
|
635
|
+
assert client.session.user_id == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|