feat: Integrate rclone command functionality into configuration management

- Added support for selecting rclone commands and their associated flags in the configuration form.
- Implemented backend logic to handle rclone command retrieval and flag management.
- Enhanced the database schema to include rclone command and flag fields in the transfer configuration.
- Created new migration scripts to add rclone-related tables and fields.
- Updated the scheduler to execute transfers using the selected rclone command and flags.
- Developed new handlers for rendering rclone command options and flags in the web interface.
- Improved error handling and logging for rclone command execution and configuration updates.
This commit is contained in:
StarFleetCPTN
2025-03-23 16:41:49 -07:00
parent fb3f834c36
commit 0cebfcf1f5
10 changed files with 1511 additions and 95 deletions
+8 -1
View File
@@ -83,6 +83,8 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles := true skipProcessedFiles := true
maxConcurrentTransfers := 4 maxConcurrentTransfers := 4
rcloneFlags := "" rcloneFlags := ""
commandId := uint(1) // Default to 'copy' command
commandFlags := ""
useBuiltinAuthSource := true useBuiltinAuthSource := true
useBuiltinAuthDest := true useBuiltinAuthDest := true
@@ -162,6 +164,8 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles = config.GetSkipProcessedFiles() skipProcessedFiles = config.GetSkipProcessedFiles()
maxConcurrentTransfers = config.MaxConcurrentTransfers maxConcurrentTransfers = config.MaxConcurrentTransfers
rcloneFlags = config.RcloneFlags rcloneFlags = config.RcloneFlags
commandId = config.CommandID
commandFlags = config.CommandFlags
if config.UseBuiltinAuthSource != nil { if config.UseBuiltinAuthSource != nil {
useBuiltinAuthSource = *config.UseBuiltinAuthSource useBuiltinAuthSource = *config.UseBuiltinAuthSource
} else if sourceClientId != "" || sourceClientSecret != "" { } else if sourceClientId != "" || sourceClientSecret != "" {
@@ -237,6 +241,8 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles: %v, skipProcessedFiles: %v,
maxConcurrentTransfers: %d, maxConcurrentTransfers: %d,
rcloneFlags: '%s', rcloneFlags: '%s',
commandId: %d,
commandFlags: '%s',
// Path validation states // Path validation states
sourcePathValid: null, sourcePathValid: null,
@@ -274,7 +280,8 @@ func getInitialData(config *db.TransferConfig) string {
destClientId, destClientSecret, destDriveId, destTeamDrive, destClientId, destClientSecret, destDriveId, destTeamDrive,
destReadOnly, destStartYear, destIncludeArchived, destReadOnly, destStartYear, destIncludeArchived,
useBuiltinAuthSource, useBuiltinAuthDest, useBuiltinAuthSource, useBuiltinAuthDest,
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags) archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags,
commandId, commandFlags)
} }
templ ConfigForm(ctx context.Context, data ConfigFormData) { templ ConfigForm(ctx context.Context, data ConfigFormData) {
+108 -1
View File
@@ -1,5 +1,10 @@
package common package common
import (
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
templ NameField() { templ NameField() {
<div class="mb-6"> <div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label> <label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label>
@@ -135,7 +140,21 @@ templ ArchiveOptions() {
templ RcloneFlags() { templ RcloneFlags() {
<div class="mb-6"> <div class="mb-6">
<label for="rclone_flags" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Flags</label> <label for="command_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Command</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-terminal text-gray-400 dark:text-gray-500"></i>
</div>
@RcloneCommandOptions()
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Select the rclone command to use for this configuration.
</p>
<!-- Command flags container - will be populated via HTMX -->
<div id="command-flags-container" class="mt-4"></div>
<label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional Rclone Flags</label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none"> <div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-flag text-gray-400 dark:text-gray-500"></i> <i class="fas fa-flag text-gray-400 dark:text-gray-500"></i>
@@ -150,6 +169,18 @@ templ RcloneFlags() {
</div> </div>
} }
// New placeholder templ for rclone command options
templ RcloneCommandOptions() {
<div
hx-get="/api/rclone/commands"
hx-trigger="load"
hx-target="this"
hx-swap="outerHTML">
<!-- Loading placeholder -->
<option value="">Loading commands...</option>
</div>
}
templ SourceSelection() { templ SourceSelection() {
<div class="mb-6"> <div class="mb-6">
<label for="source_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Type</label> <label for="source_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Type</label>
@@ -197,3 +228,79 @@ templ DestinationSelection() {
</div> </div>
</div> </div>
} }
// RcloneCommandOptionsContent renders the command options organized by category
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string) {
<select id="command_id" name="command_id" x-model="commandId"
hx-get="/api/rclone/command-flags"
hx-target="#command-flags-container"
hx-trigger="change"
hx-include="[name='command_id']"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">Select command...</option>
for _, category := range categories {
if commands, ok := categoryMap[category]; ok && len(commands) > 0 {
<optgroup label={ category }>
for _, cmd := range commands {
<option value={ fmt.Sprintf("%d", cmd.ID) }>{ cmd.Name } - { cmd.Description }</option>
}
</optgroup>
}
}
</select>
}
// RcloneCommandFlagsContent renders the command flags for a selected command
templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
if command == nil {
<div class="p-4 text-red-500">Command not found</div>
return
}
<div class="mb-4">
<h4 class="font-medium text-gray-900 dark:text-white mb-2">{ command.Name }</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{ command.Description }</p>
if len(command.Flags) > 0 {
<div class="mb-3">
<h5 class="font-medium text-gray-900 dark:text-white mb-2">Command Flags:</h5>
<div class="space-y-3">
for _, flag := range command.Flags {
<div class="flex items-start">
<input
type="checkbox"
id={ fmt.Sprintf("flag_%d", flag.ID) }
name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) }
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
/>
<div class="ml-3">
<label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
{ flag.Name } - { flag.Description }
</label>
if flag.DefaultValue != "" {
<p class="text-xs text-gray-500 dark:text-gray-400">Default: { flag.DefaultValue }</p>
}
</div>
</div>
}
</div>
</div>
<div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm">
<div class="flex items-center mb-1">
<i class="fas fa-info-circle mr-2"></i>
<span class="font-medium">Usage Example</span>
</div>
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path dest:path</code>
</div>
} else {
<div class="p-3 bg-gray-50 text-gray-600 rounded-lg border border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700 text-sm">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>This command doesn't have any specific flags.</span>
</div>
</div>
}
</div>
}
+371
View File
@@ -123,6 +123,9 @@ type TransferConfig struct {
ArchivePath string `form:"archive_path"` ArchivePath string `form:"archive_path"`
ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"` ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"`
RcloneFlags string `form:"rclone_flags"` 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
DeleteAfterTransfer *bool `gorm:"default:false" form:"delete_after_transfer"` DeleteAfterTransfer *bool `gorm:"default:false" form:"delete_after_transfer"`
SkipProcessedFiles *bool `gorm:"default:true" form:"skip_processed_files"` SkipProcessedFiles *bool `gorm:"default:true" form:"skip_processed_files"`
MaxConcurrentTransfers int `gorm:"default:4" form:"max_concurrent_transfers"` // Number of concurrent file transfers MaxConcurrentTransfers int `gorm:"default:4" form:"max_concurrent_transfers"` // Number of concurrent file transfers
@@ -250,6 +253,31 @@ type DB struct {
*gorm.DB *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) { func Initialize(dbPath string) (*DB, error) {
// Create directory if it doesn't exist // Create directory if it doesn't exist
dir := filepath.Dir(dbPath) dir := filepath.Dir(dbPath)
@@ -1707,3 +1735,346 @@ func (u *User) CheckPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
return err == nil 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=<number>", flag.Name)
case "float":
return fmt.Sprintf("%s=<decimal>", flag.Name)
case "string":
return fmt.Sprintf("%s=<text>", flag.Name)
default:
return fmt.Sprintf("%s=<value>", 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] <source> <dest>", 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
}
@@ -0,0 +1,349 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddRcloneTables adds tables for rclone commands and their flags
func AddRcloneTables() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "009_add_rclone_tables",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Create the rclone_commands table
err := tx.Exec(`
CREATE TABLE IF NOT EXISTS rclone_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL,
is_advanced BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`).Error
if err != nil {
return err
}
// Create an index on name for faster lookups
err = tx.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_rclone_commands_name ON rclone_commands(name)
`).Error
if err != nil {
return err
}
// Create an index on category for faster filtering
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_rclone_commands_category ON rclone_commands(category)
`).Error
if err != nil {
return err
}
// Create the rclone_command_flags table
err = tx.Exec(`
CREATE TABLE IF NOT EXISTS rclone_command_flags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command_id INTEGER NOT NULL,
name TEXT NOT NULL,
short_name TEXT,
description TEXT NOT NULL,
data_type TEXT NOT NULL,
is_required BOOLEAN NOT NULL DEFAULT 0,
default_value TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (command_id) REFERENCES rclone_commands(id) ON DELETE CASCADE
)
`).Error
if err != nil {
return err
}
// Create index on command_id for faster lookups
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_rclone_command_flags_command_id ON rclone_command_flags(command_id)
`).Error
if err != nil {
return err
}
// Create index on flag name for faster searches
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_rclone_command_flags_name ON rclone_command_flags(name)
`).Error
if err != nil {
return err
}
// Populate the tables with default rclone commands and flags
return populateRcloneTablesWithDefaults(tx)
},
Rollback: func(tx *gorm.DB) error {
// Drop the flags table first due to foreign key constraints
err := tx.Exec(`DROP TABLE IF EXISTS rclone_command_flags`).Error
if err != nil {
return err
}
// Then drop the commands table
return tx.Exec(`DROP TABLE IF EXISTS rclone_commands`).Error
},
}
}
// Helper function to populate rclone tables with default data
func populateRcloneTablesWithDefaults(tx *gorm.DB) error {
// Define base commands
commands := []struct {
Name string
Description string
Category string
IsAdvanced bool
}{
// Core commands
{"copy", "Copy files from source to dest, skipping already copied", "sync", false},
{"sync", "Make source and dest identical, modifying destination only", "sync", false},
{"bisync", "Bidirectional synchronization between two paths", "sync", false},
{"move", "Move files from source to dest", "sync", false},
{"delete", "Remove the contents of path", "sync", false},
{"purge", "Remove the path and all of its contents", "sync", false},
{"mkdir", "Make the path if it doesn't already exist", "sync", false},
{"rmdir", "Remove the path", "sync", false},
{"rmdirs", "Remove any empty directories under the path", "sync", false},
{"check", "Check if the files in the source and destination match", "sync", false},
{"ls", "List all the objects in the path with size and path", "listing", false},
{"lsd", "List all directories/containers/buckets in the path", "listing", false},
{"lsl", "List all the objects in the path with size, modification time and path", "listing", false},
{"lsf", "List the objects in the path (obey formatting parameters)", "listing", false},
{"lsjson", "List the objects in the path in JSON format", "listing", false},
{"md5sum", "Produce an md5sum file for all the objects in the path", "hash", false},
{"sha1sum", "Produce a sha1sum file for all the objects in the path", "hash", false},
{"size", "Return the total size and number of objects in remote:path", "info", false},
{"version", "Show the version number", "info", false},
{"cleanup", "Clean up the remote if possible", "maintenance", false},
{"dedupe", "Interactively find duplicate files and delete/rename them", "maintenance", false},
{"copyto", "Copy files from source to dest, skipping already copied", "sync", false},
{"moveto", "Move file or directory from source to dest", "sync", false},
{"listremotes", "List all the remotes in the config file", "config", false},
{"obscure", "Obscure password for use in the rclone.conf", "config", false},
{"cryptcheck", "Check the integrity of an encrypted remote", "crypto", false},
}
// Insert commands
for _, cmd := range commands {
err := tx.Exec(`
INSERT INTO rclone_commands
(name, description, category, is_advanced, created_at)
VALUES (?, ?, ?, ?, ?)
`, cmd.Name, cmd.Description, cmd.Category, cmd.IsAdvanced, time.Now()).Error
if err != nil {
return fmt.Errorf("failed to insert command %s: %v", cmd.Name, err)
}
}
// Define all flags
allFlags := []struct {
CommandName string
Name string
ShortName string
Description string
DataType string
IsRequired bool
DefaultValue string
}{
// Global flags - apply to most commands
{"global", "transfers", "n", "Number of file transfers to run in parallel", "int", false, "4"},
{"global", "checkers", "", "Number of checkers to run in parallel", "int", false, "8"},
{"global", "log-level", "", "Log level DEBUG|INFO|NOTICE|ERROR", "string", false, "NOTICE"},
{"global", "stats", "", "Interval between logging stats, e.g 500ms, 60s, 5m", "string", false, "1m0s"},
{"global", "stats-file-name-length", "", "Max file name length in stats", "int", false, "45"},
{"global", "stats-one-line", "", "Make the stats fit on one line", "bool", false, "false"},
{"global", "progress", "p", "Show progress during transfer", "bool", false, "false"},
{"global", "verbose", "v", "Show verbose output", "bool", false, "false"},
{"global", "quiet", "q", "Print as little stuff as possible", "bool", false, "false"},
{"global", "retries", "", "Retry operations this many times if they fail", "int", false, "3"},
{"global", "retries-sleep", "", "Interval between retrying operations if they fail", "string", false, "0s"},
{"global", "timeout", "", "IO idle timeout", "string", false, "5m0s"},
{"global", "tpslimit", "", "Limit HTTP transactions per second", "float", false, "0"},
{"global", "tpslimit-burst", "", "Max burst of transactions for --tpslimit", "int", false, "1"},
{"global", "size-only", "", "Skip based on size only, not mod-time or checksum", "bool", false, "false"},
{"global", "ignore-checksum", "", "Skip post copy check of checksums", "bool", false, "false"},
{"global", "ignore-existing", "", "Skip all files that exist on destination", "bool", false, "false"},
{"global", "ignore-size", "", "Skip size checks to calculate if file changed", "bool", false, "false"},
{"global", "ignore-case-sync", "", "Ignore case when synchronizing", "bool", false, "false"},
{"global", "no-update-modtime", "", "Don't update destination mod-time if files identical", "bool", false, "false"},
{"global", "no-check-certificate", "", "Do not verify server SSL certificates", "bool", false, "false"},
{"global", "ask-password", "", "Allow prompt for password for encrypted config", "bool", false, "false"},
{"global", "dump", "", "List of items to dump from: headers,bodies,requests,responses,auth,filters,goroutines,openfiles", "string", false, ""},
{"global", "metadata", "M", "Preserve metadata when copying objects", "bool", false, "false"},
{"global", "metadata-set", "", "Add metadata key=value when uploading", "string", false, ""},
// copy command specific flags
{"copy", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"copy", "create-empty-src-dirs", "", "Create empty source dirs on destination", "bool", false, "false"},
{"copy", "cutoff-mode", "", "Mode to stop transfers when reaching the cutoff threshold", "string", false, "hard"},
{"copy", "max-transfer", "", "Maximum size of data to transfer", "string", false, "off"},
{"copy", "max-backlog", "", "Maximum size of upload or download backlog", "int", false, "10000"},
{"copy", "track-renames", "", "Track file renames during sync", "bool", false, "false"},
{"copy", "track-renames-strategy", "", "Strategies to detect renames (hash|modtime|leaf)", "string", false, "hash"},
// sync command specific flags
{"sync", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"sync", "create-empty-src-dirs", "", "Create empty source dirs on destination", "bool", false, "false"},
{"sync", "backup-dir", "", "Make backups into this directory", "string", false, ""},
{"sync", "suffix", "", "Suffix to add to changed files", "string", false, ""},
{"sync", "delete-before", "", "Delete before transferring", "bool", false, "false"},
{"sync", "delete-during", "", "Delete during transferring", "bool", false, "false"},
{"sync", "delete-after", "", "Delete after transferring", "bool", false, "false"},
{"sync", "track-renames", "", "Track file renames during sync", "bool", false, "false"},
{"sync", "track-renames-strategy", "", "Strategies to detect renames (hash|modtime|leaf)", "string", false, "hash"},
// bisync command specific flags
{"bisync", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"bisync", "resync", "", "Performs the resync run", "bool", false, "false"},
{"bisync", "check-access", "", "Ensure destination has write access", "bool", false, "true"},
{"bisync", "conflict-resolve", "", "Automatically resolve conflicts (newer|larger|older|smaller)", "string", false, ""},
{"bisync", "max-delete", "", "Safety check on maximum files to delete", "int", false, "10"},
// move command specific flags
{"move", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"move", "create-empty-src-dirs", "", "Create empty source dirs on destination", "bool", false, "false"},
{"move", "delete-empty-src-dirs", "", "Delete empty source dirs after move", "bool", false, "false"},
// ls/listing related flags
{"ls", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
{"ls", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
{"ls", "format", "", "Format for the output", "string", false, ""},
{"ls", "absolute", "", "Put a leading / in front of path names", "bool", false, "false"},
{"lsl", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
{"lsl", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
{"lsd", "max-depth", "", "Maximum depth to show in the listing", "int", false, ""},
{"lsd", "dir-sort", "", "Directory sorting (alphabetical|size|time)", "string", false, "alphabetical"},
{"lsf", "format", "", "Format for the output", "string", false, ""},
{"lsf", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
{"lsf", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
{"lsjson", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
{"lsjson", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
{"lsjson", "files-only", "", "Show only files, not directories", "bool", false, "false"},
{"lsjson", "encrypted", "", "Show the encrypted names", "bool", false, "false"},
{"lsjson", "stat", "", "Show status of objects", "bool", false, "false"},
// Other specialized command flags
{"cryptcheck", "one-way", "", "Check one way only, source files must exist on destination", "bool", false, "false"},
{"cleanup", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"dedupe", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"dedupe", "mode", "", "Dedupe mode interactive|skip|first|newest|oldest|largest|smallest|rename", "string", false, "interactive"},
}
// Insert flags with associated command IDs
for _, flag := range allFlags {
// For global flags, add them to all commands
if flag.CommandName == "global" {
// Get all command IDs
var commandIDs []int64
err := tx.Raw(`SELECT id FROM rclone_commands`).Scan(&commandIDs).Error
if err != nil {
return fmt.Errorf("failed to get all command IDs: %v", err)
}
// Add global flag to each command
for _, cmdID := range commandIDs {
err = tx.Exec(`
INSERT INTO rclone_command_flags
(command_id, name, short_name, description, data_type, is_required, default_value, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, cmdID, flag.Name, flag.ShortName, flag.Description, flag.DataType, flag.IsRequired, flag.DefaultValue, time.Now()).Error
if err != nil {
return fmt.Errorf("failed to insert global flag %s for command ID %d: %v", flag.Name, cmdID, err)
}
}
} else {
// Add command-specific flag
var commandID int64
err := tx.Raw(`SELECT id FROM rclone_commands WHERE name = ?`, flag.CommandName).Scan(&commandID).Error
if err != nil {
return fmt.Errorf("failed to find command %s: %v", flag.CommandName, err)
}
err = tx.Exec(`
INSERT INTO rclone_command_flags
(command_id, name, short_name, description, data_type, is_required, default_value, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, commandID, flag.Name, flag.ShortName, flag.Description, flag.DataType, flag.IsRequired, flag.DefaultValue, time.Now()).Error
if err != nil {
return fmt.Errorf("failed to insert flag %s for command %s: %v", flag.Name, flag.CommandName, err)
}
}
}
return nil
}
@@ -0,0 +1,96 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddRcloneCommandToConfig adds rclone command fields to the transfer_configs table
func AddRcloneCommandToConfig() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "010_add_rclone_command_to_config",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Add the command_id and command_flags columns to the transfer_configs table
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_id INTEGER DEFAULT NULL REFERENCES rclone_commands(id)`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_flags TEXT DEFAULT NULL`).Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Remove the columns in reverse order
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_flags`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_id`).Error; err != nil {
return err
}
return nil
},
}
}
+2
View File
@@ -19,6 +19,8 @@ func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate {
AddTimestampsToJobHistories(), // 006 AddTimestampsToJobHistories(), // 006
AddNotificationServices(), // 007 AddNotificationServices(), // 007
AddUserNotifications(), // 008 AddUserNotifications(), // 008
AddRcloneTables(), // 009
AddRcloneCommandToConfig(), // 010
) )
return gormigrate.New(db, gormigrate.DefaultOptions, migrations) return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
+354 -3
View File
@@ -464,6 +464,30 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
// Get rclone config path // Get rclone config path
configPath := s.db.GetConfigRclonePath(&config) configPath := s.db.GetConfigRclonePath(&config)
// Get the command to use for the transfer
var rcloneCommand string = "copyto" // Default command
if config.CommandID > 0 {
// Get the command by ID
command, err := s.db.GetRcloneCommand(config.CommandID)
if err == nil && command != nil {
rcloneCommand = command.Name
s.log.LogDebug("Using rclone command %s for job %d, config %d", rcloneCommand, job.ID, config.ID)
} else {
s.log.LogError("Failed to get rclone command with ID %d: %v", config.CommandID, err)
}
}
// Determine command type to handle execution appropriately
commandType := determineCommandType(rcloneCommand)
s.log.LogDebug("Command %s is of type: %s", rcloneCommand, commandType)
// For non-file-by-file transfer commands, use the simple execution approach
if commandType != "transfer" || isDirectoryBasedTransfer(rcloneCommand) {
s.executeSimpleCommand(rcloneCommand, commandType, job, config, history, configPath)
return
}
// The rest of the function handles file-by-file transfer commands (copyto, moveto)
// Use lsjson to get file list and metadata in one operation instead of separate size and ls commands // Use lsjson to get file list and metadata in one operation instead of separate size and ls commands
listArgs := []string{ listArgs := []string{
"--config", configPath, "--config", configPath,
@@ -564,7 +588,7 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
var files []map[string]interface{} var files []map[string]interface{}
var totalSize int64 var totalSize int64
for _, entry := range fileEntries { for _, entry := range fileEntries {
// Skip directories // Process directories
if isDir, ok := entry["IsDir"].(bool); ok && isDir { if isDir, ok := entry["IsDir"].(bool); ok && isDir {
continue continue
} }
@@ -753,16 +777,43 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
wg.Done() wg.Done()
}() }()
// Prepare moveto command for transfer // Prepare rclone command
transferArgs := []string{ transferArgs := []string{
"--config", configPath, "--config", configPath,
"copyto",
"--progress", "--progress",
"--stats-one-line", "--stats-one-line",
"--verbose", "--verbose",
"--stats", "1s", "--stats", "1s",
} }
// Add the command to the arguments
transferArgs = append(transferArgs, rcloneCommand)
// Add command flags if specified
if config.CommandFlags != "" {
var flagIDs []uint
if err := json.Unmarshal([]byte(config.CommandFlags), &flagIDs); err == nil {
// Get the flags for the selected command
for _, flagID := range flagIDs {
flag, err := s.db.GetRcloneCommandFlag(flagID)
if err == nil && flag != nil {
if flag.DataType == "bool" {
// For boolean flags, just add the flag name with -- prefix
transferArgs = append(transferArgs, "--"+flag.Name)
} else if flag.DefaultValue != "" {
// For flags with default values, use the default with -- prefix
transferArgs = append(transferArgs, "--"+flag.Name, flag.DefaultValue)
}
s.log.LogDebug("Added flag %s for job %d, config %d", flag.Name, job.ID, config.ID)
} else {
s.log.LogError("Failed to get rclone flag with ID %d: %v", flagID, err)
}
}
} else {
s.log.LogError("Failed to unmarshal command flags for job %d, config %d: %v", job.ID, config.ID, err)
}
}
// Source and destination paths // Source and destination paths
var sourcePath, destPath string var sourcePath, destPath string
@@ -985,7 +1036,19 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
// Send webhook notification for success or with errors // Send webhook notification for success or with errors
s.sendWebhookNotification(&job, history, &config) s.sendWebhookNotification(&job, history, &config)
}
// isDirectoryBasedTransfer checks if a transfer command operates on directories rather than individual files
func isDirectoryBasedTransfer(commandName string) bool {
// These commands operate on entire directories, not file-by-file
dirBasedCommands := map[string]bool{
"sync": true,
"bisync": true,
"copy": true,
"move": true,
}
return dirBasedCommands[commandName]
} }
// ProcessOutputPattern processes an output pattern with variables and returns the result // ProcessOutputPattern processes an output pattern with variables and returns the result
@@ -1788,3 +1851,291 @@ func (s *Scheduler) createJobNotification(job *db.Job, history *db.JobHistory) e
message, message,
) )
} }
// determineCommandType categorizes rclone commands into types for execution
func determineCommandType(commandName string) string {
// File transfer commands
transferCommands := map[string]bool{
"copy": true,
"copyto": true,
"move": true,
"moveto": true,
"sync": true,
"bisync": true,
}
// Listing commands
listingCommands := map[string]bool{
"ls": true,
"lsd": true,
"lsl": true,
"lsf": true,
"lsjson": true,
"listremotes": true,
}
// Information commands
infoCommands := map[string]bool{
"md5sum": true,
"sha1sum": true,
"size": true,
"version": true,
}
// Directory operations
dirCommands := map[string]bool{
"mkdir": true,
"rmdir": true,
"rmdirs": true,
}
// Destructive commands
destructiveCommands := map[string]bool{
"delete": true,
"purge": true,
}
// Maintenance commands
maintenanceCommands := map[string]bool{
"cleanup": true,
"dedupe": true,
"check": true,
}
// Specialized commands
specialCommands := map[string]bool{
"obscure": true,
"cryptcheck": true,
}
// Determine the command type
if transferCommands[commandName] {
return "transfer"
} else if listingCommands[commandName] {
return "listing"
} else if infoCommands[commandName] {
return "info"
} else if dirCommands[commandName] {
return "directory"
} else if destructiveCommands[commandName] {
return "destructive"
} else if maintenanceCommands[commandName] {
return "maintenance"
} else if specialCommands[commandName] {
return "special"
}
// Default to transfer if unknown
return "transfer"
}
// executeSimpleCommand executes rclone commands that don't require file-by-file processing
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)
// Prepare base arguments
baseArgs := []string{
"--config", configPath,
"--progress",
"--stats-one-line",
"--verbose",
"--stats", "1s",
}
// Add the command name
args := append(baseArgs, cmdName)
// Add command flags if specified
if config.CommandFlags != "" {
var flagIDs []uint
if err := json.Unmarshal([]byte(config.CommandFlags), &flagIDs); err == nil {
// Get the flags for the selected command
for _, flagID := range flagIDs {
flag, err := s.db.GetRcloneCommandFlag(flagID)
if err == nil && flag != nil {
if flag.DataType == "bool" {
// For boolean flags, just add the flag name with -- prefix
args = append(args, "--"+flag.Name)
} else if flag.DefaultValue != "" {
// For flags with default values, use the default with -- prefix
args = append(args, "--"+flag.Name, flag.DefaultValue)
}
s.log.LogDebug("Added flag %s for job %d, config %d", flag.Name, job.ID, config.ID)
} else {
s.log.LogError("Failed to get rclone flag with ID %d: %v", flagID, err)
}
}
} else {
s.log.LogError("Failed to unmarshal command flags for job %d, config %d: %v", job.ID, config.ID, err)
}
}
// Add custom flags if specified
if config.RcloneFlags != "" {
customFlags := strings.Split(config.RcloneFlags, " ")
args = append(args, customFlags...)
s.log.LogDebug("Added custom flags for job %d, config %d: %v", job.ID, config.ID, customFlags)
}
// Prepare source and destination paths
var sourcePath, destPath string
// Handle source path with bucket for S3-compatible storage
if config.SourceType == "s3" || config.SourceType == "minio" || config.SourceType == "b2" {
sourcePath = fmt.Sprintf("source_%d:%s", config.ID, config.SourceBucket)
if config.SourcePath != "" && config.SourcePath != "/" {
sourcePath = fmt.Sprintf("source_%d:%s/%s", config.ID, config.SourceBucket, config.SourcePath)
}
} else {
sourcePath = fmt.Sprintf("source_%d:%s", config.ID, config.SourcePath)
}
// Handle destination path with bucket for S3-compatible storage
if config.DestinationType == "s3" || config.DestinationType == "minio" || config.DestinationType == "b2" {
destPath = fmt.Sprintf("dest_%d:%s", config.ID, config.DestBucket)
if config.DestinationPath != "" && config.DestinationPath != "/" {
destPath = fmt.Sprintf("dest_%d:%s/%s", config.ID, config.DestBucket, config.DestinationPath)
}
} else {
destPath = fmt.Sprintf("dest_%d:%s", config.ID, config.DestinationPath)
}
// Add appropriate paths based on command type
switch cmdType {
case "transfer":
// Directory-based transfers and file-specific transfers handled here
args = append(args, sourcePath, destPath)
case "maintenance":
// Check command needs both source and destination, others may just need source
if cmdName == "check" {
args = append(args, sourcePath, destPath)
} else {
args = append(args, sourcePath)
}
case "listing":
// Listing commands only need source path
args = append(args, sourcePath)
case "info":
// Info commands typically need only source path
args = append(args, sourcePath)
case "directory":
// Directory operations might need one or both paths depending on operation
if cmdName == "rmdirs" && strings.Contains(config.RcloneFlags, "--dst") {
// Special case: rmdirs with --dst flag needs both paths
args = append(args, sourcePath, destPath)
} else {
// Default case: just source path
args = append(args, sourcePath)
}
case "destructive":
// Destructive commands only need source path
args = append(args, sourcePath)
case "special":
// Special commands handled case by case
if cmdName == "cryptcheck" {
args = append(args, sourcePath, destPath)
} else if cmdName == "obscure" || cmdName == "version" || cmdName == "listremotes" {
// These commands don't need paths at all
} else {
args = append(args, sourcePath)
}
default:
// Default to source path only
args = append(args, sourcePath)
}
// Execute the command
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
s.log.LogDebug("Full command: %s %v", rclonePath, args)
cmd := exec.Command(rclonePath, args...)
// Capture output
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// Start timer for operation
startTime := time.Now()
// Run the command
err := cmd.Run()
// Calculate duration
duration := time.Since(startTime)
// Update history with basic info
history.EndTime = &time.Time{}
*history.EndTime = startTime.Add(duration)
// Check for pattern in stderr that indicates successful completion with warnings
// Some commands like sync may complete successfully but with warnings
successWithWarnings := strings.Contains(stderr.String(), "Transferred:") &&
strings.Contains(stderr.String(), "Errors:") &&
strings.Contains(stderr.String(), "Checks:")
// Process results
if err != nil && !successWithWarnings {
s.log.LogError("Error executing command '%s' for job %d, config %d: %v", cmdName, job.ID, config.ID, err)
s.log.LogError("Command stderr: %s", stderr.String())
history.Status = "failed"
history.ErrorMessage = fmt.Sprintf("Command Error: %v\nStderr: %s", err, stderr.String())
} else {
s.log.LogInfo("Successfully executed command '%s' for job %d, config %d (duration: %v)",
cmdName, job.ID, config.ID, duration)
// Handle different command output types
if cmdType == "listing" {
// For listing commands, count the number of lines in the output as "files processed"
lines := strings.Count(stdout.String(), "\n")
history.FilesTransferred = lines
history.Status = "completed"
} else if cmdType == "transfer" {
// Try to extract transfer statistics from command output
history.Status = "completed"
// Look for metrics in stderr which is where rclone puts stats
// Extract bytes transferred if available
bytesRegex := regexp.MustCompile(`Transferred:\s+(\d+)\s+/\s+(\d+)\s+Bytes`)
if matches := bytesRegex.FindStringSubmatch(stderr.String()); len(matches) >= 3 {
if bytesTransferred, err := strconv.ParseInt(matches[1], 10, 64); err == nil {
history.BytesTransferred = bytesTransferred
}
}
// Extract files transferred if available
filesRegex := regexp.MustCompile(`Transferred:\s+(\d+)\s+/\s+(\d+)\s+Files`)
if matches := filesRegex.FindStringSubmatch(stderr.String()); len(matches) >= 3 {
if filesTransferred, err := strconv.Atoi(matches[1]); err == nil {
history.FilesTransferred = filesTransferred
}
}
} else {
// For other commands, we don't have file counts, but the command completed
history.Status = "completed"
}
// Store command output in the history for reference
if cmdType == "listing" || cmdType == "info" {
// For listing and info commands, the output is the result
// Limit to first 1000 characters to avoid huge entries
output := stdout.String()
if len(output) > 1000 {
output = output[:997] + "..."
}
history.ErrorMessage = fmt.Sprintf("Command Output:\n%s", output)
}
}
// Update job history in the database
if err := s.db.UpdateJobHistory(history); err != nil {
s.log.LogError("Error updating job history for job %d, config %d: %v", job.ID, config.ID, err)
}
// Send webhook notification
s.sendWebhookNotification(&job, history, &config)
}
+107 -87
View File
@@ -1,9 +1,11 @@
package handlers package handlers
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -120,6 +122,41 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
sourceIncludeArchivedValue := sourceIncludeArchivedVal == "on" || sourceIncludeArchivedVal == "true" sourceIncludeArchivedValue := sourceIncludeArchivedVal == "on" || sourceIncludeArchivedVal == "true"
config.SourceIncludeArchived = &sourceIncludeArchivedValue config.SourceIncludeArchived = &sourceIncludeArchivedValue
// Get command_id and validate it
commandIDStr := c.Request.FormValue("command_id")
if commandIDStr != "" {
commandID, err := strconv.ParseUint(commandIDStr, 10, 64)
if err != nil {
log.Printf("Error parsing command ID: %v", err)
} else {
config.CommandID = uint(commandID)
}
} else {
// Default to copy command (ID 1)
config.CommandID = 1
}
// Get command_flags and store as JSON
commandFlags := c.PostFormArray("command_flags")
if len(commandFlags) > 0 {
flagIDs := make([]uint, 0, len(commandFlags))
for _, flagStr := range commandFlags {
flagID, err := strconv.ParseUint(flagStr, 10, 64)
if err != nil {
log.Printf("Error parsing flag ID: %v", err)
continue
}
flagIDs = append(flagIDs, uint(flagID))
}
flagsJSON, err := json.Marshal(flagIDs)
if err != nil {
log.Printf("Error marshaling flag IDs: %v", err)
} else {
config.CommandFlags = string(flagsJSON)
}
}
// Process builtin auth settings
useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source") useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source")
useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true" useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true"
config.UseBuiltinAuthSource = &useBuiltinAuthSourceValue config.UseBuiltinAuthSource = &useBuiltinAuthSourceValue
@@ -191,38 +228,49 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
c.Redirect(http.StatusFound, "/configs") c.Redirect(http.StatusFound, "/configs")
} }
// HandleUpdateConfig handles the PUT /configs/:id route // HandleUpdateConfig handles the POST /configs/:id route
func (h *Handlers) HandleUpdateConfig(c *gin.Context) { func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
id := c.Param("id") idStr := c.Param("id")
userID := c.GetUint("userID") id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
var config db.TransferConfig c.String(http.StatusBadRequest, fmt.Sprintf("Invalid ID: %v", err))
if err := h.DB.First(&config, id).Error; err != nil {
log.Printf("Error finding config: %v", err)
c.String(http.StatusNotFound, "Config not found")
return return
} }
// Check if user owns this config existingConfig, err := h.DB.GetTransferConfig(uint(id))
if config.CreatedBy != userID { if err != nil {
// Check if user is admin c.String(http.StatusInternalServerError, fmt.Sprintf("Error getting config: %v", err))
return
}
if existingConfig == nil {
c.String(http.StatusNotFound, "Configuration not found")
return
}
// Check if the user has permission to edit this config
userID := c.GetUint("userID")
if existingConfig.CreatedBy != userID {
isAdmin, exists := c.Get("isAdmin") isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true { if !exists || isAdmin != true {
c.String(http.StatusForbidden, "You do not have permission to update this config") c.String(http.StatusForbidden, "You don't have permission to edit this configuration")
return return
} }
} }
// Get the old config values for comparison var config db.TransferConfig
oldConfig := config
// Bind form data to config
if err := c.ShouldBind(&config); err != nil { if err := c.ShouldBind(&config); err != nil {
log.Printf("Error binding config form: %v", err) log.Printf("Error binding config form: %v", err)
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err)) c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
return return
} }
// Preserve the original creator ID and creation time
config.ID = existingConfig.ID
config.CreatedBy = existingConfig.CreatedBy
config.CreatedAt = existingConfig.CreatedAt
// Process Boolean fields // Process Boolean fields
skipProcessedVal := c.Request.FormValue("skip_processed_files") skipProcessedVal := c.Request.FormValue("skip_processed_files")
skipProcessedValue := skipProcessedVal == "on" || skipProcessedVal == "true" skipProcessedValue := skipProcessedVal == "on" || skipProcessedVal == "true"
@@ -261,6 +309,41 @@ func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
sourceIncludeArchivedValue := sourceIncludeArchivedVal == "on" || sourceIncludeArchivedVal == "true" sourceIncludeArchivedValue := sourceIncludeArchivedVal == "on" || sourceIncludeArchivedVal == "true"
config.SourceIncludeArchived = &sourceIncludeArchivedValue config.SourceIncludeArchived = &sourceIncludeArchivedValue
// Get command_id and validate it
commandIDStr := c.Request.FormValue("command_id")
if commandIDStr != "" {
commandID, err := strconv.ParseUint(commandIDStr, 10, 64)
if err != nil {
log.Printf("Error parsing command ID: %v", err)
} else {
config.CommandID = uint(commandID)
}
} else {
// Default to copy command (ID 1)
config.CommandID = 1
}
// Get command_flags and store as JSON
commandFlags := c.PostFormArray("command_flags")
if len(commandFlags) > 0 {
flagIDs := make([]uint, 0, len(commandFlags))
for _, flagStr := range commandFlags {
flagID, err := strconv.ParseUint(flagStr, 10, 64)
if err != nil {
log.Printf("Error parsing flag ID: %v", err)
continue
}
flagIDs = append(flagIDs, uint(flagID))
}
flagsJSON, err := json.Marshal(flagIDs)
if err != nil {
log.Printf("Error marshaling flag IDs: %v", err)
} else {
config.CommandFlags = string(flagsJSON)
}
}
// Process builtin auth settings
useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source") useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source")
useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true" useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true"
config.UseBuiltinAuthSource = &useBuiltinAuthSourceValue config.UseBuiltinAuthSource = &useBuiltinAuthSourceValue
@@ -269,82 +352,19 @@ func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
useBuiltinAuthDestValue := useBuiltinAuthDestVal == "on" || useBuiltinAuthDestVal == "true" useBuiltinAuthDestValue := useBuiltinAuthDestVal == "on" || useBuiltinAuthDestVal == "true"
config.UseBuiltinAuthDest = &useBuiltinAuthDestValue config.UseBuiltinAuthDest = &useBuiltinAuthDestValue
// Preserve fields that shouldn't be updated // Preserve the Google Drive authentication status if it's already authenticated
config.CreatedBy = oldConfig.CreatedBy config.GoogleDriveAuthenticated = existingConfig.GoogleDriveAuthenticated
// Start a transaction // Update the LastUpdated timestamp
tx := h.DB.Begin() config.UpdatedAt = time.Now()
if tx.Error != nil {
log.Printf("Error beginning transaction: %v", tx.Error) if err := h.DB.UpdateTransferConfig(&config); err != nil {
c.String(http.StatusInternalServerError, "Failed to begin transaction") c.String(http.StatusInternalServerError, fmt.Sprintf("Error updating configuration: %v", err))
return return
} }
if err := tx.Save(&config).Error; err != nil { // Redirect to the configs page
tx.Rollback() c.Redirect(http.StatusSeeOther, "/configs")
log.Printf("Error updating config: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update config: %v", err))
return
}
// Create audit log entry
auditDetails := map[string]interface{}{
"name": config.Name,
"source_type": config.SourceType,
"dest_type": config.DestinationType,
"source_path": config.SourcePath,
"dest_path": config.DestinationPath,
"skip_processed_files": *config.SkipProcessedFiles,
"archive_enabled": *config.ArchiveEnabled,
"delete_after_transfer": *config.DeleteAfterTransfer,
"source_passive_mode": *config.SourcePassiveMode,
"dest_passive_mode": *config.DestPassiveMode,
"previous_state": map[string]interface{}{
"name": oldConfig.Name,
"source_type": oldConfig.SourceType,
"dest_type": oldConfig.DestinationType,
"source_path": oldConfig.SourcePath,
"dest_path": oldConfig.DestinationPath,
"skip_processed_files": *oldConfig.SkipProcessedFiles,
"archive_enabled": *oldConfig.ArchiveEnabled,
"delete_after_transfer": *oldConfig.DeleteAfterTransfer,
"source_passive_mode": *oldConfig.SourcePassiveMode,
"dest_passive_mode": *oldConfig.DestPassiveMode,
},
}
auditLog := db.AuditLog{
Action: "update",
EntityType: "config",
EntityID: config.ID,
UserID: userID,
Details: auditDetails,
Timestamp: time.Now(),
}
if err := tx.Create(&auditLog).Error; err != nil {
tx.Rollback()
log.Printf("Error creating audit log: %v", err)
c.String(http.StatusInternalServerError, "Failed to create audit log")
return
}
// Commit the transaction
if err := tx.Commit().Error; err != nil {
log.Printf("Error committing transaction: %v", err)
c.String(http.StatusInternalServerError, "Failed to commit transaction")
return
}
// Regenerate rclone config file
if err := h.DB.GenerateRcloneConfig(&config); err != nil {
log.Printf("Warning: Failed to regenerate rclone config: %v", err)
// Continue anyway, as the config was updated in the database
} else {
log.Printf("Regenerated rclone config for config ID %d", config.ID)
}
c.Redirect(http.StatusFound, "/configs")
} }
// HandleDeleteConfig handles the DELETE /configs/:id route // HandleDeleteConfig handles the DELETE /configs/:id route
+107
View File
@@ -0,0 +1,107 @@
package handlers
import (
"html/template"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components/providers/common"
"github.com/starfleetcptn/gomft/internal/db"
)
// RcloneHandler contains handlers for rclone-related routes
type RcloneHandler struct {
DB *db.DB
}
// NewRcloneHandler creates a new RcloneHandler
func NewRcloneHandler(db *db.DB) *RcloneHandler {
return &RcloneHandler{
DB: db,
}
}
// RcloneCommandOptions renders the rclone command options for the config form
func (h *RcloneHandler) RcloneCommandOptions(c *gin.Context) {
commands, err := h.DB.GetRcloneCommands()
if err != nil {
log.Printf("Error getting rclone commands: %v", err)
c.String(http.StatusInternalServerError, "Error getting rclone commands")
return
}
// Group commands by category
categories, err := h.DB.GetRcloneCategories()
if err != nil {
log.Printf("Error getting rclone categories: %v", err)
c.String(http.StatusInternalServerError, "Error getting rclone categories")
return
}
// Create a map of category -> commands
categoryMap := make(map[string][]db.RcloneCommand)
for _, cmd := range commands {
categoryMap[cmd.Category] = append(categoryMap[cmd.Category], cmd)
}
_ = common.RcloneCommandOptionsContent(categoryMap, categories).Render(c.Request.Context(), c.Writer)
}
// RcloneCommandFlags renders the rclone command flags for the selected command
func (h *RcloneHandler) RcloneCommandFlags(c *gin.Context) {
commandIDStr := c.DefaultQuery("command_id", "")
if commandIDStr == "" {
c.String(http.StatusBadRequest, "Command ID is required")
return
}
commandID, err := strconv.ParseUint(commandIDStr, 10, 64)
if err != nil {
log.Printf("Error parsing command ID: %v", err)
c.String(http.StatusBadRequest, "Invalid command ID")
return
}
command, err := h.DB.GetRcloneCommandWithFlags(uint(commandID))
if err != nil {
log.Printf("Error getting rclone command flags: %v", err)
c.String(http.StatusInternalServerError, "Error getting rclone command flags")
return
}
if command == nil {
c.String(http.StatusNotFound, "Command not found")
return
}
_ = common.RcloneCommandFlagsContent(command).Render(c.Request.Context(), c.Writer)
}
// RcloneCommandUsage renders the usage information for a command
func (h *RcloneHandler) RcloneCommandUsage(c *gin.Context) {
commandIDStr := c.Param("id")
if commandIDStr == "" {
c.String(http.StatusBadRequest, "Command ID is required")
return
}
commandID, err := strconv.ParseUint(commandIDStr, 10, 64)
if err != nil {
log.Printf("Error parsing command ID: %v", err)
c.String(http.StatusBadRequest, "Invalid command ID")
return
}
usage, err := h.DB.GetRcloneCommandUsage(uint(commandID))
if err != nil {
log.Printf("Error getting rclone command usage: %v", err)
c.String(http.StatusInternalServerError, "Error getting rclone command usage")
return
}
c.HTML(http.StatusOK, "command_usage.html", gin.H{
"Usage": template.HTML(usage),
})
}
+6
View File
@@ -61,6 +61,12 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.GET("/configs/gdrive-callback", h.HandleGDriveAuthCallback) authorized.GET("/configs/gdrive-callback", h.HandleGDriveAuthCallback)
authorized.GET("/configs/gdrive-token", h.HandleGDriveTokenProcess) authorized.GET("/configs/gdrive-token", h.HandleGDriveTokenProcess)
// Duplicate rclone endpoints for web interface to access - same path as API
rcloneHandler := NewRcloneHandler(h.DB)
authorized.GET("api/rclone/commands", func(c *gin.Context) { rcloneHandler.RcloneCommandOptions(c) })
authorized.GET("api/rclone/command-flags", func(c *gin.Context) { rcloneHandler.RcloneCommandFlags(c) })
authorized.GET("api/rclone/command/:id/usage", func(c *gin.Context) { rcloneHandler.RcloneCommandUsage(c) })
authorized.GET("/jobs", h.HandleJobs) authorized.GET("/jobs", h.HandleJobs)
authorized.GET("/jobs/new", h.HandleNewJob) authorized.GET("/jobs/new", h.HandleNewJob)
authorized.GET("/jobs/:id", h.HandleEditJob) authorized.GET("/jobs/:id", h.HandleEditJob)