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.
Files changed (87) hide show
  1. {supernote-0.9.2 → supernote-0.11.0}/PKG-INFO +1 -1
  2. {supernote-0.9.2 → supernote-0.11.0}/pyproject.toml +1 -1
  3. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/device.py +3 -0
  4. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/app.py +1 -0
  5. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/config.py +42 -5
  6. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/constants.py +3 -0
  7. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/file.py +3 -0
  8. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/file_device.py +22 -6
  9. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/oss.py +56 -37
  10. supernote-0.11.0/supernote/server/services/blob.py +214 -0
  11. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/file.py +56 -173
  12. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/integrity.py +30 -9
  13. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/vfs.py +9 -1
  14. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/utils/url_signer.py +26 -1
  15. {supernote-0.9.2 → supernote-0.11.0}/supernote.egg-info/PKG-INFO +1 -1
  16. supernote-0.9.2/supernote/server/services/blob.py +0 -147
  17. {supernote-0.9.2 → supernote-0.11.0}/LICENSE +0 -0
  18. {supernote-0.9.2 → supernote-0.11.0}/README.md +0 -0
  19. {supernote-0.9.2 → supernote-0.11.0}/setup.cfg +0 -0
  20. {supernote-0.9.2 → supernote-0.11.0}/supernote/__init__.py +0 -0
  21. {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/__init__.py +0 -0
  22. {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/admin.py +0 -0
  23. {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/client.py +0 -0
  24. {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/main.py +0 -0
  25. {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/notebook.py +0 -0
  26. {supernote-0.9.2 → supernote-0.11.0}/supernote/cli/server.py +0 -0
  27. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/__init__.py +0 -0
  28. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/admin.py +0 -0
  29. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/api_model.py +0 -0
  30. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/auth.py +0 -0
  31. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/client.py +0 -0
  32. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/cloud_client.py +0 -0
  33. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/exceptions.py +0 -0
  34. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/hashing.py +0 -0
  35. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/login_client.py +0 -0
  36. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/schedule.py +0 -0
  37. {supernote-0.9.2 → supernote-0.11.0}/supernote/client/web.py +0 -0
  38. {supernote-0.9.2 → supernote-0.11.0}/supernote/models/__init__.py +0 -0
  39. {supernote-0.9.2 → supernote-0.11.0}/supernote/models/auth.py +0 -0
  40. {supernote-0.9.2 → supernote-0.11.0}/supernote/models/base.py +0 -0
  41. {supernote-0.9.2 → supernote-0.11.0}/supernote/models/equipment.py +0 -0
  42. {supernote-0.9.2 → supernote-0.11.0}/supernote/models/file_common.py +0 -0
  43. {supernote-0.9.2 → supernote-0.11.0}/supernote/models/file_device.py +0 -0
  44. {supernote-0.9.2 → supernote-0.11.0}/supernote/models/file_web.py +0 -0
  45. {supernote-0.9.2 → supernote-0.11.0}/supernote/models/schedule.py +0 -0
  46. {supernote-0.9.2 → supernote-0.11.0}/supernote/models/summary.py +0 -0
  47. {supernote-0.9.2 → supernote-0.11.0}/supernote/models/system.py +0 -0
  48. {supernote-0.9.2 → supernote-0.11.0}/supernote/models/user.py +0 -0
  49. {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/__init__.py +0 -0
  50. {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/color.py +0 -0
  51. {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/converter.py +0 -0
  52. {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/decoder.py +0 -0
  53. {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/exceptions.py +0 -0
  54. {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/fileformat.py +0 -0
  55. {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/manipulator.py +0 -0
  56. {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/parser.py +0 -0
  57. {supernote-0.9.2 → supernote-0.11.0}/supernote/notebook/utils.py +0 -0
  58. {supernote-0.9.2 → supernote-0.11.0}/supernote/py.typed +0 -0
  59. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/__init__.py +0 -0
  60. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/__init__.py +0 -0
  61. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/base.py +0 -0
  62. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/__init__.py +0 -0
  63. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/device.py +0 -0
  64. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/kv.py +0 -0
  65. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/login_record.py +0 -0
  66. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/schedule.py +0 -0
  67. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/models/user.py +0 -0
  68. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/db/session.py +0 -0
  69. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/exceptions.py +0 -0
  70. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/admin.py +0 -0
  71. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/auth.py +0 -0
  72. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/decorators.py +0 -0
  73. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/file_web.py +0 -0
  74. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/schedule.py +0 -0
  75. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/routes/system.py +0 -0
  76. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/__init__.py +0 -0
  77. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/coordination.py +0 -0
  78. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/schedule.py +0 -0
  79. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/services/user.py +0 -0
  80. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/utils/hashing.py +0 -0
  81. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/utils/rate_limit.py +0 -0
  82. {supernote-0.9.2 → supernote-0.11.0}/supernote/server/utils/unique_id.py +0 -0
  83. {supernote-0.9.2 → supernote-0.11.0}/supernote.egg-info/SOURCES.txt +0 -0
  84. {supernote-0.9.2 → supernote-0.11.0}/supernote.egg-info/dependency_links.txt +0 -0
  85. {supernote-0.9.2 → supernote-0.11.0}/supernote.egg-info/entry_points.txt +0 -0
  86. {supernote-0.9.2 → supernote-0.11.0}/supernote.egg-info/requires.txt +0 -0
  87. {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.9.2
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.9.2"
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:
@@ -17,3 +17,6 @@ CATEGORY_CONTAINERS = {"NOTE", "DOCUMENT"}
17
17
 
18
18
  # Forced order and specific names for web API root (when flatten=True)
19
19
  ORDERED_WEB_ROOT = ["Note", "Document"]
20
+
21
+ # Blob Storage Buckets
22
+ USER_DATA_BUCKET = "supernote-user-data"
@@ -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
- encoded_name = urllib.parse.quote(file_name)
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}&timestamp={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="supernote-local",
298
- inner_name=file_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
- total_bytes, md5_hash = await file_service.save_temp_file(
53
- user_email, object_name, field.read_chunk
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}): {total_bytes} bytes, MD5: {md5_hash}"
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=md5_hash,
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
- total_bytes, chunk_md5 = await file_service.save_chunk_file(
115
- user_email,
116
- params.upload_id,
117
- params.object_name,
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
- await file_service.merge_chunks(
132
- user_email,
133
- params.upload_id,
134
- params.object_name,
135
- params.total_chunks,
136
- )
137
- await asyncio.to_thread(
138
- file_service.cleanup_chunks, user_email, params.upload_id
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
- content_hash = info.md5
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(content_hash):
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
- async with file_service.blob_storage.open_blob(content_hash) as f:
256
- if start > 0:
257
- await f.seek(start)
258
-
259
- bytes_to_send = content_length
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)