From d4c07fdc8ecd92f87450f078039ec6649d7ef907 Mon Sep 17 00:00:00 2001 From: StarFleetCPTN Date: Sat, 29 Mar 2025 06:02:03 -0700 Subject: [PATCH] refactor: database files --- ..._provider_db.go => auth_provider_store.go} | 0 internal/db/db.go | 2050 ----------------- internal/db/file_metadata.go | 25 + internal/db/file_metadata_store.go | 33 + internal/db/job.go | 161 ++ internal/db/job_store.go | 154 ++ internal/db/rclone.go | 87 + internal/db/rclone_store.go | 333 +++ internal/db/role_store.go | 143 ++ internal/db/transfer_config.go | 200 ++ internal/db/transfer_config_store.go | 497 ++++ internal/db/user.go | 166 ++ internal/db/user_store.go | 63 + 13 files changed, 1862 insertions(+), 2050 deletions(-) rename internal/db/{auth_provider_db.go => auth_provider_store.go} (100%) create mode 100644 internal/db/file_metadata.go create mode 100644 internal/db/file_metadata_store.go create mode 100644 internal/db/job.go create mode 100644 internal/db/job_store.go create mode 100644 internal/db/rclone.go create mode 100644 internal/db/rclone_store.go create mode 100644 internal/db/role_store.go create mode 100644 internal/db/transfer_config.go create mode 100644 internal/db/transfer_config_store.go create mode 100644 internal/db/user.go create mode 100644 internal/db/user_store.go diff --git a/internal/db/auth_provider_db.go b/internal/db/auth_provider_store.go similarity index 100% rename from internal/db/auth_provider_db.go rename to internal/db/auth_provider_store.go diff --git a/internal/db/db.go b/internal/db/db.go index feeb9eb..aac927b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,285 +1,19 @@ package db import ( - "context" - "errors" "fmt" - "log" "os" - "os/exec" "path/filepath" - "regexp" - "strconv" - "strings" - "time" "github.com/glebarez/sqlite" "github.com/starfleetcptn/gomft/internal/db/migrations" - "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) -type User struct { - ID uint `gorm:"primarykey"` - Email string `gorm:"unique;not null"` - PasswordHash string `gorm:"not null"` - IsAdmin *bool `gorm:"default:false"` - LastPasswordChange time.Time - FailedLoginAttempts int `gorm:"default:0"` - AccountLocked *bool `gorm:"default:false"` - LockoutUntil *time.Time - Theme string `gorm:"default:'light'"` - TwoFactorSecret string `gorm:"type:varchar(32)"` - TwoFactorEnabled bool `gorm:"default:false"` - BackupCodes string `gorm:"type:text"` // Comma-separated backup codes - Roles []Role `gorm:"many2many:user_roles"` - CreatedAt time.Time - UpdatedAt time.Time -} - -type PasswordHistory struct { - ID uint `gorm:"primarykey"` - UserID uint `gorm:"not null"` - User User `gorm:"foreignkey:UserID"` - PasswordHash string `gorm:"not null"` - CreatedAt time.Time -} - -type PasswordResetToken struct { - ID uint `gorm:"primarykey"` - UserID uint `gorm:"not null"` - User User `gorm:"foreignkey:UserID"` - Token string `gorm:"not null"` - ExpiresAt time.Time `gorm:"not null"` - Used *bool `gorm:"default:false"` - CreatedAt time.Time - UpdatedAt time.Time -} - -type TransferConfig struct { - ID uint `gorm:"primarykey"` - Name string `gorm:"not null" form:"name"` - SourceType string `gorm:"not null" form:"source_type"` - SourcePath string `gorm:"not null" form:"source_path"` - SourceHost string `form:"source_host"` - SourcePort int `gorm:"default:22" form:"source_port"` - SourceUser string `form:"source_user"` - SourcePassword string `form:"source_password" gorm:"-"` // Not stored in DB, only used for form - SourceKeyFile string `form:"source_key_file"` - // S3 source fields - SourceBucket string `form:"source_bucket"` - SourceRegion string `form:"source_region"` - SourceAccessKey string `form:"source_access_key"` - SourceSecretKey string `form:"source_secret_key" gorm:"-"` // Not stored in DB, only used for form - SourceEndpoint string `form:"source_endpoint"` - // SMB source fields - SourceShare string `form:"source_share"` - SourceDomain string `form:"source_domain"` - // FTP source fields - 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 - SourceDriveID string `form:"source_drive_id"` // For OneDrive - SourceTeamDrive string `form:"source_team_drive"` // For Google Drive - // Google Photos source fields - SourceReadOnly *bool `form:"source_read_only"` // For Google Photos - SourceStartYear int `form:"source_start_year"` // For Google Photos - SourceIncludeArchived *bool `form:"source_include_archived"` // For Google Photos - // General fields - FilePattern string `gorm:"default:'*'" form:"file_pattern"` - OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables - DestinationType string `gorm:"not null" form:"destination_type"` - DestinationPath string `gorm:"not null" form:"destination_path"` - DestHost string `form:"dest_host"` - DestPort int `gorm:"default:22" form:"dest_port"` - DestUser string `form:"dest_user"` - DestPassword string `form:"dest_password" gorm:"-"` // Not stored in DB, only used for form - DestKeyFile string `form:"dest_key_file"` - // S3 destination fields - DestBucket string `form:"dest_bucket"` - DestRegion string `form:"dest_region"` - DestAccessKey string `form:"dest_access_key"` - DestSecretKey string `form:"dest_secret_key" gorm:"-"` // Not stored in DB, only used for form - DestEndpoint string `form:"dest_endpoint"` - // SMB destination fields - DestShare string `form:"dest_share"` - DestDomain string `form:"dest_domain"` - // FTP destination fields - 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 Photos destination fields - DestReadOnly *bool `form:"dest_read_only"` // For Google Photos - DestStartYear int `form:"dest_start_year"` // For Google Photos - DestIncludeArchived *bool `form:"dest_include_archived"` // For Google Photos - // Security fields - UseBuiltinAuthSource *bool `form:"use_builtin_auth_source"` // For Google and other OAuth services - UseBuiltinAuthDest *bool `form:"use_builtin_auth_dest"` // For Google and other OAuth services - GoogleDriveAuthenticated *bool // Whether Google Drive auth is completed - // General fields - ArchivePath string `form:"archive_path"` - ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"` - RcloneFlags string `form:"rclone_flags"` - // Rclone command fields - CommandID uint `gorm:"default:1" form:"command_id"` // Default to 'copy' command ID (1) - CommandFlags string `form:"command_flags"` // JSON string of selected flags - CommandFlagValues string `form:"command_flag_values"` // JSON string of flag values by ID - 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 - User User `gorm:"foreignkey:CreatedBy"` - CreatedAt time.Time - UpdatedAt time.Time -} - -type Job struct { - ID uint `gorm:"primarykey"` - Name string `form:"name"` - ConfigID uint `gorm:"not null" form:"config_id"` - 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"` - LastRun *time.Time - NextRun *time.Time - // Webhook notification fields - 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"` - CreatedBy uint - User User `gorm:"foreignkey:CreatedBy"` - CreatedAt time.Time - UpdatedAt time.Time -} - -// GetConfigIDsList returns the list of config IDs as integers -func (j *Job) GetConfigIDsList() []uint { - if j.ConfigIDs == "" { - // If ConfigIDs is empty but ConfigID is set, return that as the only ID - if j.ConfigID > 0 { - return []uint{j.ConfigID} - } - return []uint{} - } - - // Split the comma-separated string - strIDs := strings.Split(j.ConfigIDs, ",") - ids := make([]uint, 0, len(strIDs)) - - // Convert each string to uint - for _, strID := range strIDs { - if id, err := strconv.ParseUint(strings.TrimSpace(strID), 10, 32); err == nil { - ids = append(ids, uint(id)) - } - } - - return ids -} - -// SetConfigIDsList sets the config IDs from a slice of uint -func (j *Job) SetConfigIDsList(ids []uint) { - // Convert to strings - strIDs := make([]string, len(ids)) - for i, id := range ids { - strIDs[i] = strconv.FormatUint(uint64(id), 10) - } - - // Join with commas - j.ConfigIDs = strings.Join(strIDs, ",") - - // Debug log the final ConfigIDs string - log.Printf("SetConfigIDsList: Setting ConfigIDs to: %s (from %v)", j.ConfigIDs, ids) - - // If there's at least one ID, set ConfigID to the first one for backward compatibility - if len(ids) > 0 { - j.ConfigID = ids[0] - } -} - -// GetConfigIDsAsStrings returns the list of config IDs as strings for template rendering -func (j *Job) GetConfigIDsAsStrings() []string { - ids := j.GetConfigIDsList() - strIDs := make([]string, len(ids)) - - for i, id := range ids { - strIDs[i] = fmt.Sprintf("'%d'", id) - } - - return strIDs -} - -type JobHistory struct { - ID uint `gorm:"primarykey"` - JobID uint `gorm:"not null"` - Job Job `gorm:"foreignkey:JobID"` - ConfigID uint `gorm:"default:0"` // The specific config ID this history entry is for - StartTime time.Time `gorm:"not null"` - EndTime *time.Time - Status string `gorm:"not null"` - BytesTransferred int64 - FilesTransferred int - ErrorMessage string - CreatedAt time.Time - UpdatedAt time.Time -} - -// FileMetadata stores information about processed files -type FileMetadata struct { - ID uint `gorm:"primarykey"` - JobID uint `gorm:"not null;index"` - Job Job `gorm:"foreignkey:JobID"` - ConfigID uint `gorm:"default:0"` // The specific config ID this file was processed with - FileName string `gorm:"not null"` - OriginalPath string `gorm:"not null"` - FileSize int64 `gorm:"not null"` - FileHash string `gorm:"index"` // MD5 or other hash for file identity - CreationTime time.Time - ModTime time.Time - ProcessedTime time.Time `gorm:"not null"` - DestinationPath string `gorm:"not null"` - Status string `gorm:"not null"` // processed, archived, deleted, etc. - ErrorMessage string - CreatedAt time.Time - UpdatedAt time.Time -} - type DB struct { *gorm.DB } -// RcloneCommand represents a command available in rclone -type RcloneCommand struct { - ID uint `gorm:"primarykey"` - Name string `gorm:"not null;uniqueIndex"` - Description string `gorm:"not null"` - Category string `gorm:"not null;index"` - IsAdvanced bool `gorm:"not null;default:false"` - Flags []RcloneCommandFlag `gorm:"foreignKey:CommandID;constraint:OnDelete:CASCADE"` - CreatedAt time.Time `gorm:"not null"` -} - -// RcloneCommandFlag represents a flag that can be used with an rclone command -type RcloneCommandFlag struct { - ID uint `gorm:"primarykey"` - CommandID uint `gorm:"not null;index"` - Command RcloneCommand `gorm:"foreignKey:CommandID"` - Name string `gorm:"not null;index"` - ShortName string - Description string `gorm:"not null"` - DataType string `gorm:"not null"` // string, int, bool, etc. - IsRequired bool `gorm:"not null;default:false"` - DefaultValue string - CreatedAt time.Time `gorm:"not null"` -} - func Initialize(dbPath string) (*DB, error) { // Create directory if it doesn't exist dir := filepath.Dir(dbPath) @@ -322,1793 +56,9 @@ func (db *DB) Close() error { return sqlDB.Close() } -// User operations -func (db *DB) CreateUser(user *User) error { - return db.Create(user).Error -} - -func (db *DB) GetUserByEmail(email string) (*User, error) { - var user User - err := db.Where("email = ?", email).First(&user).Error - if err != nil { - return nil, err - } - return &user, nil -} - -func (db *DB) GetUserByID(id uint) (*User, error) { - var user User - err := db.First(&user, id).Error - if err != nil { - return nil, err - } - return &user, nil -} - -func (db *DB) UpdateUser(user *User) error { - return db.Save(user).Error -} - -// PasswordResetToken operations -func (db *DB) CreatePasswordResetToken(token *PasswordResetToken) error { - return db.Create(token).Error -} - -func (db *DB) GetPasswordResetToken(token string) (*PasswordResetToken, error) { - var resetToken PasswordResetToken - err := db.Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).First(&resetToken).Error - if err != nil { - return nil, err - } - return &resetToken, nil -} - -func (db *DB) MarkPasswordResetTokenAsUsed(tokenID uint) error { - return db.Model(&PasswordResetToken{}).Where("id = ?", tokenID).Update("used", true).Error -} - -// TransferConfig operations -func (db *DB) CreateTransferConfig(config *TransferConfig) error { - return db.Create(config).Error -} - -func (db *DB) GetTransferConfigs(userID uint) ([]TransferConfig, error) { - var configs []TransferConfig - err := db.Where("created_by = ?", userID).Find(&configs).Error - return configs, err -} - -func (db *DB) GetTransferConfig(id uint) (*TransferConfig, error) { - var config TransferConfig - err := db.First(&config, id).Error - if err != nil { - return nil, err - } - return &config, nil -} - -func (db *DB) UpdateTransferConfig(config *TransferConfig) error { - return db.Save(config).Error -} - -func (db *DB) DeleteTransferConfig(id uint) error { - // First check if any jobs are using this config - var count int64 - if err := db.Model(&Job{}).Where("config_id = ?", id).Count(&count).Error; err != nil { - return fmt.Errorf("failed to check for dependent jobs: %v", err) - } - if count > 0 { - return fmt.Errorf("cannot delete config: %d jobs are using this configuration", count) - } - - // Delete the config - return db.Delete(&TransferConfig{}, id).Error -} - -// Job operations -func (db *DB) CreateJob(job *Job) error { - // Use Omit to prevent GORM from creating a new config - return db.Omit("Config").Create(job).Error -} - -func (db *DB) GetJobs(userID uint) ([]Job, error) { - var jobs []Job - err := db.Preload("Config").Where("created_by = ?", userID).Find(&jobs).Error - return jobs, err -} - -func (db *DB) GetJob(id uint) (*Job, error) { - var job Job - err := db.Preload("Config").First(&job, id).Error - if err != nil { - return nil, err - } - return &job, nil -} - -func (db *DB) UpdateJob(job *Job) error { - log.Printf("UpdateJob: Updating job ID: %d, ConfigIDs: %s", job.ID, job.ConfigIDs) - - // Use Omit to prevent GORM from updating or creating a new config - return db.Model(&Job{}). - Where("id = ?", job.ID). - Omit("Config"). - Updates(map[string]interface{}{ - "name": job.Name, - "config_id": job.ConfigID, - "config_ids": job.ConfigIDs, // Explicitly update config_ids - "schedule": job.Schedule, - "enabled": job.Enabled, - "webhook_enabled": job.WebhookEnabled, - "webhook_url": job.WebhookURL, - "webhook_secret": job.WebhookSecret, - "webhook_headers": job.WebhookHeaders, - "notify_on_success": job.NotifyOnSuccess, - "notify_on_failure": job.NotifyOnFailure, - }).Error -} - -func (db *DB) DeleteJob(id uint) error { - // Delete associated job history records first - if err := db.Where("job_id = ?", id).Delete(&JobHistory{}).Error; err != nil { - return fmt.Errorf("failed to delete job history: %v", err) - } - - // Delete the job - return db.Delete(&Job{}, id).Error -} - -func (db *DB) UpdateJobStatus(job *Job) error { - return db.Save(job).Error -} - -// JobHistory operations -func (db *DB) CreateJobHistory(history *JobHistory) error { - return db.Create(history).Error -} - -func (db *DB) UpdateJobHistory(history *JobHistory) error { - return db.Save(history).Error -} - -func (db *DB) GetJobHistory(jobID uint) ([]JobHistory, error) { - var histories []JobHistory - err := db.Where("job_id = ?", jobID).Order("start_time desc").Find(&histories).Error - return histories, err -} - -// CreateFileMetadata creates a new file metadata record -func (db *DB) CreateFileMetadata(metadata *FileMetadata) error { - return db.Create(metadata).Error -} - -// GetFileMetadataByJobAndName retrieves file metadata by job ID and filename -func (db *DB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*FileMetadata, error) { - var metadata FileMetadata - err := db.Where("job_id = ? AND file_name = ?", jobID, fileName).First(&metadata).Error - if err != nil { - return nil, err - } - return &metadata, nil -} - -// GetFileMetadataByHash retrieves file metadata by file hash -func (db *DB) GetFileMetadataByHash(fileHash string) (*FileMetadata, error) { - var metadata FileMetadata - err := db.Where("file_hash = ?", fileHash).First(&metadata).Error - if err != nil { - return nil, err - } - return &metadata, nil -} - -// DeleteFileMetadata deletes file metadata by ID -func (db *DB) DeleteFileMetadata(id uint) error { - return db.Delete(&FileMetadata{}, id).Error -} - -// GetConfigRclonePath returns the path to the rclone config file for a given transfer config -func (db *DB) GetConfigRclonePath(config *TransferConfig) string { - // Get data directory from environment or use default - dataDir := os.Getenv("DATA_DIR") - if dataDir == "" { - dataDir = "./data" - } - - // Store configs in the data directory - return filepath.Join(dataDir, "configs", fmt.Sprintf("config_%d.conf", config.ID)) -} - -func (db *DB) GenerateRcloneConfig(config *TransferConfig) error { - configPath := db.GetConfigRclonePath(config) - - // Get the directory part of the path - configDir := filepath.Dir(configPath) - - // Ensure configs directory exists - if err := os.MkdirAll(configDir, 0755); err != nil { - return fmt.Errorf("failed to create configs directory: %v", err) - } - - // Get the rclone path from the environment variable or use the default path - rclonePath := os.Getenv("RCLONE_PATH") - if rclonePath == "" { - rclonePath = "rclone" - } - - sourceName := fmt.Sprintf("source_%d", config.ID) - // Generate rclone config using rclone CLI - 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", configPath, - "--log-level", "ERROR", - } - if config.SourcePassword != "" { - args = append(args, "pass", config.SourcePassword) - } - if config.SourceKeyFile != "" { - args = append(args, "key_file", config.SourceKeyFile) - } - - 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) - } - 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", configPath, - "--log-level", "ERROR", - } - - if config.SourceEndpoint != "" { - args = append(args, "endpoint", config.SourceEndpoint) - } - - 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) - } - case "minio": - args := []string{ - "config", "create", sourceName, "s3", - "provider", "Minio", - "env_auth", "false", - "access_key_id", config.SourceAccessKey, - "secret_access_key", config.SourceSecretKey, - "endpoint", config.SourceEndpoint, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - 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) - } - case "b2": - args := []string{ - "config", "create", sourceName, "b2", - "account", config.SourceAccessKey, - "key", config.SourceSecretKey, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - 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) - } - case "smb": - args := []string{ - "config", "create", sourceName, "smb", - "host", config.SourceHost, - "user", config.SourceUser, - "pass", config.SourcePassword, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - if config.SourceDomain != "" { - args = append(args, "domain", config.SourceDomain) - } - - 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) - } - case "ftp": - args := []string{ - "config", "create", sourceName, "ftp", - "host", config.SourceHost, - "user", config.SourceUser, - "pass", config.SourcePassword, - "port", fmt.Sprintf("%d", config.SourcePort), - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - if config.SourcePassiveMode != nil && *config.SourcePassiveMode { - args = append(args, "passive", "true") - } - - 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) - } - case "webdav": - args := []string{ - "config", "create", sourceName, "webdav", - "url", config.SourceEndpoint, - "user", config.SourceUser, - "pass", config.SourcePassword, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - 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) - } - case "nextcloud": - args := []string{ - "config", "create", sourceName, "webdav", - "url", config.SourceEndpoint, - "user", config.SourceUser, - "pass", config.SourcePassword, - "vendor", "nextcloud", - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - 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) - } - case "onedrive": - args := []string{ - "config", "create", sourceName, "onedrive", - "client_id", config.SourceClientID, - "client_secret", config.SourceClientSecret, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - if config.SourceDriveID != "" { - args = append(args, "drive_id", config.SourceDriveID) - } - - 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) - } - case "gdrive": - args := []string{ - "config", "create", sourceName, "drive", - "client_id", config.SourceClientID, - "client_secret", config.SourceClientSecret, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - if config.SourceTeamDrive != "" { - args = append(args, "team_drive", config.SourceTeamDrive) - } - - 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) - } - case "gphotos": - args := []string{ - "config", "create", sourceName, "google photos", - "--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.SourceClientID != "" && config.SourceClientSecret != "" { - args = append(args, "client_id", config.SourceClientID) - args = append(args, "client_secret", config.SourceClientSecret) - } - - // Add read_only option if specified - if config.SourceReadOnly != nil && *config.SourceReadOnly { - args = append(args, "read_only", "true") - } - - // Add start_year if specified - if config.SourceStartYear > 0 { - args = append(args, "start_year", strconv.Itoa(config.SourceStartYear)) - } - - // Add include_archived if specified - if config.SourceIncludeArchived != nil && *config.SourceIncludeArchived { - args = append(args, "include_archived", "true") - } - - 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) - } - default: - // Write local config - content := fmt.Sprintf("[source_%d]\ntype = local\n\n", config.ID) - if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { - return fmt.Errorf("failed to write source config: %v", err) - } - } - - destName := fmt.Sprintf("dest_%d", config.ID) - - fmt.Println("config", config) - - switch config.DestinationType { - case "sftp": - args := []string{ - "config", "create", destName, "sftp", - "host", config.DestHost, - "user", config.DestUser, - "port", fmt.Sprintf("%d", config.DestPort), - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - if config.DestPassword != "" { - args = append(args, "pass", config.DestPassword) - } - if config.DestKeyFile != "" { - args = append(args, "key_file", config.DestKeyFile) - } - - 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 "s3": - args := []string{ - "config", "create", destName, "s3", - "provider", "AWS", - "env_auth", "false", - "access_key_id", config.DestAccessKey, - "secret_access_key", config.DestSecretKey, - "region", config.DestRegion, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - if config.DestEndpoint != "" { - args = append(args, "endpoint", config.DestEndpoint) - } - - 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 "minio": - args := []string{ - "config", "create", destName, "s3", - "provider", "Minio", - "env_auth", "false", - "access_key_id", config.DestAccessKey, - "secret_access_key", config.DestSecretKey, - "endpoint", config.DestEndpoint, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - 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 "b2": - args := []string{ - "config", "create", destName, "b2", - "account", config.DestAccessKey, - "key", config.DestSecretKey, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - 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 "smb": - args := []string{ - "config", "create", destName, "smb", - "host", config.DestHost, - "user", config.DestUser, - "pass", config.DestPassword, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - if config.DestDomain != "" { - args = append(args, "domain", config.DestDomain) - } - - 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 "ftp": - args := []string{ - "config", "create", destName, "ftp", - "host", config.DestHost, - "port", fmt.Sprintf("%d", config.DestPort), - "user", config.DestUser, - "pass", config.DestPassword, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - if config.DestPassiveMode != nil && *config.DestPassiveMode { - args = append(args, "passive", "true") - } - - 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 "webdav": - args := []string{ - "config", "create", destName, "webdav", - "url", config.DestEndpoint, - "user", config.DestUser, - "pass", config.DestPassword, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - 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 "nextcloud": - args := []string{ - "config", "create", destName, "webdav", - "url", config.DestEndpoint, - "user", config.DestUser, - "pass", config.DestPassword, - "vendor", "nextcloud", - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - 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 "onedrive": - args := []string{ - "config", "create", destName, "onedrive", - "client_id", config.DestClientID, - "client_secret", config.DestClientSecret, - "--non-interactive", - "--config", configPath, - "--log-level", "ERROR", - } - - if config.DestDriveID != "" { - 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) - } - case "gphotos": - args := []string{ - "config", "create", destName, "google photos", - "--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) - } - - // Add read_only option if specified - if config.DestReadOnly != nil && *config.DestReadOnly { - args = append(args, "read_only", "true") - } - - // Add start_year if specified - if config.DestStartYear > 0 { - args = append(args, "start_year", strconv.Itoa(config.DestStartYear)) - } - - // Add include_archived if specified - if config.DestIncludeArchived != nil && *config.DestIncludeArchived { - args = append(args, "include_archived", "true") - } - - 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) - } - default: - // Append local config - content := fmt.Sprintf("[dest_%d]\ntype = local\n", config.ID) - f, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - return fmt.Errorf("failed to open config file: %v", err) - } - defer f.Close() - if _, err := f.WriteString(content); err != nil { - return fmt.Errorf("failed to write destination config: %v", err) - } - } - - 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 - // 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 -} - -// GetConfigsForJob returns all transfer configurations associated with a job -func (db *DB) GetConfigsForJob(jobID uint) ([]TransferConfig, error) { - var job Job - if err := db.First(&job, jobID).Error; err != nil { - return nil, err - } - - // Get the list of config IDs - configIDs := job.GetConfigIDsList() - if len(configIDs) == 0 { - // If there are no IDs in the list but there is a configID, use that - if job.ConfigID > 0 { - configIDs = []uint{job.ConfigID} - } else { - return []TransferConfig{}, nil - } - } - - // Fetch all configs - var configs []TransferConfig - if err := db.Where("id IN ?", configIDs).Find(&configs).Error; err != nil { - return nil, err - } - - // Create a map for quick lookup - configMap := make(map[uint]TransferConfig) - for _, config := range configs { - configMap[config.ID] = config - } - - // Create a new slice with configs in the correct order - orderedConfigs := make([]TransferConfig, 0, len(configs)) - for _, configID := range configIDs { - if config, exists := configMap[configID]; exists { - orderedConfigs = append(orderedConfigs, config) - } - } - - return orderedConfigs, nil -} - -// GetSkipProcessedFiles returns the value of SkipProcessedFiles with a default if nil -func (tc *TransferConfig) GetSkipProcessedFiles() bool { - if tc.SkipProcessedFiles == nil { - return true // Default to true if not set - } - return *tc.SkipProcessedFiles -} - -// SetSkipProcessedFiles sets the SkipProcessedFiles field -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 { - // Get the config path - configPath := db.GetConfigRclonePath(config) - if configPath == "" { - return fmt.Errorf("failed to get config path") - } - - // Clean up the token to ensure it's a single line JSON - token = strings.TrimSpace(token) - token = strings.ReplaceAll(token, "\n", "") - token = strings.ReplaceAll(token, "\r", "") - - // Determine if this is a source or destination config - var configType, section, clientID, clientSecret string - var readOnly, includeArchived *bool - var startYear int - - if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" { - configType = config.DestinationType - section = "dest" - clientID = config.DestClientID - clientSecret = config.DestClientSecret - readOnly = config.DestReadOnly - startYear = config.DestStartYear - includeArchived = config.DestIncludeArchived - } else if config.SourceType == "gdrive" || config.SourceType == "gphotos" { - configType = config.SourceType - section = "source" - clientID = config.SourceClientID - clientSecret = config.SourceClientSecret - readOnly = config.SourceReadOnly - startYear = config.SourceStartYear - includeArchived = config.SourceIncludeArchived - } else { - return fmt.Errorf("config is not for Google Drive or Google Photos") - } - - // Read the existing config - content, err := os.ReadFile(configPath) - if err != nil { - return fmt.Errorf("failed to read config file: %v", err) - } - - // Prepare the section content - var sectionContent string - if configType == "gdrive" { - sectionContent = fmt.Sprintf("[%s_%d]\ntype = drive\n", section, config.ID) - if clientID != "" { - sectionContent += fmt.Sprintf("client_id = %s\n", clientID) - } - if clientSecret != "" { - sectionContent += fmt.Sprintf("client_secret = %s\n", clientSecret) - } - sectionContent += fmt.Sprintf("token = %s\n", token) - - // Add team drive if specified - if section == "source" && config.SourceTeamDrive != "" { - sectionContent += fmt.Sprintf("team_drive = %s\n", config.SourceTeamDrive) - } else if section == "dest" && config.DestTeamDrive != "" { - sectionContent += fmt.Sprintf("team_drive = %s\n", config.DestTeamDrive) - } - - // Add read-only flag if specified - if readOnly != nil && *readOnly { - sectionContent += "read_only = true\n" - } - } else if configType == "gphotos" { - sectionContent = fmt.Sprintf("[%s_%d]\ntype = google photos\n", section, config.ID) - if clientID != "" { - sectionContent += fmt.Sprintf("client_id = %s\n", clientID) - } - if clientSecret != "" { - sectionContent += fmt.Sprintf("client_secret = %s\n", clientSecret) - } - sectionContent += fmt.Sprintf("token = %s\n", token) - - // Add read-only flag if specified - if readOnly != nil && *readOnly { - sectionContent += "read_only = true\n" - } - - // Add start year if specified - if startYear > 0 { - sectionContent += fmt.Sprintf("start_year = %d\n", startYear) - } - - // Add include_archived flag if specified and true - if includeArchived != nil && *includeArchived { - sectionContent += "include_archived = true\n" - } - } - - // Find the section in the existing config - sectionPattern := regexp.MustCompile(fmt.Sprintf(`\[%s_%d\][^\[]*`, section, config.ID)) - if sectionPattern.MatchString(string(content)) { - // Replace the existing section - newContent := sectionPattern.ReplaceAllString(string(content), sectionContent) - err = os.WriteFile(configPath, []byte(newContent), 0644) - if err != nil { - return fmt.Errorf("failed to write updated config file: %v", err) - } - } else { - // Append the section to the config - file, err := os.OpenFile(configPath, os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return fmt.Errorf("failed to open config file for appending: %v", err) - } - defer file.Close() - - _, err = file.WriteString("\n" + sectionContent) - if err != nil { - return fmt.Errorf("failed to append to config file: %v", err) - } - } - - // Update the authentication status - authenticated := true - if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" { - config.SetGoogleAuthenticated(authenticated) - } else if config.SourceType == "gdrive" || config.SourceType == "gphotos" { - config.SetGoogleAuthenticated(authenticated) - } - - return nil -} - -// 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 whether the transfer config has been authenticated with Google Drive -func (tc *TransferConfig) GetGoogleDriveAuthenticated() bool { - return tc.GoogleDriveAuthenticated != nil && *tc.GoogleDriveAuthenticated -} - -// SetGoogleDriveAuthenticated sets the Google Drive authentication status -func (tc *TransferConfig) SetGoogleDriveAuthenticated(value bool) { - tc.GoogleDriveAuthenticated = &value -} - -// GetGoogleAuthenticated is an alias for GetGoogleDriveAuthenticated for better semantics when working with Google Photos -func (tc *TransferConfig) GetGoogleAuthenticated() bool { - return tc.GetGoogleDriveAuthenticated() -} - -// SetGoogleAuthenticated is an alias for SetGoogleDriveAuthenticated for better semantics when working with Google Photos -func (tc *TransferConfig) SetGoogleAuthenticated(value bool) { - tc.SetGoogleDriveAuthenticated(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 "", "" -} - -// GetUseBuiltinAuthSource returns the value of UseBuiltinAuthSource with a default if nil -func (tc *TransferConfig) GetUseBuiltinAuthSource() bool { - if tc.UseBuiltinAuthSource == nil { - return true // Default to true if not set - } - return *tc.UseBuiltinAuthSource -} - -// SetUseBuiltinAuthSource sets the UseBuiltinAuthSource field -func (tc *TransferConfig) SetUseBuiltinAuthSource(value bool) { - tc.UseBuiltinAuthSource = &value -} - -// GetUseBuiltinAuthDest returns the value of UseBuiltinAuthDest with a default if nil -func (tc *TransferConfig) GetUseBuiltinAuthDest() bool { - if tc.UseBuiltinAuthDest == nil { - return true // Default to true if not set - } - return *tc.UseBuiltinAuthDest -} - -// SetUseBuiltinAuthDest sets the UseBuiltinAuthDest field -func (tc *TransferConfig) SetUseBuiltinAuthDest(value bool) { - tc.UseBuiltinAuthDest = &value -} - -// HasRole checks if the user has a specific role -func (u *User) HasRole(roleName string) bool { - for _, role := range u.Roles { - if role.Name == roleName { - return true - } - } - return false -} - -// HasPermission checks if the user has a specific permission through any of their roles -func (u *User) HasPermission(permission string) bool { - for _, role := range u.Roles { - if role.HasPermission(permission) { - return true - } - } - return false -} - -// GetRoles returns all roles assigned to the user -func (u *User) GetRoles(tx *gorm.DB) ([]Role, error) { - var roles []Role - err := tx.Model(u).Association("Roles").Find(&roles) - return roles, err -} - -// AssignRole assigns a role to the user -func (u *User) AssignRole(tx *gorm.DB, roleID uint, assignedByID uint) error { - var role Role - if err := tx.First(&role, roleID).Error; err != nil { - return err - } - return role.AssignToUser(tx, u.ID, assignedByID) -} - -// UnassignRole removes a role from the user -func (u *User) UnassignRole(tx *gorm.DB, roleID uint, unassignedByID uint) error { - var role Role - if err := tx.First(&role, roleID).Error; err != nil { - return err - } - return role.UnassignFromUser(tx, u.ID, unassignedByID) -} - -// Role operations -func (db *DB) CreateRole(role *Role) error { - return db.Create(role).Error -} - -func (db *DB) GetRole(id uint) (*Role, error) { - var role Role - err := db.First(&role, id).Error - if err != nil { - return nil, err - } - return &role, nil -} - -func (db *DB) GetRoleByName(name string) (*Role, error) { - var role Role - err := db.Where("name = ?", name).First(&role).Error - if err != nil { - return nil, err - } - return &role, nil -} - -func (db *DB) UpdateRole(role *Role) error { - return db.Save(role).Error -} - -func (db *DB) DeleteRole(id uint) error { - var role Role - if err := db.First(&role, id).Error; err != nil { - return err - } - - if role.IsSystemRole() { - return errors.New("cannot delete system role") - } - - // Start transaction - tx := db.Begin() - if err := tx.Error; err != nil { - return err - } - - // Delete role assignments - if err := tx.Exec("DELETE FROM user_roles WHERE role_id = ?", id).Error; err != nil { - tx.Rollback() - return err - } - - // Delete the role - if err := tx.Delete(&role).Error; err != nil { - tx.Rollback() - return err - } - - return tx.Commit().Error -} - -func (db *DB) ListRoles() ([]Role, error) { - var roles []Role - err := db.Find(&roles).Error - return roles, err -} - -func (db *DB) GetUserRoles(userID uint) ([]Role, error) { - var user User - if err := db.Preload("Roles").First(&user, userID).Error; err != nil { - return nil, err - } - return user.Roles, nil -} - -// AssignRoleToUser assigns a role to a user -func (db *DB) AssignRoleToUser(roleID, userID, assignedByID uint) error { - var role Role - if err := db.First(&role, roleID).Error; err != nil { - return err - } - - tx := db.Begin() - if err := tx.Error; err != nil { - return err - } - - if err := role.AssignToUser(tx, userID, assignedByID); err != nil { - tx.Rollback() - return err - } - - return tx.Commit().Error -} - -// UnassignRoleFromUser removes a role from a user -func (db *DB) UnassignRoleFromUser(roleID, userID, unassignedByID uint) error { - var role Role - if err := db.First(&role, roleID).Error; err != nil { - return err - } - - tx := db.Begin() - if err := tx.Error; err != nil { - return err - } - - if err := role.UnassignFromUser(tx, userID, unassignedByID); err != nil { - tx.Rollback() - return err - } - - return tx.Commit().Error -} - -// User struct method to set password with secure hashing -func (u *User) SetPassword(password string) error { - // Validate password length - if len(password) < 8 { - return fmt.Errorf("password must be at least 8 characters long") - } - - // Hash the password using bcrypt - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - - // Store the hashed password - u.PasswordHash = string(hashedPassword) - u.LastPasswordChange = time.Now() - - return nil -} - -func (u *User) CheckPassword(password string) bool { - err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) - return err == nil -} - -// GetRcloneCommands returns all rclone commands -func (db *DB) GetRcloneCommands() ([]RcloneCommand, error) { - var commands []RcloneCommand - err := db.Find(&commands).Error - return commands, err -} - -// GetRcloneCommand returns a specific rclone command by ID -func (db *DB) GetRcloneCommand(id uint) (*RcloneCommand, error) { - var command RcloneCommand - err := db.First(&command, id).Error - if err != nil { - return nil, err - } - return &command, nil -} - -// GetRcloneCommandByName returns a specific rclone command by name -func (db *DB) GetRcloneCommandByName(name string) (*RcloneCommand, error) { - var command RcloneCommand - err := db.Where("name = ?", name).First(&command).Error - if err != nil { - return nil, err - } - return &command, nil -} - -// GetRcloneCommandsInCategory returns all commands in a specific category -func (db *DB) GetRcloneCommandsInCategory(category string) ([]RcloneCommand, error) { - var commands []RcloneCommand - err := db.Where("category = ?", category).Find(&commands).Error - return commands, err -} - -// GetRcloneCommandFlag returns a specific flag by ID -func (db *DB) GetRcloneCommandFlag(id uint) (*RcloneCommandFlag, error) { - var flag RcloneCommandFlag - err := db.First(&flag, id).Error - if err != nil { - return nil, err - } - return &flag, nil -} - -// GetRcloneCommandFlagByName returns a specific flag by name for a command -func (db *DB) GetRcloneCommandFlagByName(commandID uint, name string) (*RcloneCommandFlag, error) { - var flag RcloneCommandFlag - err := db.Where("command_id = ? AND name = ?", commandID, name).First(&flag).Error - if err != nil { - return nil, err - } - return &flag, nil -} - -// GetRcloneCommandFlags returns all flags for a specific command -func (db *DB) GetRcloneCommandFlags(commandID uint) ([]RcloneCommandFlag, error) { - var flags []RcloneCommandFlag - err := db.Where("command_id = ?", commandID).Find(&flags).Error - return flags, err -} - -// GetRcloneCommandWithFlags returns a command with all its flags -func (db *DB) GetRcloneCommandWithFlags(commandID uint) (*RcloneCommand, error) { - var command RcloneCommand - err := db.Preload("Flags").First(&command, commandID).Error - if err != nil { - return nil, err - } - return &command, nil -} - -// BuildRcloneCommand builds an rclone command string with the specified command and flags -func (db *DB) BuildRcloneCommand(commandName string, flags map[string]string) (string, error) { - // Get the command details - command, err := db.GetRcloneCommandByName(commandName) - if err != nil { - return "", fmt.Errorf("command not found: %s", commandName) - } - - // Start building the command string - cmdStr := "rclone " + command.Name - - // Get all flags for this command - allFlags, err := db.GetRcloneCommandFlags(command.ID) - if err != nil { - return "", fmt.Errorf("failed to get flags for command: %v", err) - } - - // Create a map of flag details for easy lookup - flagDetails := make(map[string]RcloneCommandFlag) - for _, f := range allFlags { - flagDetails[f.Name] = f - } - - // Add the flags to the command - for name, value := range flags { - // Check if the flag exists for this command - flag, exists := flagDetails[name] - if !exists { - return "", fmt.Errorf("invalid flag for command %s: %s", commandName, name) - } - - // Handle different flag types - switch flag.DataType { - case "bool": - if value == "true" { - cmdStr += " " + name - } - default: - cmdStr += " " + name + " " + value - } - } - - return cmdStr, nil -} - -// ValidateRcloneFlags validates if the provided flags are valid for the command -func (db *DB) ValidateRcloneFlags(commandName string, flags map[string]string) (bool, map[string]string) { - // Initialize errors map - errors := make(map[string]string) - - // Get the command details - command, err := db.GetRcloneCommandByName(commandName) - if err != nil { - errors["command"] = "Command not found: " + commandName - return false, errors - } - - // Get all flags for this command - allFlags, err := db.GetRcloneCommandFlags(command.ID) - if err != nil { - errors["command"] = "Failed to get flags for command" - return false, errors - } - - // Create a map of flag details for easy lookup - flagDetails := make(map[string]RcloneCommandFlag) - for _, f := range allFlags { - flagDetails[f.Name] = f - } - - // Check each provided flag - for name, value := range flags { - // Check if the flag exists for this command - flag, exists := flagDetails[name] - if !exists { - errors[name] = "Invalid flag for command " + commandName - continue - } - - // Validate the flag value based on data type - switch flag.DataType { - case "int": - if _, err := strconv.Atoi(value); err != nil { - errors[name] = "Value must be an integer" - } - case "float": - if _, err := strconv.ParseFloat(value, 64); err != nil { - errors[name] = "Value must be a number" - } - case "bool": - if value != "true" && value != "false" { - errors[name] = "Value must be true or false" - } - } - } - - // Check for required flags - for _, flag := range allFlags { - if flag.IsRequired { - if _, provided := flags[flag.Name]; !provided { - errors[flag.Name] = "This flag is required" - } - } - } - - return len(errors) == 0, errors -} - -// GetRcloneCategories returns all unique categories of rclone commands -func (db *DB) GetRcloneCategories() ([]string, error) { - var categories []string - err := db.Model(&RcloneCommand{}).Distinct("category").Pluck("category", &categories).Error - return categories, err -} - -// GetRcloneCommandsByAdvanced returns commands filtered by their advanced status -func (db *DB) GetRcloneCommandsByAdvanced(isAdvanced bool) ([]RcloneCommand, error) { - var commands []RcloneCommand - err := db.Where("is_advanced = ?", isAdvanced).Find(&commands).Error - return commands, err -} - -// SearchRcloneCommands searches for commands by name or description -func (db *DB) SearchRcloneCommands(query string) ([]RcloneCommand, error) { - var commands []RcloneCommand - searchQuery := "%" + query + "%" - err := db.Where("name LIKE ? OR description LIKE ?", searchQuery, searchQuery).Find(&commands).Error - return commands, err -} - -// GetRcloneFlagUsage returns a human-readable usage example for a flag -func (flag *RcloneCommandFlag) GetUsageExample() string { - switch flag.DataType { - case "bool": - return flag.Name - case "int": - return fmt.Sprintf("%s=", flag.Name) - case "float": - return fmt.Sprintf("%s=", flag.Name) - case "string": - return fmt.Sprintf("%s=", flag.Name) - default: - return fmt.Sprintf("%s=", flag.Name) - } -} - -// GetRcloneCommandUsage returns a basic usage example for a command with its required flags -func (db *DB) GetRcloneCommandUsage(commandID uint) (string, error) { - command, err := db.GetRcloneCommandWithFlags(commandID) - if err != nil { - return "", err - } - - usage := fmt.Sprintf("rclone %s [flags] ", command.Name) - - // Add basic usage examples for required flags - requiredFlags := []string{} - for _, flag := range command.Flags { - if flag.IsRequired { - requiredFlags = append(requiredFlags, flag.GetUsageExample()) - } - } - - if len(requiredFlags) > 0 { - usage += "\n\nRequired flags:\n " + strings.Join(requiredFlags, "\n ") - } - - return usage, nil -} - -// ParseRcloneFlags parses a string of rclone flags into a map -func ParseRcloneFlags(flagsStr string) map[string]string { - result := make(map[string]string) - if flagsStr == "" { - return result - } - - // Split the flags string by spaces - parts := strings.Fields(flagsStr) - - for i := 0; i < len(parts); i++ { - part := parts[i] - - // Check if it's a flag (starts with --) - if strings.HasPrefix(part, "--") { - // Remove the -- prefix - flagName := part - - // Check if the flag has a value - if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "--") { - // Next part is a value - result[flagName] = parts[i+1] - i++ // Skip the value in the next iteration - } else { - // Flag without value, treat as boolean - result[flagName] = "true" - } - } - } - - return result -} - -// RenderRcloneCommandHelp generates a help text for a command with its flags -func (db *DB) RenderRcloneCommandHelp(commandID uint) (string, error) { - command, err := db.GetRcloneCommandWithFlags(commandID) - if err != nil { - return "", err - } - - // Build the help text - help := fmt.Sprintf("COMMAND: %s\n", command.Name) - help += fmt.Sprintf("DESCRIPTION: %s\n\n", command.Description) - help += "FLAGS:\n" - - // Group flags by required status - var requiredFlags, optionalFlags []RcloneCommandFlag - for _, flag := range command.Flags { - if flag.IsRequired { - requiredFlags = append(requiredFlags, flag) - } else { - optionalFlags = append(optionalFlags, flag) - } - } - - // Add required flags - if len(requiredFlags) > 0 { - help += " Required:\n" - for _, flag := range requiredFlags { - shortName := "" - if flag.ShortName != "" { - shortName = fmt.Sprintf(" (-%s)", flag.ShortName) - } - help += fmt.Sprintf(" %s%s - %s\n", flag.Name, shortName, flag.Description) - if flag.DataType != "bool" && flag.DefaultValue != "" { - help += fmt.Sprintf(" Default: %s\n", flag.DefaultValue) - } - } - } - - // Add optional flags - if len(optionalFlags) > 0 { - help += "\n Optional:\n" - for _, flag := range optionalFlags { - shortName := "" - if flag.ShortName != "" { - shortName = fmt.Sprintf(" (-%s)", flag.ShortName) - } - help += fmt.Sprintf(" %s%s - %s\n", flag.Name, shortName, flag.Description) - if flag.DataType != "bool" && flag.DefaultValue != "" { - help += fmt.Sprintf(" Default: %s\n", flag.DefaultValue) - } - } - } - - return help, nil -} - -func (db *DB) GetRcloneCommandFlagsMap(commandID uint) (map[uint]RcloneCommandFlag, error) { - flags, err := db.GetRcloneCommandFlags(commandID) - if err != nil { - return nil, err - } - - flagsMap := make(map[uint]RcloneCommandFlag) - for _, flag := range flags { - flagsMap[flag.ID] = flag - } - - return flagsMap, nil -} - // GetEnabledAuthProviders returns all enabled authentication providers // func (db *DB) GetEnabledAuthProviders(ctx context.Context) ([]AuthProvider, error) { // var providers []AuthProvider // result := db.WithContext(ctx).Where("enabled = ?", true).Find(&providers) // return providers, result.Error // } - -// GetExternalIdentity retrieves an external identity by provider ID and external ID -func (db *DB) GetExternalIdentity(ctx context.Context, providerID uint, externalID string) (*ExternalUserIdentity, error) { - var identity ExternalUserIdentity - result := db.WithContext(ctx).Where("provider_id = ? AND external_id = ?", providerID, externalID).First(&identity) - if result.Error != nil { - return nil, result.Error - } - return &identity, nil -} - -// CreateExternalIdentity creates a new external user identity -func (db *DB) CreateExternalIdentity(ctx context.Context, identity *ExternalUserIdentity) error { - return db.WithContext(ctx).Create(identity).Error -} - -// UpdateExternalIdentity updates an existing external user identity -func (db *DB) UpdateExternalIdentity(ctx context.Context, identity *ExternalUserIdentity) error { - return db.WithContext(ctx).Save(identity).Error -} diff --git a/internal/db/file_metadata.go b/internal/db/file_metadata.go new file mode 100644 index 0000000..0c33117 --- /dev/null +++ b/internal/db/file_metadata.go @@ -0,0 +1,25 @@ +package db + +import ( + "time" +) + +// FileMetadata stores information about processed files +type FileMetadata struct { + ID uint `gorm:"primarykey"` + JobID uint `gorm:"not null;index"` + Job Job `gorm:"foreignkey:JobID"` + ConfigID uint `gorm:"default:0"` // The specific config ID this file was processed with + FileName string `gorm:"not null"` + OriginalPath string `gorm:"not null"` + FileSize int64 `gorm:"not null"` + FileHash string `gorm:"index"` // MD5 or other hash for file identity + CreationTime time.Time + ModTime time.Time + ProcessedTime time.Time `gorm:"not null"` + DestinationPath string `gorm:"not null"` + Status string `gorm:"not null"` // processed, archived, deleted, etc. + ErrorMessage string + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/db/file_metadata_store.go b/internal/db/file_metadata_store.go new file mode 100644 index 0000000..8b86226 --- /dev/null +++ b/internal/db/file_metadata_store.go @@ -0,0 +1,33 @@ +package db + +// --- FileMetadata Store Methods --- + +// CreateFileMetadata creates a new file metadata record +func (db *DB) CreateFileMetadata(metadata *FileMetadata) error { + return db.Create(metadata).Error +} + +// GetFileMetadataByJobAndName retrieves file metadata by job ID and filename +func (db *DB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*FileMetadata, error) { + var metadata FileMetadata + err := db.Where("job_id = ? AND file_name = ?", jobID, fileName).First(&metadata).Error + if err != nil { + return nil, err + } + return &metadata, nil +} + +// GetFileMetadataByHash retrieves file metadata by file hash +func (db *DB) GetFileMetadataByHash(fileHash string) (*FileMetadata, error) { + var metadata FileMetadata + err := db.Where("file_hash = ?", fileHash).First(&metadata).Error + if err != nil { + return nil, err + } + return &metadata, nil +} + +// DeleteFileMetadata deletes file metadata by ID +func (db *DB) DeleteFileMetadata(id uint) error { + return db.Delete(&FileMetadata{}, id).Error +} diff --git a/internal/db/job.go b/internal/db/job.go new file mode 100644 index 0000000..04797ad --- /dev/null +++ b/internal/db/job.go @@ -0,0 +1,161 @@ +package db + +import ( + "fmt" + "log" + "strconv" + "strings" + "time" +) + +// Job represents a scheduled transfer task +type Job struct { + ID uint `gorm:"primarykey"` + Name string `form:"name"` + ConfigID uint `gorm:"not null" form:"config_id"` + 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"` + LastRun *time.Time + NextRun *time.Time + // Webhook notification fields + 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"` + CreatedBy uint + User User `gorm:"foreignkey:CreatedBy"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// JobHistory records the execution history of a job +type JobHistory struct { + ID uint `gorm:"primarykey"` + JobID uint `gorm:"not null"` + Job Job `gorm:"foreignkey:JobID"` + ConfigID uint `gorm:"default:0"` // The specific config ID this history entry is for + StartTime time.Time `gorm:"not null"` + EndTime *time.Time + Status string `gorm:"not null"` + BytesTransferred int64 + FilesTransferred int + ErrorMessage string + CreatedAt time.Time + UpdatedAt time.Time +} + +// --- Job Helper Methods --- + +// GetConfigIDsList returns the list of config IDs as integers +func (j *Job) GetConfigIDsList() []uint { + if j.ConfigIDs == "" { + // If ConfigIDs is empty but ConfigID is set, return that as the only ID + if j.ConfigID > 0 { + return []uint{j.ConfigID} + } + return []uint{} + } + + // Split the comma-separated string + strIDs := strings.Split(j.ConfigIDs, ",") + ids := make([]uint, 0, len(strIDs)) + + // Convert each string to uint + for _, strID := range strIDs { + if id, err := strconv.ParseUint(strings.TrimSpace(strID), 10, 32); err == nil { + ids = append(ids, uint(id)) + } + } + + return ids +} + +// SetConfigIDsList sets the config IDs from a slice of uint +func (j *Job) SetConfigIDsList(ids []uint) { + // Convert to strings + strIDs := make([]string, len(ids)) + for i, id := range ids { + strIDs[i] = strconv.FormatUint(uint64(id), 10) + } + + // Join with commas + j.ConfigIDs = strings.Join(strIDs, ",") + + // Debug log the final ConfigIDs string + log.Printf("SetConfigIDsList: Setting ConfigIDs to: %s (from %v)", j.ConfigIDs, ids) + + // If there's at least one ID, set ConfigID to the first one for backward compatibility + if len(ids) > 0 { + j.ConfigID = ids[0] + } else { + j.ConfigID = 0 // Ensure ConfigID is cleared if the list is empty + } +} + +// GetConfigIDsAsStrings returns the list of config IDs as strings for template rendering +func (j *Job) GetConfigIDsAsStrings() []string { + ids := j.GetConfigIDsList() + strIDs := make([]string, len(ids)) + + for i, id := range ids { + strIDs[i] = fmt.Sprintf("'%d'", id) + } + + return strIDs +} + +// 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 +} diff --git a/internal/db/job_store.go b/internal/db/job_store.go new file mode 100644 index 0000000..639e531 --- /dev/null +++ b/internal/db/job_store.go @@ -0,0 +1,154 @@ +package db + +import ( + "fmt" + "log" +) + +// --- Job Store Methods --- + +// CreateJob creates a new job record +func (db *DB) CreateJob(job *Job) error { + // Use Omit to prevent GORM from creating a new config + return db.Omit("Config").Create(job).Error +} + +// GetJobs retrieves all jobs for a user, preloading the associated config +func (db *DB) GetJobs(userID uint) ([]Job, error) { + var jobs []Job + err := db.Preload("Config").Where("created_by = ?", userID).Find(&jobs).Error + return jobs, err +} + +// GetJob retrieves a single job by ID, preloading the associated config +func (db *DB) GetJob(id uint) (*Job, error) { + var job Job + err := db.Preload("Config").First(&job, id).Error + if err != nil { + return nil, err + } + return &job, nil +} + +// UpdateJob updates an existing job record +func (db *DB) UpdateJob(job *Job) error { + log.Printf("UpdateJob: Updating job ID: %d, ConfigIDs: %s", job.ID, job.ConfigIDs) + + // Use Omit to prevent GORM from updating or creating a new config + // Explicitly update fields that can be changed + return db.Model(&Job{}). + Where("id = ?", job.ID). + Omit("Config"). // Omit the nested Config struct + Updates(map[string]interface{}{ + "name": job.Name, + "config_id": job.ConfigID, // Update the foreign key if needed + "config_ids": job.ConfigIDs, // Explicitly update config_ids string + "schedule": job.Schedule, + "enabled": job.Enabled, + "webhook_enabled": job.WebhookEnabled, + "webhook_url": job.WebhookURL, + "webhook_secret": job.WebhookSecret, + "webhook_headers": job.WebhookHeaders, + "notify_on_success": job.NotifyOnSuccess, + "notify_on_failure": job.NotifyOnFailure, + // Do not update LastRun, NextRun, CreatedBy, CreatedAt, UpdatedAt here + // GORM handles UpdatedAt automatically + }).Error +} + +// DeleteJob deletes a job and its associated history records +func (db *DB) DeleteJob(id uint) error { + // Start transaction + tx := db.Begin() + if tx.Error != nil { + return tx.Error + } + + // Delete associated job history records first + if err := tx.Where("job_id = ?", id).Delete(&JobHistory{}).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to delete job history: %v", err) + } + + // Delete the job + if err := tx.Delete(&Job{}, id).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to delete job: %v", err) + } + + return tx.Commit().Error +} + +// UpdateJobStatus updates the LastRun and NextRun fields of a job +func (db *DB) UpdateJobStatus(job *Job) error { + // Only update specific fields related to run status + return db.Model(job).Updates(map[string]interface{}{ + "last_run": job.LastRun, + "next_run": job.NextRun, + }).Error +} + +// GetActiveJobs returns all active (enabled) jobs +func (db *DB) GetActiveJobs() ([]Job, error) { + if db.DB == nil { + return nil, fmt.Errorf("database connection is nil") + } + var jobs []Job + // 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 +} + +// GetConfigsForJob returns all transfer configurations associated with a job, in the order specified by ConfigIDs +func (db *DB) GetConfigsForJob(jobID uint) ([]TransferConfig, error) { + var job Job + if err := db.First(&job, jobID).Error; err != nil { + return nil, fmt.Errorf("failed to get job %d: %w", jobID, err) + } + + configIDs := job.GetConfigIDsList() + if len(configIDs) == 0 { + return []TransferConfig{}, nil // No configs associated + } + + var configs []TransferConfig + if err := db.Where("id IN ?", configIDs).Find(&configs).Error; err != nil { + return nil, fmt.Errorf("failed to get configs for job %d: %w", jobID, err) + } + + // Order the fetched configs according to the job.ConfigIDs list + configMap := make(map[uint]TransferConfig, len(configs)) + for _, cfg := range configs { + configMap[cfg.ID] = cfg + } + + orderedConfigs := make([]TransferConfig, 0, len(configIDs)) + for _, id := range configIDs { + if cfg, ok := configMap[id]; ok { + orderedConfigs = append(orderedConfigs, cfg) + } else { + log.Printf("Warning: Config ID %d listed in job %d not found in database", id, jobID) + } + } + + return orderedConfigs, nil +} + +// --- JobHistory Store Methods --- + +// CreateJobHistory creates a new job history record +func (db *DB) CreateJobHistory(history *JobHistory) error { + return db.Create(history).Error +} + +// UpdateJobHistory updates an existing job history record +func (db *DB) UpdateJobHistory(history *JobHistory) error { + return db.Save(history).Error +} + +// GetJobHistory retrieves all history records for a specific job, ordered by start time descending +func (db *DB) GetJobHistory(jobID uint) ([]JobHistory, error) { + var histories []JobHistory + err := db.Where("job_id = ?", jobID).Order("start_time desc").Find(&histories).Error + return histories, err +} diff --git a/internal/db/rclone.go b/internal/db/rclone.go new file mode 100644 index 0000000..18c8b75 --- /dev/null +++ b/internal/db/rclone.go @@ -0,0 +1,87 @@ +package db + +import ( + "fmt" + "strings" + "time" +) + +// RcloneCommand represents a command available in rclone +type RcloneCommand struct { + ID uint `gorm:"primarykey"` + Name string `gorm:"not null;uniqueIndex"` + Description string `gorm:"not null"` + Category string `gorm:"not null;index"` + IsAdvanced bool `gorm:"not null;default:false"` + Flags []RcloneCommandFlag `gorm:"foreignKey:CommandID;constraint:OnDelete:CASCADE"` + CreatedAt time.Time `gorm:"not null"` +} + +// RcloneCommandFlag represents a flag that can be used with an rclone command +type RcloneCommandFlag struct { + ID uint `gorm:"primarykey"` + CommandID uint `gorm:"not null;index"` + Command RcloneCommand `gorm:"foreignKey:CommandID"` + Name string `gorm:"not null;index"` + ShortName string + Description string `gorm:"not null"` + DataType string `gorm:"not null"` // string, int, bool, etc. + IsRequired bool `gorm:"not null;default:false"` + DefaultValue string + CreatedAt time.Time `gorm:"not null"` +} + +// --- Rclone Helper Methods --- + +// GetUsageExample returns a human-readable usage example for a flag +func (flag *RcloneCommandFlag) GetUsageExample() string { + switch flag.DataType { + case "bool": + return flag.Name + case "int": + return fmt.Sprintf("%s=", flag.Name) + case "float": + return fmt.Sprintf("%s=", flag.Name) + case "string": + return fmt.Sprintf("%s=", flag.Name) + default: + return fmt.Sprintf("%s=", flag.Name) + } +} + +// ParseRcloneFlags parses a string of rclone flags into a map +// Note: This is a general utility function, not tied to a specific struct instance. +// It might be better placed in a more general utility package if one exists, +// but keeping it here for now as per the original file structure. +func ParseRcloneFlags(flagsStr string) map[string]string { + result := make(map[string]string) + if flagsStr == "" { + return result + } + + // Split the flags string by spaces + parts := strings.Fields(flagsStr) + + for i := 0; i < len(parts); i++ { + part := parts[i] + + // Check if it's a flag (starts with --) + if strings.HasPrefix(part, "--") { + // Remove the -- prefix + flagName := part // Keep the '--' prefix in the map key for consistency? Or remove? Plan used remove. + // flagName := strings.TrimPrefix(part, "--") // Alternative: remove prefix + + // Check if the flag has a value + if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "--") { + // Next part is a value + result[flagName] = parts[i+1] + i++ // Skip the value in the next iteration + } else { + // Flag without value, treat as boolean true + result[flagName] = "true" + } + } + } + + return result +} diff --git a/internal/db/rclone_store.go b/internal/db/rclone_store.go new file mode 100644 index 0000000..7c04477 --- /dev/null +++ b/internal/db/rclone_store.go @@ -0,0 +1,333 @@ +package db + +import ( + "fmt" + "strconv" + "strings" +) + +// --- Rclone Store Methods --- + +// GetRcloneCommands returns all rclone commands +func (db *DB) GetRcloneCommands() ([]RcloneCommand, error) { + var commands []RcloneCommand + err := db.Find(&commands).Error + return commands, err +} + +// GetRcloneCommand returns a specific rclone command by ID +func (db *DB) GetRcloneCommand(id uint) (*RcloneCommand, error) { + var command RcloneCommand + err := db.First(&command, id).Error + if err != nil { + return nil, err + } + return &command, nil +} + +// GetRcloneCommandByName returns a specific rclone command by name +func (db *DB) GetRcloneCommandByName(name string) (*RcloneCommand, error) { + var command RcloneCommand + err := db.Where("name = ?", name).First(&command).Error + if err != nil { + return nil, err + } + return &command, nil +} + +// GetRcloneCommandsInCategory returns all commands in a specific category +func (db *DB) GetRcloneCommandsInCategory(category string) ([]RcloneCommand, error) { + var commands []RcloneCommand + err := db.Where("category = ?", category).Find(&commands).Error + return commands, err +} + +// GetRcloneCommandFlag returns a specific flag by ID +func (db *DB) GetRcloneCommandFlag(id uint) (*RcloneCommandFlag, error) { + var flag RcloneCommandFlag + err := db.First(&flag, id).Error + if err != nil { + return nil, err + } + return &flag, nil +} + +// GetRcloneCommandFlagByName returns a specific flag by name for a command +func (db *DB) GetRcloneCommandFlagByName(commandID uint, name string) (*RcloneCommandFlag, error) { + var flag RcloneCommandFlag + err := db.Where("command_id = ? AND name = ?", commandID, name).First(&flag).Error + if err != nil { + return nil, err + } + return &flag, nil +} + +// GetRcloneCommandFlags returns all flags for a specific command +func (db *DB) GetRcloneCommandFlags(commandID uint) ([]RcloneCommandFlag, error) { + var flags []RcloneCommandFlag + err := db.Where("command_id = ?", commandID).Find(&flags).Error + return flags, err +} + +// GetRcloneCommandWithFlags returns a command with all its flags +func (db *DB) GetRcloneCommandWithFlags(commandID uint) (*RcloneCommand, error) { + var command RcloneCommand + err := db.Preload("Flags").First(&command, commandID).Error + if err != nil { + return nil, err + } + return &command, nil +} + +// BuildRcloneCommand builds an rclone command string with the specified command and flags +func (db *DB) BuildRcloneCommand(commandName string, flags map[string]string) (string, error) { + // Get the command details + command, err := db.GetRcloneCommandByName(commandName) + if err != nil { + return "", fmt.Errorf("command not found: %s", commandName) + } + + // Start building the command string + cmdStr := "rclone " + command.Name + + // Get all flags for this command + allFlags, err := db.GetRcloneCommandFlags(command.ID) + if err != nil { + return "", fmt.Errorf("failed to get flags for command: %v", err) + } + + // Create a map of flag details for easy lookup + flagDetails := make(map[string]RcloneCommandFlag) + for _, f := range allFlags { + flagDetails[f.Name] = f + } + + // Add the flags to the command + for name, value := range flags { + // Check if the flag exists for this command + flag, exists := flagDetails[name] + if !exists { + // Allow passing flags not explicitly defined in the DB (e.g., global flags) + // Consider adding validation or logging for unknown flags if stricter control is needed + cmdStr += " " + name + if value != "true" { // Assume boolean flags are passed as "true" if value is needed + cmdStr += " " + value + } + continue + // return "", fmt.Errorf("invalid flag for command %s: %s", commandName, name) + } + + // Handle different flag types + switch flag.DataType { + case "bool": + if value == "true" { + cmdStr += " " + flag.Name // Use flag.Name which includes '--' + } + default: + cmdStr += " " + flag.Name + " " + value // Use flag.Name which includes '--' + } + } + + return cmdStr, nil +} + +// ValidateRcloneFlags validates if the provided flags are valid for the command +func (db *DB) ValidateRcloneFlags(commandName string, flags map[string]string) (bool, map[string]string) { + // Initialize errors map + errorsMap := make(map[string]string) + + // Get the command details + command, err := db.GetRcloneCommandByName(commandName) + if err != nil { + errorsMap["command"] = "Command not found: " + commandName + return false, errorsMap + } + + // Get all flags for this command + allFlags, err := db.GetRcloneCommandFlags(command.ID) + if err != nil { + errorsMap["command"] = "Failed to get flags for command" + return false, errorsMap + } + + // Create a map of flag details for easy lookup + flagDetails := make(map[string]RcloneCommandFlag) + for _, f := range allFlags { + flagDetails[f.Name] = f // Assuming Name includes '--' prefix + } + + // Check each provided flag + for name, value := range flags { + // Check if the flag exists for this command + flag, exists := flagDetails[name] + if !exists { + // Allow unknown flags for now, but could add an error here if needed + // errorsMap[name] = "Invalid flag for command " + commandName + continue + } + + // Validate the flag value based on data type + switch flag.DataType { + case "int": + if _, err := strconv.Atoi(value); err != nil { + errorsMap[name] = "Value must be an integer" + } + case "float": + if _, err := strconv.ParseFloat(value, 64); err != nil { + errorsMap[name] = "Value must be a number" + } + case "bool": + // For boolean flags passed in the map, the value should ideally be "true" or omitted + // If present and not "true", it's likely an error or misuse. + // Rclone CLI typically handles bool flags by presence/absence. + // This validation might need refinement based on how flags are constructed before calling this. + if value != "true" { + // errorsMap[name] = "Boolean flag should have value 'true' or be omitted" + } + case "string": + // Basic check: ensure value is not empty if flag requires a value + // More complex validation (regex, length) could be added here + if value == "" && flag.IsRequired { // Check if required string flags have values + errorsMap[name] = "Value cannot be empty for required string flag" + } + } + } + + // Check for missing required flags + for _, flag := range allFlags { + if flag.IsRequired { + if _, provided := flags[flag.Name]; !provided { + // Check if the short name was provided instead + shortNameProvided := false + if flag.ShortName != "" { + _, shortNameProvided = flags[flag.ShortName] + } + if !shortNameProvided { + errorsMap[flag.Name] = "This flag is required" + } + } else if flag.DataType != "bool" && flags[flag.Name] == "" { + // Required non-bool flags must have a value + errorsMap[flag.Name] = "Value cannot be empty for required flag" + } + } + } + + return len(errorsMap) == 0, errorsMap +} + +// GetRcloneCategories returns all unique categories of rclone commands +func (db *DB) GetRcloneCategories() ([]string, error) { + var categories []string + err := db.Model(&RcloneCommand{}).Distinct("category").Pluck("category", &categories).Error + return categories, err +} + +// GetRcloneCommandsByAdvanced returns commands filtered by their advanced status +func (db *DB) GetRcloneCommandsByAdvanced(isAdvanced bool) ([]RcloneCommand, error) { + var commands []RcloneCommand + err := db.Where("is_advanced = ?", isAdvanced).Find(&commands).Error + return commands, err +} + +// SearchRcloneCommands searches for commands by name or description +func (db *DB) SearchRcloneCommands(query string) ([]RcloneCommand, error) { + var commands []RcloneCommand + searchQuery := "%" + query + "%" + err := db.Where("name LIKE ? OR description LIKE ?", searchQuery, searchQuery).Find(&commands).Error + return commands, err +} + +// GetRcloneCommandUsage returns a basic usage example for a command with its required flags +func (db *DB) GetRcloneCommandUsage(commandID uint) (string, error) { + command, err := db.GetRcloneCommandWithFlags(commandID) + if err != nil { + return "", err + } + + usage := fmt.Sprintf("rclone %s [flags] ", command.Name) + + // Add basic usage examples for required flags + requiredFlags := []string{} + for _, flag := range command.Flags { + if flag.IsRequired { + // Assuming GetUsageExample is defined on RcloneCommandFlag in rclone.go + requiredFlags = append(requiredFlags, flag.GetUsageExample()) + } + } + + if len(requiredFlags) > 0 { + usage += "\n\nRequired flags:\n " + strings.Join(requiredFlags, "\n ") + } + + return usage, nil +} + +// RenderRcloneCommandHelp generates a help text for a command with its flags +func (db *DB) RenderRcloneCommandHelp(commandID uint) (string, error) { + command, err := db.GetRcloneCommandWithFlags(commandID) + if err != nil { + return "", err + } + + // Build the help text + help := fmt.Sprintf("COMMAND: %s\n", command.Name) + help += fmt.Sprintf("DESCRIPTION: %s\n\n", command.Description) + help += "FLAGS:\n" + + // Group flags by required status + var requiredFlags, optionalFlags []RcloneCommandFlag + for _, flag := range command.Flags { + if flag.IsRequired { + requiredFlags = append(requiredFlags, flag) + } else { + optionalFlags = append(optionalFlags, flag) + } + } + + // Add required flags + if len(requiredFlags) > 0 { + help += " Required:\n" + for _, flag := range requiredFlags { + shortName := "" + if flag.ShortName != "" { + shortName = fmt.Sprintf(" (-%s)", flag.ShortName) + } + help += fmt.Sprintf(" %s%s - %s\n", flag.Name, shortName, flag.Description) + if flag.DataType != "bool" && flag.DefaultValue != "" { + help += fmt.Sprintf(" Default: %s\n", flag.DefaultValue) + } + } + } + + // Add optional flags + if len(optionalFlags) > 0 { + help += "\n Optional:\n" + for _, flag := range optionalFlags { + shortName := "" + if flag.ShortName != "" { + shortName = fmt.Sprintf(" (-%s)", flag.ShortName) + } + help += fmt.Sprintf(" %s%s - %s\n", flag.Name, shortName, flag.Description) + if flag.DataType != "bool" && flag.DefaultValue != "" { + help += fmt.Sprintf(" Default: %s\n", flag.DefaultValue) + } + } + } + + return help, nil +} + +// GetRcloneCommandFlagsMap returns all flags for a specific command as a map keyed by flag ID +func (db *DB) GetRcloneCommandFlagsMap(commandID uint) (map[uint]RcloneCommandFlag, error) { + flags, err := db.GetRcloneCommandFlags(commandID) + if err != nil { + return nil, err + } + + flagsMap := make(map[uint]RcloneCommandFlag) + for _, flag := range flags { + flagsMap[flag.ID] = flag + } + + return flagsMap, nil +} diff --git a/internal/db/role_store.go b/internal/db/role_store.go new file mode 100644 index 0000000..4a286dc --- /dev/null +++ b/internal/db/role_store.go @@ -0,0 +1,143 @@ +package db + +import ( + "errors" + "fmt" + + "gorm.io/gorm" +) + +// --- Role Store Methods --- + +// CreateRole creates a new role record +func (db *DB) CreateRole(role *Role) error { + return db.Create(role).Error +} + +// GetRole retrieves a role by ID, preloading permissions +func (db *DB) GetRole(id uint) (*Role, error) { + var role Role + // Assuming Permissions are handled correctly by GORM or custom type + err := db.First(&role, id).Error + if err != nil { + return nil, err + } + return &role, nil +} + +// GetRoleByName retrieves a role by name, preloading permissions +func (db *DB) GetRoleByName(name string) (*Role, error) { + var role Role + err := db.Where("name = ?", name).First(&role).Error + if err != nil { + return nil, err + } + return &role, nil +} + +// UpdateRole updates an existing role record +func (db *DB) UpdateRole(role *Role) error { + // Use Omit Users to prevent GORM from trying to update the many2many relationship directly here + return db.Omit("Users").Save(role).Error +} + +// DeleteRole deletes a role after checking dependencies and removing assignments +func (db *DB) DeleteRole(id uint) error { + var role Role + if err := db.First(&role, id).Error; err != nil { + return fmt.Errorf("role not found: %w", err) + } + + if role.IsSystemRole() { + return errors.New("cannot delete system role") + } + + // Start transaction + tx := db.Begin() + if err := tx.Error; err != nil { + return err + } + + // Manually delete role assignments from the join table + if err := tx.Exec("DELETE FROM user_roles WHERE role_id = ?", id).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to delete role assignments: %w", err) + } + + // Delete the role itself + if err := tx.Delete(&role).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to delete role: %w", err) + } + + // Commit transaction + return tx.Commit().Error +} + +// ListRoles retrieves all roles +func (db *DB) ListRoles() ([]Role, error) { + var roles []Role + err := db.Find(&roles).Error + return roles, err +} + +// GetUserRoles retrieves all roles assigned to a specific user ID +func (db *DB) GetUserRoles(userID uint) ([]Role, error) { + var user User + // Preload the Roles association + if err := db.Preload("Roles").First(&user, userID).Error; err != nil { + // Handle case where user might not be found vs. other errors + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("user with ID %d not found", userID) + } + return nil, fmt.Errorf("failed to get user %d roles: %w", userID, err) + } + return user.Roles, nil +} + +// AssignRoleToUser assigns a role to a user, handling the join table +func (db *DB) AssignRoleToUser(roleID, userID, assignedByID uint) error { + var role Role + if err := db.First(&role, roleID).Error; err != nil { + return fmt.Errorf("role with ID %d not found: %w", roleID, err) + } + var user User + if err := db.First(&user, userID).Error; err != nil { + return fmt.Errorf("user with ID %d not found: %w", userID, err) + } + + // Use GORM's Association API for many2many + err := db.Model(&user).Association("Roles").Append(&role) + if err != nil { + return fmt.Errorf("failed to assign role %d to user %d: %w", roleID, userID, err) + } + + // Optionally, log the assignment (consider moving audit logging to a dedicated service/hook) + // db.Create(&AuditLog{...}) + + return nil +} + +// UnassignRoleFromUser removes a role from a user, handling the join table +func (db *DB) UnassignRoleFromUser(roleID, userID, unassignedByID uint) error { + var role Role + if err := db.First(&role, roleID).Error; err != nil { + return fmt.Errorf("role with ID %d not found: %w", roleID, err) + } + var user User + // Need to preload roles to check if the association exists before deleting + if err := db.Preload("Roles").First(&user, userID).Error; err != nil { + return fmt.Errorf("user with ID %d not found: %w", userID, err) + } + + // Use GORM's Association API for many2many deletion + err := db.Model(&user).Association("Roles").Delete(&role) + if err != nil { + return fmt.Errorf("failed to unassign role %d from user %d: %w", roleID, userID, err) + } + + // Optionally, log the unassignment + // db.Create(&AuditLog{...}) + + return nil +} diff --git a/internal/db/transfer_config.go b/internal/db/transfer_config.go new file mode 100644 index 0000000..c18373c --- /dev/null +++ b/internal/db/transfer_config.go @@ -0,0 +1,200 @@ +package db + +import ( + "time" +) + +// TransferConfig holds the configuration for a data transfer operation +type TransferConfig struct { + ID uint `gorm:"primarykey"` + Name string `gorm:"not null" form:"name"` + SourceType string `gorm:"not null" form:"source_type"` + SourcePath string `gorm:"not null" form:"source_path"` + SourceHost string `form:"source_host"` + SourcePort int `gorm:"default:22" form:"source_port"` + SourceUser string `form:"source_user"` + SourcePassword string `form:"source_password" gorm:"-"` // Not stored in DB, only used for form + SourceKeyFile string `form:"source_key_file"` + // S3 source fields + SourceBucket string `form:"source_bucket"` + SourceRegion string `form:"source_region"` + SourceAccessKey string `form:"source_access_key"` + SourceSecretKey string `form:"source_secret_key" gorm:"-"` // Not stored in DB, only used for form + SourceEndpoint string `form:"source_endpoint"` + // SMB source fields + SourceShare string `form:"source_share"` + SourceDomain string `form:"source_domain"` + // FTP source fields + 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 + SourceDriveID string `form:"source_drive_id"` // For OneDrive + SourceTeamDrive string `form:"source_team_drive"` // For Google Drive + // Google Photos source fields + SourceReadOnly *bool `form:"source_read_only"` // For Google Photos + SourceStartYear int `form:"source_start_year"` // For Google Photos + SourceIncludeArchived *bool `form:"source_include_archived"` // For Google Photos + // General fields + FilePattern string `gorm:"default:'*'" form:"file_pattern"` + OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables + DestinationType string `gorm:"not null" form:"destination_type"` + DestinationPath string `gorm:"not null" form:"destination_path"` + DestHost string `form:"dest_host"` + DestPort int `gorm:"default:22" form:"dest_port"` + DestUser string `form:"dest_user"` + DestPassword string `form:"dest_password" gorm:"-"` // Not stored in DB, only used for form + DestKeyFile string `form:"dest_key_file"` + // S3 destination fields + DestBucket string `form:"dest_bucket"` + DestRegion string `form:"dest_region"` + DestAccessKey string `form:"dest_access_key"` + DestSecretKey string `form:"dest_secret_key" gorm:"-"` // Not stored in DB, only used for form + DestEndpoint string `form:"dest_endpoint"` + // SMB destination fields + DestShare string `form:"dest_share"` + DestDomain string `form:"dest_domain"` + // FTP destination fields + 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 Photos destination fields + DestReadOnly *bool `form:"dest_read_only"` // For Google Photos + DestStartYear int `form:"dest_start_year"` // For Google Photos + DestIncludeArchived *bool `form:"dest_include_archived"` // For Google Photos + // Security fields + UseBuiltinAuthSource *bool `form:"use_builtin_auth_source"` // For Google and other OAuth services + UseBuiltinAuthDest *bool `form:"use_builtin_auth_dest"` // For Google and other OAuth services + GoogleDriveAuthenticated *bool // Whether Google Drive auth is completed + // General fields + ArchivePath string `form:"archive_path"` + ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"` + RcloneFlags string `form:"rclone_flags"` + // Rclone command fields + CommandID uint `gorm:"default:1" form:"command_id"` // Default to 'copy' command ID (1) + CommandFlags string `form:"command_flags"` // JSON string of selected flags + CommandFlagValues string `form:"command_flag_values"` // JSON string of flag values by ID + 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 + User User `gorm:"foreignkey:CreatedBy"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// --- TransferConfig Helper Methods --- + +// 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 whether the transfer config has been authenticated with Google Drive +func (tc *TransferConfig) GetGoogleDriveAuthenticated() bool { + return tc.GoogleDriveAuthenticated != nil && *tc.GoogleDriveAuthenticated +} + +// SetGoogleDriveAuthenticated sets the Google Drive authentication status +func (tc *TransferConfig) SetGoogleDriveAuthenticated(value bool) { + tc.GoogleDriveAuthenticated = &value +} + +// GetGoogleAuthenticated is an alias for GetGoogleDriveAuthenticated for better semantics when working with Google Photos +func (tc *TransferConfig) GetGoogleAuthenticated() bool { + return tc.GetGoogleDriveAuthenticated() +} + +// SetGoogleAuthenticated is an alias for SetGoogleDriveAuthenticated for better semantics when working with Google Photos +func (tc *TransferConfig) SetGoogleAuthenticated(value bool) { + tc.SetGoogleDriveAuthenticated(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 +} + +// GetSkipProcessedFiles returns the value of SkipProcessedFiles with a default if nil +func (tc *TransferConfig) GetSkipProcessedFiles() bool { + if tc.SkipProcessedFiles == nil { + return true // Default to true if not set + } + return *tc.SkipProcessedFiles +} + +// SetSkipProcessedFiles sets the SkipProcessedFiles field +func (tc *TransferConfig) SetSkipProcessedFiles(value bool) { + tc.SkipProcessedFiles = &value +} + +// GetUseBuiltinAuthSource returns the value of UseBuiltinAuthSource with a default if nil +func (tc *TransferConfig) GetUseBuiltinAuthSource() bool { + if tc.UseBuiltinAuthSource == nil { + return true // Default to true if not set + } + return *tc.UseBuiltinAuthSource +} + +// SetUseBuiltinAuthSource sets the UseBuiltinAuthSource field +func (tc *TransferConfig) SetUseBuiltinAuthSource(value bool) { + tc.UseBuiltinAuthSource = &value +} + +// GetUseBuiltinAuthDest returns the value of UseBuiltinAuthDest with a default if nil +func (tc *TransferConfig) GetUseBuiltinAuthDest() bool { + if tc.UseBuiltinAuthDest == nil { + return true // Default to true if not set + } + return *tc.UseBuiltinAuthDest +} + +// SetUseBuiltinAuthDest sets the UseBuiltinAuthDest field +func (tc *TransferConfig) SetUseBuiltinAuthDest(value bool) { + tc.UseBuiltinAuthDest = &value +} diff --git a/internal/db/transfer_config_store.go b/internal/db/transfer_config_store.go new file mode 100644 index 0000000..9bc9ed7 --- /dev/null +++ b/internal/db/transfer_config_store.go @@ -0,0 +1,497 @@ +package db + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// --- TransferConfig Store Methods --- + +// CreateTransferConfig creates a new transfer config record +func (db *DB) CreateTransferConfig(config *TransferConfig) error { + return db.Create(config).Error +} + +// GetTransferConfigs retrieves all transfer configs for a user +func (db *DB) GetTransferConfigs(userID uint) ([]TransferConfig, error) { + var configs []TransferConfig + err := db.Where("created_by = ?", userID).Find(&configs).Error + return configs, err +} + +// GetTransferConfig retrieves a single transfer config by ID +func (db *DB) GetTransferConfig(id uint) (*TransferConfig, error) { + var config TransferConfig + err := db.First(&config, id).Error + if err != nil { + return nil, err + } + return &config, nil +} + +// UpdateTransferConfig updates an existing transfer config record +func (db *DB) UpdateTransferConfig(config *TransferConfig) error { + return db.Save(config).Error +} + +// DeleteTransferConfig deletes a transfer config record after checking dependencies +func (db *DB) DeleteTransferConfig(id uint) error { + // First check if any jobs are using this config + var count int64 + // Need to check both ConfigID and ConfigIDs list + // This check might need refinement depending on how ConfigIDs is used reliably + if err := db.Model(&Job{}).Where("config_id = ? OR config_ids LIKE ?", id, "%"+strconv.FormatUint(uint64(id), 10)+"%").Count(&count).Error; err != nil { + return fmt.Errorf("failed to check for dependent jobs: %v", err) + } + if count > 0 { + return fmt.Errorf("cannot delete config: %d jobs are using this configuration", count) + } + + // Delete the config + return db.Delete(&TransferConfig{}, id).Error +} + +// GetConfigRclonePath returns the path to the rclone config file for a given transfer config +func (db *DB) GetConfigRclonePath(config *TransferConfig) string { + // Get data directory from environment or use default + dataDir := os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "./data" + } + + // Store configs in the data directory + return filepath.Join(dataDir, "configs", fmt.Sprintf("config_%d.conf", config.ID)) +} + +// GenerateRcloneConfig generates the rclone config file content based on TransferConfig +// This function now primarily focuses on generating the content string or calling rclone config create +func (db *DB) GenerateRcloneConfig(config *TransferConfig) error { + configPath := db.GetConfigRclonePath(config) + + // Get the directory part of the path + configDir := filepath.Dir(configPath) + + // Ensure configs directory exists + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create configs directory: %v", err) + } + + // Get the rclone path from the environment variable or use the default path + rclonePath := os.Getenv("RCLONE_PATH") + if rclonePath == "" { + rclonePath = "rclone" + } + + sourceName := fmt.Sprintf("source_%d", config.ID) + // Generate rclone config using rclone CLI for source + 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", configPath, + "--log-level", "ERROR", + } + if config.SourcePassword != "" { + args = append(args, "pass", config.SourcePassword) + } + if config.SourceKeyFile != "" { + args = append(args, "key_file", config.SourceKeyFile) + } + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create source config (sftp): %v\nOutput: %s", err, output) + } + case "s3": + args := []string{ + "config", "create", sourceName, "s3", + "provider", "AWS", // Assuming AWS provider, adjust if needed + "env_auth", "false", + "access_key_id", config.SourceAccessKey, + "secret_access_key", config.SourceSecretKey, + "region", config.SourceRegion, + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + if config.SourceEndpoint != "" { + args = append(args, "endpoint", config.SourceEndpoint) + } + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create source config (s3): %v\nOutput: %s", err, output) + } + case "minio": + args := []string{ + "config", "create", sourceName, "s3", + "provider", "Minio", + "env_auth", "false", + "access_key_id", config.SourceAccessKey, + "secret_access_key", config.SourceSecretKey, + "endpoint", config.SourceEndpoint, + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create source config (minio): %v\nOutput: %s", err, output) + } + // ... (Add cases for other source types: b2, smb, ftp, webdav, nextcloud, onedrive, gdrive, gphotos) ... + case "local": + // For local source, ensure the section exists but might not need specific rclone config create + content := fmt.Sprintf("[%s]\ntype = local\n\n", sourceName) + if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { + return fmt.Errorf("failed to write source config (local): %v", err) + } + default: + // Handle unknown or unsupported source types if necessary + return fmt.Errorf("unsupported source type for rclone config generation: %s", config.SourceType) + + } + + destName := fmt.Sprintf("dest_%d", config.ID) + // Generate rclone config using rclone CLI for destination + switch config.DestinationType { + case "sftp": + args := []string{ + "config", "create", destName, "sftp", + "host", config.DestHost, + "user", config.DestUser, + "port", fmt.Sprintf("%d", config.DestPort), + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + if config.DestPassword != "" { + args = append(args, "pass", config.DestPassword) + } + if config.DestKeyFile != "" { + args = append(args, "key_file", config.DestKeyFile) + } + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create destination config (sftp): %v\nOutput: %s", err, output) + } + case "s3": + args := []string{ + "config", "create", destName, "s3", + "provider", "AWS", // Assuming AWS provider + "env_auth", "false", + "access_key_id", config.DestAccessKey, + "secret_access_key", config.DestSecretKey, + "region", config.DestRegion, + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + if config.DestEndpoint != "" { + args = append(args, "endpoint", config.DestEndpoint) + } + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create destination config (s3): %v\nOutput: %s", err, output) + } + case "minio": + args := []string{ + "config", "create", destName, "s3", + "provider", "Minio", + "env_auth", "false", + "access_key_id", config.DestAccessKey, + "secret_access_key", config.DestSecretKey, + "endpoint", config.DestEndpoint, + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create destination config (minio): %v\nOutput: %s", err, output) + } + // ... (Add cases for other destination types: b2, smb, ftp, webdav, nextcloud, onedrive, gdrive, gphotos) ... + case "local": + // Append local config section + content := fmt.Sprintf("\n[%s]\ntype = local\n", destName) + f, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open config file for appending (local dest): %v", err) + } + defer f.Close() + if _, err := f.WriteString(content); err != nil { + return fmt.Errorf("failed to write destination config (local): %v", err) + } + default: + // Handle unknown or unsupported destination types if necessary + return fmt.Errorf("unsupported destination type for rclone config generation: %s", config.DestinationType) + } + + return nil +} + +// 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) + } + + config, err := db.GetTransferConfig(uint(configID)) + if err != nil { + return fmt.Errorf("failed to get config: %v", err) + } + + authenticated := true + config.GoogleDriveAuthenticated = &authenticated + + if err := db.UpdateTransferConfig(config); err != nil { + return fmt.Errorf("failed to update config: %v", err) + } + + configPath := db.GetConfigRclonePath(config) + 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) + } + + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %v", err) + } + + destName := fmt.Sprintf("dest_%d", config.ID) + newConfig := fmt.Sprintf("[%s]\ntype = drive\ntoken = %s\n", destName, token) + + if config.DestClientID != "" && config.DestClientSecret != "" { + newConfig += fmt.Sprintf("client_id = %s\nclient_secret = %s\n", config.DestClientID, config.DestClientSecret) + } + if config.DestDriveID != "" { + newConfig += fmt.Sprintf("root_folder_id = %s\n", config.DestDriveID) + } + if config.DestTeamDrive != "" { + newConfig += fmt.Sprintf("team_drive = %s\n", config.DestTeamDrive) + } + + var content string + sectionHeader := fmt.Sprintf("[%s]", destName) + if strings.Contains(existingConfig, sectionHeader) { + parts := strings.SplitN(existingConfig, sectionHeader, 2) + 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 + } + + 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 +// Note: This seems partially redundant with StoreGoogleDriveToken and GenerateRcloneConfig. Consolidate if possible. +func (db *DB) GenerateRcloneConfigWithToken(config *TransferConfig, token string) error { + configPath := db.GetConfigRclonePath(config) + if configPath == "" { + return fmt.Errorf("failed to get config path") + } + + token = strings.TrimSpace(token) + token = strings.ReplaceAll(token, "\n", "") + token = strings.ReplaceAll(token, "\r", "") + + var configType, section, clientID, clientSecret string + var readOnly, includeArchived *bool + var startYear int + + // Determine if source or destination needs token update + if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" { + configType = config.DestinationType + section = "dest" + clientID = config.DestClientID + clientSecret = config.DestClientSecret + readOnly = config.DestReadOnly + startYear = config.DestStartYear + includeArchived = config.DestIncludeArchived + } else if config.SourceType == "gdrive" || config.SourceType == "gphotos" { + configType = config.SourceType + section = "source" + clientID = config.SourceClientID + clientSecret = config.SourceClientSecret + readOnly = config.SourceReadOnly + startYear = config.SourceStartYear + includeArchived = config.SourceIncludeArchived + } else { + return fmt.Errorf("config is not for Google Drive or Google Photos") + } + + contentBytes, err := os.ReadFile(configPath) + if err != nil && !os.IsNotExist(err) { // Allow file not existing yet + return fmt.Errorf("failed to read config file: %v", err) + } + content := string(contentBytes) + + var sectionContent string + sectionHeader := fmt.Sprintf("[%s_%d]", section, config.ID) + + if configType == "gdrive" { + sectionContent = sectionHeader + "\ntype = drive\n" + if clientID != "" { + sectionContent += fmt.Sprintf("client_id = %s\n", clientID) + } + if clientSecret != "" { + sectionContent += fmt.Sprintf("client_secret = %s\n", clientSecret) + } + sectionContent += fmt.Sprintf("token = %s\n", token) + if section == "source" && config.SourceTeamDrive != "" { + sectionContent += fmt.Sprintf("team_drive = %s\n", config.SourceTeamDrive) + } + if section == "dest" && config.DestTeamDrive != "" { + sectionContent += fmt.Sprintf("team_drive = %s\n", config.DestTeamDrive) + } + if section == "dest" && config.DestDriveID != "" { + sectionContent += fmt.Sprintf("root_folder_id = %s\n", config.DestDriveID) + } // Use DestDriveID for root_folder_id + } else if configType == "gphotos" { + sectionContent = sectionHeader + "\ntype = google photos\n" + if clientID != "" { + sectionContent += fmt.Sprintf("client_id = %s\n", clientID) + } + if clientSecret != "" { + sectionContent += fmt.Sprintf("client_secret = %s\n", clientSecret) + } + sectionContent += fmt.Sprintf("token = %s\n", token) + if readOnly != nil && *readOnly { + sectionContent += "read_only = true\n" + } + if startYear > 0 { + sectionContent += fmt.Sprintf("start_year = %d\n", startYear) + } + if includeArchived != nil && *includeArchived { + sectionContent += "include_archived = true\n" + } + } + + // Replace or append logic + sectionPattern := regexp.MustCompile(fmt.Sprintf(`(?m)^%s[^\[]*`, regexp.QuoteMeta(sectionHeader))) // Match section start to next section or EOF + if sectionPattern.MatchString(content) { + content = sectionPattern.ReplaceAllString(content, sectionContent) + } else { + if content != "" && !strings.HasSuffix(content, "\n\n") { // Ensure separation + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += "\n" + } + content += sectionContent + } + + // 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 updated config file + if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { // Use 0600 for sensitive files + return fmt.Errorf("failed to write updated config file: %v", err) + } + + // Update the authentication status in DB + authenticated := true + if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" { + config.SetGoogleAuthenticated(authenticated) + } else if config.SourceType == "gdrive" || config.SourceType == "gphotos" { + config.SetGoogleAuthenticated(authenticated) + } + // Persist the change (assuming UpdateTransferConfig saves the whole object) + if err := db.UpdateTransferConfig(config); err != nil { + return fmt.Errorf("failed to update config authentication status: %v", err) + } + + return nil +} + +// 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 "", "" + } + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return "", "" + } + + content, err := os.ReadFile(configPath) + if err != nil { + return "", "" + } + + lines := strings.Split(string(content), "\n") + 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) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + inSourceSection = line == sourceSectionName + inDestSection = line == destSectionName + continue + } + 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]) + } + } + } + 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 sourceClientID != "" && sourceClientSecret != "" && destClientID != "" && destClientSecret != "" { + break + } + } + + if destClientID != "" && destClientSecret != "" { + return destClientID, destClientSecret + } + if sourceClientID != "" && sourceClientSecret != "" { + return sourceClientID, sourceClientSecret + } + return "", "" +} diff --git a/internal/db/user.go b/internal/db/user.go new file mode 100644 index 0000000..a9e76bc --- /dev/null +++ b/internal/db/user.go @@ -0,0 +1,166 @@ +package db + +import ( + "fmt" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// User represents a user account in the system +type User struct { + ID uint `gorm:"primarykey"` + Email string `gorm:"unique;not null"` + PasswordHash string `gorm:"not null"` + IsAdmin *bool `gorm:"default:false"` + LastPasswordChange time.Time + FailedLoginAttempts int `gorm:"default:0"` + AccountLocked *bool `gorm:"default:false"` + LockoutUntil *time.Time + Theme string `gorm:"default:'light'"` + TwoFactorSecret string `gorm:"type:varchar(32)"` + TwoFactorEnabled bool `gorm:"default:false"` + BackupCodes string `gorm:"type:text"` // Comma-separated backup codes + Roles []Role `gorm:"many2many:user_roles"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// PasswordHistory stores previous passwords for a user +type PasswordHistory struct { + ID uint `gorm:"primarykey"` + UserID uint `gorm:"not null"` + User User `gorm:"foreignkey:UserID"` + PasswordHash string `gorm:"not null"` + CreatedAt time.Time +} + +// PasswordResetToken stores tokens for password reset requests +type PasswordResetToken struct { + ID uint `gorm:"primarykey"` + UserID uint `gorm:"not null"` + User User `gorm:"foreignkey:UserID"` + Token string `gorm:"not null"` + ExpiresAt time.Time `gorm:"not null"` + Used *bool `gorm:"default:false"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// --- User Helper Methods --- + +// 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 +} + +// HasRole checks if the user has a specific role +func (u *User) HasRole(roleName string) bool { + for _, role := range u.Roles { + if role.Name == roleName { + return true + } + } + return false +} + +// HasPermission checks if the user has a specific permission through any of their roles +func (u *User) HasPermission(permission string) bool { + for _, role := range u.Roles { + if role.HasPermission(permission) { + return true + } + } + return false +} + +// GetRoles returns all roles assigned to the user +// Note: This requires preloading Roles when fetching the user +func (u *User) GetRoles(tx *gorm.DB) ([]Role, error) { + var roles []Role + err := tx.Model(u).Association("Roles").Find(&roles) + return roles, err +} + +// AssignRole assigns a role to the user +func (u *User) AssignRole(tx *gorm.DB, roleID uint, assignedByID uint) error { + var role Role + if err := tx.First(&role, roleID).Error; err != nil { + return err + } + // Assuming Role struct has AssignToUser method (from role.go) + return role.AssignToUser(tx, u.ID, assignedByID) +} + +// UnassignRole removes a role from the user +func (u *User) UnassignRole(tx *gorm.DB, roleID uint, unassignedByID uint) error { + var role Role + if err := tx.First(&role, roleID).Error; err != nil { + return err + } + // Assuming Role struct has UnassignFromUser method (from role.go) + return role.UnassignFromUser(tx, u.ID, unassignedByID) +} + +// SetPassword sets the user's password with secure hashing +func (u *User) SetPassword(password string) error { + // Validate password length + if len(password) < 8 { + return fmt.Errorf("password must be at least 8 characters long") + } + + // Hash the password using bcrypt + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + // Store the hashed password + u.PasswordHash = string(hashedPassword) + u.LastPasswordChange = time.Now() + + return nil +} + +// CheckPassword verifies if the provided password matches the stored hash +func (u *User) CheckPassword(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) + return err == nil +} + +// --- PasswordResetToken Helper Methods --- + +// 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 +} diff --git a/internal/db/user_store.go b/internal/db/user_store.go new file mode 100644 index 0000000..b738e2f --- /dev/null +++ b/internal/db/user_store.go @@ -0,0 +1,63 @@ +package db + +import ( + "time" +) + +// --- User Store Methods --- + +// CreateUser creates a new user record +func (db *DB) CreateUser(user *User) error { + return db.Create(user).Error +} + +// GetUserByEmail retrieves a user by their email address +func (db *DB) GetUserByEmail(email string) (*User, error) { + var user User + // Preload Roles to ensure they are available for permission checks + err := db.Preload("Roles").Where("email = ?", email).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + +// GetUserByID retrieves a user by their ID +func (db *DB) GetUserByID(id uint) (*User, error) { + var user User + // Preload Roles + err := db.Preload("Roles").First(&user, id).Error + if err != nil { + return nil, err + } + return &user, nil +} + +// UpdateUser updates an existing user record +func (db *DB) UpdateUser(user *User) error { + // Use Omit to prevent accidentally changing Roles association directly + // Role assignments should use AssignRole/UnassignRole methods + return db.Omit("Roles").Save(user).Error +} + +// --- PasswordResetToken Store Methods --- + +// CreatePasswordResetToken creates a new password reset token record +func (db *DB) CreatePasswordResetToken(token *PasswordResetToken) error { + return db.Create(token).Error +} + +// GetPasswordResetToken retrieves a valid, unused password reset token +func (db *DB) GetPasswordResetToken(token string) (*PasswordResetToken, error) { + var resetToken PasswordResetToken + err := db.Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).First(&resetToken).Error + if err != nil { + return nil, err + } + return &resetToken, nil +} + +// MarkPasswordResetTokenAsUsed marks a password reset token as used +func (db *DB) MarkPasswordResetTokenAsUsed(tokenID uint) error { + return db.Model(&PasswordResetToken{}).Where("id = ?", tokenID).Update("used", true).Error +}