From 23db71c4a8de833dd968360a05ae7c995697ad00 Mon Sep 17 00:00:00 2001
From: StarFleetCPTN
Date: Fri, 18 Apr 2025 12:41:10 -0700
Subject: [PATCH] Add Google Drive and Photos support with headless auth for
storage providers
---
components/gdrive_headless_auth.templ | 169 ++++++
components/storage_provider_form.templ | 16 +-
...torage_provider_gdrive_headless_auth.templ | 169 ++++++
components/storage_providers.templ | 144 +++++-
internal/db/storage_provider.go | 4 +-
internal/db/transfer_config.go | 6 +-
internal/db/transfer_config_store.go | 350 +++++++++++++
internal/web/handlers/routes.go | 9 +
.../storage_provider_gdrive_handlers.go | 489 ++++++++++++++++++
.../storage_provider_handlers_test.go | 4 +-
10 files changed, 1341 insertions(+), 19 deletions(-)
create mode 100755 components/gdrive_headless_auth.templ
create mode 100644 components/storage_provider_gdrive_headless_auth.templ
create mode 100644 internal/web/handlers/storage_provider_gdrive_handlers.go
diff --git a/components/gdrive_headless_auth.templ b/components/gdrive_headless_auth.templ
new file mode 100755
index 0000000..dfab990
--- /dev/null
+++ b/components/gdrive_headless_auth.templ
@@ -0,0 +1,169 @@
+package components
+
+import (
+ "context"
+)
+
+// GDriveHeadlessAuthData contains data needed for rendering the headless auth page
+type GDriveHeadlessAuthData struct {
+ AuthCommand string
+ ConfigID string
+}
+
+// GDriveHeadlessAuth renders the headless authentication page for Google Drive/Photos
+templ GDriveHeadlessAuth(ctx context.Context, data GDriveHeadlessAuthData) {
+ // Force the layout to display as authenticated content
+ @LayoutWithContext("Google Authentication - Headless Mode", ctx) {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You need to authenticate with Google using a web browser. Since you're running GoMFT behind a reverse proxy or in a headless environment, you'll need to complete authentication on a machine with a web browser.
+
+
+
+
+
+
+
Step 1: Run the following command on a machine with a web browser
+
+
{ data.AuthCommand }
+
+
+
+
+
+
+
What this command does:
+
+ Opens a browser window on the machine where you run it
+ Allows you to authenticate with Google
+ Generates an authentication token
+
+
+
+
+
+
Step 2: Paste the authentication token below
+
+ After completing authentication in the browser, you'll receive a token. Copy and paste that token here:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
This authentication process is necessary for GoMFT to access your Google Drive or Google Photos account.
+
The token is only used for authentication and is stored securely. You'll only need to complete this process once for each configuration.
+
+
+
+
+
+
+
+ }
+}
\ No newline at end of file
diff --git a/components/storage_provider_form.templ b/components/storage_provider_form.templ
index c57ea23..3f367d0 100644
--- a/components/storage_provider_form.templ
+++ b/components/storage_provider_form.templ
@@ -161,12 +161,12 @@ templ formFields(data StorageProviderFormData) {
selected="selected"
}
>OneDrive
- Google Drive
-
+ /* Ensure proper styling for the headless auth page */
+ body.dark .auth-page {
+ background-color: #111827 !important;
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You need to authenticate with Google using a web browser. Since you're running GoMFT behind a reverse proxy or in a headless environment, you'll need to complete authentication on a machine with a web browser.
+
+
+
+
+
+
+
Step 1: Run the following command on a machine with a web browser
+
+
{ data.AuthCommand }
+
+
+
+
+
+
+
What this command does:
+
+ Opens a browser window on the machine where you run it
+ Allows you to authenticate with Google
+ Generates an authentication token
+
+
+
+
+
+
Step 2: Paste the authentication token below
+
+ After completing authentication in the browser, you'll receive a token. Copy and paste that token here:
+
+
+
+
+
+
+
Authentication Token
+
+
The token will look like a long JSON string containing access credentials.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
This authentication process is necessary for GoMFT to access your Google Drive or Google Photos account.
+
The token is only used for authentication and is stored securely. You'll only need to complete this process once for each storage provider.
+
+
+
+
+
+
+
+ }
+}
diff --git a/components/storage_providers.templ b/components/storage_providers.templ
index a0e0cf9..c57390b 100644
--- a/components/storage_providers.templ
+++ b/components/storage_providers.templ
@@ -306,6 +306,7 @@ templ StorageProviders(ctx context.Context, data StorageProvidersData) {
Your credentials are encrypted for security. You can test connections before using them in transfers.
+
Google Drive and Google Photos providers require authentication. Click the "Authenticate" button to complete setup.
@@ -322,6 +323,73 @@ templ StorageProviders(ctx context.Context, data StorageProvidersData) {
+
+
}
}
@@ -416,11 +484,11 @@ templ StorageProviders_ProvidersList(providers []db.StorageProvider) {
Region: { provider.Region }
}
- } else if provider.Type == "google_drive" || provider.Type == "google_photo" {
+ } else if provider.Type == "drive" || provider.Type == "gphotos" {
Google
- if provider.Type == "google_drive" {
+ if provider.Type == "drive" {
Drive
} else {
Photos
@@ -431,12 +499,51 @@ templ StorageProviders_ProvidersList(providers []db.StorageProvider) {
if provider.Authenticated != nil && *provider.Authenticated {
+
Authenticated
} else {
-
- Not Authenticated
-
+
+
+
+
+
+
+ Authenticate with Google
+
+
+
+
+
+
+
+
+
+
}
} else if provider.Type == "webdav" || provider.Type == "nextcloud" {
@@ -493,6 +600,33 @@ templ StorageProviders_ProvidersList(providers []db.StorageProvider) {
+
+
+ if (provider.Type == "drive" || provider.Type == "gphotos") && (provider.Authenticated == nil || !*provider.Authenticated) {
+
+
+
+
+ Authentication required for Google
+ if provider.Type == "drive" {
+ Drive
+ } else {
+ Photos
+ }
+
+
+
+
+ }
}
diff --git a/internal/db/storage_provider.go b/internal/db/storage_provider.go
index a0bc6c1..6c5167e 100644
--- a/internal/db/storage_provider.go
+++ b/internal/db/storage_provider.go
@@ -12,8 +12,8 @@ const (
ProviderTypeSFTP StorageProviderType = "sftp"
ProviderTypeS3 StorageProviderType = "s3"
ProviderTypeOneDrive StorageProviderType = "onedrive"
- ProviderTypeGoogleDrive StorageProviderType = "google_drive"
- ProviderTypeGooglePhoto StorageProviderType = "google_photo"
+ ProviderTypeGoogleDrive StorageProviderType = "drive"
+ ProviderTypeGooglePhoto StorageProviderType = "gphotos"
ProviderTypeFTP StorageProviderType = "ftp"
ProviderTypeSMB StorageProviderType = "smb"
ProviderTypeHetzner StorageProviderType = "hetzner"
diff --git a/internal/db/transfer_config.go b/internal/db/transfer_config.go
index 1f64aa3..0884f83 100644
--- a/internal/db/transfer_config.go
+++ b/internal/db/transfer_config.go
@@ -447,7 +447,8 @@ func (tc *TransferConfig) GetSourceCredentials(db interface{}) (map[string]inter
" has_encrypted_password: %v\n"+
" has_key_file: %v\n"+
" has_encrypted_secret_key: %v\n"+
- " has_encrypted_client_secret: %v\n",
+ " has_encrypted_client_secret: %v\n"+
+ " has_encrypted_refresh_token: %v\n",
creds["type"],
creds["host"],
creds["port"],
@@ -455,7 +456,8 @@ func (tc *TransferConfig) GetSourceCredentials(db interface{}) (map[string]inter
creds["encrypted_password"] != "",
creds["key_file"] != "",
creds["encrypted_secret_key"] != "",
- creds["encrypted_client_secret"] != "")
+ creds["encrypted_client_secret"] != "",
+ creds["encrypted_refresh_token"] != "")
return creds, nil
}
diff --git a/internal/db/transfer_config_store.go b/internal/db/transfer_config_store.go
index 76310c3..b448048 100644
--- a/internal/db/transfer_config_store.go
+++ b/internal/db/transfer_config_store.go
@@ -458,12 +458,190 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
}
return fmt.Errorf("%v", errorMsg)
}
+
case "local":
// For local source, ensure the section exists but might not need specific rclone config create
content := fmt.Sprintf("[%s]\ntype = local\n\n", sourceName)
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
return fmt.Errorf("failed to write source config (local): %v", err)
}
+ case "drive":
+ // For Google Drive, we need client ID and secret
+ clientID := getStringValue(sourceCredentials, "client_id", config.SourceClientID)
+
+ // Get client secret with proper decryption if from provider
+ clientSecret := ""
+ if config.SourceClientSecret != "" {
+ // Direct input from form (transient)
+ clientSecret = config.SourceClientSecret
+ } else if encryptedSecret, ok := sourceCredentials["encrypted_client_secret"].(string); ok && encryptedSecret != "" {
+ // Provider reference with encrypted secret
+ decryptedSecret, err := db.DecryptCredential(encryptedSecret)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt source client secret: %v", err)
+ }
+ clientSecret = decryptedSecret
+ }
+
+ // Get refresh token if available
+ refreshToken := getStringOrDefault(sourceCredentials, "token", "")
+ if refreshToken == "" {
+ refreshToken = getStringOrDefault(sourceCredentials, "refresh_token", "")
+ }
+
+ if refreshToken == "" {
+ if encryptedToken, ok := sourceCredentials["encrypted_refresh_token"].(string); ok && encryptedToken != "" {
+ decryptedToken, err := db.DecryptCredential(encryptedToken)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt source refresh token: %v", err)
+ }
+ refreshToken = decryptedToken
+ }
+ }
+
+ // If not found in credentials, check if using a provider reference
+ if refreshToken == "" && config.IsUsingSourceProviderReference() && config.SourceProvider != nil {
+ refreshToken = config.SourceProvider.RefreshToken
+ }
+
+ // Clean up the token
+ if refreshToken != "" {
+ refreshToken = strings.TrimSpace(refreshToken)
+ refreshToken = strings.ReplaceAll(refreshToken, "\n", "")
+ refreshToken = strings.ReplaceAll(refreshToken, "\r", "")
+ refreshToken = strings.Join(strings.Fields(refreshToken), "")
+ }
+
+ // Create rclone config for Google Drive
+ args := []string{
+ "config", "create", sourceName, "drive",
+ "client_id", clientID,
+ "client_secret", clientSecret,
+ "--non-interactive",
+ "--config", configPath,
+ "--log-level", "ERROR",
+ }
+
+ // Add team drive or drive ID if specified
+ teamDrive := getStringValue(sourceCredentials, "team_drive", config.SourceTeamDrive)
+ if teamDrive != "" {
+ args = append(args, "team_drive", teamDrive)
+ }
+
+ driveID := getStringValue(sourceCredentials, "drive_id", config.SourceDriveID)
+ if driveID != "" {
+ args = append(args, "drive_id", driveID)
+ }
+
+ // If we have a refresh token, add it
+ if refreshToken != "" {
+ args = append(args, "token", fmt.Sprintf("%s", refreshToken))
+ }
+
+ cmd := exec.Command(rclonePath, args...)
+
+ if output, err := cmd.CombinedOutput(); err != nil {
+ errorMsg := fmt.Sprintf("failed to create source config (drive): %v", err)
+ // Check if output contains useful info, especially for auth errors
+ if len(output) > 0 {
+ errorMsg += fmt.Sprintf("\nOutput: %s", output)
+ }
+ return fmt.Errorf("%v", errorMsg)
+ }
+ case "gphotos":
+ // For Google Photos, we need client ID and secret
+ clientID := getStringValue(sourceCredentials, "client_id", config.SourceClientID)
+
+ // Get client secret with proper decryption if from provider
+ clientSecret := ""
+ if config.SourceClientSecret != "" {
+ // Direct input from form (transient)
+ clientSecret = config.SourceClientSecret
+ } else if encryptedSecret, ok := sourceCredentials["encrypted_client_secret"].(string); ok && encryptedSecret != "" {
+ // Provider reference with encrypted secret
+ decryptedSecret, err := db.DecryptCredential(encryptedSecret)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt source client secret: %v", err)
+ }
+ clientSecret = decryptedSecret
+ }
+
+ // Get refresh token if available
+ refreshToken := getStringOrDefault(sourceCredentials, "token", "")
+ if refreshToken == "" {
+ refreshToken = getStringOrDefault(sourceCredentials, "refresh_token", "")
+ }
+
+ if refreshToken == "" {
+ if encryptedToken, ok := sourceCredentials["encrypted_refresh_token"].(string); ok && encryptedToken != "" {
+ decryptedToken, err := db.DecryptCredential(encryptedToken)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt source refresh token: %v", err)
+ }
+ refreshToken = decryptedToken
+ }
+ }
+
+ // Clean up the token
+ if refreshToken != "" {
+ refreshToken = strings.TrimSpace(refreshToken)
+ refreshToken = strings.ReplaceAll(refreshToken, "\n", "")
+ refreshToken = strings.ReplaceAll(refreshToken, "\r", "")
+ refreshToken = strings.Join(strings.Fields(refreshToken), "")
+ }
+
+ // Create rclone config for Google Photos
+ args := []string{
+ "config", "create", sourceName, "gphotos",
+ "client_id", clientID,
+ "client_secret", clientSecret,
+ "--non-interactive",
+ "--config", configPath,
+ "--log-level", "ERROR",
+ }
+
+ // Add read-only flag if specified
+ readOnly := false
+ if readOnlyVal, ok := sourceCredentials["read_only"].(bool); ok {
+ readOnly = readOnlyVal
+ } else if config.SourceReadOnly != nil {
+ readOnly = *config.SourceReadOnly
+ }
+ if readOnly {
+ args = append(args, "read_only", "true")
+ }
+
+ // Add start year if specified
+ startYear := getIntValue(sourceCredentials, "start_year", config.SourceStartYear)
+ if startYear > 0 {
+ args = append(args, "start_year", fmt.Sprintf("%d", startYear))
+ }
+
+ // Add include archived if specified
+ includeArchived := false
+ if includeArchivedVal, ok := sourceCredentials["include_archived"].(bool); ok {
+ includeArchived = includeArchivedVal
+ } else if config.SourceIncludeArchived != nil {
+ includeArchived = *config.SourceIncludeArchived
+ }
+ if includeArchived {
+ args = append(args, "include_archived", "true")
+ }
+
+ // If we have a refresh token, add it
+ if refreshToken != "" {
+ args = append(args, "token", fmt.Sprintf("%s", refreshToken))
+ }
+
+ cmd := exec.Command(rclonePath, args...)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ errorMsg := fmt.Sprintf("failed to create source config (gphotos): %v", err)
+ // Check if output contains useful info, especially for auth errors
+ if len(output) > 0 {
+ errorMsg += fmt.Sprintf("\nOutput: %s", output)
+ }
+ return fmt.Errorf("%v", errorMsg)
+ }
default:
// Handle unknown or unsupported source types if necessary
return fmt.Errorf("unsupported source type for rclone config generation: %s", config.SourceType)
@@ -840,6 +1018,176 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
if _, err := f.WriteString(content); err != nil {
return fmt.Errorf("failed to write destination config (local): %v", err)
}
+ case "drive":
+ // For Google Drive, we need client ID and secret
+ clientID := getStringValue(destCredentials, "client_id", config.DestClientID)
+
+ // Get client secret with proper decryption if from provider
+ clientSecret := ""
+ if config.DestClientSecret != "" {
+ // Direct input from form (transient)
+ clientSecret = config.DestClientSecret
+ } else if encryptedSecret, ok := destCredentials["encrypted_client_secret"].(string); ok && encryptedSecret != "" {
+ // Provider reference with encrypted secret
+ decryptedSecret, err := db.DecryptCredential(encryptedSecret)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt destination client secret: %v", err)
+ }
+ clientSecret = decryptedSecret
+ }
+
+ // Get refresh token if available
+ refreshToken := getStringOrDefault(destCredentials, "token", "")
+ if refreshToken == "" {
+ refreshToken = getStringOrDefault(destCredentials, "refresh_token", "")
+ }
+
+ if refreshToken == "" {
+ if encryptedToken, ok := destCredentials["encrypted_refresh_token"].(string); ok && encryptedToken != "" {
+ decryptedToken, err := db.DecryptCredential(encryptedToken)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt source refresh token: %v", err)
+ }
+ refreshToken = decryptedToken
+ }
+ }
+
+ // Clean up the token
+ if refreshToken != "" {
+ refreshToken = strings.TrimSpace(refreshToken)
+ refreshToken = strings.ReplaceAll(refreshToken, "\n", "")
+ refreshToken = strings.ReplaceAll(refreshToken, "\r", "")
+ refreshToken = strings.Join(strings.Fields(refreshToken), "")
+ }
+
+ // Create rclone config for Google Drive
+ args := []string{
+ "config", "create", destName, "drive",
+ "client_id", clientID,
+ "client_secret", clientSecret,
+ "--non-interactive",
+ "--config", configPath,
+ "--log-level", "ERROR",
+ }
+
+ // Add team drive or drive ID if specified
+ teamDrive := getStringValue(destCredentials, "team_drive", config.DestTeamDrive)
+ if teamDrive != "" {
+ args = append(args, "team_drive", teamDrive)
+ }
+
+ driveID := getStringValue(destCredentials, "drive_id", config.DestDriveID)
+ if driveID != "" {
+ args = append(args, "drive_id", driveID)
+ }
+
+ // If we have a refresh token, add it
+ if refreshToken != "" {
+ args = append(args, "token", fmt.Sprintf("%s", refreshToken))
+ }
+
+ cmd := exec.Command(rclonePath, args...)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ errorMsg := fmt.Sprintf("failed to create destination config (drive): %v", err)
+ // Check if output contains useful info, especially for auth errors
+ if len(output) > 0 {
+ errorMsg += fmt.Sprintf("\nOutput: %s", output)
+ }
+ return fmt.Errorf("%v", errorMsg)
+ }
+ case "gphotos":
+ // For Google Photos, we need client ID and secret
+ clientID := getStringValue(destCredentials, "client_id", config.DestClientID)
+
+ // Get client secret with proper decryption if from provider
+ clientSecret := ""
+ if config.DestClientSecret != "" {
+ // Direct input from form (transient)
+ clientSecret = config.DestClientSecret
+ } else if encryptedSecret, ok := destCredentials["encrypted_client_secret"].(string); ok && encryptedSecret != "" {
+ // Provider reference with encrypted secret
+ decryptedSecret, err := db.DecryptCredential(encryptedSecret)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt destination client secret: %v", err)
+ }
+ clientSecret = decryptedSecret
+ }
+
+ // Get refresh token if available
+ refreshToken := getStringOrDefault(destCredentials, "token", "")
+ if refreshToken == "" {
+ refreshToken = getStringOrDefault(destCredentials, "refresh_token", "")
+ }
+
+ if refreshToken == "" {
+ if encryptedToken, ok := destCredentials["encrypted_refresh_token"].(string); ok && encryptedToken != "" {
+ decryptedToken, err := db.DecryptCredential(encryptedToken)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt source refresh token: %v", err)
+ }
+ refreshToken = decryptedToken
+ }
+ }
+
+ // Clean up the token
+ if refreshToken != "" {
+ refreshToken = strings.TrimSpace(refreshToken)
+ refreshToken = strings.ReplaceAll(refreshToken, "\n", "")
+ refreshToken = strings.ReplaceAll(refreshToken, "\r", "")
+ }
+
+ // Create rclone config for Google Photos
+ args := []string{
+ "config", "create", destName, "gphotos",
+ "client_id", clientID,
+ "client_secret", clientSecret,
+ "--non-interactive",
+ "--config", configPath,
+ "--log-level", "ERROR",
+ }
+
+ // Add read-only flag if specified
+ readOnly := false
+ if readOnlyVal, ok := destCredentials["read_only"].(bool); ok {
+ readOnly = readOnlyVal
+ } else if config.DestReadOnly != nil {
+ readOnly = *config.DestReadOnly
+ }
+ if readOnly {
+ args = append(args, "read_only", "true")
+ }
+
+ // Add start year if specified
+ startYear := getIntValue(destCredentials, "start_year", config.DestStartYear)
+ if startYear > 0 {
+ args = append(args, "start_year", fmt.Sprintf("%d", startYear))
+ }
+
+ // Add include archived if specified
+ includeArchived := false
+ if includeArchivedVal, ok := destCredentials["include_archived"].(bool); ok {
+ includeArchived = includeArchivedVal
+ } else if config.DestIncludeArchived != nil {
+ includeArchived = *config.DestIncludeArchived
+ }
+ if includeArchived {
+ args = append(args, "include_archived", "true")
+ }
+
+ // If we have a refresh token, add it
+ if refreshToken != "" {
+ args = append(args, "token", fmt.Sprintf("%s", refreshToken))
+ }
+
+ cmd := exec.Command(rclonePath, args...)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ errorMsg := fmt.Sprintf("failed to create destination config (gphotos): %v", err)
+ // Check if output contains useful info, especially for auth errors
+ if len(output) > 0 {
+ errorMsg += fmt.Sprintf("\nOutput: %s", output)
+ }
+ return fmt.Errorf("%v", errorMsg)
+ }
default:
// Handle unknown or unsupported destination types if necessary
return fmt.Errorf("unsupported destination type for rclone config generation: %s", config.DestinationType)
@@ -875,6 +1223,8 @@ func getIntValue(creds map[string]interface{}, key string, defaultValue int) int
// StoreGoogleDriveToken stores the Google Drive auth token for a config
func (db *DB) StoreGoogleDriveToken(configIDStr string, token string) error {
+ // Remove all whitespace to ensure the token is a single line
+ token = strings.Join(strings.Fields(token), "")
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid config ID: %v", err)
diff --git a/internal/web/handlers/routes.go b/internal/web/handlers/routes.go
index 96cb592..c4cbfd4 100644
--- a/internal/web/handlers/routes.go
+++ b/internal/web/handlers/routes.go
@@ -57,6 +57,15 @@ 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)
+
+ // 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)
{
authorized.GET("/dashboard", h.HandleDashboard)
diff --git a/internal/web/handlers/storage_provider_gdrive_handlers.go b/internal/web/handlers/storage_provider_gdrive_handlers.go
new file mode 100644
index 0000000..0ad023e
--- /dev/null
+++ b/internal/web/handlers/storage_provider_gdrive_handlers.go
@@ -0,0 +1,489 @@
+package handlers
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/starfleetcptn/gomft/components"
+)
+
+// HandleStorageProviderGDriveAuth initiates the Google Drive authentication process for storage providers
+func (h *Handlers) HandleStorageProviderGDriveAuth(c *gin.Context) {
+ // Get the provider ID from the query parameter
+ providerIDStr := c.Param("id")
+ if providerIDStr == "" {
+ RenderErrorPage(c, "Missing provider ID", "")
+ return
+ }
+
+ providerID, err := strconv.ParseUint(providerIDStr, 10, 64)
+ if err != nil {
+ RenderErrorPage(c, "Invalid provider ID", err.Error())
+ return
+ }
+
+ // Get the provider
+ provider, err := h.DB.GetStorageProvider(uint(providerID))
+ if err != nil {
+ RenderErrorPage(c, "Provider not found", err.Error())
+ return
+ }
+
+ // Ensure it's a Google Drive or Google Photos provider
+ if provider.Type != "drive" && provider.Type != "gphotos" {
+ RenderErrorPage(c, "Not a Google provider", "The selected provider is not set up for Google Drive or Google Photos")
+ return
+ }
+
+ // Prepare for OAuth
+ dataDir := os.Getenv("DATA_DIR")
+ if dataDir == "" {
+ dataDir = "./data"
+ }
+
+ // Create a temporary config file for authentication
+ tempConfigDir := filepath.Join(dataDir, "temp")
+ if err := os.MkdirAll(tempConfigDir, 0755); err != nil {
+ RenderErrorPage(c, "Failed to create temporary directory", err.Error())
+ return
+ }
+
+ tempConfigPath := filepath.Join(tempConfigDir, fmt.Sprintf("gdrive_auth_provider_%d.conf", provider.ID))
+
+ // Store the temporary config path in a cookie
+ c.SetCookie("gdrive_temp_config_provider", tempConfigPath, 3600, "/", "", false, true)
+
+ // Get base URL for redirect URI
+ baseURL := os.Getenv("BASE_URL")
+ if baseURL == "" {
+ // Try to detect the base URL from the request
+ scheme := "http"
+ if c.Request.TLS != nil {
+ scheme = "https"
+ }
+ baseURL = fmt.Sprintf("%s://%s", scheme, c.Request.Host)
+ }
+
+ // Define the redirect URI for our callback
+ redirectURI := fmt.Sprintf("%s/storage-providers/gdrive-callback", baseURL)
+
+ // Attempt to get GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from ENV
+ clientID := os.Getenv("GOOGLE_CLIENT_ID")
+ clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
+
+ // Check if provider has client credentials
+ if provider.ClientID != "" {
+ clientID = provider.ClientID
+ }
+ if provider.ClientSecret != "" {
+ clientSecret = provider.ClientSecret
+ }
+
+ if clientID == "" || clientSecret == "" {
+ // fallback to rclone client ID and secret
+ clientID = "202264815644.apps.googleusercontent.com"
+ clientSecret = "X4Z3ca8xfWDb1Voo-F9a7ZxJ"
+ }
+
+ // Generate state parameter for security (to prevent CSRF)
+ state := fmt.Sprintf("gomft_provider_%d_%d", provider.ID, time.Now().Unix())
+
+ // Store state in cookie for validation during callback
+ c.SetCookie("gdrive_auth_state_provider", state, 3600, "/", "", false, true)
+
+ // Store provider ID in cookie for use during callback
+ c.SetCookie("gdrive_provider_id", providerIDStr, 3600, "/", "", false, true)
+
+ // Determine the appropriate scope based on provider type
+ var scope string
+ if provider.Type == "google_photo" {
+ scope = url.QueryEscape("https://www.googleapis.com/auth/photoslibrary")
+ } else {
+ // Default to Google Drive scope
+ scope = url.QueryEscape("https://www.googleapis.com/auth/drive")
+ }
+
+ // Create a config file with redirect URI-based auth
+ configType := "drive"
+ if provider.Type == "google_photo" {
+ configType = "google photos"
+ }
+
+ // Use a standardized name for the rclone config section
+ configSection := "temp_drive"
+ if provider.Type == "google_photo" {
+ configSection = "temp_gphotos"
+ }
+
+ configContent := fmt.Sprintf(`[%s]
+type = %s
+client_id = %s
+client_secret = %s
+redirect_url = %s
+`, configSection, configType, clientID, clientSecret, redirectURI)
+
+ // Write the config file
+ if err := os.WriteFile(tempConfigPath, []byte(configContent), 0644); err != nil {
+ RenderErrorPage(c, "Failed to create temporary config file", err.Error())
+ return
+ }
+
+ // Direct Google OAuth URL with our redirect
+ authURL := fmt.Sprintf("https://accounts.google.com/o/oauth2/auth?client_id=%s&redirect_uri=%s&scope=%s&response_type=code&access_type=offline&state=%s",
+ url.QueryEscape(clientID),
+ url.QueryEscape(redirectURI),
+ scope,
+ url.QueryEscape(state))
+
+ // Redirect the user to Google's auth page directly
+ c.Redirect(http.StatusFound, authURL)
+}
+
+// HandleStorageProviderGDriveAuthCallback handles the callback from Google OAuth for storage providers
+func (h *Handlers) HandleStorageProviderGDriveAuthCallback(c *gin.Context) {
+ // Get auth code from query parameters
+ authCode := c.Query("code")
+ if authCode == "" {
+ RenderErrorPage(c, "Authentication failed", "No authorization code received from Google")
+ return
+ }
+
+ // Verify state parameter to prevent CSRF
+ state := c.Query("state")
+ storedState, err := c.Cookie("gdrive_auth_state_provider")
+ if err != nil || state != storedState {
+ RenderErrorPage(c, "Authentication failed", "Invalid state parameter")
+ return
+ }
+
+ // Get provider ID from cookie
+ providerIDStr, err := c.Cookie("gdrive_provider_id")
+ if err != nil {
+ RenderErrorPage(c, "Authentication failed", "Unable to retrieve provider ID")
+ return
+ }
+
+ providerID, err := strconv.ParseUint(providerIDStr, 10, 64)
+ if err != nil {
+ RenderErrorPage(c, "Invalid provider ID", err.Error())
+ return
+ }
+
+ // Get the temp config path from cookie
+ tempConfigPath, err := c.Cookie("gdrive_temp_config_provider")
+ if err != nil || tempConfigPath == "" {
+ RenderErrorPage(c, "Session expired", "The authentication session has expired")
+ return
+ }
+
+ // Get base URL for redirect URI
+ baseURL := os.Getenv("BASE_URL")
+ if baseURL == "" {
+ // Try to detect the base URL from the request
+ scheme := "http"
+ if c.Request.TLS != nil {
+ scheme = "https"
+ }
+ baseURL = fmt.Sprintf("%s://%s", scheme, c.Request.Host)
+ }
+ redirectURI := fmt.Sprintf("%s/storage-providers/gdrive-callback", baseURL)
+
+ // Get the provider to retrieve client ID and secret
+ provider, err := h.DB.GetStorageProvider(uint(providerID))
+ if err != nil {
+ RenderErrorPage(c, "Failed to get provider", err.Error())
+ return
+ }
+
+ // Attempt to get GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from provider or ENV
+ clientID := provider.ClientID
+ clientSecret := provider.ClientSecret
+
+ if clientID == "" {
+ clientID = os.Getenv("GOOGLE_CLIENT_ID")
+ }
+ if clientSecret == "" {
+ clientSecret = os.Getenv("GOOGLE_CLIENT_SECRET")
+ }
+
+ if clientID == "" || clientSecret == "" {
+ // fallback to rclone client ID and secret
+ clientID = "202264815644.apps.googleusercontent.com"
+ clientSecret = "X4Z3ca8xfWDb1Voo-F9a7ZxJ"
+ }
+
+ // Exchange auth code for token using HTTP request
+ tokenURL := "https://oauth2.googleapis.com/token"
+ formData := url.Values{
+ "code": {authCode},
+ "client_id": {clientID},
+ "client_secret": {clientSecret},
+ "redirect_uri": {redirectURI},
+ "grant_type": {"authorization_code"},
+ }
+
+ resp, err := http.PostForm(tokenURL, formData)
+ if err != nil {
+ RenderErrorPage(c, "Failed to exchange authorization code for token", err.Error())
+ return
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ RenderErrorPage(c, "Failed to read token response", err.Error())
+ return
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ RenderErrorPage(c, "Failed to exchange authorization code for token", string(body))
+ return
+ }
+
+ // Parse the token response
+ var tokenResp struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int `json:"expires_in"`
+ }
+
+ if err := json.Unmarshal(body, &tokenResp); err != nil {
+ RenderErrorPage(c, "Failed to parse token response", err.Error())
+ return
+ }
+
+ // Create a token JSON in the format rclone expects
+ tokenJSON := fmt.Sprintf(`{
+ "access_token": "%s",
+ "token_type": "%s",
+ "refresh_token": "%s",
+ "expiry": "%s"
+ }`,
+ tokenResp.AccessToken,
+ tokenResp.TokenType,
+ tokenResp.RefreshToken,
+ time.Now().Add(time.Duration(tokenResp.ExpiresIn)*time.Second).Format(time.RFC3339))
+
+ // Mark the provider as authenticated in the database
+ authenticated := true
+ provider.Authenticated = &authenticated
+ if err := h.DB.UpdateStorageProvider(provider); err != nil {
+ RenderErrorPage(c, "Failed to update provider", err.Error())
+ return
+ }
+
+ // Store the token in the provider's refresh token field
+ provider.RefreshToken = tokenJSON
+ if err := h.DB.UpdateStorageProvider(provider); err != nil {
+ RenderErrorPage(c, "Failed to store token", err.Error())
+ return
+ }
+
+ // Clean up the temporary file
+ os.Remove(tempConfigPath)
+
+ // Clear cookies
+ c.SetCookie("gdrive_temp_config_provider", "", -1, "/", "", false, true)
+ c.SetCookie("gdrive_auth_state_provider", "", -1, "/", "", false, true)
+ c.SetCookie("gdrive_provider_id", "", -1, "/", "", false, true)
+
+ // Redirect to the provider list with a success message
+ c.Redirect(http.StatusFound, "/storage-providers?status=gdrive_auth_success")
+}
+
+// HandleStorageProviderGDriveTokenProcess processes a Google Drive token directly from a URL parameter for storage providers
+func (h *Handlers) HandleStorageProviderGDriveTokenProcess(c *gin.Context) {
+ // Get the parameters
+ providerID := c.Query("provider_id")
+ if providerID == "" {
+ RenderErrorPage(c, "Missing provider ID", "")
+ return
+ }
+
+ token := c.Query("token")
+ if token == "" {
+ RenderErrorPage(c, "Missing token", "")
+ return
+ }
+
+ // Parse provider ID
+ providerIDUint, err := strconv.ParseUint(providerID, 10, 64)
+ if err != nil {
+ RenderErrorPage(c, "Invalid provider ID", err.Error())
+ return
+ }
+
+ // Get the provider
+ provider, err := h.DB.GetStorageProvider(uint(providerIDUint))
+ if err != nil {
+ RenderErrorPage(c, "Provider not found", err.Error())
+ return
+ }
+
+ // Ensure it's a Google Drive or Google Photos provider
+ if provider.Type != "drive" && provider.Type != "gphotos" {
+ RenderErrorPage(c, "Not a Google provider", "")
+ return
+ }
+
+ // Mark the provider as authenticated
+ authenticated := true
+ provider.Authenticated = &authenticated
+ if err := h.DB.UpdateStorageProvider(provider); err != nil {
+ RenderErrorPage(c, "Failed to update provider", err.Error())
+ return
+ }
+
+ // Store the token in the provider's refresh token field
+ provider.RefreshToken = token
+ if err := h.DB.UpdateStorageProvider(provider); err != nil {
+ RenderErrorPage(c, "Failed to store token", err.Error())
+ return
+ }
+
+ // Redirect to the provider list with success
+ c.Redirect(http.StatusFound, "/storage-providers?status=gdrive_auth_success")
+}
+
+// HandleStorageProviderGDriveHeadlessAuth initiates the headless Google Drive/Photos authentication process for storage providers
+func (h *Handlers) HandleStorageProviderGDriveHeadlessAuth(c *gin.Context) {
+ // Get the provider ID from the query parameter
+ providerIDStr := c.Param("id")
+ if providerIDStr == "" {
+ RenderErrorPage(c, "Missing provider ID", "")
+ return
+ }
+
+ providerID, err := strconv.ParseUint(providerIDStr, 10, 64)
+ if err != nil {
+ RenderErrorPage(c, "Invalid provider ID", err.Error())
+ return
+ }
+
+ // Get the provider
+ provider, err := h.DB.GetStorageProvider(uint(providerID))
+ if err != nil {
+ RenderErrorPage(c, "Provider not found", err.Error())
+ return
+ }
+
+ // Ensure it's a Google Drive or Google Photos provider
+ if provider.Type != "drive" && provider.Type != "gphotos" {
+ RenderErrorPage(c, "Not a Google provider", "The selected provider is not set up for Google Drive or Google Photos")
+ return
+ }
+
+ // Determine which Google service we're authenticating with
+ var serviceType string
+ if provider.Type == "drive" {
+ serviceType = "drive"
+ } else {
+ serviceType = "gphotos"
+ }
+
+ // Get client ID and secret
+ clientID := provider.ClientID
+ clientSecret := provider.ClientSecret
+
+ // If not provided in provider, try env variables
+ if clientID == "" {
+ clientID = os.Getenv("GOOGLE_CLIENT_ID")
+ }
+ if clientSecret == "" {
+ clientSecret = os.Getenv("GOOGLE_CLIENT_SECRET")
+ }
+
+ // If still not provided, use default rclone values
+ if clientID == "" {
+ clientID = "202264815644.apps.googleusercontent.com"
+ }
+ if clientSecret == "" {
+ clientSecret = "X4Z3ca8xfWDb1Voo-F9a7ZxJ"
+ }
+
+ // Generate and return the authorize command to be run on a machine with a browser
+ authorizeCommand := fmt.Sprintf("rclone authorize \"%s\"", serviceType)
+
+ // If using custom client ID/secret, include them in the command
+ if clientID != "202264815644.apps.googleusercontent.com" || clientSecret != "X4Z3ca8xfWDb1Voo-F9a7ZxJ" {
+ authorizeCommand = fmt.Sprintf("rclone authorize \"%s\" %s %s", serviceType, clientID, clientSecret)
+ }
+
+ // Log the command for debugging
+ log.Printf("Generated headless auth command for provider: %s", authorizeCommand)
+
+ // Store provider ID in cookie for use during token submission
+ c.SetCookie("gdrive_headless_provider_id", providerIDStr, 3600*24, "/", "", false, true)
+
+ data := components.StorageProviderGDriveHeadlessAuthData{
+ AuthCommand: authorizeCommand,
+ ProviderID: providerIDStr,
+ }
+
+ components.StorageProviderGDriveHeadlessAuth(c, data).Render(c, c.Writer)
+}
+
+// HandleStorageProviderGDriveHeadlessTokenSubmit handles the submission of the token from the headless auth for storage providers
+func (h *Handlers) HandleStorageProviderGDriveHeadlessTokenSubmit(c *gin.Context) {
+ // Get the auth token from form submission
+ authToken := c.PostForm("auth_token")
+ if authToken == "" {
+ RenderErrorPage(c, "Missing authentication token", "")
+ return
+ }
+
+ // Get provider ID from cookie or form
+ providerIDStr, err := c.Cookie("gdrive_headless_provider_id")
+ if err != nil {
+ // If not in cookie, try from form
+ providerIDStr = c.PostForm("provider_id") // Use provider_id from the form
+ if providerIDStr == "" {
+ RenderErrorPage(c, "Authentication failed", "Unable to retrieve provider ID")
+ return
+ }
+ }
+
+ providerID, err := strconv.ParseUint(providerIDStr, 10, 64)
+ if err != nil {
+ RenderErrorPage(c, "Invalid provider ID", err.Error())
+ return
+ }
+
+ // Get the provider
+ provider, err := h.DB.GetStorageProvider(uint(providerID))
+ if err != nil {
+ RenderErrorPage(c, "Provider not found", err.Error())
+ return
+ }
+
+ // Mark the provider as authenticated
+ authenticated := true
+ provider.Authenticated = &authenticated
+ if err := h.DB.UpdateStorageProvider(provider); err != nil {
+ RenderErrorPage(c, "Failed to update provider", err.Error())
+ return
+ }
+
+ // Store the token in the provider's refresh token field
+ provider.RefreshToken = authToken
+ if err := h.DB.UpdateStorageProvider(provider); err != nil {
+ RenderErrorPage(c, "Failed to store token", err.Error())
+ return
+ }
+
+ // Clear cookie
+ c.SetCookie("gdrive_headless_provider_id", "", -1, "/", "", false, true)
+
+ // Redirect to the providers page with success message
+ c.Redirect(http.StatusFound, "/storage-providers?status=gdrive_auth_success")
+}
diff --git a/internal/web/handlers/storage_provider_handlers_test.go b/internal/web/handlers/storage_provider_handlers_test.go
index 8c0569c..03921d4 100644
--- a/internal/web/handlers/storage_provider_handlers_test.go
+++ b/internal/web/handlers/storage_provider_handlers_test.go
@@ -426,8 +426,8 @@ func TestInputValidation(t *testing.T) {
"ftp": true,
"smb": true,
"onedrive": true,
- "google_drive": true,
- "google_photo": true,
+ "drive": true,
+ "gphotos": true,
"hetzner": true,
"local": true,
}