diff --git a/apps/api/cmd/api-server/main.go b/apps/api/cmd/api-server/main.go index 332e7e7..7a48746 100644 --- a/apps/api/cmd/api-server/main.go +++ b/apps/api/cmd/api-server/main.go @@ -64,7 +64,6 @@ func main() { connRepo := connection.NewConnectionRepository(db, cryptoService) connService := connection.NewConnectionService(connRepo, connManager) - connHandler := connection.NewConnectionHandler(connService) authHandler := auth.NewAuthHandler(authService) authMiddleware := middleware.NewAuthMiddleware(secrets.JWTSecret) @@ -85,15 +84,6 @@ func main() { protected.Use(authMiddleware.RequireAuth) protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET", "OPTIONS") - protected.HandleFunc("/connections/test", connHandler.TestConnection).Methods("POST", "OPTIONS") - protected.HandleFunc("/connections/{id}/discover", connHandler.DiscoverDatabases).Methods("GET", "OPTIONS") - protected.HandleFunc("/connections/{id}/databases", connHandler.UpdateSelectedDatabases).Methods("PUT", "OPTIONS") - protected.HandleFunc("/connections/{id}", connHandler.GetConnection).Methods("GET", "OPTIONS") - protected.HandleFunc("/connections/{id}", connHandler.DeleteConnection).Methods("DELETE", "OPTIONS") - protected.HandleFunc("/connections", connHandler.SaveConnection).Methods("POST", "OPTIONS") - protected.HandleFunc("/connections", connHandler.ListConnections).Methods("GET", "OPTIONS") - protected.HandleFunc("/connections", connHandler.UpdateConnection).Methods("PUT", "OPTIONS") - backupRepo := backup.NewBackupRepository(db) settingsRepo := settings.NewSettingsRepository(db) notificationRepo := notification.NewNotificationRepository(db) @@ -108,6 +98,19 @@ func main() { cryptoService, ) + // Create connHandler after backupService is available + connHandler := connection.NewConnectionHandler(connService, backupService) + + protected.HandleFunc("/connections/test", connHandler.TestConnection).Methods("POST", "OPTIONS") + protected.HandleFunc("/connections/{id}/discover", connHandler.DiscoverDatabases).Methods("GET", "OPTIONS") + protected.HandleFunc("/connections/{id}/databases", connHandler.UpdateSelectedDatabases).Methods("PUT", "OPTIONS") + protected.HandleFunc("/connections/{id}/settings", connHandler.UpdateConnectionSettings).Methods("POST", "OPTIONS") + protected.HandleFunc("/connections/{id}", connHandler.GetConnection).Methods("GET", "OPTIONS") + protected.HandleFunc("/connections/{id}", connHandler.DeleteConnection).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/connections", connHandler.SaveConnection).Methods("POST", "OPTIONS") + protected.HandleFunc("/connections", connHandler.ListConnections).Methods("GET", "OPTIONS") + protected.HandleFunc("/connections", connHandler.UpdateConnection).Methods("PUT", "OPTIONS") + backupHandler := backup.NewBackupHandler(backupService) protected.HandleFunc("/backups/stats", backupHandler.GetBackupStats).Methods("GET", "OPTIONS") diff --git a/apps/api/internal/backup/backup_repository.go b/apps/api/internal/backup/backup_repository.go index 67ab866..4ec50ab 100644 --- a/apps/api/internal/backup/backup_repository.go +++ b/apps/api/internal/backup/backup_repository.go @@ -229,7 +229,7 @@ func (r *BackupRepository) UpdateBackupStatus(id string, status string) error { func (r *BackupRepository) GetBackupsOlderThan(connectionID string, cutoffTime time.Time) ([]*Backup, error) { rows, err := r.db.Query(` - SELECT id, path, created_at + SELECT id, path, s3_object_key, created_at FROM backups WHERE connection_id = $1 AND created_at < $2 @@ -244,7 +244,7 @@ func (r *BackupRepository) GetBackupsOlderThan(connectionID string, cutoffTime t for rows.Next() { backup := &Backup{} var createdAtStr string - err := rows.Scan(&backup.ID, &backup.Path, &createdAtStr) + err := rows.Scan(&backup.ID, &backup.Path, &backup.S3ObjectKey, &createdAtStr) if err != nil { return nil, err } @@ -472,3 +472,70 @@ func (r *BackupRepository) GetBackupStats(userID uuid.UUID) (*BackupStats, error return stats, nil } + +func (r *BackupRepository) GetBackupsByConnectionID(connectionID string) ([]*Backup, error) { + rows, err := r.db.Query(` + SELECT id, connection_id, schedule_id, status, path, s3_object_key, size, + started_time, completed_time, created_at, updated_at + FROM backups + WHERE connection_id = $1 + ORDER BY created_at DESC`, + connectionID) + if err != nil { + return nil, err + } + defer rows.Close() + + var backups []*Backup + for rows.Next() { + backup := &Backup{} + var startedTimeStr, createdAtStr, updatedAtStr string + var completedTimeStr sql.NullString + + err := rows.Scan( + &backup.ID, &backup.ConnectionID, &backup.ScheduleID, &backup.Status, + &backup.Path, &backup.S3ObjectKey, &backup.Size, + &startedTimeStr, &completedTimeStr, &createdAtStr, &updatedAtStr, + ) + if err != nil { + return nil, err + } + + backup.StartedTime, err = common.ParseTime(startedTimeStr) + if err != nil { + return nil, err + } + + if completedTimeStr.Valid { + completedTime, err := common.ParseTime(completedTimeStr.String) + if err != nil { + return nil, err + } + backup.CompletedTime = &completedTime + } + + backup.CreatedAt, err = common.ParseTime(createdAtStr) + if err != nil { + return nil, err + } + + backup.UpdatedAt, err = common.ParseTime(updatedAtStr) + if err != nil { + return nil, err + } + + backups = append(backups, backup) + } + + return backups, rows.Err() +} + + +func (r *BackupRepository) UpdateBackupS3ObjectKey(backupID string, s3ObjectKey string) error { + _, err := r.db.Exec(` + UPDATE backups + SET s3_object_key = $1, updated_at = datetime('now') + WHERE id = $2`, + s3ObjectKey, backupID) + return err +} diff --git a/apps/api/internal/backup/backup_scheduler.go b/apps/api/internal/backup/backup_scheduler.go index 2157d3e..8624f2b 100644 --- a/apps/api/internal/backup/backup_scheduler.go +++ b/apps/api/internal/backup/backup_scheduler.go @@ -1,6 +1,7 @@ package backup import ( + "context" "database/sql" "fmt" "os" @@ -125,13 +126,105 @@ func (s *BackupService) cleanupOldBackups(connectionID string, retentionDays int cutoffTime := time.Now().AddDate(0, 0, -retentionDays) oldBackups, err := s.backupRepo.GetBackupsOlderThan(connectionID, cutoffTime) if err != nil { + fmt.Printf("Error fetching old backups for cleanup: %v\n", err) return } + if len(oldBackups) == 0 { + return + } + + // Get connection to retrieve user settings for S3 + conn, err := s.connStorage.GetConnection(connectionID) + if err != nil { + fmt.Printf("Error getting connection for cleanup: %v\n", err) + return + } + + // Get user settings to check if S3 is enabled + userSettings, err := s.settingsService.GetUserSettingsInternal(conn.UserID) + if err != nil { + fmt.Printf("Warning: Failed to get user settings for cleanup: %v\n", err) + // Continue with local cleanup even if we can't get S3 settings + } + + // Initialize S3 client if S3 is enabled and configured + var s3Storage *S3Storage + if userSettings != nil && userSettings.S3Enabled && + userSettings.S3Endpoint != nil && *userSettings.S3Endpoint != "" && + userSettings.S3Bucket != nil && *userSettings.S3Bucket != "" && + userSettings.S3AccessKey != nil && *userSettings.S3AccessKey != "" && + userSettings.S3SecretKey != nil && *userSettings.S3SecretKey != "" { + + secretKey, err := s.cryptoService.Decrypt(*userSettings.S3SecretKey) + if err != nil { + fmt.Printf("Warning: Failed to decrypt S3 secret key for cleanup: %v\n", err) + } else { + region := "us-east-1" + if userSettings.S3Region != nil && *userSettings.S3Region != "" { + region = *userSettings.S3Region + } + + pathPrefix := "" + if userSettings.S3PathPrefix != nil { + pathPrefix = *userSettings.S3PathPrefix + } + + s3Config := S3Config{ + Endpoint: *userSettings.S3Endpoint, + Region: region, + Bucket: *userSettings.S3Bucket, + AccessKey: *userSettings.S3AccessKey, + SecretKey: secretKey, + UseSSL: userSettings.S3UseSSL, + PathPrefix: pathPrefix, + } + + s3Storage, err = NewS3Storage(s3Config) + if err != nil { + fmt.Printf("Warning: Failed to create S3 storage client for cleanup: %v\n", err) + s3Storage = nil + } + } + } + + // Clean up old backups + ctx := context.Background() for _, backup := range oldBackups { - os.Remove(backup.Path) - s.backupRepo.DeleteBackup(backup.ID.String()) + backupID := backup.ID.String() + + // Delete from S3 if object key exists, S3 is configured, and connection has S3 cleanup enabled + if backup.S3ObjectKey != nil && *backup.S3ObjectKey != "" && s3Storage != nil && conn.S3CleanupOnRetention { + if err := s3Storage.DeleteFile(ctx, *backup.S3ObjectKey); err != nil { + fmt.Printf("Warning: Failed to delete S3 object %s for backup %s: %v\n", + *backup.S3ObjectKey, backupID, err) + } else { + fmt.Printf("Deleted S3 object %s for backup %s (retention cleanup)\n", + *backup.S3ObjectKey, backupID) + } + } + + // Delete local file if it exists + if _, err := os.Stat(backup.Path); err == nil { + if err := os.Remove(backup.Path); err != nil { + fmt.Printf("Warning: Failed to delete local file %s for backup %s: %v\n", + backup.Path, backupID, err) + } else { + fmt.Printf("Deleted local file %s for backup %s (retention cleanup)\n", + backup.Path, backupID) + } + } + + // Delete backup record from database + if err := s.backupRepo.DeleteBackup(backupID); err != nil { + fmt.Printf("Error deleting backup record %s: %v\n", backupID, err) + } else { + fmt.Printf("Deleted backup record %s (retention cleanup)\n", backupID) + } } + + fmt.Printf("Retention cleanup completed: processed %d old backups for connection %s\n", + len(oldBackups), connectionID) } func (s *BackupService) DisableBackupSchedule(connectionID string) error { diff --git a/apps/api/internal/backup/backup_service.go b/apps/api/internal/backup/backup_service.go index 90f772f..76f2b5b 100644 --- a/apps/api/internal/backup/backup_service.go +++ b/apps/api/internal/backup/backup_service.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" "github.com/dendianugerah/velld/internal/common" @@ -497,3 +498,202 @@ func (s *BackupService) ensureBackupFileAvailable(backup *Backup, userID uuid.UU // Return temp file path and indicate it should be cleaned up return tempFilePath, true, nil } + + +// CleanupS3BackupsForConnection deletes all S3 backups for a specific connection +func (s *BackupService) CleanupS3BackupsForConnection(connectionID string) error { + // Get all backups for this connection + backups, err := s.backupRepo.GetBackupsByConnectionID(connectionID) + if err != nil { + return fmt.Errorf("failed to get backups for connection %s: %w", connectionID, err) + } + + if len(backups) == 0 { + return nil + } + + // Get connection to retrieve user settings + conn, err := s.connStorage.GetConnection(connectionID) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + + // Get user settings for S3 configuration + userSettings, err := s.settingsService.GetUserSettingsInternal(conn.UserID) + if err != nil { + return fmt.Errorf("failed to get user settings: %w", err) + } + + // Check if S3 is enabled and configured + if !userSettings.S3Enabled || + userSettings.S3Endpoint == nil || *userSettings.S3Endpoint == "" || + userSettings.S3Bucket == nil || *userSettings.S3Bucket == "" || + userSettings.S3AccessKey == nil || *userSettings.S3AccessKey == "" || + userSettings.S3SecretKey == nil || *userSettings.S3SecretKey == "" { + // S3 not configured, nothing to clean + return nil + } + + // Decrypt S3 secret key + secretKey, err := s.cryptoService.Decrypt(*userSettings.S3SecretKey) + if err != nil { + return fmt.Errorf("failed to decrypt S3 secret key: %w", err) + } + + // Configure S3 client + region := "us-east-1" + if userSettings.S3Region != nil && *userSettings.S3Region != "" { + region = *userSettings.S3Region + } + + pathPrefix := "" + if userSettings.S3PathPrefix != nil { + pathPrefix = *userSettings.S3PathPrefix + } + + s3Config := S3Config{ + Endpoint: *userSettings.S3Endpoint, + Region: region, + Bucket: *userSettings.S3Bucket, + AccessKey: *userSettings.S3AccessKey, + SecretKey: secretKey, + UseSSL: userSettings.S3UseSSL, + PathPrefix: pathPrefix, + } + + s3Storage, err := NewS3Storage(s3Config) + if err != nil { + return fmt.Errorf("failed to create S3 storage client: %w", err) + } + + // Delete S3 objects for all backups + ctx := context.Background() + deletedCount := 0 + for _, backup := range backups { + if backup.S3ObjectKey != nil && *backup.S3ObjectKey != "" { + if err := s3Storage.DeleteFile(ctx, *backup.S3ObjectKey); err != nil { + fmt.Printf("Warning: Failed to delete S3 object %s: %v\n", *backup.S3ObjectKey, err) + } else { + deletedCount++ + fmt.Printf("Deleted S3 object %s for backup %s (connection cleanup)\n", + *backup.S3ObjectKey, backup.ID) + } + } + } + + fmt.Printf("S3 cleanup completed for connection %s: deleted %d objects\n", connectionID, deletedCount) + return nil +} + + +// RenameS3FolderForConnection renames the S3 folder when connection name changes +func (s *BackupService) RenameS3FolderForConnection(connectionID string, oldName string, newName string) error { + // Get all backups for this connection + backups, err := s.backupRepo.GetBackupsByConnectionID(connectionID) + if err != nil { + return fmt.Errorf("failed to get backups: %w", err) + } + + if len(backups) == 0 { + return nil // No backups to rename + } + + // Get connection to retrieve user settings + conn, err := s.connStorage.GetConnection(connectionID) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + + // Get user settings for S3 configuration + userSettings, err := s.settingsService.GetUserSettingsInternal(conn.UserID) + if err != nil { + return fmt.Errorf("failed to get user settings: %w", err) + } + + // Check if S3 is enabled and configured + if !userSettings.S3Enabled || + userSettings.S3Endpoint == nil || *userSettings.S3Endpoint == "" || + userSettings.S3Bucket == nil || *userSettings.S3Bucket == "" || + userSettings.S3AccessKey == nil || *userSettings.S3AccessKey == "" || + userSettings.S3SecretKey == nil || *userSettings.S3SecretKey == "" { + return nil // S3 not configured, nothing to rename + } + + // Decrypt S3 secret key + secretKey, err := s.cryptoService.Decrypt(*userSettings.S3SecretKey) + if err != nil { + return fmt.Errorf("failed to decrypt S3 secret key: %w", err) + } + + // Configure S3 client + region := "us-east-1" + if userSettings.S3Region != nil && *userSettings.S3Region != "" { + region = *userSettings.S3Region + } + + pathPrefix := "" + if userSettings.S3PathPrefix != nil { + pathPrefix = *userSettings.S3PathPrefix + } + + s3Config := S3Config{ + Endpoint: *userSettings.S3Endpoint, + Region: region, + Bucket: *userSettings.S3Bucket, + AccessKey: *userSettings.S3AccessKey, + SecretKey: secretKey, + UseSSL: userSettings.S3UseSSL, + PathPrefix: pathPrefix, + } + + s3Storage, err := NewS3Storage(s3Config) + if err != nil { + return fmt.Errorf("failed to create S3 storage client: %w", err) + } + + // Sanitize old and new folder names + oldFolder := common.SanitizeConnectionName(oldName) + newFolder := common.SanitizeConnectionName(newName) + + if oldFolder == newFolder { + return nil // Names are the same after sanitization, no rename needed + } + + // Rename S3 objects + ctx := context.Background() + renamedCount := 0 + for _, backup := range backups { + if backup.S3ObjectKey == nil || *backup.S3ObjectKey == "" { + continue // No S3 object, skip + } + + oldKey := *backup.S3ObjectKey + + // Replace old folder with new folder in the object key + newKey := strings.Replace(oldKey, oldFolder, newFolder, 1) + + if oldKey == newKey { + continue // No change needed + } + + // Move object in S3 + if err := s3Storage.MoveFile(ctx, oldKey, newKey); err != nil { + fmt.Printf("Warning: Failed to rename S3 object %s to %s: %v\n", oldKey, newKey, err) + continue + } + + // Update database record with new S3 object key + backup.S3ObjectKey = &newKey + if err := s.backupRepo.UpdateBackupS3ObjectKey(backup.ID.String(), newKey); err != nil { + fmt.Printf("Warning: Failed to update S3 object key in database for backup %s: %v\n", backup.ID, err) + continue + } + + renamedCount++ + fmt.Printf("Renamed S3 object from %s to %s\n", oldKey, newKey) + } + + fmt.Printf("S3 folder rename completed for connection %s: renamed %d objects from %s to %s\n", + connectionID, renamedCount, oldFolder, newFolder) + return nil +} diff --git a/apps/api/internal/backup/s3_storage.go b/apps/api/internal/backup/s3_storage.go index 6518813..7054a0e 100644 --- a/apps/api/internal/backup/s3_storage.go +++ b/apps/api/internal/backup/s3_storage.go @@ -204,3 +204,29 @@ func (s *S3Storage) getObjectKeyWithPath(fileName string, subfolder string) stri return strings.Join(parts, "/") } + +// MoveFile moves/renames an object in S3 (copy then delete) +func (s *S3Storage) MoveFile(ctx context.Context, oldKey, newKey string) error { + // Copy to new location + src := minio.CopySrcOptions{ + Bucket: s.bucket, + Object: oldKey, + } + dst := minio.CopyDestOptions{ + Bucket: s.bucket, + Object: newKey, + } + + _, err := s.client.CopyObject(ctx, dst, src) + if err != nil { + return fmt.Errorf("failed to copy object: %w", err) + } + + // Delete old object + err = s.client.RemoveObject(ctx, s.bucket, oldKey, minio.RemoveObjectOptions{}) + if err != nil { + return fmt.Errorf("failed to delete old object: %w", err) + } + + return nil +} diff --git a/apps/api/internal/connection/connection.go b/apps/api/internal/connection/connection.go index d3a9076..b580870 100644 --- a/apps/api/internal/connection/connection.go +++ b/apps/api/internal/connection/connection.go @@ -2,6 +2,7 @@ package connection import ( "encoding/json" + "fmt" "net/http" "github.com/dendianugerah/velld/internal/common" @@ -9,13 +10,20 @@ import ( "github.com/gorilla/mux" ) +type BackupService interface { + CleanupS3BackupsForConnection(connectionID string) error + RenameS3FolderForConnection(connectionID string, oldName string, newName string) error +} + type ConnectionHandler struct { - service *ConnectionService + service *ConnectionService + backupService BackupService } -func NewConnectionHandler(service *ConnectionService) *ConnectionHandler { +func NewConnectionHandler(service *ConnectionService, backupService BackupService) *ConnectionHandler { return &ConnectionHandler{ - service: service, + service: service, + backupService: backupService, } } @@ -114,16 +122,57 @@ func (h *ConnectionHandler) UpdateConnection(w http.ResponseWriter, r *http.Requ return } + // Get existing connection to check if name changed + existingConn, err := h.service.GetConnection(config.ID) + if err != nil { + response.SendError(w, http.StatusInternalServerError, "Failed to get existing connection") + return + } + storedConn, err := h.service.UpdateConnection(config, userID) if err != nil { response.SendError(w, http.StatusInternalServerError, err.Error()) return } + // Rename S3 folder if connection name changed + if existingConn.Name != config.Name && h.backupService != nil { + if err := h.backupService.RenameS3FolderForConnection(config.ID, existingConn.Name, config.Name); err != nil { + fmt.Printf("Warning: Failed to rename S3 folder for connection %s: %v\n", config.ID, err) + // Continue even if S3 rename fails + } + } + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(storedConn) } +func (h *ConnectionHandler) UpdateConnectionSettings(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + if id == "" { + response.SendError(w, http.StatusBadRequest, "connection id is required") + return + } + + var req struct { + S3CleanupOnRetention *bool `json:"s3_cleanup_on_retention"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.SendError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := h.service.UpdateConnectionSettings(id, req.S3CleanupOnRetention); err != nil { + response.SendError(w, http.StatusInternalServerError, err.Error()) + return + } + + response.SendSuccess(w, "Connection settings updated successfully", nil) +} + func (h *ConnectionHandler) DeleteConnection(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] @@ -133,6 +182,17 @@ func (h *ConnectionHandler) DeleteConnection(w http.ResponseWriter, r *http.Requ return } + // Check if cleanup_s3 query parameter is provided + cleanupS3 := r.URL.Query().Get("cleanup_s3") == "true" + + // Cleanup S3 backups if requested + if cleanupS3 && h.backupService != nil { + if err := h.backupService.CleanupS3BackupsForConnection(id); err != nil { + fmt.Printf("Warning: Failed to cleanup S3 backups for connection %s: %v\n", id, err) + // Continue with connection deletion even if S3 cleanup fails + } + } + if err := h.service.DeleteConnection(id); err != nil { response.SendError(w, http.StatusInternalServerError, err.Error()) return diff --git a/apps/api/internal/connection/connection_repository.go b/apps/api/internal/connection/connection_repository.go index 055949a..a50b71d 100644 --- a/apps/api/internal/connection/connection_repository.go +++ b/apps/api/internal/connection/connection_repository.go @@ -56,14 +56,19 @@ func (r *ConnectionRepository) Save(conn StoredConnection) error { sshEnabledInt = 1 } + s3CleanupInt := 1 // default to true + if !conn.S3CleanupOnRetention { + s3CleanupInt = 0 + } + query := ` INSERT INTO connections ( id, name, type, host, port, username, password, database_name, ssl, database_size, created_at, updated_at, last_connected_at, user_id, status, ssh_enabled, ssh_host, - ssh_port, ssh_username, ssh_password, ssh_private_key + ssh_port, ssh_username, ssh_password, ssh_private_key, s3_cleanup_on_retention ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22 )` _, err = r.db.Exec( @@ -89,6 +94,7 @@ func (r *ConnectionRepository) Save(conn StoredConnection) error { conn.SSHUsername, sshPassword, sshPrivateKey, + s3CleanupInt, ) return err @@ -99,13 +105,14 @@ func (r *ConnectionRepository) GetConnection(id string) (*StoredConnection, erro var encryptedUsername, encryptedPassword string var encryptedSSHPassword, encryptedSSHPrivateKey sql.NullString var selectedDatabasesStr sql.NullString - var sslInt, sshEnabledInt int + var sslInt, sshEnabledInt, s3CleanupInt int query := `SELECT id, name, type, host, port, username, password, database_name, ssl, database_size, created_at, updated_at, last_connected_at, user_id, status, ssh_enabled, ssh_host, ssh_port, ssh_username, ssh_password, ssh_private_key, - COALESCE(selected_databases, '') as selected_databases + COALESCE(selected_databases, '') as selected_databases, + COALESCE(s3_cleanup_on_retention, 1) as s3_cleanup_on_retention FROM connections WHERE id = $1` err := r.db.QueryRow(query, id).Scan( @@ -131,6 +138,7 @@ func (r *ConnectionRepository) GetConnection(id string) (*StoredConnection, erro &encryptedSSHPassword, &encryptedSSHPrivateKey, &selectedDatabasesStr, + &s3CleanupInt, ) if err != nil { return nil, err @@ -138,6 +146,7 @@ func (r *ConnectionRepository) GetConnection(id string) (*StoredConnection, erro conn.SSL = sslInt != 0 conn.SSHEnabled = sshEnabledInt != 0 + conn.S3CleanupOnRetention = s3CleanupInt != 0 // Parse selected_databases from comma-separated string if selectedDatabasesStr.Valid && selectedDatabasesStr.String != "" { @@ -218,14 +227,19 @@ func (r *ConnectionRepository) Update(conn StoredConnection) error { sshEnabledInt = 1 } + s3CleanupInt := 0 + if conn.S3CleanupOnRetention { + s3CleanupInt = 1 + } + query := ` UPDATE connections SET name = $1, type = $2, host = $3, port = $4, username = $5, password = $6, database_name = $7, ssl = $8, ssh_enabled = $9, ssh_host = $10, ssh_port = $11, ssh_username = $12, ssh_password = $13, ssh_private_key = $14, - database_size = $15, updated_at = CURRENT_TIMESTAMP - WHERE id = $16` + database_size = $15, s3_cleanup_on_retention = $16, updated_at = CURRENT_TIMESTAMP + WHERE id = $17` _, err = r.db.Exec( query, @@ -244,6 +258,7 @@ func (r *ConnectionRepository) Update(conn StoredConnection) error { sshPassword, sshPrivateKey, conn.DatabaseSize, + s3CleanupInt, conn.ID, ) @@ -262,7 +277,8 @@ func (r *ConnectionRepository) ListByUserID(userID uuid.UUID) ([]ConnectionListI b.completed_time as last_backup_time, COALESCE(bs.enabled, false) as backup_enabled, bs.cron_schedule, - bs.retention_days + bs.retention_days, + COALESCE(c.s3_cleanup_on_retention, 1) as s3_cleanup_on_retention FROM connections c LEFT JOIN backup_schedules bs ON c.id = bs.connection_id AND bs.enabled = true LEFT JOIN backups b ON c.id = b.connection_id @@ -272,7 +288,7 @@ func (r *ConnectionRepository) ListByUserID(userID uuid.UUID) ([]ConnectionListI WHERE connection_id = c.id ) WHERE c.user_id = $1 - GROUP BY c.id, c.name, c.type, c.host, c.status, c.database_size, b.completed_time, bs.enabled, bs.cron_schedule, bs.retention_days + GROUP BY c.id, c.name, c.type, c.host, c.status, c.database_size, b.completed_time, bs.enabled, bs.cron_schedule, bs.retention_days, c.s3_cleanup_on_retention ` rows, err := r.db.Query(query, userID) @@ -287,6 +303,7 @@ func (r *ConnectionRepository) ListByUserID(userID uuid.UUID) ([]ConnectionListI var lastBackupTime sql.NullString var cronSchedule sql.NullString var retentionDays sql.NullInt64 + var s3CleanupInt int err := rows.Scan( &conn.ID, @@ -299,6 +316,7 @@ func (r *ConnectionRepository) ListByUserID(userID uuid.UUID) ([]ConnectionListI &conn.BackupEnabled, &cronSchedule, &retentionDays, + &s3CleanupInt, ) if err != nil { return nil, err @@ -314,6 +332,7 @@ func (r *ConnectionRepository) ListByUserID(userID uuid.UUID) ([]ConnectionListI days := int(retentionDays.Int64) conn.RetentionDays = &days } + conn.S3CleanupOnRetention = s3CleanupInt != 0 connections = append(connections, conn) } diff --git a/apps/api/internal/connection/connection_service.go b/apps/api/internal/connection/connection_service.go index 9292a77..96c8621 100644 --- a/apps/api/internal/connection/connection_service.go +++ b/apps/api/internal/connection/connection_service.go @@ -89,25 +89,37 @@ func (s *ConnectionService) UpdateConnection(config ConnectionConfig, userID uui dbSize = 0 // Set to 0 if we can't get the size } + // Get existing connection to preserve fields that aren't being updated + existingConn, err := s.repo.GetConnection(config.ID) + if err != nil { + return nil, fmt.Errorf("failed to get existing connection: %w", err) + } + storedConn := StoredConnection{ - ID: config.ID, - Name: config.Name, - Type: config.Type, - Host: config.Host, - Port: config.Port, - Username: config.Username, - Password: config.Password, - DatabaseName: config.Database, - SSL: config.SSL, - SSHEnabled: config.SSHEnabled, - SSHHost: config.SSHHost, - SSHPort: config.SSHPort, - SSHUsername: config.SSHUsername, - SSHPassword: config.SSHPassword, - SSHPrivateKey: config.SSHPrivateKey, - UserID: userID, - Status: "connected", - DatabaseSize: dbSize, + ID: config.ID, + Name: config.Name, + Type: config.Type, + Host: config.Host, + Port: config.Port, + Username: config.Username, + Password: config.Password, + DatabaseName: config.Database, + SSL: config.SSL, + SSHEnabled: config.SSHEnabled, + SSHHost: config.SSHHost, + SSHPort: config.SSHPort, + SSHUsername: config.SSHUsername, + SSHPassword: config.SSHPassword, + SSHPrivateKey: config.SSHPrivateKey, + UserID: userID, + Status: "connected", + DatabaseSize: dbSize, + S3CleanupOnRetention: existingConn.S3CleanupOnRetention, // preserve existing value + } + + // Update S3 cleanup setting if provided + if config.S3CleanupOnRetention != nil { + storedConn.S3CleanupOnRetention = *config.S3CleanupOnRetention } if err := s.repo.Update(storedConn); err != nil { @@ -117,6 +129,20 @@ func (s *ConnectionService) UpdateConnection(config ConnectionConfig, userID uui return &storedConn, nil } +// UpdateConnectionSettings updates connection settings without testing the connection +func (s *ConnectionService) UpdateConnectionSettings(id string, s3CleanupOnRetention *bool) error { + existingConn, err := s.repo.GetConnection(id) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + + if s3CleanupOnRetention != nil { + existingConn.S3CleanupOnRetention = *s3CleanupOnRetention + } + + return s.repo.Update(*existingConn) +} + func (s *ConnectionService) DeleteConnection(id string) error { return s.repo.Delete(id) } diff --git a/apps/api/internal/connection/model.go b/apps/api/internal/connection/model.go index 5a217a2..3ac2ba4 100644 --- a/apps/api/internal/connection/model.go +++ b/apps/api/internal/connection/model.go @@ -21,32 +21,34 @@ type StoredConnection struct { SSHHost string `json:"ssh_host"` SSHPort int `json:"ssh_port"` SSHUsername string `json:"ssh_username"` - SSHPassword string `json:"ssh_password"` - SSHPrivateKey string `json:"ssh_private_key"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - LastConnectedAt *time.Time `json:"last_connected_at"` - UserID uuid.UUID `json:"user_id"` - Status string `json:"status"` - DatabaseSize int64 `json:"database_size"` + SSHPassword string `json:"ssh_password"` + SSHPrivateKey string `json:"ssh_private_key"` + S3CleanupOnRetention bool `json:"s3_cleanup_on_retention"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + LastConnectedAt *time.Time `json:"last_connected_at"` + UserID uuid.UUID `json:"user_id"` + Status string `json:"status"` + DatabaseSize int64 `json:"database_size"` } type ConnectionConfig struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` - Database string `json:"database"` - SSL bool `json:"ssl"` - SSHEnabled bool `json:"ssh_enabled"` - SSHHost string `json:"ssh_host"` - SSHPort int `json:"ssh_port"` - SSHUsername string `json:"ssh_username"` - SSHPassword string `json:"ssh_password"` - SSHPrivateKey string `json:"ssh_private_key"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Database string `json:"database"` + SSL bool `json:"ssl"` + SSHEnabled bool `json:"ssh_enabled"` + SSHHost string `json:"ssh_host"` + SSHPort int `json:"ssh_port"` + SSHUsername string `json:"ssh_username"` + SSHPassword string `json:"ssh_password"` + SSHPrivateKey string `json:"ssh_private_key"` + S3CleanupOnRetention *bool `json:"s3_cleanup_on_retention,omitempty"` } type ConnectionStats struct { @@ -66,8 +68,9 @@ type ConnectionListItem struct { Host string `json:"host"` Status string `json:"status"` DatabaseSize int64 `json:"database_size"` - LastBackupTime *string `json:"last_backup_time"` - BackupEnabled bool `json:"backup_enabled"` - CronSchedule *string `json:"cron_schedule"` - RetentionDays *int `json:"retention_days"` + LastBackupTime *string `json:"last_backup_time"` + BackupEnabled bool `json:"backup_enabled"` + CronSchedule *string `json:"cron_schedule"` + RetentionDays *int `json:"retention_days"` + S3CleanupOnRetention bool `json:"s3_cleanup_on_retention"` } diff --git a/apps/api/internal/database/migrations/20251114111904_add_s3_cleanup_to_connections.sql b/apps/api/internal/database/migrations/20251114111904_add_s3_cleanup_to_connections.sql new file mode 100644 index 0000000..05bfb90 --- /dev/null +++ b/apps/api/internal/database/migrations/20251114111904_add_s3_cleanup_to_connections.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +SELECT 'Adding s3_cleanup_on_retention to connections table'; + +ALTER TABLE connections ADD COLUMN s3_cleanup_on_retention INTEGER DEFAULT 1; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +SELECT 'Removing s3_cleanup_on_retention from connections table'; + +ALTER TABLE connections DROP COLUMN s3_cleanup_on_retention; + +-- +goose StatementEnd diff --git a/apps/web/components/views/connections/connection-list/backup-schedule-dialog.tsx b/apps/web/components/views/connections/connection-list/backup-schedule-dialog.tsx index 4bdf8a8..f884ee2 100644 --- a/apps/web/components/views/connections/connection-list/backup-schedule-dialog.tsx +++ b/apps/web/components/views/connections/connection-list/backup-schedule-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Clock, Calendar } from 'lucide-react'; +import { Clock, Calendar, Cloud } from 'lucide-react'; import { Dialog, DialogContent, @@ -18,6 +18,9 @@ import { } from "@/components/ui/select"; import type { Connection } from '@/types/connection'; import { useBackup } from '@/hooks/use-backup'; +import { useSettings } from '@/hooks/use-settings'; +import { updateConnectionSettings } from '@/lib/api/connections'; +import { useToast } from '@/hooks/use-toast'; import { useState, useEffect } from 'react'; import { getScheduleFrequency } from '@/lib/helper'; @@ -48,7 +51,10 @@ export function BackupScheduleDialog({ onClose, }: BackupScheduleDialogProps) { const { createSchedule, updateExistingSchedule, disableSchedule, isScheduling, isDisabling, isUpdating } = useBackup(); + const { settings } = useSettings(); + const { toast } = useToast(); const [enabled, setEnabled] = useState(false); + const [s3Cleanup, setS3Cleanup] = useState(true); const getRetentionFromDays = (days?: number | null) => { if (!days) return '30'; @@ -63,6 +69,7 @@ export function BackupScheduleDialog({ setEnabled(connection.backup_enabled); setSchedule(getScheduleFrequency(connection.cron_schedule) || 'daily'); setRetention(getRetentionFromDays(connection.retention_days)); + setS3Cleanup(connection.s3_cleanup_on_retention ?? true); } }, [connection]); @@ -182,6 +189,47 @@ export function BackupScheduleDialog({ + + {settings?.s3_enabled && ( +
+ + {settings?.s3_enabled && ( +
+ Permanently delete all backups stored in S3 for this connection +
+This action cannot be undone. This will permanently delete the connection and all associated backup schedules.