From 5d0247159b125bf035285d56c2b9bb58d6bb3029 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Thu, 26 Feb 2026 14:39:34 +0100
Subject: [PATCH] feat: backup download endpoint + skip-backup sync option

---
 static/index.html        |    2 +-
 static/js/app.js         |   18 ++++++++++++++++--
 app/routers/sync_data.py |    6 +++++-
 app/routers/backups.py   |   26 ++++++++++++++++++++++++++
 4 files changed, 48 insertions(+), 4 deletions(-)

diff --git a/app/routers/backups.py b/app/routers/backups.py
index badf0bf..2d84f30 100644
--- a/app/routers/backups.py
+++ b/app/routers/backups.py
@@ -1,9 +1,11 @@
 import json
+import os
 from datetime import datetime, timezone
 from typing import Any, AsyncGenerator
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import StreamingResponse
+from starlette.responses import FileResponse
 
 from app.auth import verify_token
 from app.ops_runner import (
@@ -304,3 +306,27 @@
         raise HTTPException(status_code=500, detail=f"Delete failed: {results}")
 
     return {"success": True, "project": project, "env": env, "name": name, "results": results}
+
+
+@router.get("/download/{project}/{env}/{name}", summary="Download a backup to browser")
+async def download_backup(
+    project: str,
+    env: str,
+    name: str,
+    _: str = Depends(verify_token),
+) -> FileResponse:
+    """Serve a local backup file as a browser download."""
+    for param in (project, env, name):
+        if "/" in param or "\\" in param or ".." in param:
+            raise HTTPException(status_code=400, detail="Invalid path parameter")
+
+    backup_path = f"/opt/data/backups/{project}/{env}/{name}"
+    if not os.path.isfile(backup_path):
+        raise HTTPException(status_code=404, detail="Backup file not found")
+
+    media_type = "application/gzip" if name.endswith(".gz") else "application/octet-stream"
+    return FileResponse(
+        path=backup_path,
+        media_type=media_type,
+        filename=name,
+    )
diff --git a/app/routers/sync_data.py b/app/routers/sync_data.py
index 2fcc930..1b41a22 100644
--- a/app/routers/sync_data.py
+++ b/app/routers/sync_data.py
@@ -29,6 +29,7 @@
     db_only: bool,
     uploads_only: bool,
     dry_run: bool = False,
+    skip_backup: bool = False,
 ) -> AsyncGenerator[str, None]:
     """Stream sync output via SSE."""
     args = ["sync", project, "--from", from_env, "--to", to_env]
@@ -38,6 +39,8 @@
         args.append("--uploads-only")
     if dry_run:
         args.append("--dry-run")
