diff --git a/components/admin_audit.templ b/components/admin_audit.templ index 9ad2913..8fc4e21 100644 --- a/components/admin_audit.templ +++ b/components/admin_audit.templ @@ -302,93 +302,6 @@ templ AdminAuditLogs(ctx context.Context, data AuditLogsData) { - } } diff --git a/components/config_form.templ b/components/config_form.templ index f480858..a3dad3d 100644 --- a/components/config_form.templ +++ b/components/config_form.templ @@ -249,6 +249,9 @@ func getInitialData(config *db.TransferConfig) string { sourcePathError: '', destPathValid: null, destPathError: '', + + // Command configuration + requiresDestination: true, // Methods for path validation checkPath(path, type) { @@ -268,6 +271,68 @@ func getInitialData(config *db.TransferConfig) string { this[type + 'PathValid'] = false; this[type + 'PathError'] = 'Error checking path: ' + error.message; }); + }, + + // Method to check if destination is required based on command type + updateCommandRequirements() { + // List of commands that don't require destination + const listingCommands = ['ls', 'lsd', 'lsl', 'lsf', 'lsjson', 'listremotes']; + const infoCommands = ['md5sum', 'sha1sum', 'size', 'version']; + const dirCommands = ['mkdir', 'rmdir', 'rmdirs']; + const destructiveCommands = ['delete', 'purge']; + const specialSinglePathCommands = ['obscure']; + + // Get the command name from the command ID + // This will need coordination with your backend to ensure the IDs match the commands + let commandName = ''; + switch(parseInt(this.commandId)) { + // Map command IDs to command names - adjust these based on your actual command IDs + case 1: commandName = 'copy'; break; + case 2: commandName = 'move'; break; + case 3: commandName = 'sync'; break; + case 4: commandName = 'ls'; break; + case 5: commandName = 'lsd'; break; + case 6: commandName = 'lsl'; break; + case 7: commandName = 'lsf'; break; + case 8: commandName = 'lsjson'; break; + case 9: commandName = 'md5sum'; break; + case 10: commandName = 'sha1sum'; break; + case 11: commandName = 'size'; break; + case 12: commandName = 'delete'; break; + case 13: commandName = 'purge'; break; + case 14: commandName = 'mkdir'; break; + case 15: commandName = 'rmdir'; break; + case 16: commandName = 'rmdirs'; break; + case 17: commandName = 'check'; break; + case 18: commandName = 'cleanup'; break; + case 19: commandName = 'dedupe'; break; + case 20: commandName = 'version'; break; + case 21: commandName = 'listremotes'; break; + case 22: commandName = 'cryptcheck'; break; + case 23: commandName = 'bisync'; break; + case 24: commandName = 'copyto'; break; + case 25: commandName = 'moveto'; break; + default: commandName = 'copy'; // Default to copy + } + + console.log('Command ID:', this.commandId, 'Command Name:', commandName); + + // Check if the command requires a destination + if ( + listingCommands.includes(commandName) || + infoCommands.includes(commandName) || + (dirCommands.includes(commandName) && !this.rcloneFlags.includes('--dst')) || + destructiveCommands.includes(commandName) || + specialSinglePathCommands.includes(commandName) || + commandName === 'version' || + commandName === 'listremotes' + ) { + this.requiresDestination = false; + console.log('Destination not required for command:', commandName); + } else { + this.requiresDestination = true; + console.log('Destination required for command:', commandName); + } } }`, name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceAuthType, @@ -323,6 +388,8 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { destinationType = destinationType || 'local'; sourcePort = sourcePort || 22; destPort = destPort || 22; + // Initialize command requirements + updateCommandRequirements(); })" > @@ -352,6 +419,16 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {

Choose a descriptive name to identify this configuration.

+ +
+

+ Command Configuration +

+ + + @common.RcloneFlags() +
+

@@ -413,8 +490,8 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { @common.FilePatternFields()

- -
+ +

Destination Configuration

@@ -476,11 +553,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { @common.ArchiveOptions()
- -
-

Rclone Configuration

- @common.RcloneFlags() -
+
diff --git a/components/layout.templ b/components/layout.templ index 4eb6cc4..f090a35 100644 --- a/components/layout.templ +++ b/components/layout.templ @@ -3,6 +3,7 @@ package components import ( "context" "fmt" + "strings" "github.com/gin-gonic/gin" "time" ) @@ -10,7 +11,7 @@ import ( // AppVersion will be set at build time using ldflags // Example build command: // go build -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=1.2.3" -var AppVersion = "dev" +var AppVersion = "DEV" // GetReleaseURL returns the URL to the specific GitHub release func GetReleaseURL() templ.SafeURL { @@ -62,42 +63,7 @@ templ LayoutWithContext(title string, ctx context.Context) { - - - - + +
- Usage Example + Usage Examples
- rclone { command.Name } [flags] source:path dest:path + if command.Name == "ls" || + command.Name == "lsd" || + command.Name == "lsl" || + command.Name == "lsjson" || + command.Name == "lsf" || + command.Name == "delete" || + command.Name == "purge" || + command.Name == "rmdirs" || + command.Name == "mkdir" || + command.Name == "touch" || + command.Name == "md5sum" || + command.Name == "sha1sum" || + command.Name == "sha256sum" || + command.Name == "size" || + command.Name == "stat" || + command.Name == "version" { + rclone { command.Name } [flags] source:path + } else { + rclone { command.Name } [flags] source:path dest:path + }
} else {
@@ -303,4 +399,65 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
} +} + +// Helper function to render appropriate input based on flag data type +templ renderFlagInput(flag db.RcloneCommandFlag) { + if flag.DataType == "int" { + + + + } else if flag.DataType == "float" { + + + + } else { + + + + + } } \ No newline at end of file diff --git a/components/providers/destination/ftp.templ b/components/providers/destination/ftp.templ index 0ba5de7..60590c0 100644 --- a/components/providers/destination/ftp.templ +++ b/components/providers/destination/ftp.templ @@ -8,7 +8,7 @@ templ FTPDestinationForm() {
- @@ -34,7 +34,7 @@ templ FTPDestinationForm() {
- @@ -47,7 +47,7 @@ templ FTPDestinationForm() {
- @@ -69,7 +69,7 @@ templ FTPDestinationForm() {
- diff --git a/components/providers/destination/local.templ b/components/providers/destination/local.templ index 9e80a0e..dc7c080 100644 --- a/components/providers/destination/local.templ +++ b/components/providers/destination/local.templ @@ -8,29 +8,29 @@ templ LocalDestinationForm() {
-

Full path to the local directory containing your files

-
+
- +
-
+
- +
@@ -30,7 +30,7 @@ templ MinIODestinationForm() {
- @@ -45,7 +45,7 @@ templ MinIODestinationForm() {
- @@ -60,7 +60,7 @@ templ MinIODestinationForm() {
- diff --git a/components/providers/destination/nextcloud.templ b/components/providers/destination/nextcloud.templ index bede4d7..d093a37 100644 --- a/components/providers/destination/nextcloud.templ +++ b/components/providers/destination/nextcloud.templ @@ -15,7 +15,7 @@ templ NextCloudDestinationForm() {
- @@ -30,7 +30,7 @@ templ NextCloudDestinationForm() {
- @@ -45,7 +45,7 @@ templ NextCloudDestinationForm() {
- @@ -60,7 +60,7 @@ templ NextCloudDestinationForm() {
- diff --git a/components/providers/destination/s3.templ b/components/providers/destination/s3.templ index 50b630f..1e08420 100644 --- a/components/providers/destination/s3.templ +++ b/components/providers/destination/s3.templ @@ -15,7 +15,7 @@ templ S3DestinationForm() {
- @@ -30,7 +30,7 @@ templ S3DestinationForm() {
- @@ -45,7 +45,7 @@ templ S3DestinationForm() {
- @@ -60,7 +60,7 @@ templ S3DestinationForm() {
- diff --git a/components/providers/destination/sftp.templ b/components/providers/destination/sftp.templ index 1bfd02a..5b865c0 100644 --- a/components/providers/destination/sftp.templ +++ b/components/providers/destination/sftp.templ @@ -8,7 +8,7 @@ templ SFTPDestinationForm() {
- @@ -34,7 +34,7 @@ templ SFTPDestinationForm() {
- @@ -87,7 +87,7 @@ templ SFTPDestinationForm() {
- diff --git a/components/providers/destination/smb.templ b/components/providers/destination/smb.templ index 597b273..06eff84 100644 --- a/components/providers/destination/smb.templ +++ b/components/providers/destination/smb.templ @@ -15,7 +15,7 @@ templ SMBDestinationForm() {
- @@ -30,7 +30,7 @@ templ SMBDestinationForm() {
- @@ -60,7 +60,7 @@ templ SMBDestinationForm() {
- @@ -75,7 +75,7 @@ templ SMBDestinationForm() {
- diff --git a/components/providers/destination/webdav.templ b/components/providers/destination/webdav.templ index f50d0ab..9fd1e2e 100644 --- a/components/providers/destination/webdav.templ +++ b/components/providers/destination/webdav.templ @@ -15,7 +15,7 @@ templ WebDAVDestinationForm() {
- @@ -30,7 +30,7 @@ templ WebDAVDestinationForm() {
- @@ -45,7 +45,7 @@ templ WebDAVDestinationForm() {
- diff --git a/components/two_factor_verify.templ b/components/two_factor_verify.templ index 49584d9..cf8d873 100644 --- a/components/two_factor_verify.templ +++ b/components/two_factor_verify.templ @@ -10,13 +10,13 @@ templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) { @LayoutWithContext("Two-Factor Authentication", ctx) {
-
+

Two-Factor Authentication

-

+

Enter the verification code from your authenticator app to continue

diff --git a/components/user_edit.templ b/components/user_edit.templ index 3870133..37d0a3f 100644 --- a/components/user_edit.templ +++ b/components/user_edit.templ @@ -198,44 +198,3 @@ templ UserEdit(ctx context.Context, data UserEditData) {
} } - -// JavaScript to handle URL parameters for displaying messages -script handleUrlParams() { - document.addEventListener('DOMContentLoaded', function() { - const params = new URLSearchParams(window.location.search); - const error = params.get('error'); - const details = params.get('details'); - const status = params.get('status'); - - if (error) { - const errorMsgEl = document.getElementById('error-message'); - const errorTitleEl = errorMsgEl.querySelector('.error-title'); - const errorDetailsEl = errorMsgEl.querySelector('.error-details'); - - errorTitleEl.textContent = error; - if (details) { - errorDetailsEl.textContent = details; - } else { - errorDetailsEl.textContent = ''; - } - - errorMsgEl.classList.remove('hidden'); - - // If notyf is available, use it - if (window.notyf) { - window.notyf.error(error); - } - } - - if (status) { - const statusMsgEl = document.getElementById('status-message'); - statusMsgEl.textContent = status; - statusMsgEl.classList.remove('hidden'); - - // If notyf is available, use it - if (window.notyf) { - window.notyf.success(status); - } - } - }); -} \ No newline at end of file diff --git a/internal/db/db.go b/internal/db/db.go index 624f867..abc92f0 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -126,6 +126,7 @@ type TransferConfig struct { // 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 diff --git a/internal/db/migrations/010_add_rclone_command_to_config.go b/internal/db/migrations/010_add_rclone_command_to_config.go index b1a3c76..7937f92 100644 --- a/internal/db/migrations/010_add_rclone_command_to_config.go +++ b/internal/db/migrations/010_add_rclone_command_to_config.go @@ -78,6 +78,10 @@ func AddRcloneCommandToConfig() *gormigrate.Migration { return err } + if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_flag_values TEXT DEFAULT NULL`).Error; err != nil { + return err + } + return nil }, Rollback: func(tx *gorm.DB) error { @@ -90,6 +94,10 @@ func AddRcloneCommandToConfig() *gormigrate.Migration { return err } + if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_flag_values`).Error; err != nil { + return err + } + return nil }, } diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index f58ca66..c94dcc7 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -1929,7 +1929,7 @@ func determineCommandType(commandName string) string { return "transfer" } -// executeSimpleCommand executes rclone commands that don't require file-by-file processing +// executeSimpleCommand executes a simple command (non file-by-file transfer) func (s *Scheduler) executeSimpleCommand(cmdName string, cmdType string, job db.Job, config db.TransferConfig, history *db.JobHistory, configPath string) { s.log.LogInfo("Executing simple command '%s' of type '%s' for job %d, config %d", cmdName, cmdType, job.ID, config.ID) @@ -2139,3 +2139,75 @@ func (s *Scheduler) executeSimpleCommand(cmdName string, cmdType string, job db. // Send webhook notification s.sendWebhookNotification(&job, history, &config) } + +// prepareBaseArguments prepares the base arguments for a command +func (s *Scheduler) prepareBaseArguments(command string, config *db.TransferConfig, progressCallback func(string)) []string { + args := []string{command} + + // Add rclone flags from the config + if config.CommandFlags != "" { + var flagIDs []uint + if err := json.Unmarshal([]byte(config.CommandFlags), &flagIDs); err != nil { + s.log.LogError("Error parsing command flags: %v", err) + } else { + // Get all available flags for this command and their values + flagsMap, err := s.db.GetRcloneCommandFlagsMap(config.CommandID) + if err != nil { + s.log.LogError("Error getting flags map: %v", err) + } else { + // Parse flag values if available + var flagValues map[uint]string + if config.CommandFlagValues != "" { + if err := json.Unmarshal([]byte(config.CommandFlagValues), &flagValues); err != nil { + s.log.LogError("Error parsing flag values: %v", err) + } + } + + // Add each selected flag + for _, flagID := range flagIDs { + if flag, ok := flagsMap[flagID]; ok { + if flag.DataType == "bool" { + // Boolean flags don't have values + args = append(args, flag.Name) + } else if flagValues != nil { + // Check if we have a value for this flag + if value, ok := flagValues[flagID]; ok && value != "" { + args = append(args, flag.Name, value) + } else { + // If there's a default value, use it + if flag.DefaultValue != "" { + args = append(args, flag.Name, flag.DefaultValue) + } else { + // Skip flags without values + s.log.LogError("Skipping flag %s: no value provided", flag.Name) + } + } + } + } + } + } + } + } + + // Add any additional rclone flags specified by the user + if config.RcloneFlags != "" { + additionalFlags := strings.Fields(config.RcloneFlags) + args = append(args, additionalFlags...) + } + + // Add common rclone options + args = append(args, "--progress") + args = append(args, "--stats", "1s") + + // Add config file location + configPath := s.db.GetConfigRclonePath(config) + args = append(args, "--config", configPath) + + // Add progress callback + args = append(args, "--stats-one-line") + + // Set JSON output for easier parsing + args = append(args, "--json") + + return args +} diff --git a/internal/web/handlers/config_handlers.go b/internal/web/handlers/config_handlers.go index 17180eb..8350ac1 100644 --- a/internal/web/handlers/config_handlers.go +++ b/internal/web/handlers/config_handlers.go @@ -6,6 +6,7 @@ import ( "log" "net/http" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -156,6 +157,37 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) { } } + // Process flag values for non-boolean flags + flagValues := make(map[uint]string) + for key, values := range c.Request.PostForm { + // Check if key is a flag value field (format: flag_value_ID) + if strings.HasPrefix(key, "flag_value_") { + flagIDStr := strings.TrimPrefix(key, "flag_value_") + flagID, err := strconv.ParseUint(flagIDStr, 10, 64) + if err != nil { + log.Printf("Error parsing flag value ID: %v", err) + continue + } + + // Only process if the corresponding enable checkbox is checked + enableKey := fmt.Sprintf("flag_enable_%s", flagIDStr) + enableValue := c.Request.PostForm.Get(enableKey) + if enableValue == "on" && len(values) > 0 && values[0] != "" { + flagValues[uint(flagID)] = values[0] + } + } + } + + // Store flag values as JSON if any exist + if len(flagValues) > 0 { + flagValuesJSON, err := json.Marshal(flagValues) + if err != nil { + log.Printf("Error marshaling flag values: %v", err) + } else { + config.CommandFlagValues = string(flagValuesJSON) + } + } + // Process builtin auth settings useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source") useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true" @@ -343,6 +375,37 @@ func (h *Handlers) HandleUpdateConfig(c *gin.Context) { } } + // Process flag values for non-boolean flags + flagValues := make(map[uint]string) + for key, values := range c.Request.PostForm { + // Check if key is a flag value field (format: flag_value_ID) + if strings.HasPrefix(key, "flag_value_") { + flagIDStr := strings.TrimPrefix(key, "flag_value_") + flagID, err := strconv.ParseUint(flagIDStr, 10, 64) + if err != nil { + log.Printf("Error parsing flag value ID: %v", err) + continue + } + + // Only process if the corresponding enable checkbox is checked + enableKey := fmt.Sprintf("flag_enable_%s", flagIDStr) + enableValue := c.Request.PostForm.Get(enableKey) + if enableValue == "on" && len(values) > 0 && values[0] != "" { + flagValues[uint(flagID)] = values[0] + } + } + } + + // Store flag values as JSON if any exist + if len(flagValues) > 0 { + flagValuesJSON, err := json.Marshal(flagValues) + if err != nil { + log.Printf("Error marshaling flag values: %v", err) + } else { + config.CommandFlagValues = string(flagValuesJSON) + } + } + // Process builtin auth settings useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source") useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true" diff --git a/internal/web/handlers/job_handlers.go b/internal/web/handlers/job_handlers.go index 6ac8cfd..718d306 100644 --- a/internal/web/handlers/job_handlers.go +++ b/internal/web/handlers/job_handlers.go @@ -675,7 +675,7 @@ func (h *Handlers) HandleRunJob(c *gin.Context) { var job db.Job if err := h.DB.First(&job, id).Error; err != nil { c.Header("Content-Type", "text/html") - c.String(http.StatusNotFound, "") + c.String(http.StatusNotFound, "Job not found") return } @@ -685,7 +685,7 @@ func (h *Handlers) HandleRunJob(c *gin.Context) { isAdmin, exists := c.Get("isAdmin") if !exists || isAdmin != true { c.Header("Content-Type", "text/html") - c.String(http.StatusForbidden, "") + c.String(http.StatusForbidden, "You do not have permission to run this job") return } } @@ -720,7 +720,7 @@ func (h *Handlers) HandleRunJob(c *gin.Context) { // Run the job immediately using the scheduler if err := h.Scheduler.RunJobNow(job.ID); err != nil { c.Header("Content-Type", "text/html") - errorMsg := fmt.Sprintf("", err.Error()) + errorMsg := fmt.Sprintf("%s", err.Error()) c.String(http.StatusInternalServerError, errorMsg) return } @@ -730,7 +730,7 @@ func (h *Handlers) HandleRunJob(c *gin.Context) { c.Header("Content-Type", "text/html") // Return HTML with JavaScript to trigger the notification - successScript := fmt.Sprintf("", jobName) + successScript := fmt.Sprintf("Job \"%s\" has been started successfully", jobName) c.String(http.StatusOK, successScript) } diff --git a/internal/web/handlers/rclone_handlers.go b/internal/web/handlers/rclone_handlers.go index e1c3afe..6c55e3f 100644 --- a/internal/web/handlers/rclone_handlers.go +++ b/internal/web/handlers/rclone_handlers.go @@ -4,6 +4,7 @@ import ( "html/template" "log" "net/http" + "sort" "strconv" "github.com/gin-gonic/gin" @@ -76,6 +77,11 @@ func (h *RcloneHandler) RcloneCommandFlags(c *gin.Context) { return } + // Sort the flags alphabetically by name + sort.Slice(command.Flags, func(i, j int) bool { + return command.Flags[i].Name < command.Flags[j].Name + }) + _ = common.RcloneCommandFlagsContent(command).Render(c.Request.Context(), c.Writer) }