From 133b80a859e7dbbd7ce1c7b3f0631db6be226c77 Mon Sep 17 00:00:00 2001 From: Giorgio Ravera Date: Sun, 31 May 2026 00:16:14 +0200 Subject: [PATCH] Added possitiblity to upload backups --- backend/routes/backup.py | 91 ++++++++++++++++++++++++++++++++++++++++ frontend/css/layout.css | 4 +- frontend/js/api.js | 39 ++++++++++++++++- frontend/js/index.js | 91 +++++++++++++++++++++++++++++----------- frontend/js/services.js | 35 ++++++++++++---- frontend/modals.html | 13 +++++- 6 files changed, 237 insertions(+), 36 deletions(-) diff --git a/backend/routes/backup.py b/backend/routes/backup.py index 9166b34..0fcf2b7 100644 --- a/backend/routes/backup.py +++ b/backend/routes/backup.py @@ -1,11 +1,14 @@ # backend/routes/backup.py # import standard modules +from fastapi import APIRouter, HTTPException, status, UploadFile, File from fastapi.responses import Response, JSONResponse, FileResponse from pathlib import Path from pydantic import BaseModel +import shutil import time from typing import Dict, Any +import zipfile # Import local modules from backend.backup import backup_create, backup_list, backup_restore, backup_delete @@ -251,3 +254,91 @@ def download_backup(backup_id: str): filename=zip_path.name, media_type="application/zip" ) + +# --------------------------------------------------------- +# API: Upload backup +# --------------------------------------------------------- +@router.post("/api/backup/upload") +def upload_backup(file: UploadFile = File(...)): + + # Initialization + start_ns = time.monotonic_ns() + + if not file.filename.endswith(".zip"): + took_ms = (time.monotonic_ns() - start_ns) / 1_000_000 + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "BACKUP_UPLOAD_FAILED", + "status": "failure", + "message": "Only ZIP files allowed", + "took_ms": took_ms, + }, + ) + + backup_dir = Path(settings.BACKUP_PATH) + backup_dir.mkdir(parents=True, exist_ok=True) + + # safe filename + safe_name = Path(file.filename).name + target_path = backup_dir / safe_name + + # prevent overwrite + if target_path.exists(): + took_ms = (time.monotonic_ns() - start_ns) / 1_000_000 + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "BACKUP_UPLOAD_FAILED", + "status": "failure", + "message": "Backup already exists", + "took_ms": took_ms, + }, + ) + + # validate ZIP + import zipfile + try: + with zipfile.ZipFile(file.file) as z: + if z.testzip() is not None: + took_ms = (time.monotonic_ns() - start_ns) / 1_000_000 + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "BACKUP_UPLOAD_FAILED", + "status": "failure", + "message": "Corrupted ZIP file", + "took_ms": took_ms, + }, + ) + + except zipfile.BadZipFile: + took_ms = (time.monotonic_ns() - start_ns) / 1_000_000 + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "BACKUP_UPLOAD_FAILED", + "status": "failure", + "message": "Invalid ZIP file", + "took_ms": took_ms, + }, + ) + + # reset pointer after validation + file.file.seek(0) + + # save file + with target_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + backup_id = safe_name.replace(".zip", "") + + took_ms = (time.monotonic_ns() - start_ns) / 1_000_000 + + return { + "code": "BACKUP_UPLOADED", + "status": "success", + "message": "Backup uploaded successfully", + "took_ms": took_ms, + "backup_id": backup_id, + } diff --git a/frontend/css/layout.css b/frontend/css/layout.css index 8c11c64..2f1df39 100644 --- a/frontend/css/layout.css +++ b/frontend/css/layout.css @@ -294,13 +294,13 @@ select.form-select:focus, Icons ================================ */ .icon { - color: var(--icon-color); + color: currentColor; display: inline-flex; align-items: center; } .icon:hover { - color: var(--icon-color-hover); + color: inherit; } .icon-static { diff --git a/frontend/js/api.js b/frontend/js/api.js index bd64428..4bd35b2 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -119,7 +119,7 @@ export async function apiDownload(url, errorPrefix = 'Download error') { const msg = data?.detail?.message || data?.message || - "Download failed"; + errorPrefix; throw new Error(`${errorPrefix}: ${msg}`); } @@ -133,3 +133,40 @@ export async function apiDownload(url, errorPrefix = 'Download error') { return res; } + +// ------------------------------------------------------- +// API Upload (multipart/form-data) +// ------------------------------------------------------- +export async function apiUpload(url, file, errorPrefix = "Upload error") { + try { + const formData = new FormData(); + formData.append("file", file); + + const res = await fetch(url, { + method: "POST", + body: formData + }); + + if (!res.ok) { + let err; + try { + err = await res.json(); + } catch { + throw new Error(errorPrefix); + } + + const msg = + err?.detail?.message || + err?.message || + errorPrefix; + + throw new Error(msg); + } + + return await res.json(); + + } catch (err) { + console.error(err); + throw new Error(err.message || errorPrefix); + } +} diff --git a/frontend/js/index.js b/frontend/js/index.js index 655ec1b..72c024a 100644 --- a/frontend/js/index.js +++ b/frontend/js/index.js @@ -2,7 +2,7 @@ // IMPORT // ------------------------------------------------------- import { loadModals, showToast } from './common.js'; -import { serviceCheckAbount, serviceCheckHealth , serviceReloadDNS, serviceReloadDHCP, serviceBackupCreate, serviceBackupList, serviceBackupRestore, serviceDownloadBackup, serviceDeleteBackup} from './services.js'; +import { serviceCheckAbount, serviceCheckHealth , serviceReloadDNS, serviceReloadDHCP, serviceBackupCreate, serviceBackupList, serviceBackupRestore, serviceDeleteBackup, serviceDownloadBackup, serviceUploadBackup } from './services.js'; // ------------------------------------------------------- // BACKUP MODAL OPEN/CLOSE @@ -323,27 +323,6 @@ const actionHandlers = { btn.disabled = false; } }, - // Download Backup - downloadBackup: async (e, el) => { - e.stopPropagation(); - - const id = el.dataset.id; - if (!id) return; - - try { - const result = await serviceDownloadBackup(id); - - const msg = (typeof result === 'object' && result?.message) - ? result.message - : 'Backup downloaded successfully'; - - showToast(msg, true); - - } catch (err) { - console.error(err); - showToast(err?.message || "Error downloading backup", false); - } - }, // Delete Backup deleteBackup: async (e, el) => { @@ -375,16 +354,80 @@ const actionHandlers = { }, refreshBackupList: async () => { try { - const data = await serviceBackupList(); + const result = await serviceBackupList(); const msg = (typeof result === 'object' && result?.message) ? result.message : 'Backup list refreshed successfully'; showToast(msg, true); - renderBackupList(data); + renderBackupList(result); } catch (err) { showToast(err?.message || "Error refreshing backup list", false); } }, + // Download Backup + downloadBackup: async (e, el) => { + e.stopPropagation(); + + const id = el.dataset.id; + if (!id) return; + + try { + const result = await serviceDownloadBackup(id); + + const msg = (typeof result === 'object' && result?.message) + ? result.message + : 'Backup downloaded successfully'; + + showToast(msg, true); + + } catch (err) { + console.error(err); + showToast(err?.message || "Error downloading backup", false); + } + }, + // Upload Backup + uploadBackup: async (e, el) => { + const input = document.getElementById('backupUploadInput'); + if (!input?.files?.length) { + showToast("Select a file first", false); + return; + } + + const file = input.files[0]; + + const icon = el.querySelector('.icon'); + const originalClass = icon?.className; + + el.disabled = true; + if (icon) { + icon.className = "spinner-border spinner-border-sm icon"; + } + + try { + const result = await serviceUploadBackup(file); + + const msg = (result?.message) + ? result.message + : 'Backup uploaded successfully'; + + showToast(msg, true); + + console.log("Uploaded backup ID:", result?.backup_id); + + input.value = ''; + + const data = await serviceBackupList(); + renderBackupList(data); + + } catch (err) { + showToast(err?.message || "Error uploading backup", false); + } finally { + if (icon && originalClass) { + icon.className = originalClass; + } + el.disabled = false; + } + }, openBackupModal, // managed by boostrap closeBackupModal, // managed by boostrap // Reload DNS diff --git a/frontend/js/services.js b/frontend/js/services.js index 7a36686..9a508a4 100644 --- a/frontend/js/services.js +++ b/frontend/js/services.js @@ -1,5 +1,5 @@ // import api -import { apiRequest, apiGet, apiPost, apiDownload } from './api.js'; +import { apiRequest, apiGet, apiPost, apiDownload, apiUpload } from './api.js'; // ------------------------------------------------------- // Check Abount @@ -279,6 +279,19 @@ export async function serviceBackupRestore(id) { return false; } +// ------------------------------------------------------- +// Delete a Backup +// ------------------------------------------------------- +export async function serviceDeleteBackup(id) { + const data = await apiPost( + "/api/backup/delete", + { backup_id: id }, + "Error performing delete" + ); + + return data?.message ? { message: data.message } : true; +} + // ------------------------------------------------------- // Download a Backup // ------------------------------------------------------- @@ -303,14 +316,20 @@ export async function serviceDownloadBackup(id) { } // ------------------------------------------------------- -// Delete a Backup +// Upload a Backup // ------------------------------------------------------- -export async function serviceDeleteBackup(id) { - const data = await apiPost( - "/api/backup/delete", - { backup_id: id }, - "Error performing delete" +export async function serviceUploadBackup(file) { + const data = await apiUpload( + "/api/backup/upload", + file, + "Error uploading backup" ); - return data?.message ? { message: data.message } : true; + if (data?.status === 'success') { + return data?.message + ? { message: data.message, backup_id: data.backup_id } + : true; + } + + return false; } diff --git a/frontend/modals.html b/frontend/modals.html index e3ec26f..726224c 100644 --- a/frontend/modals.html +++ b/frontend/modals.html @@ -169,12 +169,23 @@ - + Select one backup to restore. + +