+    if skip_backup:
+        args.append("--skip-backup")
 
     mode = "db-only" if db_only else ("uploads-only" if uploads_only else "full")
     yield _sse_line({
@@ -62,6 +65,7 @@
     db_only: bool = Query(default=False),
     uploads_only: bool = Query(default=False),
     dry_run: bool = Query(default=False),
+    skip_backup: bool = Query(default=False),
     _: str = Depends(verify_token),
 ) -> StreamingResponse:
     """Sync data backward (prod->int, int->dev) with SSE streaming."""
@@ -71,7 +75,7 @@
             detail=f"Invalid sync path '{from_env} -> {to_env}'. Only adjacent pairs are allowed: prod<->int, int<->dev.",
         )
     return StreamingResponse(
-        _sync_generator(project, from_env, to_env, db_only, uploads_only, dry_run),
+        _sync_generator(project, from_env, to_env, db_only, uploads_only, dry_run, skip_backup),
         media_type="text/event-stream",
         headers={
             "Cache-Control": "no-cache",
diff --git a/static/index.html b/static/index.html
index 8ef0b80..eefb5d6 100644
--- a/static/index.html
+++ b/static/index.html
@@ -287,6 +287,6 @@
   </div>
 </div>
 
-<script src="/static/js/app.js?v=13"></script>
+<script src="/static/js/app.js?v=15"></script>
 </body>
 </html>
diff --git a/static/js/app.js b/static/js/app.js
index a23028a..5621618 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,5 +1,5 @@
 'use strict';
-const APP_VERSION = 'v14-20260222';
+const APP_VERSION = 'v15-20260226';
 
 // ============================================================
 // OPS Dashboard — Vanilla JS Application (v6)
@@ -827,6 +827,9 @@
         const downloadBtn = (!b.hasLocal && b.hasOffsite)
           ? `<button class="btn btn-ghost btn-xs" style="color:#34d399;border-color:rgba(52,211,153,0.25);" onclick="downloadOffsiteBackup('${esc(b.project)}','${esc(b.env)}','${esc(b.name)}')">Download</button>`
           : '';
+        const saveBtn = b.hasLocal
+          ? `<button class="btn btn-ghost btn-xs" style="color:#60a5fa;border-color:rgba(96,165,250,0.25);" onclick="downloadBackupFile('${esc(b.project)}','${esc(b.env)}','${esc(b.name)}')">Save</button>`
+          : '';
         h += `<tr>
           <td style="padding-left:0.75rem;"><input type="checkbox" class="backup-cb" value="${esc(b.name)}"${checked} onclick="toggleBackupSelect('${esc(b.name)}')" style="accent-color:#3b82f6;cursor:pointer;"></td>
           <td>${locationBadge}</td>
@@ -834,6 +837,7 @@
           <td>${esc(b.size_human || '\u2014')}</td>
           <td style="white-space:nowrap;">
             <button class="btn btn-danger btn-xs" onclick="openRestoreModal('${esc(b.project)}','${esc(b.env)}','${restoreSource}','${esc(b.name)}',${b.hasLocal},${b.hasOffsite})">Restore</button>
+            ${saveBtn}
             ${uploadBtn}
             ${downloadBtn}
             ${deleteBtn}
@@ -927,6 +931,10 @@
   cachedBackups = null;
   toast(`Deleted ${ok}${fail > 0 ? ', ' + fail + ' failed' : ''}`, fail > 0 ? 'warning' : 'success');
   if (currentPage === 'backups') renderBackups();
+}
+
+function downloadBackupFile(project, env, name) {
+  window.open(`/api/backups/download/${encodeURIComponent(project)}/${encodeURIComponent(env)}/${encodeURIComponent(name)}?token=${encodeURIComponent(getToken())}`, '_blank');
 }
 
 async function uploadOffsiteBackup(project, env, name) {
@@ -1702,6 +1710,10 @@
   ih += '<span style="font-size:0.75rem;color:#6b7280;margin-left:auto;">content flows up</span>';
   ih += '</label>';
   ih += '</div>';
+  ih += '<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#d1d5db;cursor:pointer;margin-top:0.875rem;">';
+  ih += '<input type="checkbox" id="ops-sync-skip-backup" style="width:1rem;height:1rem;accent-color:#f59e0b;">';
+  ih += 'Skip safety backup <span style="font-size:0.75rem;color:#f59e0b;margin-left:0.25rem;">(faster, no pre-sync snapshot)</span>';
+  ih += '</label>';
 
   info.innerHTML = ih;
   startBtn.className = 'btn btn-primary btn-sm';
@@ -1871,7 +1883,9 @@
   if (type === 'promote') {
     url = '/api/promote/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) + '/' + encodeURIComponent(toEnv) + '?dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken());
   } else if (type === 'sync') {
-    url = '/api/sync/' + encodeURIComponent(project) + '?from=' + encodeURIComponent(fromEnv) + '&to=' + encodeURIComponent(toEnv) + '&dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken());
+    const skipBackupEl = document.getElementById('ops-sync-skip-backup');
+    const skipBackup = skipBackupEl ? skipBackupEl.checked : false;
+    url = '/api/sync/' + encodeURIComponent(project) + '?from=' + encodeURIComponent(fromEnv) + '&to=' + encodeURIComponent(toEnv) + '&dry_run=' + dryRun + (skipBackup ? '&skip_backup=true' : '') + '&token=' + encodeURIComponent(getToken());
   } else if (type === 'restart' || type === 'rebuild') {
     url = '/api/rebuild/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv)
       + '?action=' + encodeURIComponent(type) + '&token=' + encodeURIComponent(getToken());

--
Gitblit v1.3.1