smodusermc commited on
Commit
e62bb51
·
verified ·
1 Parent(s): 2e5dd6d

Update storage_handler.py

Browse files
Files changed (1) hide show
  1. storage_handler.py +414 -106
storage_handler.py CHANGED
@@ -1,215 +1,523 @@
1
- # storage_handler.py
2
  import os
3
  import base64
 
 
4
  import logging
5
- from typing import Optional, BinaryIO, Dict, Any
 
 
6
 
7
  from huggingface_hub import HfFileSystem, HfApi
8
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
9
 
10
- logger = logging.getLogger("StorageHandler")
11
 
12
  # ------------------------------------------------------------------------
13
- # Configuration – all from environment variables
14
  # ------------------------------------------------------------------------
15
  HF_TOKEN = os.environ.get("HF_TOKEN")
16
  if not HF_TOKEN:
17
- raise ValueError("HF_TOKEN environment variable is required")
18
 
19
- BUCKET_ID = os.environ.get("INFINITY_CHAT_BUCKET", "infinitychat-data")
20
  FILE_ENCRYPTION_KEY_B64 = os.environ.get("FILE_ENCRYPTION_KEY", None)
 
21
  if FILE_ENCRYPTION_KEY_B64 is None:
22
  from cryptography.fernet import Fernet
23
  FILE_ENCRYPTION_KEY_B64 = Fernet.generate_key().decode()
24
- logger.warning("FILE_ENCRYPTION_KEY not set – generated random key. Set it permanently!")
25
- FILE_ENCRYPTION_KEY = base64.urlsafe_b64decode(FILE_ENCRYPTION_KEY_B64.encode())
26
- assert len(FILE_ENCRYPTION_KEY) == 32, "AES‑256 requires exactly 32 bytes"
 
 
 
 
 
 
27
 
28
- # Get the bucket owner (your HF username)
 
 
29
  _api = HfApi(token=HF_TOKEN)
30
  try:
31
- OWNER = _api.whoami()["name"]
32
- except Exception:
33
- # Fallback: use the bucket ID prefix (if it contains a slash)
34
- if "/" in BUCKET_ID:
35
- OWNER, BUCKET_ID = BUCKET_ID.split("/", 1)
36
- else:
37
- raise ValueError("Cannot determine bucket owner. Set INFINITY_CHAT_BUCKET as 'owner/bucket-name'")
 
 
 
 
 
 
 
 
 
38
 
39
- # Build the proper bucket URI prefix (Xet-native)
40
- BUCKET_URI_PREFIX = f"hf://buckets/{OWNER}/{BUCKET_ID}"
 
 
 
 
 
 
41
 
42
  # ------------------------------------------------------------------------
43
- # HfFileSystem for low-level bucket operations (Xet-native, NO Git/LFS)
44
  # ------------------------------------------------------------------------
45
  _fs = HfFileSystem(token=HF_TOKEN)
46
 
47
  # ------------------------------------------------------------------------
48
- # Bucket initialization (using the modern Hugging Face Hub API)
49
  # ------------------------------------------------------------------------
50
  def ensure_bucket():
51
- """
52
- Ensure the bucket exists using the native HF Bucket API.
53
- The HfApi.create_bucket call does NOT use 'name' – it uses 'bucket_id'.
54
- """
55
- api = HfApi(token=HF_TOKEN)
56
-
57
- # Modern API: create_bucket(bucket_id=..., private=True, exist_ok=True)
58
  try:
59
- api.create_bucket(
 
60
  bucket_id=f"{OWNER}/{BUCKET_ID}",
61
- private=False,
62
  exist_ok=True
63
  )
64
- logger.info(f"✅ Bucket '{OWNER}/{BUCKET_ID}' is ready (private).")
65
  except Exception as e:
66
- # Some versions require the full ID
67
- if "already exists" in str(e).lower():
68
- logger.info(f"Bucket '{OWNER}/{BUCKET_ID}' already exists.")
69
- # Try without the owner prefix
 
 
70
  else:
 
71
  try:
72
- api.create_bucket(
73
  bucket_id=BUCKET_ID,
74
  private=True,
75
  exist_ok=True
76
  )
77
- logger.info(f"✅ Bucket '{BUCKET_ID}' ready.")
78
- global BUCKET_URI_PREFIX
79
- BUCKET_URI_PREFIX = f"hf://buckets/{BUCKET_ID}"
 
