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.
Files changed (138) hide show
  1. supernote/__init__.py +28 -0
  2. supernote/alembic/__init__.py +0 -0
  3. supernote/alembic/env.py +121 -0
  4. supernote/alembic/script.py.mako +28 -0
  5. supernote/alembic/versions/0543a383957b_initial_schema.py +312 -0
  6. supernote/alembic.ini +149 -0
  7. supernote/cli/__init__.py +1 -0
  8. supernote/cli/admin.py +178 -0
  9. supernote/cli/client.py +434 -0
  10. supernote/cli/main.py +61 -0
  11. supernote/cli/notebook.py +313 -0
  12. supernote/cli/server.py +89 -0
  13. supernote/client/__init__.py +28 -0
  14. supernote/client/admin.py +76 -0
  15. supernote/client/api.py +135 -0
  16. supernote/client/api_model.py +231 -0
  17. supernote/client/auth.py +84 -0
  18. supernote/client/client.py +365 -0
  19. supernote/client/device.py +361 -0
  20. supernote/client/exceptions.py +33 -0
  21. supernote/client/extended.py +20 -0
  22. supernote/client/hashing.py +50 -0
  23. supernote/client/login_client.py +154 -0
  24. supernote/client/schedule.py +149 -0
  25. supernote/client/summary.py +156 -0
  26. supernote/client/web.py +213 -0
  27. supernote/models/__init__.py +14 -0
  28. supernote/models/auth.py +380 -0
  29. supernote/models/base.py +266 -0
  30. supernote/models/equipment.py +276 -0
  31. supernote/models/extended.py +124 -0
  32. supernote/models/file_common.py +95 -0
  33. supernote/models/file_device.py +571 -0
  34. supernote/models/file_web.py +602 -0
  35. supernote/models/schedule.py +663 -0
  36. supernote/models/summary.py +830 -0
  37. supernote/models/system.py +431 -0
  38. supernote/models/user.py +638 -0
  39. supernote/notebook/__init__.py +67 -0
  40. supernote/notebook/color.py +93 -0
  41. supernote/notebook/converter.py +560 -0
  42. supernote/notebook/decoder.py +398 -0
  43. supernote/notebook/exceptions.py +43 -0
  44. supernote/notebook/fileformat.py +463 -0
  45. supernote/notebook/manipulator.py +420 -0
  46. supernote/notebook/parser.py +737 -0
  47. supernote/notebook/utils.py +49 -0
  48. supernote/py.typed +0 -0
  49. supernote/server/__init__.py +12 -0
  50. supernote/server/app.py +428 -0
  51. supernote/server/config.py +265 -0
  52. supernote/server/constants.py +26 -0
  53. supernote/server/db/__init__.py +9 -0
  54. supernote/server/db/base.py +8 -0
  55. supernote/server/db/migrations.py +44 -0
  56. supernote/server/db/models/__init__.py +23 -0
  57. supernote/server/db/models/device.py +17 -0
  58. supernote/server/db/models/file.py +107 -0
  59. supernote/server/db/models/kv.py +17 -0
  60. supernote/server/db/models/login_record.py +22 -0
  61. supernote/server/db/models/note_processing.py +100 -0
  62. supernote/server/db/models/schedule.py +83 -0
  63. supernote/server/db/models/summary.py +122 -0
  64. supernote/server/db/models/user.py +30 -0
  65. supernote/server/db/session.py +88 -0
  66. supernote/server/events.py +63 -0
  67. supernote/server/exceptions.py +153 -0
  68. supernote/server/mcp/__init__.py +1 -0
  69. supernote/server/mcp/models.py +109 -0
  70. supernote/server/mcp/server.py +176 -0
  71. supernote/server/resources/__init__.py +0 -0
  72. supernote/server/resources/prompts/__init__.py +0 -0
  73. supernote/server/resources/prompts/ocr/common/context.md +19 -0
  74. supernote/server/resources/prompts/ocr/common/legend.md +11 -0
  75. supernote/server/resources/prompts/ocr/daily/prompt.md +12 -0
  76. supernote/server/resources/prompts/ocr/default/system.md +14 -0
  77. supernote/server/resources/prompts/ocr/monthly/prompt.md +22 -0
  78. supernote/server/resources/prompts/ocr/weekly/prompt.md +16 -0
  79. supernote/server/resources/prompts/summary/common/instruction.md +8 -0
  80. supernote/server/resources/prompts/summary/daily/prompt.md +6 -0
  81. supernote/server/resources/prompts/summary/default/prompt.md +4 -0
  82. supernote/server/resources/prompts/summary/monthly/prompt.md +6 -0
  83. supernote/server/resources/prompts/summary/weekly/prompt.md +6 -0
  84. supernote/server/routes/admin.py +109 -0
  85. supernote/server/routes/auth.py +337 -0
  86. supernote/server/routes/decorators.py +13 -0
  87. supernote/server/routes/extended.py +139 -0
  88. supernote/server/routes/file_device.py +593 -0
  89. supernote/server/routes/file_web.py +597 -0
  90. supernote/server/routes/oss.py +317 -0
  91. supernote/server/routes/schedule.py +223 -0
  92. supernote/server/routes/summary.py +390 -0
  93. supernote/server/routes/system.py +57 -0
  94. supernote/server/services/__init__.py +15 -0
  95. supernote/server/services/blob.py +214 -0
  96. supernote/server/services/coordination.py +171 -0
  97. supernote/server/services/file.py +970 -0
  98. supernote/server/services/gemini.py +66 -0
  99. supernote/server/services/integrity.py +112 -0
  100. supernote/server/services/processor.py +331 -0
  101. supernote/server/services/processor_modules/__init__.py +145 -0
  102. supernote/server/services/processor_modules/gemini_embedding.py +112 -0
  103. supernote/server/services/processor_modules/gemini_ocr.py +149 -0
  104. supernote/server/services/processor_modules/page_hashing.py +220 -0
  105. supernote/server/services/processor_modules/png_conversion.py +107 -0
  106. supernote/server/services/processor_modules/summary.py +302 -0
  107. supernote/server/services/prompt_loader.py +133 -0
  108. supernote/server/services/schedule.py +161 -0
  109. supernote/server/services/search.py +250 -0
  110. supernote/server/services/summary.py +419 -0
  111. supernote/server/services/user.py +480 -0
  112. supernote/server/services/vfs.py +476 -0
  113. supernote/server/static/favicon.ico +0 -0
  114. supernote/server/static/index.html +186 -0
  115. supernote/server/static/js/api/client.js +463 -0
  116. supernote/server/static/js/components/FileCard.js +55 -0
  117. supernote/server/static/js/components/FileViewer.js +137 -0
  118. supernote/server/static/js/components/LoginCard.js +64 -0
  119. supernote/server/static/js/components/MoveModal.js +74 -0
  120. supernote/server/static/js/components/RenameModal.js +39 -0
  121. supernote/server/static/js/components/SummaryPanel.js +101 -0
  122. supernote/server/static/js/components/SystemPanel.js +158 -0
  123. supernote/server/static/js/composables/useFileSystem.js +116 -0
  124. supernote/server/static/js/main.js +241 -0
  125. supernote/server/static/style.css +14 -0
  126. supernote/server/utils/hashing.py +18 -0
  127. supernote/server/utils/note_content.py +76 -0
  128. supernote/server/utils/paths.py +58 -0
  129. supernote/server/utils/rate_limit.py +52 -0
  130. supernote/server/utils/tasks.py +71 -0
  131. supernote/server/utils/unique_id.py +48 -0
  132. supernote/server/utils/url_signer.py +218 -0
  133. supernote-0.13.6.dist-info/METADATA +183 -0
  134. supernote-0.13.6.dist-info/RECORD +138 -0
  135. supernote-0.13.6.dist-info/WHEEL +5 -0
  136. supernote-0.13.6.dist-info/entry_points.txt +3 -0
  137. supernote-0.13.6.dist-info/licenses/LICENSE +201 -0
  138. supernote-0.13.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,361 @@
