supernote 0.9.2__tar.gz → 0.11.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.
- {supernote-0.9.2 → supernote-0.11.0}/PKG-INFO +1 -1
- {supernote-0.9.2 → supernote-0.11.0}/pyproject.toml +1 -1
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/device.py +3 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/app.py +1 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/config.py +42 -5
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/constants.py +3 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/file.py +3 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/file_device.py +22 -6
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/oss.py +56 -37
- supernote-0.11.0/supernote/server/services/blob.py +214 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/file.py +56 -173
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/integrity.py +30 -9
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/vfs.py +9 -1
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/utils/url_signer.py +26 -1
- {supernote-0.9.2 → supernote-0.11.0}/supernote.egg-info/PKG-INFO +1 -1
- supernote-0.9.2/supernote/server/services/blob.py +0 -147
- {supernote-0.9.2 → supernote-0.11.0}/LICENSE +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/README.md +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/setup.cfg +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/__init__.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/__init__.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/admin.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/client.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/main.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/notebook.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/server.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/__init__.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/admin.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/api_model.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/auth.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/client.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/cloud_client.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/exceptions.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/hashing.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/login_client.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/schedule.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/client/web.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/models/__init__.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/models/auth.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/models/base.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/models/equipment.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/models/file_common.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/models/file_device.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/models/file_web.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/models/schedule.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/models/summary.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/models/system.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/models/user.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/__init__.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/color.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/converter.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/decoder.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/exceptions.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/fileformat.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/manipulator.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/parser.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/utils.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/py.typed +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/__init__.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/__init__.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/base.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/__init__.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/device.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/kv.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/login_record.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/schedule.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/user.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/session.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/exceptions.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/admin.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/auth.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/decorators.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/file_web.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/schedule.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/system.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/__init__.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/coordination.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/schedule.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/user.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/utils/hashing.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/utils/rate_limit.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote/server/utils/unique_id.py +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote.egg-info/SOURCES.txt +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote.egg-info/dependency_links.txt +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote.egg-info/entry_points.txt +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote.egg-info/requires.txt +0 -0
- {supernote-0.9.2 → supernote-0.11.0}/supernote.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: supernote
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: All-in-one toolkit for Supernote devices: parse notebooks, self-host services, access services
|
|
5
5
|
Author-email: jya <jya@wizmy.net>, Allen Porter <allen.porter@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -4,7 +4,7 @@ requires = ["setuptools>=77.0"]
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "supernote"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.0"
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
license-files = ["LICENSE"]
|
|
10
10
|
description = "All-in-one toolkit for Supernote devices: parse notebooks, self-host services, access services"
|
|
@@ -180,6 +180,7 @@ class DeviceClient:
|
|
|
180
180
|
path=parent_path_str,
|
|
181
181
|
content_hash=md5,
|
|
182
182
|
equipment_no=equipment_no,
|
|
183
|
+
inner_name=apply.inner_name,
|
|
183
184
|
)
|
|
184
185
|
|
|
185
186
|
async def upload_apply(
|
|
@@ -199,6 +200,7 @@ class DeviceClient:
|
|
|
199
200
|
path: str,
|
|
200
201
|
content_hash: str,
|
|
201
202
|
equipment_no: str,
|
|
203
|
+
inner_name: str | None = None,
|
|
202
204
|
) -> FileUploadFinishLocalVO:
|
|
203
205
|
"""Finish file upload."""
|
|
204
206
|
dto = FileUploadFinishLocalDTO(
|
|
@@ -206,6 +208,7 @@ class DeviceClient:
|
|
|
206
208
|
path=path,
|
|
207
209
|
content_hash=content_hash,
|
|
208
210
|
equipment_no=equipment_no,
|
|
211
|
+
inner_name=inner_name,
|
|
209
212
|
)
|
|
210
213
|
return await self._client.post_json(
|
|
211
214
|
"/api/file/2/files/upload/finish",
|
|
@@ -209,6 +209,7 @@ def create_app(config: ServerConfig) -> web.Application:
|
|
|
209
209
|
|
|
210
210
|
app["session_manager"] = session_manager
|
|
211
211
|
app["coordination_service"] = coordination_service
|
|
212
|
+
app["blob_storage"] = blob_storage
|
|
212
213
|
user_service = UserService(config.auth, coordination_service, session_manager)
|
|
213
214
|
file_service = FileService(
|
|
214
215
|
config.storage_root,
|
|
@@ -16,16 +16,25 @@ class AuthConfig(DataClassYAMLMixin):
|
|
|
16
16
|
"""Authentication configuration."""
|
|
17
17
|
|
|
18
18
|
secret_key: str = ""
|
|
19
|
-
"""JWT secret key.
|
|
19
|
+
"""JWT secret key.
|
|
20
|
+
|
|
21
|
+
Env Var: `SUPERNOTE_JWT_SECRET`
|
|
22
|
+
"""
|
|
20
23
|
|
|
21
24
|
expiration_hours: int = 24
|
|
22
25
|
"""JWT expiration time in hours."""
|
|
23
26
|
|
|
24
27
|
enable_registration: bool = False
|
|
25
|
-
"""When disabled, registration is only allowed if there are no users in the system.
|
|
28
|
+
"""When disabled, registration is only allowed if there are no users in the system.
|
|
29
|
+
|
|
30
|
+
Env Var: `SUPERNOTE_ENABLE_REGISTRATION`
|
|
31
|
+
"""
|
|
26
32
|
|
|
27
33
|
enable_remote_password_reset: bool = False
|
|
28
|
-
"""When disabled, the public password reset endpoint returns 403.
|
|
34
|
+
"""When disabled, the public password reset endpoint returns 403.
|
|
35
|
+
|
|
36
|
+
Env Var: `SUPERNOTE_ENABLE_REMOTE_PASSWORD_RESET`
|
|
37
|
+
"""
|
|
29
38
|
|
|
30
39
|
class Config(BaseConfig):
|
|
31
40
|
omit_none = True
|
|
@@ -35,17 +44,36 @@ class AuthConfig(DataClassYAMLMixin):
|
|
|
35
44
|
@dataclass
|
|
36
45
|
class ServerConfig(DataClassYAMLMixin):
|
|
37
46
|
host: str = "0.0.0.0"
|
|
47
|
+
"""Host to bind the server to.
|
|
48
|
+
|
|
49
|
+
Env Var: `SUPERNOTE_HOST`
|
|
50
|
+
"""
|
|
51
|
+
|
|
38
52
|
port: int = 8080
|
|
53
|
+
"""Port to bind the server to.
|
|
54
|
+
|
|
55
|
+
Env Var: `SUPERNOTE_PORT`
|
|
56
|
+
"""
|
|
39
57
|
trace_log_file: str | None = None
|
|
40
58
|
storage_dir: str = "storage"
|
|
59
|
+
"""Directory for storing files and database.
|
|
60
|
+
|
|
61
|
+
Env Var: `SUPERNOTE_STORAGE_DIR`
|
|
62
|
+
"""
|
|
41
63
|
|
|
42
64
|
proxy_mode: str | None = None
|
|
43
|
-
"""Proxy header handling mode: None/'disabled' (ignore proxy headers), 'relaxed' (trust immediate upstream), or 'strict' (require specific trusted IPs). Defaults to None for security.
|
|
65
|
+
"""Proxy header handling mode: None/'disabled' (ignore proxy headers), 'relaxed' (trust immediate upstream), or 'strict' (require specific trusted IPs). Defaults to None for security.
|
|
66
|
+
|
|
67
|
+
Env Var: `SUPERNOTE_PROXY_MODE`
|
|
68
|
+
"""
|
|
44
69
|
|
|
45
70
|
trusted_proxies: list[str] = field(
|
|
46
71
|
default_factory=lambda: ["127.0.0.1", "::1", "172.17.0.0/16"]
|
|
47
72
|
)
|
|
48
|
-
"""List of trusted proxy IPs/networks (used in strict mode). Supports CIDR notation.
|
|
73
|
+
"""List of trusted proxy IPs/networks (used in strict mode). Supports CIDR notation.
|
|
74
|
+
|
|
75
|
+
Env Var: `SUPERNOTE_TRUSTED_PROXIES` (comma-separated)
|
|
76
|
+
"""
|
|
49
77
|
|
|
50
78
|
auth: AuthConfig = field(default_factory=AuthConfig)
|
|
51
79
|
|
|
@@ -120,6 +148,15 @@ class ServerConfig(DataClassYAMLMixin):
|
|
|
120
148
|
f"Remote Password Reset Enabled: {config.auth.enable_remote_password_reset}"
|
|
121
149
|
)
|
|
122
150
|
|
|
151
|
+
if os.getenv("SUPERNOTE_PROXY_MODE"):
|
|
152
|
+
config.proxy_mode = os.getenv("SUPERNOTE_PROXY_MODE")
|
|
153
|
+
logger.info(f"Using SUPERNOTE_PROXY_MODE: {config.proxy_mode}")
|
|
154
|
+
|
|
155
|
+
if os.getenv("SUPERNOTE_TRUSTED_PROXIES"):
|
|
156
|
+
val = os.getenv("SUPERNOTE_TRUSTED_PROXIES", "")
|
|
157
|
+
config.trusted_proxies = [p.strip() for p in val.split(",") if p.strip()]
|
|
158
|
+
logger.info(f"Using SUPERNOTE_TRUSTED_PROXIES: {config.trusted_proxies}")
|
|
159
|
+
|
|
123
160
|
if not config_file.exists():
|
|
124
161
|
# Set default trace log file if not specified
|
|
125
162
|
if config.trace_log_file is None:
|
|
@@ -39,6 +39,9 @@ class UserFileDO(Base):
|
|
|
39
39
|
md5: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
40
40
|
"""Content hash."""
|
|
41
41
|
|
|
42
|
+
storage_key: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
43
|
+
"""Physical storage key (inner_name/UUID)."""
|
|
44
|
+
|
|
42
45
|
is_active: Mapped[str] = mapped_column(String(1), default="Y", nullable=False)
|
|
43
46
|
"""'Y' = Active, 'N' = Deleted."""
|
|
44
47
|
|
|
@@ -40,6 +40,7 @@ from supernote.server.exceptions import SupernoteError
|
|
|
40
40
|
from supernote.server.services.file import (
|
|
41
41
|
FileEntity,
|
|
42
42
|
FileService,
|
|
43
|
+
generate_inner_name,
|
|
43
44
|
)
|
|
44
45
|
from supernote.server.utils.url_signer import UrlSigner
|
|
45
46
|
|
|
@@ -278,13 +279,21 @@ async def handle_upload_apply(request: web.Request) -> web.Response:
|
|
|
278
279
|
try:
|
|
279
280
|
url_signer: UrlSigner = request.app["url_signer"]
|
|
280
281
|
|
|
281
|
-
|
|
282
|
+
# Generate a unique inner name for storage
|
|
283
|
+
inner_name = generate_inner_name(file_name, req_data.equipment_no)
|
|
284
|
+
encoded_name = urllib.parse.quote(inner_name)
|
|
282
285
|
|
|
283
|
-
# Simple Upload URL: /api/oss/upload?object_name={name}
|
|
286
|
+
# Simple Upload URL: /api/oss/upload?object_name={name}×tamp={ms}
|
|
284
287
|
simple_path = f"/api/oss/upload?object_name={encoded_name}"
|
|
285
288
|
full_upload_url_path = await url_signer.sign(simple_path, user=request["user"])
|
|
286
289
|
full_upload_url = f"{request.scheme}://{request.host}{full_upload_url_path}"
|
|
287
290
|
|
|
291
|
+
# Extract signature and timestamp using UrlSigner helpers
|
|
292
|
+
signature = UrlSigner.extract_signature(full_upload_url_path)
|
|
293
|
+
x_amz_date = UrlSigner.extract_timestamp(full_upload_url_path)
|
|
294
|
+
if not signature or not x_amz_date:
|
|
295
|
+
raise SupernoteError("Server generated invalid upload URL")
|
|
296
|
+
|
|
288
297
|
# Part Upload URL: /api/oss/upload/part?object_name={name}
|
|
289
298
|
# Client will append &uploadId=...&partNumber=...
|
|
290
299
|
part_path = f"/api/oss/upload/part?object_name={encoded_name}"
|
|
@@ -294,10 +303,10 @@ async def handle_upload_apply(request: web.Request) -> web.Response:
|
|
|
294
303
|
return web.json_response(
|
|
295
304
|
FileUploadApplyLocalVO(
|
|
296
305
|
equipment_no=req_data.equipment_no or "",
|
|
297
|
-
bucket_name=
|
|
298
|
-
inner_name=
|
|
299
|
-
x_amz_date=
|
|
300
|
-
authorization=
|
|
306
|
+
bucket_name=file_name, # Reference impl checks this matches filename
|
|
307
|
+
inner_name=inner_name,
|
|
308
|
+
x_amz_date=x_amz_date,
|
|
309
|
+
authorization=signature,
|
|
301
310
|
full_upload_url=full_upload_url,
|
|
302
311
|
part_upload_url=part_upload_url,
|
|
303
312
|
).to_dict()
|
|
@@ -318,12 +327,19 @@ async def handle_upload_finish(request: web.Request) -> web.Response:
|
|
|
318
327
|
user_email = request["user"]
|
|
319
328
|
file_service: FileService = request.app["file_service"]
|
|
320
329
|
|
|
330
|
+
if not req_data.inner_name:
|
|
331
|
+
return web.json_response(
|
|
332
|
+
create_error_response("Invalid upload missing inner name").to_dict(),
|
|
333
|
+
status=400,
|
|
334
|
+
)
|
|
335
|
+
|
|
321
336
|
try:
|
|
322
337
|
entity = await file_service.finish_upload(
|
|
323
338
|
user_email,
|
|
324
339
|
req_data.file_name,
|
|
325
340
|
req_data.path,
|
|
326
341
|
req_data.content_hash,
|
|
342
|
+
inner_name=req_data.inner_name,
|
|
327
343
|
)
|
|
328
344
|
except SupernoteError as err:
|
|
329
345
|
return err.to_response()
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
5
6
|
|
|
6
7
|
from aiohttp import BodyPartReader, web
|
|
7
8
|
|
|
8
9
|
from supernote.models.base import create_error_response
|
|
9
10
|
from supernote.models.system import FileChunkParams, FileChunkVO, UploadFileVO
|
|
11
|
+
from supernote.server.constants import USER_DATA_BUCKET
|
|
10
12
|
from supernote.server.exceptions import SupernoteError
|
|
13
|
+
from supernote.server.services.blob import BlobStorage
|
|
11
14
|
from supernote.server.services.file import FileService
|
|
12
15
|
from supernote.server.utils.url_signer import UrlSigner
|
|
13
16
|
|
|
@@ -17,6 +20,20 @@ logger = logging.getLogger(__name__)
|
|
|
17
20
|
routes = web.RouteTableDef()
|
|
18
21
|
|
|
19
22
|
|
|
23
|
+
def _get_chunk_key(object_name: str, part_number: int) -> str:
|
|
24
|
+
"""Generate a storage key for a file chunk."""
|
|
25
|
+
return f"{object_name}.part.{part_number}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _stream_upload_field(field: BodyPartReader) -> AsyncGenerator[bytes, None]:
|
|
29
|
+
"""Stream chunks from a multipart field."""
|
|
30
|
+
while True:
|
|
31
|
+
chunk = await field.read_chunk()
|
|
32
|
+
if not chunk:
|
|
33
|
+
break
|
|
34
|
+
yield chunk
|
|
35
|
+
|
|
36
|
+
|
|
20
37
|
@routes.post("/api/oss/upload")
|
|
21
38
|
@public_route
|
|
22
39
|
async def handle_oss_upload(request: web.Request) -> web.Response:
|
|
@@ -24,8 +41,8 @@ async def handle_oss_upload(request: web.Request) -> web.Response:
|
|
|
24
41
|
|
|
25
42
|
Query: object_name
|
|
26
43
|
"""
|
|
27
|
-
file_service: FileService = request.app["file_service"]
|
|
28
44
|
url_signer: UrlSigner = request.app["url_signer"]
|
|
45
|
+
blob_storage: BlobStorage = request.app["blob_storage"]
|
|
29
46
|
|
|
30
47
|
try:
|
|
31
48
|
payload = await url_signer.verify(request.path_qs)
|
|
@@ -49,17 +66,17 @@ async def handle_oss_upload(request: web.Request) -> web.Response:
|
|
|
49
66
|
reader = await request.multipart()
|
|
50
67
|
field = await reader.next()
|
|
51
68
|
if isinstance(field, BodyPartReader) and field.name == "file":
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
metadata = await blob_storage.put(
|
|
70
|
+
USER_DATA_BUCKET, object_name, _stream_upload_field(field)
|
|
54
71
|
)
|
|
55
72
|
logger.info(
|
|
56
|
-
f"Received OSS upload for {object_name} (user: {user_email}): {
|
|
73
|
+
f"Received OSS upload for {object_name} (user: {user_email}): {metadata.size} bytes, MD5: {metadata.content_md5}"
|
|
57
74
|
)
|
|
58
75
|
|
|
59
76
|
# Return UploadFileVO with innerName and md5
|
|
60
77
|
response = UploadFileVO(
|
|
61
78
|
inner_name=object_name,
|
|
62
|
-
md5=
|
|
79
|
+
md5=metadata.content_md5,
|
|
63
80
|
)
|
|
64
81
|
return web.json_response(response.to_dict())
|
|
65
82
|
|
|
@@ -75,8 +92,8 @@ async def handle_oss_upload_part(request: web.Request) -> web.Response:
|
|
|
75
92
|
Endpoint: POST /api/oss/upload/part
|
|
76
93
|
Query Params: uploadId, partNumber, object_name, signature, totalChunks (optional/implied for implicit merge)
|
|
77
94
|
"""
|
|
78
|
-
file_service: FileService = request.app["file_service"]
|
|
79
95
|
url_signer: UrlSigner = request.app["url_signer"]
|
|
96
|
+
blob_storage: BlobStorage = request.app["blob_storage"]
|
|
80
97
|
|
|
81
98
|
query_dict = dict(request.query)
|
|
82
99
|
try:
|
|
@@ -111,13 +128,14 @@ async def handle_oss_upload_part(request: web.Request) -> web.Response:
|
|
|
111
128
|
reader = await request.multipart()
|
|
112
129
|
field = await reader.next()
|
|
113
130
|
if isinstance(field, BodyPartReader) and field.name == "file":
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
params.part_number,
|
|
119
|
-
field.read_chunk,
|
|
131
|
+
chunk_key = _get_chunk_key(params.object_name, params.part_number)
|
|
132
|
+
|
|
133
|
+
metadata = await blob_storage.put(
|
|
134
|
+
USER_DATA_BUCKET, chunk_key, _stream_upload_field(field)
|
|
120
135
|
)
|
|
136
|
+
chunk_md5 = metadata.content_md5
|
|
137
|
+
total_bytes = metadata.size
|
|
138
|
+
|
|
121
139
|
logger.info(
|
|
122
140
|
f"Received chunk {params.part_number} for {params.object_name} (uploadId: {params.upload_id}): {total_bytes} bytes, MD5: {chunk_md5}"
|
|
123
141
|
)
|
|
@@ -128,17 +146,28 @@ async def handle_oss_upload_part(request: web.Request) -> web.Response:
|
|
|
128
146
|
logger.info(
|
|
129
147
|
f"Implicitly merging {params.total_chunks} chunks for {params.object_name}"
|
|
130
148
|
)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
params.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
|
|
149
|
+
source_keys = [
|
|
150
|
+
_get_chunk_key(params.object_name, i)
|
|
151
|
+
for i in range(1, params.total_chunks + 1)
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
async def combined_stream() -> AsyncGenerator[bytes, None]:
|
|
155
|
+
for source_key in source_keys:
|
|
156
|
+
async for chunk in blob_storage.get(
|
|
157
|
+
USER_DATA_BUCKET, source_key
|
|
158
|
+
):
|
|
159
|
+
yield chunk
|
|
160
|
+
|
|
161
|
+
await blob_storage.put(
|
|
162
|
+
USER_DATA_BUCKET, params.object_name, combined_stream()
|
|
139
163
|
)
|
|
140
164
|
logger.info(f"Successfully merged chunks for {params.object_name}")
|
|
141
165
|
|
|
166
|
+
# Cleanup chunks
|
|
167
|
+
await asyncio.gather(
|
|
168
|
+
*[blob_storage.delete(USER_DATA_BUCKET, key) for key in source_keys]
|
|
169
|
+
)
|
|
170
|
+
|
|
142
171
|
# Return FileChunkVO with chunk MD5
|
|
143
172
|
resp_vo = FileChunkVO(
|
|
144
173
|
upload_id=params.upload_id,
|
|
@@ -194,13 +223,12 @@ async def handle_oss_download(request: web.Request) -> web.StreamResponse:
|
|
|
194
223
|
create_error_response("Not a file").to_dict(), status=400
|
|
195
224
|
)
|
|
196
225
|
|
|
197
|
-
|
|
198
|
-
if not content_hash:
|
|
226
|
+
if not info.storage_key:
|
|
199
227
|
return web.json_response(
|
|
200
228
|
create_error_response("File content not found").to_dict(), status=404
|
|
201
229
|
)
|
|
202
230
|
|
|
203
|
-
if not await file_service.blob_storage.exists(
|
|
231
|
+
if not await file_service.blob_storage.exists(USER_DATA_BUCKET, info.storage_key):
|
|
204
232
|
return web.json_response(
|
|
205
233
|
create_error_response("Blob not found").to_dict(), status=404
|
|
206
234
|
)
|
|
@@ -252,20 +280,11 @@ async def handle_oss_download(request: web.Request) -> web.StreamResponse:
|
|
|
252
280
|
await response.prepare(request)
|
|
253
281
|
|
|
254
282
|
try:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
chunk_size = 8192
|
|
261
|
-
|
|
262
|
-
while bytes_to_send > 0:
|
|
263
|
-
read_size = min(chunk_size, bytes_to_send)
|
|
264
|
-
chunk = await f.read(read_size)
|
|
265
|
-
if not chunk:
|
|
266
|
-
break
|
|
267
|
-
await response.write(chunk)
|
|
268
|
-
bytes_to_send -= len(chunk)
|
|
283
|
+
stream = file_service.blob_storage.get(
|
|
284
|
+
USER_DATA_BUCKET, info.storage_key, start=start, end=end
|
|
285
|
+
)
|
|
286
|
+
async for chunk in stream:
|
|
287
|
+
await response.write(chunk)
|
|
269
288
|
|
|
270
289
|
except FileNotFoundError:
|
|
271
290
|
return web.json_response(
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import secrets
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import AsyncGenerator
|
|
7
|
+
|
|
8
|
+
import aiofiles
|
|
9
|
+
import aiofiles.os
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class BlobMetadata:
|
|
14
|
+
"""Metadata for a blob."""
|
|
15
|
+
|
|
16
|
+
content_type: str | None = None
|
|
17
|
+
content_md5: str | None = None
|
|
18
|
+
size: int = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BlobStorage(ABC):
|
|
22
|
+
"""Interface for Key-Value Blob Storage."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def put(
|
|
26
|
+
self, bucket: str, key: str, stream: AsyncGenerator[bytes, None] | bytes
|
|
27
|
+
) -> BlobMetadata:
|
|
28
|
+
"""Write blob to storage."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def get(
|
|
33
|
+
self, bucket: str, key: str, start: int | None = None, end: int | None = None
|
|
34
|
+
) -> AsyncGenerator[bytes, None]:
|
|
35
|
+
"""Read blob content.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
bucket: Bucket name.
|
|
39
|
+
key: Blob key.
|
|
40
|
+
start: Start byte position (inclusive).
|
|
41
|
+
end: End byte position (inclusive).
|
|
42
|
+
"""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def delete(self, bucket: str, key: str) -> None:
|
|
47
|
+
"""Delete blob."""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def exists(self, bucket: str, key: str) -> bool:
|
|
52
|
+
"""Check if blob exists."""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
async def get_metadata(
|
|
57
|
+
self, bucket: str, key: str, include_md5: bool = False
|
|
58
|
+
) -> BlobMetadata:
|
|
59
|
+
"""Get metadata for a blob.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
bucket: Bucket name.
|
|
63
|
+
key: Blob key.
|
|
64
|
+
include_md5: If True, compute and return MD5 checksum.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
BlobMetadata with size and optional content_md5.
|
|
68
|
+
"""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def get_blob_path(self, bucket: str, key: str) -> Path:
|
|
73
|
+
"""Get physical path to the blob (optional, useful for serving files)."""
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LocalBlobStorage(BlobStorage):
|
|
78
|
+
"""Local filesystem implementation of Blob Storage.
|
|
79
|
+
|
|
80
|
+
Path structure: <root>/<bucket>/<key[0:2]>/<key>
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, storage_root: Path) -> None:
|
|
84
|
+
"""Create a local blob storage instance."""
|
|
85
|
+
self.root = storage_root
|
|
86
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
def _get_path(self, bucket: str, key: str) -> Path:
|
|
89
|
+
"""Get physical path to the blob."""
|
|
90
|
+
# Clean inputs to prevent traversal
|
|
91
|
+
clean_bucket = Path(bucket).name
|
|
92
|
+
clean_key = Path(key).name
|
|
93
|
+
prefix = clean_key[:2] if len(clean_key) >= 2 else "misc"
|
|
94
|
+
return self.root / clean_bucket / prefix / clean_key
|
|
95
|
+
|
|
96
|
+
async def put(
|
|
97
|
+
self, bucket: str, key: str, stream: AsyncGenerator[bytes, None] | bytes
|
|
98
|
+
) -> BlobMetadata:
|
|
99
|
+
"""Write blob to storage."""
|
|
100
|
+
blob_path = self._get_path(bucket, key)
|
|
101
|
+
await aiofiles.os.makedirs(blob_path.parent, exist_ok=True)
|
|
102
|
+
|
|
103
|
+
# Write to temp file for atomicity
|
|
104
|
+
temp_dir = self.root / "temp"
|
|
105
|
+
await aiofiles.os.makedirs(temp_dir, exist_ok=True)
|
|
106
|
+
temp_path = temp_dir / f"{secrets.token_hex(8)}.tmp"
|
|
107
|
+
|
|
108
|
+
total_size = 0
|
|
109
|
+
md5_hasher = hashlib.md5()
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
async with aiofiles.open(temp_path, "wb") as f:
|
|
113
|
+
if isinstance(stream, bytes):
|
|
114
|
+
total_size = len(stream)
|
|
115
|
+
md5_hasher.update(stream)
|
|
116
|
+
await f.write(stream)
|
|
117
|
+
else:
|
|
118
|
+
async for chunk in stream:
|
|
119
|
+
total_size += len(chunk)
|
|
120
|
+
md5_hasher.update(chunk)
|
|
121
|
+
await f.write(chunk)
|
|
122
|
+
|
|
123
|
+
# Move to final location
|
|
124
|
+
await aiofiles.os.rename(temp_path, blob_path)
|
|
125
|
+
|
|
126
|
+
return BlobMetadata(
|
|
127
|
+
content_md5=md5_hasher.hexdigest(),
|
|
128
|
+
size=total_size,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
except Exception:
|
|
132
|
+
if await aiofiles.os.path.exists(temp_path):
|
|
133
|
+
await aiofiles.os.remove(temp_path)
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
async def get(
|
|
137
|
+
self, bucket: str, key: str, start: int | None = None, end: int | None = None
|
|
138
|
+
) -> AsyncGenerator[bytes, None]:
|
|
139
|
+
"""Read blob content."""
|
|
140
|
+
path = self._get_path(bucket, key)
|
|
141
|
+
if not await aiofiles.os.path.exists(path):
|
|
142
|
+
raise FileNotFoundError(f"Blob {bucket}/{key} not found")
|
|
143
|
+
|
|
144
|
+
bytes_remaining = None
|
|
145
|
+
if end is not None:
|
|
146
|
+
if start is None:
|
|
147
|
+
start = 0
|
|
148
|
+
bytes_remaining = end - start + 1
|
|
149
|
+
|
|
150
|
+
async with aiofiles.open(path, "rb") as f:
|
|
151
|
+
if start is not None and start > 0:
|
|
152
|
+
await f.seek(start)
|
|
153
|
+
|
|
154
|
+
while True:
|
|
155
|
+
chunk_size = 8192
|
|
156
|
+
if bytes_remaining is not None:
|
|
157
|
+
if bytes_remaining <= 0:
|
|
158
|
+
break
|
|
159
|
+
chunk_size = min(chunk_size, bytes_remaining)
|
|
160
|
+
|
|
161
|
+
chunk = await f.read(chunk_size)
|
|
162
|
+
if not chunk:
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
if bytes_remaining is not None:
|
|
166
|
+
bytes_remaining -= len(chunk)
|
|
167
|
+
|
|
168
|
+
yield chunk
|
|
169
|
+
|
|
170
|
+
async def delete(self, bucket: str, key: str) -> None:
|
|
171
|
+
"""Delete blob."""
|
|
172
|
+
path = self._get_path(bucket, key)
|
|
173
|
+
if await aiofiles.os.path.exists(path):
|
|
174
|
+
await aiofiles.os.remove(path)
|
|
175
|
+
|
|
176
|
+
async def exists(self, bucket: str, key: str) -> bool:
|
|
177
|
+
"""Check if blob exists."""
|
|
178
|
+
return bool(await aiofiles.os.path.exists(self._get_path(bucket, key)))
|
|
179
|
+
|
|
180
|
+
async def get_metadata(
|
|
181
|
+
self, bucket: str, key: str, include_md5: bool = False
|
|
182
|
+
) -> BlobMetadata:
|
|
183
|
+
"""Get metadata for a blob."""
|
|
184
|
+
path = self._get_path(bucket, key)
|
|
185
|
+
if not await aiofiles.os.path.exists(path):
|
|
186
|
+
raise FileNotFoundError(f"Blob {bucket}/{key} not found")
|
|
187
|
+
|
|
188
|
+
stat = await aiofiles.os.stat(path)
|
|
189
|
+
|
|
190
|
+
if not include_md5:
|
|
191
|
+
return BlobMetadata(size=stat.st_size)
|
|
192
|
+
|
|
193
|
+
# Compute MD5 and size
|
|
194
|
+
md5_hasher = hashlib.md5()
|
|
195
|
+
read_size = 0
|
|
196
|
+
async with aiofiles.open(path, "rb") as f:
|
|
197
|
+
while True:
|
|
198
|
+
chunk = await f.read(8192)
|
|
199
|
+
if not chunk:
|
|
200
|
+
break
|
|
201
|
+
md5_hasher.update(chunk)
|
|
202
|
+
read_size += len(chunk)
|
|
203
|
+
|
|
204
|
+
if read_size != stat.st_size:
|
|
205
|
+
# This could happen if file was modified during read
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"File size changed during read: metadata={stat.st_size}, read={read_size}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return BlobMetadata(size=stat.st_size, content_md5=md5_hasher.hexdigest())
|
|
211
|
+
|
|
212
|
+
def get_blob_path(self, bucket: str, key: str) -> Path:
|
|
213
|
+
"""Get physical path to the blob."""
|
|
214
|
+
return self._get_path(bucket, key)
|