diff --git a/database/bookmarks_manager.py b/database/bookmarks_manager.py index 4cfe1a116..61862877d 100644 --- a/database/bookmarks_manager.py +++ b/database/bookmarks_manager.py @@ -62,6 +62,11 @@ def _ensure_indexes(self): self.collection.drop_index("unique_user_file_anchor") except Exception: pass + # מחיקת אינדקס ישן שהוחלף בשם חדש + try: + self.collection.drop_index("user_file_lookup") + except Exception: + pass indexes = [ # אינדקס ייחודי למניעת כפילויות בשורה @@ -85,7 +90,7 @@ def _ensure_indexes(self): # אינדקס לחיפוש מהיר לפי משתמש וקובץ IndexModel( [("user_id", ASCENDING), ("file_id", ASCENDING)], - name="user_file_lookup", + name="file_bookmarks_user_file_idx", ), # אינדקס לחיפוש לפי משתמש בלבד IndexModel( diff --git a/database/manager.py b/database/manager.py index 44aa6c78b..5706800cc 100644 --- a/database/manager.py +++ b/database/manager.py @@ -1629,12 +1629,7 @@ def safe_create_index(*args: Any, **kwargs: Any) -> None: name="announcements_is_active_idx", ) - # file_bookmarks - אינדקס משולב user_id+file_id לצמצום חיפושים לפי משתמש+קובץ - safe_create_index( - "file_bookmarks", - [("user_id", ASCENDING), ("file_id", ASCENDING)], - name="file_bookmarks_user_file_idx", - ) + # file_bookmarks — האינדקס user_id+file_id מנוהל ב-BookmarksManager._ensure_indexes() # recent_opens - אינדקס משולב user_id+file_name לשליפה מהירה של "נפתח לאחרונה" safe_create_index( diff --git a/services/personal_backup_service.py b/services/personal_backup_service.py index 55bc49e7e..fe533f901 100644 --- a/services/personal_backup_service.py +++ b/services/personal_backup_service.py @@ -3,10 +3,11 @@ """ import json import logging +import time import zipfile from datetime import datetime, timezone from io import BytesIO -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional logger = logging.getLogger(__name__) @@ -482,6 +483,7 @@ def restore_user_data( zip_bytes: bytes, *, overwrite: bool = False, + progress_cb: Optional[Callable[[int, str], None]] = None, ) -> Dict[str, Any]: """ משחזר נתוני משתמש מקובץ ZIP. @@ -490,6 +492,7 @@ def restore_user_data( user_id: מזהה המשתמש המשחזר zip_bytes: תוכן קובץ ה-ZIP overwrite: אם True, יוצר גרסה חדשה לקבצים קיימים (ההיסטוריה נשמרת) + progress_cb: callback אופציונלי (percent: int, step: str) לדיווח התקדמות Returns: dict עם סיכום: {"ok": bool, "restored": {...}, "errors": [...]} @@ -567,24 +570,44 @@ def restore_user_data( errors.append("שגיאה בקריאת backup_info") backup_info = {} + def _report(pct: int, step: str): + if progress_cb: + try: + progress_cb(pct, step) + except Exception: + pass + + _report(5, "קורא מטאדאטה...") + # 1) שחזור קבצים רגילים files_meta = self._read_json_from_zip(zf, "metadata/files.json", errors, budget=budget) regular_meta = ( files_meta.get("regular_files", []) if isinstance(files_meta, dict) else [] ) + _report(10, f"משחזר קבצים (0/{len(regular_meta)})...") restored["files"] = self._restore_regular_files( - zf, user_id, regular_meta, overwrite, errors, budget=budget + zf, user_id, regular_meta, overwrite, errors, budget=budget, + progress_cb=lambda done, total: _report( + 10 + int(40 * done / max(total, 1)), + f"משחזר קבצים ({done}/{total})...", + ), ) # 2) שחזור קבצים גדולים large_meta = ( files_meta.get("large_files", []) if isinstance(files_meta, dict) else [] ) + _report(50, f"משחזר קבצים גדולים (0/{len(large_meta)})...") restored["large_files"] = self._restore_large_files( - zf, user_id, large_meta, overwrite, errors, budget=budget + zf, user_id, large_meta, overwrite, errors, budget=budget, + progress_cb=lambda done, total: _report( + 50 + int(15 * done / max(total, 1)), + f"משחזר קבצים גדולים ({done}/{total})...", + ), ) # 3) שחזור אוספים + _report(65, "משחזר אוספים...") collections_data = self._read_json_from_zip( zf, "metadata/collections.json", errors, budget=budget ) @@ -594,11 +617,13 @@ def restore_user_data( restored["collection_items"] = ci # 4) שחזור סימניות + _report(75, "משחזר סימניות...") bookmarks_data = self._read_json_from_zip(zf, "metadata/bookmarks.json", errors, budget=budget) if isinstance(bookmarks_data, list): restored["bookmarks"] = self._restore_bookmarks(user_id, bookmarks_data, errors) # 5) שחזור פתקיות + _report(85, "משחזר פתקיות...") notes_data = self._read_json_from_zip( zf, "metadata/sticky_notes.json", errors, budget=budget ) @@ -608,6 +633,7 @@ def restore_user_data( ) # 6) שחזור העדפות + _report(92, "משחזר העדפות...") prefs_data = self._read_json_from_zip( zf, "metadata/preferences.json", errors, budget=budget ) @@ -615,10 +641,13 @@ def restore_user_data( restored["preferences"] = self._restore_preferences(user_id, prefs_data, errors) # 7) שחזור העדפות Drive + _report(96, "משחזר העדפות Drive...") drive_data = self._read_json_from_zip(zf, "metadata/drive_prefs.json", errors, budget=budget) if isinstance(drive_data, dict) and drive_data: restored["drive_prefs"] = self._restore_drive_prefs(user_id, drive_data, errors) + _report(100, "השחזור הושלם") + emit_event( "personal_backup_restore", user_id=user_id, @@ -638,6 +667,8 @@ def _restore_regular_files( errors: List[str], *, budget: "_ZipReadBudget", + progress_cb: Optional[Callable[[int, int], None]] = None, + batch_size: int = 20, ) -> int: """משחזר קבצי קוד רגילים. @@ -650,7 +681,16 @@ def _restore_regular_files( from database.models import CodeSnippet count = 0 - for meta in meta_list: + total = len(meta_list) + for idx, meta in enumerate(meta_list): + # דיווח התקדמות + הפוגה בין מנות לתת ל-DB לנשום + if progress_cb and idx % batch_size == 0: + try: + progress_cb(idx, total) + except Exception: + pass + if idx > 0 and idx % batch_size == 0: + time.sleep(0.05) file_name = meta.get("file_name", "") if not file_name: continue @@ -773,6 +813,8 @@ def _restore_large_files( errors: List[str], *, budget: "_ZipReadBudget", + progress_cb: Optional[Callable[[int, int], None]] = None, + batch_size: int = 20, ) -> int: """משחזר קבצים גדולים. @@ -783,7 +825,15 @@ def _restore_large_files( from database.models import LargeFile count = 0 - for meta in meta_list: + total = len(meta_list) + for idx, meta in enumerate(meta_list): + if progress_cb and idx % batch_size == 0: + try: + progress_cb(idx, total) + except Exception: + pass + if idx > 0 and idx % batch_size == 0: + time.sleep(0.05) file_name = meta.get("file_name", "") if not file_name: continue diff --git a/webapp/app.py b/webapp/app.py index 6a4c2f275..5be299af0 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -683,6 +683,22 @@ def _stop_background_observability_warmup() -> None: start_background_observability_warmup() +# --- Backup restore jobs TTL index --- +try: + from webapp.backup_api import _ensure_restore_indexes # noqa: E402 + _ensure_restore_indexes() +except Exception: + pass + +# --- Backup Scheduler (auto-backup to Drive & Disk) --- +try: + if str(os.getenv("DISABLE_BACKUP_SCHEDULER", "")).lower() not in {"1", "true", "yes"}: + from webapp.backup_scheduler import start_backup_scheduler # noqa: E402 + start_backup_scheduler() +except Exception: + pass + + # --- Observability: Alert Tags indexes warmup (best-effort) --- def start_background_alert_tags_indexes() -> None: """מנסה להבטיח אינדקסים ל-alert_tags ברקע (לא חוסם את השרת).""" @@ -1106,6 +1122,20 @@ def _job(): # אל תפיל את השרת אם ה-Blueprint אינו זמין (למשל בסביבת דוקס/CI) pass +# Google Drive Auth (OAuth redirect flow) +try: + from webapp.drive_auth import drive_auth_bp # noqa: E402 + app.register_blueprint(drive_auth_bp) +except Exception: + pass + +# Drive & Disk Backup API (schedule, trigger, status) +try: + from webapp.drive_backup_api import drive_backup_bp # noqa: E402 + app.register_blueprint(drive_backup_bp) +except Exception: + pass + # GitHub Webhooks (Repo Sync Engine) - לפי המדריך try: from webapp.routes.webhooks import webhooks_bp # noqa: E402 diff --git a/webapp/backup_api.py b/webapp/backup_api.py index 773a52366..0605fa2d3 100644 --- a/webapp/backup_api.py +++ b/webapp/backup_api.py @@ -2,12 +2,16 @@ Personal Backup API — ייצוא ושחזור גיבוי אישי. Endpoints: -- GET /api/backup/export — הורדת ZIP עם כל נתוני המשתמש -- POST /api/backup/restore — שחזור נתונים מקובץ ZIP +- GET /api/backup/export — הורדת ZIP עם כל נתוני המשתמש +- POST /api/backup/restore — שחזור נתונים מקובץ ZIP (סינכרוני) +- POST /api/backup/restore-async — שחזור ברקע עם דיווח התקדמות +- GET /api/backup/restore-progress/ — מצב התקדמות שחזור """ from __future__ import annotations import logging +import uuid +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from functools import wraps @@ -36,12 +40,79 @@ def _inner(f): backup_bp = Blueprint("backup", __name__) +# --- Async restore state (MongoDB-backed, multi-worker safe) --- +_restore_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="restore") +_RESTORE_TTL_SECONDS = 3600 # MongoDB TTL — ניקוי אוטומטי אחרי שעה + + +def _ensure_restore_indexes(): + """יוצר TTL index על restore_jobs (idempotent).""" + try: + db = _get_db() + db.db.restore_jobs.create_index( + "created_at", expireAfterSeconds=_RESTORE_TTL_SECONDS, + name="restore_jobs_ttl", + ) + except Exception: + logger.debug("restore_jobs TTL index already exists or DB unavailable") + + +def _create_restore_job(restore_id: str, user_id) -> None: + db = _get_db() + db.db.restore_jobs.insert_one({ + "_id": restore_id, + "user_id": int(user_id), + "status": "running", + "progress": 0, + "step": "מתחיל שחזור...", + "result": None, + "created_at": datetime.now(timezone.utc), + }) + + +def _update_restore_progress(restore_id: str, progress: int, step: str) -> None: + try: + db = _get_db() + db.db.restore_jobs.update_one( + {"_id": restore_id}, + {"$set": {"progress": progress, "step": step}}, + ) + except Exception: + pass # best-effort — אל תשבור את ה-restore בגלל עדכון progress + + +def _complete_restore_job(restore_id: str, status: str, result, step: str) -> None: + try: + db = _get_db() + update_fields = { + "status": status, + "result": result, + "step": step, + "completed_at": datetime.now(timezone.utc), + } + # בהצלחה: progress = 100. בשגיאה: לא משנים — שומרים על ה-progress שהגיע אליו + if status == "done": + update_fields["progress"] = 100 + db.db.restore_jobs.update_one( + {"_id": restore_id}, + {"$set": update_fields}, + ) + except Exception: + logger.exception("Failed to update restore job %s to status %s", restore_id, status) + + +def _get_restore_job(restore_id: str, user_id): + db = _get_db() + return db.db.restore_jobs.find_one( + {"_id": restore_id, "user_id": int(user_id)}, + ) + # Per-route upload limit (avoid global MAX_CONTENT_LENGTH side effects) @backup_bp.before_request def _limit_restore_upload_size(): try: - # Limit only the restore endpoint; export is GET anyway. - if request.endpoint == "backup.restore_backup": + # Limit restore endpoints (both sync and async); export is GET anyway. + if request.endpoint in ("backup.restore_backup", "backup.restore_backup_async"): request.max_content_length = MAX_UPLOAD_SIZE except Exception: pass @@ -179,3 +250,112 @@ def restore_backup(): ) return jsonify({"ok": False, "error": "שגיאה בשחזור הגיבוי"}), 500 + +# --- Async restore --- + +def _run_restore_in_background(restore_id: str, user_id: int, zip_bytes: bytes, overwrite: bool): + """רץ ב-thread — מבצע שחזור ומעדכן progress ב-MongoDB.""" + def _progress(pct: int, step: str): + _update_restore_progress(restore_id, pct, step) + + restore_ok = False + result = None + try: + service = _get_backup_service() + result = service.restore_user_data( + int(user_id), zip_bytes, overwrite=overwrite, progress_cb=_progress, + ) + restore_ok = not (isinstance(result, dict) and result.get("ok") is False) + except Exception as e: + logger.error(f"שגיאה בשחזור אסינכרוני: {e}") + result = {"ok": False, "error": str(e)} + + if restore_ok: + _complete_restore_job(restore_id, "done", result, "השחזור הושלם") + else: + _complete_restore_job(restore_id, "error", result, "שגיאה בשחזור") + + +@backup_bp.route("/api/backup/restore-async", methods=["POST"]) +@_require_auth +@traced("backup.restore_async") +def restore_backup_async(): + """שחזור מגיבוי ZIP — רץ ברקע עם דיווח התקדמות.""" + user_id = session["user_id"] + + # בדיקת קובץ (אותה לוגיקה כמו restore רגיל) + if "file" not in request.files: + return jsonify({"ok": False, "error": "לא נבחר קובץ"}), 400 + + uploaded = request.files["file"] + if not uploaded.filename: + return jsonify({"ok": False, "error": "לא נבחר קובץ"}), 400 + + if not uploaded.filename.lower().endswith(".zip"): + return jsonify({"ok": False, "error": "יש להעלות קובץ ZIP בלבד"}), 400 + + try: + content_len = int(request.content_length or 0) + except Exception: + content_len = 0 + if content_len and content_len > MAX_UPLOAD_SIZE: + return ( + jsonify({"ok": False, "error": f"הקובץ גדול מדי (מקסימום {MAX_UPLOAD_SIZE // (1024*1024)}MB)"}), + 413, + ) + + try: + zip_bytes = uploaded.stream.read(MAX_UPLOAD_SIZE + 1) + except Exception: + zip_bytes = uploaded.read(MAX_UPLOAD_SIZE + 1) + if len(zip_bytes) > MAX_UPLOAD_SIZE: + return ( + jsonify({"ok": False, "error": f"הקובץ גדול מדי (מקסימום {MAX_UPLOAD_SIZE // (1024*1024)}MB)"}), + 413, + ) + + overwrite = request.form.get("overwrite", "false").lower() in ("true", "1", "yes") + + # בדיקה שאין restore פעיל כבר לאותו משתמש + try: + db = _get_db() + existing = db.db.restore_jobs.find_one( + {"user_id": int(user_id), "status": "running"}, + projection={"_id": 1}, + ) + if existing: + return jsonify({"ok": False, "error": "שחזור כבר רץ — המתן לסיומו"}), 409 + except Exception: + pass # best-effort — אם הבדיקה נכשלת, ממשיכים + + # יצירת restore job ב-MongoDB והפעלת ריצה ברקע + restore_id = uuid.uuid4().hex[:12] + _create_restore_job(restore_id, user_id) + + _restore_executor.submit(_run_restore_in_background, restore_id, int(user_id), zip_bytes, overwrite) + + return jsonify({"ok": True, "restore_id": restore_id}) + + +@backup_bp.route("/api/backup/restore-progress/", methods=["GET"]) +@_require_auth +@traced("backup.restore_progress") +def restore_progress(restore_id): + """מחזיר מצב התקדמות שחזור (מ-MongoDB — multi-worker safe).""" + user_id = session["user_id"] + + entry = _get_restore_job(restore_id, user_id) + if not entry: + return jsonify({"ok": False, "error": "שחזור לא נמצא"}), 404 + + resp = { + "ok": True, + "status": entry["status"], + "progress": entry["progress"], + "step": entry["step"], + } + if entry["status"] in ("done", "error"): + resp["result"] = entry.get("result") + + return jsonify(resp) + diff --git a/webapp/backup_scheduler.py b/webapp/backup_scheduler.py new file mode 100644 index 000000000..d70247b2a --- /dev/null +++ b/webapp/backup_scheduler.py @@ -0,0 +1,419 @@ +""" +Webapp Backup Scheduler — גיבויים אוטומטיים ל-Drive ולדיסק. + +Thread daemon שרץ ברקע וסורק כל כמה דקות אם יש משתמשים +שצריכים גיבוי אוטומטי (לפי schedule_next_at). +""" +from __future__ import annotations + +import logging +import os +import threading +import time +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Dict, Optional + +logger = logging.getLogger(__name__) + +# תדירויות (בשניות) +SCHEDULE_INTERVALS: Dict[str, int] = { + "daily": 86400, + "every3": 259200, + "weekly": 604800, + "biweekly": 1209600, + "monthly": 2592000, +} + +# כל כמה שניות הסורק רץ +SCAN_INTERVAL_SECONDS = int(os.getenv("BACKUP_SCAN_INTERVAL", "300")) # 5 דקות + +# נתיב דיסק לגיבויים (Render persistent disk) +DISK_BACKUP_DIR = os.getenv("WEBAPP_BACKUPS_DIR", "/var/data/repos/backups") + +# Retention +DISK_BACKUP_RETENTION_DAYS = int(os.getenv("DISK_BACKUP_RETENTION_DAYS", "30")) +DISK_BACKUP_MAX_PER_USER = int(os.getenv("DISK_BACKUP_MAX_PER_USER", "10")) + +# מקסימום גיבויים לכל סוג בסריקה אחת — מונע הרעבה של disk כש-drive תקוע +MAX_BACKUPS_PER_SCAN = int(os.getenv("MAX_BACKUPS_PER_SCAN", "10")) + +_scheduler_thread: Optional[threading.Thread] = None +_scheduler_lock = threading.Lock() + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _compute_next_at(schedule_key: str, from_dt: Optional[datetime] = None) -> str: + """חישוב הזמן הבא לגיבוי.""" + interval = SCHEDULE_INTERVALS.get(schedule_key, 86400) + base = from_dt or _now_utc() + next_dt = base + timedelta(seconds=interval) + return next_dt.isoformat() + + +def _retry_next_at() -> str: + """מחזיר timestamp של SCAN_INTERVAL קדימה — retry בסריקה הבאה, לא בלולאה הנוכחית.""" + return (_now_utc() + timedelta(seconds=SCAN_INTERVAL_SECONDS)).isoformat() + + +def _perform_drive_backup(user_id: int) -> bool: + """מבצע גיבוי ל-Drive עבור משתמש.""" + try: + from services.google_drive_service import perform_scheduled_backup + result = perform_scheduled_backup(user_id) + if result.ok: + logger.info("Drive backup completed for user %s (uploaded=%d)", user_id, result.uploaded) + else: + logger.warning("Drive backup failed for user %s", user_id) + return result.ok + except Exception: + logger.exception("Drive backup error for user %s", user_id) + return False + + +def _perform_disk_backup(user_id: int) -> bool: + """מבצע גיבוי לדיסק עבור משתמש.""" + try: + from services.personal_backup_service import PersonalBackupService + from database import db + + service = PersonalBackupService(db) + buffer = service.export_user_data(int(user_id)) + zip_bytes = buffer.getvalue() + + # שמירה לדיסק + backup_dir = Path(DISK_BACKUP_DIR) + backup_dir.mkdir(parents=True, exist_ok=True) + + ts = _now_utc().strftime("%Y%m%d_%H%M%S") + filename = f"webapp_backup_{user_id}_{ts}.zip" + filepath = backup_dir / filename + filepath.write_bytes(zip_bytes) + + logger.info("Disk backup saved: %s (%d bytes)", filepath, len(zip_bytes)) + + # ניקוי גיבויים ישנים + _cleanup_disk_backups(user_id, backup_dir) + return True + + except Exception: + logger.exception("Disk backup error for user %s", user_id) + return False + + +def _cleanup_disk_backups(user_id: int, backup_dir: Path): + """מנקה גיבויים ישנים לפי retention ו-max per user. + + לוגיקה: ה-N הכי חדשים (עד DISK_BACKUP_MAX_PER_USER) נשמרים תמיד. + מעבר ל-max — נמחקים רק אם גם עברו את ה-retention. + """ + try: + pattern = f"webapp_backup_{user_id}_*.zip" + user_backups = sorted(backup_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True) + + cutoff = _now_utc() - timedelta(days=DISK_BACKUP_RETENTION_DAYS) + for i, bp in enumerate(user_backups): + try: + # ה-N הכי חדשים נשמרים תמיד + if i < DISK_BACKUP_MAX_PER_USER: + continue + # מעבר ל-max — מוחקים אם עברו retention + mtime = datetime.fromtimestamp(bp.stat().st_mtime, tz=timezone.utc) + if mtime < cutoff: + bp.unlink() + logger.info("Cleaned up old backup: %s", bp.name) + except Exception: + logger.exception("Error cleaning up backup: %s", bp) + except Exception: + logger.exception("Error in disk backup cleanup for user %s", user_id) + + +# TTL ל-sentinel — אחרי כמה זמן claim שלא הושלם פוקע טבעית +SENTINEL_TTL_SECONDS = int(os.getenv("BACKUP_SENTINEL_TTL", "1800")) # 30 דקות + + +def _sentinel_value() -> str: + """מחזיר sentinel שפוקע טבעית אחרי SENTINEL_TTL. + + במקום ערך קבוע "2099", משתמשים ב-now + TTL. + אם ה-backup מסתיים — הערך מוחלף בזמן הבא האמיתי. + אם ה-process קרס — אחרי TTL הערך הופך ל-$lte now ונתפס מחדש. + """ + return (_now_utc() + timedelta(seconds=SENTINEL_TTL_SECONDS)).isoformat() + + +def _scan_and_run(): + """סורק וממריץ גיבויים עם atomic claiming (multi-worker safe). + + משתמש ב-find_one_and_update כדי "לתפוס" כל משתמש אטומית — + רק scheduler אחד יכול להריץ גיבוי לכל משתמש, גם עם N workers. + """ + try: + from database import db + except Exception: + logger.exception("Cannot import database for backup scheduler") + return + + now = _now_utc() + now_iso = now.isoformat() + + # --- Drive backups (atomic claim) --- + try: + _scan_drive_backups(db, now_iso) + except Exception: + logger.exception("Error scanning Drive schedules") + + # --- Disk backups (atomic claim) --- + try: + _scan_disk_backups(db, now_iso) + except Exception: + logger.exception("Error scanning Disk schedules") + + +def _scan_drive_backups(db, now_iso: str): + """סורק ומריץ גיבויי Drive עם atomic claiming.""" + valid_keys = list(SCHEDULE_INTERVALS.keys()) + processed = 0 + while processed < MAX_BACKUPS_PER_SCAN: + # תפוס אטומית משתמש שהגיע זמנו — מזיז schedule_next_at קדימה + # כך ש-worker אחר לא יתפוס אותו + claimed = db.db.users.find_one_and_update( + { + "drive_prefs.schedule_next_at": {"$lte": now_iso, "$ne": None}, + "drive_prefs.schedule_key": {"$ne": "off"}, + "$or": [ + {"drive_prefs.schedule_key": {"$in": valid_keys}}, + {"drive_prefs.schedule.key": {"$in": valid_keys}}, + {"drive_prefs.schedule.value": {"$in": valid_keys}}, + {"drive_prefs.schedule": {"$in": valid_keys}}, + ], + }, + # מזיז את next_at רחוק קדימה (1 שעה) כ-placeholder עד שנחשב את הזמן האמיתי + {"$set": {"drive_prefs.schedule_next_at": _sentinel_value()}}, + projection={"user_id": 1, "drive_prefs": 1}, + ) + if not claimed: + break # אין עוד משתמשים שצריכים גיבוי Drive + processed += 1 + + uid = claimed.get("user_id") + prefs = claimed.get("drive_prefs") or {} + try: + schedule_key = _extract_schedule_key(prefs) + except Exception: + logger.exception("Error extracting schedule key for user %s", uid) + schedule_key = None + if not uid or not schedule_key: + # החזרת sentinel — אחרת הגיבוי תקוע לנצח ב-2099 + _reset_drive_schedule(db, claimed) + continue + + try: + logger.info("Running scheduled Drive backup for user %s", uid) + ok = _perform_drive_backup(uid) + if ok: + new_next = _compute_next_at(schedule_key) + else: + # דחייה קדימה — retry בסריקה הבאה, לא בלולאה הנוכחית + new_next = _retry_next_at() + db.db.users.update_one( + {"user_id": uid}, + {"$set": {"drive_prefs.schedule_next_at": new_next}}, + ) + except Exception: + logger.exception("Error processing Drive schedule for user %s", uid) + try: + db.db.users.update_one( + {"user_id": uid}, + {"$set": {"drive_prefs.schedule_next_at": _retry_next_at()}}, + ) + except Exception: + pass + + +def _scan_disk_backups(db, now_iso: str): + """סורק ומריץ גיבויי דיסק עם atomic claiming.""" + valid_keys = list(SCHEDULE_INTERVALS.keys()) + processed = 0 + while processed < MAX_BACKUPS_PER_SCAN: + claimed = db.db.users.find_one_and_update( + { + "disk_backup_prefs.schedule_key": {"$in": valid_keys}, + "disk_backup_prefs.schedule_next_at": {"$lte": now_iso, "$ne": None}, + }, + {"$set": {"disk_backup_prefs.schedule_next_at": _sentinel_value()}}, + projection={"user_id": 1, "disk_backup_prefs": 1}, + ) + if not claimed: + break + processed += 1 + + uid = claimed.get("user_id") + disk_prefs = claimed.get("disk_backup_prefs") or {} + schedule_key = disk_prefs.get("schedule_key") + if not uid or not schedule_key or schedule_key not in SCHEDULE_INTERVALS: + # החזרת sentinel — אחרת הגיבוי תקוע לנצח ב-2099 + _reset_disk_schedule(db, claimed) + continue + + try: + logger.info("Running scheduled Disk backup for user %s", uid) + ok = _perform_disk_backup(uid) + if ok: + update = { + "disk_backup_prefs.last_backup_at": _now_utc().isoformat(), + "disk_backup_prefs.schedule_next_at": _compute_next_at(schedule_key), + } + else: + # דחייה קדימה — retry בסריקה הבאה, לא בלולאה הנוכחית + update = { + "disk_backup_prefs.schedule_next_at": _retry_next_at(), + } + db.db.users.update_one({"user_id": uid}, {"$set": update}) + except Exception: + logger.exception("Error processing Disk schedule for user %s", uid) + try: + db.db.users.update_one( + {"user_id": uid}, + {"$set": {"disk_backup_prefs.schedule_next_at": _retry_next_at()}}, + ) + except Exception: + pass + + +def _reset_drive_schedule(db, claimed: dict): + """מחזיר schedule_next_at מ-sentinel ל-retry delay (Drive).""" + try: + uid = claimed.get("user_id") + if uid: + db.db.users.update_one( + {"user_id": uid}, + {"$set": {"drive_prefs.schedule_next_at": _retry_next_at()}}, + ) + except Exception: + logger.exception("Failed to reset Drive sentinel for user %s", claimed.get("user_id")) + + +def _reset_disk_schedule(db, claimed: dict): + """מחזיר schedule_next_at מ-sentinel ל-retry delay (Disk).""" + try: + uid = claimed.get("user_id") + if uid: + db.db.users.update_one( + {"user_id": uid}, + {"$set": {"disk_backup_prefs.schedule_next_at": _retry_next_at()}}, + ) + except Exception: + logger.exception("Failed to reset Disk sentinel for user %s", claimed.get("user_id")) + + +def _extract_schedule_key(drive_prefs: dict) -> Optional[str]: + """מחלץ את ה-schedule key מתוך drive_prefs (תואם לפורמטים שונים). + + משתמש בפונקציה המשותפת מ-handlers/drive/utils.py כדי להבטיח + עקביות בסדר שדות וכיסוי פורמטים (camelCase, name, וכו'). + """ + # אם המשתמש כיבה מפורשות — לא ליפול לשדות legacy + if drive_prefs.get("schedule_key") == "off": + return None + try: + from handlers.drive.utils import extract_schedule_key + raw = extract_schedule_key(drive_prefs) + except Exception: + # fallback מקומי — אם ה-import נכשל, בודקים ישירות + raw = drive_prefs.get("schedule_key") or drive_prefs.get("scheduleKey") + if not isinstance(raw, str): + val = drive_prefs.get("schedule") + if isinstance(val, str): + raw = val + elif isinstance(val, dict): + raw = val.get("key") or val.get("value") or val.get("name") + if isinstance(raw, str) and raw in SCHEDULE_INTERVALS: + return raw + return None + + +def _scheduler_loop(): + """לולאת ה-scheduler הראשית.""" + logger.info("Backup scheduler started (scan every %ds)", SCAN_INTERVAL_SECONDS) + # המתנה קצרה לתת לאפליקציה להתחיל + time.sleep(30) + while True: + try: + _scan_and_run() + except Exception: + logger.exception("Backup scheduler scan error") + time.sleep(SCAN_INTERVAL_SECONDS) + + +def start_backup_scheduler(): + """מפעיל את ה-scheduler כ-daemon thread (קורא פעם אחת מ-app.py).""" + global _scheduler_thread + with _scheduler_lock: + if _scheduler_thread is not None and _scheduler_thread.is_alive(): + return + t = threading.Thread(target=_scheduler_loop, daemon=True, name="backup-scheduler") + _scheduler_thread = t + t.start() + logger.info("Backup scheduler thread launched") + + +# --- פונקציות עזר לגיבוי מיידי (נקראות מה-API) --- + +def trigger_drive_backup_now(user_id: int) -> dict: + """מבצע גיבוי מיידי ל-Drive. + + הערה: perform_scheduled_backup כבר מעדכן last_backup_at דרך save_drive_prefs, + אז לא מעדכנים שוב כאן כדי להימנע מ-race condition. + """ + ok = _perform_drive_backup(user_id) + return {"ok": ok} + + +def trigger_disk_backup_now(user_id: int) -> dict: + """מבצע גיבוי מיידי לדיסק.""" + ok = _perform_disk_backup(user_id) + if ok: + try: + from database import db + db.db.users.update_one( + {"user_id": int(user_id)}, + {"$set": {"disk_backup_prefs.last_backup_at": _now_utc().isoformat()}}, + ) + except Exception: + logger.warning("Failed to update Disk last_backup_at for user %s", user_id) + return {"ok": ok} + + +def get_disk_backup_info(user_id: int) -> dict: + """מחזיר מידע על גיבויי דיסק של המשתמש.""" + try: + backup_dir = Path(DISK_BACKUP_DIR) + if not backup_dir.exists(): + return {"count": 0, "total_size": 0, "backups": []} + + pattern = f"webapp_backup_{user_id}_*.zip" + user_backups = sorted(backup_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True) + + backups = [] + total_size = 0 + for i, bp in enumerate(user_backups): + try: + stat = bp.stat() + total_size += stat.st_size + if i < 20: # מחזירים רק 20 לתצוגה, אבל סוכמים את כולם + backups.append({ + "name": bp.name, + "size": stat.st_size, + "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + }) + except Exception: + pass + + return {"count": len(user_backups), "total_size": total_size, "backups": backups} + except Exception: + logger.exception("Error getting disk backup info for user %s", user_id) + return {"count": 0, "total_size": 0, "backups": []} diff --git a/webapp/drive_auth.py b/webapp/drive_auth.py new file mode 100644 index 000000000..10fb32f27 --- /dev/null +++ b/webapp/drive_auth.py @@ -0,0 +1,255 @@ +""" +Google Drive OAuth — חיבור Drive מהוובאפ. + +Endpoints: +- GET /api/drive/auth — מפנה ל-Google OAuth consent screen +- GET /api/drive/callback — Google מחזיר authorization code +- GET /api/drive/status — סטטוס חיבור Drive +- POST /api/drive/disconnect — ניתוק Drive +""" +from __future__ import annotations + +import hashlib +import logging +import os +import secrets +from datetime import datetime, timezone +from functools import wraps + +from flask import Blueprint, Response, jsonify, redirect, request, session, url_for + +logger = logging.getLogger(__name__) + +try: + from observability import emit_event +except Exception: + def emit_event(event: str, severity: str = "info", **fields): + return None + +drive_auth_bp = Blueprint("drive_auth", __name__) + +# Google OAuth config +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "") +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "") +GOOGLE_OAUTH_SCOPES = os.getenv("GOOGLE_OAUTH_SCOPES", "https://www.googleapis.com/auth/drive.file") +GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" + + +def _require_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + if "user_id" not in session: + return jsonify({"ok": False, "error": "נדרש להתחבר"}), 401 + return f(*args, **kwargs) + return decorated + + +def _get_db(): + from database import db as _db + return _db + + +def _get_redirect_uri(): + """חישוב redirect URI דינמי.""" + base = os.getenv("WEBAPP_URL", "").rstrip("/") + if not base: + base = request.url_root.rstrip("/") + return f"{base}/api/drive/callback" + + +@drive_auth_bp.route("/api/drive/auth") +@_require_auth +def drive_auth(): + """מפנה את המשתמש ל-Google OAuth consent screen.""" + if not GOOGLE_CLIENT_ID: + return jsonify({"ok": False, "error": "Google OAuth לא מוגדר"}), 500 + + # CSRF protection via state + state = secrets.token_urlsafe(32) + session["drive_oauth_state"] = state + + params = { + "client_id": GOOGLE_CLIENT_ID, + "redirect_uri": _get_redirect_uri(), + "response_type": "code", + "scope": GOOGLE_OAUTH_SCOPES, + "access_type": "offline", + "prompt": "consent", + "state": state, + } + + auth_url = GOOGLE_AUTH_URL + "?" + "&".join(f"{k}={_url_encode(v)}" for k, v in params.items()) + return redirect(auth_url) + + +@drive_auth_bp.route("/api/drive/callback") +def drive_callback(): + """Google מחזיר authorization code — מחליפים לטוקנים.""" + # redirect-based auth check — הדפדפן מגיע ישירות, לא AJAX + if "user_id" not in session: + return _settings_redirect(f"drive_error={_url_encode('session_expired')}") + + import requests as req + + # CSRF check + state = request.args.get("state", "") + expected_state = session.pop("drive_oauth_state", "") + if not state or not expected_state or state != expected_state: + return _settings_redirect(f"drive_error={_url_encode('csrf')}") + + error = request.args.get("error") + if error: + logger.warning("Drive OAuth error: %s", error) + return _settings_redirect(f"drive_error={_url_encode(error)}") + + code = request.args.get("code") + if not code: + return _settings_redirect(f"drive_error={_url_encode('no_code')}") + + # Exchange code for tokens + try: + resp = req.post(GOOGLE_TOKEN_URL, data={ + "client_id": GOOGLE_CLIENT_ID, + "client_secret": GOOGLE_CLIENT_SECRET, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": _get_redirect_uri(), + }, timeout=15) + resp.raise_for_status() + tokens = resp.json() + except Exception as e: + logger.exception("Drive token exchange failed") + return _settings_redirect(f"drive_error={_url_encode('token_exchange')}") + + if "access_token" not in tokens: + logger.error("No access_token in response: %s", tokens.get("error")) + return _settings_redirect(f"drive_error={_url_encode('no_token')}") + + # שמירת טוקנים + user_id = session["user_id"] + db = _get_db() + + token_data = { + "access_token": tokens["access_token"], + "refresh_token": tokens.get("refresh_token", ""), + "token_type": tokens.get("token_type", "Bearer"), + "expires_in": tokens.get("expires_in", 3600), + "scope": tokens.get("scope", GOOGLE_OAUTH_SCOPES), + } + + try: + from services.google_drive_service import save_tokens + save_tokens(int(user_id), token_data) + except Exception: + # fallback — שמירה ישירה + try: + db.save_drive_tokens(int(user_id), token_data) + except Exception: + logger.exception("Failed to save Drive tokens") + return _settings_redirect(f"drive_error={_url_encode('save_failed')}") + + # Get user email for display + try: + user_info = req.get( + GOOGLE_USERINFO_URL, + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + timeout=10, + ).json() + email = user_info.get("email", "") + if email: + try: + # $set ישיר — לא save_drive_prefs שעושה read-modify-write ויכול לדרוס sentinel + db.db.users.update_one( + {"user_id": int(user_id)}, + {"$set": {"drive_prefs.drive_email": email}}, + ) + except Exception: + pass + except Exception: + pass + + emit_event("webapp_drive_connected", user_id=int(user_id)) + return _settings_redirect("drive_connected=1") + + +@drive_auth_bp.route("/api/drive/status") +@_require_auth +def drive_status(): + """מחזיר סטטוס חיבור Drive.""" + user_id = session["user_id"] + db = _get_db() + + connected = False + email = None + schedule = None + last_backup = None + schedule_next = None + + try: + tokens = db.get_drive_tokens(int(user_id)) + connected = bool(tokens and tokens.get("access_token")) + except Exception: + pass + + try: + prefs = db.get_drive_prefs(int(user_id)) or {} + email = prefs.get("drive_email") + schedule = prefs.get("schedule_key") or prefs.get("schedule") + if isinstance(schedule, dict): + schedule = schedule.get("key") or schedule.get("value") + last_backup = prefs.get("last_backup_at") + schedule_next = prefs.get("schedule_next_at") + except Exception: + pass + + return jsonify({ + "ok": True, + "connected": connected, + "email": email, + "schedule": schedule, + "last_backup_at": last_backup, + "schedule_next_at": schedule_next, + }) + + +@drive_auth_bp.route("/api/drive/disconnect", methods=["POST"]) +@_require_auth +def drive_disconnect(): + """מנתק חיבור Drive.""" + user_id = session["user_id"] + db = _get_db() + + try: + db.delete_drive_tokens(int(user_id)) + # מנקה גם schedule + try: + db.save_drive_prefs(int(user_id), { + "schedule_key": "off", + "schedule_next_at": None, + "drive_email": None, + }) + except Exception: + pass + emit_event("webapp_drive_disconnected", user_id=int(user_id)) + return jsonify({"ok": True}) + except Exception as e: + logger.exception("Error disconnecting Drive") + return jsonify({"ok": False, "error": "שגיאה בניתוק Drive"}), 500 + + +# --- Helpers --- + +def _url_encode(val: str) -> str: + """URL-encode a string.""" + from urllib.parse import quote + return quote(str(val), safe="") + + +def _settings_redirect(query: str) -> Response: + """Redirect back to settings page with query params.""" + base = os.getenv("WEBAPP_URL", "").rstrip("/") + if not base: + base = request.url_root.rstrip("/") + return redirect(f"{base}/settings?{query}#backup-section") diff --git a/webapp/drive_backup_api.py b/webapp/drive_backup_api.py new file mode 100644 index 000000000..f7f712821 --- /dev/null +++ b/webapp/drive_backup_api.py @@ -0,0 +1,221 @@ +""" +Drive & Disk Backup API — ניהול גיבויים אוטומטיים מהוובאפ. + +Endpoints: +- POST /api/drive/schedule — הגדרת תזמון גיבוי ל-Drive +- POST /api/drive/backup-now — גיבוי מיידי ל-Drive +- GET /api/drive/backup-status — סטטוס גיבוי Drive +- POST /api/disk-backup/schedule — הגדרת תזמון גיבוי לדיסק +- POST /api/disk-backup/now — גיבוי מיידי לדיסק +- GET /api/disk-backup/status — סטטוס גיבוי דיסק +""" +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor +from functools import wraps + +from flask import Blueprint, jsonify, request, session + +logger = logging.getLogger(__name__) + +try: + from observability import emit_event +except Exception: + def emit_event(event: str, severity: str = "info", **fields): + return None + +drive_backup_bp = Blueprint("drive_backup", __name__) + +_backup_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="backup-trigger") + +VALID_SCHEDULES = {"daily", "every3", "weekly", "biweekly", "monthly", "off"} + + +def _require_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + if "user_id" not in session: + return jsonify({"ok": False, "error": "נדרש להתחבר"}), 401 + return f(*args, **kwargs) + return decorated + + +def _get_db(): + from database import db as _db + return _db + + +# ==================== Drive API ==================== + +@drive_backup_bp.route("/api/drive/schedule", methods=["POST"]) +@_require_auth +def set_drive_schedule(): + """הגדרת תזמון גיבוי אוטומטי ל-Drive.""" + user_id = session["user_id"] + db = _get_db() + + data = request.get_json(silent=True) or {} + schedule = data.get("schedule", "off") + if schedule not in VALID_SCHEDULES: + return jsonify({"ok": False, "error": "תדירות לא חוקית"}), 400 + + # בדוק שיש חיבור Drive + if schedule != "off": + try: + tokens = db.get_drive_tokens(int(user_id)) + if not tokens or not tokens.get("access_token"): + return jsonify({"ok": False, "error": "יש לחבר Google Drive קודם"}), 400 + except Exception: + return jsonify({"ok": False, "error": "שגיאה בבדיקת חיבור Drive"}), 500 + + try: + from webapp.backup_scheduler import _compute_next_at + update = {"schedule_key": schedule} + if schedule != "off": + update["schedule_next_at"] = _compute_next_at(schedule) + else: + update["schedule_next_at"] = None + + # $set ישיר על שדות ספציפיים — לא read-modify-write שיכול לדרוס sentinel + set_fields = { + "drive_prefs.schedule_key": schedule, + "drive_prefs.schedule_next_at": update["schedule_next_at"], + } + update_ops = {"$set": set_fields} + # ניקוי שדות legacy כדי שה-scheduler לא יתפוס משתמש שכיבה schedule + if schedule == "off": + update_ops["$unset"] = {"drive_prefs.schedule": ""} + db.db.users.update_one({"user_id": int(user_id)}, update_ops) + + emit_event("webapp_drive_schedule_set", user_id=int(user_id), schedule=schedule) + return jsonify({"ok": True, "schedule": schedule}) + except Exception as e: + logger.exception("Error setting Drive schedule") + return jsonify({"ok": False, "error": "שגיאה בהגדרת תזמון"}), 500 + + +@drive_backup_bp.route("/api/drive/backup-now", methods=["POST"]) +@_require_auth +def drive_backup_now(): + """גיבוי מיידי ל-Drive (רץ ב-thread).""" + user_id = session["user_id"] + db = _get_db() + + # וידוא חיבור + try: + tokens = db.get_drive_tokens(int(user_id)) + if not tokens or not tokens.get("access_token"): + return jsonify({"ok": False, "error": "יש לחבר Google Drive קודם"}), 400 + except Exception: + return jsonify({"ok": False, "error": "שגיאה בבדיקת חיבור"}), 500 + + try: + from webapp.backup_scheduler import trigger_drive_backup_now + # רץ ב-thread כדי לא לחסום את הבקשה + future = _backup_executor.submit(trigger_drive_backup_now, int(user_id)) + # מחכים עד 60 שניות + result = future.result(timeout=60) + return jsonify(result) + except Exception as e: + logger.exception("Error triggering Drive backup") + return jsonify({"ok": False, "error": "שגיאה בהפעלת גיבוי"}), 500 + + +@drive_backup_bp.route("/api/drive/backup-status") +@_require_auth +def drive_backup_status(): + """סטטוס גיבוי Drive.""" + user_id = session["user_id"] + db = _get_db() + + try: + prefs = db.get_drive_prefs(int(user_id)) or {} + return jsonify({ + "ok": True, + "last_backup_at": prefs.get("last_backup_at"), + "last_full_backup_at": prefs.get("last_full_backup_at"), + "schedule_next_at": prefs.get("schedule_next_at"), + }) + except Exception: + return jsonify({"ok": True, "last_backup_at": None}) + + +# ==================== Disk Backup API ==================== + +@drive_backup_bp.route("/api/disk-backup/schedule", methods=["POST"]) +@_require_auth +def set_disk_schedule(): + """הגדרת תזמון גיבוי אוטומטי לדיסק.""" + user_id = session["user_id"] + db = _get_db() + + data = request.get_json(silent=True) or {} + schedule = data.get("schedule", "off") + if schedule not in VALID_SCHEDULES: + return jsonify({"ok": False, "error": "תדירות לא חוקית"}), 400 + + try: + from webapp.backup_scheduler import _compute_next_at + update = { + "disk_backup_prefs.schedule_key": schedule, + } + if schedule != "off": + update["disk_backup_prefs.schedule_next_at"] = _compute_next_at(schedule) + else: + update["disk_backup_prefs.schedule_next_at"] = None + + db.db.users.update_one( + {"user_id": int(user_id)}, + {"$set": update}, + ) + emit_event("webapp_disk_schedule_set", user_id=int(user_id), schedule=schedule) + return jsonify({"ok": True, "schedule": schedule}) + except Exception as e: + logger.exception("Error setting Disk schedule") + return jsonify({"ok": False, "error": "שגיאה בהגדרת תזמון"}), 500 + + +@drive_backup_bp.route("/api/disk-backup/now", methods=["POST"]) +@_require_auth +def disk_backup_now(): + """גיבוי מיידי לדיסק.""" + user_id = session["user_id"] + + try: + from webapp.backup_scheduler import trigger_disk_backup_now + future = _backup_executor.submit(trigger_disk_backup_now, int(user_id)) + result = future.result(timeout=120) + return jsonify(result) + except Exception as e: + logger.exception("Error triggering Disk backup") + return jsonify({"ok": False, "error": "שגיאה בהפעלת גיבוי"}), 500 + + +@drive_backup_bp.route("/api/disk-backup/status") +@_require_auth +def disk_backup_status(): + """סטטוס גיבוי דיסק.""" + user_id = session["user_id"] + + try: + from webapp.backup_scheduler import get_disk_backup_info + info = get_disk_backup_info(int(user_id)) + + # קרא גם schedule מה-DB + db = _get_db() + user_doc = db.db.users.find_one({"user_id": int(user_id)}, {"disk_backup_prefs": 1}) + disk_prefs = (user_doc or {}).get("disk_backup_prefs") or {} + + return jsonify({ + "ok": True, + "schedule": disk_prefs.get("schedule_key", "off"), + "last_backup_at": disk_prefs.get("last_backup_at"), + "schedule_next_at": disk_prefs.get("schedule_next_at"), + "count": info.get("count", 0), + "total_size": info.get("total_size", 0), + "backups": info.get("backups", [])[:5], + }) + except Exception as e: + logger.exception("Error getting Disk backup status") + return jsonify({"ok": True, "schedule": "off", "count": 0}) diff --git a/webapp/templates/settings.html b/webapp/templates/settings.html index 69cd3cf19..02431452a 100644 --- a/webapp/templates/settings.html +++ b/webapp/templates/settings.html @@ -210,6 +210,106 @@