1
+ import hashlib
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ from supernote.models.file_common import FileUploadApplyLocalVO
6
+ from supernote.models.file_device import (
7
+ CapacityLocalDTO,
8
+ CapacityLocalVO,
9
+ CreateFolderLocalDTO,
10
+ CreateFolderLocalVO,
11
+ DeleteFolderLocalDTO,
12
+ DeleteFolderLocalVO,
13
+ FileCopyLocalDTO,
14
+ FileCopyLocalVO,
15
+ FileDownloadLocalDTO,
16
+ FileDownloadLocalVO,
17
+ FileMoveLocalDTO,
18
+ FileMoveLocalVO,
19
+ FileQueryByPathLocalDTO,
20
+ FileQueryByPathLocalVO,
21
+ FileQueryLocalDTO,
22
+ FileQueryLocalVO,
23
+ FileUploadApplyLocalDTO,
24
+ FileUploadFinishLocalDTO,
25
+ FileUploadFinishLocalVO,
26
+ ListFolderLocalDTO,
27
+ ListFolderLocalVO,
28
+ ListFolderV2DTO,
29
+ PdfDTO,
30
+ PdfVO,
31
+ PngDTO,
32
+ PngVO,
33
+ SynchronousEndLocalDTO,
34
+ SynchronousEndLocalVO,
35
+ SynchronousStartLocalDTO,
36
+ SynchronousStartLocalVO,
37
+ )
38
+
39
+ from .client import Client
40
+
41
+ _LOGGER = logging.getLogger(__name__)
42
+
43
+
44
+ class DeviceClient:
45
+ """Client for Device (V2/V3) APIs."""
46
+
47
+ def __init__(self, client: Client) -> None:
48
+ """Initialize the DeviceClient."""
49
+ self._client = client
50
+
51
+ async def create_folder(
52
+ self, path: str, equipment_no: str, autorename: bool = False
53
+ ) -> CreateFolderLocalVO:
54
+ """Create a folder (V2)."""
55
+ dto = CreateFolderLocalDTO(
56
+ path=path, equipment_no=equipment_no, autorename=autorename
57
+ )
58
+ return await self._client.post_json(
59
+ "/api/file/2/files/create_folder_v2",
60
+ CreateFolderLocalVO,
61
+ json=dto.to_dict(),
62
+ )
63
+
64
+ async def list_folder(
65
+ self,
66
+ path: str | None = None,
67
+ folder_id: int | None = None,
68
+ equipment_no: str | None = None,
69
+ recursive: bool = False,
70
+ ) -> ListFolderLocalVO:
71
+ """List folder contents.
72
+
73
+ This supports both V2 and V3 APIs. You can either specify path or folder_id.
74
+
75
+ Args:
76
+ path: Path to list contents of.
77
+ folder_id: ID of folder to list contents of.
78
+ equipment_no: Equipment number.
79
+ recursive: Whether to list recursively.
80
+
81
+ Returns:
82
+ ListFolderLocalVO
83
+ """
84
+ if path is not None:
85
+ dto_v2 = ListFolderV2DTO(
86
+ path=path, equipment_no=equipment_no or "WEB", recursive=recursive
87
+ )
88
+ return await self._client.post_json(
89
+ "/api/file/2/files/list_folder",
90
+ ListFolderLocalVO,
91
+ json=dto_v2.to_dict(),
92
+ )
93
+ if folder_id is not None:
94
+ # List folder contents using v3/device API
95
+ dto_v3 = ListFolderLocalDTO(
96
+ id=folder_id,
97
+ equipment_no=equipment_no or "WEB",
98
+ recursive=recursive,
99
+ )
100
+ return await self._client.post_json(
101
+ "/api/file/3/files/list_folder_v3",
102
+ ListFolderLocalVO,
103
+ json=dto_v3.to_dict(),
104
+ )
105
+ raise ValueError("path or folder_id must be specified")
106
+
107
+ async def delete(self, id: int, equipment_no: str) -> DeleteFolderLocalVO:
108
+ """Delete a folder or file (V3)."""
109
+ dto = DeleteFolderLocalDTO(id=id, equipment_no=equipment_no)
110
+ return await self._client.post_json(
111
+ "/api/file/3/files/delete_folder_v3",
112
+ DeleteFolderLocalVO,
113
+ json=dto.to_dict(),
114
+ )
115
+
116
+ async def delete_by_path(
117
+ self, path: str, equipment_no: str | None = None
118
+ ) -> DeleteFolderLocalVO:
119
+ """Delete a folder or file by path (V3)."""
120
+ if equipment_no is None:
121
+ equipment_no = "WEB"
122
+ info = await self.query_by_path(path, equipment_no)
123
+ if not info.entries_vo:
124
+ raise FileNotFoundError(f"File or folder not found: {path}")
125
+ return await self.delete(int(info.entries_vo.id), equipment_no)
126
+
127
+ async def move(
128
+ self, id: int, to_path: str, equipment_no: str, autorename: bool = False
129
+ ) -> FileMoveLocalVO:
130
+ """Move a folder or file (V3)."""
131
+ dto = FileMoveLocalDTO(
132
+ id=id, to_path=to_path, equipment_no=equipment_no, autorename=autorename
133
+ )
134
+ return await self._client.post_json(
135
+ "/api/file/3/files/move_v3", FileMoveLocalVO, json=dto.to_dict()
136
+ )
137
+
138
+ async def copy(
139
+ self, id: int, to_path: str, equipment_no: str, autorename: bool = False
140
+ ) -> FileCopyLocalVO:
141
+ """Copy a folder or file (V3)."""
142
+ dto = FileCopyLocalDTO(
143
+ id=id, to_path=to_path, equipment_no=equipment_no, autorename=autorename
144
+ )
145
+ return await self._client.post_json(
146
+ "/api/file/3/files/copy_v3", FileCopyLocalVO, json=dto.to_dict()
147
+ )
148
+
149
+ async def upload_content(
150
+ self,
151
+ path: str,
152
+ content: bytes,
153
+ equipment_no: str | None = None,
154
+ chunk_size: int = 5 * 1024 * 1024,
155
+ ) -> FileUploadFinishLocalVO:
156
+ """Create a file (convenience method wrapping upload flow).
157
+
158
+ Args:
159
+ path: Full cloud path (e.g. /Folder/file.txt)
160
+ content: File content (string or bytes)
161
+ equipment_no: Equipment number
162
+ chunk_size: Upload chunk size/part size
163
+
164
+ Returns:
165
+ FileUploadFinishLocalVO containing file upload finish response
166
+ """
167
+ if equipment_no is None:
168
+ equipment_no = "WEB"
169
+ filename = Path(path).name
170
+ size = len(content)
171
+
172
+ # First upload the upload url
173
+ _LOGGER.debug("Initiating upload for file %s", path)
174
+ apply = await self.upload_apply(filename, path, size, equipment_no)
175
+
176
+ md5 = hashlib.md5(content).hexdigest()
177
+
178
+ await self._client._upload_to_oss(
179
+ content,
180
+ filename,
181
+ apply.full_upload_url,
182
+ apply.part_upload_url,
183
+ chunk_size,
184
+ )
185
+
186
+ _LOGGER.debug("Finishing upload for file %s", path)
187
+
188
+ parent = str(Path(path).parent)
189
+ if parent == ".":
190
+ parent = ""
191
+ parent_path_str = parent.strip("/")
192
+
193
+ return await self.upload_finish(
194
+ file_name=filename,
195
+ path=parent_path_str,
196
+ content_hash=md5,
197
+ equipment_no=equipment_no,
198
+ inner_name=apply.inner_name,
199
+ )
200
+
201
+ async def upload_apply(
202
+ self, file_name: str, path: str, size: int, equipment_no: str
203
+ ) -> FileUploadApplyLocalVO:
204
+ """Apply for file upload."""
205
+ dto = FileUploadApplyLocalDTO(
206
+ file_name=file_name, path=path, size=str(size), equipment_no=equipment_no
207
+ )
208
+ return await self._client.post_json(
209
+ "/api/file/3/files/upload/apply", FileUploadApplyLocalVO, json=dto.to_dict()
210
+ )
211
+
212
+ async def upload_finish(
213
+ self,
214
+ file_name: str,
215
+ path: str,
216
+ content_hash: str,
217
+ equipment_no: str,
218
+ inner_name: str | None = None,
219
+ ) -> FileUploadFinishLocalVO:
220
+ """Finish file upload."""
221
+ dto = FileUploadFinishLocalDTO(
222
+ file_name=file_name,
223
+ path=path,
224
+ content_hash=content_hash,
225
+ equipment_no=equipment_no,
226
+ inner_name=inner_name,
227
+ )
228
+ return await self._client.post_json(
229
+ "/api/file/2/files/upload/finish",
230
+ FileUploadFinishLocalVO,
231
+ json=dto.to_dict(),
232
+ )
233
+
234
+ async def download_content(
235
+ self,
236
+ path: str | None = None,
237
+ file_id: int | None = None,
238
+ equipment_no: str = "",
239
+ offset: int = 0,
240
+ length: int = -1,
241
+ ) -> bytes:
242
+ """Download file content, optionally with range.
243
+
244
+ Args:
245
+ path: File path (e.g. /Folder/file.pdf) (optional if file_id provided)
246
+ file_id: File ID (optional if path provided)
247
+ equipment_no: Equipment number
248
+ offset: Start byte offset
249
+ length: Number of bytes to read (-1 for until end)
250
+
251
+ Returns:
252
+ File content bytes
253
+ """
254
+ if equipment_no == "":
255
+ equipment_no = "WEB"
256
+
257
+ if file_id is None:
258
+ if path is None:
259
+ raise ValueError("Either path or file_id must be provided")
260
+ # Resolve path to ID
261
+ info = await self.query_by_path(path, equipment_no)
262
+ if not info.entries_vo:
263
+ raise FileNotFoundError(f"File not found: {path}")
264
+ # The API returns id as str, we need int for download_v3
265
+ file_id = int(info.entries_vo.id)
266
+
267
+ download_info = await self.download_v3(file_id, equipment_no)
268
+ url = download_info.url
269
+
270
+ headers = {}
271
+ if offset > 0 or length != -1:
272
+ end = ""
273
+ if length != -1:
274
+ end = str(offset + length - 1)
275
+ headers["Range"] = f"bytes={offset}-{end}"
276
+
277
+ return await self._client.get_content(url, headers=headers)
278
+
279
+ async def download_v3(self, file_id: int, equipment_no: str) -> FileDownloadLocalVO:
280
+ """Get download URL (V3)."""
281
+ dto = FileDownloadLocalDTO(id=file_id, equipment_no=equipment_no)
282
+ return await self._client.post_json(
283
+ "/api/file/3/files/download_v3", FileDownloadLocalVO, json=dto.to_dict()
284
+ )
285
+
286
+ async def get_capacity(self, equipment_no: str = "") -> CapacityLocalVO:
287
+ """Get storage capacity (Device)."""
288
+ dto = CapacityLocalDTO(equipment_no=equipment_no)
289
+ return await self._client.post_json(
290
+ "/api/file/2/users/get_space_usage", CapacityLocalVO, json=dto.to_dict()
291
+ )
292
+
293
+ async def query_by_path(
294
+ self, path: str, equipment_no: str
295
+ ) -> FileQueryByPathLocalVO:
296
+ """Query file info by path (V3)."""
297
+ dto = FileQueryByPathLocalDTO(path=path, equipment_no=equipment_no)
298
+ return await self._client.post_json(
299
+ "/api/file/3/files/query/by/path_v3",
300
+ FileQueryByPathLocalVO,
301
+ json=dto.to_dict(),
302
+ )
303
+
304
+ async def query_by_id(self, file_id: int, equipment_no: str) -> FileQueryLocalVO:
305
+ """Query file info by ID (V3)."""
306
+ dto = FileQueryLocalDTO(id=str(file_id), equipment_no=equipment_no)
307
+ return await self._client.post_json(
308
+ "/api/file/3/files/query_v3", FileQueryLocalVO, json=dto.to_dict()
309
+ )
310
+
311
+ async def sync_start(self, equipment_no: str) -> SynchronousStartLocalVO:
312
+ """Start sync session."""
313
+ dto = SynchronousStartLocalDTO(equipment_no=equipment_no)
314
+ return await self._client.post_json(
315
+ "/api/file/2/files/synchronous/start",
316
+ SynchronousStartLocalVO,
317
+ json=dto.to_dict(),
318
+ )
319
+
320
+ async def sync_end(self, equipment_no: str) -> SynchronousEndLocalVO:
321
+ """End sync session."""
322
+ dto = SynchronousEndLocalDTO(equipment_no=equipment_no)
323
+ return await self._client.post_json(
324
+ "/api/file/2/files/synchronous/end",
325
+ SynchronousEndLocalVO,
326
+ json=dto.to_dict(),
327
+ )
328
+
329
+ async def note_to_png(self, file_id: int) -> PngVO:
330
+ """Convert a note to PNG (Device API)."""
331
+ dto = PngDTO(id=file_id)
332
+ return await self._client.post_json(
333
+ "/api/file/note/to/png", PngVO, json=dto.to_dict()
334
+ )
335
+
336
+ async def note_to_pdf(
337
+ self, file_id: int, page_no_list: list[int] | None = None
338
+ ) -> PdfVO:
339
+ """Convert a note to PDF (Device API)."""
340
+ dto = PdfDTO(id=file_id, page_no_list=page_no_list or [])
341
+ return await self._client.post_json(
342
+ "/api/file/note/to/pdf", PdfVO, json=dto.to_dict()
343
+ )
344
+
345
+ async def get_note_png_pages(self, file_id: int) -> list[bytes]:
346
+ """Convenience method to get PNG content for all pages of a note."""
347
+ conversion = await self.note_to_png(file_id)
348
+ pages = []
349
+ for page_vo in conversion.png_page_vo_list:
350
+ content = await self._client.get_content(page_vo.url)
351
+ pages.append(content)
352
+ return pages
353
+
354
+ async def get_note_pdf(
355
+ self, file_id: int, page_no_list: list[int] | None = None
356
+ ) -> bytes:
357
+ """Convenience method to get PDF content for a note."""
358
+ conversion = await self.note_to_pdf(file_id, page_no_list)
359
+ if not conversion.url:
360
+ raise ValueError("Conversion failed: no URL returned")
361
+ return await self._client.get_content(conversion.url)
@@ -0,0 +1,33 @@
1
+ """Exceptions for supernote cloud."""
2
+
3
+
4
+ class SupernoteException(Exception):
5
+ """Base exception for supernote cloud."""
6
+
7
+
8
+ class SmsVerificationRequired(SupernoteException):
9
+ """Exception raised when SMS verification is required."""
10
+
11
+ def __init__(self, message: str, timestamp: str):
12
+ super().__init__(message)
13
+ self.timestamp = timestamp
14
+
15
+
16
+ class ApiException(SupernoteException):
17
+ """API exception."""
18
+
19
+
20
+ class ForbiddenException(ApiException):
21
+ """API exception."""
22
+
23
+
24
+ class UnauthorizedException(ApiException):
25
+ """Authentication exception."""
26
+
27
+
28
+ class NotFoundException(ApiException):
29
+ """Resource not found (404)."""
30
+
31
+
32
+ class BadRequestException(ApiException):
33
+ """Bad request (400)."""
@@ -0,0 +1,20 @@
1
+ """Client for Extended (Web) APIs."""
2
+
3
+ from supernote.models.extended import WebSummaryListRequestDTO, WebSummaryListVO
4
+
5
+ from . import Client
6
+
7
+
8
+ class ExtendedClient:
9
+ """Client for Server Extension APIs."""
10
+
11
+ def __init__(self, client: Client):
12
+ """Initialize the extended client."""
13
+ self._client = client
14
+
15
+ async def list_summaries(self, file_id: int) -> WebSummaryListVO:
16
+ """List summaries for a file (Extension)."""
17
+ dto = WebSummaryListRequestDTO(file_id=file_id)
18
+ return await self._client.post_json(
19
+ "/api/extended/file/summary/list", WebSummaryListVO, json=dto.to_dict()
20
+ )
@@ -0,0 +1,50 @@
1
+ """Module for hashing utilities."""
2
+
3
+ import hashlib
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def _sha256_string(s: str) -> str:
10
+ """Return SHA256 hex digest of a string."""
11
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
12
+
13
+
14
+ def _md5_string(s: str) -> str:
15
+ """Return MD5 hex digest of a string."""
16
+ return hashlib.md5(s.encode("utf-8")).hexdigest()
17
+
18
+
19
+ def hash_with_salt(content: str, salt: str) -> str:
20
+ """Hash content with salt using SHA256."""
21
+ return _sha256_string(f"{content}{salt}")
22
+
23
+
24
+ def hash_password(password_text: str, rc: str) -> str:
25
+ """Encode password using MD5 and SHA256."""
26
+ md5 = _md5_string(password_text)
27
+ return hash_with_salt(md5, rc)
28
+
29
+
30
+ def get_token_salt(token: str) -> str:
31
+ """Extract real key (salt) from token.
32
+
33
+ This is the dynamic key selection mechanism used during the SMS authentication
34
+ flow. It takes the last character of the token and interprets it as an integer
35
+ index n. It then splits the token string by hyphens - and returns the n-th
36
+ component from the split parts.
37
+ """
38
+ if not token:
39
+ raise ValueError("Token cannot be empty")
40
+ last_char = token[-1]
41
+ try:
42
+ index = int(last_char)
43
+ except ValueError:
44
+ raise ValueError(f"Last character of token must be an integer: {token}")
45
+ parts = token.split("-")
46
+ if 0 <= index < len(parts):
47
+ return parts[index]
48
+ raise ValueError(
49
+ f"Invalid token format (index out of bounds, index={index}, token={token})"
50
+ )
@@ -0,0 +1,154 @@
1
+ """Library for authenticating with the Supernote (Private) Cloud Server."""
2
+
3
+ import logging
4
+ from typing import TypeVar
5
+
6
+ from mashumaro.mixins.json import DataClassJSONMixin
7
+
8
+ from supernote.models.auth import (
9
+ LoginDTO,
10
+ LoginMethod,
11
+ LoginVO,
12
+ QueryTokenDTO,
13
+ QueryTokenVO,
14
+ RandomCodeDTO,
15
+ RandomCodeVO,
16
+ SendSmsDTO,
17
+ SendSmsVO,
18
+ SmsLoginDTO,
19
+ SmsLoginVO,
20
+ UserPreAuthRequest,
21
+ UserPreAuthResponse,
22
+ )
23
+
24
+ from .client import Client
25
+ from .exceptions import ApiException, SmsVerificationRequired
26
+ from .hashing import get_token_salt, hash_password, hash_with_salt
27
+
28
+ _LOGGER = logging.getLogger(__name__)
29
+
30
+
31
+ _T = TypeVar("_T", bound=DataClassJSONMixin)
32
+
33
+ __all__ = [
34
+ "LoginClient",
35
+ ]
36
+
37
+
38
+ class LoginClient:
39
+ """A client library for logging in."""
40
+
41
+ def __init__(self, client: Client):
42
+ """Initialize the client."""
43
+ self._client = client
44
+
45
+ async def login(self, email: str, password: str) -> str:
46
+ """Log in and return an access token."""
47
+ await self._token()
48
+ random_code_response = await self._get_random_code(email)
49
+ encoded_password = hash_password(password, random_code_response.random_code)
50
+ access_token_response = await self._get_access_token(
51
+ email, encoded_password, random_code_response.timestamp
52
+ )
53
+ return access_token_response.token
54
+
55
+ async def login_equipment(
56
+ self, email: str, password: str, equipment_no: str
57
+ ) -> LoginVO:
58
+ """Log in via equipment endpoint and return full login response."""
59
+ await self._token()
60
+ random_code_response = await self._get_random_code(email)
61
+ encoded_password = hash_password(password, random_code_response.random_code)
62
+
63
+ payload = LoginDTO(
64
+ account=email,
65
+ password=encoded_password,
66
+ login_method=LoginMethod.PHONE if email.isdigit() else LoginMethod.EMAIL,
67
+ timestamp=random_code_response.timestamp,
68
+ equipment_no=equipment_no,
69
+ ).to_dict()
70
+
71
+ return await self._client.post_json(
72
+ "/api/official/user/account/login/equipment", LoginVO, json=payload
73
+ )
74
+
75
+ async def sms_login(self, telephone: str, code: str, timestamp: str) -> str:
76
+ """Log in via SMS code."""
77
+ # Always get a fresh CSRF token for the SMS login request
78
+ await self._client._get_csrf_token()
79
+
80
+ payload = SmsLoginDTO(
81
+ telephone=telephone,
82
+ timestamp=timestamp,
83
+ valid_code=code,
84
+ valid_code_key=f"1-{telephone}_validCode",
85
+ ).to_dict()
86
+
87
+ response = await self._client.post_json(
88
+ "/api/official/user/sms/login", SmsLoginVO, json=payload
89
+ )
90
+ return response.token
91
+
92
+ async def request_sms_code(self, telephone: str, country_code: int = 1) -> None:
93
+ """Request an SMS verification code."""
94
+ # Pre-authentication step to obtain a token
95
+ account_with_code = f"{country_code}{telephone}"
96
+ pre_auth_payload = UserPreAuthRequest(account=account_with_code).to_dict()
97
+
98
+ # Always get a fresh CSRF token
99
+ await self._client._get_csrf_token()
100
+
101
+ pre_auth_response = await self._client.post_json(
102
+ "/api/user/validcode/pre-auth", UserPreAuthResponse, json=pre_auth_payload
103
+ )
104
+
105
+ # Calculate signature needed to send the random code over SMS
106
+ token_salt = get_token_salt(pre_auth_response.token)
107
+ sign = hash_with_salt(account_with_code, token_salt)
108
+
109
+ # Obtain a random code timestamp and send SMS
110
+ random_code_resp = await self._get_random_code(telephone)
111
+ timestamp = random_code_resp.timestamp
112
+
113
+ sms_payload = SendSmsDTO(
114
+ telephone=telephone,
115
+ timestamp=timestamp,
116
+ token=pre_auth_response.token,
117
+ sign=sign,
118
+ nationcode=country_code,
119
+ ).to_dict()
120
+ await self._client.post_json(
121
+ "/api/user/sms/validcode/send", SendSmsVO, json=sms_payload
122
+ )
123
+
124
+ async def _token(self) -> None:
125
+ """Get a random code."""
126
+ await self._client.post_json(
127
+ "/api/user/query/token", QueryTokenVO, json=QueryTokenDTO().to_dict()
128
+ )
129
+
130
+ async def _get_random_code(self, email: str) -> RandomCodeVO:
131
+ """Get a random code."""
132
+ payload = RandomCodeDTO(account=email).to_dict()
133
+ return await self._client.post_json(
134
+ "/api/official/user/query/random/code", RandomCodeVO, json=payload
135
+ )
136
+
137
+ async def _get_access_token(
138
+ self, email: str, encoded_password: str, random_code_timestamp: str
139
+ ) -> LoginVO:
140
+ """Get an access token."""
141
+ payload = LoginDTO(
142
+ account=email,
143
+ password=encoded_password,
144
+ login_method=LoginMethod.PHONE if email.isdigit() else LoginMethod.EMAIL,
145
+ timestamp=random_code_timestamp,
146
+ ).to_dict()
147
+ try:
148
+ return await self._client.post_json(
149
+ "/api/official/user/account/login/new", LoginVO, json=payload
150
+ )
151
+ except ApiException as err:
152
+ if "verification code" in str(err):
153
+ raise SmsVerificationRequired(str(err), random_code_timestamp) from err
154
+ raise