80
  except Exception as e2:
81
- if "already exists" in str(e2).lower():
82
- logger.info(f"Bucket '{BUCKET_ID}' already exists.")
 
83
  else:
84
- logger.error(f"Failed to create bucket: {e2}")
85
- raise
86
 
87
- # Initialize bucket
88
  ensure_bucket()
89
 
90
  # ------------------------------------------------------------------------
91
  # Encryption / Decryption
92
  # ------------------------------------------------------------------------
93
- def encrypt_file(data: bytes) -> bytes:
94
- """Encrypt binary data using AES‑256‑GCM. Output: nonce (12 B) || ciphertext."""
 
 
 
 
 
 
 
 
 
95
  aesgcm = AESGCM(FILE_ENCRYPTION_KEY)
96
  nonce = os.urandom(12)
97
- ciphertext = aesgcm.encrypt(nonce, data, None)
98
  return nonce + ciphertext
99
 
100
- def decrypt_file(encrypted_blob: bytes) -> bytes:
101
- """Decrypt data produced by encrypt_file()."""
 
 
 
 
 
 
 
 
 
 
102
  nonce = encrypted_blob[:12]
103
  ciphertext = encrypted_blob[12:]
104
- aesgcm = AESGCM(FILE_ENCRYPTION_KEY)
105
- return aesgcm.decrypt(nonce, ciphertext, None)
 
 
 
 
 
 
 
 
 
 
106
 
107
  # ------------------------------------------------------------------------
108
- # Core Bucket Operations (Xet-native via HfFileSystem)
109
  # ------------------------------------------------------------------------
 
 
 
 
110
  def _bucket_path(remote_path: str) -> str:
111
  """Construct the full hf://buckets/... URI."""
112
- return f"{BUCKET_URI_PREFIX}/{remote_path}"
113
 
114
- def store_file(remote_path: str, data: bytes) -> str:
115
  """
116
- Encrypt data and write it to the bucket using HfFileSystem.
117
- Returns: the remote path (for DB storage).
 
 
118
  """
