Skip to content

Add one-click backup and restore functionality for system configuration and jobs #527

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@
# Data generated by the app
/cmd/def/data
/cmd/svc/data
/data
main
153 changes: 153 additions & 0 deletions pkg/backup/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package backup

import (
"encoding/json"
"fmt"
"time"

"github.com/plaenkler/ddns-updater/pkg/cipher"
"github.com/plaenkler/ddns-updater/pkg/config"
"github.com/plaenkler/ddns-updater/pkg/database"
"github.com/plaenkler/ddns-updater/pkg/database/model"
log "github.com/plaenkler/ddns-updater/pkg/logging"
)

// BackupData represents the complete backup structure
type BackupData struct {
Version string `json:"version"`
Timestamp time.Time `json:"timestamp"`
Config *config.Config `json:"config"`
Jobs []BackupJob `json:"jobs"`
}

// BackupJob represents a job in the backup with decrypted parameters
type BackupJob struct {
Provider string `json:"provider"`
Params string `json:"params"` // decrypted
}

// Export creates a complete backup of the system configuration and jobs
func Export() (*BackupData, error) {
// Get current configuration
cfg := config.Get()
if cfg == nil {
log.Errorf("could not get configuration")
return nil, fmt.Errorf("could not get configuration")
}

// Get database connection
db := database.GetDatabase()
if db == nil {
log.Errorf("could not get database connection")
return nil, fmt.Errorf("could not get database connection")
}

// Retrieve all jobs from database
var jobs []model.SyncJob
if err := db.Find(&jobs).Error; err != nil {
log.Errorf("could not retrieve jobs from database: %s", err.Error())
return nil, fmt.Errorf("could not retrieve jobs from database: %s", err.Error())
}

// Decrypt job parameters for backup
var backupJobs []BackupJob
for _, job := range jobs {
decryptedParams, err := cipher.Decrypt(job.Params)
if err != nil {
log.Errorf("could not decrypt params for job %d: %s", job.ID, err.Error())
continue
}

backupJobs = append(backupJobs, BackupJob{
Provider: job.Provider,
Params: string(decryptedParams),
})
}

backup := &BackupData{
Version: "1.0",
Timestamp: time.Now(),
Config: cfg,
Jobs: backupJobs,
}

return backup, nil
}

// Import restores the system configuration and jobs from backup data
func Import(backupData *BackupData) error {
if backupData.Version != "1.0" {
log.Errorf("unsupported backup version: %s", backupData.Version)
return fmt.Errorf("unsupported backup version: %s", backupData.Version)
}

// Get database connection
db := database.GetDatabase()
if db == nil {
log.Errorf("could not get database connection")
return fmt.Errorf("could not get database connection")
}

// Begin transaction
tx := db.Begin()
if tx.Error != nil {
log.Errorf("could not begin transaction: %s", tx.Error.Error())
return fmt.Errorf("could not begin transaction: %s", tx.Error.Error())
}

// Clear existing jobs
if err := tx.Unscoped().Delete(&model.SyncJob{}, "1 = 1").Error; err != nil {
tx.Rollback()
log.Errorf("could not clear existing jobs: %s", err.Error())
return fmt.Errorf("could not clear existing jobs: %s", err.Error())
}

// Restore jobs
for _, backupJob := range backupData.Jobs {
// Encrypt parameters for storage
encryptedParams, err := cipher.Encrypt(backupJob.Params)
if err != nil {
log.Errorf("could not encrypt params for job with provider %s: %s", backupJob.Provider, err.Error())
continue
}

job := model.SyncJob{
Provider: backupJob.Provider,
Params: encryptedParams,
}

if err := tx.Create(&job).Error; err != nil {
log.Errorf("could not create job with provider %s: %s", backupJob.Provider, err.Error())
continue
}
}

// Commit transaction
if err := tx.Commit().Error; err != nil {
log.Errorf("could not commit transaction: %s", err.Error())
return fmt.Errorf("could not commit transaction: %s", err.Error())
}

// Update configuration
if err := config.Update(backupData.Config); err != nil {
log.Errorf("could not update configuration: %s", err.Error())
return fmt.Errorf("could not update configuration: %s", err.Error())
}

log.Infof("successfully imported backup with %d jobs", len(backupData.Jobs))
return nil
}

// Marshal converts backup data to JSON
func (b *BackupData) Marshal() ([]byte, error) {
return json.MarshalIndent(b, "", " ")
}

// UnmarshalBackupData parses JSON backup data
func UnmarshalBackupData(data []byte) (*BackupData, error) {
var backup BackupData
if err := json.Unmarshal(data, &backup); err != nil {
return nil, err
}
return &backup, nil
}
95 changes: 95 additions & 0 deletions pkg/server/routes/api/backup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package api

import (
"fmt"
"io"
"net/http"

"github.com/plaenkler/ddns-updater/pkg/backup"
log "github.com/plaenkler/ddns-updater/pkg/logging"
)

