#!/usr/bin/env python3 """ Backup UI - Flask backend Serves the backup dashboard and handles restore operations. """ import os import subprocess import json import tarfile import re from datetime import datetime from flask import Flask, render_template, jsonify, request, send_file app = Flask(__name__) APP_DIR = "/root/Apps/backup-ui" BACKUP_DIR = "/tmp/restore-temp" LOG_FILE = "/root/backup.log" PASSWORD = "@@Gi05Br;;" # Change this! os.makedirs(BACKUP_DIR, exist_ok=True) # ────────────────────────────────────────────── # AUTH # ────────────────────────────────────────────── @app.route("/api/login", methods=["POST"]) def login(): data = request.json or {} if data.get("password") == PASSWORD: return jsonify({"ok": True}) return jsonify({"ok": False, "error": "Invalid password"}), 401 # ────────────────────────────────────────────── # BACKUP STATUS # ────────────────────────────────────────────── def get_backup_log(): """Parse the backup log to get last run info.""" if not os.path.exists(LOG_FILE): return {"last_run": None, "entries": []} with open(LOG_FILE, "r") as f: lines = f.readlines() entries = [] current = {} for line in lines[-500:]: # last 500 lines if "==========" in line: if current: entries.append(current) ts = line.strip().split("==========")[1].strip() current = {"timestamp": ts, "scripts": []} elif "[Backup" in line and "started" in line: script = line.strip().split("] ")[1].split(" started")[0] current["scripts"].append({"name": script, "status": "running"}) elif "[Backup" in line and "finished" in line: script = line.strip().split("] ")[1].split(" finished")[0] for s in current.get("scripts", []): if s["name"] == script: s["status"] = "ok" elif "error" in line.lower() or "failed" in line.lower(): current["has_error"] = True current["error"] = line.strip() if current: entries.append(current) entries.reverse() return { "last_run": entries[0] if entries else None, "entries": entries[:10] } def list_rclone_contents(remote, path="", depth=1): """List contents of a rclone remote path.""" cmd = ["rclone", "lsjson", f"{remote}:{path}", "--drive-root-folder-id", "1ey-5aABgHDtirxeJarWy0ZmeqQPVTYtm", "--max-depth", str(depth)] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode == 0: return json.loads(result.stdout) except: pass return [] def get_backup_stats(): """Get backup stats from GDrive.""" stats = { "apps": {"count": 0, "latest": None, "size": 0}, "bd": {"count": 0, "latest": None, "size": 0}, "git": {"count": 0, "latest": None, "size": 0}, "repos": {"count": 0, "latest": None, "size": 0} } # Apps try: items = list_rclone_contents("gdrive", "Apps") stats["apps"]["count"] = len(items) if items: items.sort(key=lambda x: x.get("ModTime", ""), reverse=True) stats["apps"]["latest"] = items[0].get("Name") stats["apps"]["size"] = sum(i.get("Size", 0) for i in items) except: pass # BD try: items = list_rclone_contents("gdrive", "BD") stats["bd"]["count"] = len(items) if items: items.sort(key=lambda x: x.get("ModTime", ""), reverse=True) stats["bd"]["latest"] = items[0].get("Name") stats["bd"]["size"] = sum(i.get("Size", 0) for i in items) except: pass # Git try: items = list_rclone_contents("gdrive", "Git") git_items = [i for i in items if "gitea-data" in i.get("Name", "")] stats["git"]["count"] = len(git_items) if git_items: git_items.sort(key=lambda x: x.get("ModTime", ""), reverse=True) stats["git"]["latest"] = git_items[0].get("Name") stats["git"]["size"] = sum(i.get("Size", 0) for i in git_items) # Repos raw_repo_items = list_rclone_contents("gdrive", "Git/repos", depth=2) repo_items = [i for i in raw_repo_items if not i.get("IsDir")] stats["repos"]["count"] = len(repo_items) if repo_items: repo_items.sort(key=lambda x: x.get("ModTime", ""), reverse=True) stats["repos"]["latest"] = repo_items[0].get("Name") stats["repos"]["size"] = sum(i.get("Size", 0) for i in repo_items) except: pass return stats @app.route("/api/status") def status(): log = get_backup_log() stats = get_backup_stats() return jsonify({"log": log, "stats": stats}) # ────────────────────────────────────────────── # LIST BACKUPS # ────────────────────────────────────────────── @app.route("/api/backups/") def list_backups(backup_type): remotes = { "apps": "gdrive:Apps", "bd": "gdrive:BD", "git": "gdrive:Git", "repos": "gdrive:Git/repos" } remote = remotes.get(backup_type) if not remote: return jsonify({"error": "Unknown type"}), 400 if backup_type == "repos": items = list_rclone_contents("gdrive", "Git/repos", depth=2) elif backup_type == "git": items = list_rclone_contents("gdrive", "Git") items = [i for i in items if "gitea-data" in i.get("Name", "")] else: items = list_rclone_contents("gdrive", backup_type.capitalize()) # Group repos by owner if backup_type == "repos": grouped = {} for item in items: if item.get("IsDir"): continue path = item.get("Path", "") parts = path.split("/") if len(parts) >= 2: owner = parts[0] repo = "/".join(parts[1:]) if owner not in grouped: grouped[owner] = [] grouped[owner].append({"name": repo, "size": item.get("Size", 0), "modified": item.get("ModTime", "")}) else: grouped["other"] = grouped.get("other", []) grouped["other"].append({"name": name, "size": item.get("Size", 0), "modified": item.get("ModTime", "")}) return jsonify({"items": grouped, "type": "repos"}) items.sort(key=lambda x: x.get("ModTime", ""), reverse=True) return jsonify({"items": [{"name": i.get("Name"), "size": i.get("Size", 0), "modified": i.get("ModTime", "")} for i in items], "type": backup_type}) # ────────────────────────────────────────────── # RESTORE # ────────────────────────────────────────────── @app.route("/api/restore/", methods=["POST"]) def restore(backup_type): data = request.json or {} filename = data.get("filename") target = data.get("target", "") # for repos: owner/repo if not filename: return jsonify({"error": "No filename provided"}), 400 remotes = { "apps": "gdrive:Apps", "bd": "gdrive:BD", "git": "gdrive:Git", "repos": "gdrive:Git/repos" } remote = remotes.get(backup_type) if not remote: return jsonify({"error": "Unknown type"}), 400 remote_path = f"{remote}/{target}/{filename}" if target and backup_type == "repos" else f"{remote}/{filename}" # Download local_file = f"{BACKUP_DIR}/{filename}" cmd = ["rclone", "copy", remote_path, BACKUP_DIR, "--drive-root-folder-id", "1ey-5aABgHDtirxeJarWy0ZmeqQPVTYtm"] result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) if result.returncode != 0: return jsonify({"error": f"Download failed: {result.stderr}"}), 500 return jsonify({"ok": True, "file": local_file, "size": os.path.getsize(local_file)}) @app.route("/api/restore/apps", methods=["POST"]) def restore_apps(): data = request.json or {} filename = data.get("filename") if not filename: return jsonify({"error": "No filename"}), 400 src = f"/tmp/restore-temp/{filename}" if not os.path.exists(src): return jsonify({"error": "File not found locally"}), 404 # Extract to temp extract_dir = f"/tmp/restore-apps-extract" os.makedirs(extract_dir, exist_ok=True) subprocess.run(["tar", "-xzf", src, "-C", extract_dir], capture_output=True) # List what was extracted extracted = os.listdir(extract_dir) return jsonify({"ok": True, "extracted_to": extract_dir, "files": extracted, "instructions": { "Camila": f"Copiar {extract_dir}/Camila/* para /root/Apps/Camila/", "RAG": f"Copiar {extract_dir}/RAG/* para /root/Apps/RAG/", "VOXDO": f"Copiar {extract_dir}/VOXDO/* para /root/Apps/VOXDO/" }}) @app.route("/api/restore/bd", methods=["POST"]) def restore_bd(): data = request.json or {} filename = data.get("filename") db_type = data.get("db", "auto") # supabase or gitea if not filename: return jsonify({"error": "No filename"}), 400 src = f"/tmp/restore-temp/{filename}" if not os.path.exists(src): return jsonify({"error": "File not found locally"}), 404 extract_dir = f"/tmp/restore-bd-extract" os.makedirs(extract_dir, exist_ok=True) subprocess.run(["tar", "-xzf", src, "-C", extract_dir], capture_output=True) # Find the SQL file sql_files = [] for root, dirs, files in os.walk(extract_dir): for f in files: if f.endswith(".sql"): sql_files.append(os.path.join(root, f)) return jsonify({ "ok": True, "extracted_to": extract_dir, "sql_files": sql_files, "instructions": { "supabase": f"docker exec -i supabase-db psql -U postgres -d postgres < {sql_files[0] if sql_files else ''}", "gitea": f"docker exec -i coolify-db psql -U postgres -d gitea < {sql_files[0] if sql_files else ''}" } }) @app.route("/api/restore/git", methods=["POST"]) def restore_git(): data = request.json or {} filename = data.get("filename") if not filename: return jsonify({"error": "No filename"}), 400 src = f"/tmp/restore-temp/{filename}" if not os.path.exists(src): return jsonify({"error": "File not found locally"}), 404 extract_dir = f"/tmp/restore-git-extract" os.makedirs(extract_dir, exist_ok=True) subprocess.run(["tar", "-xzf", src, "-C", extract_dir], capture_output=True) # List extracted extracted = [] for root, dirs, files in os.walk(extract_dir): for f in files: extracted.append(os.path.join(root, f).replace(extract_dir + "/", "")) return jsonify({ "ok": True, "extracted_to": extract_dir, "files": extracted, "instructions": { "gitea_data": f"Copiar conteúdo para /var/lib/docker/volumes/yccsckck4g004gosccwc4kg4_gitea-data/_data/", "gitea_db": f"docker exec -i psql -U postgres -d gitea < " } }) @app.route("/api/restore/repo", methods=["POST"]) def restore_repo(): data = request.json or {} owner = data.get("owner") repo_name = data.get("repo") filename = data.get("filename") if not filename: return jsonify({"error": "No filename"}), 400 src = f"/tmp/restore-temp/{filename}" if not os.path.exists(src): return jsonify({"error": "File not found locally"}), 404 extract_dir = f"/tmp/restore-repo-{owner}-{repo_name}" os.makedirs(extract_dir, exist_ok=True) # Extract tar.gz result = subprocess.run(["tar", "-tzf", src], capture_output=True, text=True) first_item = result.stdout.split("\n")[0] if result.stdout else "" subprocess.run(["tar", "-xzf", src, "-C", extract_dir], capture_output=True) # Find the .git folder git_dir = None for root, dirs, files in os.walk(extract_dir): if ".git" in dirs: git_dir = os.path.join(root, ".git") work_tree = root break return jsonify({ "ok": True, "extracted_to": extract_dir, "git_dir": git_dir, "work_tree": work_tree if 'work_tree' in dir() else extract_dir, "instructions": { "clone": f"git clone {git_dir} /tmp/{repo_name}-restored" if git_dir else "N/A", "bare_restore": f"Copiar {git_dir} para /var/lib/docker/volumes/.../repositories/{owner}/{repo_name}.git" if git_dir else "N/A" } }) @app.route("/api/run-backup", methods=["POST"]) def run_backup(): data = request.json or {} script = data.get("script") scripts = { "apps": "/root/backup-apps.sh", "bd": "/root/backup-bd.sh", "git": "/root/backup-git.sh", "repos": "/root/backup-git-repos.sh" } if script not in scripts: return jsonify({"error": "Unknown script"}), 400 # Run in background import traceback try: proc = subprocess.Popen( [scripts[script]], stdout=open("/root/backup.log", "a"), stderr=subprocess.STDOUT, cwd="/root" ) return jsonify({"ok": True, "message": f"{script} backup started", "pid": proc.pid}) except Exception as e: return jsonify({"error": str(e), "trace": traceback.format_exc()}), 500 # ────────────────────────────────────────────── # STATIC & LOGS # ────────────────────────────────────────────── @app.route("/") def index(): return app.send_static_file("index.html") @app.route("/api/log") def log(): if os.path.exists(LOG_FILE): with open(LOG_FILE, "r") as f: lines = f.readlines() return jsonify({"lines": lines[-200:]}) return jsonify({"lines": []}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8766, debug=False)