119
- encrypted_data = encrypt_file(data)
120
- full_uri = _bucket_path(remote_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
- with _fs.open(full_uri, "wb") as f:
123
- f.write(encrypted_data)
124
 
125
- logger.debug(f"Stored file: {full_uri}")
126
- return remote_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
- def retrieve_file(remote_path: str) -> bytes:
129
  """
130
- Read from bucket using HfFileSystem and decrypt.
 
 
 
 
 
 
 
 
 
 
 
131
  """
132
- full_uri = _bucket_path(remote_path)
133
 
134
- with _fs.open(full_uri, "rb") as f:
135
- encrypted_data = f.read()
136
 
137
- return decrypt_file(encrypted_data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
- def delete_file(remote_path: str):
140
  """
141
- Delete a file from the bucket using HfFileSystem.
 
 
 
 
 
 
142
  """
 
 
143
  full_uri = _bucket_path(remote_path)
 
144
  try:
145
- _fs.rm(full_uri)
146
- logger.debug(f"Deleted file: {full_uri}")
147
- except FileNotFoundError:
148
- logger.warning(f"File not found for deletion: {full_uri}")
 
 
 
149
  except Exception as e:
150
- logger.error(f"Failed to delete file {full_uri}: {e}")
 
151
 
152
- def list_files(prefix: str = "") -> list:
 
 
 
153
  """
154
  List files in the bucket under a given prefix.
155
- Returns list of full URIs.
 
 
 
 
 
 
156
  """
157
- search_path = f"{BUCKET_URI_PREFIX}/{prefix}" if prefix else BUCKET_URI_PREFIX
 
158
  try:
159
- return _fs.ls(search_path)
 
 
 
 
 
 
 
 
160
  except FileNotFoundError:
161
  return []
 
 
 
162
 
163
  def file_exists(remote_path: str) -> bool:
164
  """Check if a file exists in the bucket."""
165
- full_uri = _bucket_path(remote_path)
166
- return _fs.exists(full_uri)
167
 
168
  def get_file_size(remote_path: str) -> Optional[int]:
169
- """Get the size of a file in bytes (compressed)."""
170
- full_uri = _bucket_path(remote_path)
 
 
 
 
 
 
171
  try:
172
- info = _fs.info(full_uri)
173
  return info.get("size")
174
- except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  return None
176
 
177
  # ------------------------------------------------------------------------
178
- # Stream-based store (for reassembled chunks)
179
  # ------------------------------------------------------------------------
180
- def store_file_stream(remote_path: str, data_stream: BinaryIO) -> str:
181
  """
182
- Read the entire stream, encrypt, and write to bucket.
 
 
 
 
 
 
 
 
183
  """
184
  data = data_stream.read()
185
- return store_file(remote_path, data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
  # ------------------------------------------------------------------------
188
- # Bucket info (for monitoring / health checks)
189
  # ------------------------------------------------------------------------
190
- def get_bucket_info() -> Dict[str, Any]:
191
- """Return information about the bucket."""
192
- api = HfApi(token=HF_TOKEN)
 
 
 
 
193
  try:
194
- # Try the modern API first
195
- info = api.bucket_info(bucket_id=f"{OWNER}/{BUCKET_ID}")
 
 
 
 
 
 
 
 
196
  return {
197
- "id": f"{OWNER}/{BUCKET_ID}",
198
- "exists": True,
199
- "private": info.get("private", True),
200
- "total_files": len(list_files()),
201
  }
202
- except Exception:
 
203
  return {
204
- "id": f"{OWNER}/{BUCKET_ID}",
205
- "exists": False,
206
- "note": "Cannot fetch bucket info"
 
 
207
  }
208
 
209
  # ------------------------------------------------------------------------
210
- # Clean shutdown
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  # ------------------------------------------------------------------------
212
  def close():
213
- """Clean up resources (HfFileSystem)."""
214
  if _fs:
215
- _fs.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import base64
3
+ import hashlib
4
+ import uuid
5
  import logging
6
+ from typing import Optional, BinaryIO, Dict, Any, List
7
+ from contextlib import contextmanager
8
+ import time
9
 
10
  from huggingface_hub import HfFileSystem, HfApi
11
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
12
 
13
+ logger = logging.getLogger("InfinityChat.Storage")
14
 
15
  # ------------------------------------------------------------------------
16
+ # Configuration
17
  # ------------------------------------------------------------------------
18
  HF_TOKEN = os.environ.get("HF_TOKEN")
19
  if not HF_TOKEN:
20
+ raise ValueError("❌ HF_TOKEN environment variable is required")
21
 
22
+ BUCKET_NAME = os.environ.get("INFINITY_CHAT_BUCKET", "infinitychat-data")
23
  FILE_ENCRYPTION_KEY_B64 = os.environ.get("FILE_ENCRYPTION_KEY", None)
24
+
25
  if FILE_ENCRYPTION_KEY_B64 is None:
26
  from cryptography.fernet import Fernet
27
  FILE_ENCRYPTION_KEY_B64 = Fernet.generate_key().decode()
28
+ logger.warning("⚠️ FILE_ENCRYPTION_KEY not set - generated random key. Set it permanently!")
29
+ print(f"Generated FILE_ENCRYPTION_KEY: {FILE_ENCRYPTION_KEY_B64}")
30
+
31
+ try:
32
+ FILE_ENCRYPTION_KEY = base64.urlsafe_b64decode(FILE_ENCRYPTION_KEY_B64.encode())
33
+ except Exception as e:
34
+ raise ValueError(f"Invalid FILE_ENCRYPTION_KEY format: {e}")
35
+
36
+ assert len(FILE_ENCRYPTION_KEY) == 32, "FILE_ENCRYPTION_KEY must decode to exactly 32 bytes for AES-256"
37
 
38
+ # ------------------------------------------------------------------------
39
+ # Get bucket owner (HF username)
40
+ # ------------------------------------------------------------------------
41
  _api = HfApi(token=HF_TOKEN)
42
  try:
43
+ owner_info = _api.whoami()
44
+ OWNER = owner_info["name"]
45
+ except Exception as e:
46
+ logger.warning(f"Could not determine HF username: {e}")
47
+ # Try to extract from token or use a default
48
+ try:
49
+ # Token might be hf_xxxx format, extract first part
50
+ token_parts = HF_TOKEN.split("_")
51
+ if len(token_parts) >= 2:
52
+ OWNER = token_parts[1][:10] # Use part of token as identifier
53
+ else:
54
+ OWNER = "infinitychat"
55
+ except:
56
+ OWNER = "infinitychat"
57
+
58
+ logger.info(f"Using owner: {OWNER}")
59
 
60
+ # Handle bucket name with or without owner prefix
61
+ if "/" in BUCKET_NAME:
62
+ OWNER, BUCKET_ID = BUCKET_NAME.split("/", 1)
63
+ else:
64
+ BUCKET_ID = BUCKET_NAME
65
+
66
+ BUCKET_URI = f"hf://buckets/{OWNER}/{BUCKET_ID}"
67
+ logger.info(f"📦 Bucket URI: {BUCKET_URI}")
68
 
69
  # ------------------------------------------------------------------------
70
+ # HfFileSystem instance
71
  # ------------------------------------------------------------------------
72
  _fs = HfFileSystem(token=HF_TOKEN)
73
 
74
  # ------------------------------------------------------------------------
75
+ # Bucket Initialization
76
  # ------------------------------------------------------------------------
77
  def ensure_bucket():
78
+ """Create the private bucket if it doesn't exist."""
 
 
 
 
 
 
79
  try:
80
+ # Try to create bucket
81
+ _api.create_bucket(
82
  bucket_id=f"{OWNER}/{BUCKET_ID}",
83
+ private=True,
84
  exist_ok=True
85
  )
86
+ logger.info(f"✅ Bucket '{OWNER}/{BUCKET_ID}' is ready (private)")
87
  except Exception as e:
88
+ error_str = str(e).lower()
89
+ if "already exists" in error_str:
90
+ logger.info(f"📦 Bucket '{OWNER}/{BUCKET_ID}' already exists")
91
+ elif "http" in error_str and ("403" in error_str or "401" in error_str):
92
+ logger.warning(f"⚠️ Cannot create bucket - permission issue: {e}")
93
+ logger.info("The bucket may already exist or you don't have permissions")
94
  else:
95
+ # Try alternative bucket name format
96
  try:
97
+ _api.create_bucket(
98
  bucket_id=BUCKET_ID,
99
  private=True,
100
  exist_ok=True
101
  )
102
+ global OWNER, BUCKET_URI, BUCKET_ID
103
+ OWNER = "" # No owner prefix
104
+ BUCKET_URI = f"hf://buckets/{BUCKET_ID}"
105
+ logger.info(f"✅ Bucket '{BUCKET_ID}' is ready (alternative format)")
106
  except Exception as e2:
107
+ error_str2 = str(e2).lower()
108
+ if "already exists" in error_str2:
109
+ logger.info(f"📦 Bucket '{BUCKET_ID}' already exists")
110
  else:
111
+ logger.error(f"❌ Failed to create bucket: {e2}")
112
+ logger.info("The app will still work but file uploads will fail")
113
 
114
+ # Initialize on import
115
  ensure_bucket()
116
 
117
  # ------------------------------------------------------------------------
118
  # Encryption / Decryption
119
  # ------------------------------------------------------------------------
120
+ def encrypt_bytes(data: bytes, aad: Optional[bytes] = None) -> bytes:
121
+ """
122
+ Encrypt binary data with AES-256-GCM.
123
+
124
+ Args:
125
+ data: Plaintext bytes to encrypt
126
+ aad: Additional authenticated data (optional)
127
+
128
+ Returns:
129
+ nonce (12 bytes) + ciphertext + tag (16 bytes) = 12 + len(data) + 16
130
+ """
131
  aesgcm = AESGCM(FILE_ENCRYPTION_KEY)
132
  nonce = os.urandom(12)
133
+ ciphertext = aesgcm.encrypt(nonce, data, aad or b"")
134
  return nonce + ciphertext
135
 
136
+ def decrypt_bytes(encrypted_blob: bytes, aad: Optional[bytes] = None) -> bytes:
137
+ """
138
+ Decrypt data encrypted with encrypt_bytes().
139
+
140
+ Args:
141
+ encrypted_blob: nonce (12 bytes) + ciphertext + tag (16 bytes)
142
+ aad: Additional authenticated data used during encryption
143
+
144
+ Returns:
145
+ Decrypted plaintext bytes
146
+ """
147
+ aesgcm = AESGCM(FILE_ENCRYPTION_KEY)
148
  nonce = encrypted_blob[:12]
149
  ciphertext = encrypted_blob[12:]
150
+ return aesgcm.decrypt(nonce, ciphertext, aad or b"")
151
+
152
+ def verify_file_integrity(encrypted_blob: bytes) -> bool:
153
+ """
154
+ Verify that encrypted blob has valid format.
155
+
156
+ Returns:
157
+ True if format appears valid (nonce + ciphertext + tag)
158
+ """
159
+ if len(encrypted_blob) < 29: # 12 (nonce) + 1 (minimum data) + 16 (tag)
160
+ return False
161
+ return True
162
 
163
  # ------------------------------------------------------------------------
164
+ # Path Utilities
165
  # ------------------------------------------------------------------------
166
+ def _build_path(*parts: str) -> str:
167
+ """Build a clean path by joining parts."""
168
+ return "/".join(p.strip("/") for p in parts if p)
169
+
170
  def _bucket_path(remote_path: str) -> str:
171
  """Construct the full hf://buckets/... URI."""
172
+ return f"{BUCKET_URI}/{remote_path.lstrip('/')}"
173
 
174
+ def _validate_path(remote_path: str) -> None:
175
  """
176
+ Validate that a path is safe to use.
177
+
178
+ Raises:
179
+ ValueError: If path contains dangerous patterns
180
  """
181
+ if not remote_path:
182
+ raise ValueError("Path cannot be empty")
183
+ if ".." in remote_path.split("/"):
184
+ raise ValueError("Path traversal detected")
185
+ if remote_path.startswith("/") or remote_path.startswith("\\"):
186
+ raise ValueError("Path cannot start with separator")
187
+ if len(remote_path) > 1024:
188
+ raise ValueError("Path too long (max 1024 characters)")
189
+
190
+ # ------------------------------------------------------------------------
191
+ # Core File Operations
192
+ # ------------------------------------------------------------------------
193
+ def store_file(remote_path: str, data: bytes, encrypt: bool = True) -> str:
194
+ """
195
+ Store a file in the bucket with optional encryption.
196
+
197
+ Args:
198
+ remote_path: Path within bucket (e.g., "avatars/user123.jpg")
199
+ data: File content as bytes
200
+ encrypt: Whether to encrypt data before storing (default: True)
201
 
202
+ Returns:
203
+ The remote path for later retrieval
204
 
205
+ Raises:
206
+ ValueError: If path is invalid
207
+ IOError: If storage fails
208
+ """
209
+ _validate_path(remote_path)
210
+
211
+ try:
212
+ # Encrypt if requested
213
+ if encrypt:
214
+ encrypted_data = encrypt_bytes(data, remote_path.encode('utf-8'))
215
+ else:
216
+ encrypted_data = data
217
+
218
+ # Ensure parent directory exists (HfFileSystem handles this)
219
+ full_uri = _bucket_path(remote_path)
220
+
221
+ # Write to bucket
222
+ with _fs.open(full_uri, "wb") as f:
223
+ f.write(encrypted_data)
224
+
225
+ # Verify file was written
226
+ if not _fs.exists(full_uri):
227
+ raise IOError(f"Failed to verify file was stored: {full_uri}")
228
+
229
+ logger.debug(f"💾 Stored file: {remote_path} ({len(data)} bytes)")
230
+ return remote_path
231
+
232
+ except Exception as e:
233
+ logger.error(f"❌ Failed to store file {remote_path}: {e}")
234
+ raise IOError(f"Storage failed: {e}")
235
 
236
+ def retrieve_file(remote_path: str, decrypt: bool = True) -> bytes:
237
  """
238
+ Retrieve and optionally decrypt a file from the bucket.
239
+
240
+ Args:
241
+ remote_path: Path within bucket
242
+ decrypt: Whether to decrypt data (default: True)
243
+
244
+ Returns:
245
+ File content as bytes
246
+
247
+ Raises:
248
+ FileNotFoundError: If file doesn't exist
249
+ IOError: If retrieval fails
250
  """
251
+ _validate_path(remote_path)
252
 
253
+ full_uri = _bucket_path(remote_path)
 
254
 
255
+ try:
256
+ # Check if file exists
257
+ if not _fs.exists(full_uri):
258
+ raise FileNotFoundError(f"File not found: {remote_path}")
259
+
260
+ # Read from bucket
261
+ with _fs.open(full_uri, "rb") as f:
262
+ encrypted_data = f.read()
263
+
264
+ if not encrypted_data:
265
+ raise IOError(f"Empty file: {remote_path}")
266
+
267
+ # Decrypt if requested
268
+ if decrypt:
269
+ if not verify_file_integrity(encrypted_data):
270
+ raise IOError(f"Corrupted file: {remote_path}")
271
+
272
+ decrypted_data = decrypt_bytes(encrypted_data, remote_path.encode('utf-8'))
273
+ logger.debug(f"📂 Retrieved file: {remote_path} ({len(decrypted_data)} bytes)")
274
+ return decrypted_data
275
+ else:
276
+ return encrypted_data
277
+
278
+ except FileNotFoundError:
279
+ raise
280
+ except Exception as e:
281
+ logger.error(f"❌ Failed to retrieve file {remote_path}: {e}")
282
+ raise IOError(f"Retrieval failed: {e}")
283
 
284
+ def delete_file(remote_path: str) -> bool:
285
  """
286
+ Delete a file from the bucket.
287
+
288
+ Args:
289
+ remote_path: Path within bucket
290
+
291
+ Returns:
292
+ True if file was deleted, False if it didn't exist
293
  """
294
+ _validate_path(remote_path)
295
+
296
  full_uri = _bucket_path(remote_path)
297
+
298
  try:
299
+ if _fs.exists(full_uri):
300
+ _fs.rm(full_uri)
301
+ logger.debug(f"🗑️ Deleted file: {remote_path}")
302
+ return True
303
+ else:
304
+ logger.warning(f"⚠️ File not found for deletion: {remote_path}")
305
+ return False
306
  except Exception as e:
307
+ logger.error(f"❌ Failed to delete file {remote_path}: {e}")
308
+ raise IOError(f"Deletion failed: {e}")
309
 
310
+ # ------------------------------------------------------------------------
311
+ # Bulk Operations
312
+ # ------------------------------------------------------------------------
313
+ def list_files(prefix: str = "", recursive: bool = True) -> List[str]:
314
  """
315
  List files in the bucket under a given prefix.
316
+
317
+ Args:
318
+ prefix: Optional path prefix to filter by
319
+ recursive: Whether to list files recursively
320
+
321
+ Returns:
322
+ List of file paths (relative to bucket root)
323
  """
324
+ search_path = _bucket_path(prefix) if prefix else BUCKET_URI
325
+
326
  try:
327
+ if recursive:
328
+ items = _fs.ls(search_path, detail=False, recursive=True)
329
+ else:
330
+ items = _fs.ls(search_path, detail=False)
331
+
332
+ # Convert full URIs to relative paths
333
+ prefix_len = len(BUCKET_URI) + 1 # +1 for trailing slash
334
+ return [item[prefix_len:] for item in items if not _fs.isdir(item)]
335
+
336
  except FileNotFoundError:
337
  return []
338
+ except Exception as e:
339
+ logger.error(f"❌ Failed to list files: {e}")
340
+ return []
341
 
342
  def file_exists(remote_path: str) -> bool:
343
  """Check if a file exists in the bucket."""
344
+ _validate_path(remote_path)
345
+ return _fs.exists(_bucket_path(remote_path))
346
 
347
  def get_file_size(remote_path: str) -> Optional[int]:
348
+ """
349
+ Get the size of a file in bytes.
350
+
351
+ Returns:
352
+ File size in bytes, or None if file doesn't exist
353
+ """
354
+ _validate_path(remote_path)
355
+
356
  try:
357
+ info = _fs.info(_bucket_path(remote_path))
358
  return info.get("size")
359
+ except FileNotFoundError:
360
+ return None
361
+ except Exception as e:
362
+ logger.error(f"Failed to get file size: {e}")
363
+ return None
364
+
365
+ def get_file_info(remote_path: str) -> Optional[Dict[str, Any]]:
366
+ """
367
+ Get detailed information about a file.
368
+
369
+ Returns:
370
+ Dict with file metadata, or None if file doesn't exist
371
+ """
372
+ _validate_path(remote_path)
373
+
374
+ try:
375
+ info = _fs.info(_bucket_path(remote_path))
376
+ return {
377
+ "name": remote_path,
378
+ "size": info.get("size"),
379
+ "created": info.get("created"),
380
+ "modified": info.get("last_modified"),
381
+ "type": info.get("type", "file")
382
+ }
383
+ except FileNotFoundError:
384
+ return None
385
+ except Exception as e:
386
+ logger.error(f"Failed to get file info: {e}")
387
  return None
388
 
389
  # ------------------------------------------------------------------------
390
+ # Stream-based Operations
391
  # ------------------------------------------------------------------------
392
+ def store_file_stream(remote_path: str, data_stream: BinaryIO, encrypt: bool = True) -> str:
393
  """
394
+ Store file from a binary stream.
395
+
396
+ Args:
397
+ remote_path: Path within bucket
398
+ data_stream: Binary stream to read from
399
+ encrypt: Whether to encrypt data
400
+
401
+ Returns:
402
+ The remote path
403
  """
404
  data = data_stream.read()
405
+ return store_file(remote_path, data, encrypt=encrypt)
406
+
407
+ def retrieve_file_stream(remote_path: str, decrypt: bool = True) -> BinaryIO:
408
+ """
409
+ Retrieve file as a binary stream.
410
+
411
+ Args:
412
+ remote_path: Path within bucket
413
+ decrypt: Whether to decrypt data
414
+
415
+ Returns:
416
+ BinaryIO object with file content
417
+ """
418
+ data = retrieve_file(remote_path, decrypt=decrypt)
419
+ import io
420
+ return io.BytesIO(data)
421
 
422
  # ------------------------------------------------------------------------
423
+ # Storage Statistics
424
  # ------------------------------------------------------------------------
425
+ def get_storage_stats() -> Dict[str, Any]:
426
+ """
427
+ Get storage statistics.
428
+
429
+ Returns:
430
+ Dict with storage information
431
+ """
432
  try:
433
+ files = list_files()
434
+ total_size = 0
435
+ file_count = 0
436
+
437
+ for f in files:
438
+ size = get_file_size(f)
439
+ if size is not None:
440
+ total_size += size
441
+ file_count += 1
442
+
443
  return {
444
+ "bucket": f"{OWNER}/{BUCKET_ID}",
445
+ "file_count": file_count,
446
+ "total_size": total_size,
447
+ "total_size_mb": round(total_size / (1024 * 1024), 2)
448
  }
449
+ except Exception as e:
450
+ logger.error(f"Failed to get storage stats: {e}")
451
  return {
452
+ "bucket": f"{OWNER}/{BUCKET_ID}",
453
+ "file_count": 0,
454
+ "total_size": 0,
455
+ "total_size_mb": 0,
456
+ "error": str(e)
457
  }
458
 
459
  # ------------------------------------------------------------------------
460
+ # Backup and Maintenance
461
+ # ------------------------------------------------------------------------
462
+ def create_backup(backup_prefix: str = "backups") -> str:
463
+ """
464
+ Create a backup of all files in the bucket.
465
+
466
+ Args:
467
+ backup_prefix: Path prefix for backup location
468
+
469
+ Returns:
470
+ Backup path
471
+ """
472
+ timestamp = int(time.time())
473
+ backup_path = f"{backup_prefix}/backup_{timestamp}"
474
+
475
+ try:
476
+ files = list_files()
477
+ for file_path in files:
478
+ try:
479
+ data = retrieve_file(file_path)
480
+ backup_file_path = f"{backup_path}/{file_path}"
481
+ store_file(backup_file_path, data, encrypt=True)
482
+ except Exception as e:
483
+ logger.error(f"Failed to backup {file_path}: {e}")
484
+
485
+ logger.info(f"💾 Backup created at: {backup_path}")
486
+ return backup_path
487
+ except Exception as e:
488
+ logger.error(f"❌ Backup failed: {e}")
489
+ raise
490
+
491
+ # ------------------------------------------------------------------------
492
+ # Cleanup
493
  # ------------------------------------------------------------------------
494
  def close():
495
+ """Clean up resources."""
496
  if _fs:
497
+ try:
498
+ _fs.close()
499
+ except:
500
+ pass
501
+
502
+ # ------------------------------------------------------------------------
503
+ # Export public API
504
+ # ------------------------------------------------------------------------
505
+ __all__ = [
506
+ 'store_file',
507
+ 'retrieve_file',
508
+ 'delete_file',
509
+ 'list_files',
510
+ 'file_exists',
511
+ 'get_file_size',
512
+ 'get_file_info',
513
+ 'store_file_stream',
514
+ 'retrieve_file_stream',
515
+ 'get_storage_stats',
516
+ 'create_backup',
517
+ 'encrypt_bytes',
518
+ 'decrypt_bytes',
519
+ 'close',
520
+ 'OWNER',
521
+ 'BUCKET_ID',
522
+ 'BUCKET_URI'
523
+ ]