feat: Enhance notification service configuration and refactor related components

- Updated notification service models to use pointers for boolean fields, allowing for better handling of enabled states.
- Introduced helper methods for getting and setting enabled states in notification and auth provider models.
- Refactored notification form templates to consolidate event trigger handling, improving code maintainability.
- Added SSL verification configuration to the application settings, allowing users to skip SSL verification for outgoing notifications.
- Enhanced the notification sending logic to respect the SSL verification setting.
- Introduced new migration to alter boolean defaults in the database schema for better compatibility with the updated models.
- Added unit tests for email and Rclone service functionalities to ensure reliability.
This commit is contained in:
StarFleetCPTN
2025-03-30 09:56:55 -07:00
parent b35174f857
commit 608da9ce1c
34 changed files with 1230 additions and 896 deletions
+12
View File
@@ -16,6 +16,7 @@ type Config struct {
Email EmailConfig `json:"email"`
BaseURL string `json:"base_url"` // Base URL for generating links in emails
TOTPEncryptKey string `json:"totp_encrypt_key"` // Encryption key for TOTP secrets
SkipSSLVerify bool `json:"skip_ssl_verify"` // Skip SSL verification for outgoing webhooks/notifications
}
type EmailConfig struct {
@@ -40,6 +41,7 @@ func Load() (*Config, error) {
JWTSecret: "change_this_to_a_secure_random_string",
BaseURL: "http://localhost:8080",
TOTPEncryptKey: "this-is-a-dev-key-not-for-production!", // Default development key
SkipSSLVerify: false, // Default to verifying SSL
Email: EmailConfig{
Enabled: false,
Host: "smtp.example.com",
@@ -119,6 +121,12 @@ func Load() (*Config, error) {
if emailRequireAuth := os.Getenv("EMAIL_REQUIRE_AUTH"); emailRequireAuth != "" {
cfg.Email.RequireAuth = strings.ToLower(emailRequireAuth) == "true"
}
// SSL Verification configuration
if sslVerify := os.Getenv("SSL_VERIFY"); sslVerify != "" {
// Default is true (verify), only set SkipSSLVerify to true if env var is explicitly "false"
cfg.SkipSSLVerify = strings.ToLower(sslVerify) == "false"
}
} else if !os.IsNotExist(err) {
return nil, err
} else {
@@ -148,6 +156,10 @@ func Load() (*Config, error) {
"EMAIL_REQUIRE_AUTH=" + strconv.FormatBool(cfg.Email.RequireAuth),
"EMAIL_USERNAME=" + cfg.Email.Username,
"EMAIL_PASSWORD=" + cfg.Email.Password,
"",
"# SSL Verification for outgoing notifications (webhooks, etc.)",
"# Set to false to disable SSL certificate verification (USE WITH CAUTION)",
"SSL_VERIFY=" + strconv.FormatBool(!cfg.SkipSSLVerify), // Default is true (verify)
}
if err := os.WriteFile(envPath, []byte(strings.Join(envContent, "\n")), 0644); err != nil {
+16 -1
View File
@@ -28,7 +28,7 @@ type AuthProvider struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"not null" json:"name"`
Type ProviderType `gorm:"not null" json:"type"`
Enabled bool `gorm:"default:true" json:"enabled"`
Enabled *bool `gorm:"default:true" json:"enabled"`
Description string `json:"description"`
ProviderURL string `json:"provider_url"`
ClientID string `json:"client_id"`
@@ -75,6 +75,21 @@ func (p *AuthProvider) SetConfig(data map[string]interface{}) error {
return nil
}
// --- AuthProvider Helper Methods ---
// GetEnabled returns the value of Enabled with a default if nil
func (p *AuthProvider) GetEnabled() bool {
if p.Enabled == nil {
return true // Default to true if not set
}
return *p.Enabled
}
// SetEnabled sets the Enabled field
func (p *AuthProvider) SetEnabled(value bool) {
p.Enabled = &value
}
// ExternalUserIdentity represents a user identity from an external authentication provider
type ExternalUserIdentity struct {
ID uint `gorm:"primarykey" json:"id"`
@@ -0,0 +1,281 @@
package migrations
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AlterBooleanDefaults changes boolean columns with default:true to pointers
// using explicit table recreation with raw SQL for SQLite compatibility.
func AlterBooleanDefaults() *gormigrate.Migration {
// --- Raw SQL CREATE TABLE statements for the target schema ---
const createNotificationServicesSQL = `
CREATE TABLE notification_services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
is_enabled INTEGER DEFAULT 1, -- Target: *bool, SQLite uses 0/1, default true
config TEXT,
description TEXT,
event_triggers TEXT DEFAULT '[]',
payload_template TEXT,
secret_key TEXT,
retry_policy TEXT DEFAULT 'simple',
last_used timestamp,
success_count INTEGER DEFAULT 0,
failure_count INTEGER DEFAULT 0,
created_by INTEGER,
created_at timestamp,
updated_at timestamp
);`
const createAuthProvidersSQL = `
CREATE TABLE auth_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
enabled INTEGER DEFAULT 1, -- Target: *bool, SQLite uses 0/1, default true
description TEXT,
provider_url TEXT,
client_id TEXT,
client_secret TEXT,
redirect_url TEXT,
scopes TEXT,
attribute_mapping TEXT,
config TEXT,
icon_url TEXT,
successful_logins INTEGER DEFAULT 0,
last_used timestamp,
created_at timestamp,
updated_at timestamp
);`
const createTransferConfigsSQL = `
CREATE TABLE transfer_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
source_type TEXT NOT NULL,
source_path TEXT NOT NULL,
source_host TEXT,
source_port INTEGER DEFAULT 22,
source_user TEXT,
source_key_file TEXT,
source_bucket TEXT,
source_region TEXT,
source_access_key TEXT,
source_endpoint TEXT,
source_share TEXT,
source_domain TEXT,
source_passive_mode INTEGER DEFAULT 1, -- Already *bool, keep default
source_client_id TEXT,
source_drive_id TEXT,
source_team_drive TEXT,
source_read_only INTEGER,
source_start_year INTEGER,
source_include_archived INTEGER,
file_pattern TEXT DEFAULT '*',
output_pattern TEXT,
destination_type TEXT NOT NULL,
destination_path TEXT NOT NULL,
dest_host TEXT,
dest_port INTEGER DEFAULT 22,
dest_user TEXT,
dest_key_file TEXT,
dest_bucket TEXT,
dest_region TEXT,
dest_access_key TEXT,
dest_endpoint TEXT,
dest_share TEXT,
dest_domain TEXT,
dest_passive_mode INTEGER DEFAULT 1, -- Already *bool, keep default
dest_client_id TEXT,
dest_drive_id TEXT,
dest_team_drive TEXT,
dest_read_only INTEGER,
dest_start_year INTEGER,
dest_include_archived INTEGER,
use_builtin_auth_source INTEGER,
use_builtin_auth_dest INTEGER,
google_drive_authenticated INTEGER,
archive_path TEXT,
archive_enabled INTEGER DEFAULT 0,
rclone_flags TEXT,
command_id INTEGER DEFAULT 1,
command_flags TEXT,
command_flag_values TEXT,
delete_after_transfer INTEGER DEFAULT 0,
skip_processed_files INTEGER DEFAULT 1, -- Target: *bool, SQLite uses 0/1, default true
max_concurrent_transfers INTEGER DEFAULT 4,
created_by INTEGER,
created_at timestamp,
updated_at timestamp
);`
// --- End Raw SQL ---
return &gormigrate.Migration{
ID: "012_alter_boolean_defaults",
Migrate: func(tx *gorm.DB) error {
// --- Backup Logic (copied) ---
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
if count > 0 {
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups"
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups"
}
}
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// --- End Backup Logic ---
// --- Table Recreation Logic for SQLite ---
if err := tx.Exec("PRAGMA foreign_keys = OFF").Error; err != nil {
return fmt.Errorf("failed to disable foreign keys: %w", err)
}
defer func() {
if err := tx.Exec("PRAGMA foreign_keys = ON").Error; err != nil {
fmt.Printf("Warning: failed to re-enable foreign keys: %v\n", err)
}
}()
// Helper function for table recreation
recreateTable := func(tableName, createSQL string) error {
fmt.Printf("Recreating table %s...\n", tableName)
oldTableName := fmt.Sprintf("_%s_old", tableName)
// Rename old table
if err := tx.Exec(fmt.Sprintf("ALTER TABLE %s RENAME TO %s", tableName, oldTableName)).Error; err == nil {
fmt.Printf("Renamed %s to %s.\n", tableName, oldTableName)
// Create new table using raw SQL
fmt.Printf("Creating new %s table...\n", tableName)
if err := tx.Exec(createSQL).Error; err != nil {
return fmt.Errorf("failed to create new %s table: %w", tableName, err)
}
fmt.Printf("New %s table created.\n", tableName)
// Copy data
fmt.Printf("Copying data to new %s table...\n", tableName)
// IMPORTANT: Ensure column order/names match if schema changed beyond types/defaults
if err := tx.Exec(fmt.Sprintf("INSERT INTO %s SELECT * FROM %s", tableName, oldTableName)).Error; err != nil {
return fmt.Errorf("failed to copy data to new %s table: %w", tableName, err)
}
fmt.Printf("Data copied to %s.\n", tableName)
// Drop old table
if err := tx.Exec(fmt.Sprintf("DROP TABLE %s", oldTableName)).Error; err != nil {
return fmt.Errorf("failed to drop old %s table: %w", tableName, err)
}
fmt.Printf("Successfully recreated %s.\n", tableName)
} else {
// Check if rename failed because table doesn't exist (fresh install)
var tableExists int
tx.Raw(fmt.Sprintf("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='%s'", tableName)).Scan(&tableExists)
if tableExists == 0 {
fmt.Printf("%s table does not exist, creating.\n", tableName)
if err := tx.Exec(createSQL).Error; err != nil { // Create table directly
return fmt.Errorf("failed to create new %s table: %w", tableName, err)
}
} else {
return fmt.Errorf("failed to rename %s: %w", tableName, err) // Real rename error
}
}
return nil
}
// Recreate tables
if err := recreateTable("notification_services", createNotificationServicesSQL); err != nil {
return err
}
if err := recreateTable("auth_providers", createAuthProvidersSQL); err != nil {
return err
}
if err := recreateTable("transfer_configs", createTransferConfigsSQL); err != nil {
return err
}
// --- End Table Recreation Logic ---
// Create audit log entry
now := time.Now()
details, auditErr := json.Marshal(map[string]interface{}{
"tables_affected": []string{"notification_services", "auth_providers", "transfer_configs"},
"columns_altered": []string{"is_enabled", "enabled", "skip_processed_files"},
"new_type": "*bool (pointer to boolean)",
"method": "Table recreation (SQLite - Raw SQL)",
"message": "Changed boolean columns with default:true to pointers to handle false values correctly with GORM.",
})
if auditErr != nil {
fmt.Printf("Warning: Failed to marshal audit log details: %v\n", auditErr)
}
if auditErr == nil {
if auditExecErr := tx.Exec(`
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
VALUES ('schema_update', 'multiple_tables', 0, 1, ?, ?, ?, ?)
`, string(details), now, now, now).Error; auditExecErr != nil {
fmt.Printf("Warning: Failed to insert audit log: %v\n", auditExecErr)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Rollback is complex and risky with table recreation. Log skip.
now := time.Now()
details, err := json.Marshal(map[string]interface{}{
"migration_id": "012_alter_boolean_defaults",
"message": "Skipping rollback of boolean column type changes (via table recreation) due to complexity/potential data loss.",
})
if err != nil {
fmt.Printf("Warning: Failed to marshal rollback audit log details: %v\n", err)
}
if err == nil {
if auditExecErr := tx.Exec(`
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
VALUES ('migration_rollback', 'multiple_tables', 0, 1, ?, ?, ?, ?)
`, string(details), now, now, now).Error; auditExecErr != nil {
fmt.Printf("Warning: Failed to insert rollback audit log: %v\n", auditExecErr)
}
}
fmt.Println("Rollback for migration 012_alter_boolean_defaults skipped for safety.")
return nil
},
}
}
+1
View File
@@ -22,6 +22,7 @@ func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate {
AddRcloneTables(), // 009
AddRcloneCommandToConfig(), // 010
AddAuthProviders(), // 011
AlterBooleanDefaults(), // 012
)
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
+21 -1
View File
@@ -12,7 +12,7 @@ type NotificationService struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Type string `json:"type" gorm:"not null"` // email, webhook
IsEnabled bool `json:"is_enabled" gorm:"default:true"`
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"`
@@ -69,6 +69,8 @@ func (db *DB) GetNotificationServices(onlyEnabled bool) ([]NotificationService,
query := db.DB
if onlyEnabled {
// When using a pointer, we need to explicitly check for true
// GORM handles the underlying SQL correctly for different dialects
query = query.Where("is_enabled = ?", true)
}
@@ -102,3 +104,21 @@ func (db *DB) UpdateNotificationService(service *NotificationService) error {
func (db *DB) DeleteNotificationService(id uint) error {
return db.Delete(&NotificationService{}, id).Error
}
// --- NotificationService Helper Methods ---
// GetIsEnabled returns the value of IsEnabled with a default if nil
func (n *NotificationService) GetIsEnabled() bool {
if n.IsEnabled == nil {
// If the pointer is nil, GORM might not have set it,
// or it was explicitly set to nil. We assume the DB default (true)
// if it's nil, aligning with the original gorm tag default.
return true
}
return *n.IsEnabled
}
// SetIsEnabled sets the IsEnabled field
func (n *NotificationService) SetIsEnabled(value bool) {
n.IsEnabled = &value
}
+1 -1
View File
@@ -25,7 +25,7 @@ type TransferConfig struct {
SourceShare string `form:"source_share"`
SourceDomain string `form:"source_domain"`
// FTP source fields
SourcePassiveMode *bool `gorm:"default:true" form:"source_passive_mode"`
SourcePassiveMode *bool `gorm:"default:true" form:"source_passive_mode"` // Already a pointer, no change needed here
// OneDrive and Google Drive source fields
SourceClientID string `form:"source_client_id"`
SourceClientSecret string `form:"source_client_secret" gorm:"-"` // Not stored in DB, only used for form
+201
View File
@@ -0,0 +1,201 @@
package email
import (
"fmt"
"strings"
"testing"
"time"
"github.com/starfleetcptn/gomft/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createTestConfig(enabled bool) *config.Config {
return &config.Config{
BaseURL: "http://localhost:8080",
Email: config.EmailConfig{
Enabled: enabled,
Host: "smtp.example.com",
Port: 587,
Username: "user",
Password: "password",
FromEmail: "noreply@example.com",
FromName: "GoMFT Test",
RequireAuth: true,
EnableTLS: true,
ReplyTo: "support@example.com",
},
}
}
func TestNewService(t *testing.T) {
cfg := createTestConfig(true)
service := NewService(cfg)
require.NotNil(t, service)
assert.Equal(t, cfg, service.Config)
}
func TestGeneratePasswordResetEmailHTML(t *testing.T) {
cfg := createTestConfig(true)
service := NewService(cfg)
testUsername := "testuser"
testResetLink := "http://localhost:8080/reset-password?token=testtoken123"
testAppName := "GoMFT"
testYear := time.Now().Year()
data := map[string]interface{}{
"Username": testUsername,
"ResetLink": testResetLink,
"AppName": testAppName,
"Year": testYear,
"ExpiresHours": 0.25,
}
htmlContent, err := service.generatePasswordResetEmailHTML(data)
require.NoError(t, err)
require.NotEmpty(t, htmlContent)
// Basic checks for content presence
assert.Contains(t, htmlContent, "Reset Your Password")
assert.Contains(t, htmlContent, fmt.Sprintf("Hello %s", testUsername))
assert.Contains(t, htmlContent, testResetLink) // Check link appears (both in button and text)
assert.Contains(t, htmlContent, fmt.Sprintf("href=\"%s\"", testResetLink))
assert.Contains(t, htmlContent, fmt.Sprintf("© %d %s", testYear, testAppName))
assert.Contains(t, htmlContent, "This link will expire in 15 minutes.")
// Test without username
dataNoUser := map[string]interface{}{
"ResetLink": testResetLink,
"AppName": testAppName,
"Year": testYear,
"ExpiresHours": 0.25,
}
htmlContentNoUser, err := service.generatePasswordResetEmailHTML(dataNoUser)
require.NoError(t, err)
assert.Contains(t, htmlContentNoUser, "Hello,") // Should just say Hello,
assert.NotContains(t, htmlContentNoUser, fmt.Sprintf("Hello %s", testUsername))
}
func TestGenerateTestEmailHTML(t *testing.T) {
cfg := createTestConfig(true)
service := NewService(cfg)
testSubject := "My Test Subject"
testMessage := "This is the test message body."
testAppName := "GoMFT"
testYear := time.Now().Year()
testCurrentTime := time.Now().Format(time.RFC1123Z) // Use the same format
data := map[string]interface{}{
"Subject": testSubject,
"Message": testMessage,
"AppName": testAppName,
"Year": testYear,
"SMTPServer": cfg.Email.Host,
"SMTPPort": cfg.Email.Port,
"FromEmail": cfg.Email.FromEmail,
"CurrentTime": testCurrentTime,
}
htmlContent, err := service.generateTestEmailHTML(data)
require.NoError(t, err)
require.NotEmpty(t, htmlContent)
// Basic checks for content presence
assert.Contains(t, htmlContent, fmt.Sprintf("<title>%s</title>", testSubject))
assert.Contains(t, htmlContent, fmt.Sprintf("<h1>%s</h1>", testSubject))
assert.Contains(t, htmlContent, testMessage)
assert.Contains(t, htmlContent, fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port))
assert.Contains(t, htmlContent, cfg.Email.FromEmail)
assert.Contains(t, htmlContent, testCurrentTime)
assert.Contains(t, htmlContent, fmt.Sprintf("&copy; %d %s", testYear, testAppName))
}
func TestSendPasswordResetEmail_Disabled(t *testing.T) {
cfg := createTestConfig(false) // Email disabled
service := NewService(cfg)
toEmail := "test@example.com"
username := "testuser"
resetToken := "disabledtoken123"
err := service.SendPasswordResetEmail(toEmail, username, resetToken)
require.Error(t, err)
expectedErrorSubstr := fmt.Sprintf("email service is disabled, reset link would be: %s/reset-password?token=%s",
cfg.BaseURL, resetToken)
assert.Contains(t, err.Error(), expectedErrorSubstr)
}
func TestSendTestEmail_Disabled(t *testing.T) {
cfg := createTestConfig(false) // Email disabled
service := NewService(cfg)
toEmail := "test@example.com"
err := service.SendTestEmail(toEmail, "Test Subject", "Test Message")
require.Error(t, err)
assert.EqualError(t, err, "email service is disabled")
}
// --- Placeholder/TODO for more complex tests ---
// TODO: TestSendPasswordResetEmail_Enabled - Requires mocking sendEmail or SMTP interactions
// TODO: TestSendTestEmail_Enabled - Requires mocking sendEmail or SMTP interactions
// TODO: TestSendEmail - Requires extensive mocking of net/smtp package
// Example structure for testing enabled path (without actual sending/mocking)
// This verifies the function prepares the correct data before calling sendEmail
func TestSendPasswordResetEmail_Enabled_DataPreparation(t *testing.T) {
cfg := createTestConfig(true)
service := NewService(cfg)
// We need a way to intercept the call to sendEmail or verify its inputs
// For now, we just check that no error occurs up to that point
// and that the HTML generation works (implicitly tested by TestGeneratePasswordResetEmailHTML)
toEmail := "recipient@example.com"
username := "testuser-enabled"
resetToken := "enabledtoken456"
// If generatePasswordResetEmailHTML works, this call should proceed
// without error until the actual sendEmail call (which we aren't testing here)
// A full test would mock sendEmail and verify the arguments passed to it.
err := service.SendPasswordResetEmail(toEmail, username, resetToken)
// In a real scenario without mocking, this might fail if SMTP connection fails.
// For this basic check, we assume HTML generation is the main potential failure point *before* sendEmail.
// If TestGeneratePasswordResetEmailHTML passes, we expect no error *from generation*.
// We cannot assert assert.NoError(t, err) reliably without mocking sendEmail.
t.Logf("SendPasswordResetEmail (enabled) returned: %v (expected success or SMTP error)", err)
// Asserting that the error, if any, is NOT related to template generation could be a weak check.
if err != nil {
assert.False(t, strings.Contains(err.Error(), "template"), "Error should be SMTP related, not template related")
}
}
func TestSendTestEmail_Enabled_DataPreparation(t *testing.T) {
cfg := createTestConfig(true)
service := NewService(cfg)
toEmail := "recipient@example.com"
subject := "Specific Test Subject"
message := "Specific test message."
// Test with specific subject and message
err := service.SendTestEmail(toEmail, subject, message)
t.Logf("SendTestEmail (enabled, specific) returned: %v (expected success or SMTP error)", err)
if err != nil {
assert.False(t, strings.Contains(err.Error(), "template"), "Error should be SMTP related, not template related")
}
// Test with default subject and message
errDefault := service.SendTestEmail(toEmail, "", "")
t.Logf("SendTestEmail (enabled, default) returned: %v (expected success or SMTP error)", errDefault)
if errDefault != nil {
assert.False(t, strings.Contains(errDefault.Error(), "template"), "Error should be SMTP related, not template related")
}
// A full test would mock sendEmail and verify the subject/message passed (checking defaults).
}
+48 -66
View File
@@ -3,6 +3,7 @@ package rclone_service
import (
"bytes"
"context"
"errors"
"fmt"
"log"
"os"
@@ -14,27 +15,36 @@ import (
"github.com/starfleetcptn/gomft/internal/db"
)
// --- Mockable os/exec ---
// execCommandContext allows mocking exec.CommandContext during tests.
var execCommandContext = exec.CommandContext
// cmdCombinedOutput allows mocking the CombinedOutput method during tests.
var cmdCombinedOutput = (*exec.Cmd).CombinedOutput
// cmdRun allows mocking the Run method during tests.
var cmdRun = (*exec.Cmd).Run
// --- Function Implementation ---
// TestRcloneConnection attempts to connect to a provider using temporary config created via `rclone config create`.
// It returns success (bool), a message (string), and an error.
func TestRcloneConnection(config db.TransferConfig, providerType string, dbInstance *db.DB) (bool, string, error) {
var remoteName string
var remotePath string
var provider string
// Removed bucket, share as they are not used in current rclone config create args
var host, user, pass, keyFile, region, accessKey, secretKey, endpoint, domain, clientID, clientSecret, driveID, teamDrive string
var port int
// Add other necessary fields like passiveMode, readOnly etc. if needed by rclone config create for specific types
var err error
// Create a temporary directory for the config file
tempDir, err := os.MkdirTemp("", "gomft-rclone-test-")
if err != nil {
return false, "Failed to create temp directory for rclone config", err
}
defer os.RemoveAll(tempDir) // Clean up the temp directory
defer os.RemoveAll(tempDir)
tempConfigPath := filepath.Join(tempDir, "rclone_test.conf")
// Extract parameters based on providerType
if providerType == "source" {
remoteName = "testSource"
remotePath = config.SourcePath
@@ -44,18 +54,15 @@ func TestRcloneConnection(config db.TransferConfig, providerType string, dbInsta
user = config.SourceUser
pass = config.SourcePassword
keyFile = config.SourceKeyFile
// bucket = config.SourceBucket // Removed - Not used in create args yet
region = config.SourceRegion
accessKey = config.SourceAccessKey
secretKey = config.SourceSecretKey
endpoint = config.SourceEndpoint
// share = config.SourceShare // Removed - Not used in create args yet
domain = config.SourceDomain
clientID = config.SourceClientID
clientSecret = config.SourceClientSecret
driveID = config.SourceDriveID
teamDrive = config.SourceTeamDrive
// Extract other source fields as needed (e.g., passiveMode, readOnly)
} else if providerType == "destination" {
remoteName = "testDest"
remotePath = config.DestinationPath
@@ -65,46 +72,38 @@ func TestRcloneConnection(config db.TransferConfig, providerType string, dbInsta
user = config.DestUser
pass = config.DestPassword
keyFile = config.DestKeyFile
// bucket = config.DestBucket // Removed - Not used in create args yet
region = config.DestRegion
accessKey = config.DestAccessKey
secretKey = config.DestSecretKey
endpoint = config.DestEndpoint
// share = config.DestShare // Removed - Not used in create args yet
domain = config.DestDomain
clientID = config.DestClientID
clientSecret = config.DestClientSecret
driveID = config.DestDriveID
teamDrive = config.DestTeamDrive
// Extract other dest fields as needed (e.g., passiveMode, readOnly)
} else {
return false, "Invalid provider type specified", fmt.Errorf("unknown provider type: %s", providerType)
}
// Get the rclone path from the environment variable or use the default path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
// --- Use `rclone config create` to generate the temporary config section ---
createArgs := []string{
"config", "create", remoteName, provider,
"--config", tempConfigPath,
"--non-interactive",
"--log-level", "DEBUG", // Use DEBUG for create to see details if it fails
"--log-level", "DEBUG",
}
// --- Declare variables for lsd command *before* the switch/goto ---
var ctx context.Context
var cancel context.CancelFunc
var lsdArgs []string
var stdout, stderr bytes.Buffer
var lsdCmd *exec.Cmd
var createCmd *exec.Cmd // Declare createCmd here as well
var createCmd *exec.Cmd
// Add provider-specific arguments
// Mirroring logic from db.GenerateRcloneConfig but passing args to CLI
switch provider {
case "sftp":
createArgs = append(createArgs, "host", host, "user", user)
@@ -112,20 +111,18 @@ func TestRcloneConnection(config db.TransferConfig, providerType string, dbInsta
createArgs = append(createArgs, "port", fmt.Sprintf("%d", port))
}
if pass != "" {
createArgs = append(createArgs, "pass", pass) // Pass directly
createArgs = append(createArgs, "pass", pass)
}
if keyFile != "" {
createArgs = append(createArgs, "key_file", keyFile)
// Note: Passphrase for keyfile might need 'pass' too, rclone handles this context.
}
// Add other SFTP args like ssh_agent, use_insecure_cipher etc. if needed
case "s3":
createArgs = append(createArgs, "provider", "AWS", "env_auth", "false") // Assume AWS for generic S3
createArgs = append(createArgs, "provider", "AWS", "env_auth", "false")
if accessKey != "" {
createArgs = append(createArgs, "access_key_id", accessKey)
}
if secretKey != "" {
createArgs = append(createArgs, "secret_access_key", secretKey) // Pass directly
createArgs = append(createArgs, "secret_access_key", secretKey)
}
if region != "" {
createArgs = append(createArgs, "region", region)
@@ -133,19 +130,18 @@ func TestRcloneConnection(config db.TransferConfig, providerType string, dbInsta
if endpoint != "" {
createArgs = append(createArgs, "endpoint", endpoint)
}
// Add other S3 args like acl, storage_class if needed
case "minio":
createArgs = append(createArgs, "provider", "Minio", "env_auth", "false")
if accessKey != "" {
createArgs = append(createArgs, "access_key_id", accessKey)
}
if secretKey != "" {
createArgs = append(createArgs, "secret_access_key", secretKey) // Pass directly
createArgs = append(createArgs, "secret_access_key", secretKey)
}
if endpoint != "" {
createArgs = append(createArgs, "endpoint", endpoint)
}
if region != "" { // Minio might ignore region, but add if present
if region != "" {
createArgs = append(createArgs, "region", region)
}
case "ftp":
@@ -156,13 +152,12 @@ func TestRcloneConnection(config db.TransferConfig, providerType string, dbInsta
if pass != "" {
createArgs = append(createArgs, "pass", pass)
}
// Add explicit_tls, passive_mode based on config boolean pointers
if config.GetSourcePassiveMode() || config.GetDestPassiveMode() { // Check based on providerType
if config.GetSourcePassiveMode() || config.GetDestPassiveMode() {
createArgs = append(createArgs, "passive_mode", "true")
} else {
createArgs = append(createArgs, "passive_mode", "false")
}
createArgs = append(createArgs, "explicit_tls", "true") // Defaulting to true
createArgs = append(createArgs, "explicit_tls", "true")
case "smb":
createArgs = append(createArgs, "host", host, "user", user)
if port != 0 {
@@ -175,7 +170,7 @@ func TestRcloneConnection(config db.TransferConfig, providerType string, dbInsta
createArgs = append(createArgs, "domain", domain)
}
case "webdav":
createArgs = append(createArgs, "url", endpoint, "vendor", "other", "user", user) // Default vendor
createArgs = append(createArgs, "url", endpoint, "vendor", "other", "user", user)
if pass != "" {
createArgs = append(createArgs, "pass", pass)
}
@@ -184,84 +179,70 @@ func TestRcloneConnection(config db.TransferConfig, providerType string, dbInsta
if pass != "" {
createArgs = append(createArgs, "pass", pass)
}
// Add cases for gdrive, gphotos - these are complex due to token handling
// For testing, they might require pre-existing tokens or manual auth flow outside this scope.
// Passing client_id/secret might work for initial setup but not subsequent tests without a token.
case "gdrive":
createArgs = append(createArgs, "scope", "drive") // Default scope
createArgs = append(createArgs, "scope", "drive")
if clientID != "" {
createArgs = append(createArgs, "client_id", clientID)
}
if clientSecret != "" {
createArgs = append(createArgs, "client_secret", clientSecret)
}
if driveID != "" { // Use root_folder_id for specific drive/folder
if driveID != "" {
createArgs = append(createArgs, "root_folder_id", driveID)
}
if teamDrive != "" {
createArgs = append(createArgs, "team_drive", teamDrive)
}
// Cannot pass token directly via 'config create' easily for testing non-interactive flow.
log.Println("Warning: Google Drive test may require pre-existing token or manual auth.")
case "gphotos":
// Similar complexity to gdrive regarding tokens
if clientID != "" {
createArgs = append(createArgs, "client_id", clientID)
}
if clientSecret != "" {
createArgs = append(createArgs, "client_secret", clientSecret)
}
// Add read_only, start_year, include_archived based on config
// Cannot pass token directly via 'config create' easily for testing non-interactive flow.
log.Println("Warning: Google Photos test may require pre-existing token or manual auth.")
case "local":
// 'rclone config create' might not be needed or work well for 'local' type.
// Write a minimal config manually for local.
localConfigContent := fmt.Sprintf("[%s]\ntype = local\nnounc = true\n", remoteName)
if err := os.WriteFile(tempConfigPath, []byte(localConfigContent), 0600); err != nil {
return false, fmt.Sprintf("Failed to write temporary local config: %v", err), err
}
goto RunLsd // Skip rclone config create for local
goto RunLsd
default:
return false, fmt.Sprintf("Provider type '%s' not yet supported for testing via 'rclone config create'", provider), fmt.Errorf("unsupported provider")
}
log.Printf("Executing rclone config create command: %s %s", rclonePath, strings.Join(createArgs, " "))
createCmd = exec.Command(rclonePath, createArgs...) // Assign value using =
if output, err := createCmd.CombinedOutput(); err != nil {
// Log the config file content on error for debugging
createCmd = execCommandContext(context.Background(), rclonePath, createArgs...)
// Use the mockable function variable
if output, err := cmdCombinedOutput(createCmd); err != nil {
configContentBytes, _ := os.ReadFile(tempConfigPath)
log.Printf("Temp config content on create error:\n---\n%s\n---", string(configContentBytes))
return false, fmt.Sprintf("Failed to create temp config section: %v\nOutput: %s", err, string(output)), err
}
log.Printf("Successfully created temp config section for %s", remoteName)
RunLsd: // Label to jump to for local type
RunLsd:
// --- Execute `rclone lsd` using the temporary config ---
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) // Assign values
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
lsdArgs = []string{ // Assign value
lsdArgs = []string{
"--config", tempConfigPath,
"lsd",
fmt.Sprintf("%s:%s", remoteName, remotePath),
"--low-level-retries", "1",
"--retries", "1",
// Add -vv for verbose logging during test if needed
// "-vv",
}
log.Printf("Executing rclone lsd command: %s %s", rclonePath, strings.Join(lsdArgs, " "))
lsdCmd = exec.CommandContext(ctx, rclonePath, lsdArgs...) // Assign value
lsdCmd = execCommandContext(ctx, rclonePath, lsdArgs...)
// var stdout, stderr bytes.Buffer // Moved declaration up
lsdCmd.Stdout = &stdout // Assign buffer
lsdCmd.Stderr = &stderr // Assign buffer
lsdCmd.Stdout = &stdout
lsdCmd.Stderr = &stderr
err = lsdCmd.Run() // Assign error
// Use the mockable function variable
err = cmdRun(lsdCmd)
stdoutStr := stdout.String()
stderrStr := stderr.String()
@@ -269,14 +250,16 @@ RunLsd: // Label to jump to for local type
log.Printf("Rclone lsd stdout:\n%s", stdoutStr)
log.Printf("Rclone lsd stderr:\n%s", stderrStr)
if ctx.Err() == context.DeadlineExceeded {
return false, "Connection test timed out after 30 seconds.", ctx.Err()
}
if err != nil {
// Try to provide a more specific error message based on lsd output
if errors.Is(err, context.DeadlineExceeded) {
return false, "Connection test timed out after 30 seconds.", context.DeadlineExceeded
}
// Check ctx.Err() as a fallback - This check might be redundant now
if ctx.Err() == context.DeadlineExceeded {
return false, "Connection test timed out after 30 seconds.", ctx.Err()
}
errMsg := fmt.Sprintf("Connection test failed: %v. Stderr: %s", err, stderrStr)
// Add specific error checks based on stderrStr if needed
if strings.Contains(stderrStr, "connect: connection refused") {
errMsg = "Connection test failed: Connection refused by host."
} else if strings.Contains(stderrStr, "no such host") || strings.Contains(stderrStr, "name resolution error") {
@@ -285,12 +268,11 @@ RunLsd: // Label to jump to for local type
errMsg = "Connection test failed: Authentication failed (check credentials/permissions)."
} else if strings.Contains(stderrStr, "directory not found") {
errMsg = "Connection test failed: Directory/Path not found (check path)."
} else if strings.Contains(stderrStr, "Couldn't find section") { // Error from rclone config create
} else if strings.Contains(stderrStr, "Couldn't find section") {
errMsg = "Connection test failed: Invalid parameters provided for provider type."
}
return false, errMsg, err
}
// If lsd runs without error, the connection is likely okay
return true, "Connection test successful!", nil
}
@@ -0,0 +1,301 @@
package rclone_service
import (
"context"
"errors"
"os/exec"
"strings"
"testing"
"github.com/starfleetcptn/gomft/internal/db"
)
// --- Mock os/exec ---
// Note: The package-level variable 'execCommandContext' is defined in rclone_service.go
// This helper function replaces it for the duration of a test.
// MockExecCommand replaces the package-level execCommandContext variable (defined in rclone_service.go)
// with a function provided by the test and returns a function to restore the original.
func MockExecCommand(mockFunc func(ctx context.Context, command string, args ...string) *exec.Cmd) (restore func()) {
original := execCommandContext
execCommandContext = mockFunc
return func() { execCommandContext = original }
}
// Helper function to find the actual rclone command within args, skipping flags.
func findRcloneCommand(args []string) string {
for i := 0; i < len(args); i++ {
arg := args[i]
if strings.HasPrefix(arg, "-") {
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
i++
}
continue
}
return arg
}
return ""
}
// --- Tests ---
func TestTestRcloneConnection_Success_SFTP(t *testing.T) {
config := db.TransferConfig{
SourceType: "sftp",
SourceHost: "testhost",
SourceUser: "testuser",
SourcePassword: "testpassword",
}
providerType := "source"
var dbInstance *db.DB
configCreateCalled := false
// Mock execCommandContext (only needed to return a basic cmd struct)
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
// Return a simple, non-nil command object. The actual execution is mocked below.
return exec.Command("echo", "mocked")
})
defer restoreExec()
// Mock cmdCombinedOutput for config create
originalCombinedOutput := cmdCombinedOutput
cmdCombinedOutput = func(c *exec.Cmd) ([]byte, error) {
configCreateCalled = true
return []byte(""), nil // Simulate success
}
defer func() { cmdCombinedOutput = originalCombinedOutput }() // Restore original
// Mock cmdRun for lsd
originalRun := cmdRun
cmdRun = func(c *exec.Cmd) error {
if !configCreateCalled {
t.Fatalf("lsd (Run) called before config create")
}
// Simulate success by returning nil error
// We also need to simulate writing to stdout if the main func uses it
if stdoutWriter, ok := c.Stdout.(interface{ WriteString(string) (int, error) }); ok {
stdoutWriter.WriteString(" -1 2023-01-01 10:00:00 -1 some_dir\n")
}
return nil
}
defer func() { cmdRun = originalRun }() // Restore original
success, msg, err := TestRcloneConnection(config, providerType, dbInstance)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
if !success {
t.Errorf("Expected success=true, but got false. Message: %s", msg)
}
if msg != "Connection test successful!" {
t.Errorf("Expected success message, but got: %q", msg)
}
}
func TestTestRcloneConnection_ConfigCreateFail(t *testing.T) {
config := db.TransferConfig{
SourceType: "sftp",
SourceHost: "testhost",
SourceUser: "testuser",
}
providerType := "source"
var dbInstance *db.DB
expectedStderr := "invalid parameters"
expectedErr := errors.New("exit status 1")
// Mock execCommandContext
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
return exec.Command("echo", "mocked")
})
defer restoreExec()
// Mock cmdCombinedOutput for config create failure
originalCombinedOutput := cmdCombinedOutput
cmdCombinedOutput = func(c *exec.Cmd) ([]byte, error) {
return []byte(expectedStderr), expectedErr // Simulate failure
}
defer func() { cmdCombinedOutput = originalCombinedOutput }()
// Mock cmdRun (should not be called)
originalRun := cmdRun
cmdRun = func(c *exec.Cmd) error {
t.Fatalf("lsd (Run) called after config create failure")
return errors.New("should not be called")
}
defer func() { cmdRun = originalRun }()
success, msg, err := TestRcloneConnection(config, providerType, dbInstance)
if err == nil {
t.Error("Expected an error from config create failure, but got nil")
} else if !errors.Is(err, expectedErr) {
t.Errorf("Expected error %v, got %v", expectedErr, err)
}
if success {
t.Error("Expected success=false for config create failure, but got true")
}
if !strings.Contains(msg, "Failed to create temp config section") {
t.Errorf("Expected message containing 'Failed to create temp config section', got: %q", msg)
}
// Note: The CombinedOutput mock returns stderr in the output byte slice
if !strings.Contains(msg, expectedStderr) {
t.Errorf("Expected message containing stderr %q, got: %q", expectedStderr, msg)
}
}
func TestTestRcloneConnection_LsdTimeout(t *testing.T) {
config := db.TransferConfig{
SourceType: "sftp",
SourceHost: "testhost",
SourceUser: "testuser",
SourcePassword: "pw",
}
providerType := "source"
var dbInstance *db.DB
// Mock execCommandContext
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
return exec.Command("echo", "mocked")
})
defer restoreExec()
// Mock cmdCombinedOutput for config create success
originalCombinedOutput := cmdCombinedOutput
cmdCombinedOutput = func(c *exec.Cmd) ([]byte, error) {
return []byte(""), nil
}
defer func() { cmdCombinedOutput = originalCombinedOutput }()
// Mock cmdRun for lsd timeout
originalRun := cmdRun
cmdRun = func(c *exec.Cmd) error {
// Simulate timeout error
return context.DeadlineExceeded
}
defer func() { cmdRun = originalRun }()
success, msg, err := TestRcloneConnection(config, providerType, dbInstance)
if err == nil {
t.Error("Expected a timeout error, but got nil")
} else if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("Expected context.DeadlineExceeded error, got: %v (type: %T)", err, err)
}
if success {
t.Error("Expected success=false for timeout, but got true")
}
if !strings.Contains(msg, "Connection test timed out") {
t.Errorf("Expected message containing 'Connection test timed out', got: %q", msg)
}
}
func TestTestRcloneConnection_LsdAuthFail(t *testing.T) {
config := db.TransferConfig{
SourceType: "sftp",
SourceHost: "testhost",
SourceUser: "wronguser",
SourcePassword: "wrongpassword",
}
providerType := "source"
var dbInstance *db.DB
expectedStderr := "authentication failed"
expectedErr := errors.New("exit status 1")
// Mock execCommandContext
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
return exec.Command("echo", "mocked")
})
defer restoreExec()
// Mock cmdCombinedOutput for config create success
originalCombinedOutput := cmdCombinedOutput
cmdCombinedOutput = func(c *exec.Cmd) ([]byte, error) {
return []byte(""), nil
}
defer func() { cmdCombinedOutput = originalCombinedOutput }()
// Mock cmdRun for lsd failure
originalRun := cmdRun
cmdRun = func(c *exec.Cmd) error {
// Simulate failure by returning error and writing to stderr buffer
if stderrWriter, ok := c.Stderr.(interface{ WriteString(string) (int, error) }); ok {
stderrWriter.WriteString(expectedStderr)
}
return expectedErr
}
defer func() { cmdRun = originalRun }()
success, msg, err := TestRcloneConnection(config, providerType, dbInstance)
if err == nil {
t.Error("Expected an error from lsd auth failure, but got nil")
} else if !errors.Is(err, expectedErr) {
if !strings.Contains(err.Error(), "exit status 1") {
t.Errorf("Expected error containing 'exit status 1', got: %v", err)
}
}
if success {
t.Error("Expected success=false for lsd auth failure, but got true")
}
// Check the parsed error message based on stderr
if !strings.Contains(msg, "Authentication failed") {
t.Errorf("Expected message containing 'Authentication failed', got: %q", msg)
}
}
func TestTestRcloneConnection_LocalSuccess(t *testing.T) {
tempPath := t.TempDir()
config := db.TransferConfig{
SourceType: "local",
SourcePath: tempPath,
}
providerType := "source"
var dbInstance *db.DB
// Mock execCommandContext (only lsd should be called)
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
rcloneCmd := findRcloneCommand(args)
if rcloneCmd != "lsd" {
t.Fatalf("Unexpected command call for local provider: %q", rcloneCmd)
}
return exec.Command("echo", "mocked for lsd")
})
defer restoreExec()
// Mock cmdCombinedOutput (should not be called)
originalCombinedOutput := cmdCombinedOutput
cmdCombinedOutput = func(c *exec.Cmd) ([]byte, error) {
t.Fatalf("CombinedOutput called unexpectedly for local provider")
return nil, errors.New("should not be called")
}
defer func() { cmdCombinedOutput = originalCombinedOutput }()
// Mock cmdRun for lsd success
originalRun := cmdRun
cmdRun = func(c *exec.Cmd) error {
// Simulate success
if stdoutWriter, ok := c.Stdout.(interface{ WriteString(string) (int, error) }); ok {
stdoutWriter.WriteString(" -1 2023-01-01 10:00:00 -1 some_local_dir\n")
}
return nil
}
defer func() { cmdRun = originalRun }()
success, msg, err := TestRcloneConnection(config, providerType, dbInstance)
if err != nil {
t.Errorf("Expected no error for local success, but got: %v", err)
}
if !success {
t.Errorf("Expected success=true for local success, but got false. Message: %s", msg)
}
if msg != "Connection test successful!" {
t.Errorf("Expected success message, but got: %q", msg)
}
}
// TODO: Add more tests for other providers (S3, FTP, WebDAV, etc.)
// TODO: Add tests for destination providerType
// TODO: Add tests for specific error string parsing (connection refused, dir not found)
+36 -17
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
@@ -30,15 +31,17 @@ type NotificationDB interface {
// Notifier handles sending notifications via various services.
type Notifier struct {
db NotificationDB // Use the interface type
logger *Logger
db NotificationDB // Use the interface type
logger *Logger
skipSSLVerify bool
}
// NewNotifier creates a new Notifier.
func NewNotifier(database NotificationDB, logger *Logger) *Notifier { // Accept the interface type
func NewNotifier(database NotificationDB, logger *Logger, skipSSLVerify bool) *Notifier { // Accept the interface type and skip flag
return &Notifier{
db: database,
logger: logger,
db: database,
logger: logger,
skipSSLVerify: skipSSLVerify, // Store the flag
}
}
@@ -138,10 +141,8 @@ func (n *Notifier) sendJobWebhookNotification(job *db.Job, history *db.JobHistor
n.logger.LogDebug("Webhook headers: %+v", req.Header)
// Send the request with a timeout
client := &http.Client{
Timeout: 10 * time.Second,
}
// 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)
@@ -328,6 +329,26 @@ func generateEmailBody(job *db.Job, history *db.JobHistory, config *db.TransferC
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)
@@ -413,10 +434,8 @@ func (n *Notifier) sendServiceWebhookNotification(service *db.NotificationServic
timeout = 15 * time.Second
}
// Prepare client with timeout
client := &http.Client{
Timeout: timeout,
}
// Prepare client with timeout and configured TLS settings
client := createHTTPClient(timeout, n.skipSSLVerify)
// Attempt to send with retries
var resp *http.Response
@@ -834,7 +853,7 @@ func (n *Notifier) sendPushbulletNotification(service *db.NotificationService, j
req.Header.Set("Access-Token", apiKey)
// Send the request
client := &http.Client{Timeout: 10 * time.Second}
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)
@@ -930,7 +949,7 @@ func (n *Notifier) sendNtfyNotification(service *db.NotificationService, job *db
}
// Send the request
client := &http.Client{Timeout: 10 * time.Second}
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)
@@ -1016,7 +1035,7 @@ func (n *Notifier) sendGotifyNotification(service *db.NotificationService, job *
req.Header.Set("X-Gotify-Key", token)
// Send the request
client := &http.Client{Timeout: 10 * time.Second}
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)
@@ -1111,7 +1130,7 @@ func (n *Notifier) sendPushoverNotification(service *db.NotificationService, job
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Send the request
client := &http.Client{Timeout: 10 * time.Second}
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)
+2 -2
View File
@@ -698,7 +698,7 @@ func (h *Handlers) HandleAuthProviderInit(c *gin.Context) {
// Get the auth provider
provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
if err != nil || !provider.Enabled {
if err != nil || !provider.GetEnabled() { // Use getter
h.HandleBadRequest(c, "Provider Not Available", "The authentication provider is not available")
return
}
@@ -862,7 +862,7 @@ func (h *Handlers) HandleAuthProviderCallback(c *gin.Context) {
// Get the auth provider
provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
if err != nil || !provider.Enabled {
if err != nil || !provider.GetEnabled() { // Use getter
h.HandleBadRequest(c, "Provider Not Available", "The authentication provider is not available")
return
}
@@ -99,8 +99,9 @@ func (h *Handlers) HandleCreateAuthProvider(c *gin.Context) {
Scopes: c.PostForm("scopes"),
Description: c.PostForm("description"),
IconURL: c.PostForm("icon_url"),
Enabled: c.PostForm("enabled") == "on",
// Enabled will be set using the helper method below
}
provider.SetEnabled(c.PostForm("enabled") == "on") // Use helper method
// Process config values based on provider type
config := make(map[string]interface{})
@@ -211,7 +212,7 @@ func (h *Handlers) HandleUpdateAuthProvider(c *gin.Context) {
existingProvider.RedirectURL = c.PostForm("redirect_url")
existingProvider.Scopes = c.PostForm("scopes")
existingProvider.Description = c.PostForm("description")
existingProvider.Enabled = c.PostForm("enabled") == "on"
existingProvider.SetEnabled(c.PostForm("enabled") == "on") // Use setter
// Update the type if changed
if providerType := c.PostForm("type"); providerType != "" {
+52 -146
View File
@@ -43,7 +43,7 @@ func (h *Handlers) HandleSettings(c *gin.Context) {
ID: service.ID,
Name: service.Name,
Type: service.Type,
IsEnabled: service.IsEnabled,
IsEnabled: service.GetIsEnabled(), // Use getter
Config: service.Config,
Description: service.Description,
EventTriggers: service.EventTriggers,
@@ -76,6 +76,8 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
serviceType := c.PostForm("type")
description := c.PostForm("description")
isEnabled := c.PostForm("is_enabled") == "on"
// Read event triggers directly from the form array
eventTriggers := c.PostFormArray("event_triggers[]")
// Validate required fields
if name == "" || serviceType == "" {
@@ -86,7 +88,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create config map based on service type
config := make(map[string]string)
eventTriggers := make([]string, 0)
// eventTriggers slice is now populated above
fmt.Println("serviceType", serviceType)
switch serviceType {
case "email":
@@ -102,16 +104,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["title_template"] = c.PostForm("pushbullet_title_template")
config["body_template"] = c.PostForm("pushbullet_body_template")
// Build event triggers
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
// Event triggers are handled above
// Validate required fields
if config["api_key"] == "" {
@@ -121,14 +114,15 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create new notification service
service := db.NotificationService{
Name: name,
Type: serviceType,
IsEnabled: isEnabled,
Name: name,
Type: serviceType,
// IsEnabled will be set using the helper method below
Config: config,
Description: description,
EventTriggers: eventTriggers,
CreatedBy: c.GetUint("userID"),
}
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database
if err := h.DB.Create(&service).Error; err != nil {
@@ -141,7 +135,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{
"name": service.Name,
"type": service.Type,
"is_enabled": service.IsEnabled,
"is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description,
"event_triggers": eventTriggers,
}
@@ -171,16 +165,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["title_template"] = c.PostForm("ntfy_title_template")
config["message_template"] = c.PostForm("ntfy_message_template")
// Build event triggers
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
// Event triggers are handled above
// Validate required fields
if config["server"] == "" || config["topic"] == "" {
@@ -190,14 +175,15 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create new notification service
service := db.NotificationService{
Name: name,
Type: serviceType,
IsEnabled: isEnabled,
Name: name,
Type: serviceType,
// IsEnabled will be set using the helper method below
Config: config,
Description: description,
EventTriggers: eventTriggers,
CreatedBy: c.GetUint("userID"),
}
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database
if err := h.DB.Create(&service).Error; err != nil {
@@ -210,7 +196,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{
"name": service.Name,
"type": service.Type,
"is_enabled": service.IsEnabled,
"is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description,
"event_triggers": eventTriggers,
}
@@ -238,16 +224,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["title_template"] = c.PostForm("gotify_title_template")
config["message_template"] = c.PostForm("gotify_message_template")
// Build event triggers
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
// Event triggers are handled above
// Validate required fields
if config["url"] == "" || config["token"] == "" {
@@ -257,14 +234,15 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create new notification service
service := db.NotificationService{
Name: name,
Type: serviceType,
IsEnabled: isEnabled,
Name: name,
Type: serviceType,
// IsEnabled will be set using the helper method below
Config: config,
Description: description,
EventTriggers: eventTriggers,
CreatedBy: c.GetUint("userID"),
}
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database
if err := h.DB.Create(&service).Error; err != nil {
@@ -277,7 +255,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{
"name": service.Name,
"type": service.Type,
"is_enabled": service.IsEnabled,
"is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description,
"event_triggers": eventTriggers,
}
@@ -307,16 +285,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["title_template"] = c.PostForm("pushover_title_template")
config["message_template"] = c.PostForm("pushover_message_template")
// Build event triggers
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
// Event triggers are handled above
// Validate required fields
if config["app_token"] == "" || config["user_key"] == "" {
@@ -326,14 +295,15 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create new notification service
service := db.NotificationService{
Name: name,
Type: serviceType,
IsEnabled: isEnabled,
Name: name,
Type: serviceType,
// IsEnabled will be set using the helper method below
Config: config,
Description: description,
EventTriggers: eventTriggers,
CreatedBy: c.GetUint("userID"),
}
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database
if err := h.DB.Create(&service).Error; err != nil {
@@ -346,7 +316,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{
"name": service.Name,
"type": service.Type,
"is_enabled": service.IsEnabled,
"is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description,
"event_triggers": eventTriggers,
}
@@ -373,26 +343,13 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["headers"] = c.PostForm("headers")
// Add the new webhook fields
// 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"))
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
// Event triggers are handled above
// Create new notification service with additional fields
service := db.NotificationService{
Name: name,
Type: serviceType,
IsEnabled: isEnabled,
Name: name,
Type: serviceType,
// IsEnabled will be set using the helper method below
Config: config,
Description: description,
EventTriggers: eventTriggers,
@@ -401,6 +358,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
RetryPolicy: c.PostForm("retry_policy"),
CreatedBy: c.GetUint("userID"),
}
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database
if err := h.DB.Create(&service).Error; err != nil {
@@ -413,7 +371,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{
"name": service.Name,
"type": service.Type,
"is_enabled": service.IsEnabled,
"is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description,
"event_triggers": eventTriggers,
"retry_policy": service.RetryPolicy,
@@ -442,13 +400,14 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create new notification service
service := db.NotificationService{
Name: name,
Type: serviceType,
IsEnabled: isEnabled,
Name: name,
Type: serviceType,
// IsEnabled will be set using the helper method below
Config: config,
Description: description,
CreatedBy: c.GetUint("userID"),
}
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database
if err := h.DB.Create(&service).Error; err != nil {
@@ -461,7 +420,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{
"name": service.Name,
"type": service.Type,
"is_enabled": service.IsEnabled,
"is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description,
}
@@ -1142,7 +1101,7 @@ func (h *Handlers) HandleNotificationsPage(c *gin.Context) {
ID: service.ID,
Name: service.Name,
Type: service.Type,
IsEnabled: service.IsEnabled,
IsEnabled: service.GetIsEnabled(), // Use getter
Config: service.Config,
Description: service.Description,
EventTriggers: service.EventTriggers,
@@ -1281,7 +1240,7 @@ func (h *Handlers) HandleEditNotificationPage(c *gin.Context) {
Name: service.Name,
Description: service.Description,
Type: service.Type,
IsEnabled: service.IsEnabled,
IsEnabled: service.GetIsEnabled(), // Use getter
EventTriggers: service.EventTriggers,
RetryPolicy: service.RetryPolicy,
PayloadTemplate: service.PayloadTemplate,
@@ -1353,6 +1312,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
serviceType := c.PostForm("type")
description := c.PostForm("description")
isEnabled := c.PostForm("is_enabled") == "on"
eventTriggers := c.PostFormArray("event_triggers[]")
// Validate required fields
if name == "" || serviceType == "" {
@@ -1364,7 +1324,8 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.Name = name
service.Type = serviceType
service.Description = description
service.IsEnabled = isEnabled
service.EventTriggers = eventTriggers
service.SetIsEnabled(isEnabled) // Use setter
// Update type-specific fields based on service type
switch serviceType {
@@ -1377,18 +1338,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.SecretKey = c.PostForm("secret_key")
service.RetryPolicy = c.PostForm("retry_policy")
// Update event triggers
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
service.EventTriggers = eventTriggers
// Event triggers are handled above
case "pushbullet":
// Update Pushbullet-specific fields
@@ -1397,18 +1347,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.Config["title_template"] = c.PostForm("pushbullet_title_template")
service.Config["body_template"] = c.PostForm("pushbullet_body_template")
// Update event triggers
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
service.EventTriggers = eventTriggers
// Event triggers are handled above
case "ntfy":
// Update Ntfy-specific fields
@@ -1420,18 +1359,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.Config["title_template"] = c.PostForm("ntfy_title_template")
service.Config["message_template"] = c.PostForm("ntfy_message_template")
// Update event triggers
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
service.EventTriggers = eventTriggers
// Event triggers are handled above
case "gotify":
// Update Gotify-specific fields
@@ -1441,18 +1369,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.Config["title_template"] = c.PostForm("gotify_title_template")
service.Config["message_template"] = c.PostForm("gotify_message_template")
// Update event triggers
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
service.EventTriggers = eventTriggers
// Event triggers are handled above
case "pushover":
// Update Pushover-specific fields
@@ -1464,18 +1381,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.Config["title_template"] = c.PostForm("pushover_title_template")
service.Config["message_template"] = c.PostForm("pushover_message_template")
// Update event triggers
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
service.EventTriggers = eventTriggers
// Event triggers are handled above
}
// Save to database
@@ -1489,7 +1395,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{
"name": service.Name,
"type": service.Type,
"is_enabled": service.IsEnabled,
"is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description,
"event_triggers": service.EventTriggers,
}
@@ -1524,7 +1430,7 @@ func (h *Handlers) handleNotificationsWithError(c *gin.Context, errorMessage str
ID: service.ID,
Name: service.Name,
Type: service.Type,
IsEnabled: service.IsEnabled,
IsEnabled: service.GetIsEnabled(), // Use getter
Config: service.Config,
Description: service.Description,
EventTriggers: service.EventTriggers,
@@ -1559,7 +1465,7 @@ func (h *Handlers) handleNotificationsWithSuccess(c *gin.Context, successMessage
ID: service.ID,
Name: service.Name,
Type: service.Type,
IsEnabled: service.IsEnabled,
IsEnabled: service.GetIsEnabled(), // Use getter
Config: service.Config,
Description: service.Description,
EventTriggers: service.EventTriggers,