From 5da298ef0b32cad9d8e62bd69e523c9d5fd66464 Mon Sep 17 00:00:00 2001 From: StarFleetCPTN Date: Fri, 18 Apr 2025 16:16:49 -0700 Subject: [PATCH] Add storage provider import feature with rclone config support --- components/rclone_import_types.go | 13 + components/storage_providers.templ | 9 + components/storage_providers_import.templ | 146 ++++++ .../storage_providers_import_page.templ | 12 + internal/db/storage_provider.go | 1 + internal/db/storage_provider_store.go | 10 + internal/web/handlers/routes.go | 9 +- .../web/handlers/storage_provider_handlers.go | 461 ++++++++++++++++++ 8 files changed, 659 insertions(+), 2 deletions(-) create mode 100644 components/rclone_import_types.go create mode 100644 components/storage_providers_import.templ create mode 100644 components/storage_providers_import_page.templ 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() + +
+ + + Import rclone config + +
+ + + } else { + + } +} 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 := "
\n" + errorMsg += "
\n" + errorMsg += "\n" + errorMsg += "Failed to import some providers:\n" + errorMsg += "
\n" + errorMsg += "\n" + errorMsg += "
\n" + + // If some providers were imported successfully + if successCount > 0 { + errorMsg += "
\n" + errorMsg += "
\n" + errorMsg += "\n" + errorMsg += fmt.Sprintf("Successfully imported %d provider(s)\n", successCount) + errorMsg += "
\n" + errorMsg += "
\n" + } + + resultHTML = errorMsg + } else if successCount > 0 { + // Success message + successMsg := "
\n" + successMsg += "
\n" + successMsg += "\n" + successMsg += fmt.Sprintf("Successfully imported %d provider(s)\n", successCount) + successMsg += "
\n" + successMsg += "
\n" + + resultHTML = successMsg + } else { + // No providers selected + resultHTML = "
\n" + resultHTML += "
\n" + resultHTML += "\n" + resultHTML += "No providers were selected for import\n" + resultHTML += "
\n" + resultHTML += "
\n" + } + + // Add buttons + resultHTML += "
\n" + resultHTML += "\n" + resultHTML += "View All Providers\n" + resultHTML += "\n" + resultHTML += "\n" + resultHTML += "Import Another Config\n" + resultHTML += "\n" + resultHTML += "
\n" + + // Send response + c.Writer.Header().Set("Content-Type", "text/html") + c.Writer.WriteHeader(http.StatusOK) + c.Writer.Write([]byte(resultHTML)) + } else { + // For regular requests, redirect to storage providers page with a flash message + if len(importErrs) > 0 { + // Show error page + _ = components.StorageProvidersImportPage(ctx, components.RcloneImportPreview{Error: strings.Join(importErrs, "; ")}).Render(ctx, c.Writer) + } else { + // Redirect to storage providers page on success + c.Redirect(http.StatusSeeOther, "/storage-providers") + } + } +} + +// Handler for importing rclone config file +func (h *Handlers) HandleImportRcloneConfig(c *gin.Context) { + userID := c.GetUint("userID") + file, _, err := c.Request.FormFile("rclone_config") + if err != nil { + c.JSON(400, gin.H{"error": "Missing file: " + err.Error()}) + return + } + defer file.Close() + + // Read the file content + content, err := io.ReadAll(file) + if err != nil { + c.JSON(400, gin.H{"error": "Failed to read file: " + err.Error()}) + return + } + + // Parse as INI (rclone config format) + cfg, err := parseRcloneConfig(content) + if err != nil { + c.JSON(400, gin.H{"error": "Invalid rclone config: " + err.Error()}) + return + } + + imported := 0 + failed := 0 + var errors []string + for name, section := range cfg { + provider, err := storageProviderFromRcloneSection(name, section, userID) + if err != nil { + failed++ + errors = append(errors, name+": "+err.Error()) + continue + } + // Try to create or update + err = createOrUpdateStorageProvider(h.DB, &provider) + if err != nil { + failed++ + errors = append(errors, name+": "+err.Error()) + continue + } + imported++ + } + if failed == 0 { + c.JSON(200, gin.H{"message": "Imported successfully", "imported": imported}) + } else { + c.JSON(400, gin.H{"error": "Some remotes failed", "imported": imported, "failed": failed, "details": errors}) + } +} + +// Helper: parse rclone config INI into map[string]map[string]string +func parseRcloneConfig(content []byte) (map[string]map[string]string, error) { + cfg := make(map[string]map[string]string) + var current string + lines := strings.Split(string(content), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + current = strings.TrimSuffix(strings.TrimPrefix(line, "["), "]") + cfg[current] = make(map[string]string) + continue + } + if current == "" { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + cfg[current][strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + return cfg, nil +} + +// Helper: create or update provider (fallback if DB method not present) +func createOrUpdateStorageProvider(dbh *db.DB, provider *db.StorageProvider) error { + existing, err := dbh.GetStorageProviderByNameAndUser(provider.Name, provider.CreatedBy) + if err == nil && existing != nil { + provider.ID = existing.ID + return dbh.UpdateStorageProvider(provider) + } + return dbh.CreateStorageProvider(provider) +} + +// Helper: convert rclone section to StorageProvider +func storageProviderFromRcloneSection(name string, section map[string]string, userID uint) (db.StorageProvider, error) { + providerType, ok := section["type"] + if !ok || providerType == "" { + providerType = string(db.ProviderTypeGeneric) + } + // Optionally: you can check for known types and set generic if not recognized + knownTypes := map[string]bool{ + "sftp": true, "s3": true, "onedrive": true, "drive": true, "gphotos": true, "ftp": true, "smb": true, "hetzner": true, "local": true, "webdav": true, "nextcloud": true, "b2": true, "wasabi": true, "minio": true, + } + if !knownTypes[providerType] { + providerType = string(db.ProviderTypeGeneric) + } + provider := db.StorageProvider{ + Name: name, + Type: db.StorageProviderType(providerType), + CreatedBy: userID, + } + // Map common fields + for k, v := range section { + switch k { + case "host": + provider.Host = v + case "user": + provider.Username = v + case "pass": + provider.Password = v + case "port": + if port, err := strconv.Atoi(v); err == nil { + provider.Port = port + } + case "bucket": + provider.Bucket = v + case "region": + provider.Region = v + case "access_key_id": + provider.AccessKey = v + case "secret_access_key": + provider.SecretKey = v + case "endpoint": + provider.Endpoint = v + case "domain": + provider.Domain = v + // Add more mappings as needed + } + } + return provider, nil +} + // Helper function to parse provider from form func (h *Handlers) parseProviderFromForm(c *gin.Context) (db.StorageProvider, error) { provider := db.StorageProvider{}