api.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import glob
  2. import hashlib
  3. import math
  4. import os
  5. import shutil
  6. import subprocess
  7. import uuid
  8. from datetime import datetime
  9. from flask import (
  10. Blueprint,
  11. Response,
  12. current_app,
  13. jsonify,
  14. request,
  15. stream_with_context,
  16. )
  17. from db import db, Job, Run, Upload
  18. from helpers import read_archive_info
  19. bp = Blueprint("api", __name__, url_prefix="/api/v1")
  20. @bp.before_request
  21. def _check_api_auth():
  22. if request.endpoint == "api.api_health":
  23. return
  24. token = request.headers.get("X-BackupManager-Key", "")
  25. if token != current_app.config["API_TOKEN"]:
  26. return jsonify({"error": "Unauthorized"}), 401
  27. # --- Santé / jobs -------------------------------------------------------------
  28. @bp.route("/health")
  29. def api_health():
  30. return jsonify({"status": "ok", "instance": current_app.config.get("INSTANCE_NAME")})
  31. @bp.route("/running")
  32. def api_running():
  33. """Retourne les activités en cours (runs locaux status=running)."""
  34. running_runs = Run.query.filter_by(status="running").order_by(Run.started_at.asc()).all()
  35. items = []
  36. for r in running_runs:
  37. job = db.session.get(Job, r.job_id)
  38. items.append({
  39. "kind": "backup" if not r.archive_name or "restore" not in (r.archive_name or "") else "restore",
  40. "name": job.name if job else f"job#{r.job_id}",
  41. "started_at": r.started_at.isoformat() if r.started_at else None,
  42. "size_human": r.size_human if r.size_bytes else None,
  43. })
  44. return jsonify({"running": items})
  45. @bp.route("/jobs")
  46. def api_jobs():
  47. jobs = Job.query.all()
  48. return jsonify([
  49. {
  50. "id": j.id,
  51. "name": j.name,
  52. "type": j.type,
  53. "cron_expr": j.cron_expr,
  54. "enabled": j.enabled,
  55. "retention_mode": j.retention_mode,
  56. "retention_value": j.retention_value,
  57. }
  58. for j in jobs
  59. ])
  60. @bp.route("/jobs/<int:job_id>/runs")
  61. def api_job_runs(job_id):
  62. runs = Run.query.filter_by(job_id=job_id).order_by(Run.started_at.desc()).limit(50).all()
  63. return jsonify([
  64. {
  65. "id": r.id,
  66. "started_at": r.started_at.isoformat() if r.started_at else None,
  67. "finished_at": r.finished_at.isoformat() if r.finished_at else None,
  68. "status": r.status,
  69. "archive_name": r.archive_name,
  70. "size_bytes": r.size_bytes,
  71. }
  72. for r in runs
  73. ])
  74. @bp.route("/jobs/<int:job_id>/run", methods=["POST"])
  75. def api_job_run(job_id):
  76. import threading
  77. job = db.get_or_404(Job, job_id)
  78. from scheduler import _execute_job
  79. threading.Thread(target=_execute_job, args=(job.id,), daemon=True).start()
  80. return jsonify({"status": "triggered", "job_id": job_id})
  81. # --- Archives -----------------------------------------------------------------
  82. @bp.route("/archives")
  83. def api_archives():
  84. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  85. archives = []
  86. try:
  87. from jobs.utils import sudo_listdir, sudo_getsize, sudo_getmtime
  88. for fname in sorted(sudo_listdir(backup_dir)):
  89. if fname.endswith(".tar"):
  90. path = os.path.join(backup_dir, fname)
  91. archives.append({
  92. "name": fname[:-4],
  93. "size_bytes": sudo_getsize(path),
  94. "modified_at": datetime.utcfromtimestamp(sudo_getmtime(path)).isoformat(),
  95. })
  96. except OSError:
  97. pass
  98. return jsonify(archives)
  99. @bp.route("/archives/<name>", methods=["DELETE"])
  100. def api_archive_delete(name):
  101. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  102. from jobs.utils import sudo_exists
  103. for ext in (".tar", ".info.json"):
  104. path = os.path.join(backup_dir, name + ext)
  105. if sudo_exists(path):
  106. subprocess.run(["sudo", "rm", "-f", path], capture_output=True)
  107. return jsonify({"status": "deleted", "name": name})
  108. @bp.route("/archives/<name>/info")
  109. def api_archive_info(name):
  110. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  111. return jsonify(read_archive_info(name, backup_dir))
  112. @bp.route("/archives/<name>/restore", methods=["POST"])
  113. def api_archive_restore(name):
  114. from blueprints.jobs import _start_restore
  115. restore_run_id, _ = _start_restore(name)
  116. return jsonify({"status": "started", "run_id": restore_run_id})
  117. @bp.route("/archives/<name>/restore/status")
  118. def api_archive_restore_status(name):
  119. run = (Run.query
  120. .filter(Run.archive_name == name, Run.log_text.like("[RESTAURATION%"))
  121. .order_by(Run.started_at.desc())
  122. .first())
  123. if not run:
  124. return jsonify({"error": "Aucune restauration trouvée pour cette archive."}), 404
  125. return jsonify({
  126. "status": run.status,
  127. "log": run.log_text,
  128. "started_at": run.started_at.isoformat() if run.started_at else None,
  129. "finished_at": run.finished_at.isoformat() if run.finished_at else None,
  130. })
  131. @bp.route("/summary")
  132. def api_summary():
  133. jobs = Job.query.all()
  134. result = []
  135. for job in jobs:
  136. last_run = (Run.query.filter_by(job_id=job.id)
  137. .order_by(Run.started_at.desc()).first())
  138. result.append({
  139. "id": job.id,
  140. "name": job.name,
  141. "type": job.type,
  142. "cron_expr": job.cron_expr,
  143. "enabled": job.enabled,
  144. "last_run": {
  145. "id": last_run.id,
  146. "started_at": last_run.started_at.isoformat() if last_run.started_at else None,
  147. "status": last_run.status,
  148. "archive_name": last_run.archive_name,
  149. "size_bytes": last_run.size_bytes,
  150. } if last_run else None,
  151. })
  152. return jsonify({"instance": current_app.config.get("INSTANCE_NAME"), "jobs": result})
  153. # --- Téléchargement archives --------------------------------------------------
  154. @bp.route("/archives/<name>/info-json-download")
  155. def api_archive_info_json_download(name):
  156. from jobs.utils import sudo_exists
  157. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  158. info_path = os.path.join(backup_dir, name + ".info.json")
  159. if not sudo_exists(info_path):
  160. return jsonify({"error": "info.json introuvable"}), 404
  161. tmp_path = f"/tmp/backupmanager_dl_{name}.info.json"
  162. content = None
  163. try:
  164. result = subprocess.run(["sudo", "rsync", info_path, tmp_path],
  165. capture_output=True, text=True)
  166. if result.returncode != 0:
  167. return jsonify({"error": result.stderr.strip()}), 500
  168. with open(tmp_path, "rb") as f:
  169. content = f.read()
  170. except Exception as exc:
  171. return jsonify({"error": str(exc)}), 500
  172. finally:
  173. subprocess.run(["sudo", "rm", "-rf", tmp_path], capture_output=True)
  174. return Response(content, mimetype="application/json")
  175. @bp.route("/archives/<name>/download")
  176. def api_archive_download(name):
  177. from jobs.utils import sudo_exists
  178. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  179. archive_path = os.path.join(backup_dir, name + ".tar")
  180. if not sudo_exists(archive_path):
  181. return jsonify({"error": "archive introuvable"}), 404
  182. tmp_path = f"/tmp/backupmanager_dl_{name}.tar"
  183. try:
  184. result = subprocess.run(
  185. ["sudo", "rsync", archive_path, tmp_path],
  186. capture_output=True, text=True, timeout=3600,
  187. )
  188. if result.returncode != 0:
  189. return jsonify({"error": result.stderr.strip()}), 500
  190. def stream_and_cleanup():
  191. try:
  192. with open(tmp_path, "rb") as f:
  193. while True:
  194. chunk = f.read(1024 * 1024)
  195. if not chunk:
  196. break
  197. yield chunk
  198. finally:
  199. if os.path.exists(tmp_path):
  200. os.unlink(tmp_path)
  201. return Response(
  202. stream_with_context(stream_and_cleanup()),
  203. mimetype="application/octet-stream",
  204. headers={"Content-Disposition": f'attachment; filename="{name}.tar"'},
  205. )
  206. except Exception as exc:
  207. if os.path.exists(tmp_path):
  208. os.unlink(tmp_path)
  209. return jsonify({"error": str(exc)}), 500
  210. # --- Upload chunked -----------------------------------------------------------
  211. @bp.route("/archives/upload/start", methods=["POST"])
  212. def api_upload_start():
  213. data = request.get_json(force=True) or {}
  214. filename = data.get("filename", "")
  215. total_size = int(data.get("total_size", 0))
  216. chunk_size = int(data.get("chunk_size", 50 * 1024 * 1024))
  217. chunks_total = int(data.get("chunks_total",
  218. math.ceil(total_size / chunk_size) if chunk_size else 1))
  219. checksum = data.get("checksum", "")
  220. if not filename:
  221. return jsonify({"error": "filename requis"}), 400
  222. upload_id = str(uuid.uuid4())
  223. upload = Upload(
  224. upload_id=upload_id,
  225. filename=filename,
  226. total_size=total_size,
  227. chunk_size=chunk_size,
  228. chunks_total=chunks_total,
  229. chunks_received=0,
  230. checksum=checksum,
  231. status="pending",
  232. )
  233. db.session.add(upload)
  234. db.session.commit()
  235. return jsonify({"upload_id": upload_id, "chunks_total": chunks_total})
  236. @bp.route("/archives/upload/<upload_id>/chunk/<int:n>", methods=["POST"])
  237. def api_upload_chunk(upload_id, n):
  238. upload = db.get_or_404(Upload, upload_id)
  239. if upload.status == "complete":
  240. return jsonify({"error": "upload déjà terminé"}), 400
  241. tmp_dir = os.path.join(current_app.config["DATA_DIR"], "uploads", upload_id)
  242. os.makedirs(tmp_dir, exist_ok=True)
  243. chunk_path = os.path.join(tmp_dir, f"chunk_{n:06d}")
  244. with open(chunk_path, "wb") as f:
  245. f.write(request.data)
  246. upload.chunks_received = (upload.chunks_received or 0) + 1
  247. upload.status = "in_progress"
  248. db.session.commit()
  249. return jsonify({"chunk": n, "received": upload.chunks_received})
  250. @bp.route("/archives/upload/<upload_id>/finish", methods=["POST"])
  251. def api_upload_finish(upload_id):
  252. upload = db.get_or_404(Upload, upload_id)
  253. tmp_dir = os.path.join(current_app.config["DATA_DIR"], "uploads", upload_id)
  254. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  255. chunk_files = sorted(glob.glob(os.path.join(tmp_dir, "chunk_*")))
  256. if not chunk_files:
  257. return jsonify({"error": "aucun chunk reçu"}), 400
  258. tmp_archive = os.path.join(tmp_dir, upload.filename)
  259. sha256 = hashlib.sha256()
  260. with open(tmp_archive, "wb") as out:
  261. for chunk_file in chunk_files:
  262. with open(chunk_file, "rb") as f:
  263. data = f.read()
  264. out.write(data)
  265. sha256.update(data)
  266. if upload.checksum and sha256.hexdigest() != upload.checksum:
  267. upload.status = "error"
  268. db.session.commit()
  269. shutil.rmtree(tmp_dir, ignore_errors=True)
  270. return jsonify({"error": "checksum invalide"}), 400
  271. dest_path = os.path.join(backup_dir, upload.filename)
  272. result = subprocess.run(
  273. ["sudo", "rsync", tmp_archive, dest_path],
  274. capture_output=True, text=True,
  275. )
  276. if result.returncode != 0:
  277. upload.status = "error"
  278. db.session.commit()
  279. shutil.rmtree(tmp_dir, ignore_errors=True)
  280. return jsonify({"error": result.stderr.strip()}), 500
  281. data = request.get_json(silent=True) or {}
  282. info_json_str = data.get("info_json")
  283. if info_json_str:
  284. archive_base = upload.filename[:-4] if upload.filename.endswith(".tar") else upload.filename
  285. tmp_info = os.path.join(tmp_dir, archive_base + ".info.json")
  286. with open(tmp_info, "w") as f:
  287. f.write(info_json_str)
  288. subprocess.run(
  289. ["sudo", "rsync", tmp_info,
  290. os.path.join(backup_dir, archive_base + ".info.json")],
  291. capture_output=True,
  292. )
  293. shutil.rmtree(tmp_dir, ignore_errors=True)
  294. upload.status = "complete"
  295. db.session.commit()
  296. return jsonify({"status": "complete", "filename": upload.filename})
  297. @bp.route("/archives/upload/<upload_id>", methods=["DELETE"])
  298. def api_upload_cancel(upload_id):
  299. upload = db.get_or_404(Upload, upload_id)
  300. tmp_dir = os.path.join(current_app.config["DATA_DIR"], "uploads", upload_id)
  301. shutil.rmtree(tmp_dir, ignore_errors=True)
  302. db.session.delete(upload)
  303. db.session.commit()
  304. return jsonify({"status": "cancelled"})