supernote 0.13.6__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.
- supernote/__init__.py +28 -0
- supernote/alembic/__init__.py +0 -0
- supernote/alembic/env.py +121 -0
- supernote/alembic/script.py.mako +28 -0
- supernote/alembic/versions/0543a383957b_initial_schema.py +312 -0
- supernote/alembic.ini +149 -0
- supernote/cli/__init__.py +1 -0
- supernote/cli/admin.py +178 -0
- supernote/cli/client.py +434 -0
- supernote/cli/main.py +61 -0
- supernote/cli/notebook.py +313 -0
- supernote/cli/server.py +89 -0
- supernote/client/__init__.py +28 -0
- supernote/client/admin.py +76 -0
- supernote/client/api.py +135 -0
- supernote/client/api_model.py +231 -0
- supernote/client/auth.py +84 -0
- supernote/client/client.py +365 -0
- supernote/client/device.py +361 -0
- supernote/client/exceptions.py +33 -0
- supernote/client/extended.py +20 -0
- supernote/client/hashing.py +50 -0
- supernote/client/login_client.py +154 -0
- supernote/client/schedule.py +149 -0
- supernote/client/summary.py +156 -0
- supernote/client/web.py +213 -0
- supernote/models/__init__.py +14 -0
- supernote/models/auth.py +380 -0
- supernote/models/base.py +266 -0
- supernote/models/equipment.py +276 -0
- supernote/models/extended.py +124 -0
- supernote/models/file_common.py +95 -0
- supernote/models/file_device.py +571 -0
- supernote/models/file_web.py +602 -0
- supernote/models/schedule.py +663 -0
- supernote/models/summary.py +830 -0
- supernote/models/system.py +431 -0
- supernote/models/user.py +638 -0
- supernote/notebook/__init__.py +67 -0
- supernote/notebook/color.py +93 -0
- supernote/notebook/converter.py +560 -0
- supernote/notebook/decoder.py +398 -0
- supernote/notebook/exceptions.py +43 -0
- supernote/notebook/fileformat.py +463 -0
- supernote/notebook/manipulator.py +420 -0
- supernote/notebook/parser.py +737 -0
- supernote/notebook/utils.py +49 -0
- supernote/py.typed +0 -0
- supernote/server/__init__.py +12 -0
- supernote/server/app.py +428 -0
- supernote/server/config.py +265 -0
- supernote/server/constants.py +26 -0
- supernote/server/db/__init__.py +9 -0
- supernote/server/db/base.py +8 -0
- supernote/server/db/migrations.py +44 -0
- supernote/server/db/models/__init__.py +23 -0
- supernote/server/db/models/device.py +17 -0
- supernote/server/db/models/file.py +107 -0
- supernote/server/db/models/kv.py +17 -0
- supernote/server/db/models/login_record.py +22 -0
- supernote/server/db/models/note_processing.py +100 -0
- supernote/server/db/models/schedule.py +83 -0
- supernote/server/db/models/summary.py +122 -0
- supernote/server/db/models/user.py +30 -0
- supernote/server/db/session.py +88 -0
- supernote/server/events.py +63 -0
- supernote/server/exceptions.py +153 -0
- supernote/server/mcp/__init__.py +1 -0
- supernote/server/mcp/models.py +109 -0
- supernote/server/mcp/server.py +176 -0
- supernote/server/resources/__init__.py +0 -0
- supernote/server/resources/prompts/__init__.py +0 -0
- supernote/server/resources/prompts/ocr/common/context.md +19 -0
- supernote/server/resources/prompts/ocr/common/legend.md +11 -0
- supernote/server/resources/prompts/ocr/daily/prompt.md +12 -0
- supernote/server/resources/prompts/ocr/default/system.md +14 -0
- supernote/server/resources/prompts/ocr/monthly/prompt.md +22 -0
- supernote/server/resources/prompts/ocr/weekly/prompt.md +16 -0
- supernote/server/resources/prompts/summary/common/instruction.md +8 -0
- supernote/server/resources/prompts/summary/daily/prompt.md +6 -0
- supernote/server/resources/prompts/summary/default/prompt.md +4 -0
- supernote/server/resources/prompts/summary/monthly/prompt.md +6 -0
- supernote/server/resources/prompts/summary/weekly/prompt.md +6 -0
- supernote/server/routes/admin.py +109 -0
- supernote/server/routes/auth.py +337 -0
- supernote/server/routes/decorators.py +13 -0
- supernote/server/routes/extended.py +139 -0
- supernote/server/routes/file_device.py +593 -0
- supernote/server/routes/file_web.py +597 -0
- supernote/server/routes/oss.py +317 -0
- supernote/server/routes/schedule.py +223 -0
- supernote/server/routes/summary.py +390 -0
- supernote/server/routes/system.py +57 -0
- supernote/server/services/__init__.py +15 -0
- supernote/server/services/blob.py +214 -0
- supernote/server/services/coordination.py +171 -0
- supernote/server/services/file.py +970 -0
- supernote/server/services/gemini.py +66 -0
- supernote/server/services/integrity.py +112 -0
- supernote/server/services/processor.py +331 -0
- supernote/server/services/processor_modules/__init__.py +145 -0
- supernote/server/services/processor_modules/gemini_embedding.py +112 -0
- supernote/server/services/processor_modules/gemini_ocr.py +149 -0
- supernote/server/services/processor_modules/page_hashing.py +220 -0
- supernote/server/services/processor_modules/png_conversion.py +107 -0
- supernote/server/services/processor_modules/summary.py +302 -0
- supernote/server/services/prompt_loader.py +133 -0
- supernote/server/services/schedule.py +161 -0
- supernote/server/services/search.py +250 -0
- supernote/server/services/summary.py +419 -0
- supernote/server/services/user.py +480 -0
- supernote/server/services/vfs.py +476 -0
- supernote/server/static/favicon.ico +0 -0
- supernote/server/static/index.html +186 -0
- supernote/server/static/js/api/client.js +463 -0
- supernote/server/static/js/components/FileCard.js +55 -0
- supernote/server/static/js/components/FileViewer.js +137 -0
- supernote/server/static/js/components/LoginCard.js +64 -0
- supernote/server/static/js/components/MoveModal.js +74 -0
- supernote/server/static/js/components/RenameModal.js +39 -0
- supernote/server/static/js/components/SummaryPanel.js +101 -0
- supernote/server/static/js/components/SystemPanel.js +158 -0
- supernote/server/static/js/composables/useFileSystem.js +116 -0
- supernote/server/static/js/main.js +241 -0
- supernote/server/static/style.css +14 -0
- supernote/server/utils/hashing.py +18 -0
- supernote/server/utils/note_content.py +76 -0
- supernote/server/utils/paths.py +58 -0
- supernote/server/utils/rate_limit.py +52 -0
- supernote/server/utils/tasks.py +71 -0
- supernote/server/utils/unique_id.py +48 -0
- supernote/server/utils/url_signer.py +218 -0
- supernote-0.13.6.dist-info/METADATA +183 -0
- supernote-0.13.6.dist-info/RECORD +138 -0
- supernote-0.13.6.dist-info/WHEEL +5 -0
- supernote-0.13.6.dist-info/entry_points.txt +3 -0
- supernote-0.13.6.dist-info/licenses/LICENSE +201 -0
- supernote-0.13.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Model classes for the Supernote Service API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from mashumaro import field_options
|
|
6
|
+
from mashumaro.config import BaseConfig
|
|
7
|
+
from mashumaro.mixins.json import DataClassJSONMixin
|
|
8
|
+
|
|
9
|
+
COUNTRY_CODE = 1
|
|
10
|
+
BROWSER = "Chrome142"
|
|
11
|
+
EQUIPMENT = 1
|
|
12
|
+
LANGUAGE = "en"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class BaseResponse(DataClassJSONMixin):
|
|
17
|
+
"""Base response class."""
|
|
18
|
+
|
|
19
|
+
success: bool = True
|
|
20
|
+
error_code: str = field(metadata=field_options(alias="errorCode"), default="")
|
|
21
|
+
error_msg: str = field(metadata=field_options(alias="errorMsg"), default="")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class QueryUserRequest(DataClassJSONMixin):
|
|
26
|
+
"""Request to query user."""
|
|
27
|
+
|
|
28
|
+
account: str
|
|
29
|
+
country_code: int = field(
|
|
30
|
+
metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
class Config(BaseConfig):
|
|
34
|
+
serialize_by_alias = True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(kw_only=True)
|
|
38
|
+
class QueryUserResponse(BaseResponse):
|
|
39
|
+
"""Response from query user call."""
|
|
40
|
+
|
|
41
|
+
user_id: str = field(metadata=field_options(alias="userId"))
|
|
42
|
+
user_name: str = field(metadata=field_options(alias="userName"))
|
|
43
|
+
birthday: str = field(metadata=field_options(alias="birthday"))
|
|
44
|
+
country_code: str = field(
|
|
45
|
+
metadata=field_options(alias="countryCode"), default=str(COUNTRY_CODE)
|
|
46
|
+
)
|
|
47
|
+
telephone: str = field(metadata=field_options(alias="telephone"), default="")
|
|
48
|
+
sex: str = ""
|
|
49
|
+
file_server: str = field(metadata=field_options(alias="fileServer"), default="")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class TokenRequest(DataClassJSONMixin):
|
|
54
|
+
"""Request to token endpoint."""
|
|
55
|
+
|
|
56
|
+
class Config(BaseConfig):
|
|
57
|
+
serialize_by_alias = True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class TokenResponse(BaseResponse):
|
|
62
|
+
"""Response from token endpoint."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class UserRandomCodeRequest(DataClassJSONMixin):
|
|
67
|
+
"""Request to get a random code."""
|
|
68
|
+
|
|
69
|
+
account: str
|
|
70
|
+
country_code: int = field(
|
|
71
|
+
metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
class Config(BaseConfig):
|
|
75
|
+
serialize_by_alias = True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class UserRandomCodeResponse(BaseResponse):
|
|
80
|
+
"""Response from login."""
|
|
81
|
+
|
|
82
|
+
random_code: str = field(metadata=field_options(alias="randomCode"), default="")
|
|
83
|
+
timestamp: str = ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class UserLoginRequest(DataClassJSONMixin):
|
|
88
|
+
"""Request to login."""
|
|
89
|
+
|
|
90
|
+
account: str
|
|
91
|
+
password: str
|
|
92
|
+
login_method: int = field(metadata=field_options(alias="loginMethod"))
|
|
93
|
+
timestamp: str
|
|
94
|
+
language: str = LANGUAGE
|
|
95
|
+
country_code: int = field(
|
|
96
|
+
metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
|
|
97
|
+
)
|
|
98
|
+
browser: str = BROWSER
|
|
99
|
+
equipment: int = EQUIPMENT
|
|
100
|
+
|
|
101
|
+
class Config(BaseConfig):
|
|
102
|
+
serialize_by_alias = True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(kw_only=True)
|
|
106
|
+
class UserLoginResponse(BaseResponse):
|
|
107
|
+
"""Response from access token call."""
|
|
108
|
+
|
|
109
|
+
token: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class UserSmsLoginRequest(DataClassJSONMixin):
|
|
114
|
+
"""Request to login via sms."""
|
|
115
|
+
|
|
116
|
+
telephone: str
|
|
117
|
+
timestamp: str
|
|
118
|
+
valid_code: str = field(metadata=field_options(alias="validCode"))
|
|
119
|
+
# String like "1-{telephone}_validCode"
|
|
120
|
+
valid_code_key: str = field(metadata=field_options(alias="validCodeKey"))
|
|
121
|
+
|
|
122
|
+
country_code: int = field(
|
|
123
|
+
metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
|
|
124
|
+
)
|
|
125
|
+
browser: str = BROWSER
|
|
126
|
+
equipment: int = EQUIPMENT
|
|
127
|
+
|
|
128
|
+
class Config(BaseConfig):
|
|
129
|
+
serialize_by_alias = True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class UserPreAuthRequest(DataClassJSONMixin):
|
|
134
|
+
"""Request for pre-auth."""
|
|
135
|
+
|
|
136
|
+
account: str
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class UserPreAuthResponse(BaseResponse):
|
|
141
|
+
"""Response from pre-auth."""
|
|
142
|
+
|
|
143
|
+
token: str = ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class UserSendSmsRequest(DataClassJSONMixin):
|
|
148
|
+
"""Request to send SMS code."""
|
|
149
|
+
|
|
150
|
+
telephone: str
|
|
151
|
+
timestamp: str
|
|
152
|
+
token: str
|
|
153
|
+
sign: str
|
|
154
|
+
nationcode: int = field(
|
|
155
|
+
metadata=field_options(alias="nationcode"), default=COUNTRY_CODE
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
class Config(BaseConfig):
|
|
159
|
+
serialize_by_alias = True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class UserSendSmsResponse(BaseResponse):
|
|
164
|
+
"""Response from send SMS."""
|
|
165
|
+
|
|
166
|
+
valid_code_key: str = field(
|
|
167
|
+
metadata=field_options(alias="validCodeKey"), default=""
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(kw_only=True)
|
|
172
|
+
class UserSmsLoginResponse(BaseResponse):
|
|
173
|
+
"""Response from access token call."""
|
|
174
|
+
|
|
175
|
+
token: str
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(kw_only=True)
|
|
179
|
+
class File(DataClassJSONMixin):
|
|
180
|
+
"""Representation of a file."""
|
|
181
|
+
|
|
182
|
+
id: int
|
|
183
|
+
directory_id: int = field(metadata=field_options(alias="directoryId"))
|
|
184
|
+
file_name: str = field(metadata=field_options(alias="fileName"))
|
|
185
|
+
size: int = 0
|
|
186
|
+
md5: str = ""
|
|
187
|
+
is_folder: str = field(metadata=field_options(alias="isFolder")) # "Y" or "N"
|
|
188
|
+
create_time: int = field(metadata=field_options(alias="createTime"))
|
|
189
|
+
update_time: int = field(metadata=field_options(alias="updateTime"))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass
|
|
193
|
+
class FileListRequest(DataClassJSONMixin):
|
|
194
|
+
"""Request for file list."""
|
|
195
|
+
|
|
196
|
+
directory_id: int = field(metadata=field_options(alias="directoryId"))
|
|
197
|
+
page_no: int = field(metadata=field_options(alias="pageNo"))
|
|
198
|
+
page_size: int = field(metadata=field_options(alias="pageSize"), default=20)
|
|
199
|
+
order: str = "time"
|
|
200
|
+
sequence: str = "desc"
|
|
201
|
+
|
|
202
|
+
class Config(BaseConfig):
|
|
203
|
+
serialize_by_alias = True
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(kw_only=True)
|
|
207
|
+
class FileListResponse(BaseResponse):
|
|
208
|
+
"""Response from file list call."""
|
|
209
|
+
|
|
210
|
+
total: int
|
|
211
|
+
size: int
|
|
212
|
+
pages: int
|
|
213
|
+
file_list: list[File] = field(metadata=field_options(alias="userFileVOList"))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass
|
|
217
|
+
class GetFileDownloadUrlRequest(DataClassJSONMixin):
|
|
218
|
+
"""Request for file download."""
|
|
219
|
+
|
|
220
|
+
file_id: int = field(metadata=field_options(alias="id"))
|
|
221
|
+
file_type: int = field(metadata=field_options(alias="type"), default=0)
|
|
222
|
+
|
|
223
|
+
class Config(BaseConfig):
|
|
224
|
+
serialize_by_alias = True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(kw_only=True)
|
|
228
|
+
class GetFileDownloadUrlResponse(BaseResponse):
|
|
229
|
+
"""Response from file download call."""
|
|
230
|
+
|
|
231
|
+
url: str
|
supernote/client/auth.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Library for authentication."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import pickle
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
_LOGGER = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AbstractAuth(ABC):
|
|
12
|
+
"""Authentication library."""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def async_get_access_token(self) -> str:
|
|
16
|
+
"""Return a valid access token."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConstantAuth(AbstractAuth):
|
|
20
|
+
"""Authentication library."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, access_token: str):
|
|
23
|
+
"""Initialize the auth."""
|
|
24
|
+
self._access_token = access_token
|
|
25
|
+
|
|
26
|
+
async def async_get_access_token(self) -> str:
|
|
27
|
+
"""Return a valid access token."""
|
|
28
|
+
return self._access_token
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def token(self) -> str:
|
|
32
|
+
"""Return the access token."""
|
|
33
|
+
return self._access_token
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FileCacheAuth(AbstractAuth):
|
|
37
|
+
"""Authentication library that caches token in a file."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, cache_path: str):
|
|
40
|
+
"""Initialize the auth."""
|
|
41
|
+
self._cache_path = cache_path
|
|
42
|
+
self._access_token: str | None = None
|
|
43
|
+
self._host: str | None = None
|
|
44
|
+
try:
|
|
45
|
+
self._load_from_cache()
|
|
46
|
+
except ValueError as err:
|
|
47
|
+
_LOGGER.info(f"No cached credentials found at {self._cache_path} ({err})")
|
|
48
|
+
|
|
49
|
+
async def async_get_access_token(self) -> str:
|
|
50
|
+
"""Return a valid access token."""
|
|
51
|
+
if self._access_token:
|
|
52
|
+
return self._access_token
|
|
53
|
+
raise ValueError(f"No access token found in cache: {self._cache_path}")
|
|
54
|
+
|
|
55
|
+
def get_host(self) -> str | None:
|
|
56
|
+
"""Return the cached host URL."""
|
|
57
|
+
return self._host
|
|
58
|
+
|
|
59
|
+
def _load_from_cache(self) -> None:
|
|
60
|
+
if not os.path.exists(self._cache_path):
|
|
61
|
+
raise ValueError("Cache file does not exist")
|
|
62
|
+
try:
|
|
63
|
+
with open(self._cache_path, "rb") as f:
|
|
64
|
+
data = pickle.load(f)
|
|
65
|
+
except Exception as err:
|
|
66
|
+
raise ValueError(f"Failed to load token from cache: {err}")
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
raise ValueError("Cache file is not a dictionary")
|
|
69
|
+
self._access_token = data["access_token"]
|
|
70
|
+
if "host" not in data:
|
|
71
|
+
raise ValueError("Cache file is missing host")
|
|
72
|
+
self._host = data["host"]
|
|
73
|
+
|
|
74
|
+
def save_credentials(self, token: str, host: str) -> None:
|
|
75
|
+
"""Save access token and host to cache."""
|
|
76
|
+
self._access_token = token
|
|
77
|
+
self._host = host
|
|
78
|
+
|
|
79
|
+
# Ensure directory exists
|
|
80
|
+
os.makedirs(os.path.dirname(self._cache_path), exist_ok=True)
|
|
81
|
+
|
|
82
|
+
with open(self._cache_path, "wb") as f:
|
|
83
|
+
data = {"access_token": token, "host": host}
|
|
84
|
+
pickle.dump(data, f)
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Library for accessing backups in Supenote Cloud."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any, Type, TypeVar
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from aiohttp import FormData
|
|
10
|
+
from aiohttp.client_exceptions import ClientError
|
|
11
|
+
|
|
12
|
+
from supernote.models.base import BaseResponse
|
|
13
|
+
from supernote.models.system import FileChunkParams, FileChunkVO, UploadFileVO
|
|
14
|
+
|
|
15
|
+
from .auth import AbstractAuth
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
ApiException,
|
|
18
|
+
BadRequestException,
|
|
19
|
+
ForbiddenException,
|
|
20
|
+
NotFoundException,
|
|
21
|
+
UnauthorizedException,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_LOGGER = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Client",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_T = TypeVar("_T", bound=BaseResponse)
|
|
32
|
+
|
|
33
|
+
CLOUD_API_URL = "https://cloud.supernote.com"
|
|
34
|
+
HEADERS = {
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
|
|
37
|
+
}
|
|
38
|
+
ACCESS_TOKEN = "x-access-token"
|
|
39
|
+
XSRF_COOKIE = "XSRF-TOKEN"
|
|
40
|
+
XSRF_HEADER = "X-XSRF-TOKEN"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _create_headers(host: str | None = None) -> dict[str, Any]:
|
|
44
|
+
headers = {
|
|
45
|
+
**HEADERS,
|
|
46
|
+
}
|
|
47
|
+
if host:
|
|
48
|
+
headers["Referer"] = host
|
|
49
|
+
headers["Origin"] = host
|
|
50
|
+
return headers
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Client:
|
|
54
|
+
"""Library that makes authenticated HTTP requests."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
websession: aiohttp.ClientSession,
|
|
59
|
+
host: str | None = None,
|
|
60
|
+
auth: AbstractAuth | None = None,
|
|
61
|
+
):
|
|
62
|
+
"""Initialize the auth."""
|
|
63
|
+
self._websession = websession
|
|
64
|
+
self._host = host or CLOUD_API_URL
|
|
65
|
+
self._auth = auth
|
|
66
|
+
self._xsrf_token: str | None = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def host(self) -> str:
|
|
70
|
+
"""Return the host URL."""
|
|
71
|
+
return self._host
|
|
72
|
+
|
|
73
|
+
def with_auth(self, auth: AbstractAuth) -> "Client":
|
|
74
|
+
"""Return a new client with the given authentication credentials."""
|
|
75
|
+
return Client(self._websession, host=self._host, auth=auth)
|
|
76
|
+
|
|
77
|
+
def get_auth(self) -> AbstractAuth | None:
|
|
78
|
+
"""Return the current authentication credentials."""
|
|
79
|
+
return self._auth
|
|
80
|
+
|
|
81
|
+
def _url(self, url: str) -> str:
|
|
82
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
83
|
+
if self._host.endswith("/"):
|
|
84
|
+
if url.startswith("/"):
|
|
85
|
+
url = url[1:]
|
|
86
|
+
elif not url.startswith("/"):
|
|
87
|
+
url = f"/{url}"
|
|
88
|
+
url = f"{self._host}{url}"
|
|
89
|
+
return url
|
|
90
|
+
|
|
91
|
+
async def request(
|
|
92
|
+
self,
|
|
93
|
+
method: str,
|
|
94
|
+
url: str,
|
|
95
|
+
headers: dict[str, Any] | None = None,
|
|
96
|
+
**kwargs: Any,
|
|
97
|
+
) -> aiohttp.ClientResponse:
|
|
98
|
+
"""Make a request."""
|
|
99
|
+
if headers is None:
|
|
100
|
+
headers = _create_headers(self._host)
|
|
101
|
+
# Always get a fresh CSRF token
|
|
102
|
+
self._xsrf_token = await self._get_csrf_token()
|
|
103
|
+
headers[XSRF_HEADER] = self._xsrf_token
|
|
104
|
+
|
|
105
|
+
if self._auth and ACCESS_TOKEN not in headers:
|
|
106
|
+
access_token = await self._auth.async_get_access_token()
|
|
107
|
+
headers[ACCESS_TOKEN] = access_token
|
|
108
|
+
url = self._url(url)
|
|
109
|
+
_LOGGER.debug(
|
|
110
|
+
"request[%s]=%s %s %s",
|
|
111
|
+
method,
|
|
112
|
+
url,
|
|
113
|
+
kwargs.get("params"),
|
|
114
|
+
headers,
|
|
115
|
+
)
|
|
116
|
+
if method != "get" and "json" in kwargs:
|
|
117
|
+
_LOGGER.debug("request[post json]=%s", kwargs["json"])
|
|
118
|
+
response = await self._websession.request(
|
|
119
|
+
method, url, **kwargs, headers=headers
|
|
120
|
+
)
|
|
121
|
+
return response
|
|
122
|
+
|
|
123
|
+
async def get(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
124
|
+
"""Make a get request."""
|
|
125
|
+
try:
|
|
126
|
+
resp = await self.request("get", url, **kwargs)
|
|
127
|
+
except ClientError as err:
|
|
128
|
+
raise ApiException(f"Error connecting to API: {err}") from err
|
|
129
|
+
return await self._raise_for_status(resp)
|
|
130
|
+
|
|
131
|
+
async def get_json(
|
|
132
|
+
self,
|
|
133
|
+
url: str,
|
|
134
|
+
data_cls: Type[_T],
|
|
135
|
+
**kwargs: Any,
|
|
136
|
+
) -> _T:
|
|
137
|
+
"""Make a get request and return json response."""
|
|
138
|
+
resp = await self.get(url, **kwargs)
|
|
139
|
+
try:
|
|
140
|
+
result = await resp.text()
|
|
141
|
+
except ClientError as err:
|
|
142
|
+
raise ApiException("Server returned malformed response") from err
|
|
143
|
+
_LOGGER.debug("response=%s", result)
|
|
144
|
+
try:
|
|
145
|
+
data_response = data_cls.from_json(result)
|
|
146
|
+
except (LookupError, ValueError) as err:
|
|
147
|
+
raise ApiException(f"Server return malformed response: {result}") from err
|
|
148
|
+
if not data_response.success:
|
|
149
|
+
raise ApiException(data_response.error_msg)
|
|
150
|
+
return data_response
|
|
151
|
+
|
|
152
|
+
async def post(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
153
|
+
"""Make a post request."""
|
|
154
|
+
try:
|
|
155
|
+
resp = await self.request("post", url, **kwargs)
|
|
156
|
+
except ClientError as err:
|
|
157
|
+
raise ApiException(f"Error connecting to API: {err}") from err
|
|
158
|
+
return await self._raise_for_status(resp)
|
|
159
|
+
|
|
160
|
+
async def put(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
161
|
+
"""Make a put request."""
|
|
162
|
+
try:
|
|
163
|
+
resp = await self.request("put", url, **kwargs)
|
|
164
|
+
except ClientError as err:
|
|
165
|
+
raise ApiException(f"Error connecting to API: {err}") from err
|
|
166
|
+
return await self._raise_for_status(resp)
|
|
167
|
+
|
|
168
|
+
async def get_content(self, url: str, **kwargs: Any) -> bytes:
|
|
169
|
+
"""Make a get request and return bytes."""
|
|
170
|
+
resp = await self.get(url, **kwargs)
|
|
171
|
+
try:
|
|
172
|
+
return await resp.read()
|
|
173
|
+
except ClientError as err:
|
|
174
|
+
raise ApiException(f"Error reading response: {err}") from err
|
|
175
|
+
|
|
176
|
+
async def post_json(self, url: str, data_cls: Type[_T], **kwargs: Any) -> _T:
|
|
177
|
+
"""Make a post request and return a json response."""
|
|
178
|
+
resp = await self.post(url, **kwargs)
|
|
179
|
+
try:
|
|
180
|
+
result = await resp.text()
|
|
181
|
+
except ClientError as err:
|
|
182
|
+
raise ApiException("Server returned malformed response") from err
|
|
183
|
+
try:
|
|
184
|
+
data_response = data_cls.from_json(result)
|
|
185
|
+
except (LookupError, ValueError) as err:
|
|
186
|
+
raise ApiException(
|
|
187
|
+
f"Server return malformed response type {data_cls.__name__}: {result}"
|
|
188
|
+
) from err
|
|
189
|
+
if not data_response.success:
|
|
190
|
+
raise ApiException(data_response.error_msg)
|
|
191
|
+
return data_response
|
|
192
|
+
|
|
193
|
+
async def put_json(self, url: str, data_cls: Type[_T], **kwargs: Any) -> _T:
|
|
194
|
+
"""Make a put request and return a json response."""
|
|
195
|
+
resp = await self.put(url, **kwargs)
|
|
196
|
+
try:
|
|
197
|
+
result = await resp.text()
|
|
198
|
+
except ClientError as err:
|
|
199
|
+
raise ApiException("Server returned malformed response") from err
|
|
200
|
+
try:
|
|
201
|
+
data_response = data_cls.from_json(result)
|
|
202
|
+
except (LookupError, ValueError) as err:
|
|
203
|
+
raise ApiException(
|
|
204
|
+
f"Server return malformed response type {data_cls.__name__}: {result}"
|
|
205
|
+
) from err
|
|
206
|
+
if not data_response.success:
|
|
207
|
+
raise ApiException(data_response.error_msg)
|
|
208
|
+
return data_response
|
|
209
|
+
|
|
210
|
+
async def _get_csrf_token(self) -> str:
|
|
211
|
+
"""Get the CSRF token."""
|
|
212
|
+
url = self._url("/api/csrf")
|
|
213
|
+
_LOGGER.debug("CSRF request[get]=%s %s", url, HEADERS)
|
|
214
|
+
resp = await self._websession.request("get", url, headers=HEADERS)
|
|
215
|
+
try:
|
|
216
|
+
await resp.text()
|
|
217
|
+
except ClientError as err:
|
|
218
|
+
raise ApiException("Server returned malformed response") from err
|
|
219
|
+
# Can be added back later for debugging
|
|
220
|
+
# _LOGGER.debug("CSRF response headers=%s", resp.headers)
|
|
221
|
+
token = resp.headers.get(XSRF_HEADER)
|
|
222
|
+
if token is None:
|
|
223
|
+
raise ApiException("Failed to get CSRF token from header")
|
|
224
|
+
return token
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
async def _raise_for_status(
|
|
228
|
+
cls, resp: aiohttp.ClientResponse
|
|
229
|
+
) -> aiohttp.ClientResponse:
|
|
230
|
+
"""Raise exceptions on failure methods."""
|
|
231
|
+
error_detail = await cls._error_detail(resp)
|
|
232
|
+
try:
|
|
233
|
+
resp.raise_for_status()
|
|
234
|
+
except aiohttp.ClientResponseError as err:
|
|
235
|
+
if err.status == 401:
|
|
236
|
+
raise UnauthorizedException(
|
|
237
|
+
f"Unauthorized response from API ({err.status}): {error_detail}"
|
|
238
|
+
) from err
|
|
239
|
+
if err.status == 403:
|
|
240
|
+
raise ForbiddenException(
|
|
241
|
+
f"Forbidden response from API ({err.status}): {error_detail}"
|
|
242
|
+
) from err
|
|
243
|
+
if err.status == 404:
|
|
244
|
+
raise NotFoundException(
|
|
245
|
+
f"Not found response from API ({err.status}): {error_detail}"
|
|
246
|
+
) from err
|
|
247
|
+
if err.status == 400:
|
|
248
|
+
raise BadRequestException(
|
|
249
|
+
f"Bad request response from API ({err.status}): {error_detail}"
|
|
250
|
+
) from err
|
|
251
|
+
error_message = f"Error response from API ({err.status}): {error_detail}"
|
|
252
|
+
raise ApiException(error_message) from err
|
|
253
|
+
except aiohttp.ClientError as err:
|
|
254
|
+
raise ApiException(f"Error from API: {err}") from err
|
|
255
|
+
return resp
|
|
256
|
+
|
|
257
|
+
@classmethod
|
|
258
|
+
async def _error_detail(cls, resp: aiohttp.ClientResponse) -> str | None:
|
|
259
|
+
"""Returns an error message string from the APi response."""
|
|
260
|
+
if resp.status < 400:
|
|
261
|
+
return None
|
|
262
|
+
try:
|
|
263
|
+
result = await resp.text()
|
|
264
|
+
except ClientError:
|
|
265
|
+
return None
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
async def _upload_to_oss(
|
|
269
|
+
self,
|
|
270
|
+
content: bytes,
|
|
271
|
+
filename: str,
|
|
272
|
+
full_upload_url: str | None,
|
|
273
|
+
part_upload_url: str | None,
|
|
274
|
+
chunk_size: int = 5 * 1024 * 1024,
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Upload content to OSS (support single or multi-part)."""
|
|
277
|
+
size = len(content)
|
|
278
|
+
|
|
279
|
+
if size < chunk_size or part_upload_url is None:
|
|
280
|
+
if full_upload_url is None:
|
|
281
|
+
raise ValueError("No upload URL available")
|
|
282
|
+
|
|
283
|
+
# Compute MD5 of content for verification
|
|
284
|
+
content_md5 = hashlib.md5(content).hexdigest()
|
|
285
|
+
|
|
286
|
+
_LOGGER.debug(
|
|
287
|
+
"Uploading file %s in one chunk (MD5: %s)", filename, content_md5
|
|
288
|
+
)
|
|
289
|
+
data = FormData()
|
|
290
|
+
data.add_field("file", content, filename=filename)
|
|
291
|
+
# Pass empty dict to headers to avoid default application/json Content-Type
|
|
292
|
+
try:
|
|
293
|
+
resp = await self.request(
|
|
294
|
+
"post",
|
|
295
|
+
full_upload_url,
|
|
296
|
+
data=data,
|
|
297
|
+
headers={},
|
|
298
|
+
)
|
|
299
|
+
except ClientError as err:
|
|
300
|
+
raise ApiException("Failed to upload file") from err
|
|
301
|
+
# Parse the UploadFileVO response
|
|
302
|
+
try:
|
|
303
|
+
result = await resp.text()
|
|
304
|
+
except ClientError:
|
|
305
|
+
raise ApiException("Server returned malformed response")
|
|
306
|
+
_LOGGER.debug("Upload response: %s", result)
|
|
307
|
+
try:
|
|
308
|
+
upload_vo = UploadFileVO.from_json(result)
|
|
309
|
+
except (LookupError, ValueError) as err:
|
|
310
|
+
raise ApiException(
|
|
311
|
+
f"Server returned malformed upload response: {result}"
|
|
312
|
+
) from err
|
|
313
|
+
if not upload_vo.success:
|
|
314
|
+
raise ApiException(f"Upload failed: {upload_vo.error_msg}")
|
|
315
|
+
|
|
316
|
+
# Verify MD5 matches
|
|
317
|
+
if upload_vo.md5 != content_md5:
|
|
318
|
+
raise ApiException(
|
|
319
|
+
f"MD5 mismatch: client={content_md5}, server={upload_vo.md5}"
|
|
320
|
+
)
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
upload_id = uuid.uuid4().hex
|
|
324
|
+
# Break into chunks
|
|
325
|
+
chunks = [content[i : i + chunk_size] for i in range(0, size, chunk_size)]
|
|
326
|
+
for i, chunk in enumerate(chunks):
|
|
327
|
+
chunk_md5 = hashlib.md5(chunk).hexdigest()
|
|
328
|
+
_LOGGER.debug(f"Uploading chunk {i + 1} of {size} ({len(chunk)} bytes)")
|
|
329
|
+
data = FormData()
|
|
330
|
+
data.add_field("file", chunk, filename=filename)
|
|
331
|
+
params = FileChunkParams(
|
|
332
|
+
upload_id=upload_id,
|
|
333
|
+
part_number=i + 1,
|
|
334
|
+
total_chunks=len(chunks),
|
|
335
|
+
)
|
|
336
|
+
try:
|
|
337
|
+
resp = await self.request(
|
|
338
|
+
"post",
|
|
339
|
+
part_upload_url,
|
|
340
|
+
data=data,
|
|
341
|
+
params={k: v for k, v in params.to_dict().items() if v is not None},
|
|
342
|
+
headers={},
|
|
343
|
+
)
|
|
344
|
+
except ApiException as err:
|
|
345
|
+
raise ApiException(f"Chunk upload failed: {err}")
|
|
346
|
+
try:
|
|
347
|
+
result = await resp.text()
|
|
348
|
+
except ClientError as err:
|
|
349
|
+
raise ApiException("Failed to get chunk response") from err
|
|
350
|
+
try:
|
|
351
|
+
_LOGGER.debug("Chunk response: %s", result)
|
|
352
|
+
chunk_vo = FileChunkVO.from_json(result)
|
|
353
|
+
except (LookupError, ValueError) as err:
|
|
354
|
+
raise ApiException(
|
|
355
|
+
f"Server returned malformed chunk response: {result}"
|
|
356
|
+
) from err
|
|
357
|
+
|
|
358
|
+
if not chunk_vo.success:
|
|
359
|
+
raise ApiException(f"Chunk upload failed: {chunk_vo.error_msg}")
|
|
360
|
+
|
|
361
|
+
# Verify MD5 matches
|
|
362
|
+
if chunk_vo.chunk_md5 != chunk_md5:
|
|
363
|
+
raise ApiException(
|
|
364
|
+
f"Chunk {i + 1} MD5 mismatch: client={chunk_md5}, server={chunk_vo.chunk_md5}"
|
|
365
|
+
)
|