diff --git a/components/config_form.templ b/components/config_form.templ
index ff377fe..90e685f 100644
--- a/components/config_form.templ
+++ b/components/config_form.templ
@@ -457,6 +457,29 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
// Initialize command requirements
updateCommandRequirements();
+
+ // Initialize hidden input fields with their values to ensure they're included in form submission
+ document.getElementById('hidden_name').value = name;
+ document.getElementById('hidden_source_path').value = sourcePath;
+ document.getElementById('hidden_destination_path').value = destinationPath;
+
+ // Initialize S3 source fields if applicable
+ if (sourceType === 's3' || sourceType === 'b2' || sourceType === 'wasabi' || sourceType === 'minio') {
+ document.getElementById('hidden_source_access_key').value = sourceAccessKey;
+ document.getElementById('hidden_source_secret_key').value = sourceSecretKey;
+ document.getElementById('hidden_source_endpoint').value = sourceEndpoint;
+ document.getElementById('hidden_source_bucket').value = sourceBucket;
+ document.getElementById('hidden_source_region').value = sourceRegion;
+ }
+
+ // Initialize S3 destination fields if applicable
+ if (destinationType === 's3' || destinationType === 'b2' || destinationType === 'wasabi' || destinationType === 'minio') {
+ document.getElementById('hidden_dest_access_key').value = destAccessKey;
+ document.getElementById('hidden_dest_secret_key').value = destSecretKey;
+ document.getElementById('hidden_dest_endpoint').value = destEndpoint;
+ document.getElementById('hidden_dest_bucket').value = destBucket;
+ document.getElementById('hidden_dest_region').value = destRegion;
+ }
})"
x-effect="if (sourceType === 'sftp' && (sourcePort === 0 || sourcePort === 21 || sourcePort === 23)) {
sourcePort = 22;
@@ -478,8 +501,50 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
console.log('Updating destination port to 23 for Hetzner');
}"
@formvalidation
+ @submit="
+ // Basic fields
+ document.getElementById('hidden_name').value = name;
+ document.getElementById('hidden_source_path').value = sourcePath;
+ document.getElementById('hidden_destination_path').value = destinationPath;
+
+ // S3 source fields
+ if (sourceType === 's3' || sourceType === 'b2' || sourceType === 'wasabi' || sourceType === 'minio') {
+ document.getElementById('hidden_source_access_key').value = sourceAccessKey;
+ document.getElementById('hidden_source_secret_key').value = sourceSecretKey;
+ document.getElementById('hidden_source_endpoint').value = sourceEndpoint;
+ document.getElementById('hidden_source_bucket').value = sourceBucket;
+ document.getElementById('hidden_source_region').value = sourceRegion;
+ }
+
+ // S3 destination fields
+ if (destinationType === 's3' || destinationType === 'b2' || destinationType === 'wasabi' || destinationType === 'minio') {
+ document.getElementById('hidden_dest_access_key').value = destAccessKey;
+ document.getElementById('hidden_dest_secret_key').value = destSecretKey;
+ document.getElementById('hidden_dest_endpoint').value = destEndpoint;
+ document.getElementById('hidden_dest_bucket').value = destBucket;
+ document.getElementById('hidden_dest_region').value = destRegion;
+ }
+ "
>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -510,6 +575,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
id="name"
name="name"
x-model="name"
+ @input="document.getElementById('hidden_name').value = name"
required
aria-required="true"
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 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"
@@ -645,6 +711,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
id="source_path"
name="source_path"
x-model="sourcePath"
+ @input="document.getElementById('hidden_source_path').value = sourcePath"
@blur="checkPath(sourcePath, 'source')"
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"
placeholder="/path/to/source"
@@ -775,6 +842,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
id="destination_path"
name="destination_path"
x-model="destinationPath"
+ @input="document.getElementById('hidden_destination_path').value = destinationPath"
@blur="checkPath(destinationPath, 'dest')"
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"
placeholder="/path/to/destination"
diff --git a/components/storage_provider_form.templ b/components/storage_provider_form.templ
index 83ec726..6134dd1 100644
--- a/components/storage_provider_form.templ
+++ b/components/storage_provider_form.templ
@@ -161,12 +161,12 @@ templ formFields(data StorageProviderFormData) {
selected="selected"
}
>OneDrive
-
-
+
+
+
+
+
+
+
+
+
+
+
}
// JavaScript helper for toggling provider fields
@@ -369,6 +408,133 @@ templ providerFormScript() {
// Mark the field as user-modified
this.dataset.userModified = 'true';
});
+
+ // Add event listeners for S3 fields
+ document.getElementById('endpoint').addEventListener('input', function() {
+ document.getElementById('hidden_endpoint').value = this.value;
+ });
+
+ document.getElementById('region').addEventListener('input', function() {
+ document.getElementById('hidden_region').value = this.value;
+ });
+
+ document.getElementById('bucket').addEventListener('input', function() {
+ document.getElementById('hidden_bucket').value = this.value;
+ });
+
+ document.getElementById('accessKey').addEventListener('input', function() {
+ document.getElementById('hidden_accessKey').value = this.value;
+ });
+
+ document.getElementById('secretKey').addEventListener('input', function() {
+ document.getElementById('hidden_secretKey').value = this.value;
+ });
+
+ // Add event listeners for Google Drive/Photos fields
+ document.getElementById('clientID').addEventListener('input', function() {
+ document.getElementById('hidden_clientID').value = this.value;
+ });
+
+ document.getElementById('clientSecret').addEventListener('input', function() {
+ document.getElementById('hidden_clientSecret').value = this.value;
+ });
+
+ // Add form submit listener to ensure all hidden fields are populated
+ const forms = document.querySelectorAll('form');
+ forms.forEach(form => {
+ form.addEventListener('submit', function(e) {
+ // Find active provider type
+ const providerType = document.getElementById('type').value;
+
+ // If S3-compatible, update hidden fields
+ if (['s3', 'wasabi', 'minio', 'b2'].includes(providerType)) {
+ document.getElementById('hidden_endpoint').value = document.getElementById('endpoint').value;
+ document.getElementById('hidden_region').value = document.getElementById('region').value;
+ document.getElementById('hidden_bucket').value = document.getElementById('bucket').value;
+ document.getElementById('hidden_accessKey').value = document.getElementById('accessKey').value;
+
+ // Make sure secretKey is always copied to the hidden field
+ // This is especially important for B2 which uses this as Application Key
+ const secretKeyValue = document.getElementById('secretKey').value;
+ document.getElementById('hidden_secretKey').value = secretKeyValue;
+
+ // For validation - ensure we have appropriate fields for each provider type
+ if (providerType === 'b2') {
+ // B2 does not require region or endpoint
+ if (!document.getElementById('hidden_bucket').value) {
+ alert('Bucket name is required');
+ e.preventDefault();
+ return false;
+ }
+ if (!document.getElementById('hidden_accessKey').value) {
+ alert('Account ID is required');
+ e.preventDefault();
+ return false;
+ }
+ if (!secretKeyValue && !document.getElementById('hidden_secretKey').value) {
+ alert('Application Key is required');
+ e.preventDefault();
+ return false;
+ }
+ } else if (providerType === 'wasabi') {
+ // Wasabi does not require region
+ if (!document.getElementById('hidden_endpoint').value) {
+ alert('Endpoint is required for Wasabi');
+ e.preventDefault();
+ return false;
+ }
+ if (!document.getElementById('hidden_bucket').value) {
+ alert('Bucket name is required');
+ e.preventDefault();
+ return false;
+ }
+ if (!document.getElementById('hidden_accessKey').value) {
+ alert('Access key is required');
+ e.preventDefault();
+ return false;
+ }
+ if (!secretKeyValue && !document.getElementById('hidden_secretKey').value) {
+ alert('Secret key is required');
+ e.preventDefault();
+ return false;
+ }
+ }
+
+ console.log('Submitting S3-compatible form with:', {
+ provider: providerType,
+ endpoint: document.getElementById('hidden_endpoint').value,
+ region: document.getElementById('hidden_region').value,
+ bucket: document.getElementById('hidden_bucket').value,
+ accessKey: document.getElementById('hidden_accessKey').value,
+ secretKey: document.getElementById('hidden_secretKey').value ? '[PRESENT]' : '[EMPTY]',
+ secretKeyLength: document.getElementById('hidden_secretKey').value.length
+ });
+ }
+
+ // If Google Drive or Google Photos, update hidden fields
+ if (['google_drive', 'google_photo', 'onedrive'].includes(providerType)) {
+ document.getElementById('hidden_clientID').value = document.getElementById('clientID').value;
+
+ // Make sure clientSecret is always copied to the hidden field
+ const clientSecretValue = document.getElementById('clientSecret').value;
+ document.getElementById('hidden_clientSecret').value = clientSecretValue;
+
+ // Validate required fields
+ if (!document.getElementById('hidden_clientID').value) {
+ alert('Client ID is required');
+ e.preventDefault();
+ return false;
+ }
+
+ console.log('Submitting cloud storage form with:', {
+ provider: providerType,
+ clientID: document.getElementById('hidden_clientID').value,
+ clientSecret: document.getElementById('hidden_clientSecret').value ? '[PRESENT]' : '[EMPTY]',
+ clientSecretLength: document.getElementById('hidden_clientSecret').value.length
+ });
+ }
+ });
+ });
});
function toggleProviderFields() {
@@ -437,20 +603,48 @@ templ providerFormScript() {
// S3-compatible storage
if (['s3', 'wasabi', 'minio', 'b2'].includes(provider)) {
document.getElementById('s3-fields').classList.remove('hidden');
+
+ // Set region and endpoint requirements based on provider type
+ const regionRequired = document.getElementById('region-required');
+ const endpointRequired = document.getElementById('endpoint-required');
+ const regionInput = document.getElementById('region');
+ const endpointInput = document.getElementById('endpoint');
+
+ // For B2: endpoint and region are optional
+ if (provider === 'b2') {
+ regionRequired.style.display = 'none';
+ endpointRequired.style.display = 'none';
+ regionInput.removeAttribute('required');
+ endpointInput.removeAttribute('required');
+ }
+ // For Wasabi: region is optional
+ else if (provider === 'wasabi') {
+ regionRequired.style.display = 'none';
+ endpointRequired.style.display = 'inline';
+ regionInput.removeAttribute('required');
+ endpointInput.setAttribute('required', 'required');
+ }
+ // For S3 and MinIO: both are required
+ else {
+ regionRequired.style.display = 'inline';
+ endpointRequired.style.display = 'inline';
+ regionInput.setAttribute('required', 'required');
+ endpointInput.setAttribute('required', 'required');
+ }
}
// Google services
- if (['gdrive', 'gphotos'].includes(provider)) {
+ if (['google_drive', 'google_photo'].includes(provider)) {
document.getElementById('cloud-fields').classList.remove('hidden');
// Google Drive specific fields
- if (provider === 'gdrive') {
+ if (provider === 'google_drive') {
document.getElementById('drive-id-field').classList.remove('hidden');
document.getElementById('team-drive-field').classList.remove('hidden');
}
// Google Photos specific fields
- if (provider === 'gphotos') {
+ if (provider === 'google_photo') {
document.getElementById('readonly-field').classList.remove('hidden');
}
}
diff --git a/internal/db/storage_provider.go b/internal/db/storage_provider.go
index 0419748..243cbf6 100644
--- a/internal/db/storage_provider.go
+++ b/internal/db/storage_provider.go
@@ -169,9 +169,7 @@ func (sp *StorageProvider) SetAuthenticated(value bool) {
func (sp *StorageProvider) IsOAuthProvider() bool {
return sp.Type == ProviderTypeOneDrive ||
sp.Type == ProviderTypeGoogleDrive ||
- sp.Type == ProviderTypeGooglePhoto ||
- sp.Type == ProviderTypeWebDAV ||
- sp.Type == ProviderTypeNextcloud
+ sp.Type == ProviderTypeGooglePhoto
}
// RequiresEncryption returns true if the provider has sensitive fields that need encryption
diff --git a/internal/db/storage_provider_validator.go b/internal/db/storage_provider_validator.go
index a96f74b..141ebf7 100644
--- a/internal/db/storage_provider_validator.go
+++ b/internal/db/storage_provider_validator.go
@@ -30,6 +30,8 @@ func (sp *StorageProvider) Validate() error {
return sp.validateS3()
case ProviderTypeFTP:
return sp.validateFTP()
+ case ProviderTypeWebDAV, ProviderTypeNextcloud:
+ return sp.validateWebDAV()
case ProviderTypeSMB:
return sp.validateSMB()
case ProviderTypeOneDrive:
@@ -67,6 +69,24 @@ func (sp *StorageProvider) validateSFTP() error {
return nil
}
+// validateWebDAV validates WebDAV-specific fields
+func (sp *StorageProvider) validateWebDAV() error {
+ if strings.TrimSpace(sp.Host) == "" {
+ return errors.New("host is required for WebDAV provider")
+ }
+
+ if strings.TrimSpace(sp.Username) == "" {
+ return errors.New("username is required for WebDAV provider")
+ }
+
+ // Either password or encrypted password must be provided
+ if strings.TrimSpace(sp.Password) == "" && strings.TrimSpace(sp.EncryptedPassword) == "" {
+ return errors.New("password is required for WebDAV provider")
+ }
+
+ return nil
+}
+
// validateS3 validates S3-specific fields
func (sp *StorageProvider) validateS3() error {
// For S3, either AccessKey or Username is used
@@ -115,10 +135,6 @@ func (sp *StorageProvider) validateSMB() error {
return errors.New("host is required for SMB provider")
}
- if strings.TrimSpace(sp.Share) == "" {
- return errors.New("share is required for SMB provider")
- }
-
if strings.TrimSpace(sp.Username) == "" {
return errors.New("username is required for SMB provider")
}
diff --git a/internal/db/transfer_config_store.go b/internal/db/transfer_config_store.go
index 9abca38..ec2f169 100644
--- a/internal/db/transfer_config_store.go
+++ b/internal/db/transfer_config_store.go
@@ -191,6 +191,56 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config (sftp): %v\nOutput: %s", err, output)
}
+ case "smb":
+ args := []string{
+ "config", "create", sourceName, "smb",
+ "host", getStringValue(sourceCredentials, "host", config.SourceHost),
+ "user", getStringValue(sourceCredentials, "username", config.SourceUser),
+ "--non-interactive",
+ "--config", configPath,
+ "--log-level", "ERROR",
+ }
+
+ // Get domain if provided
+ domain := getStringValue(sourceCredentials, "domain", config.SourceDomain)
+ if domain != "" {
+ args = append(args, "domain", domain)
+ }
+
+ // Get port if specified (default is 445)
+ port := getIntValue(sourceCredentials, "port", config.SourcePort)
+ if port > 0 && port != 445 {
+ args = append(args, "port", fmt.Sprintf("%d", port))
+ }
+
+ // Get share if provided
+ share := getStringValue(sourceCredentials, "share", config.SourceShare)
+ if share != "" {
+ args = append(args, "share", share)
+ }
+
+ // Handle password
+ password := ""
+ if config.SourcePassword != "" {
+ password = config.SourcePassword
+ } else if encryptedPwd, ok := sourceCredentials["encrypted_password"].(string); ok && encryptedPwd != "" {
+ decryptedPwd, err := db.DecryptCredential(encryptedPwd)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt source password: %v", err)
+ }
+ password = decryptedPwd
+ } else if pwVal, ok := sourceCredentials["password"].(string); ok && pwVal != "" {
+ password = pwVal
+ }
+
+ if password != "" {
+ args = append(args, "pass", password)
+ }
+
+ cmd := exec.Command(rclonePath, args...)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("failed to create source config (smb): %v\nOutput: %s", err, output)
+ }
case "s3":
args := []string{
"config", "create", sourceName, "s3",
@@ -269,12 +319,36 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
case "b2":
args := []string{
"config", "create", sourceName, "b2",
- "account", config.SourceAccessKey, // B2 Account ID
- "key", config.SourceSecretKey, // B2 Application Key
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
+ // Handle account ID (access key)
+ accountID := getStringValue(sourceCredentials, "access_key", config.SourceAccessKey)
+ if accountID != "" {
+ args = append(args, "account", accountID)
+ }
+
+ // Handle secret key with proper decryption if from provider
+ secretKey := ""
+ if config.SourceSecretKey != "" {
+ // Direct input from form (transient)
+ secretKey = config.SourceSecretKey
+ } else if encryptedSecret, ok := sourceCredentials["encrypted_secret_key"].(string); ok && encryptedSecret != "" {
+ // Provider reference with encrypted secret
+ decryptedSecret, err := db.DecryptCredential(encryptedSecret)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt source secret key: %v", err)
+ }
+ secretKey = decryptedSecret
+ } else if secretVal, ok := sourceCredentials["secret_key"].(string); ok && secretVal != "" {
+ // Backward compatibility
+ secretKey = secretVal
+ }
+
+ if secretKey != "" {
+ args = append(args, "key", secretKey)
+ }
if config.SourceEndpoint != "" {
args = append(args, "endpoint", config.SourceEndpoint)
}
@@ -288,12 +362,32 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
"provider", "Minio",
"env_auth", "false",
"access_key_id", getStringValue(sourceCredentials, "access_key", config.SourceAccessKey),
- "secret_access_key", getStringOrDefault(sourceCredentials, "secret_key", config.SourceSecretKey),
"endpoint", getStringValue(sourceCredentials, "endpoint", config.SourceEndpoint),
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
+ // Handle secret key with proper decryption if from provider
+ secretKey := ""
+ if config.SourceSecretKey != "" {
+ // Direct input from form (transient)
+ secretKey = config.SourceSecretKey
+ } else if encryptedSecret, ok := sourceCredentials["encrypted_secret_key"].(string); ok && encryptedSecret != "" {
+ // Provider reference with encrypted secret
+ decryptedSecret, err := db.DecryptCredential(encryptedSecret)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt source secret key: %v", err)
+ }
+ secretKey = decryptedSecret
+ } else if secretVal, ok := sourceCredentials["secret_key"].(string); ok && secretVal != "" {
+ // Backward compatibility
+ secretKey = secretVal
+ }
+
+ if secretKey != "" {
+ args = append(args, "secret_access_key", secretKey)
+ }
+
// Add region if specified
if getStringValue(sourceCredentials, "region", config.SourceRegion) != "" {
args = append(args, "region", getStringValue(sourceCredentials, "region", config.SourceRegion))
@@ -413,19 +507,89 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config (sftp): %v\nOutput: %s", err, output)
}
+ case "smb":
+ args := []string{
+ "config", "create", destName, "smb",
+ "host", getStringValue(destCredentials, "host", config.DestHost),
+ "user", getStringValue(destCredentials, "username", config.DestUser),
+ "--non-interactive",
+ "--config", configPath,
+ "--log-level", "ERROR",
+ }
+
+ // Get domain if provided
+ domain := getStringValue(destCredentials, "domain", config.DestDomain)
+ if domain != "" {
+ args = append(args, "domain", domain)
+ }
+
+ // Get port if specified (default is 445)
+ port := getIntValue(destCredentials, "port", config.DestPort)
+ if port > 0 && port != 445 {
+ args = append(args, "port", fmt.Sprintf("%d", port))
+ }
+
+ // Get share if provided
+ share := getStringValue(destCredentials, "share", config.DestShare)
+ if share != "" {
+ args = append(args, "share", share)
+ }
+
+ // Handle password
+ password := ""
+ if config.DestPassword != "" {
+ password = config.DestPassword
+ } else if encryptedPwd, ok := destCredentials["encrypted_password"].(string); ok && encryptedPwd != "" {
+ decryptedPwd, err := db.DecryptCredential(encryptedPwd)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt destination password: %v", err)
+ }
+ password = decryptedPwd
+ } else if pwVal, ok := destCredentials["password"].(string); ok && pwVal != "" {
+ password = pwVal
+ }
+
+ if password != "" {
+ args = append(args, "pass", password)
+ }
+
+ cmd := exec.Command(rclonePath, args...)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("failed to create destination config (smb): %v\nOutput: %s", err, output)
+ }
case "s3":
args := []string{
"config", "create", destName, "s3",
"provider", "AWS", // Assuming AWS provider
"env_auth", "false",
"access_key_id", getStringValue(destCredentials, "access_key", config.DestAccessKey),
- "secret_access_key", getStringOrDefault(destCredentials, "secret_key", config.DestSecretKey),
"region", getStringValue(destCredentials, "region", config.DestRegion),
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
+ // Handle secret key with proper decryption if from provider
+ secretKey := ""
+ if config.DestSecretKey != "" {
+ // Direct input from form (transient)
+ secretKey = config.DestSecretKey
+ } else if encryptedSecret, ok := destCredentials["encrypted_secret_key"].(string); ok && encryptedSecret != "" {
+ // Provider reference with encrypted secret
+ decryptedSecret, err := db.DecryptCredential(encryptedSecret)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt destination secret key: %v", err)
+ }
+ secretKey = decryptedSecret
+ } else if secretVal, ok := destCredentials["secret_key"].(string); ok && secretVal != "" {
+ // Backward compatibility
+ secretKey = secretVal
+ }
+
+ if secretKey != "" {
+ args = append(args, "secret_access_key", secretKey)
+ }
+
endpoint := getStringValue(destCredentials, "endpoint", config.DestEndpoint)
if endpoint != "" {
args = append(args, "endpoint", endpoint)
@@ -441,13 +605,33 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
"provider", "Wasabi",
"env_auth", "false",
"access_key_id", getStringValue(destCredentials, "access_key", config.DestAccessKey),
- "secret_access_key", getStringOrDefault(destCredentials, "secret_key", config.DestSecretKey),
"region", getStringValue(destCredentials, "region", config.DestRegion),
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
+ // Handle secret key with proper decryption if from provider
+ secretKey := ""
+ if config.DestSecretKey != "" {
+ // Direct input from form (transient)
+ secretKey = config.DestSecretKey
+ } else if encryptedSecret, ok := destCredentials["encrypted_secret_key"].(string); ok && encryptedSecret != "" {
+ // Provider reference with encrypted secret
+ decryptedSecret, err := db.DecryptCredential(encryptedSecret)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt destination secret key: %v", err)
+ }
+ secretKey = decryptedSecret
+ } else if secretVal, ok := destCredentials["secret_key"].(string); ok && secretVal != "" {
+ // Backward compatibility
+ secretKey = secretVal
+ }
+
+ if secretKey != "" {
+ args = append(args, "secret_access_key", secretKey)
+ }
+
endpoint := getStringValue(destCredentials, "endpoint", config.DestEndpoint)
if endpoint == "" {
endpoint = "s3.wasabisys.com"
@@ -472,6 +656,27 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
args = append(args, "account", accountID)
}
+ // Handle secret key with proper decryption if from provider
+ secretKey := ""
+ if config.DestSecretKey != "" {
+ // Direct input from form (transient)
+ secretKey = config.DestSecretKey
+ } else if encryptedSecret, ok := destCredentials["encrypted_secret_key"].(string); ok && encryptedSecret != "" {
+ // Provider reference with encrypted secret
+ decryptedSecret, err := db.DecryptCredential(encryptedSecret)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt destination secret key: %v", err)
+ }
+ secretKey = decryptedSecret
+ } else if secretVal, ok := destCredentials["secret_key"].(string); ok && secretVal != "" {
+ // Backward compatibility
+ secretKey = secretVal
+ }
+
+ if secretKey != "" {
+ args = append(args, "key", secretKey)
+ }
+
// Handle application key (secret key) with proper decryption if from provider
appKey := ""
if config.DestSecretKey != "" {
@@ -508,12 +713,33 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
"provider", "Minio",
"env_auth", "false",
"access_key_id", getStringValue(destCredentials, "access_key", config.DestAccessKey),
- "secret_access_key", getStringOrDefault(destCredentials, "secret_key", config.DestSecretKey),
"endpoint", getStringValue(destCredentials, "endpoint", config.DestEndpoint),
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
+
+ // Handle secret key with proper decryption if from provider
+ secretKey := ""
+ if config.DestSecretKey != "" {
+ // Direct input from form (transient)
+ secretKey = config.DestSecretKey
+ } else if encryptedSecret, ok := destCredentials["encrypted_secret_key"].(string); ok && encryptedSecret != "" {
+ // Provider reference with encrypted secret
+ decryptedSecret, err := db.DecryptCredential(encryptedSecret)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt destination secret key: %v", err)
+ }
+ secretKey = decryptedSecret
+ } else if secretVal, ok := destCredentials["secret_key"].(string); ok && secretVal != "" {
+ // Backward compatibility
+ secretKey = secretVal
+ }
+
+ if secretKey != "" {
+ args = append(args, "secret_access_key", secretKey)
+ }
+
// Add region if specified
if getStringValue(destCredentials, "region", config.DestRegion) != "" {
args = append(args, "region", getStringValue(destCredentials, "region", config.DestRegion))
diff --git a/internal/storage/connector_service.go b/internal/storage/connector_service.go
index e496ae3..4320a02 100644
--- a/internal/storage/connector_service.go
+++ b/internal/storage/connector_service.go
@@ -104,7 +104,15 @@ func createTempTransferConfig(provider *db.StorageProvider) *db.TransferConfig {
config.SetSourcePassiveMode(passive)
}
- case db.ProviderTypeS3:
+ case db.ProviderTypeS3, db.ProviderTypeWasabi, db.ProviderTypeMinio:
+ config.SourceAccessKey = provider.AccessKey
+ config.SourceSecretKey = provider.SecretKey
+ config.SourceBucket = provider.Bucket
+ config.SourceRegion = provider.Region
+ config.SourceEndpoint = provider.Endpoint
+
+ case db.ProviderTypeB2:
+ // B2 uses AccessKey as account and SecretKey as application key
config.SourceAccessKey = provider.AccessKey
config.SourceSecretKey = provider.SecretKey
config.SourceBucket = provider.Bucket
@@ -162,13 +170,18 @@ func (s *ConnectorService) decryptProviderCredentials(provider *db.StorageProvid
provider.Password = password
}
- case db.ProviderTypeS3:
+ case db.ProviderTypeS3, db.ProviderTypeWasabi, db.ProviderTypeMinio, db.ProviderTypeB2:
if provider.EncryptedSecretKey != "" {
secretKey, err := s.credentialEncryptor.Decrypt(provider.EncryptedSecretKey)
if err != nil {
return fmt.Errorf("failed to decrypt secret key: %w", err)
}
provider.SecretKey = secretKey
+ log.Printf("DEBUG: Decrypted secret key for %s provider (ID: %d, Type: %s) with length: %d",
+ provider.Name, provider.ID, provider.Type, len(provider.SecretKey))
+ } else {
+ log.Printf("WARNING: No encrypted secret key found for %s provider (ID: %d, Type: %s)",
+ provider.Name, provider.ID, provider.Type)
}
case db.ProviderTypeOneDrive, db.ProviderTypeGoogleDrive, db.ProviderTypeGooglePhoto:
diff --git a/internal/web/handlers/config_handlers.go b/internal/web/handlers/config_handlers.go
index 2a1d407..79dec71 100644
--- a/internal/web/handlers/config_handlers.go
+++ b/internal/web/handlers/config_handlers.go
@@ -147,6 +147,112 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
userID := c.GetUint("userID")
config.CreatedBy = userID
+ // Explicitly set critical fields
+ config.Name = c.PostForm("name")
+ config.SourcePath = c.PostForm("source_path")
+ config.DestinationPath = c.PostForm("destination_path")
+ config.SourceType = c.PostForm("source_type")
+ config.DestinationType = c.PostForm("destination_type")
+
+ // Debug logs
+ log.Printf("DEBUG: Config data from form - Name: '%s', SourcePath: '%s', DestPath: '%s', SourceType: '%s', DestType: '%s'",
+ config.Name, config.SourcePath, config.DestinationPath, config.SourceType, config.DestinationType)
+
+ // Additional fields we need to explicitly set
+ if config.SourceType == "sftp" || config.SourceType == "ftp" || config.SourceType == "hetzner" {
+ config.SourceHost = c.PostForm("source_host")
+ portStr := c.PostForm("source_port")
+ if portStr != "" {
+ port, err := strconv.Atoi(portStr)
+ if err == nil {
+ config.SourcePort = port
+ }
+ }
+ config.SourceUser = c.PostForm("source_user")
+ config.SourcePassword = c.PostForm("source_password")
+ config.SourceKeyFile = c.PostForm("source_key_file")
+ }
+
+ if config.DestinationType == "sftp" || config.DestinationType == "ftp" || config.DestinationType == "hetzner" {
+ config.DestHost = c.PostForm("dest_host")
+ portStr := c.PostForm("dest_port")
+ if portStr != "" {
+ port, err := strconv.Atoi(portStr)
+ if err == nil {
+ config.DestPort = port
+ }
+ }
+ config.DestUser = c.PostForm("dest_user")
+ config.DestPassword = c.PostForm("dest_password")
+ config.DestKeyFile = c.PostForm("dest_key_file")
+ }
+
+ // S3 and similar providers
+ if config.SourceType == "s3" || config.SourceType == "wasabi" || config.SourceType == "minio" || config.SourceType == "b2" {
+ config.SourceBucket = c.PostForm("source_bucket")
+ config.SourceRegion = c.PostForm("source_region")
+ config.SourceAccessKey = c.PostForm("source_access_key")
+ config.SourceSecretKey = c.PostForm("source_secret_key")
+ config.SourceEndpoint = c.PostForm("source_endpoint")
+
+ // Debug logging for S3-compatible providers
+ log.Printf("DEBUG: S3-compatible source provider details - Type: %s, Bucket: %s, Region: %s, Endpoint: %s, Has Access Key: %t, Has Secret Key: %t",
+ config.SourceType,
+ config.SourceBucket,
+ config.SourceRegion,
+ config.SourceEndpoint,
+ config.SourceAccessKey != "",
+ config.SourceSecretKey != "")
+ }
+
+ if config.DestinationType == "s3" || config.DestinationType == "wasabi" || config.DestinationType == "minio" || config.DestinationType == "b2" {
+ config.DestBucket = c.PostForm("dest_bucket")
+ config.DestRegion = c.PostForm("dest_region")
+ config.DestAccessKey = c.PostForm("dest_access_key")
+ config.DestSecretKey = c.PostForm("dest_secret_key")
+ config.DestEndpoint = c.PostForm("dest_endpoint")
+
+ // Debug logging for S3-compatible providers
+ log.Printf("DEBUG: S3-compatible destination provider details - Type: %s, Bucket: %s, Region: %s, Endpoint: %s, Has Access Key: %t, Has Secret Key: %t",
+ config.DestinationType,
+ config.DestBucket,
+ config.DestRegion,
+ config.DestEndpoint,
+ config.DestAccessKey != "",
+ config.DestSecretKey != "")
+ }
+
+ // SMB specific fields
+ if config.SourceType == "smb" {
+ config.SourceShare = c.PostForm("source_share")
+ config.SourceDomain = c.PostForm("source_domain")
+ }
+
+ if config.DestinationType == "smb" {
+ config.DestShare = c.PostForm("dest_share")
+ config.DestDomain = c.PostForm("dest_domain")
+ }
+
+ // File pattern fields
+ config.FilePattern = c.PostForm("file_pattern")
+ config.OutputPattern = c.PostForm("output_pattern")
+
+ // Archive path
+ config.ArchivePath = c.PostForm("archive_path")
+
+ // Max concurrent transfers
+ maxConcurrentStr := c.PostForm("max_concurrent_transfers")
+ if maxConcurrentStr != "" {
+ maxConcurrent, err := strconv.Atoi(maxConcurrentStr)
+ if err == nil && maxConcurrent > 0 {
+ config.MaxConcurrentTransfers = maxConcurrent
+ } else {
+ config.MaxConcurrentTransfers = 4 // Default value
+ }
+ } else {
+ config.MaxConcurrentTransfers = 4 // Default value
+ }
+
// Process Boolean fields
skipProcessedVal := c.Request.FormValue("skip_processed_files")
skipProcessedValue := skipProcessedVal == "on" || skipProcessedVal == "true"
diff --git a/internal/web/handlers/storage_provider_handlers.go b/internal/web/handlers/storage_provider_handlers.go
index 0b4c76a..bcaf461 100644
--- a/internal/web/handlers/storage_provider_handlers.go
+++ b/internal/web/handlers/storage_provider_handlers.go
@@ -441,7 +441,12 @@ func (h *Handlers) parseProviderFromForm(c *gin.Context) (db.StorageProvider, er
return provider, fmt.Errorf("host is required")
}
- case db.ProviderTypeS3:
+ case db.ProviderTypeWebDAV, db.ProviderTypeNextcloud:
+ // WebDAV specific fields
+ provider.Username = c.PostForm("username")
+ provider.Password = c.PostForm("password")
+
+ case db.ProviderTypeS3, db.ProviderTypeWasabi, db.ProviderTypeMinio, db.ProviderTypeB2:
// S3 specific fields
provider.AccessKey = c.PostForm("accessKey")
provider.SecretKey = c.PostForm("secretKey")
@@ -458,8 +463,13 @@ func (h *Handlers) parseProviderFromForm(c *gin.Context) (db.StorageProvider, er
return provider, fmt.Errorf("bucket is required")
}
- if provider.Region == "" {
- return provider, fmt.Errorf("region is required")
+ // Validate region based on provider type
+ // B2 doesn't require region or endpoint
+ // Wasabi doesn't require region
+ if provider.Type == db.ProviderTypeS3 || provider.Type == db.ProviderTypeMinio {
+ if provider.Region == "" {
+ return provider, fmt.Errorf("region is required for %s", provider.Type)
+ }
}
case db.ProviderTypeOneDrive, db.ProviderTypeGoogleDrive, db.ProviderTypeGooglePhoto: