api.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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_rm_archive
  103. sudo_rm_archive(name, backup_dir)
  104. return jsonify({"status": "deleted", "name": name})
  105. @bp.route("/archives/<name>/info")
  106. def api_archive_info(name):
  107. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  108. return jsonify(read_archive_info(name, backup_dir))
  109. @bp.route("/archives/<name>/restore", methods=["POST"])
  110. def api_archive_restore(name):
  111. from blueprints.jobs import _start_restore
  112. restore_run_id, _ = _start_restore(name)
  113. return jsonify({"status": "started", "run_id": restore_run_id})
  114. @bp.route("/archives/<name>/restore/status")
  115. def api_archive_restore_status(name):
  116. run = (Run.query
  117. .filter(Run.archive_name == name, Run.log_text.like("[RESTAURATION%"))
  118. .order_by(Run.started_at.desc())
  119. .first())
  120. if not run:
  121. return jsonify({"error": "Aucune restauration trouvée pour cette archive."}), 404
  122. return jsonify({
  123. "status": run.status,
  124. "log": run.log_text,
  125. "started_at": run.started_at.isoformat() if run.started_at else None,
  126. "finished_at": run.finished_at.isoformat() if run.finished_at else None,
  127. })
  128. @bp.route("/summary")
  129. def api_summary():
  130. jobs = Job.query.all()
  131. result = []
  132. for job in jobs:
  133. last_run = (Run.query.filter_by(job_id=job.id)
  134. .order_by(Run.started_at.desc()).first())
  135. result.append({
  136. "id": job.id,
  137. "name": job.name,
  138. "type": job.type,
  139. "cron_expr": job.cron_expr,
  140. "enabled": job.enabled,
  141. "last_run": {
  142. "id": last_run.id,
  143. "started_at": last_run.started_at.isoformat() if last_run.started_at else None,
  144. "status": last_run.status,
  145. "archive_name": last_run.archive_name,
  146. "size_bytes": last_run.size_bytes,
  147. } if last_run else None,
  148. })
  149. return jsonify({"instance": current_app.config.get("INSTANCE_NAME"), "jobs": result})
  150. # --- Téléchargement archives --------------------------------------------------
  151. @bp.route("/archives/<name>/info-json-download")
  152. def api_archive_info_json_download(name):
  153. from jobs.utils import sudo_exists
  154. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  155. info_path = os.path.join(backup_dir, name + ".info.json")
  156. if not sudo_exists(info_path):
  157. return jsonify({"error": "info.json introuvable"}), 404
  158. tmp_path = f"/tmp/backupmanager_dl_{name}.info.json"
  159. content = None
  160. try:
  161. result = subprocess.run(["sudo", "rsync", info_path, tmp_path],
  162. capture_output=True, text=True)
  163. if result.returncode != 0:
  164. return jsonify({"error": result.stderr.strip()}), 500
  165. with open(tmp_path, "rb") as f:
  166. content = f.read()
  167. except Exception as exc:
  168. return jsonify({"error": str(exc)}), 500
  169. finally:
  170. subprocess.run(["sudo", "rm", "-rf", tmp_path], capture_output=True)
  171. return Response(content, mimetype="application/json")
  172. @bp.route("/archives/<name>/download")
  173. def api_archive_download(name):
  174. from jobs.utils import sudo_exists
  175. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  176. archive_path = os.path.join(backup_dir, name + ".tar")
  177. if not sudo_exists(archive_path):
  178. return jsonify({"error": "archive introuvable"}), 404
  179. tmp_path = f"/tmp/backupmanager_dl_{name}.tar"
  180. try:
  181. result = subprocess.run(
  182. ["sudo", "rsync", archive_path, tmp_path],
  183. capture_output=True, text=True, timeout=3600,
  184. )
  185. if result.returncode != 0:
  186. return jsonify({"error": result.stderr.strip()}), 500
  187. def stream_and_cleanup():
  188. try:
  189. with open(tmp_path, "rb") as f:
  190. while True:
  191. chunk = f.read(1024 * 1024)
  192. if not chunk:
  193. break
  194. yield chunk
  195. finally:
  196. if os.path.exists(tmp_path):
  197. os.unlink(tmp_path)
  198. return Response(
  199. stream_with_context(stream_and_cleanup()),
  200. mimetype="application/octet-stream",
  201. headers={"Content-Disposition": f'attachment; filename="{name}.tar"'},
  202. )
  203. except Exception as exc:
  204. if os.path.exists(tmp_path):
  205. os.unlink(tmp_path)
  206. return jsonify({"error": str(exc)}), 500
  207. # --- Upload chunked -----------------------------------------------------------
  208. @bp.route("/archives/upload/start", methods=["POST"])
  209. def api_upload_start():
  210. data = request.get_json(force=True) or {}
  211. filename = data.get("filename", "")
  212. total_size = int(data.get("total_size", 0))
  213. chunk_size = int(data.get("chunk_size", 50 * 1024 * 1024))
  214. chunks_total = int(data.get("chunks_total",
  215. math.ceil(total_size / chunk_size) if chunk_size else 1))
  216. checksum = data.get("checksum", "")
  217. if not filename:
  218. return jsonify({"error": "filename requis"}), 400
  219. upload_id = str(uuid.uuid4())
  220. upload = Upload(
  221. upload_id=upload_id,
  222. filename=filename,
  223. total_size=total_size,
  224. chunk_size=chunk_size,
  225. chunks_total=chunks_total,
  226. chunks_received=0,
  227. checksum=checksum,
  228. status="pending",
  229. )
  230. db.session.add(upload)
  231. db.session.commit()
  232. return jsonify({"upload_id": upload_id, "chunks_total": chunks_total})
  233. @bp.route("/archives/upload/<upload_id>/chunk/<int:n>", methods=["POST"])
  234. def api_upload_chunk(upload_id, n):
  235. upload = db.get_or_404(Upload, upload_id)
  236. if upload.status == "complete":
  237. return jsonify({"error": "upload déjà terminé"}), 400
  238. tmp_dir = os.path.join(current_app.config["DATA_DIR"], "uploads", upload_id)
  239. os.makedirs(tmp_dir, exist_ok=True)
  240. chunk_path = os.path.join(tmp_dir, f"chunk_{n:06d}")
  241. with open(chunk_path, "wb") as f:
  242. f.write(request.data)
  243. upload.chunks_received = (upload.chunks_received or 0) + 1
  244. upload.status = "in_progress"
  245. db.session.commit()
  246. return jsonify({"chunk": n, "received": upload.chunks_received})
  247. @bp.route("/archives/upload/<upload_id>/finish", methods=["POST"])
  248. def api_upload_finish(upload_id):
  249. upload = db.get_or_404(Upload, upload_id)
  250. tmp_dir = os.path.join(current_app.config["DATA_DIR"], "uploads", upload_id)
  251. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  252. chunk_files = sorted(glob.glob(os.path.join(tmp_dir, "chunk_*")))
  253. if not chunk_files:
  254. return jsonify({"error": "aucun chunk reçu"}), 400
  255. tmp_archive = os.path.join(tmp_dir, upload.filename)
  256. sha256 = hashlib.sha256()
  257. with open(tmp_archive, "wb") as out:
  258. for chunk_file in chunk_files:
  259. with open(chunk_file, "rb") as f:
  260. data = f.read()
  261. out.write(data)
  262. sha256.update(data)
  263. if upload.checksum and sha256.hexdigest() != upload.checksum:
  264. upload.status = "error"
  265. db.session.commit()
  266. shutil.rmtree(tmp_dir, ignore_errors=True)
  267. return jsonify({"error": "checksum invalide"}), 400
  268. dest_path = os.path.join(backup_dir, upload.filename)
  269. result = subprocess.run(
  270. ["sudo", "rsync", tmp_archive, dest_path],
  271. capture_output=True, text=True,
  272. )
  273. if result.returncode != 0:
  274. upload.status = "error"
  275. db.session.commit()
  276. shutil.rmtree(tmp_dir, ignore_errors=True)
  277. return jsonify({"error": result.stderr.strip()}), 500
  278. data = request.get_json(silent=True) or {}
  279. info_json_str = data.get("info_json")
  280. if info_json_str:
  281. archive_base = upload.filename[:-4] if upload.filename.endswith(".tar") else upload.filename
  282. tmp_info = os.path.join(tmp_dir, archive_base + ".info.json")
  283. with open(tmp_info, "w") as f:
  284. f.write(info_json_str)
  285. subprocess.run(
  286. ["sudo", "rsync", tmp_info,
  287. os.path.join(backup_dir, archive_base + ".info.json")],
  288. capture_output=True,
  289. )
  290. shutil.rmtree(tmp_dir, ignore_errors=True)
  291. upload.status = "complete"
  292. db.session.commit()
  293. return jsonify({"status": "complete", "filename": upload.filename})
  294. @bp.route("/archives/upload/<upload_id>", methods=["DELETE"])
  295. def api_upload_cancel(upload_id):
  296. upload = db.get_or_404(Upload, upload_id)
  297. tmp_dir = os.path.join(current_app.config["DATA_DIR"], "uploads", upload_id)
  298. shutil.rmtree(tmp_dir, ignore_errors=True)
  299. db.session.delete(upload)
  300. db.session.commit()
  301. return jsonify({"status": "cancelled"})