mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-10 00:20:45 +02:00
Add Google Drive and Photos support with headless auth for storage providers
This commit is contained in:
Executable
+169
@@ -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) {
|
||||
<style>
|
||||
/* Ensure proper styling for the headless auth page */
|
||||
body.dark .auth-page {
|
||||
background-color: #111827 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="auth-container" class="auth-page w-full pb-8 bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; background-color: rgb(249, 250, 251);">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Headless Google Authentication
|
||||
</h1>
|
||||
<a href="/configs" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
|
||||
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
|
||||
Back to Configurations
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-info-circle h-5 w-5 text-blue-500"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 1: Run the following command on a machine with a web browser</h2>
|
||||
<div class="relative mb-4">
|
||||
<pre id="auth-command-text" class="bg-gray-50 dark:bg-gray-900 rounded-md p-4 overflow-x-auto text-sm font-mono">{ data.AuthCommand }</pre>
|
||||
<button id="copy-command" class="absolute top-2 right-2 bg-gray-200 dark:bg-gray-700 p-1.5 rounded hover:bg-gray-300 dark:hover:bg-gray-600" title="Copy to clipboard">
|
||||
<i class="fas fa-copy h-5 w-5 text-gray-700 dark:text-gray-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-md font-medium mb-2 text-gray-900 dark:text-white">What this command does:</h3>
|
||||
<ul class="list-disc ml-6 text-sm text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Opens a browser window on the machine where you run it</li>
|
||||
<li>Allows you to authenticate with Google</li>
|
||||
<li>Generates an authentication token</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 2: Paste the authentication token below</h2>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
|
||||
After completing authentication in the browser, you'll receive a token. Copy and paste that token here:
|
||||
</p>
|
||||
|
||||
<form action="/configs/gdrive-headless-token" method="POST" class="space-y-4">
|
||||
<input type="hidden" name="config_id" value={ data.ConfigID } />
|
||||
|
||||
<div>
|
||||
<label for="auth_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authentication Token</label>
|
||||
<textarea
|
||||
id="auth_token"
|
||||
name="auth_token"
|
||||
rows="5"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="Paste your authentication token here..."
|
||||
required
|
||||
></textarea>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">The token will look like a long JSON string containing access credentials.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-6">
|
||||
<a href="/configs" class="mr-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400">
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Submit Token
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">This authentication process is necessary for GoMFT to access your Google Drive or Google Photos account.</p>
|
||||
<p class="mt-1 text-gray-600 dark:text-gray-400">The token is only used for authentication and is stored securely. You'll only need to complete this process once for each configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('auth-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('auth-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Store the actual command text
|
||||
const actualCommand = document.getElementById('auth-command-text').textContent.trim();
|
||||
|
||||
// Add click handler to copy button
|
||||
document.getElementById('copy-command').addEventListener('click', function() {
|
||||
// Copy the actual command text, not the template variable
|
||||
navigator.clipboard.writeText(actualCommand).then(function() {
|
||||
// Show a success message
|
||||
const button = document.getElementById('copy-command');
|
||||
const originalTitle = button.getAttribute('title');
|
||||
button.setAttribute('title', 'Copied!');
|
||||
|
||||
// Also show visual feedback
|
||||
button.classList.add('bg-green-200', 'dark:bg-green-700');
|
||||
button.classList.remove('bg-gray-200', 'dark:bg-gray-700');
|
||||
|
||||
setTimeout(function() {
|
||||
button.setAttribute('title', originalTitle);
|
||||
button.classList.remove('bg-green-200', 'dark:bg-green-700');
|
||||
button.classList.add('bg-gray-200', 'dark:bg-gray-700');
|
||||
}, 2000);
|
||||
}).catch(function(err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
alert('Failed to copy command. Please select and copy it manually.');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
@@ -161,12 +161,12 @@ templ formFields(data StorageProviderFormData) {
|
||||
selected="selected"
|
||||
}
|
||||
>OneDrive</option>
|
||||
<option value="google_drive"
|
||||
<option value="drive"
|
||||
if data.Provider.Type == db.ProviderTypeGoogleDrive {
|
||||
selected="selected"
|
||||
}
|
||||
>Google Drive</option>
|
||||
<option value="google_photo"
|
||||
<option value="gphotos"
|
||||
if data.Provider.Type == db.ProviderTypeGooglePhoto {
|
||||
selected="selected"
|
||||
}
|
||||
@@ -512,7 +512,7 @@ templ providerFormScript() {
|
||||
}
|
||||
|
||||
// If Google Drive or Google Photos, update hidden fields
|
||||
if (['google_drive', 'google_photo', 'onedrive'].includes(providerType)) {
|
||||
if (['drive', 'gphotos', 'onedrive'].includes(providerType)) {
|
||||
document.getElementById('hidden_clientID').value = document.getElementById('clientID').value;
|
||||
|
||||
// Make sure clientSecret is always copied to the hidden field
|
||||
@@ -866,7 +866,7 @@ templ providerFormScript() {
|
||||
}
|
||||
|
||||
// Google services
|
||||
if (['google_drive', 'google_photo'].includes(provider)) {
|
||||
if (['drive', 'gphotos'].includes(provider)) {
|
||||
document.getElementById('cloud-fields').classList.remove('hidden');
|
||||
document.getElementById('built-in-auth-field').classList.remove('hidden');
|
||||
|
||||
@@ -880,9 +880,9 @@ templ providerFormScript() {
|
||||
|
||||
if (editMode) {
|
||||
// Edit mode - provider specific text
|
||||
if (provider === 'google_drive') {
|
||||
if (provider === 'drive') {
|
||||
clientSecretDescription.textContent = "Leave empty to keep the current Google Drive client secret";
|
||||
} else if (provider === 'google_photo') {
|
||||
} else if (provider === 'gphotos') {
|
||||
clientSecretDescription.textContent = "Leave empty to keep the current Google Photos client secret";
|
||||
}
|
||||
}
|
||||
@@ -890,13 +890,13 @@ templ providerFormScript() {
|
||||
}
|
||||
|
||||
// Google Drive specific fields
|
||||
if (provider === 'google_drive') {
|
||||
if (provider === 'drive') {
|
||||
document.getElementById('drive-id-field').classList.remove('hidden');
|
||||
document.getElementById('team-drive-field').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Google Photos specific fields
|
||||
if (provider === 'google_photo') {
|
||||
if (provider === 'gphotos') {
|
||||
document.getElementById('gphotos-options-field').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// StorageProviderGDriveHeadlessAuthData contains data needed for rendering the headless auth page for storage providers
|
||||
type StorageProviderGDriveHeadlessAuthData struct {
|
||||
AuthCommand string
|
||||
ProviderID string
|
||||
}
|
||||
|
||||
// StorageProviderGDriveHeadlessAuth renders the headless authentication page for Google Drive/Photos for storage providers
|
||||
templ StorageProviderGDriveHeadlessAuth(ctx context.Context, data StorageProviderGDriveHeadlessAuthData) {
|
||||
// Force the layout to display as authenticated content
|
||||
@LayoutWithContext("Google Authentication - Headless Mode", ctx) {
|
||||
<style>
|
||||
/* Ensure proper styling for the headless auth page */
|
||||
body.dark .auth-page {
|
||||
background-color: #111827 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="auth-container" class="auth-page w-full pb-8 bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; background-color: rgb(249, 250, 251);">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Headless Google Authentication
|
||||
</h1>
|
||||
<a href="/storage-providers" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
|
||||
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
|
||||
Back to Storage Providers
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-info-circle h-5 w-5 text-blue-500"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 1: Run the following command on a machine with a web browser</h2>
|
||||
<div class="relative mb-4">
|
||||
<pre id="auth-command-text" class="bg-gray-50 dark:bg-gray-900 rounded-md p-4 overflow-x-auto text-sm font-mono">{ data.AuthCommand }</pre>
|
||||
<button id="copy-command" class="absolute top-2 right-2 bg-gray-200 dark:bg-gray-700 p-1.5 rounded hover:bg-gray-300 dark:hover:bg-gray-600" title="Copy to clipboard">
|
||||
<i class="fas fa-copy h-5 w-5 text-gray-700 dark:text-gray-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-md font-medium mb-2 text-gray-900 dark:text-white">What this command does:</h3>
|
||||
<ul class="list-disc ml-6 text-sm text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<li>Opens a browser window on the machine where you run it</li>
|
||||
<li>Allows you to authenticate with Google</li>
|
||||
<li>Generates an authentication token</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 2: Paste the authentication token below</h2>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
|
||||
After completing authentication in the browser, you'll receive a token. Copy and paste that token here:
|
||||
</p>
|
||||
|
||||
<form action="/storage-providers/gdrive-headless-token" method="POST" class="space-y-4">
|
||||
<input type="hidden" name="provider_id" value={ data.ProviderID } />
|
||||
|
||||
<div>
|
||||
<label for="auth_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authentication Token</label>
|
||||
<textarea
|
||||
id="auth_token"
|
||||
name="auth_token"
|
||||
rows="5"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="Paste your authentication token here..."
|
||||
required
|
||||
></textarea>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">The token will look like a long JSON string containing access credentials.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-6">
|
||||
<a href="/storage-providers" class="mr-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400">
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Submit Token
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">This authentication process is necessary for GoMFT to access your Google Drive or Google Photos account.</p>
|
||||
<p class="mt-1 text-gray-600 dark:text-gray-400">The token is only used for authentication and is stored securely. You'll only need to complete this process once for each storage provider.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('auth-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('auth-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Store the actual command text
|
||||
const actualCommand = document.getElementById('auth-command-text').textContent.trim();
|
||||
|
||||
// Add click handler to copy button
|
||||
document.getElementById('copy-command').addEventListener('click', function() {
|
||||
// Copy the actual command text, not the template variable
|
||||
navigator.clipboard.writeText(actualCommand).then(function() {
|
||||
// Show a success message
|
||||
const button = document.getElementById('copy-command');
|
||||
const originalTitle = button.getAttribute('title');
|
||||
button.setAttribute('title', 'Copied!');
|
||||
|
||||
// Also show visual feedback
|
||||
button.classList.add('bg-green-200', 'dark:bg-green-700');
|
||||
button.classList.remove('bg-gray-200', 'dark:bg-gray-700');
|
||||
|
||||
setTimeout(function() {
|
||||
button.setAttribute('title', originalTitle);
|
||||
button.classList.remove('bg-green-200', 'dark:bg-green-700');
|
||||
button.classList.add('bg-gray-200', 'dark:bg-gray-700');
|
||||
}, 2000);
|
||||
}).catch(function(err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
alert('Failed to copy command. Please select and copy it manually.');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
@@ -306,6 +306,7 @@ templ StorageProviders(ctx context.Context, data StorageProvidersData) {
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Your credentials are encrypted for security. You can test connections before using them in transfers.</p>
|
||||
<p class="mt-1 text-gray-600 dark:text-gray-400">Google Drive and Google Photos providers require authentication. Click the "Authenticate" button to complete setup.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -322,6 +323,73 @@ templ StorageProviders(ctx context.Context, data StorageProvidersData) {
|
||||
</div>
|
||||
|
||||
<script src="/static/js/storage-providers.js"></script>
|
||||
|
||||
<script>
|
||||
// Function to initialize all auth dropdowns
|
||||
function initAllAuthDropdowns() {
|
||||
// Get all dropdown buttons
|
||||
const dropdownButtons = document.querySelectorAll('[id^="auth-dropdown-button-"]');
|
||||
|
||||
dropdownButtons.forEach(button => {
|
||||
const providerId = button.getAttribute('data-provider-id');
|
||||
const menu = document.getElementById(`auth-dropdown-menu-${providerId}`);
|
||||
|
||||
if (button && menu) {
|
||||
// Add click listener
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Position dropdown based on available space
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const spaceRight = window.innerWidth - buttonRect.right;
|
||||
const spaceLeft = buttonRect.left;
|
||||
|
||||
// Check if there's more space on the left or right side
|
||||
if (spaceLeft > spaceRight) {
|
||||
menu.classList.add('right-0');
|
||||
menu.classList.remove('left-0');
|
||||
} else {
|
||||
menu.classList.add('left-0');
|
||||
menu.classList.remove('right-0');
|
||||
}
|
||||
|
||||
// Toggle visibility
|
||||
menu.classList.toggle('hidden');
|
||||
console.log(`Toggled dropdown for provider ${providerId}`);
|
||||
});
|
||||
|
||||
console.log(`Initialized dropdown for provider ${providerId}`);
|
||||
} else {
|
||||
console.error(`Could not find dropdown elements for provider ${providerId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdowns when clicking elsewhere
|
||||
document.addEventListener('click', function(e) {
|
||||
dropdownButtons.forEach(button => {
|
||||
const providerId = button.getAttribute('data-provider-id');
|
||||
const menu = document.getElementById(`auth-dropdown-menu-${providerId}`);
|
||||
|
||||
if (menu && !button.contains(e.target) && !menu.contains(e.target)) {
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize dropdowns when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check for status messages based on URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('status') === 'gdrive_auth_success') {
|
||||
showToast("Google Drive authentication completed successfully", 'success');
|
||||
}
|
||||
|
||||
// Initialize all authentication dropdowns
|
||||
initAllAuthDropdowns();
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,11 +484,11 @@ templ StorageProviders_ProvidersList(providers []db.StorageProvider) {
|
||||
Region: { provider.Region }
|
||||
</p>
|
||||
}
|
||||
} else if provider.Type == "google_drive" || provider.Type == "google_photo" {
|
||||
} else if provider.Type == "drive" || provider.Type == "gphotos" {
|
||||
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fab fa-google-drive w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||
Google
|
||||
if provider.Type == "google_drive" {
|
||||
if provider.Type == "drive" {
|
||||
Drive
|
||||
} else {
|
||||
Photos
|
||||
@@ -431,12 +499,51 @@ templ StorageProviders_ProvidersList(providers []db.StorageProvider) {
|
||||
</p>
|
||||
if provider.Authenticated != nil && *provider.Authenticated {
|
||||
<span class="mt-2 md:mt-0 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
|
||||
Authenticated
|
||||
</span>
|
||||
} else {
|
||||
<span class="mt-2 md:mt-0 bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300">
|
||||
Not Authenticated
|
||||
</span>
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center mt-2 md:mt-0">
|
||||
|
||||
<!-- Google Authentication Dropdown -->
|
||||
<div class="relative inline-block text-left mt-2 md:mt-0">
|
||||
<button
|
||||
id={ fmt.Sprintf("auth-dropdown-button-%d", provider.ID) }
|
||||
data-provider-id={ fmt.Sprintf("%d", provider.ID) }
|
||||
type="button"
|
||||
class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true">
|
||||
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
|
||||
Authenticate with Google
|
||||
<i class="fas fa-chevron-down w-3.5 h-3.5 ml-1.5"></i>
|
||||
</button>
|
||||
<div id={ fmt.Sprintf("auth-dropdown-menu-%d", provider.ID) } class="origin-top-right absolute left-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-700 ring-1 ring-black ring-opacity-5 focus:outline-none z-50 hidden" style="max-height: 200px; overflow-y: auto;" role="menu" aria-orientation="vertical" aria-labelledby={ fmt.Sprintf("auth-dropdown-button-%d", provider.ID) }>
|
||||
<div class="py-1" role="none">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 group flex items-center px-4 py-2 text-sm" role="menuitem">
|
||||
<i class="fas fa-globe w-4 h-4 mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Standard Authentication
|
||||
</a>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 group flex items-center px-4 py-2 text-sm" role="menuitem">
|
||||
<i class="fas fa-terminal w-4 h-4 mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Headless Authentication
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden fallback links - only shown when JavaScript is disabled -->
|
||||
<noscript>
|
||||
<div class="flex flex-col text-xs text-gray-500 dark:text-gray-400 mt-1 ml-1">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="hover:underline hover:text-blue-500">
|
||||
Direct Standard Auth
|
||||
</a>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="hover:underline hover:text-blue-500">
|
||||
Direct Headless Auth
|
||||
</a>
|
||||
</div>
|
||||
</noscript>
|
||||
</div>
|
||||
}
|
||||
} else if provider.Type == "webdav" || provider.Type == "nextcloud" {
|
||||
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
@@ -493,6 +600,33 @@ templ StorageProviders_ProvidersList(providers []db.StorageProvider) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authentication Notice (More Visible) -->
|
||||
if (provider.Type == "drive" || provider.Type == "gphotos") && (provider.Authenticated == nil || !*provider.Authenticated) {
|
||||
<div class="mt-3 flex items-center justify-between bg-yellow-50 dark:bg-yellow-900/30 rounded-lg p-3 border border-yellow-200 dark:border-yellow-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500 w-5 h-5 mr-2"></i>
|
||||
<span class="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
Authentication required for Google
|
||||
if provider.Type == "drive" {
|
||||
Drive
|
||||
} else {
|
||||
Photos
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg">
|
||||
<i class="fas fa-globe w-3.5 h-3.5 mr-1.5"></i>
|
||||
Standard Auth
|
||||
</a>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="text-sm font-medium text-white bg-green-600 hover:bg-green-700 px-3 py-1.5 rounded-lg">
|
||||
<i class="fas fa-terminal w-3.5 h-3.5 mr-1.5"></i>
|
||||
Headless Auth
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user