Spaces:
Running
Running
Update storage_handler.py
Browse files- storage_handler.py +323 -39
storage_handler.py
CHANGED
|
@@ -43,6 +43,14 @@ except Exception as e:
|
|
| 43 |
|
| 44 |
assert len(FILE_ENCRYPTION_KEY) == 32, "FILE_ENCRYPTION_KEY must decode to exactly 32 bytes for AES-256"
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
# ------------------------------------------------------------------------
|
| 47 |
# Storage availability
|
| 48 |
# ------------------------------------------------------------------------
|
|
@@ -198,15 +206,53 @@ def _validate_path(remote_path: str) -> None:
|
|
| 198 |
if len(remote_path) > 1024:
|
| 199 |
raise ValueError("Path too long (max 1024 characters)")
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
# ------------------------------------------------------------------------
|
| 202 |
# Core File Operations
|
| 203 |
# ------------------------------------------------------------------------
|
| 204 |
def store_file(remote_path: str, data: bytes, encrypt: bool = True) -> str:
|
| 205 |
_validate_path(remote_path)
|
| 206 |
-
_require_available()
|
| 207 |
-
fs = _get_fs()
|
| 208 |
try:
|
| 209 |
payload = encrypt_bytes(data, remote_path.encode('utf-8')) if encrypt else data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
full_uri = _bucket_path(remote_path)
|
| 211 |
with fs.open(full_uri, "wb") as f:
|
| 212 |
f.write(payload)
|
|
@@ -221,14 +267,20 @@ def store_file(remote_path: str, data: bytes, encrypt: bool = True) -> str:
|
|
| 221 |
|
| 222 |
def retrieve_file(remote_path: str, decrypt: bool = True) -> bytes:
|
| 223 |
_validate_path(remote_path)
|
| 224 |
-
_require_available()
|
| 225 |
-
fs = _get_fs()
|
| 226 |
-
full_uri = _bucket_path(remote_path)
|
| 227 |
try:
|
| 228 |
-
if
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
if not payload:
|
| 233 |
raise IOError(f"Empty file: {remote_path}")
|
| 234 |
if decrypt:
|
|
@@ -247,11 +299,17 @@ def retrieve_file(remote_path: str, decrypt: bool = True) -> bytes:
|
|
| 247 |
|
| 248 |
def delete_file(remote_path: str) -> bool:
|
| 249 |
_validate_path(remote_path)
|
| 250 |
-
if _OFFLINE:
|
| 251 |
-
return False
|
| 252 |
-
fs = _get_fs()
|
| 253 |
-
full_uri = _bucket_path(remote_path)
|
| 254 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
if fs.exists(full_uri):
|
| 256 |
fs.rm(full_uri)
|
| 257 |
logger.debug(f"🗑️ Deleted: {remote_path}")
|
|
@@ -264,19 +322,19 @@ def delete_file(remote_path: str) -> bool:
|
|
| 264 |
|
| 265 |
|
| 266 |
def file_exists(remote_path: str) -> bool:
|
| 267 |
-
if _OFFLINE:
|
| 268 |
-
return False
|
| 269 |
try:
|
| 270 |
_validate_path(remote_path)
|
|
|
|
|
|
|
| 271 |
return _get_fs().exists(_bucket_path(remote_path))
|
| 272 |
except Exception:
|
| 273 |
return False
|
| 274 |
|
| 275 |
|
| 276 |
def list_files(prefix: str = "", recursive: bool = True) -> List[str]:
|
| 277 |
-
if _OFFLINE:
|
| 278 |
-
return []
|
| 279 |
try:
|
|
|
|
|
|
|
| 280 |
fs = _get_fs()
|
| 281 |
search_path = _bucket_path(prefix) if prefix else BUCKET_URI
|
| 282 |
items = fs.ls(search_path, detail=False, recursive=recursive)
|
|
@@ -290,10 +348,11 @@ def list_files(prefix: str = "", recursive: bool = True) -> List[str]:
|
|
| 290 |
|
| 291 |
|
| 292 |
def get_file_size(remote_path: str) -> Optional[int]:
|
| 293 |
-
if _OFFLINE:
|
| 294 |
-
return None
|
| 295 |
try:
|
| 296 |
_validate_path(remote_path)
|
|
|
|
|
|
|
|
|
|
| 297 |
info = _get_fs().info(_bucket_path(remote_path))
|
| 298 |
return info.get("size")
|
| 299 |
except Exception:
|
|
@@ -301,10 +360,20 @@ def get_file_size(remote_path: str) -> Optional[int]:
|
|
| 301 |
|
| 302 |
|
| 303 |
def get_file_info(remote_path: str) -> Optional[Dict[str, Any]]:
|
| 304 |
-
if _OFFLINE:
|
| 305 |
-
return None
|
| 306 |
try:
|
| 307 |
_validate_path(remote_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
fs = _get_fs()
|
| 309 |
info = fs.info(_bucket_path(remote_path))
|
| 310 |
return {
|
|
@@ -332,10 +401,14 @@ def retrieve_file_stream(remote_path: str, decrypt: bool = True) -> BinaryIO:
|
|
| 332 |
|
| 333 |
def get_storage_stats() -> Dict[str, Any]:
|
| 334 |
if _OFFLINE:
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
try:
|
| 340 |
fs = _get_fs()
|
| 341 |
items = fs.ls(BUCKET_URI, detail=True, recursive=True)
|
|
@@ -391,23 +464,26 @@ DB_SYNC_INTERVAL = 30 # seconds
|
|
| 391 |
|
| 392 |
def download_database(local_path: str) -> bool:
|
| 393 |
"""
|
| 394 |
-
Download database from
|
| 395 |
-
|
|
|
|
|
|
|
| 396 |
"""
|
| 397 |
-
if
|
| 398 |
-
logger.info("
|
| 399 |
return False
|
| 400 |
try:
|
| 401 |
if file_exists(DB_BUCKET_PATH):
|
| 402 |
-
logger.info("📥 Downloading database from
|
| 403 |
data = retrieve_file(DB_BUCKET_PATH)
|
| 404 |
-
os.
|
|
|
|
| 405 |
with open(local_path, 'wb') as f:
|
| 406 |
f.write(data)
|
| 407 |
logger.info(f"✅ Database downloaded ({len(data):,} bytes)")
|
| 408 |
return True
|
| 409 |
else:
|
| 410 |
-
logger.info("📭 No existing database in
|
| 411 |
return False
|
| 412 |
except Exception as e:
|
| 413 |
logger.error(f"❌ Failed to download database: {e}")
|
|
@@ -425,8 +501,6 @@ def upload_database(local_path: str) -> bool:
|
|
| 425 |
Returns True on success.
|
| 426 |
"""
|
| 427 |
global _last_db_sync
|
| 428 |
-
if _OFFLINE:
|
| 429 |
-
return False
|
| 430 |
with _db_sync_lock:
|
| 431 |
tmp_path = None
|
| 432 |
try:
|
|
@@ -467,14 +541,223 @@ def upload_database(local_path: str) -> bool:
|
|
| 467 |
pass
|
| 468 |
|
| 469 |
|
| 470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
"""
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
"""
|
| 474 |
-
|
| 475 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
return
|
| 477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
def _sync_loop():
|
| 479 |
while True:
|
| 480 |
time.sleep(DB_SYNC_INTERVAL)
|
|
@@ -504,5 +787,6 @@ __all__ = [
|
|
| 504 |
'store_file_stream', 'retrieve_file_stream',
|
| 505 |
'get_storage_stats', 'create_backup', 'close',
|
| 506 |
'download_database', 'upload_database', 'start_db_sync',
|
| 507 |
-
'
|
|
|
|
| 508 |
]
|
|
|
|
| 43 |
|
| 44 |
assert len(FILE_ENCRYPTION_KEY) == 32, "FILE_ENCRYPTION_KEY must decode to exactly 32 bytes for AES-256"
|
| 45 |
|
| 46 |
+
# Local filesystem fallback used when HF_TOKEN is not configured. This lets
|
| 47 |
+
# previews/dev machines upload files, run backups, and keep data between
|
| 48 |
+
# restarts without a Hugging Face account.
|
| 49 |
+
LOCAL_STORAGE_DIR = os.environ.get(
|
| 50 |
+
"LOCAL_STORAGE_DIR",
|
| 51 |
+
os.path.join(os.path.expanduser("~"), ".infinitychat_storage"),
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
# ------------------------------------------------------------------------
|
| 55 |
# Storage availability
|
| 56 |
# ------------------------------------------------------------------------
|
|
|
|
| 206 |
if len(remote_path) > 1024:
|
| 207 |
raise ValueError("Path too long (max 1024 characters)")
|
| 208 |
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _local_root() -> str:
|
| 212 |
+
os.makedirs(LOCAL_STORAGE_DIR, exist_ok=True)
|
| 213 |
+
return LOCAL_STORAGE_DIR
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _local_path(remote_path: str) -> str:
|
| 217 |
+
_validate_path(remote_path)
|
| 218 |
+
return os.path.join(_local_root(), *remote_path.split("/"))
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _local_list(prefix: str = "", recursive: bool = True) -> List[str]:
|
| 222 |
+
root = _local_root()
|
| 223 |
+
base = os.path.join(root, *prefix.split("/")) if prefix else root
|
| 224 |
+
if not os.path.isdir(base):
|
| 225 |
+
return []
|
| 226 |
+
out = []
|
| 227 |
+
if recursive:
|
| 228 |
+
for dirpath, _dirnames, filenames in os.walk(base):
|
| 229 |
+
for name in filenames:
|
| 230 |
+
full = os.path.join(dirpath, name)
|
| 231 |
+
out.append(os.path.relpath(full, root).replace(os.sep, "/"))
|
| 232 |
+
else:
|
| 233 |
+
for name in os.listdir(base):
|
| 234 |
+
full = os.path.join(base, name)
|
| 235 |
+
if os.path.isfile(full):
|
| 236 |
+
out.append(os.path.relpath(full, root).replace(os.sep, "/"))
|
| 237 |
+
return sorted(out)
|
| 238 |
+
|
| 239 |
# ------------------------------------------------------------------------
|
| 240 |
# Core File Operations
|
| 241 |
# ------------------------------------------------------------------------
|
| 242 |
def store_file(remote_path: str, data: bytes, encrypt: bool = True) -> str:
|
| 243 |
_validate_path(remote_path)
|
|
|
|
|
|
|
| 244 |
try:
|
| 245 |
payload = encrypt_bytes(data, remote_path.encode('utf-8')) if encrypt else data
|
| 246 |
+
if _OFFLINE:
|
| 247 |
+
path = _local_path(remote_path)
|
| 248 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 249 |
+
tmp = path + ".tmp"
|
| 250 |
+
with open(tmp, "wb") as f:
|
| 251 |
+
f.write(payload)
|
| 252 |
+
os.replace(tmp, path)
|
| 253 |
+
logger.debug(f"💾 Stored (local): {remote_path} ({len(data)} bytes)")
|
| 254 |
+
return remote_path
|
| 255 |
+
fs = _get_fs()
|
| 256 |
full_uri = _bucket_path(remote_path)
|
| 257 |
with fs.open(full_uri, "wb") as f:
|
| 258 |
f.write(payload)
|
|
|
|
| 267 |
|
| 268 |
def retrieve_file(remote_path: str, decrypt: bool = True) -> bytes:
|
| 269 |
_validate_path(remote_path)
|
|
|
|
|
|
|
|
|
|
| 270 |
try:
|
| 271 |
+
if _OFFLINE:
|
| 272 |
+
path = _local_path(remote_path)
|
| 273 |
+
if not os.path.exists(path):
|
| 274 |
+
raise FileNotFoundError(f"File not found: {remote_path}")
|
| 275 |
+
with open(path, "rb") as f:
|
| 276 |
+
payload = f.read()
|
| 277 |
+
else:
|
| 278 |
+
fs = _get_fs()
|
| 279 |
+
full_uri = _bucket_path(remote_path)
|
| 280 |
+
if not fs.exists(full_uri):
|
| 281 |
+
raise FileNotFoundError(f"File not found: {remote_path}")
|
| 282 |
+
with fs.open(full_uri, "rb") as f:
|
| 283 |
+
payload = f.read()
|
| 284 |
if not payload:
|
| 285 |
raise IOError(f"Empty file: {remote_path}")
|
| 286 |
if decrypt:
|
|
|
|
| 299 |
|
| 300 |
def delete_file(remote_path: str) -> bool:
|
| 301 |
_validate_path(remote_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
try:
|
| 303 |
+
if _OFFLINE:
|
| 304 |
+
path = _local_path(remote_path)
|
| 305 |
+
if os.path.exists(path):
|
| 306 |
+
os.unlink(path)
|
| 307 |
+
logger.debug(f"🗑️ Deleted (local): {remote_path}")
|
| 308 |
+
return True
|
| 309 |
+
logger.warning(f"⚠️ Not found for deletion: {remote_path}")
|
| 310 |
+
return False
|
| 311 |
+
fs = _get_fs()
|
| 312 |
+
full_uri = _bucket_path(remote_path)
|
| 313 |
if fs.exists(full_uri):
|
| 314 |
fs.rm(full_uri)
|
| 315 |
logger.debug(f"🗑️ Deleted: {remote_path}")
|
|
|
|
| 322 |
|
| 323 |
|
| 324 |
def file_exists(remote_path: str) -> bool:
|
|
|
|
|
|
|
| 325 |
try:
|
| 326 |
_validate_path(remote_path)
|
| 327 |
+
if _OFFLINE:
|
| 328 |
+
return os.path.exists(_local_path(remote_path))
|
| 329 |
return _get_fs().exists(_bucket_path(remote_path))
|
| 330 |
except Exception:
|
| 331 |
return False
|
| 332 |
|
| 333 |
|
| 334 |
def list_files(prefix: str = "", recursive: bool = True) -> List[str]:
|
|
|
|
|
|
|
| 335 |
try:
|
| 336 |
+
if _OFFLINE:
|
| 337 |
+
return _local_list(prefix, recursive)
|
| 338 |
fs = _get_fs()
|
| 339 |
search_path = _bucket_path(prefix) if prefix else BUCKET_URI
|
| 340 |
items = fs.ls(search_path, detail=False, recursive=recursive)
|
|
|
|
| 348 |
|
| 349 |
|
| 350 |
def get_file_size(remote_path: str) -> Optional[int]:
|
|
|
|
|
|
|
| 351 |
try:
|
| 352 |
_validate_path(remote_path)
|
| 353 |
+
if _OFFLINE:
|
| 354 |
+
path = _local_path(remote_path)
|
| 355 |
+
return os.path.getsize(path) if os.path.exists(path) else None
|
| 356 |
info = _get_fs().info(_bucket_path(remote_path))
|
| 357 |
return info.get("size")
|
| 358 |
except Exception:
|
|
|
|
| 360 |
|
| 361 |
|
| 362 |
def get_file_info(remote_path: str) -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
|
| 363 |
try:
|
| 364 |
_validate_path(remote_path)
|
| 365 |
+
if _OFFLINE:
|
| 366 |
+
path = _local_path(remote_path)
|
| 367 |
+
if not os.path.exists(path):
|
| 368 |
+
return None
|
| 369 |
+
st = os.stat(path)
|
| 370 |
+
return {
|
| 371 |
+
"name": remote_path,
|
| 372 |
+
"size": st.st_size,
|
| 373 |
+
"created": st.st_ctime,
|
| 374 |
+
"modified": st.st_mtime,
|
| 375 |
+
"type": "file",
|
| 376 |
+
}
|
| 377 |
fs = _get_fs()
|
| 378 |
info = fs.info(_bucket_path(remote_path))
|
| 379 |
return {
|
|
|
|
| 401 |
|
| 402 |
def get_storage_stats() -> Dict[str, Any]:
|
| 403 |
if _OFFLINE:
|
| 404 |
+
try:
|
| 405 |
+
files = _local_list()
|
| 406 |
+
total = sum(os.path.getsize(_local_path(p)) for p in files)
|
| 407 |
+
return {"bucket": "local", "file_count": len(files), "total_size": total,
|
| 408 |
+
"total_size_mb": round(total / (1024 * 1024), 2)}
|
| 409 |
+
except Exception as e:
|
| 410 |
+
return {"bucket": "local", "file_count": 0, "total_size": 0,
|
| 411 |
+
"total_size_mb": 0, "error": str(e)}
|
| 412 |
try:
|
| 413 |
fs = _get_fs()
|
| 414 |
items = fs.ls(BUCKET_URI, detail=True, recursive=True)
|
|
|
|
| 464 |
|
| 465 |
def download_database(local_path: str) -> bool:
|
| 466 |
"""
|
| 467 |
+
Download database from storage to the local path when no local database
|
| 468 |
+
exists yet. The local SQLite file is the working copy; overwriting it on
|
| 469 |
+
every restart would discard newer local changes (DMs/groups/social posts)
|
| 470 |
+
that haven't been synced back yet.
|
| 471 |
"""
|
| 472 |
+
if os.path.exists(local_path):
|
| 473 |
+
logger.info("📁 Local database already exists - keeping it")
|
| 474 |
return False
|
| 475 |
try:
|
| 476 |
if file_exists(DB_BUCKET_PATH):
|
| 477 |
+
logger.info("📥 Downloading database from storage...")
|
| 478 |
data = retrieve_file(DB_BUCKET_PATH)
|
| 479 |
+
dname = os.path.dirname(os.path.abspath(local_path))
|
| 480 |
+
os.makedirs(dname, exist_ok=True)
|
| 481 |
with open(local_path, 'wb') as f:
|
| 482 |
f.write(data)
|
| 483 |
logger.info(f"✅ Database downloaded ({len(data):,} bytes)")
|
| 484 |
return True
|
| 485 |
else:
|
| 486 |
+
logger.info("📭 No existing database in storage - will create fresh")
|
| 487 |
return False
|
| 488 |
except Exception as e:
|
| 489 |
logger.error(f"❌ Failed to download database: {e}")
|
|
|
|
| 501 |
Returns True on success.
|
| 502 |
"""
|
| 503 |
global _last_db_sync
|
|
|
|
|
|
|
| 504 |
with _db_sync_lock:
|
| 505 |
tmp_path = None
|
| 506 |
try:
|
|
|
|
| 541 |
pass
|
| 542 |
|
| 543 |
|
| 544 |
+
# ------------------------------------------------------------------------
|
| 545 |
+
# Backups (database + all data files) to a separate folder in the bucket
|
| 546 |
+
# ------------------------------------------------------------------------
|
| 547 |
+
BACKUP_DIR = "backups"
|
| 548 |
+
BACKUP_RETENTION_HOURS = int(os.environ.get("BACKUP_RETENTION_HOURS", "48"))
|
| 549 |
+
BACKUP_RETENTION_DAILY = int(os.environ.get("BACKUP_RETENTION_DAILY", "7"))
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
def _utc_slug() -> str:
|
| 553 |
+
return time.strftime("%Y-%m-%d_%H-%M-%S", time.gmtime())
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def _snapshot_db_bytes(local_path: str) -> bytes:
|
| 557 |
+
"""Take a consistent SQLite snapshot and return its bytes.
|
| 558 |
+
|
| 559 |
+
Uses the sqlite online backup API so WAL-mode writes are not lost and the
|
| 560 |
+
copied file is never a torn/corrupt snapshot.
|
| 561 |
"""
|
| 562 |
+
if not os.path.exists(local_path):
|
| 563 |
+
raise FileNotFoundError(f"Database not found at {local_path}")
|
| 564 |
+
fd, tmp = tempfile.mkstemp(suffix=".db", dir=os.path.dirname(os.path.abspath(local_path)) or ".")
|
| 565 |
+
os.close(fd)
|
| 566 |
+
os.unlink(tmp)
|
| 567 |
+
src = sqlite3.connect(f"file:{local_path}?mode=ro", uri=True)
|
| 568 |
+
try:
|
| 569 |
+
dst = sqlite3.connect(tmp)
|
| 570 |
+
try:
|
| 571 |
+
src.backup(dst)
|
| 572 |
+
finally:
|
| 573 |
+
dst.close()
|
| 574 |
+
finally:
|
| 575 |
+
src.close()
|
| 576 |
+
with open(tmp, "rb") as f:
|
| 577 |
+
data = f.read()
|
| 578 |
+
try:
|
| 579 |
+
os.unlink(tmp)
|
| 580 |
+
except Exception:
|
| 581 |
+
pass
|
| 582 |
+
if not data:
|
| 583 |
+
raise IOError("Database snapshot was empty")
|
| 584 |
+
return data
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
def _copy_live_files_to_backup(backup_prefix: str) -> int:
|
| 588 |
+
"""Copy current data files (uploads/avatars/database) into a backup folder.
|
| 589 |
+
|
| 590 |
+
Existing backups under backups/ are excluded so hourly backups don't grow
|
| 591 |
+
exponentially by copying previous backups.
|
| 592 |
"""
|
| 593 |
+
copied = 0
|
| 594 |
+
for path in list_files(""):
|
| 595 |
+
if path == DB_BUCKET_PATH or path == "database/infinitychat.db":
|
| 596 |
+
continue # database is saved separately from the snapshot
|
| 597 |
+
if path.startswith(BACKUP_DIR + "/"):
|
| 598 |
+
continue
|
| 599 |
+
try:
|
| 600 |
+
data = retrieve_file(path) # decrypted to plaintext
|
| 601 |
+
store_file(f"{backup_prefix}/{path}", data, encrypt=True)
|
| 602 |
+
copied += 1
|
| 603 |
+
except Exception as e:
|
| 604 |
+
logger.warning(f"⚠️ Backup skipped {path}: {e}")
|
| 605 |
+
return copied
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
def list_backups() -> List[Dict[str, Any]]:
|
| 609 |
+
"""Return metadata for all timestamped backups (newest first)."""
|
| 610 |
+
out: Dict[str, Dict[str, Any]] = {}
|
| 611 |
+
try:
|
| 612 |
+
for path in list_files(BACKUP_DIR):
|
| 613 |
+
parts = path.split("/")
|
| 614 |
+
if len(parts) < 4:
|
| 615 |
+
continue
|
| 616 |
+
category, stamp = parts[1], parts[2]
|
| 617 |
+
key = f"{category}/{stamp}"
|
| 618 |
+
if key not in out:
|
| 619 |
+
out[key] = {
|
| 620 |
+
"path": f"{BACKUP_DIR}/{category}/{stamp}",
|
| 621 |
+
"category": category,
|
| 622 |
+
"timestamp": stamp,
|
| 623 |
+
"file_count": 0,
|
| 624 |
+
"size": 0,
|
| 625 |
+
"created": None,
|
| 626 |
+
"has_database": False,
|
| 627 |
+
}
|
| 628 |
+
info = get_file_info(path)
|
| 629 |
+
out[key]["file_count"] += 1
|
| 630 |
+
out[key]["size"] += int(info.get("size") or 0) if info else 0
|
| 631 |
+
out[key]["created"] = info.get("created") if info else None
|
| 632 |
+
if path.endswith("infinitychat.db"):
|
| 633 |
+
out[key]["has_database"] = True
|
| 634 |
+
except Exception as e:
|
| 635 |
+
logger.error(f"Failed to list backups: {e}")
|
| 636 |
+
return sorted(out.values(), key=lambda x: x["timestamp"], reverse=True)
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def create_timestamped_backup(local_path: str, category: str = "hourly") -> Optional[str]:
|
| 640 |
+
"""Create a full point-in-time backup of the DB + data files.
|
| 641 |
+
|
| 642 |
+
Returns the backup prefix (e.g. backups/hourly/2026-01-01_00-00-00) or None
|
| 643 |
+
when the storage backend is unavailable.
|
| 644 |
+
"""
|
| 645 |
+
with _db_sync_lock:
|
| 646 |
+
prefix = f"{BACKUP_DIR}/{category}/{_utc_slug()}"
|
| 647 |
+
try:
|
| 648 |
+
db_bytes = _snapshot_db_bytes(local_path)
|
| 649 |
+
store_file(f"{prefix}/database/infinitychat.db", db_bytes, encrypt=True)
|
| 650 |
+
copied = _copy_live_files_to_backup(prefix)
|
| 651 |
+
logger.info(f"💾 Backup created: {prefix} (db + {copied} files)")
|
| 652 |
+
prune_backups()
|
| 653 |
+
return prefix
|
| 654 |
+
except Exception as e:
|
| 655 |
+
logger.error(f"❌ Backup failed: {e}")
|
| 656 |
+
return None
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def prune_backups():
|
| 660 |
+
"""Keep the newest N hourly backups and the last daily marker per day."""
|
| 661 |
+
try:
|
| 662 |
+
hourly = sorted([b for b in list_backups() if b["category"] == "hourly"],
|
| 663 |
+
key=lambda b: b["timestamp"], reverse=True)
|
| 664 |
+
for b in hourly[BACKUP_RETENTION_HOURS:]:
|
| 665 |
+
_delete_tree(b["path"])
|
| 666 |
+
# Keep at most one backup per calendar day (the newest of that day),
|
| 667 |
+
# for a longer daily retention window.
|
| 668 |
+
seen_days = set()
|
| 669 |
+
for b in hourly[:BACKUP_RETENTION_HOURS]:
|
| 670 |
+
day = b["timestamp"][:10]
|
| 671 |
+
if day not in seen_days:
|
| 672 |
+
seen_days.add(day)
|
| 673 |
+
daily = sorted([b for b in hourly if b["timestamp"][:10] not in seen_days],
|
| 674 |
+
key=lambda b: b["timestamp"], reverse=True)
|
| 675 |
+
for b in daily[BACKUP_RETENTION_DAILY:]:
|
| 676 |
+
_delete_tree(b["path"])
|
| 677 |
+
except Exception as e:
|
| 678 |
+
logger.error(f"Failed to prune backups: {e}")
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
def _delete_tree(prefix: str):
|
| 682 |
+
try:
|
| 683 |
+
for path in list_files(prefix):
|
| 684 |
+
try:
|
| 685 |
+
delete_file(path)
|
| 686 |
+
except Exception:
|
| 687 |
+
pass
|
| 688 |
+
except Exception as e:
|
| 689 |
+
logger.warning(f"Failed to delete backup tree {prefix}: {e}")
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
def restore_backup(local_db_path: str, backup_prefix: str) -> int:
|
| 693 |
+
"""Restore a database + data files from a backup prefix.
|
| 694 |
+
|
| 695 |
+
Returns the number of files restored. WAL/SHM sidecars are removed first so
|
| 696 |
+
the server reads the restored snapshot on the next request.
|
| 697 |
+
"""
|
| 698 |
+
if not backup_prefix or backup_prefix.startswith("/") or ".." in backup_prefix.split("/"):
|
| 699 |
+
raise ValueError("Invalid backup prefix")
|
| 700 |
+
files = list_files(backup_prefix)
|
| 701 |
+
if not files:
|
| 702 |
+
raise FileNotFoundError("Backup not found")
|
| 703 |
+
restored = 0
|
| 704 |
+
for path in files:
|
| 705 |
+
data = retrieve_file(path) # decrypt
|
| 706 |
+
rel = path[len(backup_prefix) + 1:]
|
| 707 |
+
if rel == "database/infinitychat.db":
|
| 708 |
+
os.makedirs(os.path.dirname(local_db_path) or ".", exist_ok=True)
|
| 709 |
+
with open(local_db_path, "wb") as f:
|
| 710 |
+
f.write(data)
|
| 711 |
+
for suffix in ("-wal", "-shm"):
|
| 712 |
+
try:
|
| 713 |
+
if os.path.exists(local_db_path + suffix):
|
| 714 |
+
os.unlink(local_db_path + suffix)
|
| 715 |
+
except Exception:
|
| 716 |
+
pass
|
| 717 |
+
else:
|
| 718 |
+
store_file(rel, data, encrypt=True) # re-encrypt with current key
|
| 719 |
+
restored += 1
|
| 720 |
+
logger.info(f"♻️ Restored {restored} file(s) from {backup_prefix}")
|
| 721 |
+
return restored
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
def delete_backup(backup_prefix: str) -> None:
|
| 725 |
+
"""Delete a timestamped backup tree. Only backups/... paths are allowed."""
|
| 726 |
+
if not backup_prefix or backup_prefix.startswith("/") or ".." in backup_prefix.split("/"):
|
| 727 |
+
raise ValueError("Invalid backup prefix")
|
| 728 |
+
bucket_prefix = f"{BACKUP_DIR}/"
|
| 729 |
+
if not backup_prefix.startswith(bucket_prefix):
|
| 730 |
+
raise ValueError("Only backups from the backup bucket can be deleted")
|
| 731 |
+
_delete_tree(backup_prefix)
|
| 732 |
+
logger.info(f"🗑 Deleted backup tree: {backup_prefix}")
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
_backup_loop_started = False
|
| 736 |
+
|
| 737 |
+
|
| 738 |
+
def start_backup_loop(local_path: str, interval: int = 3600):
|
| 739 |
+
"""Background thread that creates an hourly full backup."""
|
| 740 |
+
global _backup_loop_started
|
| 741 |
+
if _backup_loop_started:
|
| 742 |
return
|
| 743 |
|
| 744 |
+
def _loop():
|
| 745 |
+
while True:
|
| 746 |
+
time.sleep(interval)
|
| 747 |
+
try:
|
| 748 |
+
create_timestamped_backup(local_path, "hourly")
|
| 749 |
+
except Exception as e:
|
| 750 |
+
logger.warning(f"⚠️ Hourly backup failed: {e}")
|
| 751 |
+
|
| 752 |
+
_backup_loop_started = True
|
| 753 |
+
t = threading.Thread(target=_loop, daemon=True)
|
| 754 |
+
t.start()
|
| 755 |
+
logger.info(f"🕐 Hourly backup thread started (every {interval}s)")
|
| 756 |
+
|
| 757 |
+
def start_db_sync(local_path: str):
|
| 758 |
+
"""
|
| 759 |
+
Start background thread that periodically uploads the database to bucket.
|
| 760 |
+
"""
|
| 761 |
def _sync_loop():
|
| 762 |
while True:
|
| 763 |
time.sleep(DB_SYNC_INTERVAL)
|
|
|
|
| 787 |
'store_file_stream', 'retrieve_file_stream',
|
| 788 |
'get_storage_stats', 'create_backup', 'close',
|
| 789 |
'download_database', 'upload_database', 'start_db_sync',
|
| 790 |
+
'list_backups', 'create_timestamped_backup', 'restore_backup',
|
| 791 |
+
'start_backup_loop', 'DB_BUCKET_PATH', 'StorageUnavailableError'
|
| 792 |
]
|