diff --git a/.gitignore b/.gitignore index 834b81e..b2c36ca 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ # Data generated by the app /cmd/def/data /cmd/svc/data +/data +main diff --git a/pkg/backup/service.go b/pkg/backup/service.go new file mode 100644 index 0000000..a8b74a2 --- /dev/null +++ b/pkg/backup/service.go @@ -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 +} \ No newline at end of file diff --git a/pkg/server/routes/api/backup.go b/pkg/server/routes/api/backup.go new file mode 100644 index 0000000..c6f0967 --- /dev/null +++ b/pkg/server/routes/api/backup.go @@ -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)) +} \ No newline at end of file diff --git a/pkg/server/routes/web/index.go b/pkg/server/routes/web/index.go index e230aef..d5e1414 100644 --- a/pkg/server/routes/web/index.go +++ b/pkg/server/routes/web/index.go @@ -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 { diff --git a/pkg/server/routes/web/static/html/pages/index.html b/pkg/server/routes/web/static/html/pages/index.html index 06c459f..8385f7e 100644 --- a/pkg/server/routes/web/static/html/pages/index.html +++ b/pkg/server/routes/web/static/html/pages/index.html @@ -10,6 +10,7 @@ {{template "navbar" .}} {{template "config-modal" .}} + {{template "backup-modal" .}} {{template "add-modal" .}} {{template "edit-modal" .}}
diff --git a/pkg/server/routes/web/static/html/partials/modals.html b/pkg/server/routes/web/static/html/partials/modals.html index 5bb0d0f..30a40a2 100644 --- a/pkg/server/routes/web/static/html/partials/modals.html +++ b/pkg/server/routes/web/static/html/partials/modals.html @@ -222,3 +222,57 @@
{{end}} + +{{define "backup-modal"}} + +{{end}} diff --git a/pkg/server/routes/web/static/html/partials/navbar.html b/pkg/server/routes/web/static/html/partials/navbar.html index 56bd7cb..279dca1 100644 --- a/pkg/server/routes/web/static/html/partials/navbar.html +++ b/pkg/server/routes/web/static/html/partials/navbar.html @@ -6,6 +6,15 @@ IP-Address: {{.IPAddress}}

+