// ExportBackup handles the backup export API endpoint
func ExportBackup(w http.ResponseWriter, r *http.Request) {
// Export backup data
backupData, err := backup.Export()
if err != nil {
log.Errorf("could not export backup: %s", err.Error())
http.Error(w, "Could not export backup", http.StatusInternalServerError)
return
}

// Convert to JSON
jsonData, err := backupData.Marshal()
if err != nil {
log.Errorf("could not marshal backup data: %s", err.Error())
http.Error(w, "Could not marshal backup data", http.StatusInternalServerError)
return
}

// Set headers for file download
filename := fmt.Sprintf("ddns-backup-%s.json", backupData.Timestamp.Format("2006-01-02-15-04-05"))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(jsonData)))

// Write JSON data
_, err = w.Write(jsonData)
if err != nil {
log.Errorf("could not write backup data: %s", err.Error())
return
}

log.Infof("exported backup with %d jobs", len(backupData.Jobs))
}

// ImportBackup handles the backup import API endpoint
func ImportBackup(w http.ResponseWriter, r *http.Request) {
// Parse multipart form
err := r.ParseMultipartForm(10 << 20) // 10 MB limit
if err != nil {
log.Errorf("could not parse multipart form: %s", err.Error())
http.Error(w, "Could not parse form", http.StatusBadRequest)
return
}

// Get uploaded file
file, _, err := r.FormFile("backup")
if err != nil {
log.Errorf("could not get uploaded file: %s", err.Error())
http.Error(w, "Could not get uploaded file", http.StatusBadRequest)
return
}
defer file.Close()

// Read file content
fileContent, err := io.ReadAll(file)
if err != nil {
log.Errorf("could not read file content: %s", err.Error())
http.Error(w, "Could not read file content", http.StatusInternalServerError)
return
}

// Parse backup data
backupData, err := backup.UnmarshalBackupData(fileContent)
if err != nil {
log.Errorf("could not parse backup data: %s", err.Error())
http.Error(w, "Invalid backup file format", http.StatusBadRequest)
return
}

// Import backup data
err = backup.Import(backupData)
if err != nil {
log.Errorf("could not import backup: %s", err.Error())
http.Error(w, "Could not import backup", http.StatusInternalServerError)
return
}

// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"success": true, "message": "Backup imported successfully"}`))

log.Infof("imported backup with %d jobs", len(backupData.Jobs))
}
8 changes: 2 additions & 6 deletions pkg/server/routes/web/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,8 @@ func ProvideIndex(w http.ResponseWriter, r *http.Request) {
}
addr, err := ddns.GetPublicIP()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, err = fmt.Fprintf(w, "could not get public IP address: %s", err.Error())
if err != nil {
log.Errorf("failed to write response: %v", err)
}
return
log.Errorf("could not get public IP address: %s", err.Error())
addr = "Unable to resolve IP"
}
img, err := totps.GetKeyAsQR()
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions pkg/server/routes/web/static/html/pages/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<body class="bg-dark">
{{template "navbar" .}}
{{template "config-modal" .}}
{{template "backup-modal" .}}
{{template "add-modal" .}}
{{template "edit-modal" .}}
<div class="table-container">
Expand Down
54 changes: 54 additions & 0 deletions pkg/server/routes/web/static/html/partials/modals.html
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,57 @@ <h5 class="modal-title" id="edit-modal-label">Edit Job</h5>
</div>
</div>
{{end}}

{{define "backup-modal"}}
<div
class="modal fade dark-mode"
id="backup-modal"
tabindex="-1"
aria-labelledby="backup-modal-label"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light">
<div class="modal-header">
<h5 class="modal-title text-light" id="backup-modal-label">
Backup & Restore
</h5>
<button
type="button"
class="btn-close btn-close-white"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div class="mb-4">
<h6 class="text-light mb-3">Export Configuration</h6>
<p class="text-muted small">Download a complete backup of your system configuration and all DDNS jobs.</p>
<button type="button" class="btn btn-primary" id="export-backup-btn">
<i class="bi bi-download"></i> Download Backup
</button>
</div>
<hr class="text-muted">
<div>
<h6 class="text-light mb-3">Import Configuration</h6>
<p class="text-muted small">Upload a backup file to restore your system configuration and DDNS jobs. This will replace all current data.</p>
<form id="import-backup-form" enctype="multipart/form-data">
<div class="mb-3">
<input
type="file"
class="form-control bg-white text-dark"
id="backup-file-input"
accept=".json"
required
/>
</div>
<button type="submit" class="btn btn-warning">
<i class="bi bi-upload"></i> Restore Backup
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}
9 changes: 9 additions & 0 deletions pkg/server/routes/web/static/html/partials/navbar.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
IP-Address: {{.IPAddress}}
</p>
<div class="d-flex">
<button
type="button"
class="btn btn-warning me-2"
data-bs-toggle="modal"
data-bs-target="#backup-modal"
title="Backup & Restore"
>
<i class="bi bi-cloud-arrow-up-down"></i>
</button>
<button
type="button"
class="btn btn-danger me-2"
Expand Down
Loading