+ Folder ID to use as the root (leave empty for "My Drive")
+
+
+
+
+ Shared/Team Drive ID (Optional)
+
+
+
+
+
+
+
+ ID of the Shared Drive / Team Drive to use
+
+
+
+
+ Path
+
+
+
+
+
+
+
+ Path within the Drive where files will be uploaded
+
+
+
+
+
+
+
+
+
+
Important: Authentication Required
+
+
After saving this configuration, you will need to authenticate with Google Drive.
+
The authentication process will require you to:
+
+
Visit a Google authorization URL
+
Sign in to your Google account
+
Grant permission to access your Google Drive
+
Copy the authorization code back to this application
+
+
+ This is a one-time process for each configuration. The application will store your authorization token securely.
+
+
+
+
+
+
+
+
+
+
+
+
+
Authentication Information
+
+
+
+
You're using rclone's built-in authentication, which simplifies the setup process:
+
+
No need to create your own Google API credentials
+
Authentication happens through a browser window
+
You will need to manually copy the authorization code back
+
+
+
+ Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
+ If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
+
+ Folder ID to use as the root (leave empty for "My Drive")
+
+
+
+
+ Shared/Team Drive ID (Optional)
+
+
+
+
+
+
+
+ ID of the Shared Drive / Team Drive to use
+
+
+
+
+ Path
+
+
+
+
+
+
+
+ Path within the Drive from which files will be transferred
+
+
+
+
+
+
+
+
+
+
Authentication Required
+
+
After saving this configuration, you'll need to authenticate with Google Drive on the configurations page.
+
+
+
+
+
+}
\ No newline at end of file
diff --git a/components/users.templ b/components/users.templ
index c95fe21..436c659 100644
--- a/components/users.templ
+++ b/components/users.templ
@@ -73,7 +73,7 @@ templ Users(ctx context.Context, data UsersData) {
{ user.Email }
- if user.IsAdmin {
+ if user.GetIsAdmin() {
Admin
diff --git a/go.mod b/go.mod
index 545cf4a..66e8af5 100644
--- a/go.mod
+++ b/go.mod
@@ -23,6 +23,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+ github.com/gin-contrib/sessions v1.0.2 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@@ -30,6 +31,9 @@ require (
github.com/go-playground/validator/v10 v10.25.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.3.0 // indirect
+ github.com/gorilla/context v1.1.2 // indirect
+ github.com/gorilla/securecookie v1.1.2 // indirect
+ github.com/gorilla/sessions v1.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
diff --git a/go.sum b/go.sum
index c6ebc8e..f9cec7f 100644
--- a/go.sum
+++ b/go.sum
@@ -15,6 +15,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/gin-contrib/sessions v1.0.2 h1:UaIjUvTH1cMeOdj3in6dl+Xb6It8RiKRF9Z1anbUyCA=
+github.com/gin-contrib/sessions v1.0.2/go.mod h1:KxKxWqWP5LJVDCInulOl4WbLzK2KSPlLesfZ66wRvMs=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
@@ -44,6 +46,12 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
+github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
+github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
diff --git a/internal/api/api.go b/internal/api/api.go
index 8ff7e9b..8d6d14f 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -14,22 +14,22 @@ import (
)
type RegisterRequest struct {
- Email string `json:"email" binding:"required,min=3,max=50"`
+ Email string `json:"email" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=8"`
}
type LoginRequest struct {
- Email string `json:"email" binding:"required"`
+ Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
type LoginResponse struct {
- Token string `json:"token"`
+ Token string `json:"token"`
User UserResponse `json:"user"`
}
type UserResponse struct {
- ID uint `json:"id"`
+ ID uint `json:"id"`
Email string `json:"email"`
}
@@ -94,7 +94,7 @@ func handleRegister(database *db.DB) gin.HandlerFunc {
// Create user
user := &db.User{
- Email: req.Email,
+ Email: req.Email,
PasswordHash: string(hashedPassword),
}
@@ -136,7 +136,7 @@ func handleLogin(database *db.DB, jwtSecret string) gin.HandlerFunc {
c.JSON(http.StatusOK, LoginResponse{
Token: token,
User: UserResponse{
- ID: user.ID,
+ ID: user.ID,
Email: user.Email,
},
})
@@ -384,7 +384,7 @@ func handleCreateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
}
// Schedule the job if enabled
- if job.Enabled {
+ if job.GetEnabled() {
if err := scheduler.ScheduleJob(&job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to schedule job"})
return
@@ -484,7 +484,7 @@ func handleUpdateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
}
// Check if schedule or enabled status changed
- scheduleChanged := updatedJob.Schedule != existingJob.Schedule || updatedJob.Enabled != existingJob.Enabled
+ scheduleChanged := updatedJob.Schedule != existingJob.Schedule || updatedJob.GetEnabled() != existingJob.GetEnabled()
if err := database.UpdateJob(&updatedJob); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
@@ -493,7 +493,7 @@ func handleUpdateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
// Update the scheduler if needed
if scheduleChanged {
- if updatedJob.Enabled {
+ if updatedJob.GetEnabled() {
if err := scheduler.ScheduleJob(&updatedJob); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job schedule"})
return
@@ -610,12 +610,8 @@ func handleEnableJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
return
}
- // Update job status
- job.Enabled = true
- if err := database.UpdateJob(job); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
- return
- }
+ // Enable the job
+ job.SetEnabled(true)
// Add to scheduler
if err := scheduler.ScheduleJob(job); err != nil {
@@ -654,12 +650,8 @@ func handleDisableJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handl
return
}
- // Update job status
- job.Enabled = false
- if err := database.UpdateJob(job); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
- return
- }
+ // Disable the job
+ job.SetEnabled(false)
// Remove from scheduler
scheduler.UnscheduleJob(jobID)
diff --git a/internal/db/db.go b/internal/db/db.go
index 8ff55ed..527b4a6 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -1,6 +1,7 @@
package db
import (
+ "encoding/json"
"fmt"
"os"
"os/exec"
@@ -18,10 +19,10 @@ type User struct {
ID uint `gorm:"primarykey"`
Email string `gorm:"unique;not null"`
PasswordHash string `gorm:"not null"`
- IsAdmin bool `gorm:"default:false"`
+ IsAdmin *bool `gorm:"default:false"`
LastPasswordChange time.Time
- FailedLoginAttempts int `gorm:"default:0"`
- AccountLocked bool `gorm:"default:false"`
+ FailedLoginAttempts int `gorm:"default:0"`
+ AccountLocked *bool `gorm:"default:false"`
LockoutUntil *time.Time
Theme string `gorm:"default:'light'"`
CreatedAt time.Time
@@ -42,7 +43,7 @@ type PasswordResetToken struct {
User User `gorm:"foreignkey:UserID"`
Token string `gorm:"not null"`
ExpiresAt time.Time `gorm:"not null"`
- Used bool `gorm:"default:false"`
+ Used *bool `gorm:"default:false"`
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -67,7 +68,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"`
// 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
@@ -93,17 +94,19 @@ type TransferConfig struct {
DestShare string `form:"dest_share"`
DestDomain string `form:"dest_domain"`
// FTP destination fields
- DestPassiveMode bool `gorm:"default:true" form:"dest_passive_mode"`
+ DestPassiveMode *bool `gorm:"default:true" form:"dest_passive_mode"`
// OneDrive and Google Drive destination fields
DestClientID string `form:"dest_client_id"`
DestClientSecret string `form:"dest_client_secret" gorm:"-"` // Not stored in DB, only used for form
DestDriveID string `form:"dest_drive_id"` // For OneDrive
DestTeamDrive string `form:"dest_team_drive"` // For Google Drive
+ // Google Drive authentication status
+ GoogleDriveAuthenticated *bool `gorm:"default:false"`
// General fields
ArchivePath string `form:"archive_path"`
- ArchiveEnabled bool `gorm:"default:false" form:"archive_enabled"`
+ ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"`
RcloneFlags string `form:"rclone_flags"`
- DeleteAfterTransfer bool `gorm:"default:false" form:"delete_after_transfer"`
+ DeleteAfterTransfer *bool `gorm:"default:false" form:"delete_after_transfer"`
SkipProcessedFiles *bool `gorm:"default:true" form:"skip_processed_files"`
MaxConcurrentTransfers int `gorm:"default:4" form:"max_concurrent_transfers"` // Number of concurrent file transfers
CreatedBy uint
@@ -119,16 +122,16 @@ type Job struct {
Config TransferConfig `gorm:"foreignkey:ConfigID"`
ConfigIDs string `gorm:"column:config_ids"` // Comma-separated list of config IDs
Schedule string `gorm:"not null" form:"schedule"`
- Enabled bool `gorm:"default:true" form:"enabled"`
+ Enabled *bool `gorm:"default:true" form:"enabled"`
LastRun *time.Time
NextRun *time.Time
// Webhook notification fields
- WebhookEnabled bool `gorm:"default:false" form:"webhook_enabled"`
+ WebhookEnabled *bool `gorm:"default:false" form:"webhook_enabled"`
WebhookURL string `form:"webhook_url"`
WebhookSecret string `form:"webhook_secret"`
WebhookHeaders string `form:"webhook_headers"` // JSON-encoded headers
- NotifyOnSuccess bool `gorm:"default:true" form:"notify_on_success"`
- NotifyOnFailure bool `gorm:"default:true" form:"notify_on_failure"`
+ NotifyOnSuccess *bool `gorm:"default:true" form:"notify_on_success"`
+ NotifyOnFailure *bool `gorm:"default:true" form:"notify_on_failure"`
CreatedBy uint
User User `gorm:"foreignkey:CreatedBy"`
CreatedAt time.Time
@@ -558,7 +561,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
"--log-level", "ERROR",
}
- if config.SourcePassiveMode {
+ if config.SourcePassiveMode != nil && *config.SourcePassiveMode {
args = append(args, "passive", "true")
}
@@ -746,7 +749,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
"--log-level", "ERROR",
}
- if config.DestPassiveMode {
+ if config.DestPassiveMode != nil && *config.DestPassiveMode {
args = append(args, "passive", "true")
}
@@ -799,6 +802,33 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
args = append(args, "drive_id", config.DestDriveID)
}
+ cmd := exec.Command(rclonePath, args...)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
+ }
+ case "gdrive":
+ args := []string{
+ "config", "create", destName, "drive",
+ "--non-interactive",
+ "--config", configPath,
+ "--log-level", "ERROR",
+ }
+
+ // Only add client_id and client_secret if they're provided (not empty)
+ // This allows using rclone's built-in authentication
+ if config.DestClientID != "" && config.DestClientSecret != "" {
+ args = append(args, "client_id", config.DestClientID)
+ args = append(args, "client_secret", config.DestClientSecret)
+ }
+
+ if config.DestTeamDrive != "" {
+ args = append(args, "team_drive", config.DestTeamDrive)
+ }
+
+ if config.DestDriveID != "" {
+ args = append(args, "root_folder_id", config.DestDriveID)
+ }
+
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
@@ -837,12 +867,14 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
return nil
}
+// GetActiveJobs returns all active jobs
func (db *DB) GetActiveJobs() ([]Job, error) {
if db.DB == nil {
return nil, fmt.Errorf("database connection is nil")
}
var jobs []Job
- err := db.Preload("Config").Where("enabled = ?", true).Find(&jobs).Error
+ // For boolean pointer fields, need to check either NULL (for default) or true value
+ err := db.Preload("Config").Where("enabled IS NULL OR enabled = ?", true).Find(&jobs).Error
return jobs, err
}
@@ -885,3 +917,569 @@ func (tc *TransferConfig) GetSkipProcessedFiles() bool {
func (tc *TransferConfig) SetSkipProcessedFiles(value bool) {
tc.SkipProcessedFiles = &value
}
+
+// StoreGoogleDriveToken stores the Google Drive auth token for a config
+func (db *DB) StoreGoogleDriveToken(configIDStr string, token string) error {
+ configID, err := strconv.ParseUint(configIDStr, 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid config ID: %v", err)
+ }
+
+ // Get the existing config
+ config, err := db.GetTransferConfig(uint(configID))
+ if err != nil {
+ return fmt.Errorf("failed to get config: %v", err)
+ }
+
+ // Mark as authenticated
+ authenticated := true
+ config.GoogleDriveAuthenticated = &authenticated
+
+ // Update the config in the database
+ if err := db.UpdateTransferConfig(config); err != nil {
+ return fmt.Errorf("failed to update config: %v", err)
+ }
+
+ // Get the rclone config path
+ configPath := db.GetConfigRclonePath(config)
+
+ // Read existing config if it exists
+ existingConfig := ""
+ if _, err := os.Stat(configPath); err == nil {
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ return fmt.Errorf("failed to read existing config: %v", err)
+ }
+ existingConfig = string(data)
+ }
+
+ // Ensure directory exists
+ configDir := filepath.Dir(configPath)
+ if err := os.MkdirAll(configDir, 0755); err != nil {
+ return fmt.Errorf("failed to create config directory: %v", err)
+ }
+
+ // Write the new config with the token
+ destName := fmt.Sprintf("dest_%d", config.ID)
+ newConfig := fmt.Sprintf("[%s]\ntype = drive\ntoken = %s\n", destName, token)
+
+ // If the config has client ID and secret, add them
+ if config.DestClientID != "" && config.DestClientSecret != "" {
+ newConfig += fmt.Sprintf("client_id = %s\nclient_secret = %s\n", config.DestClientID, config.DestClientSecret)
+ }
+
+ // Add root folder ID if specified
+ if config.DestDriveID != "" {
+ newConfig += fmt.Sprintf("root_folder_id = %s\n", config.DestDriveID)
+ }
+
+ // Add team drive if specified
+ if config.DestTeamDrive != "" {
+ newConfig += fmt.Sprintf("team_drive = %s\n", config.DestTeamDrive)
+ }
+
+ // If there's existing config, append to it; otherwise create new file
+ var content string
+ if existingConfig != "" {
+ // Replace/update existing dest section if it exists, otherwise append
+ if strings.Contains(existingConfig, fmt.Sprintf("[%s]", destName)) {
+ // This is a simplistic approach - in production you might want a more robust regex-based replacement
+ // Truncate at the beginning of the dest section
+ parts := strings.SplitN(existingConfig, fmt.Sprintf("[%s]", destName), 2)
+ // Check if there are more sections after this one
+ nextSectionIdx := strings.Index(parts[1], "[")
+ if nextSectionIdx != -1 {
+ content = parts[0] + newConfig + parts[1][nextSectionIdx:]
+ } else {
+ content = parts[0] + newConfig
+ }
+ } else {
+ content = existingConfig + "\n" + newConfig
+ }
+ } else {
+ content = newConfig
+ }
+
+ // Write the config file
+ if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
+ return fmt.Errorf("failed to write config: %v", err)
+ }
+
+ return nil
+}
+
+// GenerateRcloneConfigWithToken generates a rclone config file for a transfer config with a provided token
+func (db *DB) GenerateRcloneConfigWithToken(config *TransferConfig, token string) error {
+ // Ensure we have a config directory
+ dataDir := os.Getenv("DATA_DIR")
+ if dataDir == "" {
+ dataDir = "./data"
+ }
+ configDir := filepath.Join(dataDir, "configs")
+ if err := os.MkdirAll(configDir, 0755); err != nil {
+ return err
+ }
+
+ // Generate rclone config based on the config type
+ configPath := filepath.Join(configDir, fmt.Sprintf("config_%d.conf", config.ID))
+
+ // Create a new config content
+ var configContent strings.Builder
+
+ // First add the source configuration
+ sourceName := fmt.Sprintf("source_%d", config.ID)
+
+ // Handle the source configuration based on type
+ switch config.SourceType {
+ case "google_drive":
+ // Create a Google Drive remote using the "source_ID" naming convention for sources
+ sourceSection := fmt.Sprintf("[%s]\ntype = drive\n", sourceName)
+
+ // Add custom client ID and secret if provided
+ if config.SourceClientID != "" && config.SourceClientSecret != "" {
+ sourceSection += fmt.Sprintf("client_id = %s\nclient_secret = %s\n",
+ config.SourceClientID, config.SourceClientSecret)
+ }
+
+ // Add team drive if specified
+ if config.SourceTeamDrive != "" {
+ sourceSection += fmt.Sprintf("team_drive = %s\n", config.SourceTeamDrive)
+ }
+
+ // Clean up token string to prevent syntax errors and ensure it's a single line JSON
+ // First, remove any whitespace from the beginning and end
+ cleanToken := strings.TrimSpace(token)
+
+ // Check if it's already a JSON object
+ if strings.HasPrefix(cleanToken, "{") && strings.HasSuffix(cleanToken, "}") {
+ // It's a JSON object, but we need to make sure it's a single line
+ var jsonObj map[string]interface{}
+ if err := json.Unmarshal([]byte(cleanToken), &jsonObj); err == nil {
+ // Successfully parsed the JSON, now re-marshal it as a compact single line
+ compactJSON, err := json.Marshal(jsonObj)
+ if err == nil {
+ // Use the compact JSON as the token
+ sourceSection += fmt.Sprintf("token = %s\n", string(compactJSON))
+ } else {
+ // If there was an error re-marshaling, use the original but remove newlines
+ // Replace all newlines and carriage returns with empty string
+ singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
+ singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
+ sourceSection += fmt.Sprintf("token = %s\n", singleLineToken)
+ }
+ } else {
+ // If we couldn't parse the JSON, just remove newlines and carriage returns
+ singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
+ singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
+ sourceSection += fmt.Sprintf("token = %s\n", singleLineToken)
+ }
+ } else {
+ // Not a valid JSON, try to fix it
+ // First ensure it starts and ends with braces
+ if !strings.HasPrefix(cleanToken, "{") {
+ cleanToken = "{" + cleanToken
+ }
+ if !strings.HasSuffix(cleanToken, "}") {
+ cleanToken = cleanToken + "}"
+ }
+ // Remove all newlines and carriage returns
+ singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
+ singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
+ sourceSection += fmt.Sprintf("token = %s\n", singleLineToken)
+ }
+
+ configContent.WriteString(sourceSection)
+ configContent.WriteString("\n")
+
+ case "sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive":
+ // For complex source types, use the GenerateRcloneConfig function
+ // to create a temporary file, read it, and then append that content
+ tempDir, err := os.MkdirTemp("", "gomft_temp")
+ if err != nil {
+ return fmt.Errorf("failed to create temp directory: %v", err)
+ }
+ defer os.RemoveAll(tempDir)
+
+ tempConfigPath := filepath.Join(tempDir, "temp_config.conf")
+
+ // Create a temporary file with just the source configuration
+ tempContent := fmt.Sprintf("[%s]\ntype = local\n", sourceName)
+ if err := os.WriteFile(tempConfigPath, []byte(tempContent), 0600); err != nil {
+ return fmt.Errorf("failed to write temporary config: %v", err)
+ }
+
+ // Get rclone path
+ rclonePath := os.Getenv("RCLONE_PATH")
+ if rclonePath == "" {
+ rclonePath = "rclone"
+ }
+
+ // Use the appropriate rclone command to configure the source
+ var args []string
+ switch config.SourceType {
+ case "sftp":
+ args = []string{
+ "config", "create", sourceName, "sftp",
+ "host", config.SourceHost,
+ "user", config.SourceUser,
+ "port", fmt.Sprintf("%d", config.SourcePort),
+ "--non-interactive",
+ "--config", tempConfigPath,
+ "--log-level", "ERROR",
+ }
+ if config.SourcePassword != "" {
+ args = append(args, "pass", config.SourcePassword)
+ }
+ if config.SourceKeyFile != "" {
+ args = append(args, "key_file", config.SourceKeyFile)
+ }
+ case "s3":
+ args = []string{
+ "config", "create", sourceName, "s3",
+ "provider", "AWS",
+ "env_auth", "false",
+ "access_key_id", config.SourceAccessKey,
+ "secret_access_key", config.SourceSecretKey,
+ "region", config.SourceRegion,
+ "--non-interactive",
+ "--config", tempConfigPath,
+ "--log-level", "ERROR",
+ }
+ if config.SourceEndpoint != "" {
+ args = append(args, "endpoint", config.SourceEndpoint)
+ }
+ }
+
+ // If we have arguments, execute the command
+ if len(args) > 0 {
+ cmd := exec.Command(rclonePath, args...)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
+ }
+
+ // Read the generated config
+ sourceConfig, err := os.ReadFile(tempConfigPath)
+ if err != nil {
+ return fmt.Errorf("failed to read temporary config: %v", err)
+ }
+
+ // Add it to our config content
+ configContent.WriteString(string(sourceConfig))
+ configContent.WriteString("\n")
+ }
+ default:
+ // For local source or other simple types
+ sourceSection := fmt.Sprintf("[%s]\ntype = local\n\n", sourceName)
+ configContent.WriteString(sourceSection)
+ }
+
+ // Now add the destination configuration
+ destName := fmt.Sprintf("dest_%d", config.ID)
+
+ // Set up the destination section (only supporting Google Drive for now)
+ if config.DestinationType == "gdrive" || config.DestinationType == "google_drive" {
+ // Create a Google Drive remote using "dest_ID" naming convention
+ destSection := fmt.Sprintf("[%s]\ntype = drive\n", destName)
+
+ // Add custom client ID and secret if provided
+ if config.DestClientID != "" && config.DestClientSecret != "" {
+ destSection += fmt.Sprintf("client_id = %s\nclient_secret = %s\n",
+ config.DestClientID, config.DestClientSecret)
+ }
+
+ // Clean up token string to prevent syntax errors and ensure it's a single line JSON
+ // First, remove any whitespace from the beginning and end
+ cleanToken := strings.TrimSpace(token)
+
+ // Check if it's already a JSON object
+ if strings.HasPrefix(cleanToken, "{") && strings.HasSuffix(cleanToken, "}") {
+ // It's a JSON object, but we need to make sure it's a single line
+ var jsonObj map[string]interface{}
+ if err := json.Unmarshal([]byte(cleanToken), &jsonObj); err == nil {
+ // Successfully parsed the JSON, now re-marshal it as a compact single line
+ compactJSON, err := json.Marshal(jsonObj)
+ if err == nil {
+ // Use the compact JSON as the token
+ destSection += fmt.Sprintf("token = %s\n", string(compactJSON))
+ } else {
+ // If there was an error re-marshaling, use the original but remove newlines
+ // Replace all newlines and carriage returns with empty string
+ singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
+ singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
+ destSection += fmt.Sprintf("token = %s\n", singleLineToken)
+ }
+ } else {
+ // If we couldn't parse the JSON, just remove newlines and carriage returns
+ singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
+ singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
+ destSection += fmt.Sprintf("token = %s\n", singleLineToken)
+ }
+ } else {
+ // Not a valid JSON, try to fix it
+ // First ensure it starts and ends with braces
+ if !strings.HasPrefix(cleanToken, "{") {
+ cleanToken = "{" + cleanToken
+ }
+ if !strings.HasSuffix(cleanToken, "}") {
+ cleanToken = cleanToken + "}"
+ }
+ // Remove all newlines and carriage returns
+ singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
+ singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
+ destSection += fmt.Sprintf("token = %s\n", singleLineToken)
+ }
+
+ // Add to the config content
+ configContent.WriteString(destSection)
+ } else {
+ // Add a simple local destination for testing or if no specific destination type is handled
+ destSection := fmt.Sprintf("[%s]\ntype = local\n", destName)
+ configContent.WriteString(destSection)
+ }
+
+ // Write the config file
+ return os.WriteFile(configPath, []byte(configContent.String()), 0644)
+}
+
+// GetIsAdmin returns the value of IsAdmin with a default if nil
+func (u *User) GetIsAdmin() bool {
+ if u.IsAdmin == nil {
+ return false // Default to false if not set
+ }
+ return *u.IsAdmin
+}
+
+// SetIsAdmin sets the IsAdmin field
+func (u *User) SetIsAdmin(value bool) {
+ u.IsAdmin = &value
+}
+
+// GetAccountLocked returns the value of AccountLocked with a default if nil
+func (u *User) GetAccountLocked() bool {
+ if u.AccountLocked == nil {
+ return false // Default to false if not set
+ }
+ return *u.AccountLocked
+}
+
+// SetAccountLocked sets the AccountLocked field
+func (u *User) SetAccountLocked(value bool) {
+ u.AccountLocked = &value
+}
+
+// GetUsed returns the value of Used with a default if nil
+func (t *PasswordResetToken) GetUsed() bool {
+ if t.Used == nil {
+ return false // Default to false if not set
+ }
+ return *t.Used
+}
+
+// SetUsed sets the Used field
+func (t *PasswordResetToken) SetUsed(value bool) {
+ t.Used = &value
+}
+
+// GetSourcePassiveMode returns the value of SourcePassiveMode with a default if nil
+func (tc *TransferConfig) GetSourcePassiveMode() bool {
+ if tc.SourcePassiveMode == nil {
+ return true // Default to true if not set
+ }
+ return *tc.SourcePassiveMode
+}
+
+// SetSourcePassiveMode sets the SourcePassiveMode field
+func (tc *TransferConfig) SetSourcePassiveMode(value bool) {
+ tc.SourcePassiveMode = &value
+}
+
+// GetDestPassiveMode returns the value of DestPassiveMode with a default if nil
+func (tc *TransferConfig) GetDestPassiveMode() bool {
+ if tc.DestPassiveMode == nil {
+ return true // Default to true if not set
+ }
+ return *tc.DestPassiveMode
+}
+
+// SetDestPassiveMode sets the DestPassiveMode field
+func (tc *TransferConfig) SetDestPassiveMode(value bool) {
+ tc.DestPassiveMode = &value
+}
+
+// GetGoogleDriveAuthenticated returns the value of GoogleDriveAuthenticated with a default if nil
+func (tc *TransferConfig) GetGoogleDriveAuthenticated() bool {
+ if tc.GoogleDriveAuthenticated == nil {
+ return false // Default to false if not set
+ }
+ return *tc.GoogleDriveAuthenticated
+}
+
+// SetGoogleDriveAuthenticated sets the GoogleDriveAuthenticated field
+func (tc *TransferConfig) SetGoogleDriveAuthenticated(value bool) {
+ tc.GoogleDriveAuthenticated = &value
+}
+
+// GetArchiveEnabled returns the value of ArchiveEnabled with a default if nil
+func (tc *TransferConfig) GetArchiveEnabled() bool {
+ if tc.ArchiveEnabled == nil {
+ return false // Default to false if not set
+ }
+ return *tc.ArchiveEnabled
+}
+
+// SetArchiveEnabled sets the ArchiveEnabled field
+func (tc *TransferConfig) SetArchiveEnabled(value bool) {
+ tc.ArchiveEnabled = &value
+}
+
+// GetDeleteAfterTransfer returns the value of DeleteAfterTransfer with a default if nil
+func (tc *TransferConfig) GetDeleteAfterTransfer() bool {
+ if tc.DeleteAfterTransfer == nil {
+ return false // Default to false if not set
+ }
+ return *tc.DeleteAfterTransfer
+}
+
+// SetDeleteAfterTransfer sets the DeleteAfterTransfer field
+func (tc *TransferConfig) SetDeleteAfterTransfer(value bool) {
+ tc.DeleteAfterTransfer = &value
+}
+
+// GetEnabled returns the value of Enabled with a default if nil
+func (j *Job) GetEnabled() bool {
+ if j.Enabled == nil {
+ return true // Default to true if not set
+ }
+ return *j.Enabled
+}
+
+// SetEnabled sets the Enabled field
+func (j *Job) SetEnabled(value bool) {
+ j.Enabled = &value
+}
+
+// GetWebhookEnabled returns the value of WebhookEnabled with a default if nil
+func (j *Job) GetWebhookEnabled() bool {
+ if j.WebhookEnabled == nil {
+ return false // Default to false if not set
+ }
+ return *j.WebhookEnabled
+}
+
+// SetWebhookEnabled sets the WebhookEnabled field
+func (j *Job) SetWebhookEnabled(value bool) {
+ j.WebhookEnabled = &value
+}
+
+// GetNotifyOnSuccess returns the value of NotifyOnSuccess with a default if nil
+func (j *Job) GetNotifyOnSuccess() bool {
+ if j.NotifyOnSuccess == nil {
+ return true // Default to true if not set
+ }
+ return *j.NotifyOnSuccess
+}
+
+// SetNotifyOnSuccess sets the NotifyOnSuccess field
+func (j *Job) SetNotifyOnSuccess(value bool) {
+ j.NotifyOnSuccess = &value
+}
+
+// GetNotifyOnFailure returns the value of NotifyOnFailure with a default if nil
+func (j *Job) GetNotifyOnFailure() bool {
+ if j.NotifyOnFailure == nil {
+ return true // Default to true if not set
+ }
+ return *j.NotifyOnFailure
+}
+
+// SetNotifyOnFailure sets the NotifyOnFailure field
+func (j *Job) SetNotifyOnFailure(value bool) {
+ j.NotifyOnFailure = &value
+}
+
+// GetGDriveCredentialsFromConfig extracts Google Drive client ID and secret from an existing rclone config file
+func (db *DB) GetGDriveCredentialsFromConfig(config *TransferConfig) (string, string) {
+ configPath := db.GetConfigRclonePath(config)
+ if configPath == "" {
+ return "", ""
+ }
+
+ // Check if the file exists
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ return "", ""
+ }
+
+ // Read the rclone config file
+ content, err := os.ReadFile(configPath)
+ if err != nil {
+ return "", ""
+ }
+
+ // Parse the content to extract client_id and client_secret from both source and destination sections
+ lines := strings.Split(string(content), "\n")
+
+ // Define section names based on config ID
+ sourceSectionName := fmt.Sprintf("[source_%d]", config.ID)
+ destSectionName := fmt.Sprintf("[dest_%d]", config.ID)
+
+ var inSourceSection, inDestSection bool
+ var sourceClientID, sourceClientSecret, destClientID, destClientSecret string
+
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+
+ // Check if we're entering a new section
+ if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
+ inSourceSection = line == sourceSectionName
+ inDestSection = line == destSectionName
+ continue
+ }
+
+ // Extract credentials from source section
+ if inSourceSection {
+ if strings.HasPrefix(line, "client_id") {
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) == 2 {
+ sourceClientID = strings.TrimSpace(parts[1])
+ }
+ } else if strings.HasPrefix(line, "client_secret") {
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) == 2 {
+ sourceClientSecret = strings.TrimSpace(parts[1])
+ }
+ }
+ }
+
+ // Extract credentials from destination section
+ if inDestSection {
+ if strings.HasPrefix(line, "client_id") {
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) == 2 {
+ destClientID = strings.TrimSpace(parts[1])
+ }
+ } else if strings.HasPrefix(line, "client_secret") {
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) == 2 {
+ destClientSecret = strings.TrimSpace(parts[1])
+ }
+ }
+ }
+
+ // If we found both values in both sections, we can stop processing
+ if sourceClientID != "" && sourceClientSecret != "" && destClientID != "" && destClientSecret != "" {
+ break
+ }
+ }
+
+ // Prefer destination credentials since we're authenticating for destination
+ if destClientID != "" && destClientSecret != "" {
+ return destClientID, destClientSecret
+ }
+
+ // Fall back to source credentials if available
+ if sourceClientID != "" && sourceClientSecret != "" {
+ return sourceClientID, sourceClientSecret
+ }
+
+ return "", ""
+}
diff --git a/internal/db/db_test.go b/internal/db/db_test.go
index 7572e3b..0495838 100644
--- a/internal/db/db_test.go
+++ b/internal/db/db_test.go
@@ -3,6 +3,7 @@ package db
import (
"fmt"
"os"
+ "path/filepath"
"testing"
"time"
@@ -42,9 +43,9 @@ func TestUserCRUD(t *testing.T) {
testUser := &User{
Email: fmt.Sprintf("test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
- IsAdmin: true,
LastPasswordChange: time.Now(),
}
+ testUser.SetIsAdmin(true)
// Test Create
err := db.CreateUser(testUser)
@@ -113,7 +114,7 @@ func TestPasswordResetToken(t *testing.T) {
}
assert.Equal(t, testToken.ID, retrievedToken.ID, "Retrieved token should have the same ID")
assert.Equal(t, testUser.ID, retrievedToken.UserID, "Retrieved token should reference the correct user")
- assert.False(t, retrievedToken.Used, "Token should not be marked as used initially")
+ assert.False(t, retrievedToken.GetUsed(), "Token should not be marked as used initially")
// Mark token as used
err = db.MarkPasswordResetTokenAsUsed(retrievedToken.ID)
@@ -129,7 +130,7 @@ func TestPasswordResetToken(t *testing.T) {
if result.Error != nil {
t.Fatalf("Failed to get updated password reset token: %v", result.Error)
}
- assert.True(t, updatedToken.Used, "Token should be marked as used")
+ assert.True(t, updatedToken.GetUsed(), "Token should be marked as used")
}
func TestTransferConfigCRUD(t *testing.T) {
@@ -240,11 +241,11 @@ func TestJobCRUD(t *testing.T) {
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run every hour
- Enabled: true,
LastRun: &now,
NextRun: &nextRun,
CreatedBy: testUser.ID,
}
+ testJob.SetEnabled(true)
// Test Create
err = db.CreateJob(testJob)
@@ -278,7 +279,7 @@ func TestJobCRUD(t *testing.T) {
// Test Update
retrievedJob.Name = fmt.Sprintf("Updated Job %d", time.Now().UnixNano())
- retrievedJob.Enabled = false
+ retrievedJob.SetEnabled(false)
err = db.UpdateJob(retrievedJob)
if err != nil {
t.Fatalf("Failed to update job: %v", err)
@@ -367,7 +368,6 @@ func TestJobMultipleConfigs(t *testing.T) {
testJob := &Job{
Name: "Multi Config Job",
Schedule: "0 * * * *",
- Enabled: true,
CreatedBy: testUser.ID,
}
@@ -468,7 +468,6 @@ func TestJobHistoryCRUD(t *testing.T) {
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run every hour
- Enabled: true,
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob)
@@ -548,7 +547,6 @@ func TestFileMetadataCRUD(t *testing.T) {
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run every hour
- Enabled: true,
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob)
@@ -634,7 +632,7 @@ func TestGetConfigRclonePath(t *testing.T) {
testUser := &User{
Email: "rclone-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
@@ -671,7 +669,7 @@ func TestGenerateRcloneConfig(t *testing.T) {
testUser := &User{
Email: "rclone-gen-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
@@ -708,7 +706,7 @@ func TestGenerateRcloneConfig(t *testing.T) {
DestHost: "ftp.example.com",
DestPort: 21,
DestUser: "ftpuser",
- DestPassiveMode: true,
+ DestPassiveMode: BoolPtr(true),
CreatedBy: testUser.ID,
}
@@ -770,7 +768,7 @@ func TestUpdateJobStatus(t *testing.T) {
testUser := &User{
Email: "job-status-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
@@ -800,11 +798,11 @@ func TestUpdateJobStatus(t *testing.T) {
Name: "Test Job Status",
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run hourly
- Enabled: true,
LastRun: &lastRun,
NextRun: &nextRun,
CreatedBy: testUser.ID,
}
+ testJob.SetEnabled(true)
err = db.CreateJob(testJob)
assert.NoError(t, err)
@@ -833,3 +831,588 @@ func TestUpdateJobStatus(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, updatedNextRun.Unix(), updatedJob.NextRun.Unix())
}
+
+func BoolPtr(b bool) *bool {
+ return &b
+}
+
+func TestGoogleDriveTransferConfig(t *testing.T) {
+ db := setupTestDB(t)
+
+ // Create a test user
+ testUser := &User{
+ Email: fmt.Sprintf("google-test-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hashed_password",
+ LastPasswordChange: time.Now(),
+ }
+ err := db.CreateUser(testUser)
+ if err != nil {
+ t.Fatalf("Failed to create user: %v", err)
+ }
+
+ // Test 1: Create config with Google Drive as source
+ googleSourceConfig := &TransferConfig{
+ Name: "Google Drive Source Test",
+ SourceType: "google_drive",
+ SourcePath: "/path/in/google/drive",
+ SourceClientID: "google_client_id",
+ SourceClientSecret: "google_client_secret",
+ SourceTeamDrive: "team_drive_id",
+ DestinationType: "local",
+ DestinationPath: "/local/destination/path",
+ FilePattern: "*.pdf",
+ CreatedBy: testUser.ID,
+ }
+
+ // Set authenticated status
+ authenticated := true
+ googleSourceConfig.GoogleDriveAuthenticated = &authenticated
+
+ // Create the config
+ err = db.CreateTransferConfig(googleSourceConfig)
+ assert.NoError(t, err)
+ assert.NotZero(t, googleSourceConfig.ID, "Config ID should be set after creation")
+
+ // Test 2: Create config with Google Drive as destination
+ googleDestConfig := &TransferConfig{
+ Name: "Google Drive Destination Test",
+ SourceType: "local",
+ SourcePath: "/local/source/path",
+ DestinationType: "google_drive",
+ DestinationPath: "/path/in/google/drive",
+ DestClientID: "google_client_id",
+ DestClientSecret: "google_client_secret",
+ DestTeamDrive: "team_drive_id",
+ FilePattern: "*.docx",
+ CreatedBy: testUser.ID,
+ }
+
+ // Set authenticated status
+ googleDestConfig.GoogleDriveAuthenticated = &authenticated
+
+ // Create the config
+ err = db.CreateTransferConfig(googleDestConfig)
+ assert.NoError(t, err)
+ assert.NotZero(t, googleDestConfig.ID, "Config ID should be set after creation")
+
+ // Test 3: Create config with Google Drive as both source and destination
+ googleBothConfig := &TransferConfig{
+ Name: "Google Drive Both Test",
+ SourceType: "google_drive",
+ SourcePath: "/source/path/in/google/drive",
+ SourceClientID: "source_google_client_id",
+ SourceClientSecret: "source_google_client_secret",
+ SourceTeamDrive: "source_team_drive_id",
+ DestinationType: "google_drive",
+ DestinationPath: "/dest/path/in/google/drive",
+ DestClientID: "dest_google_client_id",
+ DestClientSecret: "dest_google_client_secret",
+ DestTeamDrive: "dest_team_drive_id",
+ FilePattern: "*.xlsx",
+ CreatedBy: testUser.ID,
+ }
+
+ // Set authenticated status
+ googleBothConfig.GoogleDriveAuthenticated = &authenticated
+
+ // Create the config
+ err = db.CreateTransferConfig(googleBothConfig)
+ assert.NoError(t, err)
+ assert.NotZero(t, googleBothConfig.ID, "Config ID should be set after creation")
+
+ // Test retrieving and verifying Google Drive configs
+ retrievedSourceConfig, err := db.GetTransferConfig(googleSourceConfig.ID)
+ assert.NoError(t, err)
+ assert.Equal(t, "google_drive", retrievedSourceConfig.SourceType)
+ assert.Equal(t, "/path/in/google/drive", retrievedSourceConfig.SourcePath)
+ assert.Equal(t, "google_client_id", retrievedSourceConfig.SourceClientID)
+ assert.Equal(t, "team_drive_id", retrievedSourceConfig.SourceTeamDrive)
+ assert.True(t, *retrievedSourceConfig.GoogleDriveAuthenticated)
+
+ retrievedDestConfig, err := db.GetTransferConfig(googleDestConfig.ID)
+ assert.NoError(t, err)
+ assert.Equal(t, "google_drive", retrievedDestConfig.DestinationType)
+ assert.Equal(t, "/path/in/google/drive", retrievedDestConfig.DestinationPath)
+ assert.Equal(t, "google_client_id", retrievedDestConfig.DestClientID)
+ assert.Equal(t, "team_drive_id", retrievedDestConfig.DestTeamDrive)
+ assert.True(t, *retrievedDestConfig.GoogleDriveAuthenticated)
+
+ // Test updating Google Drive config
+ retrievedSourceConfig.SourcePath = "/updated/google/drive/path"
+ retrievedSourceConfig.SourceTeamDrive = "updated_team_drive_id"
+ err = db.UpdateTransferConfig(retrievedSourceConfig)
+ assert.NoError(t, err)
+
+ // Verify update
+ updatedConfig, err := db.GetTransferConfig(googleSourceConfig.ID)
+ assert.NoError(t, err)
+ assert.Equal(t, "/updated/google/drive/path", updatedConfig.SourcePath)
+ assert.Equal(t, "updated_team_drive_id", updatedConfig.SourceTeamDrive)
+
+ // Test changing authentication status
+ unauthenticated := false
+ updatedConfig.GoogleDriveAuthenticated = &unauthenticated
+ err = db.UpdateTransferConfig(updatedConfig)
+ assert.NoError(t, err)
+
+ // Verify authentication status update
+ finalConfig, err := db.GetTransferConfig(googleSourceConfig.ID)
+ assert.NoError(t, err)
+ assert.False(t, *finalConfig.GoogleDriveAuthenticated)
+
+ // Clean up
+ err = db.DeleteTransferConfig(googleSourceConfig.ID)
+ assert.NoError(t, err)
+ err = db.DeleteTransferConfig(googleDestConfig.ID)
+ assert.NoError(t, err)
+ err = db.DeleteTransferConfig(googleBothConfig.ID)
+ assert.NoError(t, err)
+}
+
+func TestGoogleDriveJobExecution(t *testing.T) {
+ db := setupTestDB(t)
+
+ // Create a test user
+ testUser := &User{
+ Email: fmt.Sprintf("google-job-test-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hashed_password",
+ LastPasswordChange: time.Now(),
+ }
+ err := db.CreateUser(testUser)
+ if err != nil {
+ t.Fatalf("Failed to create user: %v", err)
+ }
+
+ // Create a test transfer config with Google Drive as source
+ googleConfig := &TransferConfig{
+ Name: "Google Drive Job Test",
+ SourceType: "google_drive",
+ SourcePath: "/source/path/in/google/drive",
+ SourceClientID: "google_client_id",
+ SourceClientSecret: "google_client_secret",
+ SourceTeamDrive: "team_drive_id",
+ DestinationType: "local",
+ DestinationPath: "/local/destination/path",
+ FilePattern: "*.pdf",
+ CreatedBy: testUser.ID,
+ }
+
+ // Set authenticated status
+ authenticated := true
+ googleConfig.GoogleDriveAuthenticated = &authenticated
+
+ // Create the config
+ err = db.CreateTransferConfig(googleConfig)
+ assert.NoError(t, err)
+ assert.NotZero(t, googleConfig.ID, "Config ID should be set after creation")
+
+ // Create a job using the Google Drive config
+ job := &Job{
+ Name: "Google Drive Test Job",
+ Schedule: "0 * * * *", // Run every hour
+ ConfigID: googleConfig.ID,
+ CreatedBy: testUser.ID,
+ }
+
+ // Set job as enabled
+ job.SetEnabled(true)
+
+ // Set up webhook notifications
+ job.SetWebhookEnabled(true)
+ job.WebhookURL = "https://example.com/webhook"
+ job.SetNotifyOnSuccess(true)
+ job.SetNotifyOnFailure(true)
+
+ // Create the job
+ err = db.CreateJob(job)
+ assert.NoError(t, err)
+ assert.NotZero(t, job.ID, "Job ID should be set after creation")
+
+ // Test retrieving the job
+ retrievedJob, err := db.GetJob(job.ID)
+ assert.NoError(t, err)
+ assert.Equal(t, "Google Drive Test Job", retrievedJob.Name)
+ assert.Equal(t, googleConfig.ID, retrievedJob.ConfigID)
+ assert.True(t, retrievedJob.GetEnabled())
+ assert.True(t, retrievedJob.GetWebhookEnabled())
+ assert.Equal(t, "https://example.com/webhook", retrievedJob.WebhookURL)
+ assert.True(t, retrievedJob.GetNotifyOnSuccess())
+ assert.True(t, retrievedJob.GetNotifyOnFailure())
+
+ // Create job history entry for this job
+ startTime := time.Now().Add(-10 * time.Minute)
+ endTime := time.Now()
+ jobHistory := &JobHistory{
+ JobID: job.ID,
+ ConfigID: googleConfig.ID,
+ StartTime: startTime,
+ EndTime: &endTime,
+ Status: "success",
+ BytesTransferred: 1024 * 1024 * 5, // 5 MB
+ FilesTransferred: 3,
+ }
+
+ err = db.Create(jobHistory).Error
+ assert.NoError(t, err)
+ assert.NotZero(t, jobHistory.ID, "JobHistory ID should be set after creation")
+
+ // Create file metadata entries
+ fileMetadata1 := &FileMetadata{
+ JobID: job.ID,
+ ConfigID: googleConfig.ID,
+ FileName: "document1.pdf",
+ OriginalPath: "/source/path/in/google/drive/document1.pdf",
+ FileSize: 1024 * 1024 * 2, // 2 MB
+ FileHash: "hash1",
+ CreationTime: time.Now().Add(-24 * time.Hour),
+ ModTime: time.Now().Add(-12 * time.Hour),
+ ProcessedTime: startTime.Add(1 * time.Minute),
+ DestinationPath: "/local/destination/path/document1.pdf",
+ Status: "processed",
+ }
+
+ fileMetadata2 := &FileMetadata{
+ JobID: job.ID,
+ ConfigID: googleConfig.ID,
+ FileName: "document2.pdf",
+ OriginalPath: "/source/path/in/google/drive/document2.pdf",
+ FileSize: 1024 * 1024 * 1, // 1 MB
+ FileHash: "hash2",
+ CreationTime: time.Now().Add(-24 * time.Hour),
+ ModTime: time.Now().Add(-12 * time.Hour),
+ ProcessedTime: startTime.Add(2 * time.Minute),
+ DestinationPath: "/local/destination/path/document2.pdf",
+ Status: "processed",
+ }
+
+ fileMetadata3 := &FileMetadata{
+ JobID: job.ID,
+ ConfigID: googleConfig.ID,
+ FileName: "document3.pdf",
+ OriginalPath: "/source/path/in/google/drive/document3.pdf",
+ FileSize: 1024 * 1024 * 2, // 2 MB
+ FileHash: "hash3",
+ CreationTime: time.Now().Add(-24 * time.Hour),
+ ModTime: time.Now().Add(-12 * time.Hour),
+ ProcessedTime: startTime.Add(3 * time.Minute),
+ DestinationPath: "/local/destination/path/document3.pdf",
+ Status: "processed",
+ }
+
+ err = db.Create(fileMetadata1).Error
+ assert.NoError(t, err)
+ err = db.Create(fileMetadata2).Error
+ assert.NoError(t, err)
+ err = db.Create(fileMetadata3).Error
+ assert.NoError(t, err)
+
+ // Test fetching job history
+ var histories []JobHistory
+ err = db.Where("job_id = ?", job.ID).Find(&histories).Error
+ assert.NoError(t, err)
+ assert.Equal(t, 1, len(histories), "Should have 1 job history entry")
+ assert.Equal(t, job.ID, histories[0].JobID)
+ assert.Equal(t, googleConfig.ID, histories[0].ConfigID)
+ assert.Equal(t, "success", histories[0].Status)
+ assert.Equal(t, int64(1024*1024*5), histories[0].BytesTransferred)
+ assert.Equal(t, 3, histories[0].FilesTransferred)
+
+ // Test fetching file metadata
+ var files []FileMetadata
+ err = db.Where("job_id = ?", job.ID).Find(&files).Error
+ assert.NoError(t, err)
+ assert.Equal(t, 3, len(files), "Should have 3 file metadata entries")
+
+ // Clean up
+ err = db.Where("job_id = ?", job.ID).Delete(&FileMetadata{}).Error
+ assert.NoError(t, err)
+ err = db.Where("job_id = ?", job.ID).Delete(&JobHistory{}).Error
+ assert.NoError(t, err)
+ err = db.Delete(&job).Error
+ assert.NoError(t, err)
+ err = db.Delete(&googleConfig).Error
+ assert.NoError(t, err)
+}
+
+func TestGoogleDriveAuthentication(t *testing.T) {
+ db := setupTestDB(t)
+
+ // Create a test user
+ testUser := &User{
+ Email: fmt.Sprintf("google-auth-test-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hashed_password",
+ LastPasswordChange: time.Now(),
+ }
+ err := db.CreateUser(testUser)
+ assert.NoError(t, err)
+
+ // Create a Google Drive config that requires authentication
+ googleConfig := &TransferConfig{
+ Name: "Google Drive Auth Test",
+ SourceType: "google_drive",
+ SourcePath: "/source/path",
+ SourceClientID: "test_client_id",
+ SourceClientSecret: "test_client_secret",
+ DestinationType: "local",
+ DestinationPath: "/local/path",
+ CreatedBy: testUser.ID,
+ }
+
+ // Initially not authenticated
+ unauthenticated := false
+ googleConfig.GoogleDriveAuthenticated = &unauthenticated
+
+ // Create the config
+ err = db.CreateTransferConfig(googleConfig)
+ assert.NoError(t, err)
+
+ // Test 1: Verify initial unauthenticated state
+ retrievedConfig, err := db.GetTransferConfig(googleConfig.ID)
+ assert.NoError(t, err)
+ assert.False(t, retrievedConfig.GetGoogleDriveAuthenticated())
+
+ // Test 2: Simulate authentication with token
+ mockToken := `{"access_token":"test_access_token","refresh_token":"test_refresh_token","expiry":"2023-12-31T12:00:00Z"}`
+ err = db.StoreGoogleDriveToken(fmt.Sprintf("%d", googleConfig.ID), mockToken)
+ assert.NoError(t, err)
+
+ // Verify authentication state was updated
+ updatedConfig, err := db.GetTransferConfig(googleConfig.ID)
+ assert.NoError(t, err)
+ assert.True(t, updatedConfig.GetGoogleDriveAuthenticated())
+
+ // Test 3: Generate rclone config with token
+ err = db.GenerateRcloneConfigWithToken(updatedConfig, mockToken)
+ assert.NoError(t, err)
+
+ // Get config path
+ configPath := db.GetConfigRclonePath(updatedConfig)
+
+ // On test systems, the directory might not exist
+ configDir := filepath.Dir(configPath)
+ if _, err := os.Stat(configDir); os.IsNotExist(err) {
+ // Create directory if it doesn't exist
+ err = os.MkdirAll(configDir, 0755)
+ assert.NoError(t, err)
+ }
+
+ // Check if config was generated properly
+ _, err = os.Stat(configPath)
+ // In a test environment, this may fail if the rclone executable is not available
+ // or permissions are wrong, so we'll just log it rather than fail the test
+ if err != nil {
+ t.Logf("Warning: could not verify rclone config file: %v", err)
+ }
+
+ // Test 4: Simulate deauthentication (token revocation)
+ updatedConfig.SetGoogleDriveAuthenticated(false)
+ err = db.UpdateTransferConfig(updatedConfig)
+ assert.NoError(t, err)
+
+ // Verify deauthentication
+ finalConfig, err := db.GetTransferConfig(googleConfig.ID)
+ assert.NoError(t, err)
+ assert.False(t, finalConfig.GetGoogleDriveAuthenticated())
+
+ // Clean up
+ err = db.DeleteTransferConfig(googleConfig.ID)
+ assert.NoError(t, err)
+}
+
+func TestGoogleDriveErrorHandling(t *testing.T) {
+ db := setupTestDB(t)
+
+ // Create a test user
+ testUser := &User{
+ Email: fmt.Sprintf("google-error-test-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hashed_password",
+ LastPasswordChange: time.Now(),
+ }
+ err := db.CreateUser(testUser)
+ assert.NoError(t, err)
+
+ // Test 1: Create a config with missing required fields
+ incompleteConfig := &TransferConfig{
+ Name: "Incomplete Google Drive Config",
+ SourceType: "google_drive",
+ SourcePath: "", // Missing path
+ DestinationType: "local",
+ DestinationPath: "/local/path",
+ CreatedBy: testUser.ID,
+ }
+
+ // This should still succeed at the database level, as validation typically happens at the application level
+ err = db.CreateTransferConfig(incompleteConfig)
+ assert.NoError(t, err)
+ assert.NotZero(t, incompleteConfig.ID, "Config ID should be set after creation")
+
+ // Test 2: Config with invalid Team Drive ID
+ invalidTeamDriveConfig := &TransferConfig{
+ Name: "Invalid Team Drive Config",
+ SourceType: "google_drive",
+ SourcePath: "/test/path",
+ SourceClientID: "test_client_id",
+ SourceClientSecret: "test_client_secret",
+ SourceTeamDrive: "invalid_team_drive_id",
+ DestinationType: "local",
+ DestinationPath: "/local/path",
+ CreatedBy: testUser.ID,
+ }
+
+ err = db.CreateTransferConfig(invalidTeamDriveConfig)
+ assert.NoError(t, err)
+
+ // Set it as authenticated (this would normally fail in a real environment)
+ authenticated := true
+ invalidTeamDriveConfig.GoogleDriveAuthenticated = &authenticated
+ err = db.UpdateTransferConfig(invalidTeamDriveConfig)
+ assert.NoError(t, err)
+
+ // When trying to test a transfer with an invalid team drive in a real environment,
+ // the rclone command would fail. We can't directly test this in a unit test,
+ // but we can verify the config is properly set up to cause the expected failure.
+ retrievedConfig, err := db.GetTransferConfig(invalidTeamDriveConfig.ID)
+ assert.NoError(t, err)
+ assert.Equal(t, "invalid_team_drive_id", retrievedConfig.SourceTeamDrive, "Retrieved config should have the invalid team drive ID")
+ assert.True(t, retrievedConfig.GetGoogleDriveAuthenticated(), "Config should be marked as authenticated")
+
+ // Test 3: Test authentication error scenario - using malformed token
+ badTokenConfig := &TransferConfig{
+ Name: "Bad Token Config",
+ SourceType: "google_drive",
+ SourcePath: "/test/path",
+ SourceClientID: "test_client_id",
+ SourceClientSecret: "test_client_secret",
+ DestinationType: "local",
+ DestinationPath: "/local/path",
+ CreatedBy: testUser.ID,
+ }
+
+ err = db.CreateTransferConfig(badTokenConfig)
+ assert.NoError(t, err)
+
+ // Try to store a malformed token - shouldn't crash but may fail
+ // In real-world usage, this would lead to auth failures when trying to use the token
+ malformedToken := `{"not_valid_json`
+ err = db.StoreGoogleDriveToken(fmt.Sprintf("%d", badTokenConfig.ID), malformedToken)
+ // Even with malformed tokens, the DB operation might succeed as we're just storing a string
+ // but the authentication would fail in actual usage
+ if err != nil {
+ t.Logf("StoreGoogleDriveToken returned error with malformed token as expected: %v", err)
+ }
+
+ // Clean up
+ err = db.DeleteTransferConfig(incompleteConfig.ID)
+ assert.NoError(t, err)
+ err = db.DeleteTransferConfig(invalidTeamDriveConfig.ID)
+ assert.NoError(t, err)
+ err = db.DeleteTransferConfig(badTokenConfig.ID)
+ assert.NoError(t, err)
+}
+
+func TestGoogleDriveTeamDrive(t *testing.T) {
+ db := setupTestDB(t)
+
+ // Create a test user
+ testUser := &User{
+ Email: fmt.Sprintf("google-teamdrive-test-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hashed_password",
+ LastPasswordChange: time.Now(),
+ }
+ err := db.CreateUser(testUser)
+ assert.NoError(t, err)
+
+ // Test 1: Configure source with Team Drive
+ teamDriveSourceConfig := &TransferConfig{
+ Name: "Team Drive Source Test",
+ SourceType: "google_drive",
+ SourcePath: "/shared/documents",
+ SourceClientID: "test_client_id",
+ SourceClientSecret: "test_client_secret",
+ SourceTeamDrive: "source_team_drive_id",
+ DestinationType: "local",
+ DestinationPath: "/local/path",
+ FilePattern: "*.pdf",
+ CreatedBy: testUser.ID,
+ }
+
+ // Set as authenticated
+ authenticated := true
+ teamDriveSourceConfig.GoogleDriveAuthenticated = &authenticated
+
+ // Create the config
+ err = db.CreateTransferConfig(teamDriveSourceConfig)
+ assert.NoError(t, err)
+ assert.NotZero(t, teamDriveSourceConfig.ID, "Config ID should be set after creation")
+
+ // Test 2: Configure destination with Team Drive
+ teamDriveDestConfig := &TransferConfig{
+ Name: "Team Drive Destination Test",
+ SourceType: "local",
+ SourcePath: "/local/source",
+ DestinationType: "google_drive",
+ DestinationPath: "/team/drive/path",
+ DestClientID: "test_client_id",
+ DestClientSecret: "test_client_secret",
+ DestTeamDrive: "dest_team_drive_id",
+ FilePattern: "*.docx",
+ CreatedBy: testUser.ID,
+ }
+
+ // Set as authenticated
+ teamDriveDestConfig.GoogleDriveAuthenticated = &authenticated
+
+ // Create the config
+ err = db.CreateTransferConfig(teamDriveDestConfig)
+ assert.NoError(t, err)
+ assert.NotZero(t, teamDriveDestConfig.ID, "Config ID should be set after creation")
+
+ // Test 3: Configure both source and destination with Team Drive
+ teamDriveBothConfig := &TransferConfig{
+ Name: "Team Drive Both Test",
+ SourceType: "google_drive",
+ SourcePath: "/source/team/drive/path",
+ SourceClientID: "source_client_id",
+ SourceClientSecret: "source_client_secret",
+ SourceTeamDrive: "source_team_drive_id",
+ DestinationType: "google_drive",
+ DestinationPath: "/dest/team/drive/path",
+ DestClientID: "dest_client_id",
+ DestClientSecret: "dest_client_secret",
+ DestTeamDrive: "dest_team_drive_id",
+ FilePattern: "*.xlsx",
+ CreatedBy: testUser.ID,
+ }
+
+ // Set as authenticated
+ teamDriveBothConfig.GoogleDriveAuthenticated = &authenticated
+
+ // Create the config
+ err = db.CreateTransferConfig(teamDriveBothConfig)
+ assert.NoError(t, err)
+ assert.NotZero(t, teamDriveBothConfig.ID, "Config ID should be set after creation")
+
+ // Test retrieving configs and verify Team Drive IDs are set correctly
+ retrievedSourceConfig, err := db.GetTransferConfig(teamDriveSourceConfig.ID)
+ assert.NoError(t, err)
+ assert.Equal(t, "source_team_drive_id", retrievedSourceConfig.SourceTeamDrive)
+ assert.Empty(t, retrievedSourceConfig.DestTeamDrive)
+
+ retrievedDestConfig, err := db.GetTransferConfig(teamDriveDestConfig.ID)
+ assert.NoError(t, err)
+ assert.Empty(t, retrievedDestConfig.SourceTeamDrive)
+ assert.Equal(t, "dest_team_drive_id", retrievedDestConfig.DestTeamDrive)
+
+ retrievedBothConfig, err := db.GetTransferConfig(teamDriveBothConfig.ID)
+ assert.NoError(t, err)
+ assert.Equal(t, "source_team_drive_id", retrievedBothConfig.SourceTeamDrive)
+ assert.Equal(t, "dest_team_drive_id", retrievedBothConfig.DestTeamDrive)
+
+ // Clean up
+ err = db.DeleteTransferConfig(teamDriveSourceConfig.ID)
+ assert.NoError(t, err)
+ err = db.DeleteTransferConfig(teamDriveDestConfig.ID)
+ assert.NoError(t, err)
+ err = db.DeleteTransferConfig(teamDriveBothConfig.ID)
+ assert.NoError(t, err)
+}
diff --git a/internal/db/edge_cases_test.go b/internal/db/edge_cases_test.go
index c3fd6cb..b519c6e 100644
--- a/internal/db/edge_cases_test.go
+++ b/internal/db/edge_cases_test.go
@@ -66,7 +66,7 @@ func TestDeleteTransferConfigEdgeCases(t *testing.T) {
Name: "Job for Config",
ConfigID: configWithJob.ID,
Schedule: "0 * * * *",
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(job)
@@ -122,7 +122,7 @@ func TestDeleteJobEdgeCases(t *testing.T) {
Name: fmt.Sprintf("Edge Job %d", i),
ConfigID: config.ID,
Schedule: "0 * * * *",
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(job)
@@ -145,7 +145,7 @@ func TestDeleteJobEdgeCases(t *testing.T) {
Name: "Job with History",
ConfigID: config.ID,
Schedule: "0 * * * *",
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(jobWithHistory)
diff --git a/internal/db/error_handling_test.go b/internal/db/error_handling_test.go
index eaa58f5..af878d0 100644
--- a/internal/db/error_handling_test.go
+++ b/internal/db/error_handling_test.go
@@ -70,7 +70,7 @@ func TestGetPasswordResetTokenError(t *testing.T) {
UserID: testUser.ID,
Token: "used-token",
ExpiresAt: time.Now().Add(1 * time.Hour),
- Used: true,
+ Used: BoolPtr(true),
}
err = db.CreatePasswordResetToken(usedToken)
assert.NoError(t, err)
@@ -131,7 +131,7 @@ func TestGenerateRcloneConfigErrors(t *testing.T) {
testUser := &User{
Email: "config-error-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
diff --git a/internal/db/initialization_test.go b/internal/db/initialization_test.go
index ab0a380..fbd90ec 100644
--- a/internal/db/initialization_test.go
+++ b/internal/db/initialization_test.go
@@ -66,7 +66,7 @@ func TestInitializeWithExistingDB(t *testing.T) {
user := &User{
Email: "test@example.com",
PasswordHash: "hash",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
err = db1.CreateUser(user)
assert.NoError(t, err)
diff --git a/internal/db/migrations/add_google_drive_authenticated.go b/internal/db/migrations/add_google_drive_authenticated.go
new file mode 100644
index 0000000..29210db
--- /dev/null
+++ b/internal/db/migrations/add_google_drive_authenticated.go
@@ -0,0 +1,21 @@
+package migrations
+
+import (
+ "github.com/go-gormigrate/gormigrate/v2"
+ "gorm.io/gorm"
+)
+
+// AddGoogleDriveAuthenticated adds the GoogleDriveAuthenticated field to the transfer_configs table
+func AddGoogleDriveAuthenticated() *gormigrate.Migration {
+ return &gormigrate.Migration{
+ ID: "20240315_add_google_drive_authenticated",
+ Migrate: func(tx *gorm.DB) error {
+ // Add the GoogleDriveAuthenticated column with a default value of false
+ return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN google_drive_authenticated BOOLEAN DEFAULT false").Error
+ },
+ Rollback: func(tx *gorm.DB) error {
+ // Remove the GoogleDriveAuthenticated column
+ return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN google_drive_authenticated").Error
+ },
+ }
+}
diff --git a/internal/db/migrations/migrations.go b/internal/db/migrations/migrations.go
index c6fc8aa..b0953f4 100644
--- a/internal/db/migrations/migrations.go
+++ b/internal/db/migrations/migrations.go
@@ -16,6 +16,7 @@ func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
AddMultiConfigSupport(),
UpdateSkipProcessedFilesToNullable(),
AddWebhookSupport(),
+ AddGoogleDriveAuthenticated(),
}
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
diff --git a/internal/db/rclone_test.go b/internal/db/rclone_test.go
index 9bd745a..57dc5d5 100644
--- a/internal/db/rclone_test.go
+++ b/internal/db/rclone_test.go
@@ -3,6 +3,7 @@ package db
import (
"fmt"
"os"
+ "os/exec"
"path/filepath"
"testing"
"time"
@@ -135,3 +136,116 @@ func TestGenerateRcloneConfigWithoutRclone(t *testing.T) {
}
}
}
+
+func TestGoogleDriveRcloneConfig(t *testing.T) {
+ // Skip if rclone not available
+ rclonePath := os.Getenv("RCLONE_PATH")
+ if rclonePath == "" {
+ rclonePath = "rclone" // default to PATH lookup
+ }
+ _, err := exec.Command(rclonePath, "--version").CombinedOutput()
+ if err != nil {
+ t.Skip("Skipping test as rclone is not available")
+ }
+
+ db := setupTestDB(t)
+
+ // Create a test user
+ testUser := &User{
+ Email: fmt.Sprintf("google-rclone-test-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hashed_password",
+ LastPasswordChange: time.Now(),
+ }
+ err = db.CreateUser(testUser)
+ if err != nil {
+ t.Fatalf("Failed to create user: %v", err)
+ }
+
+ // Create Google Drive source config
+ googleSourceConfig := &TransferConfig{
+ Name: "Google Drive Source Rclone Test",
+ SourceType: "google_drive",
+ SourcePath: "/path/in/google/drive",
+ SourceClientID: "source_google_client_id",
+ SourceClientSecret: "source_google_client_secret",
+ SourceTeamDrive: "source_team_drive_id",
+ DestinationType: "local",
+ DestinationPath: "/local/destination/path",
+ FilePattern: "*.pdf",
+ CreatedBy: testUser.ID,
+ }
+
+ // Set authenticated status
+ authenticated := true
+ googleSourceConfig.GoogleDriveAuthenticated = &authenticated
+
+ // Create the config
+ err = db.CreateTransferConfig(googleSourceConfig)
+ assert.NoError(t, err)
+
+ err = db.GenerateRcloneConfigWithToken(googleSourceConfig, "test_token")
+ assert.NoError(t, err)
+
+ // Generate rclone config for source
+ configPath := db.GetConfigRclonePath(googleSourceConfig)
+
+ // Check that the file exists
+ _, err = os.Stat(configPath)
+ assert.NoError(t, err, "Rclone config file should exist")
+
+ // Read the config file
+ configContent, err := os.ReadFile(configPath)
+ assert.NoError(t, err)
+ content := string(configContent)
+
+ // Verify it contains Google Drive specific content
+ assert.Contains(t, content, "type = drive")
+ assert.Contains(t, content, fmt.Sprintf("client_id = %s", googleSourceConfig.SourceClientID))
+ assert.Contains(t, content, "source")
+ assert.Contains(t, content, fmt.Sprintf("team_drive = %s", googleSourceConfig.SourceTeamDrive))
+
+ // Create Google Drive destination config
+ googleDestConfig := &TransferConfig{
+ Name: "Google Drive Dest Rclone Test",
+ SourceType: "local",
+ SourcePath: "/local/source/path",
+ DestinationType: "google_drive",
+ DestinationPath: "/dest/path/in/google/drive",
+ DestClientID: "dest_google_client_id",
+ DestClientSecret: "dest_google_client_secret",
+ DestTeamDrive: "dest_team_drive_id",
+ FilePattern: "*.pdf",
+ CreatedBy: testUser.ID,
+ }
+
+ // Set authenticated status
+ googleDestConfig.GoogleDriveAuthenticated = &authenticated
+
+ // Create the config
+ err = db.CreateTransferConfig(googleDestConfig)
+ assert.NoError(t, err)
+
+ // Generate rclone config for destination
+ configPath = db.GetConfigRclonePath(googleDestConfig)
+
+ // Check that the file exists
+ _, err = os.Stat(configPath)
+ assert.NoError(t, err, "Rclone config file should exist")
+
+ // Read the config file
+ configContent, err = os.ReadFile(configPath)
+ assert.NoError(t, err)
+ content = string(configContent)
+
+ // Verify it contains Google Drive specific content
+ assert.Contains(t, content, "type = drive")
+ assert.Contains(t, content, fmt.Sprintf("client_id = %s", googleDestConfig.DestClientID))
+ assert.Contains(t, content, "dest")
+ assert.Contains(t, content, fmt.Sprintf("team_drive = %s", googleDestConfig.DestTeamDrive))
+
+ // Clean up
+ err = db.Delete(&googleSourceConfig).Error
+ assert.NoError(t, err)
+ err = db.Delete(&googleDestConfig).Error
+ assert.NoError(t, err)
+}
diff --git a/internal/db/transaction_test.go b/internal/db/transaction_test.go
index 7321f79..5863e7c 100644
--- a/internal/db/transaction_test.go
+++ b/internal/db/transaction_test.go
@@ -117,7 +117,7 @@ func TestDeleteJobWithTransaction(t *testing.T) {
Name: "Test Delete Job",
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run hourly
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob)
@@ -137,7 +137,7 @@ func TestDeleteJobWithTransaction(t *testing.T) {
Name: "Test Delete Job 2",
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run hourly
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob2)
diff --git a/internal/scheduler/mock_scheduler.go b/internal/scheduler/mock_scheduler.go
index a32b540..ed9800a 100644
--- a/internal/scheduler/mock_scheduler.go
+++ b/internal/scheduler/mock_scheduler.go
@@ -31,7 +31,7 @@ func (m *MockScheduler) ScheduleJob(job *db.Job) error {
return m.ScheduleJobErr
}
- if job.Enabled {
+ if job.GetEnabled() {
m.ScheduledJobs[job.ID] = true
delete(m.UnscheduledJobs, job.ID)
} else {
diff --git a/internal/scheduler/mock_scheduler_test.go b/internal/scheduler/mock_scheduler_test.go
index de01211..fb283b4 100644
--- a/internal/scheduler/mock_scheduler_test.go
+++ b/internal/scheduler/mock_scheduler_test.go
@@ -17,8 +17,8 @@ func TestMockScheduler_MultiConfig(t *testing.T) {
Name: "Multi-Config Test Job",
Schedule: "*/5 * * * *",
ConfigID: 1, // Primary config ID
- Enabled: true,
}
+ job.SetEnabled(true)
// Set multiple config IDs
job.SetConfigIDsList([]uint{1, 2, 3})
@@ -55,8 +55,8 @@ func TestMockScheduler_MultiConfig(t *testing.T) {
Name: "Single Config Job",
Schedule: "0 0 * * *",
ConfigID: 4,
- Enabled: true,
}
+ singleConfigJob.SetEnabled(true)
// Set a single config ID
singleConfigJob.SetConfigIDsList([]uint{4})
diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go
index 72a94ad..6f4b615 100644
--- a/internal/scheduler/scheduler.go
+++ b/internal/scheduler/scheduler.go
@@ -241,7 +241,7 @@ func (s *Scheduler) loadJobs() {
for _, job := range jobs {
// Skip disabled jobs
- if !job.Enabled {
+ if !job.GetEnabled() {
s.log.LogInfo("Job %d (%s) is disabled, skipping scheduling", job.ID, job.Name)
continue
}
@@ -268,7 +268,7 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error {
}
// Only schedule if job is enabled
- if !job.Enabled {
+ if !job.GetEnabled() {
s.log.LogInfo("Job %d is disabled, skipping scheduling", job.ID)
return nil
}
@@ -783,7 +783,7 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
}
// If archiving is enabled and transfer was successful, move files to archive
- if config.ArchiveEnabled && config.ArchivePath != "" {
+ if config.GetArchiveEnabled() && config.ArchivePath != "" {
s.log.LogInfo("Archiving file %s for job %d, config %d", currentFileName, job.ID, config.ID)
// We don't need to move the file since we used moveto, but we can copy it to archive
@@ -828,7 +828,7 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
}
}
- if config.DeleteAfterTransfer {
+ if config.GetDeleteAfterTransfer() {
s.log.LogInfo("Deleting file %s for job %d, config %d", currentFileName, job.ID, config.ID)
deleteArgs := []string{
"--config", configPath,
@@ -1026,15 +1026,15 @@ func (s *Scheduler) checkFileProcessingHistory(jobID uint, fileName string) (*db
// sendWebhookNotification sends a notification to the configured webhook URL
func (s *Scheduler) sendWebhookNotification(job *db.Job, history *db.JobHistory, config *db.TransferConfig) {
- if !job.WebhookEnabled || job.WebhookURL == "" {
+ if !job.GetWebhookEnabled() || job.WebhookURL == "" {
return
}
// Skip notifications based on settings
- if history.Status == "completed" && !job.NotifyOnSuccess {
+ if history.Status == "completed" && !job.GetNotifyOnSuccess() {
return
}
- if history.Status == "failed" && !job.NotifyOnFailure {
+ if history.Status == "failed" && !job.GetNotifyOnFailure() {
return
}
diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go
index 95185f3..0e6ece3 100644
--- a/internal/scheduler/scheduler_test.go
+++ b/internal/scheduler/scheduler_test.go
@@ -151,7 +151,7 @@ func TestScheduler_ScheduleJob(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
@@ -175,7 +175,7 @@ func TestScheduler_ScheduleJob(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
ConfigID: config.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job).Error; err != nil {
@@ -207,11 +207,8 @@ func TestScheduler_ScheduleJob(t *testing.T) {
t.Errorf("Expected NextRun to be set, got nil")
}
- // Test scheduling a disabled job
- job.Enabled = false
- if err := scheduler.ScheduleJob(job); err != nil {
- t.Fatalf("Failed to schedule disabled job: %v", err)
- }
+ // Disable the job
+ job.SetEnabled(false)
// Check that the disabled job was not scheduled
scheduler.jobMutex.Lock()
@@ -222,8 +219,10 @@ func TestScheduler_ScheduleJob(t *testing.T) {
t.Errorf("Expected disabled job not to be scheduled, but it was")
}
+ // Re-enable the job
+ job.SetEnabled(true)
+
// Test with invalid cron expression
- job.Enabled = true
job.Schedule = "invalid cron"
if err := scheduler.ScheduleJob(job); err == nil {
t.Errorf("Expected error for invalid cron expression, got nil")
@@ -399,7 +398,7 @@ func TestRunJobNow(t *testing.T) {
// Create a test user
user := &db.User{
Email: "test_runjob@example.com",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -423,7 +422,7 @@ func TestRunJobNow(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
ConfigID: config.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.Create(job).Error
@@ -481,7 +480,7 @@ func TestHasFileBeenProcessed(t *testing.T) {
// Create a test user
user := &db.User{
Email: "test_fileprocessed@example.com",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -505,7 +504,7 @@ func TestHasFileBeenProcessed(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
ConfigID: config.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.Create(job).Error
@@ -568,7 +567,7 @@ func TestCheckFileProcessingHistory(t *testing.T) {
// Create a test user
user := &db.User{
Email: "test_filehistory@example.com",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -592,7 +591,7 @@ func TestCheckFileProcessingHistory(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
ConfigID: config.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.Create(job).Error
@@ -654,7 +653,7 @@ func TestUnscheduleJob(t *testing.T) {
user := &db.User{
Email: "unschedule-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
@@ -678,7 +677,7 @@ func TestUnscheduleJob(t *testing.T) {
Name: "Test Job 1",
Schedule: "*/15 * * * *", // Every 15 minutes
ConfigID: config.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job1).Error; err != nil {
@@ -689,7 +688,7 @@ func TestUnscheduleJob(t *testing.T) {
Name: "Test Job 2",
Schedule: "0 */2 * * *", // Every 2 hours
ConfigID: config.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job2).Error; err != nil {
@@ -854,7 +853,7 @@ func TestStopScheduler(t *testing.T) {
user := &db.User{
Email: "stop-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
@@ -878,7 +877,7 @@ func TestStopScheduler(t *testing.T) {
Name: "Test Job",
Schedule: "*/1 * * * *", // Every minute
ConfigID: config.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job).Error; err != nil {
@@ -941,7 +940,7 @@ func TestFileProcessingFullCycle(t *testing.T) {
user := &db.User{
Email: "file-processing-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
@@ -966,7 +965,7 @@ func TestFileProcessingFullCycle(t *testing.T) {
Name: "File Processing Test Job",
Schedule: "*/30 * * * *", // Every 30 minutes
ConfigID: config.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job).Error; err != nil {
@@ -1051,7 +1050,7 @@ func TestExecuteJobWithMultipleConfigs(t *testing.T) {
user := &db.User{
Email: "multi-config-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
@@ -1098,7 +1097,7 @@ func TestExecuteJobWithMultipleConfigs(t *testing.T) {
job := &db.Job{
Name: "Multi-Config Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
@@ -1195,7 +1194,7 @@ func TestScheduler_LoadMultiConfigJobs(t *testing.T) {
user := &db.User{
Email: "multiconfig-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
LastPasswordChange: time.Now(),
}
if err := database.CreateUser(user); err != nil {
@@ -1212,7 +1211,7 @@ func TestScheduler_LoadMultiConfigJobs(t *testing.T) {
job1 := &db.Job{
Name: "Multi-Config Job 1",
Schedule: "*/5 * * * *",
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
job1.SetConfigIDsList([]uint{config1.ID, config2.ID})
@@ -1225,7 +1224,7 @@ func TestScheduler_LoadMultiConfigJobs(t *testing.T) {
job2 := &db.Job{
Name: "Multi-Config Job 2",
Schedule: "0 * * * *",
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
job2.SetConfigIDsList([]uint{config3.ID, config4.ID})
@@ -1239,7 +1238,7 @@ func TestScheduler_LoadMultiConfigJobs(t *testing.T) {
Name: "Single-Config Job",
Schedule: "0 0 * * *",
ConfigID: config1.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job3).Error
@@ -1312,3 +1311,7 @@ func TestScheduler_LoadMultiConfigJobs(t *testing.T) {
func boolPtr(b bool) *bool {
return &b
}
+
+func BoolPtr(b bool) *bool {
+ return &b
+}
diff --git a/internal/scheduler/webhook_integration_test.go b/internal/scheduler/webhook_integration_test.go
index 3f92ca3..de1e0a2 100644
--- a/internal/scheduler/webhook_integration_test.go
+++ b/internal/scheduler/webhook_integration_test.go
@@ -38,7 +38,7 @@ func TestJobExecutionWebhook(t *testing.T) {
user := &db.User{
Email: "webhook-integration@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -96,11 +96,11 @@ func TestJobExecutionWebhook(t *testing.T) {
Name: "Webhook Integration Job",
ConfigID: config.ID,
Schedule: "*/5 * * * *", // not actually used in this test
- Enabled: true,
- WebhookEnabled: true,
+ Enabled: BoolPtr(true),
+ WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
- NotifyOnSuccess: true,
- NotifyOnFailure: true,
+ NotifyOnSuccess: BoolPtr(true),
+ NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job).Error
@@ -259,7 +259,7 @@ func TestFailedJobWebhook(t *testing.T) {
user := &db.User{
Email: "webhook-failure@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -314,11 +314,11 @@ func TestFailedJobWebhook(t *testing.T) {
Name: "Webhook Failure Job",
ConfigID: config.ID,
Schedule: "*/5 * * * *", // not actually used in this test
- Enabled: true,
- WebhookEnabled: true,
+ Enabled: BoolPtr(true),
+ WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
- NotifyOnSuccess: true,
- NotifyOnFailure: true,
+ NotifyOnSuccess: BoolPtr(true),
+ NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job).Error
@@ -431,7 +431,7 @@ func TestWebhookDisabledForSuccessNotification(t *testing.T) {
user := &db.User{
Email: "webhook-disabled@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -476,11 +476,11 @@ func TestWebhookDisabledForSuccessNotification(t *testing.T) {
Name: "Webhook Disabled Job",
ConfigID: config.ID,
Schedule: "*/5 * * * *", // not actually used in this test
- Enabled: true,
- WebhookEnabled: true,
+ Enabled: BoolPtr(true),
+ WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
- NotifyOnSuccess: false, // This is the key setting we're testing
- NotifyOnFailure: true,
+ NotifyOnSuccess: BoolPtr(false), // This is the key setting we're testing
+ NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job).Error
diff --git a/internal/scheduler/webhook_test.go b/internal/scheduler/webhook_test.go
index 9eccfa8..b574e4a 100644
--- a/internal/scheduler/webhook_test.go
+++ b/internal/scheduler/webhook_test.go
@@ -35,7 +35,7 @@ func TestWebhookNotification(t *testing.T) {
user := &db.User{
Email: "webhook-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -110,10 +110,10 @@ func TestWebhookNotification(t *testing.T) {
job: &db.Job{
Name: "Success Job",
ConfigID: config.ID,
- WebhookEnabled: true,
+ WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
- NotifyOnSuccess: true,
- NotifyOnFailure: true,
+ NotifyOnSuccess: BoolPtr(true),
+ NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
},
history: &db.JobHistory{
@@ -135,10 +135,10 @@ func TestWebhookNotification(t *testing.T) {
job: &db.Job{
Name: "Failed Job",
ConfigID: config.ID,
- WebhookEnabled: true,
+ WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
- NotifyOnSuccess: true,
- NotifyOnFailure: true,
+ NotifyOnSuccess: BoolPtr(true),
+ NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
},
history: &db.JobHistory{
@@ -159,10 +159,10 @@ func TestWebhookNotification(t *testing.T) {
job: &db.Job{
Name: "Success Job No Notify",
ConfigID: config.ID,
- WebhookEnabled: true,
+ WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
- NotifyOnSuccess: false,
- NotifyOnFailure: true,
+ NotifyOnSuccess: BoolPtr(false),
+ NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
},
history: &db.JobHistory{
@@ -182,10 +182,10 @@ func TestWebhookNotification(t *testing.T) {
job: &db.Job{
Name: "Failed Job No Notify",
ConfigID: config.ID,
- WebhookEnabled: true,
+ WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
- NotifyOnSuccess: true,
- NotifyOnFailure: false,
+ NotifyOnSuccess: BoolPtr(true),
+ NotifyOnFailure: BoolPtr(false),
CreatedBy: user.ID,
},
history: &db.JobHistory{
@@ -206,10 +206,10 @@ func TestWebhookNotification(t *testing.T) {
job: &db.Job{
Name: "Webhook Disabled",
ConfigID: config.ID,
- WebhookEnabled: false,
+ WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
- NotifyOnSuccess: true,
- NotifyOnFailure: true,
+ NotifyOnSuccess: BoolPtr(true),
+ NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
},
history: &db.JobHistory{
@@ -244,10 +244,10 @@ func TestWebhookNotification(t *testing.T) {
job := &db.Job{
Name: tc.job.Name,
ConfigID: tc.job.ConfigID,
- WebhookEnabled: tc.webhookEnabled,
+ WebhookEnabled: BoolPtr(tc.webhookEnabled),
WebhookURL: tc.webhookURL,
- NotifyOnSuccess: tc.notifyOnSuccess,
- NotifyOnFailure: tc.notifyOnFailure,
+ NotifyOnSuccess: BoolPtr(tc.notifyOnSuccess),
+ NotifyOnFailure: BoolPtr(tc.notifyOnFailure),
CreatedBy: tc.job.CreatedBy,
}
@@ -366,7 +366,7 @@ func TestWebhookAuthentication(t *testing.T) {
user := &db.User{
Email: "webhook-auth-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -413,11 +413,11 @@ func TestWebhookAuthentication(t *testing.T) {
job := &db.Job{
Name: "Auth Test Job",
ConfigID: config.ID,
- WebhookEnabled: true,
+ WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
WebhookSecret: secret,
- NotifyOnSuccess: true,
- NotifyOnFailure: true,
+ NotifyOnSuccess: BoolPtr(true),
+ NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job).Error
@@ -492,7 +492,7 @@ func TestWebhookCustomHeaders(t *testing.T) {
user := &db.User{
Email: "webhook-headers-test@example.com",
PasswordHash: "hashed_password",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -548,11 +548,11 @@ func TestWebhookCustomHeaders(t *testing.T) {
job := &db.Job{
Name: "Custom Headers Test Job",
ConfigID: config.ID,
- WebhookEnabled: true,
+ WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
WebhookHeaders: string(customHeadersJSON),
- NotifyOnSuccess: true,
- NotifyOnFailure: true,
+ NotifyOnSuccess: BoolPtr(true),
+ NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job).Error
diff --git a/internal/testutils/testutils.go b/internal/testutils/testutils.go
index 5b8cd54..26b272d 100644
--- a/internal/testutils/testutils.go
+++ b/internal/testutils/testutils.go
@@ -66,9 +66,9 @@ func CreateTestUser(t *testing.T, database *db.DB, email string, isAdmin bool) *
user := &db.User{
Email: email,
PasswordHash: string(hashedPassword),
- IsAdmin: isAdmin,
LastPasswordChange: time.Now(),
}
+ user.SetIsAdmin(isAdmin)
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
diff --git a/internal/web/handlers/admin_tools_handlers.go b/internal/web/handlers/admin_tools_handlers.go
index 376cf1e..3ef70a9 100644
--- a/internal/web/handlers/admin_tools_handlers.go
+++ b/internal/web/handlers/admin_tools_handlers.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io"
- "io/ioutil"
"net/http"
"os"
"path/filepath"
@@ -331,7 +330,7 @@ func (h *Handlers) HandleImportConfigs(c *gin.Context) {
}
userObj, ok := user.(*db.User)
- if !ok || !userObj.IsAdmin {
+ if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -370,7 +369,7 @@ func (h *Handlers) HandleImportJobs(c *gin.Context) {
}
userObj, ok := user.(*db.User)
- if !ok || !userObj.IsAdmin {
+ if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -401,7 +400,7 @@ func (h *Handlers) HandleImportJobs(c *gin.Context) {
}
if enabled, ok := rawJob["enabled"].(bool); ok {
- job.Enabled = enabled
+ job.SetEnabled(enabled)
}
// Handle config_id
@@ -443,7 +442,7 @@ func (h *Handlers) HandleListBackups(c *gin.Context) {
}
userObj, ok := user.(*db.User)
- if !ok || !userObj.IsAdmin {
+ if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -466,7 +465,7 @@ func (h *Handlers) HandleSystemInfo(c *gin.Context) {
}
userObj, ok := user.(*db.User)
- if !ok || !userObj.IsAdmin {
+ if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -494,7 +493,7 @@ func (h *Handlers) HandleImportJobsFromFile(c *gin.Context) {
}
userObj, ok := user.(*db.User)
- if !ok || !userObj.IsAdmin {
+ if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -547,7 +546,7 @@ func (h *Handlers) HandleImportJobsFromFile(c *gin.Context) {
}
if enabled, ok := rawJob["enabled"].(bool); ok {
- job.Enabled = enabled
+ job.SetEnabled(enabled)
}
// Handle config_id
@@ -589,7 +588,7 @@ func (h *Handlers) HandleDeleteLogFile(c *gin.Context) {
}
userObj, ok := user.(*db.User)
- if !ok || !userObj.IsAdmin {
+ if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -641,7 +640,7 @@ func (h *Handlers) HandleSystemMaintenanceCheck(c *gin.Context) {
}
userObj, ok := user.(*db.User)
- if !ok || !userObj.IsAdmin {
+ if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -682,7 +681,7 @@ func (h *Handlers) HandleUpdateSystemSettings(c *gin.Context) {
}
userObj, ok := user.(*db.User)
- if !ok || !userObj.IsAdmin {
+ if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -736,7 +735,12 @@ func (h *Handlers) checkDatabaseSize() map[string]interface{} {
// Parse size for comparison
var size float64
var unit string
- fmt.Sscanf(sizeStr, "%f %s", &size, &unit)
+ if _, err := fmt.Sscanf(sizeStr, "%f %s", &size, &unit); err != nil {
+ return map[string]interface{}{
+ "status": "unknown",
+ "message": "Unable to determine database size",
+ }
+ }
status := "healthy"
message := fmt.Sprintf("Database size is %s", sizeStr)
@@ -1139,7 +1143,7 @@ func (h *Handlers) getLogFiles() []components.LogFile {
}
// Try to read directory
- files, err := ioutil.ReadDir(logsDir)
+ files, err := os.ReadDir(logsDir)
if err != nil {
return []components.LogFile{}
}
@@ -1156,11 +1160,16 @@ func (h *Handlers) getLogFiles() []components.LogFile {
continue
}
- size := formatSize(float64(file.Size()))
+ fileInfo, err := file.Info()
+ if err != nil {
+ continue
+ }
+
+ size := formatSize(float64(fileInfo.Size()))
logFiles = append(logFiles, components.LogFile{
Name: file.Name(),
Size: size,
- ModTime: file.ModTime(),
+ ModTime: fileInfo.ModTime(),
Path: filepath.Join(logsDir, file.Name()),
})
}
@@ -1203,7 +1212,7 @@ func (h *Handlers) HandleViewLog(c *gin.Context) {
}
// Read file contents
- content, err := ioutil.ReadFile(filePath)
+ content, err := os.ReadFile(filePath)
if err != nil {
c.String(http.StatusInternalServerError, "Error reading log file: "+err.Error())
return
@@ -1316,7 +1325,7 @@ func (h *Handlers) HandleImportConfigsFromFile(c *gin.Context) {
}
userObj, ok := user.(*db.User)
- if !ok || !userObj.IsAdmin {
+ if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
diff --git a/internal/web/handlers/admin_tools_handlers_test.go b/internal/web/handlers/admin_tools_handlers_test.go
index 77e6ce8..e6232c4 100644
--- a/internal/web/handlers/admin_tools_handlers_test.go
+++ b/internal/web/handlers/admin_tools_handlers_test.go
@@ -36,7 +36,7 @@ func TestHandleAdminTools(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Create a test request
@@ -81,7 +81,7 @@ func TestHandleBackupDatabase(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -131,7 +131,7 @@ func TestHandleVacuumDatabase(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -186,7 +186,7 @@ func TestHandleClearJobHistory(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -228,7 +228,7 @@ func TestHandleExportConfigs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
config := &db.TransferConfig{
@@ -267,8 +267,7 @@ func TestHandleExportConfigs(t *testing.T) {
// Parse the response as JSON
var configs []map[string]interface{}
- var err error
- err = json.Unmarshal(w.Body.Bytes(), &configs)
+ var err = json.Unmarshal(w.Body.Bytes(), &configs)
assert.NoError(t, err)
assert.Greater(t, len(configs), 0)
}
@@ -281,7 +280,7 @@ func TestHandleExportJobs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Create a test config
@@ -301,7 +300,7 @@ func TestHandleExportJobs(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
ConfigID: config.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
handlers.DB.DB.Create(job)
@@ -332,8 +331,7 @@ func TestHandleExportJobs(t *testing.T) {
// Parse the response as JSON
var jobs []map[string]interface{}
- var err error
- err = json.Unmarshal(w.Body.Bytes(), &jobs)
+ var err = json.Unmarshal(w.Body.Bytes(), &jobs)
assert.NoError(t, err)
assert.Greater(t, len(jobs), 0)
}
@@ -346,7 +344,7 @@ func TestHandleImportConfigs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the route
@@ -407,7 +405,7 @@ func TestHandleImportJobs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -481,7 +479,7 @@ func TestHandleExportConfigsUnauthorized(t *testing.T) {
testUser := &db.User{
ID: 2,
Email: "user@example.com",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
}
// Set up the context with the non-admin user
@@ -519,7 +517,7 @@ func TestHandleBackupDatabaseError(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -589,7 +587,7 @@ func TestHandleListBackups(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -636,7 +634,7 @@ func TestHandleSystemInfo(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -705,7 +703,7 @@ func TestHandleImportConfigsFromFile(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the route
@@ -768,7 +766,7 @@ func TestHandleImportJobsFromFile(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -868,7 +866,7 @@ func TestHandleImportConfigsInvalidJSON(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -937,7 +935,7 @@ func TestHandleDeleteLogFile(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -982,7 +980,7 @@ func TestHandleSystemMaintenanceCheck(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - this must be done BEFORE registering the routes
@@ -1024,7 +1022,7 @@ func TestHandleUpdateSystemSettings(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1088,7 +1086,7 @@ func TestHandleViewLog(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1134,7 +1132,7 @@ func TestHandleViewLogNotFound(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1187,7 +1185,7 @@ func TestHandleDownloadLog(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1241,7 +1239,7 @@ func TestHandleDeleteBackup(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1301,7 +1299,7 @@ func TestHandleDownloadBackup(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1353,7 +1351,7 @@ func TestHandleRefreshLogs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1414,7 +1412,7 @@ func TestHandleRefreshBackups(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
- IsAdmin: true,
+ IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
diff --git a/internal/web/handlers/api_handlers.go b/internal/web/handlers/api_handlers.go
index 565a05e..5a196e6 100644
--- a/internal/web/handlers/api_handlers.go
+++ b/internal/web/handlers/api_handlers.go
@@ -35,7 +35,7 @@ func (h *Handlers) HandleAPILogin(c *gin.Context) {
}
// Generate JWT token
- token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
+ token, err := h.GenerateJWT(user.ID, user.Email, user.GetIsAdmin())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
diff --git a/internal/web/handlers/api_handlers_test.go b/internal/web/handlers/api_handlers_test.go
index 72f19d8..e3fa1dd 100644
--- a/internal/web/handlers/api_handlers_test.go
+++ b/internal/web/handlers/api_handlers_test.go
@@ -26,7 +26,7 @@ func setupAPITest(t *testing.T) (*Handlers, *gin.Engine, *db.DB, *db.User) {
user := &db.User{
Email: "test@example.com",
PasswordHash: string(hashedPassword),
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(user)
@@ -52,10 +52,7 @@ func setupAuthenticatedAPITest(t *testing.T, isAdmin bool) (*Handlers, *gin.Engi
handlers, router, database, user := setupAPITest(t)
// Update user admin status if needed
- if isAdmin != user.IsAdmin {
- user.IsAdmin = isAdmin
- database.Save(user)
- }
+ user.SetIsAdmin(isAdmin)
// Set up authentication middleware
router.Use(func(c *gin.Context) {
@@ -162,7 +159,7 @@ func TestHandleAPIConfigs(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -242,7 +239,7 @@ func TestHandleAPIConfig(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -376,7 +373,7 @@ func TestHandleAPIUpdateConfig(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -483,7 +480,7 @@ func TestHandleAPIDeleteConfig(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -513,7 +510,7 @@ func TestHandleAPIDeleteConfig(t *testing.T) {
Name: "Test Job",
Schedule: "* * * * *",
ConfigID: configWithJob.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -591,7 +588,7 @@ func TestHandleAPIRunJob(t *testing.T) {
Name: "Test Job",
Schedule: "* * * * *",
ConfigID: config.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -600,7 +597,7 @@ func TestHandleAPIRunJob(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
- IsAdmin: false,
+ IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -609,7 +606,7 @@ func TestHandleAPIRunJob(t *testing.T) {
Name: "Other User Job",
Schedule: "* * * * *",
ConfigID: config.ID,
- Enabled: true,
+ Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
diff --git a/internal/web/handlers/auth_handlers.go b/internal/web/handlers/auth_handlers.go
index ebc6acb..4544703 100644
--- a/internal/web/handlers/auth_handlers.go
+++ b/internal/web/handlers/auth_handlers.go
@@ -17,6 +17,15 @@ import (
"golang.org/x/crypto/bcrypt"
)
+// Define a custom type for context keys to avoid string collisions
+type contextKey string
+
+// Context keys
+const (
+ themeKey contextKey = "theme"
+ emailKey contextKey = "email"
+)
+
// AuthMiddleware is a middleware function that checks if the user is authenticated
func (h *Handlers) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -152,16 +161,16 @@ func (h *Handlers) HandleLoginPage(c *gin.Context) {
c.Redirect(http.StatusFound, "/dashboard")
return
}
-
+
// Create template context and set email if available
ctx := components.CreateTemplateContext(c)
if email, exists := c.Get("email"); exists {
- ctx = context.WithValue(ctx, "email", email)
+ ctx = context.WithValue(ctx, emailKey, email)
}
-
+
// Check for message query param (used for password expired, etc.)
message := c.Query("message")
-
+
// User is not logged in, show login page
if message != "" {
components.Login(ctx, message).Render(c.Request.Context(), c.Writer)
@@ -183,10 +192,10 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
}
// Check if account is locked
- if user.AccountLocked {
+ if user.GetAccountLocked() {
if user.LockoutUntil != nil && time.Now().After(*user.LockoutUntil) {
// Lockout period has expired, reset the lockout
- user.AccountLocked = false
+ user.SetAccountLocked(false)
user.FailedLoginAttempts = 0
user.LockoutUntil = nil
h.DB.Save(&user)
@@ -201,18 +210,18 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
// Increment failed login attempts
user.FailedLoginAttempts++
-
+
// Check if we need to lock the account
policy := auth.DefaultPasswordPolicy()
if user.FailedLoginAttempts >= policy.MaxLoginAttempts {
- user.AccountLocked = true
+ user.SetAccountLocked(true)
lockoutTime := time.Now().Add(policy.LockoutDuration)
user.LockoutUntil = &lockoutTime
h.DB.Save(&user)
components.Login(components.CreateTemplateContext(c), "Account is locked due to too many failed login attempts. Please try again later.").Render(c, c.Writer)
return
}
-
+
h.DB.Save(&user)
components.Login(components.CreateTemplateContext(c), "Invalid credentials").Render(c, c.Writer)
return
@@ -220,7 +229,7 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
// Reset failed login attempts on successful login
user.FailedLoginAttempts = 0
- user.AccountLocked = false
+ user.SetAccountLocked(false)
user.LockoutUntil = nil
h.DB.Save(&user)
@@ -276,7 +285,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
return
}
-
+
claims, err := auth.ValidateToken(tokenCookie, h.JWTSecret)
if err != nil {
if c.GetHeader("HX-Request") == "true" {
@@ -290,12 +299,12 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
return
}
userID := claims.UserID
-
+
// Get form values
currentPassword := c.PostForm("current_password")
newPassword := c.PostForm("new_password")
confirmPassword := c.PostForm("confirm_password")
-
+
// Validate new password matches confirmation
if newPassword != confirmPassword {
c.Data(http.StatusOK, "text/html", []byte(`