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) if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("failed to create database directory: %v", err) } // Open database connection with modernc.org/sqlite driver db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) if err != nil { return nil, fmt.Errorf("failed to connect to database: %v", err) } // Initialize and run migrations m := migrations.GetMigrations(db) if err := m.Migrate(); err != nil { return nil, fmt.Errorf("failed to run migrations: %v", err) } return &DB{DB: db}, nil } // ReopenWithoutMigrations reopens the database connection without running migrations // This should be used when temporarily closing and reopening the database func ReopenWithoutMigrations(dbPath string) (*DB, error) { // Open database connection with modernc.org/sqlite driver db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) if err != nil { return nil, fmt.Errorf("failed to connect to database: %v", err) } return &DB{DB: db}, nil } func (db *DB) Close() error { sqlDB, err := db.DB.DB() if err != nil { return err } 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, "--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.SourceHost, "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.SourceHost, "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) 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, "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.DestHost, "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.DestHost, "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 }