feat: Implement configuration and job duplication functionality

- Added duplication feature for configurations and jobs, allowing users to create copies of existing entries.
- Introduced new buttons in the UI for duplicating configurations and jobs, enhancing user experience.
- Implemented backend logic to handle duplication requests, ensuring proper ownership checks and data integrity.
- Added notifications for successful duplication actions to inform users of the outcome.
- Updated relevant templates and handlers to support the new duplication functionality.
This commit is contained in:
StarFleetCPTN
2025-03-22 22:19:05 -07:00
parent 49a586db53
commit db6259f8a3
20 changed files with 2433 additions and 347 deletions
+142
View File
@@ -427,3 +427,145 @@ func (h *Handlers) HandleDeleteConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
}
// HandleDuplicateConfig handles the POST /configs/:id/duplicate route
func (h *Handlers) HandleDuplicateConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var originalConfig db.TransferConfig
if err := h.DB.First(&originalConfig, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user owns this config
if originalConfig.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to duplicate this config"})
return
}
}
// Create a duplicate config
duplicateConfig := originalConfig
duplicateConfig.ID = 0 // Set ID to 0 to create a new record
duplicateConfig.Name = originalConfig.Name + " - Copy"
duplicateConfig.CreatedAt = time.Now()
duplicateConfig.UpdatedAt = time.Now()
duplicateConfig.CreatedBy = userID
// Deep copy all boolean pointers
skipProcessedVal := *originalConfig.SkipProcessedFiles
duplicateConfig.SkipProcessedFiles = &skipProcessedVal
archiveEnabledVal := *originalConfig.ArchiveEnabled
duplicateConfig.ArchiveEnabled = &archiveEnabledVal
deleteAfterTransferVal := *originalConfig.DeleteAfterTransfer
duplicateConfig.DeleteAfterTransfer = &deleteAfterTransferVal
sourcePassiveModeVal := *originalConfig.SourcePassiveMode
duplicateConfig.SourcePassiveMode = &sourcePassiveModeVal
destPassiveModeVal := *originalConfig.DestPassiveMode
duplicateConfig.DestPassiveMode = &destPassiveModeVal
// Google Photos specific fields
if originalConfig.DestReadOnly != nil {
destReadOnlyVal := *originalConfig.DestReadOnly
duplicateConfig.DestReadOnly = &destReadOnlyVal
}
if originalConfig.SourceReadOnly != nil {
sourceReadOnlyVal := *originalConfig.SourceReadOnly
duplicateConfig.SourceReadOnly = &sourceReadOnlyVal
}
if originalConfig.DestIncludeArchived != nil {
destIncludeArchivedVal := *originalConfig.DestIncludeArchived
duplicateConfig.DestIncludeArchived = &destIncludeArchivedVal
}
if originalConfig.SourceIncludeArchived != nil {
sourceIncludeArchivedVal := *originalConfig.SourceIncludeArchived
duplicateConfig.SourceIncludeArchived = &sourceIncludeArchivedVal
}
if originalConfig.UseBuiltinAuthSource != nil {
useBuiltinAuthSourceVal := *originalConfig.UseBuiltinAuthSource
duplicateConfig.UseBuiltinAuthSource = &useBuiltinAuthSourceVal
}
if originalConfig.UseBuiltinAuthDest != nil {
useBuiltinAuthDestVal := *originalConfig.UseBuiltinAuthDest
duplicateConfig.UseBuiltinAuthDest = &useBuiltinAuthDestVal
}
// Start a transaction
tx := h.DB.Begin()
if tx.Error != nil {
log.Printf("Error beginning transaction: %v", tx.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
if err := tx.Create(&duplicateConfig).Error; err != nil {
tx.Rollback()
log.Printf("Error creating duplicate config: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create duplicate config: %v", err)})
return
}
// Create audit log entry
auditDetails := map[string]interface{}{
"name": duplicateConfig.Name,
"source_type": duplicateConfig.SourceType,
"dest_type": duplicateConfig.DestinationType,
"source_path": duplicateConfig.SourcePath,
"dest_path": duplicateConfig.DestinationPath,
"skip_processed_files": *duplicateConfig.SkipProcessedFiles,
"archive_enabled": *duplicateConfig.ArchiveEnabled,
"delete_after_transfer": *duplicateConfig.DeleteAfterTransfer,
"source_passive_mode": *duplicateConfig.SourcePassiveMode,
"dest_passive_mode": *duplicateConfig.DestPassiveMode,
"duplicated_from": originalConfig.ID,
}
auditLog := db.AuditLog{
Action: "duplicate",
EntityType: "config",
EntityID: duplicateConfig.ID,
UserID: userID,
Details: auditDetails,
Timestamp: time.Now(),
}
if err := tx.Create(&auditLog).Error; err != nil {
tx.Rollback()
log.Printf("Error creating audit log: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create audit log"})
return
}
// Commit the transaction
if err := tx.Commit().Error; err != nil {
log.Printf("Error committing transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
return
}
// Generate rclone config file for the duplicate
if err := h.DB.GenerateRcloneConfig(&duplicateConfig); err != nil {
log.Printf("Warning: Failed to generate rclone config for duplicate: %v", err)
// Continue anyway, as the config was created in the database
} else {
log.Printf("Generated rclone config for duplicate config ID %d", duplicateConfig.ID)
}
// Return with full page reload to show the new config
c.Header("HX-Refresh", "true")
c.JSON(http.StatusOK, gin.H{"message": "Config duplicated successfully"})
}
+84
View File
@@ -733,3 +733,87 @@ func (h *Handlers) HandleRunJob(c *gin.Context) {
successScript := fmt.Sprintf("<script>window.notyfInstance.success('Job \"%s\" has been started successfully')</script>", jobName)
c.String(http.StatusOK, successScript)
}
// HandleDuplicateJob handles duplication of a job
func (h *Handlers) HandleDuplicateJob(c *gin.Context) {
// Get the job ID from the URL
idParam := c.Param("id")
id, err := strconv.ParseUint(idParam, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
return
}
userID := c.GetUint("userID")
// Get the original job
var originalJob db.Job
if err := h.DB.First(&originalJob, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Create a new job as a copy of the original
newJob := originalJob
newJob.ID = 0 // Reset ID to create a new record
newJob.Name = originalJob.Name + " - Copy"
newJob.CreatedAt = time.Now()
newJob.UpdatedAt = time.Now()
// Reset execution specific fields
newJob.LastRun = nil
newJob.NextRun = nil
// Save the new job
if err := h.DB.Create(&newJob).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create duplicate job: " + err.Error()})
return
}
// If the job has associated configs, duplicate those associations
configIDs := originalJob.GetConfigIDsList()
if len(configIDs) > 0 {
// Set the new job's config IDs
newJob.SetConfigIDsList(configIDs)
if err := h.DB.Save(&newJob).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update config associations: " + err.Error()})
return
}
}
// Create audit log entry
auditDetails := map[string]interface{}{
"name": newJob.Name,
"original_job_id": originalJob.ID,
"new_job_id": newJob.ID,
"schedule": newJob.Schedule,
"enabled": newJob.GetEnabled(),
"config_ids": configIDs,
"webhook_enabled": newJob.GetWebhookEnabled(),
"notify_on_success": newJob.GetNotifyOnSuccess(),
"notify_on_failure": newJob.GetNotifyOnFailure(),
}
auditLog := db.AuditLog{
Action: "duplicate",
EntityType: "job",
EntityID: newJob.ID,
UserID: userID,
Details: auditDetails,
Timestamp: time.Now(),
}
if err := h.DB.Create(&auditLog).Error; err != nil {
log.Printf("Warning: Failed to create audit log for job duplication: %v", err)
// Continue anyway, as this is not critical
}
// Schedule the job with the scheduler
if err := h.Scheduler.ScheduleJob(&newJob); err != nil {
log.Printf("Warning: Failed to schedule duplicated job: %v", err)
// Continue anyway, as user can manually schedule later
}
// Redirect to jobs page
c.Redirect(http.StatusFound, "/jobs")
}
@@ -0,0 +1,122 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
)
// HandleNotifications displays all notifications for the current user
func (h *Handlers) HandleNotifications(c *gin.Context) {
userID := c.GetUint("userID")
// Get notifications for the user
notifications, err := h.DB.GetUserNotifications(userID, 50) // Get more for the full page
if err != nil {
c.String(http.StatusInternalServerError, "Failed to load notifications")
return
}
// Get unread count
unreadCount, err := h.DB.GetUnreadNotificationCount(userID)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to load notification count")
return
}
data := components.NotificationsData{
Notifications: notifications,
UnreadCount: unreadCount,
}
// Render the notifications page
components.NotificationsPage(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleLoadNotifications loads the notifications dropdown content
func (h *Handlers) HandleLoadNotifications(c *gin.Context) {
userID := c.GetUint("userID")
// Get 10 most recent notifications
notifications, err := h.DB.GetUserNotifications(userID, 10)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to load notifications")
return
}
// Get unread count
unreadCount, err := h.DB.GetUnreadNotificationCount(userID)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to load notification count")
return
}
data := components.NotificationsData{
Notifications: notifications,
UnreadCount: unreadCount,
}
// Render just the dropdown content
components.NotificationDropdown(data).Render(c, c.Writer)
}
// HandleNotificationCount returns the notification count badge
func (h *Handlers) HandleNotificationCount(c *gin.Context) {
userID := c.GetUint("userID")
// Get unread count
unreadCount, err := h.DB.GetUnreadNotificationCount(userID)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to load notification count")
return
}
// Render just the count badge
components.NotificationCount(unreadCount).Render(c, c.Writer)
}
// HandleMarkNotificationAsRead marks a single notification as read
func (h *Handlers) HandleMarkNotificationAsRead(c *gin.Context) {
userID := c.GetUint("userID")
// Get notification ID from path
idParam := c.Param("id")
id, err := strconv.ParseUint(idParam, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
return
}
// Mark as read
if err := h.DB.MarkNotificationAsRead(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"})
return
}
// Return updated count
unreadCount, err := h.DB.GetUnreadNotificationCount(userID)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to load notification count")
return
}
// Return just the updated count badge
components.NotificationCount(unreadCount).Render(c, c.Writer)
}
// HandleMarkAllNotificationsAsRead marks all notifications for a user as read
func (h *Handlers) HandleMarkAllNotificationsAsRead(c *gin.Context) {
userID := c.GetUint("userID")
// Mark all as read
if err := h.DB.MarkAllNotificationsAsRead(userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notifications as read"})
return
}
// Return empty count (no more unread notifications)
components.NotificationCount(0).Render(c, c.Writer)
}
+10
View File
@@ -35,6 +35,13 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.GET("/profile/2fa/backup-codes", h.Handle2FABackupCodes)
authorized.POST("/profile/2fa/regenerate-codes", h.Handle2FARegenerateCodes)
// Add notifications routes
authorized.GET("/notifications", h.HandleNotifications)
authorized.GET("/notifications/dropdown", h.HandleLoadNotifications)
authorized.GET("/notifications/count", h.HandleNotificationCount)
authorized.POST("/notifications/:id/read", h.HandleMarkNotificationAsRead)
authorized.POST("/notifications/mark-all-read", h.HandleMarkAllNotificationsAsRead)
{
authorized.GET("/dashboard", h.HandleDashboard)
authorized.GET("/configs", h.HandleConfigs)
@@ -44,6 +51,7 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.PUT("/configs/:id", h.HandleUpdateConfig)
authorized.POST("/configs/:id", h.HandleUpdateConfig)
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
authorized.POST("/configs/:id/duplicate", h.HandleDuplicateConfig)
// Path validation endpoint
authorized.GET("/check-path", h.HandleCheckPath)
@@ -60,6 +68,7 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.PUT("/jobs/:id", h.HandleUpdateJob)
authorized.POST("/jobs/:id", h.HandleUpdateJob)
authorized.DELETE("/jobs/:id", h.HandleDeleteJob)
authorized.POST("/jobs/:id/duplicate", h.HandleDuplicateJob)
authorized.POST("/jobs/:id/run", h.HandleRunJob)
authorized.GET("/history", h.HandleHistory)
authorized.GET("/job-runs/:id", h.HandleJobRunDetails)
@@ -132,6 +141,7 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
settingsGroup.GET("", h.HandleSettings)
settingsGroup.POST("/notifications", h.HandleCreateNotificationService)
settingsGroup.DELETE("/notifications/:id", h.HandleDeleteNotificationService)
settingsGroup.POST("/notifications/test", h.HandleTestNotification)
settingsGroup.POST("/general", h.HandleSettings) // Placeholder for future implementation
settingsGroup.POST("/security", h.HandleSettings) // Placeholder for future implementation
}
+241 -85
View File
@@ -1,56 +1,24 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"crypto/hmac"
"crypto/sha256"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// NotificationService represents a notification service configuration
type NotificationService struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Type string `json:"type" gorm:"not null"` // email, slack, webhook
IsEnabled bool `json:"is_enabled" gorm:"default:true"`
Config map[string]string `json:"config" gorm:"-"`
ConfigJSON string `json:"-" gorm:"column:config"`
Description string `json:"description"`
EventTriggers string `json:"event_triggers" gorm:"column:event_triggers;default:'[]'"`
PayloadTemplate string `json:"payload_template" gorm:"column:payload_template"`
SecretKey string `json:"secret_key" gorm:"column:secret_key"`
RetryPolicy string `json:"retry_policy" gorm:"column:retry_policy;default:'simple'"`
LastUsed time.Time `json:"last_used" gorm:"column:last_used"`
SuccessCount int `json:"success_count" gorm:"column:success_count;default:0"`
FailureCount int `json:"failure_count" gorm:"column:failure_count;default:0"`
CreatedBy uint `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeSave converts Config map to JSON string for storage
func (n *NotificationService) BeforeSave() error {
configJSON, err := json.Marshal(n.Config)
if err != nil {
return err
}
n.ConfigJSON = string(configJSON)
return nil
}
// AfterFind converts JSON string back to Config map
func (n *NotificationService) AfterFind() error {
if n.ConfigJSON != "" {
return json.Unmarshal([]byte(n.ConfigJSON), &n.Config)
}
return nil
}
// HandleSettings handles GET /settings
func (h *Handlers) HandleSettings(c *gin.Context) {
// Check if the user has permission to view settings
@@ -59,7 +27,7 @@ func (h *Handlers) HandleSettings(c *gin.Context) {
return
}
var notificationServices []NotificationService
var notificationServices []db.NotificationService
if err := h.DB.Find(&notificationServices).Error; err != nil {
log.Printf("Error fetching notification services: %v", err)
}
@@ -67,14 +35,6 @@ func (h *Handlers) HandleSettings(c *gin.Context) {
// Convert to components.NotificationService
var componentServices []components.NotificationService
for _, service := range notificationServices {
// Parse event triggers from JSON string to string slice
var eventTriggers []string
if service.EventTriggers != "" {
if err := json.Unmarshal([]byte(service.EventTriggers), &eventTriggers); err != nil {
log.Printf("Error parsing event triggers: %v", err)
}
}
componentServices = append(componentServices, components.NotificationService{
ID: service.ID,
Name: service.Name,
@@ -82,7 +42,7 @@ func (h *Handlers) HandleSettings(c *gin.Context) {
IsEnabled: service.IsEnabled,
Config: service.Config,
Description: service.Description,
EventTriggers: eventTriggers,
EventTriggers: service.EventTriggers,
PayloadTemplate: service.PayloadTemplate,
SecretKey: service.SecretKey,
RetryPolicy: service.RetryPolicy,
@@ -130,16 +90,17 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["smtp_username"] = c.PostForm("smtp_username")
config["smtp_password"] = c.PostForm("smtp_password")
config["from_email"] = c.PostForm("from_email")
case "slack":
config["webhook_url"] = c.PostForm("webhook_url")
config["channel"] = c.PostForm("channel")
case "webhook":
config["webhook_url"] = c.PostForm("webhook_url")
config["method"] = c.PostForm("method")
config["headers"] = c.PostForm("headers")
// Add the new webhook fields
// Create event triggers JSON array
// Create event triggers array
// print all event triggers
log.Printf("Event triggers: %v", c.PostForm("trigger_job_start"))
log.Printf("Event triggers: %v", c.PostForm("trigger_job_complete"))
log.Printf("Event triggers: %v", c.PostForm("trigger_job_error"))
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
@@ -151,21 +112,14 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
eventTriggers = append(eventTriggers, "job_error")
}
// Marshal the event triggers to JSON
eventTriggersJSON, err := json.Marshal(eventTriggers)
if err != nil {
h.handleSettingsWithError(c, "Failed to process event triggers: "+err.Error())
return
}
// Create new notification service with additional fields
service := NotificationService{
service := db.NotificationService{
Name: name,
Type: serviceType,
IsEnabled: isEnabled,
Config: config,
Description: description,
EventTriggers: string(eventTriggersJSON),
EventTriggers: eventTriggers,
PayloadTemplate: c.PostForm("payload_template"),
SecretKey: c.PostForm("secret_key"),
RetryPolicy: c.PostForm("retry_policy"),
@@ -211,7 +165,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
}
// Create new notification service
service := NotificationService{
service := db.NotificationService{
Name: name,
Type: serviceType,
IsEnabled: isEnabled,
@@ -267,14 +221,14 @@ func (h *Handlers) HandleDeleteNotificationService(c *gin.Context) {
}
// Find service to delete (for audit log)
var service NotificationService
var service db.NotificationService
if err := h.DB.First(&service, serviceID).Error; err != nil {
h.handleSettingsWithError(c, "Notification service not found.")
return
}
// Delete the service
if err := h.DB.Delete(&NotificationService{}, serviceID).Error; err != nil {
if err := h.DB.Delete(&db.NotificationService{}, serviceID).Error; err != nil {
log.Printf("Error deleting notification service: %v", err)
h.handleSettingsWithError(c, "Failed to delete notification service: "+err.Error())
return
@@ -304,6 +258,224 @@ func (h *Handlers) HandleDeleteNotificationService(c *gin.Context) {
h.handleSettingsWithSuccess(c, "Notification service deleted successfully.")
}
// HandleTestNotification handles POST /settings/notifications/test
// This endpoint tests a notification configuration without saving it
func (h *Handlers) HandleTestNotification(c *gin.Context) {
// Check if the user has permission to manage settings
if !h.checkPermission(c, "system.settings") {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "You don't have permission to test notifications",
})
return
}
// Parse form data to create a test notification service
name := c.PostForm("name")
serviceType := c.PostForm("type")
// Validate required fields
if name == "" || serviceType == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Name and type are required fields",
})
return
}
// Create config map based on service type
config := make(map[string]string)
switch serviceType {
case "email":
config["smtp_host"] = c.PostForm("smtp_host")
config["smtp_port"] = c.PostForm("smtp_port")
config["smtp_username"] = c.PostForm("smtp_username")
config["smtp_password"] = c.PostForm("smtp_password")
config["from_email"] = c.PostForm("from_email")
// Basic validation
if config["smtp_host"] == "" || config["smtp_port"] == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "SMTP host and port are required for email notifications",
})
return
}
case "webhook":
config["webhook_url"] = c.PostForm("webhook_url")
config["method"] = c.PostForm("method")
config["headers"] = c.PostForm("headers")
// Basic validation
if config["webhook_url"] == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Webhook URL is required for webhook notifications",
})
return
}
// Get additional webhook fields
payloadTemplate := c.PostForm("payload_template")
secretKey := c.PostForm("secret_key")
// Create and format sample payload
samplePayload := generateSamplePayload(payloadTemplate)
// Send test webhook
err := sendTestWebhook(config, samplePayload, secretKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "Failed to send test webhook: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Test webhook sent successfully",
})
return
default:
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid notification service type",
})
return
}
// For email, simulate a successful test for now
// In a real implementation, you would send an actual test notification
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("Simulated %s notification test successful", serviceType),
})
}
// generateSamplePayload creates a sample payload for testing
func generateSamplePayload(template string) string {
// If no template provided, use a default sample
if template == "" {
return `{
"event": "job_complete",
"job": {
"id": "sample-job-123",
"name": "Test Job",
"status": "completed",
"message": "This is a test notification",
"started_at": "` + time.Now().Add(-5*time.Minute).Format(time.RFC3339) + `",
"completed_at": "` + time.Now().Format(time.RFC3339) + `",
"duration_seconds": 300,
"config_id": "config-456",
"config_name": "Test Config",
"transfer_bytes": 1024,
"file_count": 5
},
"instance": {
"id": "gomft-instance-1",
"name": "GoMFT Test Instance",
"version": "1.0.0",
"environment": "testing"
},
"timestamp": "` + time.Now().Format(time.RFC3339) + `",
"notification_id": "test-notification"
}`
}
// Replace placeholders in the template with sample values
samplePayload := template
// Replace common placeholders
replacements := map[string]string{
"{{job.id}}": "sample-job-123",
"{{job.name}}": "Test Job",
"{{job.status}}": "completed",
"{{job.message}}": "This is a test notification",
"{{job.event}}": "job_complete",
"{{job.started_at}}": time.Now().Add(-5 * time.Minute).Format(time.RFC3339),
"{{job.completed_at}}": time.Now().Format(time.RFC3339),
"{{job.duration_seconds}}": "300",
"{{job.config_id}}": "config-456",
"{{job.config_name}}": "Test Config",
"{{job.transfer_bytes}}": "1024",
"{{job.file_count}}": "5",
"{{instance.id}}": "gomft-instance-1",
"{{instance.name}}": "GoMFT Test Instance",
"{{instance.version}}": "1.0.0",
"{{instance.environment}}": "testing",
"{{timestamp}}": time.Now().Format(time.RFC3339),
"{{notification.id}}": "test-notification",
}
for placeholder, value := range replacements {
samplePayload = strings.Replace(samplePayload, placeholder, value, -1)
}
return samplePayload
}
// sendTestWebhook sends a test webhook to the specified URL
func sendTestWebhook(config map[string]string, payload string, secretKey string) error {
webhookURL := config["webhook_url"]
method := config["method"]
if method == "" {
method = "POST"
}
// Create the request
req, err := http.NewRequest(method, webhookURL, bytes.NewBufferString(payload))
if err != nil {
return fmt.Errorf("error creating request: %v", err)
}
// Set default Content-Type if not specified
req.Header.Set("Content-Type", "application/json")
// Parse and set custom headers
if config["headers"] != "" {
var headers map[string]string
if err := json.Unmarshal([]byte(config["headers"]), &headers); err == nil {
for key, value := range headers {
req.Header.Set(key, value)
}
}
}
// Add signature if secret key is provided
if secretKey != "" {
signature := calculateSignature(payload, secretKey)
req.Header.Set("X-GoMFT-Signature", signature)
}
// Send the request
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error sending webhook: %v", err)
}
defer resp.Body.Close()
// Check the response
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("webhook returned error %d: %s", resp.StatusCode, string(body))
}
return nil
}
// calculateSignature generates an HMAC signature for webhook payloads
func calculateSignature(payload string, secretKey string) string {
h := hmac.New(sha256.New, []byte(secretKey))
h.Write([]byte(payload))
return fmt.Sprintf("sha256=%x", h.Sum(nil))
}
// Helper function to check if user has a specific permission
func (h *Handlers) checkPermission(c *gin.Context, permission string) bool {
// If user is admin, they have all permissions
@@ -332,7 +504,7 @@ func (h *Handlers) checkPermission(c *gin.Context, permission string) bool {
// handleSettingsWithError renders the settings page with an error message
func (h *Handlers) handleSettingsWithError(c *gin.Context, errorMessage string) {
var notificationServices []NotificationService
var notificationServices []db.NotificationService
if err := h.DB.Find(&notificationServices).Error; err != nil {
log.Printf("Error fetching notification services: %v", err)
}
@@ -340,14 +512,6 @@ func (h *Handlers) handleSettingsWithError(c *gin.Context, errorMessage string)
// Convert to components.NotificationService
var componentServices []components.NotificationService
for _, service := range notificationServices {
// Parse event triggers from JSON string to string slice
var eventTriggers []string
if service.EventTriggers != "" {
if err := json.Unmarshal([]byte(service.EventTriggers), &eventTriggers); err != nil {
log.Printf("Error parsing event triggers: %v", err)
}
}
componentServices = append(componentServices, components.NotificationService{
ID: service.ID,
Name: service.Name,
@@ -355,7 +519,7 @@ func (h *Handlers) handleSettingsWithError(c *gin.Context, errorMessage string)
IsEnabled: service.IsEnabled,
Config: service.Config,
Description: service.Description,
EventTriggers: eventTriggers,
EventTriggers: service.EventTriggers,
PayloadTemplate: service.PayloadTemplate,
SecretKey: service.SecretKey,
RetryPolicy: service.RetryPolicy,
@@ -375,7 +539,7 @@ func (h *Handlers) handleSettingsWithError(c *gin.Context, errorMessage string)
// handleSettingsWithSuccess renders the settings page with a success message
func (h *Handlers) handleSettingsWithSuccess(c *gin.Context, successMessage string) {
var notificationServices []NotificationService
var notificationServices []db.NotificationService
if err := h.DB.Find(&notificationServices).Error; err != nil {
log.Printf("Error fetching notification services: %v", err)
}
@@ -383,14 +547,6 @@ func (h *Handlers) handleSettingsWithSuccess(c *gin.Context, successMessage stri
// Convert to components.NotificationService
var componentServices []components.NotificationService
for _, service := range notificationServices {
// Parse event triggers from JSON string to string slice
var eventTriggers []string
if service.EventTriggers != "" {
if err := json.Unmarshal([]byte(service.EventTriggers), &eventTriggers); err != nil {
log.Printf("Error parsing event triggers: %v", err)
}
}
componentServices = append(componentServices, components.NotificationService{
ID: service.ID,
Name: service.Name,
@@ -398,7 +554,7 @@ func (h *Handlers) handleSettingsWithSuccess(c *gin.Context, successMessage stri
IsEnabled: service.IsEnabled,
Config: service.Config,
Description: service.Description,
EventTriggers: eventTriggers,
EventTriggers: service.EventTriggers,
PayloadTemplate: service.PayloadTemplate,
SecretKey: service.SecretKey,
RetryPolicy: service.RetryPolicy,