feat: Refactor Google Drive and Google Photos authentication handling

- Separate built-in authentication options for source and destination in configuration forms.
- Update UI components to reflect changes in authentication handling for Google Drive and Google Photos.
- Implement path validation for local directories in the configuration forms, enhancing user experience.
- Revise README to clarify supported source and destination types, including updated terminology for Google Drive.
- Introduce new API endpoint for path validation, improving error handling and user feedback.
This commit is contained in:
StarFleetCPTN
2025-03-16 16:12:42 -07:00
parent 522699d96a
commit b1db53b89e
32 changed files with 676 additions and 548 deletions
+2 -2
View File
@@ -348,8 +348,8 @@ User management features:
### Transfer Configuration Options
1. **Source/Destination Types**:
- Google Drive (with built-in or custom authentication)
- Google Photos (with built-in or custom authentication)
- Google Drive
- Google Photos
- Local filesystem
- Amazon S3
- MinIO (S3-compatible storage)
+41 -8
View File
@@ -84,7 +84,8 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles := true
maxConcurrentTransfers := 4
rcloneFlags := ""
useBuiltinAuth := true
useBuiltinAuthSource := true
useBuiltinAuthDest := true
// If editing an existing config, populate with those values
if config != nil {
@@ -162,14 +163,19 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles = config.GetSkipProcessedFiles()
maxConcurrentTransfers = config.MaxConcurrentTransfers
rcloneFlags = config.RcloneFlags
if config.UseBuiltinAuth != nil {
useBuiltinAuth = *config.UseBuiltinAuth
if config.UseBuiltinAuthSource != nil {
useBuiltinAuthSource = *config.UseBuiltinAuthSource
} else if sourceClientId != "" || sourceClientSecret != "" {
useBuiltinAuthSource = false
}
if config.UseBuiltinAuthDest != nil {
useBuiltinAuthDest = *config.UseBuiltinAuthDest
} else if destClientId != "" || destClientSecret != "" {
useBuiltinAuth = false
useBuiltinAuthDest = false
}
}
// Return the JSON-formatted string with all the data
// Return the JSON-formatted string with all the data, add new path validation states
return fmt.Sprintf(`{
name: '%s',
sourceType: '%s',
@@ -223,7 +229,8 @@ func getInitialData(config *db.TransferConfig) string {
destStartYear: %d,
destIncludeArchived: %v,
useBuiltinAuth: %v,
useBuiltinAuthSource: %v,
useBuiltinAuthDest: %v,
archivePath: '%s',
archiveEnabled: %v,
@@ -231,6 +238,32 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles: %v,
maxConcurrentTransfers: %d,
rcloneFlags: '%s',
// Path validation states
sourcePathValid: null,
sourcePathError: '',
destPathValid: null,
destPathError: '',
// Methods for path validation
checkPath(path, type) {
if (!path) {
this[type + 'PathValid'] = false;
this[type + 'PathError'] = 'Path cannot be empty';
return;
}
fetch('/check-path?path=' + encodeURIComponent(path))
.then(response => response.json())
.then(data => {
this[type + 'PathValid'] = data.valid;
this[type + 'PathError'] = data.error || '';
})
.catch(error => {
this[type + 'PathValid'] = false;
this[type + 'PathError'] = 'Error checking path: ' + error.message;
});
}
}`,
name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceAuthType,
sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint, sourceShare, sourceDomain, sourcePassiveMode,
@@ -241,7 +274,7 @@ func getInitialData(config *db.TransferConfig) string {
destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, destShare, destDomain, destPassiveMode,
destClientId, destClientSecret, destDriveId, destTeamDrive,
destReadOnly, destStartYear, destIncludeArchived,
useBuiltinAuth,
useBuiltinAuthSource, useBuiltinAuthDest,
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags)
}
@@ -321,7 +354,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
@source.NextCloudSourceForm()
</template>
<template x-if="sourceType === 'google_drive'">
<template x-if="sourceType === 'gdrive'">
@source.GoogleDriveSourceForm()
</template>
+3 -3
View File
@@ -289,7 +289,7 @@ templ Configs(ctx context.Context, data ConfigsData) {
<!-- Google Drive Authentication Button -->
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="btn-warning btn-sm">
<i class="fab fa-google-drive mr-1"></i>
<i class="fab fa-google mr-1"></i>
Authenticate
</a>
}
@@ -356,8 +356,8 @@ templ Configs(ctx context.Context, data ConfigsData) {
<!-- Google Drive Auth Help -->
<div class="mt-4 text-center">
<p class="text-sm text-secondary-500 dark:text-secondary-400">
<i class="fab fa-google-drive mr-1 text-blue-500 inline-flex items-center"></i>
Google Drive configurations require authentication. Click the "Authenticate" button to complete setup.
<i class="fab fa-google mr-1 text-blue-500 inline-flex items-center"></i>
Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.
</p>
</div>
</div>
+1 -1
View File
@@ -194,7 +194,7 @@ templ SourceSelection() {
<option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option>
<option value="google_drive">Google Drive</option>
<option value="gdrive">Google Drive</option>
<option value="gphotos">Google Photos</option>
</select>
</div>
+13 -12
View File
@@ -1,22 +1,23 @@
package destination
templ GoogleDriveDestinationForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuth', value => {
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
if(value) {
destClientId = '';
destClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth" class="flex items-center cursor-pointer">
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth" name="use_builtin_auth" type="checkbox"
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
class="sr-only"
x-model="useBuiltinAuth"
x-model="useBuiltinAuthDest"
:value="useBuiltinAuthDest ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuth ? 'transform translate-x-6 bg-primary-500' : ''"></div>
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
@@ -27,11 +28,11 @@ templ GoogleDriveDestinationForm() {
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuth }">
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
<div>
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -39,7 +40,7 @@ templ GoogleDriveDestinationForm() {
</div>
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Drive OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -50,7 +51,7 @@ templ GoogleDriveDestinationForm() {
<div class="mt-4">
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -58,7 +59,7 @@ templ GoogleDriveDestinationForm() {
</div>
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Drive OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -140,7 +141,7 @@ templ GoogleDriveDestinationForm() {
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuth">
<template x-if="useBuiltinAuthDest">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
@@ -155,7 +156,7 @@ templ GoogleDriveDestinationForm() {
</p>
</div>
</template>
<template x-if="!useBuiltinAuth">
<template x-if="!useBuiltinAuthDest">
<div>
<p>To use Google Drive with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
+15 -14
View File
@@ -1,22 +1,23 @@
package destination
templ GooglePhotosDestinationForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuth', value => {
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
if(value) {
destClientId = '';
destClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth" class="flex items-center cursor-pointer">
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth" name="use_builtin_auth" type="checkbox"
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
class="sr-only"
x-model="useBuiltinAuth"
x-model="useBuiltinAuthDest"
:value="useBuiltinAuthDest ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuth ? 'transform translate-x-6 bg-primary-500' : ''"></div>
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
@@ -27,11 +28,11 @@ templ GooglePhotosDestinationForm() {
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuth }">
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
<div>
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -39,7 +40,7 @@ templ GooglePhotosDestinationForm() {
</div>
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Photos OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -50,7 +51,7 @@ templ GooglePhotosDestinationForm() {
<div class="mt-4">
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -58,7 +59,7 @@ templ GooglePhotosDestinationForm() {
</div>
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Photos OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -130,10 +131,10 @@ templ GooglePhotosDestinationForm() {
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Album path (e.g., /album/my-photos)" />
placeholder="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within Google Photos where files will be uploaded
Path within Google Photos where files will be uploaded.
</p>
</div>
@@ -165,7 +166,7 @@ templ GooglePhotosDestinationForm() {
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuth">
<template x-if="useBuiltinAuthDest">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
@@ -180,7 +181,7 @@ templ GooglePhotosDestinationForm() {
</p>
</div>
</template>
<template x-if="!useBuiltinAuth">
<template x-if="!useBuiltinAuthDest">
<div>
<p>To use Google Photos with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
+19 -6
View File
@@ -6,7 +6,7 @@ templ LocalDestinationForm() {
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
@@ -14,13 +14,26 @@ templ LocalDestinationForm() {
id="destination_path"
x-model="destinationPath"
required
aria-describedby="destination_path_help"
:class="{ 'border-red-300 dark:border-red-700': destPathValid === false, 'border-green-300 dark:border-green-700': destPathValid === true }"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/destination"/>
placeholder="Local directory path (e.g., /path/to/destination)"
/>
</div>
<p id="destination_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Absolute path to the local directory where files will be saved.
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Full path to the local directory where files will be saved
</p>
<!-- Path validation message -->
<template x-if="destPathValid !== null">
<p x-show="destPathError" class="mt-1 text-sm" :class="destPathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="destPathError"></p>
</template>
</div>
<button
type="button"
class="btn-secondary h-10 whitespace-nowrap"
@click="checkPath(destinationPath, 'dest')"
>
<i class="fas fa-check-circle mr-2"></i>
Check Location
</button>
</div>
}
}
+11 -10
View File
@@ -1,7 +1,7 @@
package source
templ GoogleDriveSourceForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuth', value => {
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
if(value) {
sourceClientId = '';
sourceClientSecret = '';
@@ -12,11 +12,12 @@ templ GoogleDriveSourceForm() {
<div class="relative">
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
class="sr-only"
x-model="useBuiltinAuth"
x-model="useBuiltinAuthSource"
:value="useBuiltinAuthSource ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuth ? 'transform translate-x-6 bg-primary-500' : ''"></div>
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
@@ -27,11 +28,11 @@ templ GoogleDriveSourceForm() {
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuth }">
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
<div>
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -39,7 +40,7 @@ templ GoogleDriveSourceForm() {
</div>
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Drive OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -50,7 +51,7 @@ templ GoogleDriveSourceForm() {
<div class="mt-4">
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -58,7 +59,7 @@ templ GoogleDriveSourceForm() {
</div>
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Drive OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -140,7 +141,7 @@ templ GoogleDriveSourceForm() {
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuth">
<template x-if="useBuiltinAuthSource">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
@@ -155,7 +156,7 @@ templ GoogleDriveSourceForm() {
</p>
</div>
</template>
<template x-if="!useBuiltinAuth">
<template x-if="!useBuiltinAuthSource">
<div>
<p>To use Google Drive with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
+12 -11
View File
@@ -1,7 +1,7 @@
package source
templ GooglePhotosSourceForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuth', value => {
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
if(value) {
sourceClientId = '';
sourceClientSecret = '';
@@ -12,11 +12,12 @@ templ GooglePhotosSourceForm() {
<div class="relative">
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
class="sr-only"
x-model="useBuiltinAuth"
x-model="useBuiltinAuthSource"
:value="useBuiltinAuthSource ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuth ? 'transform translate-x-6 bg-primary-500' : ''"></div>
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
@@ -27,11 +28,11 @@ templ GooglePhotosSourceForm() {
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuth }">
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
<div>
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -39,7 +40,7 @@ templ GooglePhotosSourceForm() {
</div>
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Photos OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -50,7 +51,7 @@ templ GooglePhotosSourceForm() {
<div class="mt-4">
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuth" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -58,7 +59,7 @@ templ GooglePhotosSourceForm() {
</div>
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuth"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Photos OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
@@ -130,7 +131,7 @@ templ GooglePhotosSourceForm() {
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Album path (e.g., /album/my-photos)" />
placeholder="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within Google Photos to download files from
@@ -165,7 +166,7 @@ templ GooglePhotosSourceForm() {
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuth">
<template x-if="useBuiltinAuthSource">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
@@ -180,7 +181,7 @@ templ GooglePhotosSourceForm() {
</p>
</div>
</template>
<template x-if="!useBuiltinAuth">
<template x-if="!useBuiltinAuthSource">
<div>
<p>To use Google Photos with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
+19 -6
View File
@@ -6,7 +6,7 @@ templ LocalSourceForm() {
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
@@ -14,13 +14,26 @@ templ LocalSourceForm() {
id="source_path"
x-model="sourcePath"
required
aria-describedby="source_path_help"
:class="{ 'border-red-300 dark:border-red-700': sourcePathValid === false, 'border-green-300 dark:border-green-700': sourcePathValid === true }"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/source"/>
placeholder="Local directory path (e.g., /path/to/files)"
/>
</div>
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Absolute path to the local directory containing the files to transfer.
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Full path to the local directory containing your files
</p>
<!-- Path validation message -->
<template x-if="sourcePathValid !== null">
<p x-show="sourcePathError" class="mt-1 text-sm" :class="sourcePathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="sourcePathError"></p>
</template>
</div>
<button
type="button"
class="btn-secondary h-10 whitespace-nowrap"
@click="checkPath(sourcePath, 'source')"
>
<i class="fas fa-check-circle mr-2"></i>
Check Location
</button>
</div>
}
}
+1 -1
View File
@@ -711,7 +711,7 @@ func handleListHistory(database *db.DB) gin.HandlerFunc {
// For now, just return the most recent 100 history entries for the user's jobs
var history []db.JobHistory
err := database.DB.
Joins("JOIN jobs ON job_histories.job_id = jobs.id").
Joins("JOIN jobs ON job_history.job_id = jobs.id").
Where("jobs.created_by = ?", userID).
Order("start_time DESC").
Limit(100).
+34 -25
View File
@@ -11,7 +11,7 @@ import (
"time"
"github.com/glebarez/sqlite"
"github.com/starfleetcptn/gomft/internal/auth"
"github.com/starfleetcptn/gomft/internal/db/migrations"
"gorm.io/gorm"
)
@@ -109,7 +109,8 @@ type TransferConfig struct {
DestStartYear int `form:"dest_start_year"` // For Google Photos
DestIncludeArchived *bool `form:"dest_include_archived"` // For Google Photos
// Security fields
UseBuiltinAuth *bool `form:"use_builtin_auth"` // For Google and other OAuth services
UseBuiltinAuthSource *bool `form:"use_builtin_auth_source"` // For Google and other OAuth services
UseBuiltinAuthDest *bool `form:"use_builtin_auth_dest"` // For Google and other OAuth services
GoogleDriveAuthenticated *bool // Whether Google Drive auth is completed
// General fields
ArchivePath string `form:"archive_path"`
@@ -250,10 +251,10 @@ func Initialize(dbPath string) (*DB, error) {
return nil, fmt.Errorf("failed to connect to database: %v", err)
}
// Auto migrate the schema
err = db.AutoMigrate(&User{}, &auth.PasswordHistory{}, &PasswordResetToken{}, &TransferConfig{}, &Job{}, &JobHistory{}, &FileMetadata{})
if err != nil {
return nil, fmt.Errorf("failed to migrate database: %v", err)
// Initialize and run migrations
m := migrations.InitMigrations(db)
if err := m.Migrate(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %v", err)
}
return &DB{DB: db}, nil
@@ -627,7 +628,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
}
case "google_drive":
case "gdrive":
args := []string{
"config", "create", sourceName, "drive",
"client_id", config.SourceClientID,
@@ -906,24 +907,6 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
args = append(args, "include_archived", "true")
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
case "google_drive":
args := []string{
"config", "create", destName, "drive",
"client_id", config.DestClientID,
"client_secret", config.DestClientSecret,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.DestTeamDrive != "" {
args = append(args, "team_drive", config.DestTeamDrive)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
@@ -1461,3 +1444,29 @@ func (db *DB) GetGDriveCredentialsFromConfig(config *TransferConfig) (string, st
return "", ""
}
// GetUseBuiltinAuthSource returns the value of UseBuiltinAuthSource with a default if nil
func (tc *TransferConfig) GetUseBuiltinAuthSource() bool {
if tc.UseBuiltinAuthSource == nil {
return true // Default to true if not set
}
return *tc.UseBuiltinAuthSource
}
// SetUseBuiltinAuthSource sets the UseBuiltinAuthSource field
func (tc *TransferConfig) SetUseBuiltinAuthSource(value bool) {
tc.UseBuiltinAuthSource = &value
}
// GetUseBuiltinAuthDest returns the value of UseBuiltinAuthDest with a default if nil
func (tc *TransferConfig) GetUseBuiltinAuthDest() bool {
if tc.UseBuiltinAuthDest == nil {
return true // Default to true if not set
}
return *tc.UseBuiltinAuthDest
}
// SetUseBuiltinAuthDest sets the UseBuiltinAuthDest field
func (tc *TransferConfig) SetUseBuiltinAuthDest(value bool) {
tc.UseBuiltinAuthDest = &value
}
+29 -26
View File
@@ -853,7 +853,7 @@ func TestGoogleDriveTransferConfig(t *testing.T) {
// Test 1: Create config with Google Drive as source
googleSourceConfig := &TransferConfig{
Name: "Google Drive Source Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/path/in/google/drive",
SourceClientID: "google_client_id",
SourceClientSecret: "google_client_secret",
@@ -878,7 +878,7 @@ func TestGoogleDriveTransferConfig(t *testing.T) {
Name: "Google Drive Destination Test",
SourceType: "local",
SourcePath: "/local/source/path",
DestinationType: "google_drive",
DestinationType: "gdrive",
DestinationPath: "/path/in/google/drive",
DestClientID: "google_client_id",
DestClientSecret: "google_client_secret",
@@ -898,12 +898,12 @@ func TestGoogleDriveTransferConfig(t *testing.T) {
// Test 3: Create config with Google Drive as both source and destination
googleBothConfig := &TransferConfig{
Name: "Google Drive Both Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/source/path/in/google/drive",
SourceClientID: "source_google_client_id",
SourceClientSecret: "source_google_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "google_drive",
DestinationType: "gdrive",
DestinationPath: "/dest/path/in/google/drive",
DestClientID: "dest_google_client_id",
DestClientSecret: "dest_google_client_secret",
@@ -923,7 +923,7 @@ func TestGoogleDriveTransferConfig(t *testing.T) {
// Test retrieving and verifying Google Drive configs
retrievedSourceConfig, err := db.GetTransferConfig(googleSourceConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "google_drive", retrievedSourceConfig.SourceType)
assert.Equal(t, "gdrive", retrievedSourceConfig.SourceType)
assert.Equal(t, "/path/in/google/drive", retrievedSourceConfig.SourcePath)
assert.Equal(t, "google_client_id", retrievedSourceConfig.SourceClientID)
assert.Equal(t, "team_drive_id", retrievedSourceConfig.SourceTeamDrive)
@@ -931,7 +931,7 @@ func TestGoogleDriveTransferConfig(t *testing.T) {
retrievedDestConfig, err := db.GetTransferConfig(googleDestConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "google_drive", retrievedDestConfig.DestinationType)
assert.Equal(t, "gdrive", retrievedDestConfig.DestinationType)
assert.Equal(t, "/path/in/google/drive", retrievedDestConfig.DestinationPath)
assert.Equal(t, "google_client_id", retrievedDestConfig.DestClientID)
assert.Equal(t, "team_drive_id", retrievedDestConfig.DestTeamDrive)
@@ -986,7 +986,7 @@ func TestGoogleDriveJobExecution(t *testing.T) {
// Create a test transfer config with Google Drive as source
googleConfig := &TransferConfig{
Name: "Google Drive Job Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/source/path/in/google/drive",
SourceClientID: "google_client_id",
SourceClientSecret: "google_client_secret",
@@ -1149,7 +1149,7 @@ func TestGoogleDriveAuthentication(t *testing.T) {
// Create a Google Drive config that requires authentication
googleConfig := &TransferConfig{
Name: "Google Drive Auth Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/source/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
@@ -1234,7 +1234,7 @@ func TestGoogleDriveErrorHandling(t *testing.T) {
// Test 1: Create a config with missing required fields
incompleteConfig := &TransferConfig{
Name: "Incomplete Google Drive Config",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "", // Missing path
DestinationType: "local",
DestinationPath: "/local/path",
@@ -1249,7 +1249,7 @@ func TestGoogleDriveErrorHandling(t *testing.T) {
// Test 2: Config with invalid Team Drive ID
invalidTeamDriveConfig := &TransferConfig{
Name: "Invalid Team Drive Config",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/test/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
@@ -1279,7 +1279,7 @@ func TestGoogleDriveErrorHandling(t *testing.T) {
// Test 3: Test authentication error scenario - using malformed token
badTokenConfig := &TransferConfig{
Name: "Bad Token Config",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/test/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
@@ -1325,7 +1325,7 @@ func TestGoogleDriveTeamDrive(t *testing.T) {
// Test 1: Configure source with Team Drive
teamDriveSourceConfig := &TransferConfig{
Name: "Team Drive Source Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/shared/documents",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
@@ -1350,7 +1350,7 @@ func TestGoogleDriveTeamDrive(t *testing.T) {
Name: "Team Drive Destination Test",
SourceType: "local",
SourcePath: "/local/source",
DestinationType: "google_drive",
DestinationType: "gdrive",
DestinationPath: "/team/drive/path",
DestClientID: "test_client_id",
DestClientSecret: "test_client_secret",
@@ -1370,12 +1370,12 @@ func TestGoogleDriveTeamDrive(t *testing.T) {
// Test 3: Configure both source and destination with Team Drive
teamDriveBothConfig := &TransferConfig{
Name: "Team Drive Both Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/source/team/drive/path",
SourceClientID: "source_client_id",
SourceClientSecret: "source_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "google_drive",
DestinationType: "gdrive",
DestinationPath: "/dest/team/drive/path",
DestClientID: "dest_client_id",
DestClientSecret: "dest_client_secret",
@@ -1434,7 +1434,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
// Test 1: Create config with Google Photos as source
sourceReadOnly := true
sourceIncludeArchived := false
useBuiltinAuth := true
useBuiltinAuthSource := true
googleSourceConfig := &TransferConfig{
Name: "Google Photos Source Test",
SourceType: "gphotos",
@@ -1444,7 +1444,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
SourceReadOnly: &sourceReadOnly,
SourceStartYear: 2015,
SourceIncludeArchived: &sourceIncludeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthSource: &useBuiltinAuthSource,
DestinationType: "local",
DestinationPath: "/local/destination/path",
FilePattern: "*.jpg",
@@ -1463,6 +1463,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
// Test 2: Create config with Google Photos as destination
destReadOnly := false
destIncludeArchived := true
useBuiltinAuthDest := true
googleDestConfig := &TransferConfig{
Name: "Google Photos Destination Test",
SourceType: "local",
@@ -1474,7 +1475,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
DestReadOnly: &destReadOnly,
DestStartYear: 2018,
DestIncludeArchived: &destIncludeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthDest: &useBuiltinAuthDest,
FilePattern: "*.png",
CreatedBy: testUser.ID,
}
@@ -1504,7 +1505,8 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
DestReadOnly: &destReadOnly,
DestStartYear: 2020,
DestIncludeArchived: &destIncludeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthSource: &useBuiltinAuthSource,
UseBuiltinAuthDest: &useBuiltinAuthDest,
FilePattern: "*.jpeg",
CreatedBy: testUser.ID,
}
@@ -1524,7 +1526,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
assert.Equal(t, sourceReadOnly, *retrievedConfig.SourceReadOnly)
assert.Equal(t, 2015, retrievedConfig.SourceStartYear)
assert.Equal(t, sourceIncludeArchived, *retrievedConfig.SourceIncludeArchived)
assert.Equal(t, useBuiltinAuth, *retrievedConfig.UseBuiltinAuth)
assert.Equal(t, useBuiltinAuthSource, *retrievedConfig.UseBuiltinAuthSource)
assert.Equal(t, true, retrievedConfig.GetGoogleAuthenticated())
retrievedConfig, err = db.GetTransferConfig(googleDestConfig.ID)
@@ -1533,7 +1535,7 @@ func TestGooglePhotosTransferConfig(t *testing.T) {
assert.Equal(t, destReadOnly, *retrievedConfig.DestReadOnly)
assert.Equal(t, 2018, retrievedConfig.DestStartYear)
assert.Equal(t, destIncludeArchived, *retrievedConfig.DestIncludeArchived)
assert.Equal(t, useBuiltinAuth, *retrievedConfig.UseBuiltinAuth)
assert.Equal(t, useBuiltinAuthDest, *retrievedConfig.UseBuiltinAuthDest)
assert.Equal(t, true, retrievedConfig.GetGoogleAuthenticated())
}
@@ -1573,7 +1575,7 @@ func TestGooglePhotosRcloneConfig(t *testing.T) {
// TEST 1: Google Photos as source with standard options
sourceReadOnly := true
sourceIncludeArchived := false
useBuiltinAuth := true
useBuiltinAuthSource := true
gphotosSourceConfig := &TransferConfig{
ID: 1, // Force ID for predictable config path
Name: "Google Photos Source Config",
@@ -1584,7 +1586,7 @@ func TestGooglePhotosRcloneConfig(t *testing.T) {
SourceReadOnly: &sourceReadOnly,
SourceStartYear: 2015,
SourceIncludeArchived: &sourceIncludeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthSource: &useBuiltinAuthSource,
DestinationType: "local",
DestinationPath: "/tmp/destination",
FilePattern: "*.jpg",
@@ -1617,6 +1619,7 @@ func TestGooglePhotosRcloneConfig(t *testing.T) {
// TEST 2: Google Photos as destination with authenticated token
destReadOnly := false
destIncludeArchived := true
useBuiltinAuthDest := true
gphotosDestConfig := &TransferConfig{
ID: 2, // Force ID for predictable config path
Name: "Google Photos Destination Config",
@@ -1629,7 +1632,7 @@ func TestGooglePhotosRcloneConfig(t *testing.T) {
DestReadOnly: &destReadOnly,
DestStartYear: 2018,
DestIncludeArchived: &destIncludeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthDest: &useBuiltinAuthDest,
FilePattern: "*.png",
CreatedBy: testUser.ID,
}
@@ -1686,7 +1689,7 @@ func TestGooglePhotosAuthentication(t *testing.T) {
// Create a transfer config with Google Photos
readOnly := true
includeArchived := false
useBuiltinAuth := true
useBuiltinAuthSource := true
gPhotosConfig := &TransferConfig{
Name: "Test Google Photos Auth",
SourceType: "gphotos",
@@ -1696,7 +1699,7 @@ func TestGooglePhotosAuthentication(t *testing.T) {
SourceReadOnly: &readOnly,
SourceStartYear: 2015,
SourceIncludeArchived: &includeArchived,
UseBuiltinAuth: &useBuiltinAuth,
UseBuiltinAuthSource: &useBuiltinAuthSource,
DestinationType: "local",
DestinationPath: "/tmp/destination",
FilePattern: "*.jpg",
@@ -0,0 +1,278 @@
package migrations
import (
"fmt"
"os"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func InitialSchema() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "001_initial_schema",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Create backup file with timestamp
backupFile := fmt.Sprintf("%s.backup.%s", dbPath, time.Now().Format("20060102_150405"))
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Disable foreign key constraints while creating tables
if err := tx.Exec("PRAGMA foreign_keys = OFF").Error; err != nil {
return fmt.Errorf("failed to disable foreign key constraints: %v", err)
}
defer func() {
if err := tx.Exec("PRAGMA foreign_keys = ON").Error; err != nil {
fmt.Printf("Warning: failed to re-enable foreign key constraints: %v\n", err)
}
}()
// Change the table name from job_histories to job_history
if err := tx.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='job_histories'").Scan(&count).Error; err != nil {
return err
}
if count > 0 {
if err := tx.Exec("ALTER TABLE job_histories RENAME TO job_history").Error; err != nil {
return err
}
}
// Change the table name from password_histories to password_history
count = 0
if err := tx.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='password_histories'").Scan(&count).Error; err != nil {
return err
}
if count > 0 {
if err := tx.Exec("ALTER TABLE password_histories RENAME TO password_history").Error; err != nil {
return err
}
}
// Create Users table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_admin BOOLEAN DEFAULT FALSE,
last_password_change DATETIME,
failed_login_attempts INTEGER DEFAULT 0,
account_locked BOOLEAN DEFAULT FALSE,
lockout_until DATETIME,
theme VARCHAR(255) DEFAULT 'light',
created_at DATETIME,
updated_at DATETIME
)`).Error; err != nil {
return err
}
// Create PasswordHistory table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS password_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create PasswordResetToken table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create TransferConfigs table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS transfer_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
source_type VARCHAR(255) NOT NULL,
source_path TEXT NOT NULL,
source_host VARCHAR(255),
source_port INTEGER DEFAULT 22,
source_user VARCHAR(255),
source_key_file TEXT,
source_bucket VARCHAR(255),
source_region VARCHAR(255),
source_access_key VARCHAR(255),
source_endpoint VARCHAR(255),
source_share VARCHAR(255),
source_domain VARCHAR(255),
source_passive_mode BOOLEAN DEFAULT TRUE,
source_client_id VARCHAR(255),
source_drive_id VARCHAR(255),
source_team_drive VARCHAR(255),
source_read_only BOOLEAN,
source_start_year INTEGER,
source_include_archived BOOLEAN,
file_pattern VARCHAR(255) DEFAULT '*',
output_pattern TEXT,
destination_type VARCHAR(255) NOT NULL,
destination_path TEXT NOT NULL,
dest_host VARCHAR(255),
dest_port INTEGER DEFAULT 22,
dest_user VARCHAR(255),
dest_key_file TEXT,
dest_bucket VARCHAR(255),
dest_region VARCHAR(255),
dest_access_key VARCHAR(255),
dest_endpoint VARCHAR(255),
dest_share VARCHAR(255),
dest_domain VARCHAR(255),
dest_passive_mode BOOLEAN DEFAULT TRUE,
dest_client_id VARCHAR(255),
dest_drive_id VARCHAR(255),
dest_team_drive VARCHAR(255),
dest_read_only BOOLEAN,
dest_start_year INTEGER,
dest_include_archived BOOLEAN,
use_builtin_auth_source BOOLEAN DEFAULT TRUE,
use_builtin_auth_dest BOOLEAN DEFAULT TRUE,
google_drive_authenticated BOOLEAN,
archive_path TEXT,
archive_enabled BOOLEAN DEFAULT FALSE,
rclone_flags TEXT,
delete_after_transfer BOOLEAN DEFAULT FALSE,
skip_processed_files BOOLEAN DEFAULT TRUE,
max_concurrent_transfers INTEGER DEFAULT 4,
created_by INTEGER,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (created_by) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create Jobs table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255),
config_id INTEGER NOT NULL,
config_ids TEXT,
schedule VARCHAR(255) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
last_run DATETIME,
next_run DATETIME,
webhook_enabled BOOLEAN DEFAULT FALSE,
webhook_url TEXT,
webhook_secret TEXT,
webhook_headers TEXT,
notify_on_success BOOLEAN DEFAULT TRUE,
notify_on_failure BOOLEAN DEFAULT TRUE,
created_by INTEGER,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (config_id) REFERENCES transfer_configs(id),
FOREIGN KEY (created_by) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create JobHistory table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS job_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
config_id INTEGER DEFAULT 0,
start_time DATETIME NOT NULL,
end_time DATETIME,
status VARCHAR(255) NOT NULL,
bytes_transferred INTEGER,
files_transferred INTEGER,
error_message TEXT,
FOREIGN KEY (job_id) REFERENCES jobs(id)
)`).Error; err != nil {
return err
}
// Create FileMetadata table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS file_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
config_id INTEGER DEFAULT 0,
file_name VARCHAR(255) NOT NULL,
original_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_hash VARCHAR(255),
creation_time DATETIME,
mod_time DATETIME,
processed_time DATETIME NOT NULL,
destination_path TEXT NOT NULL,
status VARCHAR(255) NOT NULL,
error_message TEXT,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (job_id) REFERENCES jobs(id)
)`).Error; err != nil {
return err
}
// Re-enable foreign key constraints and verify integrity
if err := tx.Exec("PRAGMA foreign_key_check").Error; err != nil {
return fmt.Errorf("foreign key integrity check failed: %v", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop tables in reverse order to handle foreign key constraints
tables := []string{
"file_metadata",
"job_history",
"jobs",
"transfer_configs",
"password_reset_tokens",
"password_history",
"users",
}
for _, table := range tables {
if err := tx.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", table)).Error; err != nil {
return err
}
}
return nil
},
}
}
@@ -0,0 +1,49 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// UpdateBuiltinAuthFields updates the use_builtin_auth field to separate source and destination fields
func UpdateBuiltinAuthFields() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "002_update_builtin_auth_fields",
Migrate: func(tx *gorm.DB) error {
// First, add the new columns
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth_source BOOLEAN`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth_dest BOOLEAN`).Error; err != nil {
return err
}
// Copy the old value to both new columns
if err := tx.Exec(`UPDATE transfer_configs SET
use_builtin_auth_source = use_builtin_auth,
use_builtin_auth_dest = use_builtin_auth`).Error; err != nil {
return err
}
// Drop the old column
return tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth`).Error
},
Rollback: func(tx *gorm.DB) error {
// Add back the original column
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth BOOLEAN`).Error; err != nil {
return err
}
// Copy the source value back (could also use dest, they should be the same)
if err := tx.Exec(`UPDATE transfer_configs SET use_builtin_auth = use_builtin_auth_source`).Error; err != nil {
return err
}
// Drop the new columns
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth_source`).Error; err != nil {
return err
}
return tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth_dest`).Error
},
}
}
@@ -0,0 +1,31 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// UpdateGDriveType updates the source_type and destination_type from 'google_drive' to 'gdrive'
func UpdateGDriveType() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "003_update_gdrive_type",
Migrate: func(tx *gorm.DB) error {
// Update source_type
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'gdrive' WHERE source_type = 'google_drive'`).Error; err != nil {
return err
}
// Update destination_type
return tx.Exec(`UPDATE transfer_configs SET destination_type = 'gdrive' WHERE destination_type = 'google_drive'`).Error
},
Rollback: func(tx *gorm.DB) error {
// Revert source_type
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'google_drive' WHERE source_type = 'gdrive'`).Error; err != nil {
return err
}
// Revert destination_type
return tx.Exec(`UPDATE transfer_configs SET destination_type = 'google_drive' WHERE destination_type = 'gdrive'`).Error
},
}
}
@@ -1,63 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddCloudStorageFields adds fields for WebDAV, NextCloud, OneDrive, and Google Drive
func AddCloudStorageFields() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "add_cloud_storage_fields",
Migrate: func(tx *gorm.DB) error {
// Add source fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_client_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_drive_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_team_drive VARCHAR(255)").Error; err != nil {
return err
}
// Add destination fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_client_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_drive_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_team_drive VARCHAR(255)").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop source fields
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_client_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_drive_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_team_drive").Error; err != nil {
return err
}
// Drop destination fields
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_client_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_drive_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_team_drive").Error; err != nil {
return err
}
return nil
},
}
}
@@ -1,19 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddDeleteAfterTransferColumn adds the delete_after_transfer column to transfer_configs table
func AddDeleteAfterTransferColumn() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "add_delete_after_transfer_column",
Migrate: func(tx *gorm.DB) error {
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN delete_after_transfer BOOLEAN NOT NULL DEFAULT false").Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN delete_after_transfer").Error
},
}
}
@@ -1,21 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddGoogleDriveAuthenticated adds the GoogleDriveAuthenticated field to the transfer_configs table
func AddGoogleDriveAuthenticated() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20240315_add_google_drive_authenticated",
Migrate: func(tx *gorm.DB) error {
// Add the GoogleDriveAuthenticated column with a default value of false
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN google_drive_authenticated BOOLEAN DEFAULT false").Error
},
Rollback: func(tx *gorm.DB) error {
// Remove the GoogleDriveAuthenticated column
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN google_drive_authenticated").Error
},
}
}
@@ -1,61 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddGooglePhotosSupport adds Google Photos related fields to the transfer_configs table
func AddGooglePhotosSupport() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20240518_add_google_photos_support",
Migrate: func(tx *gorm.DB) error {
// Add Google Photos source fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_read_only BOOLEAN DEFAULT false").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_start_year INTEGER DEFAULT 0").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_include_archived BOOLEAN DEFAULT false").Error; err != nil {
return err
}
// Add Google Photos destination fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_read_only BOOLEAN DEFAULT false").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_start_year INTEGER DEFAULT 0").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_include_archived BOOLEAN DEFAULT false").Error; err != nil {
return err
}
// Add OAuth field
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth BOOLEAN DEFAULT true").Error
},
Rollback: func(tx *gorm.DB) error {
// Remove all added columns in reverse order
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_include_archived").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_start_year").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_read_only").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_include_archived").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_start_year").Error; err != nil {
return err
}
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_read_only").Error
},
}
}
@@ -1,21 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddMaxConcurrentTransfersColumn adds the max_concurrent_transfers column to transfer_configs table
func AddMaxConcurrentTransfersColumn() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250311_add_max_concurrent_transfers",
Migrate: func(tx *gorm.DB) error {
// Add max_concurrent_transfers column with default value of 4
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN max_concurrent_transfers INTEGER DEFAULT 4").Error
},
Rollback: func(tx *gorm.DB) error {
// Drop the column if needed
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN max_concurrent_transfers").Error
},
}
}
@@ -1,49 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddMultiConfigSupport adds support for multiple configurations per job
func AddMultiConfigSupport() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250315_add_multi_config_support",
Migrate: func(tx *gorm.DB) error {
// Add config_ids column to jobs table
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN config_ids TEXT").Error; err != nil {
return err
}
// Add config_id column to job_histories table
if err := tx.Exec("ALTER TABLE job_histories ADD COLUMN config_id INTEGER").Error; err != nil {
return err
}
// Add config_id column to file_metadata table
if err := tx.Exec("ALTER TABLE file_metadata ADD COLUMN config_id INTEGER").Error; err != nil {
return err
}
// Update existing jobs to set the config_ids field to match the current config_id
if err := tx.Exec("UPDATE jobs SET config_ids = config_id WHERE config_id > 0").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop the config_id columns from job_histories and file_metadata
if err := tx.Exec("ALTER TABLE job_histories DROP COLUMN config_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE file_metadata DROP COLUMN config_id").Error; err != nil {
return err
}
// Drop the config_ids column from jobs
return tx.Exec("ALTER TABLE jobs DROP COLUMN config_ids").Error
},
}
}
@@ -1,21 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddSkipProcessedFilesColumn adds the skip_processed_files column to transfer_configs table
func AddSkipProcessedFilesColumn() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250310_add_skip_processed_files",
Migrate: func(tx *gorm.DB) error {
// Add skip_processed_files column with default value of true
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN skip_processed_files BOOLEAN DEFAULT true").Error
},
Rollback: func(tx *gorm.DB) error {
// Drop the column if needed
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN skip_processed_files").Error
},
}
}
@@ -1,61 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddWebhookSupport adds webhook notification fields to the jobs table
func AddWebhookSupport() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20240618_add_webhook_support",
Migrate: func(tx *gorm.DB) error {
// Add webhook URL field
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_enabled BOOLEAN DEFAULT false").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_url VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_secret VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_headers TEXT").Error; err != nil {
return err
}
// Add notification settings
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN notify_on_success BOOLEAN DEFAULT true").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN notify_on_failure BOOLEAN DEFAULT true").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop the webhook fields from jobs
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_enabled").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_url").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_secret").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_headers").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN notify_on_success").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN notify_on_failure").Error; err != nil {
return err
}
return nil
},
}
}
+3 -10
View File
@@ -8,16 +8,9 @@ import (
// InitMigrations initializes the migrations
func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
migrations := []*gormigrate.Migration{
// ... existing migrations
AddDeleteAfterTransferColumn(),
AddCloudStorageFields(),
AddSkipProcessedFilesColumn(),
AddMaxConcurrentTransfersColumn(),
AddMultiConfigSupport(),
UpdateSkipProcessedFilesToNullable(),
AddWebhookSupport(),
AddGoogleDriveAuthenticated(),
AddGooglePhotosSupport(),
InitialSchema(),
UpdateBuiltinAuthFields(),
UpdateGDriveType(),
}
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
@@ -1,70 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// UpdateSkipProcessedFilesToNullable changes the skip_processed_files column to be nullable
func UpdateSkipProcessedFilesToNullable() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250515_update_skip_processed_files_to_nullable",
Migrate: func(tx *gorm.DB) error {
// SQLite specific command - this would need to be adjusted for other databases
return tx.Exec("ALTER TABLE transfer_configs RENAME TO transfer_configs_old; " +
"CREATE TABLE transfer_configs (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"name VARCHAR(255) NOT NULL, " +
"source_type VARCHAR(255) NOT NULL, " +
"source_path VARCHAR(255) NOT NULL, " +
"source_host VARCHAR(255), " +
"source_port INTEGER DEFAULT 22, " +
"source_user VARCHAR(255), " +
"source_key_file VARCHAR(255), " +
"source_bucket VARCHAR(255), " +
"source_region VARCHAR(255), " +
"source_access_key VARCHAR(255), " +
"source_endpoint VARCHAR(255), " +
"source_share VARCHAR(255), " +
"source_domain VARCHAR(255), " +
"source_passive_mode BOOLEAN DEFAULT true, " +
"source_client_id VARCHAR(255), " +
"source_drive_id VARCHAR(255), " +
"source_team_drive VARCHAR(255), " +
"file_pattern VARCHAR(255) DEFAULT '*', " +
"output_pattern VARCHAR(255), " +
"destination_type VARCHAR(255) NOT NULL, " +
"destination_path VARCHAR(255) NOT NULL, " +
"dest_host VARCHAR(255), " +
"dest_port INTEGER DEFAULT 22, " +
"dest_user VARCHAR(255), " +
"dest_key_file VARCHAR(255), " +
"dest_bucket VARCHAR(255), " +
"dest_region VARCHAR(255), " +
"dest_access_key VARCHAR(255), " +
"dest_endpoint VARCHAR(255), " +
"dest_share VARCHAR(255), " +
"dest_domain VARCHAR(255), " +
"dest_passive_mode BOOLEAN DEFAULT true, " +
"dest_client_id VARCHAR(255), " +
"dest_drive_id VARCHAR(255), " +
"dest_team_drive VARCHAR(255), " +
"archive_path VARCHAR(255), " +
"archive_enabled BOOLEAN DEFAULT false, " +
"rclone_flags VARCHAR(255), " +
"delete_after_transfer BOOLEAN DEFAULT false, " +
"skip_processed_files BOOLEAN DEFAULT true, " + // Keep as BOOLEAN, but now it's nullable
"max_concurrent_transfers INTEGER DEFAULT 4, " +
"created_by INTEGER, " +
"created_at DATETIME, " +
"updated_at DATETIME" +
"); " +
"INSERT INTO transfer_configs SELECT * FROM transfer_configs_old; " +
"DROP TABLE transfer_configs_old;").Error
},
Rollback: func(tx *gorm.DB) error {
// No need to rollback as the data structure remains compatible
return nil
},
}
}
+4 -4
View File
@@ -72,7 +72,7 @@ func TestGenerateRcloneConfigWithoutRclone(t *testing.T) {
assert.NoError(t, err)
// Test configs for different source types
sourceTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "google_drive"}
sourceTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "gdrive"}
for _, sourceType := range sourceTypes {
testConfig := &TransferConfig{
@@ -102,7 +102,7 @@ func TestGenerateRcloneConfigWithoutRclone(t *testing.T) {
}
// Test configs for different destination types
destTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "google_drive"}
destTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "gdrive"}
for _, destType := range destTypes {
testConfig := &TransferConfig{
@@ -164,7 +164,7 @@ func TestGoogleDriveRcloneConfig(t *testing.T) {
// Create Google Drive source config
googleSourceConfig := &TransferConfig{
Name: "Google Drive Source Rclone Test",
SourceType: "google_drive",
SourceType: "gdrive",
SourcePath: "/path/in/google/drive",
SourceClientID: "source_google_client_id",
SourceClientSecret: "source_google_client_secret",
@@ -209,7 +209,7 @@ func TestGoogleDriveRcloneConfig(t *testing.T) {
Name: "Google Drive Dest Rclone Test",
SourceType: "local",
SourcePath: "/local/source/path",
DestinationType: "google_drive",
DestinationType: "gdrive",
DestinationPath: "/dest/path/in/google/drive",
DestClientID: "dest_google_client_id",
DestClientSecret: "dest_google_client_secret",
+15 -6
View File
@@ -71,6 +71,7 @@ func (h *Handlers) HandleEditConfig(c *gin.Context) {
// HandleCreateConfig handles the POST /configs route
func (h *Handlers) HandleCreateConfig(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBind(&config); err != nil {
log.Printf("Error binding config form: %v", err)
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
@@ -118,9 +119,13 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
sourceIncludeArchivedValue := sourceIncludeArchivedVal == "on" || sourceIncludeArchivedVal == "true"
config.SourceIncludeArchived = &sourceIncludeArchivedValue
useBuiltinAuthVal := c.Request.FormValue("use_builtin_auth")
useBuiltinAuthValue := useBuiltinAuthVal == "on" || useBuiltinAuthVal == "true"
config.UseBuiltinAuth = &useBuiltinAuthValue
useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source")
useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true"
config.UseBuiltinAuthSource = &useBuiltinAuthSourceValue
useBuiltinAuthDestVal := c.Request.FormValue("use_builtin_auth_dest")
useBuiltinAuthDestValue := useBuiltinAuthDestVal == "on" || useBuiltinAuthDestVal == "true"
config.UseBuiltinAuthDest = &useBuiltinAuthDestValue
if err := h.DB.Create(&config).Error; err != nil {
log.Printf("Error creating config: %v", err)
@@ -209,9 +214,13 @@ func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
sourceIncludeArchivedValue := sourceIncludeArchivedVal == "on" || sourceIncludeArchivedVal == "true"
config.SourceIncludeArchived = &sourceIncludeArchivedValue
useBuiltinAuthVal := c.Request.FormValue("use_builtin_auth")
useBuiltinAuthValue := useBuiltinAuthVal == "on" || useBuiltinAuthVal == "true"
config.UseBuiltinAuth = &useBuiltinAuthValue
useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source")
useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true"
config.UseBuiltinAuthSource = &useBuiltinAuthSourceValue
useBuiltinAuthDestVal := c.Request.FormValue("use_builtin_auth_dest")
useBuiltinAuthDestValue := useBuiltinAuthDestVal == "on" || useBuiltinAuthDestVal == "true"
config.UseBuiltinAuthDest = &useBuiltinAuthDestValue
// Preserve fields that shouldn't be updated
config.CreatedBy = oldConfig.CreatedBy
+3 -3
View File
@@ -37,7 +37,7 @@ func (h *Handlers) HandleGDriveAuth(c *gin.Context) {
}
// Ensure it's a Google Drive or Google Photos configuration
if config.DestinationType != "gdrive" && config.DestinationType != "gphotos" {
if config.SourceType != "gdrive" && config.DestinationType != "gdrive" && config.SourceType != "gphotos" && config.DestinationType != "gphotos" {
RenderErrorPage(c, "Not a Google configuration", "The selected configuration is not set up for Google Drive or Google Photos")
return
}
@@ -365,8 +365,8 @@ func (h *Handlers) HandleGDriveTokenProcess(c *gin.Context) {
return
}
// Ensure it's a Google Drive configuration
if config.DestinationType != "gdrive" {
// Ensure it's a Google Drive or Google Photos configuration
if config.SourceType != "gdrive" && config.DestinationType != "gdrive" && config.SourceType != "gphotos" && config.DestinationType != "gphotos" {
RenderErrorPage(c, "Not a Google Drive configuration", "")
return
}
+14 -14
View File
@@ -64,26 +64,26 @@ func (h *MockHandlers) HandleGDriveAuth(c *gin.Context) {
// Get the config ID from the query parameter
configIDStr := c.Param("id")
if configIDStr == "" {
RenderErrorPage(c, "Missing configuration ID", "")
RenderErrorPageTest(c, "Missing configuration ID", "")
return
}
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
RenderErrorPage(c, "Invalid configuration ID", err.Error())
RenderErrorPageTest(c, "Invalid configuration ID", err.Error())
return
}
// Get the configuration
config, err := h.DB.GetTransferConfig(uint(configID))
if err != nil {
RenderErrorPage(c, "Configuration not found", err.Error())
RenderErrorPageTest(c, "Configuration not found", err.Error())
return
}
// Ensure it's a Google Drive or Google Photos configuration
if config.DestinationType != "gdrive" && config.DestinationType != "gphotos" {
RenderErrorPage(c, "Not a Google configuration", "The selected configuration is not set up for Google Drive or Google Photos")
RenderErrorPageTest(c, "Not a Google configuration", "The selected configuration is not set up for Google Drive or Google Photos")
return
}
@@ -96,14 +96,14 @@ func (h *MockHandlers) HandleGDriveAuth(c *gin.Context) {
// Get Rclone Config Path
rcloneConfigPath := h.DB.GetConfigRclonePath(config)
if rcloneConfigPath == "" {
RenderErrorPage(c, "Rclone config not found", "The selected configuration does not have a valid rclone config")
RenderErrorPageTest(c, "Rclone config not found", "The selected configuration does not have a valid rclone config")
return
}
// 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())
RenderErrorPageTest(c, "Failed to create temporary directory", err.Error())
return
}
@@ -180,7 +180,7 @@ func (h *MockHandlers) HandleGDriveAuthCallback(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")
RenderErrorPageTest(c, "Authentication failed", "No authorization code received from Google")
return
}
@@ -188,27 +188,27 @@ func (h *MockHandlers) HandleGDriveAuthCallback(c *gin.Context) {
state := c.Query("state")
storedState, err := c.Cookie("gdrive_auth_state")
if err != nil || state != storedState {
RenderErrorPage(c, "Authentication failed", "Invalid state parameter")
RenderErrorPageTest(c, "Authentication failed", "Invalid state parameter")
return
}
// Get config ID from cookie
configIDStr, err := c.Cookie("gdrive_config_id")
if err != nil {
RenderErrorPage(c, "Authentication failed", "Unable to retrieve configuration ID")
RenderErrorPageTest(c, "Authentication failed", "Unable to retrieve configuration ID")
return
}
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
RenderErrorPage(c, "Invalid configuration ID", err.Error())
RenderErrorPageTest(c, "Invalid configuration ID", err.Error())
return
}
// Get the configuration
config, err := h.DB.GetTransferConfig(uint(configID))
if err != nil {
RenderErrorPage(c, "Failed to get configuration", err.Error())
RenderErrorPageTest(c, "Failed to get configuration", err.Error())
return
}
@@ -219,7 +219,7 @@ func (h *MockHandlers) HandleGDriveAuthCallback(c *gin.Context) {
// Update the config with the token
err = h.DB.GenerateRcloneConfigWithToken(config, mockToken)
if err != nil {
RenderErrorPage(c, "Failed to update configuration", err.Error())
RenderErrorPageTest(c, "Failed to update configuration", err.Error())
return
}
@@ -420,8 +420,8 @@ func TestHandleGDriveAuth_NonGoogleConfig(t *testing.T) {
assert.Contains(t, w.Body.String(), "Not a Google configuration")
}
// RenderErrorPage renders an error page with the given message
func RenderErrorPage(c *gin.Context, title string, details string) {
// RenderErrorPageTest renders an error page with the given message
func RenderErrorPageTest(c *gin.Context, title string, details string) {
// Here we'd typically use a component for error display
// For now, we'll just render a simple HTML error page for testing
errorHTML := fmt.Sprintf("<html><body><h1>Error: %s</h1><p>%s</p></body></html>", title, details)
+76
View File
@@ -0,0 +1,76 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
)
// HandleCheckPath validates if a given path exists and is accessible
func (h *Handlers) HandleCheckPath(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{
"valid": false,
"error": "No path provided",
})
return
}
// Clean and resolve the path
path = filepath.Clean(path)
absPath, err := filepath.Abs(path)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Invalid path format",
})
return
}
// Check if path exists
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Path does not exist",
})
return
}
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Error accessing path: " + err.Error(),
})
return
}
// Check if it's a directory
if !info.IsDir() {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Path exists but is not a directory",
})
return
}
// Check if we have read access
testFile := filepath.Join(absPath, ".gomft_test")
f, err := os.OpenFile(testFile, os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Directory exists but is not writable",
})
return
}
f.Close()
os.Remove(testFile)
c.JSON(http.StatusOK, gin.H{
"valid": true,
"error": "",
})
}
+3
View File
@@ -32,6 +32,9 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.POST("/configs/:id", h.HandleUpdateConfig)
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
// Path validation endpoint
authorized.GET("/check-path", h.HandleCheckPath)
// Google Drive authentication routes
authorized.GET("/configs/:id/gdrive-auth", h.HandleGDriveAuth)
authorized.GET("/configs/gdrive-callback", h.HandleGDriveAuthCallback)