mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 23:50:48 +02:00
- Added a hidden input for the notification form to ensure proper handling of the 'is_enabled' state. - Updated the configuration loading to allow skipping SSL verification with a new environment variable. - Introduced temporary log file creation for rclone commands, improving error handling and logging of file processing. - Enhanced the transfer executor to parse rclone logs for file metadata, ensuring accurate tracking of processed files. - Added debug logging for received form data in the notification service update handler to aid in troubleshooting.
1166 lines
38 KiB
Go
1166 lines
38 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/starfleetcptn/gomft/internal/db"
|
|
"gorm.io/gorm" // Needed for the Create method signature in the interface
|
|
)
|
|
|
|
// NotificationDB defines the database methods needed by Notifier.
|
|
type NotificationDB interface {
|
|
GetNotificationServices(enabledOnly bool) ([]db.NotificationService, error)
|
|
UpdateNotificationService(service *db.NotificationService) error
|
|
GetJob(jobID uint) (*db.Job, error)
|
|
CreateJobNotification(userID uint, jobID uint, historyID uint, notificationType db.NotificationType, title string, message string) error
|
|
Create(value interface{}) *gorm.DB // Used by createJobHistoryAndNotify
|
|
}
|
|
|
|
// Notifier handles sending notifications via various services.
|
|
type Notifier struct {
|
|
db NotificationDB // Use the interface type
|
|
logger *Logger
|
|
skipSSLVerify bool
|
|
}
|
|
|
|
// NewNotifier creates a new Notifier.
|
|
func NewNotifier(database NotificationDB, logger *Logger, skipSSLVerify bool) *Notifier { // Accept the interface type and skip flag
|
|
return &Notifier{
|
|
db: database,
|
|
logger: logger,
|
|
skipSSLVerify: skipSSLVerify, // Store the flag
|
|
}
|
|
}
|
|
|
|
// SendNotifications is the main entry point for sending notifications for a job execution step.
|
|
// It handles job-specific webhooks and triggers global notifications.
|
|
func (n *Notifier) SendNotifications(job *db.Job, history *db.JobHistory, config *db.TransferConfig) {
|
|
// First, handle job-specific webhook if configured
|
|
if job.GetWebhookEnabled() && job.WebhookURL != "" {
|
|
// Skip notifications based on settings
|
|
if history.Status == "completed" && !job.GetNotifyOnSuccess() {
|
|
n.logger.LogDebug("Skipping success notification for job %d (notifyOnSuccess=false)", job.ID)
|
|
} else if history.Status == "failed" && !job.GetNotifyOnFailure() {
|
|
n.logger.LogDebug("Skipping failure notification for job %d (notifyOnFailure=false)", job.ID)
|
|
} else {
|
|
n.logger.LogInfo("Sending job-specific webhook notification for job %d", job.ID)
|
|
n.sendJobWebhookNotification(job, history, config) // Call the job-specific sender
|
|
}
|
|
}
|
|
|
|
// Next, process global notification services
|
|
n.sendGlobalNotifications(job, history, config)
|
|
}
|
|
|
|
// sendJobWebhookNotification sends a notification to the job's configured webhook URL.
|
|
func (n *Notifier) sendJobWebhookNotification(job *db.Job, history *db.JobHistory, config *db.TransferConfig) {
|
|
// Create the payload with useful information
|
|
payload := map[string]interface{}{
|
|
"event_type": "job_execution",
|
|
"job_id": job.ID,
|
|
"job_name": job.Name,
|
|
"config_id": config.ID,
|
|
"config_name": config.Name,
|
|
"status": history.Status,
|
|
"start_time": history.StartTime.Format(time.RFC3339),
|
|
"history_id": history.ID,
|
|
"bytes_transferred": history.BytesTransferred,
|
|
"files_transferred": history.FilesTransferred,
|
|
}
|
|
|
|
if history.EndTime != nil {
|
|
payload["end_time"] = history.EndTime.Format(time.RFC3339)
|
|
duration := history.EndTime.Sub(history.StartTime)
|
|
payload["duration_seconds"] = duration.Seconds()
|
|
}
|
|
|
|
if history.ErrorMessage != "" {
|
|
payload["error_message"] = history.ErrorMessage
|
|
}
|
|
|
|
// Add source and destination information
|
|
payload["source"] = map[string]string{
|
|
"type": config.SourceType,
|
|
"path": config.SourcePath,
|
|
}
|
|
payload["destination"] = map[string]string{
|
|
"type": config.DestinationType,
|
|
"path": config.DestinationPath,
|
|
}
|
|
|
|
// Convert payload to JSON
|
|
jsonPayload, err := json.Marshal(payload)
|
|
if err != nil {
|
|
n.logger.LogError("Error marshaling webhook payload for job %d: %v", job.ID, err)
|
|
return
|
|
}
|
|
|
|
n.logger.LogDebug("Webhook payload: %s", string(jsonPayload))
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequest("POST", job.WebhookURL, bytes.NewBuffer(jsonPayload))
|
|
if err != nil {
|
|
n.logger.LogError("Error creating webhook request for job %d: %v", job.ID, err)
|
|
return
|
|
}
|
|
|
|
fmt.Println(job.WebhookURL)
|
|
|
|
// Set headers
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "GoMFT-Webhook/1.0")
|
|
|
|
// Add X-Hub-Signature if secret is configured
|
|
if job.WebhookSecret != "" {
|
|
h := hmac.New(sha256.New, []byte(job.WebhookSecret))
|
|
h.Write(jsonPayload)
|
|
signature := hex.EncodeToString(h.Sum(nil))
|
|
req.Header.Set("X-Hub-Signature-256", signature)
|
|
}
|
|
|
|
// Add custom headers if specified
|
|
if job.WebhookHeaders != "" {
|
|
var headers map[string]string
|
|
if err := json.Unmarshal([]byte(job.WebhookHeaders), &headers); err == nil {
|
|
for key, value := range headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
n.logger.LogDebug("Webhook headers: %+v", req.Header)
|
|
|
|
// Send the request with a timeout and configured TLS settings
|
|
client := createHTTPClient(10*time.Second, n.skipSSLVerify)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
n.logger.LogError("Error sending webhook for job %d: %v", job.ID, err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Log the response
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
n.logger.LogInfo("Webhook notification for job %d sent successfully (status: %d)", job.ID, resp.StatusCode)
|
|
} else {
|
|
n.logger.LogError("Webhook notification for job %d failed with status: %d", job.ID, resp.StatusCode)
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if len(respBody) > 0 {
|
|
n.logger.LogDebug("Webhook response: %s", respBody)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendGlobalNotifications sends notifications through all configured notification services.
|
|
func (n *Notifier) sendGlobalNotifications(job *db.Job, history *db.JobHistory, config *db.TransferConfig) {
|
|
// Fetch all enabled notification services
|
|
services, err := n.db.GetNotificationServices(true) // Calls interface method
|
|
if err != nil {
|
|
n.logger.LogError("Error fetching notification services: %v", err)
|
|
return
|
|
}
|
|
|
|
if len(services) == 0 {
|
|
n.logger.LogDebug("No enabled notification services found")
|
|
return
|
|
}
|
|
|
|
n.logger.LogInfo("Found %d enabled notification services", len(services))
|
|
|
|
// Determine event type based on job status
|
|
var eventType string
|
|
switch history.Status {
|
|
case "running":
|
|
eventType = "job_start"
|
|
case "completed", "completed_with_errors":
|
|
eventType = "job_complete"
|
|
case "failed":
|
|
eventType = "job_error"
|
|
default:
|
|
eventType = "job_status"
|
|
}
|
|
|
|
// Process each notification service
|
|
for i := range services {
|
|
service := &services[i] // Use pointer to update stats
|
|
n.logger.LogInfo("Processing notification service %s (%s)", service.Name, service.Type)
|
|
// Check if this service should handle this event type
|
|
shouldSend := false
|
|
for _, trigger := range service.EventTriggers {
|
|
if trigger == eventType {
|
|
shouldSend = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Skip if this service doesn't handle this event type
|
|
if !shouldSend {
|
|
n.logger.LogDebug("Skipping notification service %s (%s) for event %s (not in triggers)",
|
|
service.Name, service.Type, eventType)
|
|
continue
|
|
}
|
|
|
|
// --- Moved Update Logic Inside this block ---
|
|
n.logger.LogInfo("Sending notification via service %s (%s) for job %d",
|
|
service.Name, service.Type, job.ID)
|
|
|
|
// Send notification based on service type
|
|
var notifyErr error
|
|
switch service.Type {
|
|
case "email":
|
|
notifyErr = n.sendEmailNotification(service, job, history, config, eventType)
|
|
case "webhook":
|
|
notifyErr = n.sendServiceWebhookNotification(service, job, history, config, eventType)
|
|
case "pushbullet":
|
|
notifyErr = n.sendPushbulletNotification(service, job, history, config, eventType)
|
|
case "ntfy":
|
|
notifyErr = n.sendNtfyNotification(service, job, history, config, eventType)
|
|
case "gotify":
|
|
notifyErr = n.sendGotifyNotification(service, job, history, config, eventType)
|
|
case "pushover":
|
|
notifyErr = n.sendPushoverNotification(service, job, history, config, eventType)
|
|
default:
|
|
n.logger.LogError("Unsupported notification service type: %s", service.Type)
|
|
// Skip update logic below if type is unsupported
|
|
continue
|
|
}
|
|
|
|
// Update service success/failure count
|
|
if notifyErr != nil {
|
|
service.FailureCount++
|
|
n.logger.LogError("Notification service %s failed: %v", service.Name, notifyErr)
|
|
} else {
|
|
service.SuccessCount++
|
|
service.LastUsed = time.Now()
|
|
n.logger.LogInfo("Notification service %s sent successfully", service.Name)
|
|
}
|
|
|
|
// Update notification service stats in the database
|
|
if err := n.db.UpdateNotificationService(service); err != nil { // Calls interface method
|
|
n.logger.LogError("Error updating notification service stats for service %s: %v", service.Name, err)
|
|
}
|
|
// --- End of Moved Update Logic ---
|
|
|
|
} // End of loop through services
|
|
}
|
|
|
|
// sendEmailNotification sends an email notification using the configured email service.
|
|
func (n *Notifier) sendEmailNotification(service *db.NotificationService, job *db.Job, history *db.JobHistory, config *db.TransferConfig, eventType string) error {
|
|
n.logger.LogDebug("Preparing email notification via service %s for job %d", service.Name, job.ID)
|
|
|
|
// Extract SMTP settings from service config
|
|
smtpHost := service.Config["smtp_host"]
|
|
smtpPortStr := service.Config["smtp_port"]
|
|
fromEmail := service.Config["from_email"]
|
|
toEmail := service.Config["to_email"]
|
|
|
|
// Validate required settings
|
|
if smtpHost == "" || smtpPortStr == "" || fromEmail == "" || toEmail == "" {
|
|
return fmt.Errorf("missing required SMTP settings")
|
|
}
|
|
|
|
// Parse SMTP port
|
|
smtpPort, err := strconv.Atoi(smtpPortStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid SMTP port: %v", err)
|
|
}
|
|
|
|
// Prepare email content
|
|
subject := fmt.Sprintf("[GoMFT] Job %s: %s", job.Name, history.Status)
|
|
body := generateEmailBody(job, history, config, eventType) // Use package-level helper
|
|
|
|
// TODO: Implement actual email sending logic
|
|
// This would typically involve using a package like "net/smtp" or a third-party
|
|
// email library to send the actual email.
|
|
// For actual implementation, you would use:
|
|
// - smtpUsername := service.Config["smtp_username"]
|
|
// - smtpPassword := service.Config["smtp_password"]
|
|
|
|
n.logger.LogInfo("Email would be sent to %s with subject: %s", toEmail, subject)
|
|
n.logger.LogDebug("Email body: %s", body)
|
|
|
|
// Placeholder for actual email sending
|
|
// For now, we'll just log that the email would be sent
|
|
n.logger.LogInfo("Email notification prepared (SMTP: %s:%d, From: %s, To: %s)",
|
|
smtpHost, smtpPort, fromEmail, toEmail)
|
|
|
|
return nil
|
|
}
|
|
|
|
// generateEmailBody creates the email body for job notifications.
|
|
// (Remains package-level helper)
|
|
func generateEmailBody(job *db.Job, history *db.JobHistory, config *db.TransferConfig, eventType string) string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(fmt.Sprintf("Job: %s (ID: %d)\n", job.Name, job.ID))
|
|
b.WriteString(fmt.Sprintf("Status: %s\n", history.Status))
|
|
b.WriteString(fmt.Sprintf("Start Time: %s\n", history.StartTime.Format(time.RFC3339)))
|
|
|
|
if history.EndTime != nil {
|
|
b.WriteString(fmt.Sprintf("End Time: %s\n", history.EndTime.Format(time.RFC3339)))
|
|
duration := history.EndTime.Sub(history.StartTime)
|
|
b.WriteString(fmt.Sprintf("Duration: %.2f seconds\n", duration.Seconds()))
|
|
}
|
|
|
|
b.WriteString(fmt.Sprintf("Files Transferred: %d\n", history.FilesTransferred))
|
|
b.WriteString(fmt.Sprintf("Bytes Transferred: %d\n", history.BytesTransferred))
|
|
|
|
b.WriteString("\nTransfer Configuration:\n")
|
|
b.WriteString(fmt.Sprintf("Name: %s (ID: %d)\n", config.Name, config.ID))
|
|
b.WriteString(fmt.Sprintf("Source: %s:%s\n", config.SourceType, config.SourcePath))
|
|
b.WriteString(fmt.Sprintf("Destination: %s:%s\n", config.DestinationType, config.DestinationPath))
|
|
|
|
if history.ErrorMessage != "" {
|
|
b.WriteString("\nError Details:\n")
|
|
b.WriteString(history.ErrorMessage)
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// createHTTPClient creates an HTTP client with appropriate TLS settings and timeout.
|
|
func createHTTPClient(timeout time.Duration, skipSSLVerify bool) *http.Client {
|
|
// Clone the default transport to avoid modifying global state
|
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
|
|
if skipSSLVerify {
|
|
// Ensure TLSClientConfig exists before modifying it
|
|
if transport.TLSClientConfig == nil {
|
|
transport.TLSClientConfig = &tls.Config{}
|
|
}
|
|
transport.TLSClientConfig.InsecureSkipVerify = true
|
|
// TODO: Consider adding a log warning here when skipping verification
|
|
}
|
|
|
|
return &http.Client{
|
|
Timeout: timeout,
|
|
Transport: transport,
|
|
}
|
|
}
|
|
|
|
// sendServiceWebhookNotification sends a webhook notification using a configured notification service.
|
|
func (n *Notifier) sendServiceWebhookNotification(service *db.NotificationService, job *db.Job, history *db.JobHistory, config *db.TransferConfig, eventType string) error {
|
|
n.logger.LogDebug("Preparing webhook notification via service %s for job %d", service.Name, job.ID)
|
|
|
|
// Extract webhook settings
|
|
webhookURL := service.Config["webhook_url"]
|
|
method := service.Config["method"]
|
|
if method == "" {
|
|
method = "POST" // Default to POST if not specified
|
|
}
|
|
|
|
// Validate required settings
|
|
if webhookURL == "" {
|
|
return fmt.Errorf("missing webhook URL")
|
|
}
|
|
|
|
// Prepare payload
|
|
var payload map[string]interface{}
|
|
|
|
// Use custom payload template if provided
|
|
if service.PayloadTemplate != "" {
|
|
// Parse the template and fill in variables
|
|
payload = generateCustomPayload(service.PayloadTemplate, job, history, config, eventType) // Use package-level helper
|
|
} else {
|
|
// Use default payload format
|
|
payload = generateDefaultPayload(job, history, config, eventType) // Use package-level helper
|
|
}
|
|
|
|
// Convert payload to JSON
|
|
jsonPayload, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("error marshaling webhook payload: %v", err)
|
|
}
|
|
|
|
n.logger.LogDebug("Webhook payload: %s", string(jsonPayload))
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequest(method, webhookURL, bytes.NewBuffer(jsonPayload))
|
|
if err != nil {
|
|
return fmt.Errorf("error creating webhook request: %v", err)
|
|
}
|
|
|
|
// Set default headers
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "GoMFT-Notification/1.0")
|
|
|
|
// Add signature if secret key is provided
|
|
if service.SecretKey != "" {
|
|
h := hmac.New(sha256.New, []byte(service.SecretKey))
|
|
h.Write(jsonPayload)
|
|
signature := hex.EncodeToString(h.Sum(nil))
|
|
req.Header.Set("X-GoMFT-Signature", signature)
|
|
}
|
|
|
|
// Add custom headers if specified
|
|
if headersStr := service.Config["headers"]; headersStr != "" {
|
|
var headers map[string]string
|
|
if err := json.Unmarshal([]byte(headersStr), &headers); err == nil {
|
|
for key, value := range headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
n.logger.LogDebug("Webhook headers: %+v", req.Header)
|
|
|
|
// Determine timeout based on retry policy
|
|
timeout := 10 * time.Second
|
|
maxRetries := 0
|
|
|
|
switch service.RetryPolicy {
|
|
case "none":
|
|
maxRetries = 0
|
|
case "simple":
|
|
maxRetries = 3
|
|
timeout = 15 * time.Second
|
|
case "exponential":
|
|
maxRetries = 5
|
|
timeout = 30 * time.Second
|
|
default:
|
|
// Default to simple
|
|
maxRetries = 3
|
|
timeout = 15 * time.Second
|
|
}
|
|
|
|
// Prepare client with timeout and configured TLS settings
|
|
client := createHTTPClient(timeout, n.skipSSLVerify)
|
|
|
|
// Attempt to send with retries
|
|
var resp *http.Response
|
|
var lastErr error
|
|
|
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
// Wait before retry with increasing backoff
|
|
backoffDuration := time.Duration(1<<uint(attempt-1)) * time.Second
|
|
n.logger.LogInfo("Retrying webhook notification (attempt %d/%d) after %v",
|
|
attempt, maxRetries, backoffDuration)
|
|
time.Sleep(backoffDuration)
|
|
}
|
|
|
|
resp, err = client.Do(req)
|
|
if err == nil {
|
|
// Check for success status code
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
defer resp.Body.Close()
|
|
n.logger.LogInfo("Webhook notification sent successfully (status: %d)", resp.StatusCode)
|
|
return nil
|
|
}
|
|
|
|
// Error status code
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
lastErr = fmt.Errorf("webhook returned status %d: %s", resp.StatusCode, respBody)
|
|
n.logger.LogError("Webhook error (attempt %d/%d): %v", attempt+1, maxRetries+1, lastErr)
|
|
} else {
|
|
// Network or request error
|
|
lastErr = fmt.Errorf("webhook request failed: %v", err)
|
|
n.logger.LogError("Webhook request error (attempt %d/%d): %v", attempt+1, maxRetries+1, lastErr)
|
|
}
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// generateDefaultPayload creates a standard webhook payload.
|
|
// (Remains package-level helper)
|
|
func generateDefaultPayload(job *db.Job, history *db.JobHistory, config *db.TransferConfig, eventType string) map[string]interface{} {
|
|
// get event type
|
|
switch eventType {
|
|
case "job_start":
|
|
eventType = "Job Started"
|
|
case "job_complete":
|
|
eventType = "Job Completed"
|
|
case "job_fail":
|
|
eventType = "Job Failed"
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"event": eventType,
|
|
"job": map[string]interface{}{
|
|
"id": job.ID,
|
|
"name": job.Name,
|
|
"status": history.Status,
|
|
"event": eventType,
|
|
"message": history.ErrorMessage,
|
|
"started_at": history.StartTime.Format(time.RFC3339),
|
|
"config_id": config.ID,
|
|
"config_name": config.Name,
|
|
"transfer_bytes": history.BytesTransferred,
|
|
"file_count": history.FilesTransferred,
|
|
},
|
|
"instance": map[string]interface{}{
|
|
"id": "gomft",
|
|
"name": "GoMFT",
|
|
"version": "1.0", // TODO: Get actual version
|
|
"environment": "production", // TODO: Get from env
|
|
},
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
}
|
|
|
|
if history.EndTime != nil {
|
|
payload["job"].(map[string]interface{})["completed_at"] = history.EndTime.Format(time.RFC3339)
|
|
duration := history.EndTime.Sub(history.StartTime)
|
|
payload["job"].(map[string]interface{})["duration_seconds"] = duration.Seconds()
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
// generateCustomPayload creates a webhook payload from a template.
|
|
// (Remains package-level helper)
|
|
func generateCustomPayload(template string, job *db.Job, history *db.JobHistory, config *db.TransferConfig, eventType string) map[string]interface{} {
|
|
// Start with the default payload as a base
|
|
defaultPayload := generateDefaultPayload(job, history, config, eventType) // Use package-level helper
|
|
|
|
// Parse the template string to JSON
|
|
var customPayload map[string]interface{}
|
|
if err := json.Unmarshal([]byte(template), &customPayload); err != nil {
|
|
// If template can't be parsed, fall back to default payload
|
|
return defaultPayload
|
|
}
|
|
|
|
// Replace variables in the template
|
|
// This is a simplified version - a real implementation would do deep traversal
|
|
// and replace all variables in the structure
|
|
processedPayload := processPayloadVariables(customPayload, defaultPayload) // Use package-level helper
|
|
|
|
return processedPayload
|
|
}
|
|
|
|
// processPayloadVariables recursively processes a payload structure and replaces variables.
|
|
// (Remains package-level helper)
|
|
func processPayloadVariables(customPayload map[string]interface{}, variables map[string]interface{}) map[string]interface{} {
|
|
result := make(map[string]interface{})
|
|
|
|
// Process each key-value pair in the custom payload
|
|
for key, value := range customPayload {
|
|
switch v := value.(type) {
|
|
case string:
|
|
// Replace string variables
|
|
result[key] = replaceVariables(v, variables) // Use package-level helper
|
|
case map[string]interface{}:
|
|
// Recursively process nested maps
|
|
result[key] = processPayloadVariables(v, variables) // Recursive call
|
|
case []interface{}:
|
|
// Process arrays
|
|
result[key] = processArrayVariables(v, variables) // Use package-level helper
|
|
default:
|
|
// Keep other types as is
|
|
result[key] = value
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// processArrayVariables processes array elements for variable replacement.
|
|
// (Remains package-level helper)
|
|
func processArrayVariables(array []interface{}, variables map[string]interface{}) []interface{} {
|
|
result := make([]interface{}, len(array))
|
|
|
|
for i, value := range array {
|
|
switch v := value.(type) {
|
|
case string:
|
|
result[i] = replaceVariables(v, variables) // Use package-level helper
|
|
case map[string]interface{}:
|
|
result[i] = processPayloadVariables(v, variables) // Recursive call
|
|
case []interface{}:
|
|
result[i] = processArrayVariables(v, variables) // Recursive call
|
|
default:
|
|
result[i] = value
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// replaceVariables replaces variable placeholders in a string with their values.
|
|
// (Remains package-level helper)
|
|
func replaceVariables(template string, variables map[string]interface{}) string {
|
|
// Check for variable pattern like {{job.name}}
|
|
re := regexp.MustCompile(`{{([^{}]+)}}`)
|
|
result := re.ReplaceAllStringFunc(template, func(match string) string {
|
|
// Extract variable path (e.g., "job.name")
|
|
varPath := re.FindStringSubmatch(match)[1]
|
|
parts := strings.Split(varPath, ".")
|
|
|
|
// Navigate the variables structure to find the value
|
|
var current interface{} = variables
|
|
for _, part := range parts {
|
|
if m, ok := current.(map[string]interface{}); ok {
|
|
if val, exists := m[part]; exists {
|
|
current = val
|
|
} else {
|
|
return match // Keep original if not found
|
|
}
|
|
} else {
|
|
return match // Keep original if structure doesn't match
|
|
}
|
|
}
|
|
|
|
// Convert the found value to string
|
|
switch v := current.(type) {
|
|
case string:
|
|
return v
|
|
case int, int64, uint, uint64, float32, float64:
|
|
return fmt.Sprintf("%v", v)
|
|
case bool:
|
|
return fmt.Sprintf("%v", v)
|
|
case time.Time:
|
|
return v.Format(time.RFC3339)
|
|
default:
|
|
// For complex types, convert to JSON
|
|
if bytes, err := json.Marshal(v); err == nil {
|
|
return string(bytes)
|
|
}
|
|
return match
|
|
}
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
// updateJobStatus creates notifications based on job status changes.
|
|
// Note: The original function updated history, this one focuses on notification creation.
|
|
// TODO: This function seems redundant with createJobNotification, review and potentially remove.
|
|
func (n *Notifier) updateJobStatus(jobID uint, status string, startTime, endTime time.Time, message string) (*db.JobHistory, error) {
|
|
// Create a temporary history object just for notification context
|
|
history := &db.JobHistory{
|
|
JobID: jobID,
|
|
Status: status,
|
|
StartTime: startTime,
|
|
EndTime: &endTime,
|
|
ErrorMessage: message,
|
|
}
|
|
|
|
// Get job details to find the creator for notification targeting
|
|
job, err := n.db.GetJob(jobID) // Calls interface method
|
|
if err != nil {
|
|
// Log error but don't necessarily fail the whole operation if job fetch fails
|
|
n.logger.LogError("Failed to get job details for notification: jobID=%d, error=%v", jobID, err)
|
|
// Return the temporary history object and nil error, as notification is best-effort
|
|
return history, nil
|
|
}
|
|
|
|
// Get the user who created the job
|
|
userID := job.CreatedBy
|
|
|
|
// Create job title from job name or ID
|
|
jobTitle := job.Name
|
|
if jobTitle == "" {
|
|
jobTitle = fmt.Sprintf("Job #%d", job.ID)
|
|
}
|
|
|
|
// Check which notification to send based on status
|
|
var notificationType db.NotificationType
|
|
var title string
|
|
var notificationMessage string // Renamed from message to avoid conflict
|
|
|
|
switch status {
|
|
case "running":
|
|
notificationType = db.NotificationJobStart
|
|
title = "Job Started"
|
|
notificationMessage = jobTitle
|
|
case "completed":
|
|
notificationType = db.NotificationJobComplete
|
|
title = "Job Complete"
|
|
notificationMessage = jobTitle
|
|
case "failed":
|
|
notificationType = db.NotificationJobFail
|
|
title = "Job Failed"
|
|
notificationMessage = jobTitle
|
|
if history.ErrorMessage != "" {
|
|
notificationMessage = jobTitle + ": " + history.ErrorMessage
|
|
}
|
|
default:
|
|
// Don't create notifications for other statuses
|
|
return history, nil
|
|
}
|
|
|
|
// Create the notification in the database
|
|
// Assuming history.ID is set elsewhere if needed, or pass 0 if not applicable here
|
|
err = n.db.CreateJobNotification( // Calls interface method
|
|
userID,
|
|
jobID,
|
|
0, // History ID might not be available/relevant here, pass 0 or adjust DB function
|
|
notificationType,
|
|
title,
|
|
notificationMessage,
|
|
)
|
|
|
|
if err != nil {
|
|
n.logger.LogError("Failed to create job notification: jobID=%d, error=%v", job.ID, err)
|
|
// Continue anyway, not critical
|
|
}
|
|
|
|
// Return the temporary history object and nil error
|
|
return history, nil
|
|
}
|
|
|
|
// Create a job history record and send notification
|
|
// TODO: This function mixes history creation and notification.
|
|
// History creation should likely happen elsewhere (e.g., JobExecutor).
|
|
// This method should perhaps just focus on sending notifications based on a history record.
|
|
// Renaming to sendNotificationsForHistory might be better.
|
|
func (n *Notifier) createJobHistoryAndNotify(job *db.Job, status string, startTime time.Time, endTime time.Time, message string) error {
|
|
// Create the job history entry
|
|
history := db.JobHistory{
|
|
JobID: job.ID,
|
|
Status: status,
|
|
StartTime: startTime,
|
|
EndTime: &endTime,
|
|
ErrorMessage: message,
|
|
}
|
|
|
|
// Save to database - TODO: Move this responsibility?
|
|
if err := n.db.Create(&history).Error; err != nil { // Calls interface method
|
|
n.logger.LogError("Failed to create job history: jobID=%d, error=%v", job.ID, err)
|
|
return err // Return error if history creation fails
|
|
}
|
|
|
|
// Create notification based on the *saved* history record (which now has an ID)
|
|
err := n.createJobNotification(job, &history) // Call the dedicated notification creation method
|
|
if err != nil {
|
|
n.logger.LogError("Failed to create job notification: jobID=%d, error=%v", job.ID, err)
|
|
// Continue anyway - notification is not critical
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Create a notification database record for a job event based on its history.
|
|
func (n *Notifier) createJobNotification(job *db.Job, history *db.JobHistory) error {
|
|
// Get the user who created the job
|
|
userID := job.CreatedBy
|
|
|
|
// Create job title from job name or ID
|
|
jobTitle := job.Name
|
|
if jobTitle == "" {
|
|
jobTitle = fmt.Sprintf("Job #%d", job.ID)
|
|
}
|
|
|
|
// Determine notification type and content
|
|
var notificationType db.NotificationType
|
|
var title string
|
|
var message string
|
|
|
|
switch history.Status {
|
|
case "running":
|
|
notificationType = db.NotificationJobStart
|
|
title = "Job Started"
|
|
message = jobTitle
|
|
case "completed":
|
|
notificationType = db.NotificationJobComplete
|
|
title = "Job Complete"
|
|
message = jobTitle
|
|
case "failed":
|
|
notificationType = db.NotificationJobFail
|
|
title = "Job Failed"
|
|
message = jobTitle
|
|
if history.ErrorMessage != "" {
|
|
message = jobTitle + ": " + history.ErrorMessage
|
|
}
|
|
default:
|
|
// Don't create notifications for other statuses like 'completed_with_errors' here?
|
|
// Or maybe map 'completed_with_errors' to NotificationJobComplete?
|
|
n.logger.LogDebug("Skipping DB notification creation for status: %s", history.Status)
|
|
return nil
|
|
}
|
|
|
|
// Create the notification record in the database
|
|
return n.db.CreateJobNotification( // Calls interface method
|
|
userID,
|
|
job.ID,
|
|
history.ID, // Use the actual history ID
|
|
notificationType,
|
|
title,
|
|
message,
|
|
)
|
|
}
|
|
|
|
// sendPushbulletNotification sends a notification via Pushbullet.
|
|
func (n *Notifier) sendPushbulletNotification(service *db.NotificationService, job *db.Job, history *db.JobHistory, config *db.TransferConfig, eventType string) error {
|
|
n.logger.LogDebug("Sending Pushbullet notification for job %d", job.ID)
|
|
|
|
// Get API key from service config
|
|
apiKey, ok := service.Config["api_key"]
|
|
if !ok || apiKey == "" {
|
|
return fmt.Errorf("missing API key for Pushbullet notification")
|
|
}
|
|
|
|
// Get device identifier (optional)
|
|
deviceIden := service.Config["device_iden"]
|
|
|
|
// Prepare notification title
|
|
titleTemplate := service.Config["title_template"]
|
|
if titleTemplate == "" {
|
|
titleTemplate = "GoMFT: {{job.event}} - {{job.name}}"
|
|
}
|
|
|
|
// Prepare notification body
|
|
bodyTemplate := service.Config["body_template"]
|
|
if bodyTemplate == "" {
|
|
bodyTemplate = "Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
|
}
|
|
|
|
// Create variables for template replacement
|
|
variables := generateDefaultPayload(job, history, config, eventType) // Use package-level helper
|
|
|
|
// Replace variables in templates
|
|
title := replaceVariables(titleTemplate, variables) // Use package-level helper
|
|
body := replaceVariables(bodyTemplate, variables) // Use package-level helper
|
|
|
|
// Prepare request data
|
|
url := "https://api.pushbullet.com/v2/pushes"
|
|
data := map[string]interface{}{
|
|
"type": "note",
|
|
"title": title,
|
|
"body": body,
|
|
}
|
|
|
|
// Add device identifier if provided
|
|
if deviceIden != "" {
|
|
data["device_iden"] = deviceIden
|
|
}
|
|
|
|
// Convert data to JSON
|
|
jsonData, err := json.Marshal(data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal Pushbullet notification data: %v", err)
|
|
}
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create Pushbullet request: %v", err)
|
|
}
|
|
|
|
// Set headers
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Access-Token", apiKey)
|
|
|
|
// Send the request
|
|
client := createHTTPClient(10*time.Second, n.skipSSLVerify)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send Pushbullet notification: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check response
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("Pushbullet API returned error status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
n.logger.LogInfo("Successfully sent Pushbullet notification for job %d", job.ID)
|
|
return nil
|
|
}
|
|
|
|
// sendNtfyNotification sends a notification via ntfy.sh or a self-hosted ntfy server.
|
|
func (n *Notifier) sendNtfyNotification(service *db.NotificationService, job *db.Job, history *db.JobHistory, config *db.TransferConfig, eventType string) error {
|
|
n.logger.LogDebug("Sending ntfy notification for job %d", job.ID)
|
|
|
|
// Get ntfy server and topic from service config
|
|
topic, ok := service.Config["topic"]
|
|
if !ok || topic == "" {
|
|
return fmt.Errorf("missing topic for ntfy notification")
|
|
}
|
|
|
|
// Get server (use default if not provided)
|
|
server := service.Config["server"]
|
|
if server == "" {
|
|
server = "https://ntfy.sh"
|
|
}
|
|
|
|
// Prepare notification title
|
|
titleTemplate := service.Config["title_template"]
|
|
if titleTemplate == "" {
|
|
titleTemplate = "GoMFT: {{job.event}} - {{job.name}}"
|
|
}
|
|
|
|
// Prepare notification body
|
|
messageTemplate := service.Config["message_template"]
|
|
if messageTemplate == "" {
|
|
messageTemplate = "Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
|
}
|
|
|
|
// Get priority if specified, default to 3
|
|
priority := 3
|
|
if priorityStr, ok := service.Config["priority"]; ok && priorityStr != "" {
|
|
if p, err := strconv.Atoi(priorityStr); err == nil && p >= 1 && p <= 5 {
|
|
priority = p
|
|
}
|
|
}
|
|
|
|
// Create variables for template replacement
|
|
variables := generateDefaultPayload(job, history, config, eventType) // Use package-level helper
|
|
|
|
// Replace variables in templates
|
|
title := replaceVariables(titleTemplate, variables) // Use package-level helper
|
|
message := replaceVariables(messageTemplate, variables) // Use package-level helper
|
|
|
|
// Create the URL for the notification
|
|
ntfyURL := fmt.Sprintf("%s/%s", strings.TrimRight(server, "/"), topic)
|
|
|
|
// Create the notification data
|
|
ntfyData := map[string]interface{}{
|
|
"topic": topic,
|
|
"title": title,
|
|
"message": message,
|
|
"priority": priority,
|
|
}
|
|
|
|
// Add username and password if provided
|
|
username := service.Config["username"]
|
|
password := service.Config["password"]
|
|
|
|
// Convert data to JSON
|
|
jsonData, err := json.Marshal(ntfyData)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal ntfy notification data: %v", err)
|
|
}
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequest("POST", ntfyURL, bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create ntfy request: %v", err)
|
|
}
|
|
|
|
// Set headers
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Add basic auth if credentials provided
|
|
if username != "" && password != "" {
|
|
req.SetBasicAuth(username, password)
|
|
}
|
|
|
|
// Send the request
|
|
client := createHTTPClient(10*time.Second, n.skipSSLVerify)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send ntfy notification: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check response
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("ntfy API returned error status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
n.logger.LogInfo("Successfully sent ntfy notification for job %d", job.ID)
|
|
return nil
|
|
}
|
|
|
|
// sendGotifyNotification sends a notification via Gotify.
|
|
func (n *Notifier) sendGotifyNotification(service *db.NotificationService, job *db.Job, history *db.JobHistory, config *db.TransferConfig, eventType string) error {
|
|
n.logger.LogDebug("Sending Gotify notification for job %d", job.ID)
|
|
|
|
// Get Gotify server URL and token from service config
|
|
serverURL, ok := service.Config["url"]
|
|
if !ok || serverURL == "" {
|
|
return fmt.Errorf("missing server URL for Gotify notification")
|
|
}
|
|
|
|
token, ok := service.Config["token"]
|
|
if !ok || token == "" {
|
|
return fmt.Errorf("missing application token for Gotify notification")
|
|
}
|
|
|
|
// Prepare notification title
|
|
titleTemplate := service.Config["title_template"]
|
|
if titleTemplate == "" {
|
|
titleTemplate = "GoMFT: {{job.event}} - {{job.name}}"
|
|
}
|
|
|
|
// Prepare notification message
|
|
messageTemplate := service.Config["message_template"]
|
|
if messageTemplate == "" {
|
|
messageTemplate = "Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
|
}
|
|
|
|
// Get priority if specified, default to 5
|
|
priority := 5
|
|
if priorityStr, ok := service.Config["priority"]; ok && priorityStr != "" {
|
|
if p, err := strconv.Atoi(priorityStr); err == nil {
|
|
priority = p
|
|
}
|
|
}
|
|
|
|
// Create variables for template replacement
|
|
variables := generateDefaultPayload(job, history, config, eventType) // Use package-level helper
|
|
|
|
// Replace variables in templates
|
|
title := replaceVariables(titleTemplate, variables) // Use package-level helper
|
|
message := replaceVariables(messageTemplate, variables) // Use package-level helper
|
|
|
|
// Create the URL for the notification
|
|
gotifyURL := fmt.Sprintf("%s/message", strings.TrimRight(serverURL, "/"))
|
|
|
|
// Create the notification data
|
|
gotifyData := map[string]interface{}{
|
|
"title": title,
|
|
"message": message,
|
|
"priority": priority,
|
|
}
|
|
|
|
// Convert data to JSON
|
|
jsonData, err := json.Marshal(gotifyData)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal Gotify notification data: %v", err)
|
|
}
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequest("POST", gotifyURL, bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create Gotify request: %v", err)
|
|
}
|
|
|
|
// Set headers
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Gotify-Key", token)
|
|
|
|
// Send the request
|
|
client := createHTTPClient(10*time.Second, n.skipSSLVerify)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send Gotify notification: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check response
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("Gotify API returned error status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
n.logger.LogInfo("Successfully sent Gotify notification for job %d", job.ID)
|
|
return nil
|
|
}
|
|
|
|
// sendPushoverNotification sends a notification via Pushover.
|
|
func (n *Notifier) sendPushoverNotification(service *db.NotificationService, job *db.Job, history *db.JobHistory, config *db.TransferConfig, eventType string) error {
|
|
n.logger.LogDebug("Sending Pushover notification for job %d", job.ID)
|
|
|
|
// Get Pushover tokens from service config
|
|
appToken, ok := service.Config["app_token"]
|
|
if !ok || appToken == "" {
|
|
return fmt.Errorf("missing application token/key for Pushover notification")
|
|
}
|
|
|
|
userKey, ok := service.Config["user_key"]
|
|
if !ok || userKey == "" {
|
|
return fmt.Errorf("missing user key for Pushover notification")
|
|
}
|
|
|
|
// Get optional device
|
|
device := service.Config["device"]
|
|
|
|
// Prepare notification title
|
|
titleTemplate := service.Config["title_template"]
|
|
if titleTemplate == "" {
|
|
titleTemplate = "GoMFT: {{job.event}} - {{job.name}}"
|
|
}
|
|
|
|
// Prepare notification message
|
|
messageTemplate := service.Config["message_template"]
|
|
if messageTemplate == "" {
|
|
messageTemplate = "Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
|
}
|
|
|
|
// Get priority if specified, default to 0 (normal)
|
|
priority := 0
|
|
if priorityStr, ok := service.Config["priority"]; ok && priorityStr != "" {
|
|
if p, err := strconv.Atoi(priorityStr); err == nil && p >= -2 && p <= 2 {
|
|
priority = p
|
|
}
|
|
}
|
|
|
|
// Get sound if specified
|
|
sound := service.Config["sound"]
|
|
if sound == "" {
|
|
sound = "pushover" // Default sound
|
|
}
|
|
|
|
// Create variables for template replacement
|
|
variables := generateDefaultPayload(job, history, config, eventType) // Use package-level helper
|
|
|
|
// Replace variables in templates
|
|
title := replaceVariables(titleTemplate, variables) // Use package-level helper
|
|
message := replaceVariables(messageTemplate, variables) // Use package-level helper
|
|
|
|
// Create the URL for the notification
|
|
pushoverURL := "https://api.pushover.net/1/messages.json"
|
|
|
|
// Create the form data
|
|
formData := url.Values{}
|
|
formData.Set("token", appToken)
|
|
formData.Set("user", userKey)
|
|
formData.Set("title", title)
|
|
formData.Set("message", message)
|
|
formData.Set("priority", strconv.Itoa(priority))
|
|
formData.Set("sound", sound)
|
|
|
|
// Add device if specified
|
|
if device != "" {
|
|
formData.Set("device", device)
|
|
}
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequest("POST", pushoverURL, strings.NewReader(formData.Encode()))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create Pushover request: %v", err)
|
|
}
|
|
|
|
// Set headers
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
// Send the request
|
|
client := createHTTPClient(10*time.Second, n.skipSSLVerify)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send Pushover notification: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check response and parse the JSON
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("Pushover API returned error status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
// Check response for success status
|
|
var pushoverResp map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&pushoverResp); err != nil {
|
|
return fmt.Errorf("failed to decode Pushover response: %v", err)
|
|
}
|
|
|
|
// Verify the status is 1 (success)
|
|
if status, ok := pushoverResp["status"].(float64); !ok || status != 1 {
|
|
errMsg := "unknown error"
|
|
if errors, ok := pushoverResp["errors"].([]interface{}); ok && len(errors) > 0 {
|
|
errMsg = fmt.Sprintf("%v", errors[0])
|
|
}
|
|
return fmt.Errorf("Pushover API returned error: %s", errMsg)
|
|
}
|
|
|
|
n.logger.LogInfo("Successfully sent Pushover notification for job %d", job.ID)
|
|
return nil
|
|
}
|