+ +
+

+ + גיבוי אוטומטי ל-Drive +

+

+ חבר את Google Drive כדי לגבות אוטומטית את כל הנתונים שלך. +

+ + + + + + + +
+ טוען... +
+
+ + +
+

+ + גיבוי אוטומטי לדיסק +

+

+ גיבוי אוטומטי לדיסק המקומי של השרת. שומר עותקים מקומיים למקרה חירום. +

+ +
+
+ + +
+
+ + +
+
+ + + + +
+ {# Web Push נשאר במערכת. #} {% set show_web_push_settings = true %} {% if show_web_push_settings %} @@ -3128,7 +3228,7 @@

const overwrite = document.getElementById('restore-overwrite').checked; const MAX_UPLOAD_SIZE = 100 * 1024 * 1024; - // בדיקת גודל מוקדמת כדי למנוע 413/HTML מהשרת + // בדיקת גודל מוקדמת try { if (typeof file.size === 'number' && file.size > MAX_UPLOAD_SIZE) { status.style.display = 'block'; @@ -3138,7 +3238,6 @@

} } catch (_) {} - // אישור מהמשתמש const msg = overwrite ? 'שחזור עם עדכון קבצים קיימים (תיווצר גרסה חדשה). להמשיך?' : 'שחזור מגיבוי — קבצים קיימים לא ישתנו. להמשיך?'; @@ -3148,44 +3247,87 @@

} btn.disabled = true; - btn.innerHTML = ' משחזר...'; - status.style.display = 'none'; + btn.innerHTML = ' מעלה קובץ...'; + status.style.display = 'block'; + status.innerHTML = '
מתחיל שחזור...
' + + '
' + + '
' + + '
0%
'; try { const formData = new FormData(); formData.append('file', file); formData.append('overwrite', overwrite ? 'true' : 'false'); - const resp = await fetch('/api/backup/restore', { + // שלב 1: התחלת שחזור אסינכרוני + const startResp = await fetch('/api/backup/restore-async', { method: 'POST', body: formData, }); - // Flask עשוי להחזיר 413 כ-HTML (MAX_CONTENT_LENGTH), לכן לא מניחים JSON תמיד - let data = null; + let startData = null; try { - const ct = (resp.headers.get('Content-Type') || '').toLowerCase(); + const ct = (startResp.headers.get('Content-Type') || '').toLowerCase(); if (ct.includes('application/json')) { - data = await resp.json(); + startData = await startResp.json(); } else { - const txt = await resp.text(); - try { data = txt ? JSON.parse(txt) : null; } catch (_) { data = null; } + const txt = await startResp.text(); + try { startData = txt ? JSON.parse(txt) : null; } catch (_) { startData = null; } } - } catch (_) { - data = null; + } catch (_) { startData = null; } + + if (!startResp.ok) { + if (startResp.status === 413) throw new Error('הקובץ גדול מדי (מקסימום 100MB)'); + throw new Error((startData && startData.error) ? startData.error : 'שגיאה בהתחלת שחזור'); + } + if (!startData || !startData.ok) { + throw new Error((startData && startData.error) ? startData.error : 'שגיאה בהתחלת שחזור'); } - if (!resp.ok) { - if (resp.status === 413) { - throw new Error('הקובץ גדול מדי (מקסימום 100MB)'); + const restoreId = startData.restore_id; + btn.innerHTML = ' משחזר...'; + + // שלב 2: polling על התקדמות + let done = false; + let finalResult = null; + let pollCount = 0; + const maxPolls = 300; // timeout: 10 דקות (300 × 2 שניות) + while (!done) { + if (++pollCount > maxPolls) { + throw new Error('זמן ההמתנה לשחזור עבר — נסה שנית'); + } + await new Promise(r => setTimeout(r, 2000)); + try { + const progResp = await fetch(`/api/backup/restore-progress/${restoreId}`); + const progData = await progResp.json(); + if (!progData.ok && progData.error === 'שחזור לא נמצא') { + throw new Error('השחזור אבד — נסה שנית'); + } + // עדכון progress bar + const pct = progData.progress || 0; + const step = progData.step || ''; + const bar = document.getElementById('restore-progress-bar'); + const pctEl = document.getElementById('restore-percent'); + const stepEl = document.getElementById('restore-step'); + if (bar) bar.style.width = pct + '%'; + if (pctEl) pctEl.textContent = pct + '%'; + if (stepEl) stepEl.textContent = step; + + if (progData.status === 'done' || progData.status === 'error') { + done = true; + finalResult = progData.result; + } + } catch (pollErr) { + if (pollErr.message.includes('אבד')) throw pollErr; + // network hiccup — continue polling } - throw new Error((data && data.error) ? data.error : 'שגיאה בשחזור'); } - if (!data || !data.ok) { - throw new Error((data && data.error) ? data.error : 'שגיאה בשחזור'); + + if (!finalResult || !finalResult.ok) { + throw new Error((finalResult && finalResult.error) ? finalResult.error : 'שגיאה בשחזור'); } - const r = data.restored || {}; + const r = finalResult.restored || {}; let summary = 'שחזור הושלם:
'; if (r.files > 0) summary += `קבצים: ${r.files}
`; if (r.large_files > 0) summary += `קבצים גדולים: ${r.large_files}
`; @@ -3196,15 +3338,13 @@

if (r.preferences) summary += `העדפות: שוחזרו
`; if (r.drive_prefs) summary += `העדפות Drive: שוחזרו
`; - if (data.errors && data.errors.length > 0) { - summary += `
${data.errors.length} שגיאות (ראה console)`; - console.warn('Restore errors:', data.errors); + if (finalResult.errors && finalResult.errors.length > 0) { + summary += `
${finalResult.errors.length} שגיאות (ראה console)`; + console.warn('Restore errors:', finalResult.errors); } - status.style.display = 'block'; status.innerHTML = '
' + summary + '
'; } catch (err) { - status.style.display = 'block'; status.innerHTML = ' ' + _esc(err.message) + ''; } finally { btn.disabled = false; @@ -3212,6 +3352,230 @@

input.value = ''; } } + +// ==================== Drive Backup ==================== + +async function loadDriveStatus() { + const loading = document.getElementById('drive-loading'); + const notConnected = document.getElementById('drive-not-connected'); + const connected = document.getElementById('drive-connected'); + + try { + const resp = await fetch('/api/drive/status'); + const data = await resp.json(); + if (loading) loading.style.display = 'none'; + + if (data.connected) { + if (notConnected) notConnected.style.display = 'none'; + if (connected) connected.style.display = 'block'; + const emailEl = document.getElementById('drive-email-display'); + if (emailEl && data.email) emailEl.textContent = `מחובר: ${data.email}`; + + const select = document.getElementById('drive-schedule-select'); + if (select && data.schedule) select.value = data.schedule; + + if (data.last_backup_at) { + const el = document.getElementById('drive-last-backup'); + if (el) { + el.style.display = 'block'; + try { + const d = new Date(data.last_backup_at); + el.textContent = `גיבוי אחרון: ${d.toLocaleDateString('he-IL')} ${d.toLocaleTimeString('he-IL')}`; + } catch (_) { + el.textContent = `גיבוי אחרון: ${data.last_backup_at}`; + } + } + } + } else { + if (notConnected) notConnected.style.display = 'block'; + if (connected) connected.style.display = 'none'; + } + } catch (err) { + if (loading) loading.style.display = 'none'; + if (notConnected) notConnected.style.display = 'block'; + } +} + +async function setDriveSchedule(schedule) { + try { + const resp = await fetch('/api/drive/schedule', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ schedule }), + }); + const data = await resp.json(); + const msg = document.getElementById('drive-backup-status-msg'); + if (msg) { + msg.style.display = 'block'; + if (data.ok) { + const labels = { off: 'כבוי', daily: 'יומי', every3: 'כל 3 ימים', weekly: 'שבועי', biweekly: 'דו-שבועי', monthly: 'חודשי' }; + msg.innerHTML = ` תזמון עודכן: ${labels[schedule] || schedule}`; + } else { + msg.innerHTML = ` ${_esc(data.error || 'שגיאה')}`; + } + setTimeout(() => { msg.style.display = 'none'; }, 4000); + } + } catch (err) { + console.error('setDriveSchedule error:', err); + } +} + +async function driveBackupNow() { + const btn = document.getElementById('btn-drive-backup-now'); + const msg = document.getElementById('drive-backup-status-msg'); + btn.disabled = true; + btn.innerHTML = ' מגבה...'; + if (msg) msg.style.display = 'none'; + + try { + const resp = await fetch('/api/drive/backup-now', { method: 'POST' }); + const data = await resp.json(); + if (msg) { + msg.style.display = 'block'; + if (data.ok) { + msg.innerHTML = ' הגיבוי ל-Drive הושלם בהצלחה'; + loadDriveStatus(); // רענן תאריך גיבוי אחרון + } else { + msg.innerHTML = ` ${_esc(data.error || 'שגיאה')}`; + } + } + } catch (err) { + if (msg) { + msg.style.display = 'block'; + msg.innerHTML = ' שגיאה בגיבוי'; + } + } finally { + btn.disabled = false; + btn.innerHTML = ' גבה עכשיו ל-Drive'; + } +} + +async function disconnectDrive() { + if (!confirm('לנתק את Google Drive? הגיבויים האוטומטיים ייפסקו.')) return; + try { + await fetch('/api/drive/disconnect', { method: 'POST' }); + loadDriveStatus(); + } catch (err) { + console.error('disconnectDrive error:', err); + } +} + +// ==================== Disk Backup ==================== + +async function loadDiskStatus() { + try { + const resp = await fetch('/api/disk-backup/status'); + const data = await resp.json(); + + const select = document.getElementById('disk-schedule-select'); + if (select && data.schedule) select.value = data.schedule; + + if (data.last_backup_at) { + const el = document.getElementById('disk-last-backup'); + if (el) { + el.style.display = 'block'; + try { + const d = new Date(data.last_backup_at); + el.textContent = `גיבוי אחרון: ${d.toLocaleDateString('he-IL')} ${d.toLocaleTimeString('he-IL')}`; + } catch (_) { + el.textContent = `גיבוי אחרון: ${data.last_backup_at}`; + } + } + } + + if (typeof data.count === 'number' && data.count > 0) { + const el = document.getElementById('disk-backup-count'); + if (el) { + el.style.display = 'block'; + const sizeMB = ((data.total_size || 0) / (1024 * 1024)).toFixed(1); + el.textContent = `${data.count} גיבויים שמורים (${sizeMB} MB)`; + } + } + } catch (err) { + console.error('loadDiskStatus error:', err); + } +} + +async function setDiskSchedule(schedule) { + try { + const resp = await fetch('/api/disk-backup/schedule', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ schedule }), + }); + const data = await resp.json(); + const msg = document.getElementById('disk-backup-status-msg'); + if (msg) { + msg.style.display = 'block'; + if (data.ok) { + const labels = { off: 'כבוי', daily: 'יומי', every3: 'כל 3 ימים', weekly: 'שבועי', biweekly: 'דו-שבועי', monthly: 'חודשי' }; + msg.innerHTML = ` תזמון עודכן: ${labels[schedule] || schedule}`; + } else { + msg.innerHTML = ` ${_esc(data.error || 'שגיאה')}`; + } + setTimeout(() => { msg.style.display = 'none'; }, 4000); + } + } catch (err) { + console.error('setDiskSchedule error:', err); + } +} + +async function diskBackupNow() { + const btn = document.getElementById('btn-disk-backup-now'); + const msg = document.getElementById('disk-backup-status-msg'); + btn.disabled = true; + btn.innerHTML = ' מגבה...'; + if (msg) msg.style.display = 'none'; + + try { + const resp = await fetch('/api/disk-backup/now', { method: 'POST' }); + const data = await resp.json(); + if (msg) { + msg.style.display = 'block'; + if (data.ok) { + msg.innerHTML = ' הגיבוי לדיסק הושלם בהצלחה'; + loadDiskStatus(); + } else { + msg.innerHTML = ` ${_esc(data.error || 'שגיאה')}`; + } + } + } catch (err) { + if (msg) { + msg.style.display = 'block'; + msg.innerHTML = ' שגיאה בגיבוי'; + } + } finally { + btn.disabled = false; + btn.innerHTML = ' גבה עכשיו לדיסק'; + } +} + +// Load statuses on page load +document.addEventListener('DOMContentLoaded', function() { + loadDriveStatus(); + loadDiskStatus(); + + // בדיקת URL params לאחר OAuth redirect + const params = new URLSearchParams(window.location.search); + if (params.get('drive_connected') === '1') { + const msg = document.getElementById('drive-backup-status-msg'); + if (msg) { + msg.style.display = 'block'; + msg.innerHTML = ' Google Drive חובר בהצלחה!'; + setTimeout(() => { msg.style.display = 'none'; }, 5000); + } + // נקה URL params + history.replaceState({}, '', window.location.pathname + '#backup-section'); + } + if (params.get('drive_error')) { + const msg = document.getElementById('drive-backup-status-msg'); + if (msg) { + msg.style.display = 'block'; + msg.innerHTML = ' שגיאה בחיבור Drive: ' + _esc(params.get('drive_error')) + ''; + } + history.replaceState({}, '', window.location.pathname + '#backup-section'); + } +}); {# Web Push: bind in an isolated script so failures elsewhere won't break it #}