diff --git a/components/rclone_import_types.go b/components/rclone_import_types.go
new file mode 100644
index 0000000..d6cef76
--- /dev/null
+++ b/components/rclone_import_types.go
@@ -0,0 +1,13 @@
+package components
+
+type RcloneImportPreview struct {
+ Remotes []RcloneRemotePreview
+ Error string
+}
+
+type RcloneRemotePreview struct {
+ Name string
+ Type string
+ Fields map[string]string
+ Import bool // Should import
+}
diff --git a/components/storage_providers.templ b/components/storage_providers.templ
index c57390b..3da50c9 100644
--- a/components/storage_providers.templ
+++ b/components/storage_providers.templ
@@ -19,6 +19,14 @@ templ StorageProviders(ctx context.Context, data StorageProvidersData) {
@toast.Container()
@toast.ShowToastJS()
+
+
+
+
Info
+
+ Upload a config file to preview rclone remotes for import.
+
+
+ }
+}
diff --git a/components/storage_providers_import_page.templ b/components/storage_providers_import_page.templ
new file mode 100644
index 0000000..1c90d81
--- /dev/null
+++ b/components/storage_providers_import_page.templ
@@ -0,0 +1,12 @@
+package components
+
+import (
+ "context"
+)
+
+// StorageProvidersImportPage wraps the import component with the layout
+templ StorageProvidersImportPage(ctx context.Context, preview RcloneImportPreview) {
+ @LayoutWithContext("Import rclone Config", ctx) {
+ @StorageProvidersImport(ctx, preview)
+ }
+}
diff --git a/internal/db/storage_provider.go b/internal/db/storage_provider.go
index 6c5167e..f974713 100644
--- a/internal/db/storage_provider.go
+++ b/internal/db/storage_provider.go
@@ -9,6 +9,7 @@ type StorageProviderType string
const (
// Storage provider types
+ ProviderTypeGeneric StorageProviderType = "generic" // Generic/unknown provider type
ProviderTypeSFTP StorageProviderType = "sftp"
ProviderTypeS3 StorageProviderType = "s3"
ProviderTypeOneDrive StorageProviderType = "onedrive"
diff --git a/internal/db/storage_provider_store.go b/internal/db/storage_provider_store.go
index 5f045c8..4886b53 100644
--- a/internal/db/storage_provider_store.go
+++ b/internal/db/storage_provider_store.go
@@ -6,6 +6,16 @@ import (
// --- StorageProvider Store Methods ---
+// GetStorageProviderByNameAndUser retrieves a storage provider by name and user
+func (db *DB) GetStorageProviderByNameAndUser(name string, userID uint) (*StorageProvider, error) {
+ var provider StorageProvider
+ err := db.Where("name = ? AND created_by = ?", name, userID).First(&provider).Error
+ if err != nil {
+ return nil, err
+ }
+ return &provider, nil
+}
+
// CreateStorageProvider creates a new storage provider record
func (db *DB) CreateStorageProvider(provider *StorageProvider) error {
return db.Create(provider).Error
diff --git a/internal/web/handlers/routes.go b/internal/web/handlers/routes.go
index c4cbfd4..3c7b641 100644
--- a/internal/web/handlers/routes.go
+++ b/internal/web/handlers/routes.go
@@ -57,12 +57,17 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.DELETE("/storage-providers/:id", h.HandleDeleteStorageProvider)
authorized.POST("/storage-providers/:id/test", h.HandleTestStorageProvider)
authorized.POST("/storage-providers/:id/duplicate", h.HandleDuplicateStorageProvider)
-
+
+ // New import workflow
+ authorized.GET("/storage-providers/import", h.HandleStorageProvidersImportPage)
+ authorized.POST("/storage-providers/import/preview", h.HandleStorageProvidersImportPreview)
+ authorized.POST("/storage-providers/import/confirm", h.HandleStorageProvidersImportConfirm)
+
// Google Drive authentication routes for storage providers
authorized.GET("/storage-providers/:id/gdrive-auth", h.HandleStorageProviderGDriveAuth)
authorized.GET("/storage-providers/gdrive-callback", h.HandleStorageProviderGDriveAuthCallback)
authorized.GET("/storage-providers/gdrive-token", h.HandleStorageProviderGDriveTokenProcess)
-
+
// Google Drive headless authentication routes for storage providers
authorized.GET("/storage-providers/:id/gdrive-headless-auth", h.HandleStorageProviderGDriveHeadlessAuth)
authorized.POST("/storage-providers/gdrive-headless-token", h.HandleStorageProviderGDriveHeadlessTokenSubmit)
diff --git a/internal/web/handlers/storage_provider_handlers.go b/internal/web/handlers/storage_provider_handlers.go
index fe5142e..01f7cf7 100644
--- a/internal/web/handlers/storage_provider_handlers.go
+++ b/internal/web/handlers/storage_provider_handlers.go
@@ -1,7 +1,9 @@
package handlers
import (
+ "encoding/json"
"fmt"
+ "io"
"log"
"net/http"
"strconv"
@@ -383,6 +385,465 @@ func (h *Handlers) HandleDuplicateStorageProvider(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Provider duplicated successfully"})
}
+// Handler for rendering the import page
+func (h *Handlers) HandleStorageProvidersImportPage(c *gin.Context) {
+ ctx := components.CreateTemplateContext(c)
+ _ = components.StorageProvidersImportPage(ctx, components.RcloneImportPreview{}).Render(ctx, c.Writer)
+}
+
+// Handler for previewing rclone config remotes
+func (h *Handlers) HandleStorageProvidersImportPreview(c *gin.Context) {
+ userID := c.GetUint("userID")
+ file, _, err := c.Request.FormFile("rclone_config")
+ if err != nil {
+ ctx := components.CreateTemplateContext(c)
+ // Check if this is an HTMX request
+ if c.GetHeader("HX-Request") == "true" {
+ _ = components.RcloneImportPreviewContent(ctx, components.RcloneImportPreview{Error: "Failed to read uploaded file"}).Render(ctx, c.Writer)
+ } else {
+ _ = components.StorageProvidersImportPage(ctx, components.RcloneImportPreview{Error: "Failed to read uploaded file"}).Render(ctx, c.Writer)
+ }
+ return
+ }
+ defer file.Close()
+ content, err := io.ReadAll(file)
+ if err != nil {
+ ctx := components.CreateTemplateContext(c)
+ // Check if this is an HTMX request
+ if c.GetHeader("HX-Request") == "true" {
+ _ = components.RcloneImportPreviewContent(ctx, components.RcloneImportPreview{Error: "Failed to read file content"}).Render(ctx, c.Writer)
+ } else {
+ _ = components.StorageProvidersImportPage(ctx, components.RcloneImportPreview{Error: "Failed to read file content"}).Render(ctx, c.Writer)
+ }
+ return
+ }
+ parsed, err := parseRcloneConfig(content)
+ if err != nil {
+ ctx := components.CreateTemplateContext(c)
+ // Check if this is an HTMX request
+ if c.GetHeader("HX-Request") == "true" {
+ _ = components.RcloneImportPreviewContent(ctx, components.RcloneImportPreview{Error: fmt.Sprintf("Failed to parse config: %v", err)}).Render(ctx, c.Writer)
+ } else {
+ _ = components.StorageProvidersImportPage(ctx, components.RcloneImportPreview{Error: fmt.Sprintf("Failed to parse config: %v", err)}).Render(ctx, c.Writer)
+ }
+ return
+ }
+
+ var remotes []components.RcloneRemotePreview
+ for name, section := range parsed {
+ provider, _ := storageProviderFromRcloneSection(name, section, userID)
+ fields := make(map[string]string)
+ for k, v := range section {
+ fields[k] = v
+ }
+ remotes = append(remotes, components.RcloneRemotePreview{
+ Name: provider.Name,
+ Type: string(provider.Type),
+ Fields: fields,
+ Import: true,
+ })
+ }
+ ctx := components.CreateTemplateContext(c)
+
+ // Check if this is an HTMX request
+ if c.GetHeader("HX-Request") == "true" {
+ _ = components.RcloneImportPreviewContent(ctx, components.RcloneImportPreview{Remotes: remotes}).Render(ctx, c.Writer)
+ } else {
+ _ = components.StorageProvidersImportPage(ctx, components.RcloneImportPreview{Remotes: remotes}).Render(ctx, c.Writer)
+ }
+}
+
+// Handler for confirming import of selected remotes
+func (h *Handlers) HandleStorageProvidersImportConfirm(c *gin.Context) {
+ userID := c.GetUint("userID")
+
+ // Make sure form is parsed
+ err := c.Request.ParseMultipartForm(32 << 20) // 32MB max memory
+ if err != nil {
+ log.Printf("Error parsing form: %v", err)
+ }
+
+ // For debugging
+ log.Printf("Form data: %+v", c.Request.PostForm)
+ log.Printf("Form method: %s", c.Request.Method)
+
+ // Parse remotes from form
+ remotes := []db.StorageProvider{}
+
+ // Debug: check for import_ keys
+ var importKeys []string
+ for key := range c.Request.PostForm {
+ if strings.HasPrefix(key, "import_") {
+ importKeys = append(importKeys, key)
+ log.Printf("Found import key: %s with value: %v", key, c.Request.PostForm[key])
+ }
+ }
+ log.Printf("Import keys found: %v", importKeys)
+
+ for key, vals := range c.Request.PostForm {
+ log.Printf("Processing key: %s with values: %v", key, vals)
+ if strings.HasPrefix(key, "import_") {
+ // Check the value - it might not be exactly "on"
+ log.Printf("Import checkbox value: %v", vals)
+
+ // Accept any non-empty value as checked
+ if len(vals) > 0 && vals[0] != "" {
+ name := strings.TrimPrefix(key, "import_")
+ log.Printf("Processing remote: %s", name)
+ providerType := db.StorageProviderType(c.PostForm("type_" + name))
+
+ provider := db.StorageProvider{
+ Name: c.PostForm("name_" + name),
+ Type: providerType,
+ CreatedBy: userID,
+ }
+
+ // Collect all fields for this provider
+ fields := map[string]string{}
+ for k, v := range c.Request.PostForm {
+ if strings.HasPrefix(k, "field_"+name+"_") && len(v) > 0 {
+ fieldKey := strings.TrimPrefix(k, "field_"+name+"_")
+ fields[fieldKey] = v[0]
+ }
+ }
+
+ // Map fields to provider struct based on provider type
+ switch providerType {
+ case db.ProviderTypeGoogleDrive:
+ // Map Google Drive specific fields
+ for fieldKey, fieldValue := range fields {
+ switch fieldKey {
+ case "client_id":
+ provider.ClientID = fieldValue
+ case "client_secret":
+ provider.ClientSecret = fieldValue
+ case "refresh_token":
+ provider.RefreshToken = fieldValue
+ case "token":
+ // Token is a JSON object containing access_token, refresh_token, etc.
+ // Extract refresh_token if not already set
+ if provider.RefreshToken == "" {
+ // Try to parse the token JSON
+ var tokenData map[string]interface{}
+ if err := json.Unmarshal([]byte(fieldValue), &tokenData); err == nil {
+ if rt, ok := tokenData["refresh_token"].(string); ok && rt != "" {
+ provider.RefreshToken = rt
+ }
+ }
+ }
+ case "team_drive":
+ provider.TeamDrive = fieldValue
+ }
+ }
+ // Set authenticated to true for OAuth providers with refresh token
+ if provider.RefreshToken != "" {
+ authenticated := true
+ provider.Authenticated = &authenticated
+ }
+ case db.ProviderTypeS3, db.ProviderTypeB2, db.ProviderTypeWasabi, db.ProviderTypeMinio:
+ // Map S3-compatible provider fields
+ for fieldKey, fieldValue := range fields {
+ switch fieldKey {
+ case "access_key_id", "access_key":
+ provider.AccessKey = fieldValue
+ case "secret_access_key", "secret_key":
+ provider.SecretKey = fieldValue
+ case "endpoint":
+ provider.Endpoint = fieldValue
+ case "region":
+ provider.Region = fieldValue
+ case "bucket":
+ provider.Bucket = fieldValue
+ }
+ }
+ case db.ProviderTypeSFTP, db.ProviderTypeFTP:
+ // Map SFTP/FTP fields
+ for fieldKey, fieldValue := range fields {
+ switch fieldKey {
+ case "host":
+ provider.Host = fieldValue
+ case "user", "username":
+ provider.Username = fieldValue
+ case "pass", "password":
+ provider.Password = fieldValue
+ case "port":
+ if port, err := strconv.Atoi(fieldValue); err == nil {
+ provider.Port = port
+ }
+ }
+ }
+ case db.ProviderTypeSMB:
+ // Map SMB fields
+ for fieldKey, fieldValue := range fields {
+ switch fieldKey {
+ case "host":
+ provider.Host = fieldValue
+ case "user", "username":
+ provider.Username = fieldValue
+ case "pass", "password":
+ provider.Password = fieldValue
+ case "domain":
+ provider.Domain = fieldValue
+ case "share":
+ provider.Share = fieldValue
+ }
+ }
+ case db.ProviderTypeOneDrive:
+ // Map OneDrive fields
+ for fieldKey, fieldValue := range fields {
+ switch fieldKey {
+ case "client_id":
+ provider.ClientID = fieldValue
+ case "client_secret":
+ provider.ClientSecret = fieldValue
+ case "refresh_token":
+ provider.RefreshToken = fieldValue
+ case "drive_id":
+ provider.DriveID = fieldValue
+ }
+ }
+ // Set authenticated to true for OAuth providers with refresh token
+ if provider.RefreshToken != "" {
+ authenticated := true
+ provider.Authenticated = &authenticated
+ }
+ default:
+ // For other provider types, log the fields for debugging
+ log.Printf("Unhandled provider type: %s with fields: %v", providerType, fields)
+ }
+
+ remotes = append(remotes, provider)
+ }
+ }
+ }
+
+ // Import each selected provider
+ var importErrs []string
+ var successCount int
+
+ for _, provider := range remotes {
+ log.Printf("Importing provider: %+v", provider)
+ err := createOrUpdateStorageProvider(h.DB, &provider)
+ if err != nil {
+ importErrs = append(importErrs, fmt.Sprintf("%s: %v", provider.Name, err))
+ } else {
+ successCount++
+ }
+ }
+
+ // Create template context
+ ctx := components.CreateTemplateContext(c)
+
+ // Prepare result message
+ if c.GetHeader("HX-Request") == "true" {
+ var resultHTML string
+
+ if len(importErrs) > 0 {
+ // Error message
+ errorMsg := "