Spaces:
Runtime error
Runtime error
| #!/usr/bin/env python3 | |
| """Disaster-recovery backup for Firebase / Firestore (pielts project). | |
| What it backs up | |
| ---------------- | |
| 1. Firestore β every collection exported to newline-delimited JSON. | |
| 2. Firebase Auth users β exported to auth_users.json. | |
| 3. Firebase Hosting meta β records the live deploy URL for reference. | |
| Output layout | |
| ------------- | |
| data/backups/firebase/YYYYMMDD_HHMMSS/ | |
| firestore/<collection>.jsonl | |
| auth_users.json | |
| hosting_meta.json | |
| manifest.json β summary with doc counts, timestamps, errors | |
| Retention: keeps the last KEEP_BACKUPS snapshots (default 8). | |
| Environment / config used | |
| ------------------------- | |
| FIREBASE_ADMIN_JSON path to service-account JSON (default: <root>/firebase-admin.json) | |
| BACKUP_FIREBASE_DIR override output directory | |
| BACKUP_KEEP how many snapshots to keep (default 8) | |
| """ | |
| import json | |
| import os | |
| import sys | |
| from datetime import datetime | |
| from pathlib import Path | |
| # ββ resolve project root so we can import saathi.config ββββββββββββββββββββββ | |
| ROOT = Path(__file__).resolve().parent.parent | |
| sys.path.insert(0, str(ROOT)) | |
| try: | |
| from saathi import config as _cfg | |
| TELEGRAM_BOT_TOKEN = _cfg.TELEGRAM_BOT_TOKEN | |
| TELEGRAM_CHAT_ID = _cfg.TELEGRAM_CHAT_ID | |
| except Exception: | |
| TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") | |
| TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") | |
| FIREBASE_ADMIN_JSON = Path(os.getenv("FIREBASE_ADMIN_JSON", str(ROOT / "firebase-admin.json"))) | |
| BACKUP_BASE = Path(os.getenv("BACKUP_FIREBASE_DIR", str(ROOT / "data" / "backups" / "firebase"))) | |
| KEEP_BACKUPS = int(os.getenv("BACKUP_KEEP", "8")) | |
| # ββ helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _telegram(msg: str): | |
| """Best-effort Telegram notification.""" | |
| if not (TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID): | |
| return | |
| try: | |
| import urllib.request | |
| url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" | |
| data = json.dumps({"chat_id": TELEGRAM_CHAT_ID, "text": msg[:4000]}).encode() | |
| req = urllib.request.Request(url, data=data, | |
| headers={"Content-Type": "application/json"}) | |
| urllib.request.urlopen(req, timeout=10) | |
| except Exception: | |
| pass | |
| def _serialize(value): | |
| """Convert Firestore-native types to JSON-serialisable Python types.""" | |
| from google.cloud.firestore_v1 import DocumentReference | |
| from google.protobuf.timestamp_pb2 import Timestamp # type: ignore | |
| if hasattr(value, "isoformat"): # datetime / date | |
| return value.isoformat() | |
| if isinstance(value, DocumentReference): | |
| return {"__ref__": value.path} | |
| if isinstance(value, dict): | |
| return {k: _serialize(v) for k, v in value.items()} | |
| if isinstance(value, list): | |
| return [_serialize(i) for i in value] | |
| return value | |
| # ββ main backup routine βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_backup() -> dict: | |
| """Execute the full Firebase backup. Returns a summary dict.""" | |
| ts = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| out_dir = BACKUP_BASE / ts | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| (out_dir / "firestore").mkdir() | |
| manifest: dict = { | |
| "timestamp": ts, | |
| "collections": {}, | |
| "auth_users": 0, | |
| "errors": [], | |
| } | |
| # ββ 0. Initialise Firebase Admin SDK βββββββββββββββββββββββββββββββββββββ | |
| try: | |
| import firebase_admin | |
| from firebase_admin import credentials, firestore, auth | |
| except ImportError: | |
| msg = ("firebase-admin not installed β run:\n" | |
| " pip install firebase-admin\n" | |
| "then retry.") | |
| manifest["errors"].append(msg) | |
| (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2)) | |
| _telegram(f"π΄ Firebase backup FAILED\n{msg}") | |
| return manifest | |
| if not FIREBASE_ADMIN_JSON.exists(): | |
| msg = f"Service-account file not found: {FIREBASE_ADMIN_JSON}" | |
| manifest["errors"].append(msg) | |
| (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2)) | |
| _telegram(f"π΄ Firebase backup FAILED\n{msg}") | |
| return manifest | |
| # Re-use existing app if already initialised (e.g. running inside Baadar) | |
| try: | |
| app = firebase_admin.get_app("backup") | |
| except ValueError: | |
| cred = credentials.Certificate(str(FIREBASE_ADMIN_JSON)) | |
| app = firebase_admin.initialize_app(cred, name="backup") | |
| # ββ 1. Firestore export βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| db = firestore.client(app=app) | |
| try: | |
| collections = list(db.collections()) | |
| except Exception as e: | |
| manifest["errors"].append(f"Firestore list collections: {e}") | |
| collections = [] | |
| for col_ref in collections: | |
| col_name = col_ref.id | |
| jsonl_path = out_dir / "firestore" / f"{col_name}.jsonl" | |
| count = 0 | |
| try: | |
| with open(jsonl_path, "w", encoding="utf-8") as fh: | |
| for doc in col_ref.stream(): | |
| row = {"__id__": doc.id} | |
| row.update(_serialize(doc.to_dict() or {})) | |
| fh.write(json.dumps(row, ensure_ascii=False) + "\n") | |
| count += 1 | |
| manifest["collections"][col_name] = count | |
| except Exception as e: | |
| manifest["errors"].append(f"Firestore collection '{col_name}': {e}") | |
| # ββ 2. Auth users export ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| users = [] | |
| try: | |
| page = auth.list_users(app=app) | |
| while page: | |
| for u in page.users: | |
| users.append({ | |
| "uid": u.uid, | |
| "email": u.email, | |
| "display_name": u.display_name, | |
| "phone_number": u.phone_number, | |
| "disabled": u.disabled, | |
| "email_verified": u.email_verified, | |
| "creation_time": u.user_metadata.creation_timestamp, | |
| "last_sign_in": u.user_metadata.last_sign_in_timestamp, | |
| "provider_ids": [p.provider_id for p in u.provider_data], | |
| }) | |
| page = page.get_next_page() | |
| (out_dir / "auth_users.json").write_text( | |
| json.dumps(users, indent=2, ensure_ascii=False)) | |
| manifest["auth_users"] = len(users) | |
| except Exception as e: | |
| manifest["errors"].append(f"Auth export: {e}") | |
| # ββ 3. Hosting meta (project ID from service-account) ββββββββββββββββββββ | |
| try: | |
| sa = json.loads(FIREBASE_ADMIN_JSON.read_text()) | |
| hosting_meta = { | |
| "project_id": sa.get("project_id"), | |
| "client_email": sa.get("client_email"), | |
| "hosting_url": f"https://{sa.get('project_id')}.web.app", | |
| "backup_ts": ts, | |
| } | |
| (out_dir / "hosting_meta.json").write_text( | |
| json.dumps(hosting_meta, indent=2)) | |
| except Exception as e: | |
| manifest["errors"].append(f"Hosting meta: {e}") | |
| # ββ 4. Write manifest βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2)) | |
| # ββ 5. Prune old snapshots ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| snapshots = sorted(BACKUP_BASE.glob("2*")) # all YYYYMMDD_* dirs | |
| for old in snapshots[:-KEEP_BACKUPS]: | |
| try: | |
| import shutil | |
| shutil.rmtree(old) | |
| except Exception: | |
| pass | |
| # ββ 6. Report βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| total_docs = sum(manifest["collections"].values()) | |
| status = "β " if not manifest["errors"] else "β οΈ" | |
| summary = ( | |
| f"{status} Firebase backup {ts}\n" | |
| f"Collections: {len(manifest['collections'])} ({total_docs} docs)\n" | |
| f"Auth users: {manifest['auth_users']}\n" | |
| f"Saved to: {out_dir}\n" | |
| ) | |
| if manifest["errors"]: | |
| summary += "Errors:\n" + "\n".join(f" β’ {e}" for e in manifest["errors"]) | |
| print(summary) | |
| _telegram(summary) | |
| return manifest | |
| if __name__ == "__main__": | |
| result = run_backup() | |
| sys.exit(1 if result.get("errors") else 0) | |