feat: Integrate Google Drive support and enhance configuration handling

- Add Google Drive as a source and destination option in the configuration forms.
- Implement Google Drive authentication flow and token management.
- Update job and configuration handlers to support Google Drive-specific settings.
- Enhance UI components to include Google Drive configuration templates.
- Introduce new tests for Google Drive integration and ensure proper handling of authentication and configuration.
- Update database migrations to accommodate new fields related to Google Drive configurations.
This commit is contained in:
StarFleetCPTN
2025-03-15 17:36:36 -07:00
parent a954023800
commit 8607f2098a
52 changed files with 2795 additions and 355 deletions
+2 -1
View File
@@ -85,7 +85,8 @@ script hideDialog(id string) {
}
script submitFormAndHideDialog(formId string, dialogId string) {
document.getElementById(formId).submit();
// Use HTMX's API to trigger the request instead of bypassing it
htmx.trigger(document.getElementById(formId), 'submit');
document.getElementById(dialogId).classList.add("hidden");
}
+17 -4
View File
@@ -75,6 +75,7 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles := true
maxConcurrentTransfers := 4
rcloneFlags := ""
useBuiltinAuth := true
// If editing an existing config, populate with those values
if config != nil {
@@ -96,7 +97,7 @@ func getInitialData(config *db.TransferConfig) string {
sourceEndpoint = config.SourceEndpoint
sourceShare = config.SourceShare
sourceDomain = config.SourceDomain
sourcePassiveMode = config.SourcePassiveMode
sourcePassiveMode = config.GetSourcePassiveMode()
sourceClientId = config.SourceClientID
sourceClientSecret = config.SourceClientSecret
sourceDriveId = config.SourceDriveID
@@ -122,18 +123,21 @@ func getInitialData(config *db.TransferConfig) string {
destEndpoint = config.DestEndpoint
destShare = config.DestShare
destDomain = config.DestDomain
destPassiveMode = config.DestPassiveMode
destPassiveMode = config.GetDestPassiveMode()
destClientId = config.DestClientID
destClientSecret = config.DestClientSecret
destDriveId = config.DestDriveID
destTeamDrive = config.DestTeamDrive
archivePath = config.ArchivePath
archiveEnabled = config.ArchiveEnabled
deleteAfterTransfer = config.DeleteAfterTransfer
archiveEnabled = config.GetArchiveEnabled()
deleteAfterTransfer = config.GetDeleteAfterTransfer()
skipProcessedFiles = config.GetSkipProcessedFiles()
maxConcurrentTransfers = config.MaxConcurrentTransfers
rcloneFlags = config.RcloneFlags
if destClientId != "" || destClientSecret != "" {
useBuiltinAuth = false
}
}
// Return the JSON-formatted string with all the data
@@ -183,6 +187,7 @@ func getInitialData(config *db.TransferConfig) string {
destClientSecret: '%s',
destDriveId: '%s',
destTeamDrive: '%s',
useBuiltinAuth: %v,
archivePath: '%s',
archiveEnabled: %v,
@@ -198,6 +203,7 @@ func getInitialData(config *db.TransferConfig) string {
destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile, destAuthType,
destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, destShare, destDomain, destPassiveMode,
destClientId, destClientSecret, destDriveId, destTeamDrive,
useBuiltinAuth,
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags)
}
@@ -277,6 +283,9 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
@source.NextCloudSourceForm()
</template>
<template x-if="sourceType === 'google_drive'">
@source.GoogleDriveSourceForm()
</template>
<!-- File pattern fields -->
@common.FilePatternFields()
@@ -317,6 +326,10 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
@destination.WebDAVDestinationForm()
</template>
<template x-if="destinationType === 'gdrive'">
@destination.GoogleDriveDestinationForm()
</template>
<!-- Archive options -->
@common.ArchiveOptions()
+56
View File
@@ -68,6 +68,9 @@ script triggerConfigDelete(dialogId string, configID uint, configName string) {
type ConfigsData struct {
Configs []db.TransferConfig
Error string
ErrorDetails string
Status string
}
templ Configs(ctx context.Context, data ConfigsData) {
@@ -106,6 +109,27 @@ templ Configs(ctx context.Context, data ConfigsData) {
console.log("Notyf initialized:", window.notyf);
}
// Show status messages based on URL parameters
document.addEventListener('DOMContentLoaded', function() {
// Check for error message
const urlParams = new URLSearchParams(window.location.search);
const errorMsg = urlParams.get('error');
const errorDetails = urlParams.get('details');
const status = urlParams.get('status');
if (errorMsg) {
let message = errorMsg;
if (errorDetails) {
message += ": " + errorDetails;
}
window.notyf.error(message);
}
if (status === 'gdrive_auth_success') {
window.notyf.success("Google Drive authentication completed successfully");
}
});
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
@@ -244,8 +268,32 @@ templ Configs(ctx context.Context, data ConfigsData) {
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
{ config.Name }
</p>
<!-- Google Drive Authentication Badge -->
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.DestinationType == "drive") && !config.GetGoogleDriveAuthenticated() {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">
<i class="fas fa-exclamation-triangle mr-1 flex items-center"></i>
Authentication Required
</span>
}
<!-- Google Drive Authentication Status Indicator -->
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.DestinationType == "drive") && config.GetGoogleDriveAuthenticated() {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
<i class="fas fa-check-circle mr-1 flex items-center"></i>
Authenticated
</span>
}
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<!-- Google Drive Authentication Button -->
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive") && !config.GetGoogleDriveAuthenticated() {
<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>
Authenticate
</a>
}
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="btn-secondary btn-sm">
<i class="fas fa-edit mr-1"></i>
Edit
@@ -304,6 +352,14 @@ templ Configs(ctx context.Context, data ConfigsData) {
Configurations define how files are transferred between systems
</p>
</div>
<!-- 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.
</p>
</div>
</div>
</div>
}
+26 -19
View File
@@ -194,13 +194,14 @@ templ JobForm(ctx context.Context, data JobFormData) {
id="enabled"
name="enabled"
value="true"
checked
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
if data.Job.GetEnabled() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-exclamation-triangle mr-1 text-amber-500"></i>
Disabled jobs will not run automatically.
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Jobs that are not enabled will not run automatically on schedule.
</p>
</div>
@@ -218,9 +219,12 @@ templ JobForm(ctx context.Context, data JobFormData) {
id="webhook_enabled"
name="webhook_enabled"
value="true"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
if data.Job.GetWebhookEnabled() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
<label for="webhook_enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Enable webhook notifications
Send webhook notification on completion
</label>
</div>
@@ -293,7 +297,9 @@ templ JobForm(ctx context.Context, data JobFormData) {
id="notify_on_success"
name="notify_on_success"
value="true"
checked
if data.Job.GetNotifyOnSuccess() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
<label for="notify_on_success" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Notify on successful jobs
@@ -306,7 +312,9 @@ templ JobForm(ctx context.Context, data JobFormData) {
id="notify_on_failure"
name="notify_on_failure"
value="true"
checked
if data.Job.GetNotifyOnFailure() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
<label for="notify_on_failure" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Notify on failed jobs
@@ -437,15 +445,14 @@ templ JobForm(ctx context.Context, data JobFormData) {
id="enabled"
name="enabled"
value="true"
if data.Job.Enabled {
if data.Job.GetEnabled() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-exclamation-triangle mr-1 text-amber-500"></i>
Disabled jobs will not run automatically.
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Jobs that are not enabled will not run automatically on schedule.
</p>
</div>
@@ -463,12 +470,12 @@ templ JobForm(ctx context.Context, data JobFormData) {
id="webhook_enabled"
name="webhook_enabled"
value="true"
if data.Job.WebhookEnabled {
if data.Job.GetWebhookEnabled() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
<label for="webhook_enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Enable webhook notifications
Send webhook notification on completion
</label>
</div>
@@ -544,7 +551,7 @@ templ JobForm(ctx context.Context, data JobFormData) {
id="notify_on_success"
name="notify_on_success"
value="true"
if data.Job.NotifyOnSuccess {
if data.Job.GetNotifyOnSuccess() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
@@ -559,7 +566,7 @@ templ JobForm(ctx context.Context, data JobFormData) {
id="notify_on_failure"
name="notify_on_failure"
value="true"
if data.Job.NotifyOnFailure {
if data.Job.GetNotifyOnFailure() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
+1 -1
View File
@@ -287,7 +287,7 @@ templ Jobs(ctx context.Context, data JobsData) {
{ job.Config.Name }
}
</p>
if job.Enabled {
if job.GetEnabled() {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
Active
</span>
+1 -1
View File
@@ -34,7 +34,7 @@ templ Profile(ctx context.Context, user db.User) {
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Role</dt>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
if user.IsAdmin {
if user.GetIsAdmin() {
<span class="badge badge-success">
<i class="fas fa-user-shield mr-1"></i> Administrator
</span>
+2
View File
@@ -194,6 +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>
</select>
</div>
</div>
@@ -217,6 +218,7 @@ templ DestinationSelection() {
<option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option>
<option value="gdrive">Google Drive</option>
</select>
</div>
</div>
@@ -0,0 +1,178 @@
package destination
templ GoogleDriveDestinationForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuth', value => {
if(value) {
destClientId = '';
destClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth" name="use_builtin_auth" type="checkbox"
class="sr-only"
x-model="useBuiltinAuth"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
:class="useBuiltinAuth ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
Use rclone's built-in Google authentication (recommended)
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Simple one-click authentication using rclone's shared credentials
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuth }">
<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>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
</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"
placeholder="Google Drive OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client ID from Google Cloud Console
</p>
</div>
<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>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</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"
placeholder="Google Drive OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client Secret from Google Cloud Console
</p>
</div>
</div>
<div class="mt-4">
<label for="dest_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Folder ID (Optional)</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>
</div>
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destDriveId"
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="Google Drive Folder ID (optional)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Folder ID to use as the root (leave empty for "My Drive")
</p>
</div>
<div class="mt-4">
<label for="dest_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Shared/Team Drive ID (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-users text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destTeamDrive"
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="Shared/Team Drive ID (optional)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
ID of the Shared Drive / Team Drive to use
</p>
</div>
<div class="mt-4">
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">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-open text-secondary-400 dark:text-secondary-600"></i>
</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="Path within Google Drive (e.g., /backup)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within the Drive where files will be uploaded
</p>
</div>
<div class="p-4 bg-amber-50 rounded-lg border border-amber-100">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800">Important: Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700">
<p><strong>After saving this configuration</strong>, you will need to authenticate with Google Drive.</p>
<p class="mt-1">The authentication process will require you to:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Visit a Google authorization URL</li>
<li>Sign in to your Google account</li>
<li>Grant permission to access your Google Drive</li>
<li>Copy the authorization code back to this application</li>
</ol>
<p class="mt-2 text-xs">
This is a one-time process for each configuration. The application will store your authorization token securely.
</p>
</div>
</div>
</div>
</div>
<div class="p-4 bg-blue-50 rounded-lg border border-blue-100">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700">
<template x-if="useBuiltinAuth">
<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">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through a browser window</li>
<li>You will need to manually copy the authorization code back</li>
</ul>
<p class="mt-2 text-xs text-amber-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuth">
<div>
<p>To use Google Drive with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 underline">Google Cloud Console</a></li>
<li>Create a project and enable the Google Drive API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
}
+10
View File
@@ -86,6 +86,11 @@ func TestSourceProviders(t *testing.T) {
err := source.WebDAVSourceForm().Render(ctx, &buf)
return buf.String(), err
}},
{"GoogleDriveSourceForm", func() (string, error) {
var buf strings.Builder
err := source.GoogleDriveSourceForm().Render(ctx, &buf)
return buf.String(), err
}},
}
for _, provider := range providers {
@@ -138,6 +143,11 @@ func TestDestinationProviders(t *testing.T) {
err := destination.WebDAVDestinationForm().Render(ctx, &buf)
return buf.String(), err
}},
{"GoogleDriveDestinationForm", func() (string, error) {
var buf strings.Builder
err := destination.GoogleDriveDestinationForm().Render(ctx, &buf)
return buf.String(), err
}},
}
for _, provider := range providers {
+129
View File
@@ -0,0 +1,129 @@
package source
templ GoogleDriveSourceForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuth', value => {
if(value) {
sourceClientId = '';
sourceClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth_source" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
class="sr-only"
x-model="useBuiltinAuth"
/>
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
:class="useBuiltinAuth ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
Use rclone's built-in Google authentication (recommended)
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Simple one-click authentication using rclone's shared credentials
</p>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuth }">
<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>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
</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"
placeholder="Google Drive OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client ID from Google Cloud Console
</p>
</div>
<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>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</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"
placeholder="Google Drive OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client Secret from Google Cloud Console
</p>
</div>
</div>
<div class="mt-4">
<label for="source_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Folder ID (Optional)</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>
</div>
<input type="text" id="source_drive_id" name="source_drive_id" x-model="sourceDriveId"
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="Google Drive Folder ID (optional)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Folder ID to use as the root (leave empty for "My Drive")
</p>
</div>
<div class="mt-4">
<label for="source_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Shared/Team Drive ID (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-users text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="source_team_drive" name="source_team_drive" x-model="sourceTeamDrive"
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="Shared/Team Drive ID (optional)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
ID of the Shared Drive / Team Drive to use
</p>
</div>
<div class="mt-4">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">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-open text-secondary-400 dark:text-secondary-600"></i>
</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="Path within Google Drive (e.g., /backup)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within the Drive from which files will be transferred
</p>
</div>
<div class="p-4 bg-amber-50 rounded-lg border border-amber-100">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700">
<p>After saving this configuration, you'll need to authenticate with Google Drive on the configurations page.</p>
</div>
</div>
</div>
</div>
</div>
}
+1 -1
View File
@@ -73,7 +73,7 @@ templ Users(ctx context.Context, data UsersData) {
<div class="text-sm font-medium text-secondary-900 dark:text-secondary-100">{ user.Email }</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
if user.IsAdmin {
if user.GetIsAdmin() {
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-300">
<i class="fas fa-user-shield mr-1"></i> Admin
</span>
+4
View File
@@ -23,6 +23,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sessions v1.0.2 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@@ -30,6 +31,9 @@ require (
github.com/go-playground/validator/v10 v10.25.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
+8
View File
@@ -15,6 +15,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sessions v1.0.2 h1:UaIjUvTH1cMeOdj3in6dl+Xb6It8RiKRF9Z1anbUyCA=
github.com/gin-contrib/sessions v1.0.2/go.mod h1:KxKxWqWP5LJVDCInulOl4WbLzK2KSPlLesfZ66wRvMs=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
@@ -44,6 +46,12 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+13 -21
View File
@@ -14,22 +14,22 @@ import (
)
type RegisterRequest struct {
Email string `json:"email" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=8"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
type LoginResponse struct {
Token string `json:"token"`
Token string `json:"token"`
User UserResponse `json:"user"`
}
type UserResponse struct {
ID uint `json:"id"`
ID uint `json:"id"`
Email string `json:"email"`
}
@@ -94,7 +94,7 @@ func handleRegister(database *db.DB) gin.HandlerFunc {
// Create user
user := &db.User{
Email: req.Email,
Email: req.Email,
PasswordHash: string(hashedPassword),
}
@@ -136,7 +136,7 @@ func handleLogin(database *db.DB, jwtSecret string) gin.HandlerFunc {
c.JSON(http.StatusOK, LoginResponse{
Token: token,
User: UserResponse{
ID: user.ID,
ID: user.ID,
Email: user.Email,
},
})
@@ -384,7 +384,7 @@ func handleCreateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
}
// Schedule the job if enabled
if job.Enabled {
if job.GetEnabled() {
if err := scheduler.ScheduleJob(&job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to schedule job"})
return
@@ -484,7 +484,7 @@ func handleUpdateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
}
// Check if schedule or enabled status changed
scheduleChanged := updatedJob.Schedule != existingJob.Schedule || updatedJob.Enabled != existingJob.Enabled
scheduleChanged := updatedJob.Schedule != existingJob.Schedule || updatedJob.GetEnabled() != existingJob.GetEnabled()
if err := database.UpdateJob(&updatedJob); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
@@ -493,7 +493,7 @@ func handleUpdateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
// Update the scheduler if needed
if scheduleChanged {
if updatedJob.Enabled {
if updatedJob.GetEnabled() {
if err := scheduler.ScheduleJob(&updatedJob); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job schedule"})
return
@@ -610,12 +610,8 @@ func handleEnableJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
return
}
// Update job status
job.Enabled = true
if err := database.UpdateJob(job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
return
}
// Enable the job
job.SetEnabled(true)
// Add to scheduler
if err := scheduler.ScheduleJob(job); err != nil {
@@ -654,12 +650,8 @@ func handleDisableJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handl
return
}
// Update job status
job.Enabled = false
if err := database.UpdateJob(job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
return
}
// Disable the job
job.SetEnabled(false)
// Remove from scheduler
scheduler.UnscheduleJob(jobID)
+613 -15
View File
@@ -1,6 +1,7 @@
package db
import (
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -18,10 +19,10 @@ type User struct {
ID uint `gorm:"primarykey"`
Email string `gorm:"unique;not null"`
PasswordHash string `gorm:"not null"`
IsAdmin bool `gorm:"default:false"`
IsAdmin *bool `gorm:"default:false"`
LastPasswordChange time.Time
FailedLoginAttempts int `gorm:"default:0"`
AccountLocked bool `gorm:"default:false"`
FailedLoginAttempts int `gorm:"default:0"`
AccountLocked *bool `gorm:"default:false"`
LockoutUntil *time.Time
Theme string `gorm:"default:'light'"`
CreatedAt time.Time
@@ -42,7 +43,7 @@ type PasswordResetToken struct {
User User `gorm:"foreignkey:UserID"`
Token string `gorm:"not null"`
ExpiresAt time.Time `gorm:"not null"`
Used bool `gorm:"default:false"`
Used *bool `gorm:"default:false"`
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -67,7 +68,7 @@ type TransferConfig struct {
SourceShare string `form:"source_share"`
SourceDomain string `form:"source_domain"`
// FTP source fields
SourcePassiveMode bool `gorm:"default:true" form:"source_passive_mode"`
SourcePassiveMode *bool `gorm:"default:true" form:"source_passive_mode"`
// OneDrive and Google Drive source fields
SourceClientID string `form:"source_client_id"`
SourceClientSecret string `form:"source_client_secret" gorm:"-"` // Not stored in DB, only used for form
@@ -93,17 +94,19 @@ type TransferConfig struct {
DestShare string `form:"dest_share"`
DestDomain string `form:"dest_domain"`
// FTP destination fields
DestPassiveMode bool `gorm:"default:true" form:"dest_passive_mode"`
DestPassiveMode *bool `gorm:"default:true" form:"dest_passive_mode"`
// OneDrive and Google Drive destination fields
DestClientID string `form:"dest_client_id"`
DestClientSecret string `form:"dest_client_secret" gorm:"-"` // Not stored in DB, only used for form
DestDriveID string `form:"dest_drive_id"` // For OneDrive
DestTeamDrive string `form:"dest_team_drive"` // For Google Drive
// Google Drive authentication status
GoogleDriveAuthenticated *bool `gorm:"default:false"`
// General fields
ArchivePath string `form:"archive_path"`
ArchiveEnabled bool `gorm:"default:false" form:"archive_enabled"`
ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"`
RcloneFlags string `form:"rclone_flags"`
DeleteAfterTransfer bool `gorm:"default:false" form:"delete_after_transfer"`
DeleteAfterTransfer *bool `gorm:"default:false" form:"delete_after_transfer"`
SkipProcessedFiles *bool `gorm:"default:true" form:"skip_processed_files"`
MaxConcurrentTransfers int `gorm:"default:4" form:"max_concurrent_transfers"` // Number of concurrent file transfers
CreatedBy uint
@@ -119,16 +122,16 @@ type Job struct {
Config TransferConfig `gorm:"foreignkey:ConfigID"`
ConfigIDs string `gorm:"column:config_ids"` // Comma-separated list of config IDs
Schedule string `gorm:"not null" form:"schedule"`
Enabled bool `gorm:"default:true" form:"enabled"`
Enabled *bool `gorm:"default:true" form:"enabled"`
LastRun *time.Time
NextRun *time.Time
// Webhook notification fields
WebhookEnabled bool `gorm:"default:false" form:"webhook_enabled"`
WebhookEnabled *bool `gorm:"default:false" form:"webhook_enabled"`
WebhookURL string `form:"webhook_url"`
WebhookSecret string `form:"webhook_secret"`
WebhookHeaders string `form:"webhook_headers"` // JSON-encoded headers
NotifyOnSuccess bool `gorm:"default:true" form:"notify_on_success"`
NotifyOnFailure bool `gorm:"default:true" form:"notify_on_failure"`
NotifyOnSuccess *bool `gorm:"default:true" form:"notify_on_success"`
NotifyOnFailure *bool `gorm:"default:true" form:"notify_on_failure"`
CreatedBy uint
User User `gorm:"foreignkey:CreatedBy"`
CreatedAt time.Time
@@ -558,7 +561,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
"--log-level", "ERROR",
}
if config.SourcePassiveMode {
if config.SourcePassiveMode != nil && *config.SourcePassiveMode {
args = append(args, "passive", "true")
}
@@ -746,7 +749,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
"--log-level", "ERROR",
}
if config.DestPassiveMode {
if config.DestPassiveMode != nil && *config.DestPassiveMode {
args = append(args, "passive", "true")
}
@@ -799,6 +802,33 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
args = append(args, "drive_id", config.DestDriveID)
}
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 "gdrive":
args := []string{
"config", "create", destName, "drive",
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
// Only add client_id and client_secret if they're provided (not empty)
// This allows using rclone's built-in authentication
if config.DestClientID != "" && config.DestClientSecret != "" {
args = append(args, "client_id", config.DestClientID)
args = append(args, "client_secret", config.DestClientSecret)
}
if config.DestTeamDrive != "" {
args = append(args, "team_drive", config.DestTeamDrive)
}
if config.DestDriveID != "" {
args = append(args, "root_folder_id", config.DestDriveID)
}
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)
@@ -837,12 +867,14 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
return nil
}
// GetActiveJobs returns all active jobs
func (db *DB) GetActiveJobs() ([]Job, error) {
if db.DB == nil {
return nil, fmt.Errorf("database connection is nil")
}
var jobs []Job
err := db.Preload("Config").Where("enabled = ?", true).Find(&jobs).Error
// For boolean pointer fields, need to check either NULL (for default) or true value
err := db.Preload("Config").Where("enabled IS NULL OR enabled = ?", true).Find(&jobs).Error
return jobs, err
}
@@ -885,3 +917,569 @@ func (tc *TransferConfig) GetSkipProcessedFiles() bool {
func (tc *TransferConfig) SetSkipProcessedFiles(value bool) {
tc.SkipProcessedFiles = &value
}
// StoreGoogleDriveToken stores the Google Drive auth token for a config
func (db *DB) StoreGoogleDriveToken(configIDStr string, token string) error {
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid config ID: %v", err)
}
// Get the existing config
config, err := db.GetTransferConfig(uint(configID))
if err != nil {
return fmt.Errorf("failed to get config: %v", err)
}
// Mark as authenticated
authenticated := true
config.GoogleDriveAuthenticated = &authenticated
// Update the config in the database
if err := db.UpdateTransferConfig(config); err != nil {
return fmt.Errorf("failed to update config: %v", err)
}
// Get the rclone config path
configPath := db.GetConfigRclonePath(config)
// Read existing config if it exists
existingConfig := ""
if _, err := os.Stat(configPath); err == nil {
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read existing config: %v", err)
}
existingConfig = string(data)
}
// Ensure directory exists
configDir := filepath.Dir(configPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %v", err)
}
// Write the new config with the token
destName := fmt.Sprintf("dest_%d", config.ID)
newConfig := fmt.Sprintf("[%s]\ntype = drive\ntoken = %s\n", destName, token)
// If the config has client ID and secret, add them
if config.DestClientID != "" && config.DestClientSecret != "" {
newConfig += fmt.Sprintf("client_id = %s\nclient_secret = %s\n", config.DestClientID, config.DestClientSecret)
}
// Add root folder ID if specified
if config.DestDriveID != "" {
newConfig += fmt.Sprintf("root_folder_id = %s\n", config.DestDriveID)
}
// Add team drive if specified
if config.DestTeamDrive != "" {
newConfig += fmt.Sprintf("team_drive = %s\n", config.DestTeamDrive)
}
// If there's existing config, append to it; otherwise create new file
var content string
if existingConfig != "" {
// Replace/update existing dest section if it exists, otherwise append
if strings.Contains(existingConfig, fmt.Sprintf("[%s]", destName)) {
// This is a simplistic approach - in production you might want a more robust regex-based replacement
// Truncate at the beginning of the dest section
parts := strings.SplitN(existingConfig, fmt.Sprintf("[%s]", destName), 2)
// Check if there are more sections after this one
nextSectionIdx := strings.Index(parts[1], "[")
if nextSectionIdx != -1 {
content = parts[0] + newConfig + parts[1][nextSectionIdx:]
} else {
content = parts[0] + newConfig
}
} else {
content = existingConfig + "\n" + newConfig
}
} else {
content = newConfig
}
// Write the config file
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
return fmt.Errorf("failed to write config: %v", err)
}
return nil
}
// GenerateRcloneConfigWithToken generates a rclone config file for a transfer config with a provided token
func (db *DB) GenerateRcloneConfigWithToken(config *TransferConfig, token string) error {
// Ensure we have a config directory
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
configDir := filepath.Join(dataDir, "configs")
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
// Generate rclone config based on the config type
configPath := filepath.Join(configDir, fmt.Sprintf("config_%d.conf", config.ID))
// Create a new config content
var configContent strings.Builder
// First add the source configuration
sourceName := fmt.Sprintf("source_%d", config.ID)
// Handle the source configuration based on type
switch config.SourceType {
case "google_drive":
// Create a Google Drive remote using the "source_ID" naming convention for sources
sourceSection := fmt.Sprintf("[%s]\ntype = drive\n", sourceName)
// Add custom client ID and secret if provided
if config.SourceClientID != "" && config.SourceClientSecret != "" {
sourceSection += fmt.Sprintf("client_id = %s\nclient_secret = %s\n",
config.SourceClientID, config.SourceClientSecret)
}
// Add team drive if specified
if config.SourceTeamDrive != "" {
sourceSection += fmt.Sprintf("team_drive = %s\n", config.SourceTeamDrive)
}
// Clean up token string to prevent syntax errors and ensure it's a single line JSON
// First, remove any whitespace from the beginning and end
cleanToken := strings.TrimSpace(token)
// Check if it's already a JSON object
if strings.HasPrefix(cleanToken, "{") && strings.HasSuffix(cleanToken, "}") {
// It's a JSON object, but we need to make sure it's a single line
var jsonObj map[string]interface{}
if err := json.Unmarshal([]byte(cleanToken), &jsonObj); err == nil {
// Successfully parsed the JSON, now re-marshal it as a compact single line
compactJSON, err := json.Marshal(jsonObj)
if err == nil {
// Use the compact JSON as the token
sourceSection += fmt.Sprintf("token = %s\n", string(compactJSON))
} else {
// If there was an error re-marshaling, use the original but remove newlines
// Replace all newlines and carriage returns with empty string
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
sourceSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
} else {
// If we couldn't parse the JSON, just remove newlines and carriage returns
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
sourceSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
} else {
// Not a valid JSON, try to fix it
// First ensure it starts and ends with braces
if !strings.HasPrefix(cleanToken, "{") {
cleanToken = "{" + cleanToken
}
if !strings.HasSuffix(cleanToken, "}") {
cleanToken = cleanToken + "}"
}
// Remove all newlines and carriage returns
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
sourceSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
configContent.WriteString(sourceSection)
configContent.WriteString("\n")
case "sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive":
// For complex source types, use the GenerateRcloneConfig function
// to create a temporary file, read it, and then append that content
tempDir, err := os.MkdirTemp("", "gomft_temp")
if err != nil {
return fmt.Errorf("failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
tempConfigPath := filepath.Join(tempDir, "temp_config.conf")
// Create a temporary file with just the source configuration
tempContent := fmt.Sprintf("[%s]\ntype = local\n", sourceName)
if err := os.WriteFile(tempConfigPath, []byte(tempContent), 0600); err != nil {
return fmt.Errorf("failed to write temporary config: %v", err)
}
// Get rclone path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
// Use the appropriate rclone command to configure the source
var args []string
switch config.SourceType {
case "sftp":
args = []string{
"config", "create", sourceName, "sftp",
"host", config.SourceHost,
"user", config.SourceUser,
"port", fmt.Sprintf("%d", config.SourcePort),
"--non-interactive",
"--config", tempConfigPath,
"--log-level", "ERROR",
}
if config.SourcePassword != "" {
args = append(args, "pass", config.SourcePassword)
}
if config.SourceKeyFile != "" {
args = append(args, "key_file", config.SourceKeyFile)
}
case "s3":
args = []string{
"config", "create", sourceName, "s3",
"provider", "AWS",
"env_auth", "false",
"access_key_id", config.SourceAccessKey,
"secret_access_key", config.SourceSecretKey,
"region", config.SourceRegion,
"--non-interactive",
"--config", tempConfigPath,
"--log-level", "ERROR",
}
if config.SourceEndpoint != "" {
args = append(args, "endpoint", config.SourceEndpoint)
}
}
// If we have arguments, execute the command
if len(args) > 0 {
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
}
// Read the generated config
sourceConfig, err := os.ReadFile(tempConfigPath)
if err != nil {
return fmt.Errorf("failed to read temporary config: %v", err)
}
// Add it to our config content
configContent.WriteString(string(sourceConfig))
configContent.WriteString("\n")
}
default:
// For local source or other simple types
sourceSection := fmt.Sprintf("[%s]\ntype = local\n\n", sourceName)
configContent.WriteString(sourceSection)
}
// Now add the destination configuration
destName := fmt.Sprintf("dest_%d", config.ID)
// Set up the destination section (only supporting Google Drive for now)
if config.DestinationType == "gdrive" || config.DestinationType == "google_drive" {
// Create a Google Drive remote using "dest_ID" naming convention
destSection := fmt.Sprintf("[%s]\ntype = drive\n", destName)
// Add custom client ID and secret if provided
if config.DestClientID != "" && config.DestClientSecret != "" {
destSection += fmt.Sprintf("client_id = %s\nclient_secret = %s\n",
config.DestClientID, config.DestClientSecret)
}
// Clean up token string to prevent syntax errors and ensure it's a single line JSON
// First, remove any whitespace from the beginning and end
cleanToken := strings.TrimSpace(token)
// Check if it's already a JSON object
if strings.HasPrefix(cleanToken, "{") && strings.HasSuffix(cleanToken, "}") {
// It's a JSON object, but we need to make sure it's a single line
var jsonObj map[string]interface{}
if err := json.Unmarshal([]byte(cleanToken), &jsonObj); err == nil {
// Successfully parsed the JSON, now re-marshal it as a compact single line
compactJSON, err := json.Marshal(jsonObj)
if err == nil {
// Use the compact JSON as the token
destSection += fmt.Sprintf("token = %s\n", string(compactJSON))
} else {
// If there was an error re-marshaling, use the original but remove newlines
// Replace all newlines and carriage returns with empty string
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
destSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
} else {
// If we couldn't parse the JSON, just remove newlines and carriage returns
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
destSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
} else {
// Not a valid JSON, try to fix it
// First ensure it starts and ends with braces
if !strings.HasPrefix(cleanToken, "{") {
cleanToken = "{" + cleanToken
}
if !strings.HasSuffix(cleanToken, "}") {
cleanToken = cleanToken + "}"
}
// Remove all newlines and carriage returns
singleLineToken := strings.ReplaceAll(cleanToken, "\n", "")
singleLineToken = strings.ReplaceAll(singleLineToken, "\r", "")
destSection += fmt.Sprintf("token = %s\n", singleLineToken)
}
// Add to the config content
configContent.WriteString(destSection)
} else {
// Add a simple local destination for testing or if no specific destination type is handled
destSection := fmt.Sprintf("[%s]\ntype = local\n", destName)
configContent.WriteString(destSection)
}
// Write the config file
return os.WriteFile(configPath, []byte(configContent.String()), 0644)
}
// GetIsAdmin returns the value of IsAdmin with a default if nil
func (u *User) GetIsAdmin() bool {
if u.IsAdmin == nil {
return false // Default to false if not set
}
return *u.IsAdmin
}
// SetIsAdmin sets the IsAdmin field
func (u *User) SetIsAdmin(value bool) {
u.IsAdmin = &value
}
// GetAccountLocked returns the value of AccountLocked with a default if nil
func (u *User) GetAccountLocked() bool {
if u.AccountLocked == nil {
return false // Default to false if not set
}
return *u.AccountLocked
}
// SetAccountLocked sets the AccountLocked field
func (u *User) SetAccountLocked(value bool) {
u.AccountLocked = &value
}
// GetUsed returns the value of Used with a default if nil
func (t *PasswordResetToken) GetUsed() bool {
if t.Used == nil {
return false // Default to false if not set
}
return *t.Used
}
// SetUsed sets the Used field
func (t *PasswordResetToken) SetUsed(value bool) {
t.Used = &value
}
// GetSourcePassiveMode returns the value of SourcePassiveMode with a default if nil
func (tc *TransferConfig) GetSourcePassiveMode() bool {
if tc.SourcePassiveMode == nil {
return true // Default to true if not set
}
return *tc.SourcePassiveMode
}
// SetSourcePassiveMode sets the SourcePassiveMode field
func (tc *TransferConfig) SetSourcePassiveMode(value bool) {
tc.SourcePassiveMode = &value
}
// GetDestPassiveMode returns the value of DestPassiveMode with a default if nil
func (tc *TransferConfig) GetDestPassiveMode() bool {
if tc.DestPassiveMode == nil {
return true // Default to true if not set
}
return *tc.DestPassiveMode
}
// SetDestPassiveMode sets the DestPassiveMode field
func (tc *TransferConfig) SetDestPassiveMode(value bool) {
tc.DestPassiveMode = &value
}
// GetGoogleDriveAuthenticated returns the value of GoogleDriveAuthenticated with a default if nil
func (tc *TransferConfig) GetGoogleDriveAuthenticated() bool {
if tc.GoogleDriveAuthenticated == nil {
return false // Default to false if not set
}
return *tc.GoogleDriveAuthenticated
}
// SetGoogleDriveAuthenticated sets the GoogleDriveAuthenticated field
func (tc *TransferConfig) SetGoogleDriveAuthenticated(value bool) {
tc.GoogleDriveAuthenticated = &value
}
// GetArchiveEnabled returns the value of ArchiveEnabled with a default if nil
func (tc *TransferConfig) GetArchiveEnabled() bool {
if tc.ArchiveEnabled == nil {
return false // Default to false if not set
}
return *tc.ArchiveEnabled
}
// SetArchiveEnabled sets the ArchiveEnabled field
func (tc *TransferConfig) SetArchiveEnabled(value bool) {
tc.ArchiveEnabled = &value
}
// GetDeleteAfterTransfer returns the value of DeleteAfterTransfer with a default if nil
func (tc *TransferConfig) GetDeleteAfterTransfer() bool {
if tc.DeleteAfterTransfer == nil {
return false // Default to false if not set
}
return *tc.DeleteAfterTransfer
}
// SetDeleteAfterTransfer sets the DeleteAfterTransfer field
func (tc *TransferConfig) SetDeleteAfterTransfer(value bool) {
tc.DeleteAfterTransfer = &value
}
// GetEnabled returns the value of Enabled with a default if nil
func (j *Job) GetEnabled() bool {
if j.Enabled == nil {
return true // Default to true if not set
}
return *j.Enabled
}
// SetEnabled sets the Enabled field
func (j *Job) SetEnabled(value bool) {
j.Enabled = &value
}
// GetWebhookEnabled returns the value of WebhookEnabled with a default if nil
func (j *Job) GetWebhookEnabled() bool {
if j.WebhookEnabled == nil {
return false // Default to false if not set
}
return *j.WebhookEnabled
}
// SetWebhookEnabled sets the WebhookEnabled field
func (j *Job) SetWebhookEnabled(value bool) {
j.WebhookEnabled = &value
}
// GetNotifyOnSuccess returns the value of NotifyOnSuccess with a default if nil
func (j *Job) GetNotifyOnSuccess() bool {
if j.NotifyOnSuccess == nil {
return true // Default to true if not set
}
return *j.NotifyOnSuccess
}
// SetNotifyOnSuccess sets the NotifyOnSuccess field
func (j *Job) SetNotifyOnSuccess(value bool) {
j.NotifyOnSuccess = &value
}
// GetNotifyOnFailure returns the value of NotifyOnFailure with a default if nil
func (j *Job) GetNotifyOnFailure() bool {
if j.NotifyOnFailure == nil {
return true // Default to true if not set
}
return *j.NotifyOnFailure
}
// SetNotifyOnFailure sets the NotifyOnFailure field
func (j *Job) SetNotifyOnFailure(value bool) {
j.NotifyOnFailure = &value
}
// GetGDriveCredentialsFromConfig extracts Google Drive client ID and secret from an existing rclone config file
func (db *DB) GetGDriveCredentialsFromConfig(config *TransferConfig) (string, string) {
configPath := db.GetConfigRclonePath(config)
if configPath == "" {
return "", ""
}
// Check if the file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return "", ""
}
// Read the rclone config file
content, err := os.ReadFile(configPath)
if err != nil {
return "", ""
}
// Parse the content to extract client_id and client_secret from both source and destination sections
lines := strings.Split(string(content), "\n")
// Define section names based on config ID
sourceSectionName := fmt.Sprintf("[source_%d]", config.ID)
destSectionName := fmt.Sprintf("[dest_%d]", config.ID)
var inSourceSection, inDestSection bool
var sourceClientID, sourceClientSecret, destClientID, destClientSecret string
for _, line := range lines {
line = strings.TrimSpace(line)
// Check if we're entering a new section
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
inSourceSection = line == sourceSectionName
inDestSection = line == destSectionName
continue
}
// Extract credentials from source section
if inSourceSection {
if strings.HasPrefix(line, "client_id") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
sourceClientID = strings.TrimSpace(parts[1])
}
} else if strings.HasPrefix(line, "client_secret") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
sourceClientSecret = strings.TrimSpace(parts[1])
}
}
}
// Extract credentials from destination section
if inDestSection {
if strings.HasPrefix(line, "client_id") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
destClientID = strings.TrimSpace(parts[1])
}
} else if strings.HasPrefix(line, "client_secret") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
destClientSecret = strings.TrimSpace(parts[1])
}
}
}
// If we found both values in both sections, we can stop processing
if sourceClientID != "" && sourceClientSecret != "" && destClientID != "" && destClientSecret != "" {
break
}
}
// Prefer destination credentials since we're authenticating for destination
if destClientID != "" && destClientSecret != "" {
return destClientID, destClientSecret
}
// Fall back to source credentials if available
if sourceClientID != "" && sourceClientSecret != "" {
return sourceClientID, sourceClientSecret
}
return "", ""
}
+596 -13
View File
@@ -3,6 +3,7 @@ package db
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
@@ -42,9 +43,9 @@ func TestUserCRUD(t *testing.T) {
testUser := &User{
Email: fmt.Sprintf("test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
IsAdmin: true,
LastPasswordChange: time.Now(),
}
testUser.SetIsAdmin(true)
// Test Create
err := db.CreateUser(testUser)
@@ -113,7 +114,7 @@ func TestPasswordResetToken(t *testing.T) {
}
assert.Equal(t, testToken.ID, retrievedToken.ID, "Retrieved token should have the same ID")
assert.Equal(t, testUser.ID, retrievedToken.UserID, "Retrieved token should reference the correct user")
assert.False(t, retrievedToken.Used, "Token should not be marked as used initially")
assert.False(t, retrievedToken.GetUsed(), "Token should not be marked as used initially")
// Mark token as used
err = db.MarkPasswordResetTokenAsUsed(retrievedToken.ID)
@@ -129,7 +130,7 @@ func TestPasswordResetToken(t *testing.T) {
if result.Error != nil {
t.Fatalf("Failed to get updated password reset token: %v", result.Error)
}
assert.True(t, updatedToken.Used, "Token should be marked as used")
assert.True(t, updatedToken.GetUsed(), "Token should be marked as used")
}
func TestTransferConfigCRUD(t *testing.T) {
@@ -240,11 +241,11 @@ func TestJobCRUD(t *testing.T) {
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run every hour
Enabled: true,
LastRun: &now,
NextRun: &nextRun,
CreatedBy: testUser.ID,
}
testJob.SetEnabled(true)
// Test Create
err = db.CreateJob(testJob)
@@ -278,7 +279,7 @@ func TestJobCRUD(t *testing.T) {
// Test Update
retrievedJob.Name = fmt.Sprintf("Updated Job %d", time.Now().UnixNano())
retrievedJob.Enabled = false
retrievedJob.SetEnabled(false)
err = db.UpdateJob(retrievedJob)
if err != nil {
t.Fatalf("Failed to update job: %v", err)
@@ -367,7 +368,6 @@ func TestJobMultipleConfigs(t *testing.T) {
testJob := &Job{
Name: "Multi Config Job",
Schedule: "0 * * * *",
Enabled: true,
CreatedBy: testUser.ID,
}
@@ -468,7 +468,6 @@ func TestJobHistoryCRUD(t *testing.T) {
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run every hour
Enabled: true,
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob)
@@ -548,7 +547,6 @@ func TestFileMetadataCRUD(t *testing.T) {
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run every hour
Enabled: true,
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob)
@@ -634,7 +632,7 @@ func TestGetConfigRclonePath(t *testing.T) {
testUser := &User{
Email: "rclone-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
@@ -671,7 +669,7 @@ func TestGenerateRcloneConfig(t *testing.T) {
testUser := &User{
Email: "rclone-gen-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
@@ -708,7 +706,7 @@ func TestGenerateRcloneConfig(t *testing.T) {
DestHost: "ftp.example.com",
DestPort: 21,
DestUser: "ftpuser",
DestPassiveMode: true,
DestPassiveMode: BoolPtr(true),
CreatedBy: testUser.ID,
}
@@ -770,7 +768,7 @@ func TestUpdateJobStatus(t *testing.T) {
testUser := &User{
Email: "job-status-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
@@ -800,11 +798,11 @@ func TestUpdateJobStatus(t *testing.T) {
Name: "Test Job Status",
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run hourly
Enabled: true,
LastRun: &lastRun,
NextRun: &nextRun,
CreatedBy: testUser.ID,
}
testJob.SetEnabled(true)
err = db.CreateJob(testJob)
assert.NoError(t, err)
@@ -833,3 +831,588 @@ func TestUpdateJobStatus(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, updatedNextRun.Unix(), updatedJob.NextRun.Unix())
}
func BoolPtr(b bool) *bool {
return &b
}
func TestGoogleDriveTransferConfig(t *testing.T) {
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err := db.CreateUser(testUser)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Test 1: Create config with Google Drive as source
googleSourceConfig := &TransferConfig{
Name: "Google Drive Source Test",
SourceType: "google_drive",
SourcePath: "/path/in/google/drive",
SourceClientID: "google_client_id",
SourceClientSecret: "google_client_secret",
SourceTeamDrive: "team_drive_id",
DestinationType: "local",
DestinationPath: "/local/destination/path",
FilePattern: "*.pdf",
CreatedBy: testUser.ID,
}
// Set authenticated status
authenticated := true
googleSourceConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleSourceConfig)
assert.NoError(t, err)
assert.NotZero(t, googleSourceConfig.ID, "Config ID should be set after creation")
// Test 2: Create config with Google Drive as destination
googleDestConfig := &TransferConfig{
Name: "Google Drive Destination Test",
SourceType: "local",
SourcePath: "/local/source/path",
DestinationType: "google_drive",
DestinationPath: "/path/in/google/drive",
DestClientID: "google_client_id",
DestClientSecret: "google_client_secret",
DestTeamDrive: "team_drive_id",
FilePattern: "*.docx",
CreatedBy: testUser.ID,
}
// Set authenticated status
googleDestConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleDestConfig)
assert.NoError(t, err)
assert.NotZero(t, googleDestConfig.ID, "Config ID should be set after creation")
// Test 3: Create config with Google Drive as both source and destination
googleBothConfig := &TransferConfig{
Name: "Google Drive Both Test",
SourceType: "google_drive",
SourcePath: "/source/path/in/google/drive",
SourceClientID: "source_google_client_id",
SourceClientSecret: "source_google_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "google_drive",
DestinationPath: "/dest/path/in/google/drive",
DestClientID: "dest_google_client_id",
DestClientSecret: "dest_google_client_secret",
DestTeamDrive: "dest_team_drive_id",
FilePattern: "*.xlsx",
CreatedBy: testUser.ID,
}
// Set authenticated status
googleBothConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleBothConfig)
assert.NoError(t, err)
assert.NotZero(t, googleBothConfig.ID, "Config ID should be set after creation")
// 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, "/path/in/google/drive", retrievedSourceConfig.SourcePath)
assert.Equal(t, "google_client_id", retrievedSourceConfig.SourceClientID)
assert.Equal(t, "team_drive_id", retrievedSourceConfig.SourceTeamDrive)
assert.True(t, *retrievedSourceConfig.GoogleDriveAuthenticated)
retrievedDestConfig, err := db.GetTransferConfig(googleDestConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "google_drive", 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)
assert.True(t, *retrievedDestConfig.GoogleDriveAuthenticated)
// Test updating Google Drive config
retrievedSourceConfig.SourcePath = "/updated/google/drive/path"
retrievedSourceConfig.SourceTeamDrive = "updated_team_drive_id"
err = db.UpdateTransferConfig(retrievedSourceConfig)
assert.NoError(t, err)
// Verify update
updatedConfig, err := db.GetTransferConfig(googleSourceConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "/updated/google/drive/path", updatedConfig.SourcePath)
assert.Equal(t, "updated_team_drive_id", updatedConfig.SourceTeamDrive)
// Test changing authentication status
unauthenticated := false
updatedConfig.GoogleDriveAuthenticated = &unauthenticated
err = db.UpdateTransferConfig(updatedConfig)
assert.NoError(t, err)
// Verify authentication status update
finalConfig, err := db.GetTransferConfig(googleSourceConfig.ID)
assert.NoError(t, err)
assert.False(t, *finalConfig.GoogleDriveAuthenticated)
// Clean up
err = db.DeleteTransferConfig(googleSourceConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(googleDestConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(googleBothConfig.ID)
assert.NoError(t, err)
}
func TestGoogleDriveJobExecution(t *testing.T) {
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-job-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err := db.CreateUser(testUser)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Create a test transfer config with Google Drive as source
googleConfig := &TransferConfig{
Name: "Google Drive Job Test",
SourceType: "google_drive",
SourcePath: "/source/path/in/google/drive",
SourceClientID: "google_client_id",
SourceClientSecret: "google_client_secret",
SourceTeamDrive: "team_drive_id",
DestinationType: "local",
DestinationPath: "/local/destination/path",
FilePattern: "*.pdf",
CreatedBy: testUser.ID,
}
// Set authenticated status
authenticated := true
googleConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleConfig)
assert.NoError(t, err)
assert.NotZero(t, googleConfig.ID, "Config ID should be set after creation")
// Create a job using the Google Drive config
job := &Job{
Name: "Google Drive Test Job",
Schedule: "0 * * * *", // Run every hour
ConfigID: googleConfig.ID,
CreatedBy: testUser.ID,
}
// Set job as enabled
job.SetEnabled(true)
// Set up webhook notifications
job.SetWebhookEnabled(true)
job.WebhookURL = "https://example.com/webhook"
job.SetNotifyOnSuccess(true)
job.SetNotifyOnFailure(true)
// Create the job
err = db.CreateJob(job)
assert.NoError(t, err)
assert.NotZero(t, job.ID, "Job ID should be set after creation")
// Test retrieving the job
retrievedJob, err := db.GetJob(job.ID)
assert.NoError(t, err)
assert.Equal(t, "Google Drive Test Job", retrievedJob.Name)
assert.Equal(t, googleConfig.ID, retrievedJob.ConfigID)
assert.True(t, retrievedJob.GetEnabled())
assert.True(t, retrievedJob.GetWebhookEnabled())
assert.Equal(t, "https://example.com/webhook", retrievedJob.WebhookURL)
assert.True(t, retrievedJob.GetNotifyOnSuccess())
assert.True(t, retrievedJob.GetNotifyOnFailure())
// Create job history entry for this job
startTime := time.Now().Add(-10 * time.Minute)
endTime := time.Now()
jobHistory := &JobHistory{
JobID: job.ID,
ConfigID: googleConfig.ID,
StartTime: startTime,
EndTime: &endTime,
Status: "success",
BytesTransferred: 1024 * 1024 * 5, // 5 MB
FilesTransferred: 3,
}
err = db.Create(jobHistory).Error
assert.NoError(t, err)
assert.NotZero(t, jobHistory.ID, "JobHistory ID should be set after creation")
// Create file metadata entries
fileMetadata1 := &FileMetadata{
JobID: job.ID,
ConfigID: googleConfig.ID,
FileName: "document1.pdf",
OriginalPath: "/source/path/in/google/drive/document1.pdf",
FileSize: 1024 * 1024 * 2, // 2 MB
FileHash: "hash1",
CreationTime: time.Now().Add(-24 * time.Hour),
ModTime: time.Now().Add(-12 * time.Hour),
ProcessedTime: startTime.Add(1 * time.Minute),
DestinationPath: "/local/destination/path/document1.pdf",
Status: "processed",
}
fileMetadata2 := &FileMetadata{
JobID: job.ID,
ConfigID: googleConfig.ID,
FileName: "document2.pdf",
OriginalPath: "/source/path/in/google/drive/document2.pdf",
FileSize: 1024 * 1024 * 1, // 1 MB
FileHash: "hash2",
CreationTime: time.Now().Add(-24 * time.Hour),
ModTime: time.Now().Add(-12 * time.Hour),
ProcessedTime: startTime.Add(2 * time.Minute),
DestinationPath: "/local/destination/path/document2.pdf",
Status: "processed",
}
fileMetadata3 := &FileMetadata{
JobID: job.ID,
ConfigID: googleConfig.ID,
FileName: "document3.pdf",
OriginalPath: "/source/path/in/google/drive/document3.pdf",
FileSize: 1024 * 1024 * 2, // 2 MB
FileHash: "hash3",
CreationTime: time.Now().Add(-24 * time.Hour),
ModTime: time.Now().Add(-12 * time.Hour),
ProcessedTime: startTime.Add(3 * time.Minute),
DestinationPath: "/local/destination/path/document3.pdf",
Status: "processed",
}
err = db.Create(fileMetadata1).Error
assert.NoError(t, err)
err = db.Create(fileMetadata2).Error
assert.NoError(t, err)
err = db.Create(fileMetadata3).Error
assert.NoError(t, err)
// Test fetching job history
var histories []JobHistory
err = db.Where("job_id = ?", job.ID).Find(&histories).Error
assert.NoError(t, err)
assert.Equal(t, 1, len(histories), "Should have 1 job history entry")
assert.Equal(t, job.ID, histories[0].JobID)
assert.Equal(t, googleConfig.ID, histories[0].ConfigID)
assert.Equal(t, "success", histories[0].Status)
assert.Equal(t, int64(1024*1024*5), histories[0].BytesTransferred)
assert.Equal(t, 3, histories[0].FilesTransferred)
// Test fetching file metadata
var files []FileMetadata
err = db.Where("job_id = ?", job.ID).Find(&files).Error
assert.NoError(t, err)
assert.Equal(t, 3, len(files), "Should have 3 file metadata entries")
// Clean up
err = db.Where("job_id = ?", job.ID).Delete(&FileMetadata{}).Error
assert.NoError(t, err)
err = db.Where("job_id = ?", job.ID).Delete(&JobHistory{}).Error
assert.NoError(t, err)
err = db.Delete(&job).Error
assert.NoError(t, err)
err = db.Delete(&googleConfig).Error
assert.NoError(t, err)
}
func TestGoogleDriveAuthentication(t *testing.T) {
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-auth-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err := db.CreateUser(testUser)
assert.NoError(t, err)
// Create a Google Drive config that requires authentication
googleConfig := &TransferConfig{
Name: "Google Drive Auth Test",
SourceType: "google_drive",
SourcePath: "/source/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
DestinationType: "local",
DestinationPath: "/local/path",
CreatedBy: testUser.ID,
}
// Initially not authenticated
unauthenticated := false
googleConfig.GoogleDriveAuthenticated = &unauthenticated
// Create the config
err = db.CreateTransferConfig(googleConfig)
assert.NoError(t, err)
// Test 1: Verify initial unauthenticated state
retrievedConfig, err := db.GetTransferConfig(googleConfig.ID)
assert.NoError(t, err)
assert.False(t, retrievedConfig.GetGoogleDriveAuthenticated())
// Test 2: Simulate authentication with token
mockToken := `{"access_token":"test_access_token","refresh_token":"test_refresh_token","expiry":"2023-12-31T12:00:00Z"}`
err = db.StoreGoogleDriveToken(fmt.Sprintf("%d", googleConfig.ID), mockToken)
assert.NoError(t, err)
// Verify authentication state was updated
updatedConfig, err := db.GetTransferConfig(googleConfig.ID)
assert.NoError(t, err)
assert.True(t, updatedConfig.GetGoogleDriveAuthenticated())
// Test 3: Generate rclone config with token
err = db.GenerateRcloneConfigWithToken(updatedConfig, mockToken)
assert.NoError(t, err)
// Get config path
configPath := db.GetConfigRclonePath(updatedConfig)
// On test systems, the directory might not exist
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
// Create directory if it doesn't exist
err = os.MkdirAll(configDir, 0755)
assert.NoError(t, err)
}
// Check if config was generated properly
_, err = os.Stat(configPath)
// In a test environment, this may fail if the rclone executable is not available
// or permissions are wrong, so we'll just log it rather than fail the test
if err != nil {
t.Logf("Warning: could not verify rclone config file: %v", err)
}
// Test 4: Simulate deauthentication (token revocation)
updatedConfig.SetGoogleDriveAuthenticated(false)
err = db.UpdateTransferConfig(updatedConfig)
assert.NoError(t, err)
// Verify deauthentication
finalConfig, err := db.GetTransferConfig(googleConfig.ID)
assert.NoError(t, err)
assert.False(t, finalConfig.GetGoogleDriveAuthenticated())
// Clean up
err = db.DeleteTransferConfig(googleConfig.ID)
assert.NoError(t, err)
}
func TestGoogleDriveErrorHandling(t *testing.T) {
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-error-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err := db.CreateUser(testUser)
assert.NoError(t, err)
// Test 1: Create a config with missing required fields
incompleteConfig := &TransferConfig{
Name: "Incomplete Google Drive Config",
SourceType: "google_drive",
SourcePath: "", // Missing path
DestinationType: "local",
DestinationPath: "/local/path",
CreatedBy: testUser.ID,
}
// This should still succeed at the database level, as validation typically happens at the application level
err = db.CreateTransferConfig(incompleteConfig)
assert.NoError(t, err)
assert.NotZero(t, incompleteConfig.ID, "Config ID should be set after creation")
// Test 2: Config with invalid Team Drive ID
invalidTeamDriveConfig := &TransferConfig{
Name: "Invalid Team Drive Config",
SourceType: "google_drive",
SourcePath: "/test/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
SourceTeamDrive: "invalid_team_drive_id",
DestinationType: "local",
DestinationPath: "/local/path",
CreatedBy: testUser.ID,
}
err = db.CreateTransferConfig(invalidTeamDriveConfig)
assert.NoError(t, err)
// Set it as authenticated (this would normally fail in a real environment)
authenticated := true
invalidTeamDriveConfig.GoogleDriveAuthenticated = &authenticated
err = db.UpdateTransferConfig(invalidTeamDriveConfig)
assert.NoError(t, err)
// When trying to test a transfer with an invalid team drive in a real environment,
// the rclone command would fail. We can't directly test this in a unit test,
// but we can verify the config is properly set up to cause the expected failure.
retrievedConfig, err := db.GetTransferConfig(invalidTeamDriveConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "invalid_team_drive_id", retrievedConfig.SourceTeamDrive, "Retrieved config should have the invalid team drive ID")
assert.True(t, retrievedConfig.GetGoogleDriveAuthenticated(), "Config should be marked as authenticated")
// Test 3: Test authentication error scenario - using malformed token
badTokenConfig := &TransferConfig{
Name: "Bad Token Config",
SourceType: "google_drive",
SourcePath: "/test/path",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
DestinationType: "local",
DestinationPath: "/local/path",
CreatedBy: testUser.ID,
}
err = db.CreateTransferConfig(badTokenConfig)
assert.NoError(t, err)
// Try to store a malformed token - shouldn't crash but may fail
// In real-world usage, this would lead to auth failures when trying to use the token
malformedToken := `{"not_valid_json`
err = db.StoreGoogleDriveToken(fmt.Sprintf("%d", badTokenConfig.ID), malformedToken)
// Even with malformed tokens, the DB operation might succeed as we're just storing a string
// but the authentication would fail in actual usage
if err != nil {
t.Logf("StoreGoogleDriveToken returned error with malformed token as expected: %v", err)
}
// Clean up
err = db.DeleteTransferConfig(incompleteConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(invalidTeamDriveConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(badTokenConfig.ID)
assert.NoError(t, err)
}
func TestGoogleDriveTeamDrive(t *testing.T) {
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-teamdrive-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err := db.CreateUser(testUser)
assert.NoError(t, err)
// Test 1: Configure source with Team Drive
teamDriveSourceConfig := &TransferConfig{
Name: "Team Drive Source Test",
SourceType: "google_drive",
SourcePath: "/shared/documents",
SourceClientID: "test_client_id",
SourceClientSecret: "test_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "local",
DestinationPath: "/local/path",
FilePattern: "*.pdf",
CreatedBy: testUser.ID,
}
// Set as authenticated
authenticated := true
teamDriveSourceConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(teamDriveSourceConfig)
assert.NoError(t, err)
assert.NotZero(t, teamDriveSourceConfig.ID, "Config ID should be set after creation")
// Test 2: Configure destination with Team Drive
teamDriveDestConfig := &TransferConfig{
Name: "Team Drive Destination Test",
SourceType: "local",
SourcePath: "/local/source",
DestinationType: "google_drive",
DestinationPath: "/team/drive/path",
DestClientID: "test_client_id",
DestClientSecret: "test_client_secret",
DestTeamDrive: "dest_team_drive_id",
FilePattern: "*.docx",
CreatedBy: testUser.ID,
}
// Set as authenticated
teamDriveDestConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(teamDriveDestConfig)
assert.NoError(t, err)
assert.NotZero(t, teamDriveDestConfig.ID, "Config ID should be set after creation")
// Test 3: Configure both source and destination with Team Drive
teamDriveBothConfig := &TransferConfig{
Name: "Team Drive Both Test",
SourceType: "google_drive",
SourcePath: "/source/team/drive/path",
SourceClientID: "source_client_id",
SourceClientSecret: "source_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "google_drive",
DestinationPath: "/dest/team/drive/path",
DestClientID: "dest_client_id",
DestClientSecret: "dest_client_secret",
DestTeamDrive: "dest_team_drive_id",
FilePattern: "*.xlsx",
CreatedBy: testUser.ID,
}
// Set as authenticated
teamDriveBothConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(teamDriveBothConfig)
assert.NoError(t, err)
assert.NotZero(t, teamDriveBothConfig.ID, "Config ID should be set after creation")
// Test retrieving configs and verify Team Drive IDs are set correctly
retrievedSourceConfig, err := db.GetTransferConfig(teamDriveSourceConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "source_team_drive_id", retrievedSourceConfig.SourceTeamDrive)
assert.Empty(t, retrievedSourceConfig.DestTeamDrive)
retrievedDestConfig, err := db.GetTransferConfig(teamDriveDestConfig.ID)
assert.NoError(t, err)
assert.Empty(t, retrievedDestConfig.SourceTeamDrive)
assert.Equal(t, "dest_team_drive_id", retrievedDestConfig.DestTeamDrive)
retrievedBothConfig, err := db.GetTransferConfig(teamDriveBothConfig.ID)
assert.NoError(t, err)
assert.Equal(t, "source_team_drive_id", retrievedBothConfig.SourceTeamDrive)
assert.Equal(t, "dest_team_drive_id", retrievedBothConfig.DestTeamDrive)
// Clean up
err = db.DeleteTransferConfig(teamDriveSourceConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(teamDriveDestConfig.ID)
assert.NoError(t, err)
err = db.DeleteTransferConfig(teamDriveBothConfig.ID)
assert.NoError(t, err)
}
+3 -3
View File
@@ -66,7 +66,7 @@ func TestDeleteTransferConfigEdgeCases(t *testing.T) {
Name: "Job for Config",
ConfigID: configWithJob.ID,
Schedule: "0 * * * *",
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(job)
@@ -122,7 +122,7 @@ func TestDeleteJobEdgeCases(t *testing.T) {
Name: fmt.Sprintf("Edge Job %d", i),
ConfigID: config.ID,
Schedule: "0 * * * *",
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(job)
@@ -145,7 +145,7 @@ func TestDeleteJobEdgeCases(t *testing.T) {
Name: "Job with History",
ConfigID: config.ID,
Schedule: "0 * * * *",
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(jobWithHistory)
+2 -2
View File
@@ -70,7 +70,7 @@ func TestGetPasswordResetTokenError(t *testing.T) {
UserID: testUser.ID,
Token: "used-token",
ExpiresAt: time.Now().Add(1 * time.Hour),
Used: true,
Used: BoolPtr(true),
}
err = db.CreatePasswordResetToken(usedToken)
assert.NoError(t, err)
@@ -131,7 +131,7 @@ func TestGenerateRcloneConfigErrors(t *testing.T) {
testUser := &User{
Email: "config-error-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
+1 -1
View File
@@ -66,7 +66,7 @@ func TestInitializeWithExistingDB(t *testing.T) {
user := &User{
Email: "test@example.com",
PasswordHash: "hash",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
err = db1.CreateUser(user)
assert.NoError(t, err)
@@ -0,0 +1,21 @@
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
View File
@@ -16,6 +16,7 @@ func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
AddMultiConfigSupport(),
UpdateSkipProcessedFilesToNullable(),
AddWebhookSupport(),
AddGoogleDriveAuthenticated(),
}
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
+114
View File
@@ -3,6 +3,7 @@ package db
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
@@ -135,3 +136,116 @@ func TestGenerateRcloneConfigWithoutRclone(t *testing.T) {
}
}
}
func TestGoogleDriveRcloneConfig(t *testing.T) {
// Skip if rclone not available
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone" // default to PATH lookup
}
_, err := exec.Command(rclonePath, "--version").CombinedOutput()
if err != nil {
t.Skip("Skipping test as rclone is not available")
}
db := setupTestDB(t)
// Create a test user
testUser := &User{
Email: fmt.Sprintf("google-rclone-test-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hashed_password",
LastPasswordChange: time.Now(),
}
err = db.CreateUser(testUser)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Create Google Drive source config
googleSourceConfig := &TransferConfig{
Name: "Google Drive Source Rclone Test",
SourceType: "google_drive",
SourcePath: "/path/in/google/drive",
SourceClientID: "source_google_client_id",
SourceClientSecret: "source_google_client_secret",
SourceTeamDrive: "source_team_drive_id",
DestinationType: "local",
DestinationPath: "/local/destination/path",
FilePattern: "*.pdf",
CreatedBy: testUser.ID,
}
// Set authenticated status
authenticated := true
googleSourceConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleSourceConfig)
assert.NoError(t, err)
err = db.GenerateRcloneConfigWithToken(googleSourceConfig, "test_token")
assert.NoError(t, err)
// Generate rclone config for source
configPath := db.GetConfigRclonePath(googleSourceConfig)
// Check that the file exists
_, err = os.Stat(configPath)
assert.NoError(t, err, "Rclone config file should exist")
// Read the config file
configContent, err := os.ReadFile(configPath)
assert.NoError(t, err)
content := string(configContent)
// Verify it contains Google Drive specific content
assert.Contains(t, content, "type = drive")
assert.Contains(t, content, fmt.Sprintf("client_id = %s", googleSourceConfig.SourceClientID))
assert.Contains(t, content, "source")
assert.Contains(t, content, fmt.Sprintf("team_drive = %s", googleSourceConfig.SourceTeamDrive))
// Create Google Drive destination config
googleDestConfig := &TransferConfig{
Name: "Google Drive Dest Rclone Test",
SourceType: "local",
SourcePath: "/local/source/path",
DestinationType: "google_drive",
DestinationPath: "/dest/path/in/google/drive",
DestClientID: "dest_google_client_id",
DestClientSecret: "dest_google_client_secret",
DestTeamDrive: "dest_team_drive_id",
FilePattern: "*.pdf",
CreatedBy: testUser.ID,
}
// Set authenticated status
googleDestConfig.GoogleDriveAuthenticated = &authenticated
// Create the config
err = db.CreateTransferConfig(googleDestConfig)
assert.NoError(t, err)
// Generate rclone config for destination
configPath = db.GetConfigRclonePath(googleDestConfig)
// Check that the file exists
_, err = os.Stat(configPath)
assert.NoError(t, err, "Rclone config file should exist")
// Read the config file
configContent, err = os.ReadFile(configPath)
assert.NoError(t, err)
content = string(configContent)
// Verify it contains Google Drive specific content
assert.Contains(t, content, "type = drive")
assert.Contains(t, content, fmt.Sprintf("client_id = %s", googleDestConfig.DestClientID))
assert.Contains(t, content, "dest")
assert.Contains(t, content, fmt.Sprintf("team_drive = %s", googleDestConfig.DestTeamDrive))
// Clean up
err = db.Delete(&googleSourceConfig).Error
assert.NoError(t, err)
err = db.Delete(&googleDestConfig).Error
assert.NoError(t, err)
}
+2 -2
View File
@@ -117,7 +117,7 @@ func TestDeleteJobWithTransaction(t *testing.T) {
Name: "Test Delete Job",
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run hourly
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob)
@@ -137,7 +137,7 @@ func TestDeleteJobWithTransaction(t *testing.T) {
Name: "Test Delete Job 2",
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run hourly
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = db.CreateJob(testJob2)
+1 -1
View File
@@ -31,7 +31,7 @@ func (m *MockScheduler) ScheduleJob(job *db.Job) error {
return m.ScheduleJobErr
}
if job.Enabled {
if job.GetEnabled() {
m.ScheduledJobs[job.ID] = true
delete(m.UnscheduledJobs, job.ID)
} else {
+2 -2
View File
@@ -17,8 +17,8 @@ func TestMockScheduler_MultiConfig(t *testing.T) {
Name: "Multi-Config Test Job",
Schedule: "*/5 * * * *",
ConfigID: 1, // Primary config ID
Enabled: true,
}
job.SetEnabled(true)
// Set multiple config IDs
job.SetConfigIDsList([]uint{1, 2, 3})
@@ -55,8 +55,8 @@ func TestMockScheduler_MultiConfig(t *testing.T) {
Name: "Single Config Job",
Schedule: "0 0 * * *",
ConfigID: 4,
Enabled: true,
}
singleConfigJob.SetEnabled(true)
// Set a single config ID
singleConfigJob.SetConfigIDsList([]uint{4})
+7 -7
View File
@@ -241,7 +241,7 @@ func (s *Scheduler) loadJobs() {
for _, job := range jobs {
// Skip disabled jobs
if !job.Enabled {
if !job.GetEnabled() {
s.log.LogInfo("Job %d (%s) is disabled, skipping scheduling", job.ID, job.Name)
continue
}
@@ -268,7 +268,7 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error {
}
// Only schedule if job is enabled
if !job.Enabled {
if !job.GetEnabled() {
s.log.LogInfo("Job %d is disabled, skipping scheduling", job.ID)
return nil
}
@@ -783,7 +783,7 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
}
// If archiving is enabled and transfer was successful, move files to archive
if config.ArchiveEnabled && config.ArchivePath != "" {
if config.GetArchiveEnabled() && config.ArchivePath != "" {
s.log.LogInfo("Archiving file %s for job %d, config %d", currentFileName, job.ID, config.ID)
// We don't need to move the file since we used moveto, but we can copy it to archive
@@ -828,7 +828,7 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
}
}
if config.DeleteAfterTransfer {
if config.GetDeleteAfterTransfer() {
s.log.LogInfo("Deleting file %s for job %d, config %d", currentFileName, job.ID, config.ID)
deleteArgs := []string{
"--config", configPath,
@@ -1026,15 +1026,15 @@ func (s *Scheduler) checkFileProcessingHistory(jobID uint, fileName string) (*db
// sendWebhookNotification sends a notification to the configured webhook URL
func (s *Scheduler) sendWebhookNotification(job *db.Job, history *db.JobHistory, config *db.TransferConfig) {
if !job.WebhookEnabled || job.WebhookURL == "" {
if !job.GetWebhookEnabled() || job.WebhookURL == "" {
return
}
// Skip notifications based on settings
if history.Status == "completed" && !job.NotifyOnSuccess {
if history.Status == "completed" && !job.GetNotifyOnSuccess() {
return
}
if history.Status == "failed" && !job.NotifyOnFailure {
if history.Status == "failed" && !job.GetNotifyOnFailure() {
return
}
+30 -27
View File
@@ -151,7 +151,7 @@ func TestScheduler_ScheduleJob(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
@@ -175,7 +175,7 @@ func TestScheduler_ScheduleJob(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job).Error; err != nil {
@@ -207,11 +207,8 @@ func TestScheduler_ScheduleJob(t *testing.T) {
t.Errorf("Expected NextRun to be set, got nil")
}
// Test scheduling a disabled job
job.Enabled = false
if err := scheduler.ScheduleJob(job); err != nil {
t.Fatalf("Failed to schedule disabled job: %v", err)
}
// Disable the job
job.SetEnabled(false)
// Check that the disabled job was not scheduled
scheduler.jobMutex.Lock()
@@ -222,8 +219,10 @@ func TestScheduler_ScheduleJob(t *testing.T) {
t.Errorf("Expected disabled job not to be scheduled, but it was")
}
// Re-enable the job
job.SetEnabled(true)
// Test with invalid cron expression
job.Enabled = true
job.Schedule = "invalid cron"
if err := scheduler.ScheduleJob(job); err == nil {
t.Errorf("Expected error for invalid cron expression, got nil")
@@ -399,7 +398,7 @@ func TestRunJobNow(t *testing.T) {
// Create a test user
user := &db.User{
Email: "test_runjob@example.com",
IsAdmin: false,
IsAdmin: BoolPtr(false),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -423,7 +422,7 @@ func TestRunJobNow(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.Create(job).Error
@@ -481,7 +480,7 @@ func TestHasFileBeenProcessed(t *testing.T) {
// Create a test user
user := &db.User{
Email: "test_fileprocessed@example.com",
IsAdmin: false,
IsAdmin: BoolPtr(false),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -505,7 +504,7 @@ func TestHasFileBeenProcessed(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.Create(job).Error
@@ -568,7 +567,7 @@ func TestCheckFileProcessingHistory(t *testing.T) {
// Create a test user
user := &db.User{
Email: "test_filehistory@example.com",
IsAdmin: false,
IsAdmin: BoolPtr(false),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -592,7 +591,7 @@ func TestCheckFileProcessingHistory(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.Create(job).Error
@@ -654,7 +653,7 @@ func TestUnscheduleJob(t *testing.T) {
user := &db.User{
Email: "unschedule-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
@@ -678,7 +677,7 @@ func TestUnscheduleJob(t *testing.T) {
Name: "Test Job 1",
Schedule: "*/15 * * * *", // Every 15 minutes
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job1).Error; err != nil {
@@ -689,7 +688,7 @@ func TestUnscheduleJob(t *testing.T) {
Name: "Test Job 2",
Schedule: "0 */2 * * *", // Every 2 hours
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job2).Error; err != nil {
@@ -854,7 +853,7 @@ func TestStopScheduler(t *testing.T) {
user := &db.User{
Email: "stop-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
@@ -878,7 +877,7 @@ func TestStopScheduler(t *testing.T) {
Name: "Test Job",
Schedule: "*/1 * * * *", // Every minute
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job).Error; err != nil {
@@ -941,7 +940,7 @@ func TestFileProcessingFullCycle(t *testing.T) {
user := &db.User{
Email: "file-processing-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
@@ -966,7 +965,7 @@ func TestFileProcessingFullCycle(t *testing.T) {
Name: "File Processing Test Job",
Schedule: "*/30 * * * *", // Every 30 minutes
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job).Error; err != nil {
@@ -1051,7 +1050,7 @@ func TestExecuteJobWithMultipleConfigs(t *testing.T) {
user := &db.User{
Email: "multi-config-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
@@ -1098,7 +1097,7 @@ func TestExecuteJobWithMultipleConfigs(t *testing.T) {
job := &db.Job{
Name: "Multi-Config Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
@@ -1195,7 +1194,7 @@ func TestScheduler_LoadMultiConfigJobs(t *testing.T) {
user := &db.User{
Email: "multiconfig-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
LastPasswordChange: time.Now(),
}
if err := database.CreateUser(user); err != nil {
@@ -1212,7 +1211,7 @@ func TestScheduler_LoadMultiConfigJobs(t *testing.T) {
job1 := &db.Job{
Name: "Multi-Config Job 1",
Schedule: "*/5 * * * *",
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
job1.SetConfigIDsList([]uint{config1.ID, config2.ID})
@@ -1225,7 +1224,7 @@ func TestScheduler_LoadMultiConfigJobs(t *testing.T) {
job2 := &db.Job{
Name: "Multi-Config Job 2",
Schedule: "0 * * * *",
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
job2.SetConfigIDsList([]uint{config3.ID, config4.ID})
@@ -1239,7 +1238,7 @@ func TestScheduler_LoadMultiConfigJobs(t *testing.T) {
Name: "Single-Config Job",
Schedule: "0 0 * * *",
ConfigID: config1.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job3).Error
@@ -1312,3 +1311,7 @@ func TestScheduler_LoadMultiConfigJobs(t *testing.T) {
func boolPtr(b bool) *bool {
return &b
}
func BoolPtr(b bool) *bool {
return &b
}
+15 -15
View File
@@ -38,7 +38,7 @@ func TestJobExecutionWebhook(t *testing.T) {
user := &db.User{
Email: "webhook-integration@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -96,11 +96,11 @@ func TestJobExecutionWebhook(t *testing.T) {
Name: "Webhook Integration Job",
ConfigID: config.ID,
Schedule: "*/5 * * * *", // not actually used in this test
Enabled: true,
WebhookEnabled: true,
Enabled: BoolPtr(true),
WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
NotifyOnSuccess: true,
NotifyOnFailure: true,
NotifyOnSuccess: BoolPtr(true),
NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job).Error
@@ -259,7 +259,7 @@ func TestFailedJobWebhook(t *testing.T) {
user := &db.User{
Email: "webhook-failure@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -314,11 +314,11 @@ func TestFailedJobWebhook(t *testing.T) {
Name: "Webhook Failure Job",
ConfigID: config.ID,
Schedule: "*/5 * * * *", // not actually used in this test
Enabled: true,
WebhookEnabled: true,
Enabled: BoolPtr(true),
WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
NotifyOnSuccess: true,
NotifyOnFailure: true,
NotifyOnSuccess: BoolPtr(true),
NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job).Error
@@ -431,7 +431,7 @@ func TestWebhookDisabledForSuccessNotification(t *testing.T) {
user := &db.User{
Email: "webhook-disabled@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -476,11 +476,11 @@ func TestWebhookDisabledForSuccessNotification(t *testing.T) {
Name: "Webhook Disabled Job",
ConfigID: config.ID,
Schedule: "*/5 * * * *", // not actually used in this test
Enabled: true,
WebhookEnabled: true,
Enabled: BoolPtr(true),
WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
NotifyOnSuccess: false, // This is the key setting we're testing
NotifyOnFailure: true,
NotifyOnSuccess: BoolPtr(false), // This is the key setting we're testing
NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job).Error
+27 -27
View File
@@ -35,7 +35,7 @@ func TestWebhookNotification(t *testing.T) {
user := &db.User{
Email: "webhook-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -110,10 +110,10 @@ func TestWebhookNotification(t *testing.T) {
job: &db.Job{
Name: "Success Job",
ConfigID: config.ID,
WebhookEnabled: true,
WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
NotifyOnSuccess: true,
NotifyOnFailure: true,
NotifyOnSuccess: BoolPtr(true),
NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
},
history: &db.JobHistory{
@@ -135,10 +135,10 @@ func TestWebhookNotification(t *testing.T) {
job: &db.Job{
Name: "Failed Job",
ConfigID: config.ID,
WebhookEnabled: true,
WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
NotifyOnSuccess: true,
NotifyOnFailure: true,
NotifyOnSuccess: BoolPtr(true),
NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
},
history: &db.JobHistory{
@@ -159,10 +159,10 @@ func TestWebhookNotification(t *testing.T) {
job: &db.Job{
Name: "Success Job No Notify",
ConfigID: config.ID,
WebhookEnabled: true,
WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
NotifyOnSuccess: false,
NotifyOnFailure: true,
NotifyOnSuccess: BoolPtr(false),
NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
},
history: &db.JobHistory{
@@ -182,10 +182,10 @@ func TestWebhookNotification(t *testing.T) {
job: &db.Job{
Name: "Failed Job No Notify",
ConfigID: config.ID,
WebhookEnabled: true,
WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
NotifyOnSuccess: true,
NotifyOnFailure: false,
NotifyOnSuccess: BoolPtr(true),
NotifyOnFailure: BoolPtr(false),
CreatedBy: user.ID,
},
history: &db.JobHistory{
@@ -206,10 +206,10 @@ func TestWebhookNotification(t *testing.T) {
job: &db.Job{
Name: "Webhook Disabled",
ConfigID: config.ID,
WebhookEnabled: false,
WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
NotifyOnSuccess: true,
NotifyOnFailure: true,
NotifyOnSuccess: BoolPtr(true),
NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
},
history: &db.JobHistory{
@@ -244,10 +244,10 @@ func TestWebhookNotification(t *testing.T) {
job := &db.Job{
Name: tc.job.Name,
ConfigID: tc.job.ConfigID,
WebhookEnabled: tc.webhookEnabled,
WebhookEnabled: BoolPtr(tc.webhookEnabled),
WebhookURL: tc.webhookURL,
NotifyOnSuccess: tc.notifyOnSuccess,
NotifyOnFailure: tc.notifyOnFailure,
NotifyOnSuccess: BoolPtr(tc.notifyOnSuccess),
NotifyOnFailure: BoolPtr(tc.notifyOnFailure),
CreatedBy: tc.job.CreatedBy,
}
@@ -366,7 +366,7 @@ func TestWebhookAuthentication(t *testing.T) {
user := &db.User{
Email: "webhook-auth-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -413,11 +413,11 @@ func TestWebhookAuthentication(t *testing.T) {
job := &db.Job{
Name: "Auth Test Job",
ConfigID: config.ID,
WebhookEnabled: true,
WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
WebhookSecret: secret,
NotifyOnSuccess: true,
NotifyOnFailure: true,
NotifyOnSuccess: BoolPtr(true),
NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job).Error
@@ -492,7 +492,7 @@ func TestWebhookCustomHeaders(t *testing.T) {
user := &db.User{
Email: "webhook-headers-test@example.com",
PasswordHash: "hashed_password",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
err := database.CreateUser(user)
require.NoError(t, err)
@@ -548,11 +548,11 @@ func TestWebhookCustomHeaders(t *testing.T) {
job := &db.Job{
Name: "Custom Headers Test Job",
ConfigID: config.ID,
WebhookEnabled: true,
WebhookEnabled: BoolPtr(true),
WebhookURL: mockServer.URL,
WebhookHeaders: string(customHeadersJSON),
NotifyOnSuccess: true,
NotifyOnFailure: true,
NotifyOnSuccess: BoolPtr(true),
NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
}
err = database.DB.Create(job).Error
+1 -1
View File
@@ -66,9 +66,9 @@ func CreateTestUser(t *testing.T, database *db.DB, email string, isAdmin bool) *
user := &db.User{
Email: email,
PasswordHash: string(hashedPassword),
IsAdmin: isAdmin,
LastPasswordChange: time.Now(),
}
user.SetIsAdmin(isAdmin)
if err := database.CreateUser(user); err != nil {
t.Fatalf("Failed to create test user: %v", err)
+26 -17
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
@@ -331,7 +330,7 @@ func (h *Handlers) HandleImportConfigs(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -370,7 +369,7 @@ func (h *Handlers) HandleImportJobs(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -401,7 +400,7 @@ func (h *Handlers) HandleImportJobs(c *gin.Context) {
}
if enabled, ok := rawJob["enabled"].(bool); ok {
job.Enabled = enabled
job.SetEnabled(enabled)
}
// Handle config_id
@@ -443,7 +442,7 @@ func (h *Handlers) HandleListBackups(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -466,7 +465,7 @@ func (h *Handlers) HandleSystemInfo(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -494,7 +493,7 @@ func (h *Handlers) HandleImportJobsFromFile(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -547,7 +546,7 @@ func (h *Handlers) HandleImportJobsFromFile(c *gin.Context) {
}
if enabled, ok := rawJob["enabled"].(bool); ok {
job.Enabled = enabled
job.SetEnabled(enabled)
}
// Handle config_id
@@ -589,7 +588,7 @@ func (h *Handlers) HandleDeleteLogFile(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -641,7 +640,7 @@ func (h *Handlers) HandleSystemMaintenanceCheck(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -682,7 +681,7 @@ func (h *Handlers) HandleUpdateSystemSettings(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -736,7 +735,12 @@ func (h *Handlers) checkDatabaseSize() map[string]interface{} {
// Parse size for comparison
var size float64
var unit string
fmt.Sscanf(sizeStr, "%f %s", &size, &unit)
if _, err := fmt.Sscanf(sizeStr, "%f %s", &size, &unit); err != nil {
return map[string]interface{}{
"status": "unknown",
"message": "Unable to determine database size",
}
}
status := "healthy"
message := fmt.Sprintf("Database size is %s", sizeStr)
@@ -1139,7 +1143,7 @@ func (h *Handlers) getLogFiles() []components.LogFile {
}
// Try to read directory
files, err := ioutil.ReadDir(logsDir)
files, err := os.ReadDir(logsDir)
if err != nil {
return []components.LogFile{}
}
@@ -1156,11 +1160,16 @@ func (h *Handlers) getLogFiles() []components.LogFile {
continue
}
size := formatSize(float64(file.Size()))
fileInfo, err := file.Info()
if err != nil {
continue
}
size := formatSize(float64(fileInfo.Size()))
logFiles = append(logFiles, components.LogFile{
Name: file.Name(),
Size: size,
ModTime: file.ModTime(),
ModTime: fileInfo.ModTime(),
Path: filepath.Join(logsDir, file.Name()),
})
}
@@ -1203,7 +1212,7 @@ func (h *Handlers) HandleViewLog(c *gin.Context) {
}
// Read file contents
content, err := ioutil.ReadFile(filePath)
content, err := os.ReadFile(filePath)
if err != nil {
c.String(http.StatusInternalServerError, "Error reading log file: "+err.Error())
return
@@ -1316,7 +1325,7 @@ func (h *Handlers) HandleImportConfigsFromFile(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -36,7 +36,7 @@ func TestHandleAdminTools(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Create a test request
@@ -81,7 +81,7 @@ func TestHandleBackupDatabase(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -131,7 +131,7 @@ func TestHandleVacuumDatabase(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -186,7 +186,7 @@ func TestHandleClearJobHistory(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -228,7 +228,7 @@ func TestHandleExportConfigs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
config := &db.TransferConfig{
@@ -267,8 +267,7 @@ func TestHandleExportConfigs(t *testing.T) {
// Parse the response as JSON
var configs []map[string]interface{}
var err error
err = json.Unmarshal(w.Body.Bytes(), &configs)
var err = json.Unmarshal(w.Body.Bytes(), &configs)
assert.NoError(t, err)
assert.Greater(t, len(configs), 0)
}
@@ -281,7 +280,7 @@ func TestHandleExportJobs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Create a test config
@@ -301,7 +300,7 @@ func TestHandleExportJobs(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
handlers.DB.DB.Create(job)
@@ -332,8 +331,7 @@ func TestHandleExportJobs(t *testing.T) {
// Parse the response as JSON
var jobs []map[string]interface{}
var err error
err = json.Unmarshal(w.Body.Bytes(), &jobs)
var err = json.Unmarshal(w.Body.Bytes(), &jobs)
assert.NoError(t, err)
assert.Greater(t, len(jobs), 0)
}
@@ -346,7 +344,7 @@ func TestHandleImportConfigs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the route
@@ -407,7 +405,7 @@ func TestHandleImportJobs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -481,7 +479,7 @@ func TestHandleExportConfigsUnauthorized(t *testing.T) {
testUser := &db.User{
ID: 2,
Email: "user@example.com",
IsAdmin: false,
IsAdmin: BoolPtr(false),
}
// Set up the context with the non-admin user
@@ -519,7 +517,7 @@ func TestHandleBackupDatabaseError(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -589,7 +587,7 @@ func TestHandleListBackups(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -636,7 +634,7 @@ func TestHandleSystemInfo(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -705,7 +703,7 @@ func TestHandleImportConfigsFromFile(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the route
@@ -768,7 +766,7 @@ func TestHandleImportJobsFromFile(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -868,7 +866,7 @@ func TestHandleImportConfigsInvalidJSON(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -937,7 +935,7 @@ func TestHandleDeleteLogFile(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -982,7 +980,7 @@ func TestHandleSystemMaintenanceCheck(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - this must be done BEFORE registering the routes
@@ -1024,7 +1022,7 @@ func TestHandleUpdateSystemSettings(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1088,7 +1086,7 @@ func TestHandleViewLog(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1134,7 +1132,7 @@ func TestHandleViewLogNotFound(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1187,7 +1185,7 @@ func TestHandleDownloadLog(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1241,7 +1239,7 @@ func TestHandleDeleteBackup(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1301,7 +1299,7 @@ func TestHandleDownloadBackup(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1353,7 +1351,7 @@ func TestHandleRefreshLogs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1414,7 +1412,7 @@ func TestHandleRefreshBackups(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
+1 -1
View File
@@ -35,7 +35,7 @@ func (h *Handlers) HandleAPILogin(c *gin.Context) {
}
// Generate JWT token
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
token, err := h.GenerateJWT(user.ID, user.Email, user.GetIsAdmin())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
+10 -13
View File
@@ -26,7 +26,7 @@ func setupAPITest(t *testing.T) (*Handlers, *gin.Engine, *db.DB, *db.User) {
user := &db.User{
Email: "test@example.com",
PasswordHash: string(hashedPassword),
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(user)
@@ -52,10 +52,7 @@ func setupAuthenticatedAPITest(t *testing.T, isAdmin bool) (*Handlers, *gin.Engi
handlers, router, database, user := setupAPITest(t)
// Update user admin status if needed
if isAdmin != user.IsAdmin {
user.IsAdmin = isAdmin
database.Save(user)
}
user.SetIsAdmin(isAdmin)
// Set up authentication middleware
router.Use(func(c *gin.Context) {
@@ -162,7 +159,7 @@ func TestHandleAPIConfigs(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -242,7 +239,7 @@ func TestHandleAPIConfig(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -376,7 +373,7 @@ func TestHandleAPIUpdateConfig(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -483,7 +480,7 @@ func TestHandleAPIDeleteConfig(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -513,7 +510,7 @@ func TestHandleAPIDeleteConfig(t *testing.T) {
Name: "Test Job",
Schedule: "* * * * *",
ConfigID: configWithJob.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -591,7 +588,7 @@ func TestHandleAPIRunJob(t *testing.T) {
Name: "Test Job",
Schedule: "* * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -600,7 +597,7 @@ func TestHandleAPIRunJob(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -609,7 +606,7 @@ func TestHandleAPIRunJob(t *testing.T) {
Name: "Other User Job",
Schedule: "* * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
+29 -20
View File
@@ -17,6 +17,15 @@ import (
"golang.org/x/crypto/bcrypt"
)
// Define a custom type for context keys to avoid string collisions
type contextKey string
// Context keys
const (
themeKey contextKey = "theme"
emailKey contextKey = "email"
)
// AuthMiddleware is a middleware function that checks if the user is authenticated
func (h *Handlers) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -156,7 +165,7 @@ func (h *Handlers) HandleLoginPage(c *gin.Context) {
// Create template context and set email if available
ctx := components.CreateTemplateContext(c)
if email, exists := c.Get("email"); exists {
ctx = context.WithValue(ctx, "email", email)
ctx = context.WithValue(ctx, emailKey, email)
}
// Check for message query param (used for password expired, etc.)
@@ -183,10 +192,10 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
}
// Check if account is locked
if user.AccountLocked {
if user.GetAccountLocked() {
if user.LockoutUntil != nil && time.Now().After(*user.LockoutUntil) {
// Lockout period has expired, reset the lockout
user.AccountLocked = false
user.SetAccountLocked(false)
user.FailedLoginAttempts = 0
user.LockoutUntil = nil
h.DB.Save(&user)
@@ -205,7 +214,7 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
// Check if we need to lock the account
policy := auth.DefaultPasswordPolicy()
if user.FailedLoginAttempts >= policy.MaxLoginAttempts {
user.AccountLocked = true
user.SetAccountLocked(true)
lockoutTime := time.Now().Add(policy.LockoutDuration)
user.LockoutUntil = &lockoutTime
h.DB.Save(&user)
@@ -220,7 +229,7 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
// Reset failed login attempts on successful login
user.FailedLoginAttempts = 0
user.AccountLocked = false
user.SetAccountLocked(false)
user.LockoutUntil = nil
h.DB.Save(&user)
@@ -375,7 +384,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
// HandleForgotPasswordPage displays the forgot password form
func (h *Handlers) HandleForgotPasswordPage(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ForgotPassword(ctx, "", "").Render(c.Request.Context(), c.Writer)
}
@@ -383,7 +392,7 @@ func (h *Handlers) HandleForgotPasswordPage(c *gin.Context) {
func (h *Handlers) HandleForgotPassword(c *gin.Context) {
email := c.PostForm("email")
if email == "" {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ForgotPassword(ctx, "Email is required", "").Render(c.Request.Context(), c.Writer)
return
}
@@ -394,7 +403,7 @@ func (h *Handlers) HandleForgotPassword(c *gin.Context) {
// Don't reveal that the email doesn't exist for security reasons
// But we'll log it for debugging
log.Printf("Password reset requested for non-existent email: %s", email)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ForgotPassword(ctx, "", "If your email is registered, you will receive a password reset link.").Render(c.Request.Context(), c.Writer)
return
}
@@ -403,7 +412,7 @@ func (h *Handlers) HandleForgotPassword(c *gin.Context) {
token, err := generateResetToken(32)
if err != nil {
log.Printf("Error generating reset token: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ForgotPassword(ctx, "An error occurred. Please try again later.", "").Render(c.Request.Context(), c.Writer)
return
}
@@ -411,14 +420,14 @@ func (h *Handlers) HandleForgotPassword(c *gin.Context) {
// Save token in database with expiration time (15 minutes)
expiration := time.Now().Add(15 * time.Minute)
resetToken := &db.PasswordResetToken{
UserID: user.ID,
Token: token,
ExpiresAt: expiration,
UserID: user.ID,
Token: token,
ExpiresAt: expiration,
}
if err := h.DB.CreatePasswordResetToken(resetToken); err != nil {
log.Printf("Error saving reset token: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ForgotPassword(ctx, "An error occurred. Please try again later.", "").Render(c.Request.Context(), c.Writer)
return
}
@@ -437,7 +446,7 @@ func (h *Handlers) HandleForgotPassword(c *gin.Context) {
// Show success message regardless of whether email was sent
// This prevents user enumeration attacks
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ForgotPassword(ctx, "", "If your email is registered, you will receive a password reset link.").Render(c.Request.Context(), c.Writer)
}
@@ -457,7 +466,7 @@ func (h *Handlers) HandleResetPasswordPage(c *gin.Context) {
return
}
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ResetPassword(ctx, token, "").Render(c.Request.Context(), c.Writer)
}
@@ -473,19 +482,19 @@ func (h *Handlers) HandleResetPassword(c *gin.Context) {
}
if password == "" || confirmPassword == "" {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ResetPassword(ctx, token, "Both password fields are required.").Render(c.Request.Context(), c.Writer)
return
}
if password != confirmPassword {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ResetPassword(ctx, token, "Passwords do not match.").Render(c.Request.Context(), c.Writer)
return
}
if len(password) < 8 {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ResetPassword(ctx, token, "Password must be at least 8 characters long.").Render(c.Request.Context(), c.Writer)
return
}
@@ -510,7 +519,7 @@ func (h *Handlers) HandleResetPassword(c *gin.Context) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ResetPassword(ctx, token, "An error occurred. Please try again later.").Render(c.Request.Context(), c.Writer)
return
}
@@ -520,7 +529,7 @@ func (h *Handlers) HandleResetPassword(c *gin.Context) {
user.LastPasswordChange = time.Now()
if err := h.DB.UpdateUser(user); err != nil {
log.Printf("Error updating user password: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ResetPassword(ctx, token, "An error occurred. Please try again later.").Render(c.Request.Context(), c.Writer)
return
}
+13 -13
View File
@@ -318,9 +318,9 @@ func TestHandleLogin(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: string(hashedPassword),
IsAdmin: false,
IsAdmin: BoolPtr(false),
FailedLoginAttempts: 0,
AccountLocked: false,
AccountLocked: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(user)
@@ -437,9 +437,9 @@ func TestHandleChangePassword(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: string(hashedPassword),
IsAdmin: false,
IsAdmin: BoolPtr(false),
FailedLoginAttempts: 0,
AccountLocked: false,
AccountLocked: BoolPtr(false),
LastPasswordChange: time.Now().Add(-24 * time.Hour), // 1 day ago
}
database.Create(user)
@@ -577,7 +577,7 @@ func TestHandleForgotPassword(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: string(hashedPassword),
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(user)
@@ -612,7 +612,7 @@ func TestHandleForgotPassword(t *testing.T) {
result := database.Where("user_id = ?", user.ID).First(&resetToken)
assert.NoError(t, result.Error, "Reset token should be created")
assert.NotEmpty(t, resetToken.Token, "Token should not be empty")
assert.False(t, resetToken.Used, "Token should not be marked as used")
assert.False(t, resetToken.GetUsed(), "Token should not be marked as used")
// Verify email would have been sent (if not mocked)
// Note: We can't check SendPasswordResetEmailCalls with our current mock
@@ -654,7 +654,7 @@ func TestHandleResetPasswordPage(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(user)
@@ -665,7 +665,7 @@ func TestHandleResetPasswordPage(t *testing.T) {
UserID: user.ID,
Token: token,
ExpiresAt: time.Now().Add(15 * time.Minute),
Used: false,
Used: BoolPtr(false),
}
database.Create(resetToken)
@@ -719,7 +719,7 @@ func TestHandleResetPassword(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: string(hashedPassword),
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now().Add(-24 * time.Hour), // 1 day ago
}
database.Create(user)
@@ -730,7 +730,7 @@ func TestHandleResetPassword(t *testing.T) {
UserID: user.ID,
Token: token,
ExpiresAt: time.Now().Add(15 * time.Minute),
Used: false,
Used: BoolPtr(false),
}
database.Create(resetToken)
@@ -767,7 +767,7 @@ func TestHandleResetPassword(t *testing.T) {
// Verify token is marked as used
var updatedToken db.PasswordResetToken
database.First(&updatedToken, resetToken.ID)
assert.True(t, updatedToken.Used, "Token should be marked as used")
assert.True(t, updatedToken.GetUsed(), "Token should be marked as used")
// Test case 2: Passwords don't match
// Create another token first
@@ -776,7 +776,7 @@ func TestHandleResetPassword(t *testing.T) {
UserID: user.ID,
Token: token2,
ExpiresAt: time.Now().Add(15 * time.Minute),
Used: false,
Used: BoolPtr(false),
}
database.Create(resetToken2)
@@ -800,7 +800,7 @@ func TestHandleResetPassword(t *testing.T) {
UserID: user.ID,
Token: token3,
ExpiresAt: time.Now().Add(15 * time.Minute),
Used: false,
Used: BoolPtr(false),
}
database.Create(resetToken3)
+47 -5
View File
@@ -17,8 +17,16 @@ func (h *Handlers) HandleConfigs(c *gin.Context) {
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
// Check for error or status parameters in the URL
error := c.Query("error")
errorDetails := c.Query("details")
status := c.Query("status")
data := components.ConfigsData{
Configs: configs,
Configs: configs,
Error: error,
ErrorDetails: errorDetails,
Status: status,
}
components.Configs(c.Request.Context(), data).Render(c, c.Writer)
}
@@ -72,10 +80,27 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
userID := c.GetUint("userID")
config.CreatedBy = userID
// Process skipProcessedFiles value (now using pointer)
skipProcessedValue := c.Request.FormValue("skip_processed_files") == "true"
// Process Boolean fields
skipProcessedVal := c.Request.FormValue("skip_processed_files")
skipProcessedValue := skipProcessedVal == "on" || skipProcessedVal == "true"
config.SkipProcessedFiles = &skipProcessedValue
archiveEnabledVal := c.Request.FormValue("archive_enabled")
archiveEnabledValue := archiveEnabledVal == "on" || archiveEnabledVal == "true"
config.ArchiveEnabled = &archiveEnabledValue
deleteAfterTransferVal := c.Request.FormValue("delete_after_transfer")
deleteAfterTransferValue := deleteAfterTransferVal == "on" || deleteAfterTransferVal == "true"
config.DeleteAfterTransfer = &deleteAfterTransferValue
sourcePassiveModeVal := c.Request.FormValue("source_passive_mode")
sourcePassiveModeValue := sourcePassiveModeVal == "on" || sourcePassiveModeVal == "true"
config.SourcePassiveMode = &sourcePassiveModeValue
destPassiveModeVal := c.Request.FormValue("dest_passive_mode")
destPassiveModeValue := destPassiveModeVal == "on" || destPassiveModeVal == "true"
config.DestPassiveMode = &destPassiveModeValue
if err := h.DB.Create(&config).Error; err != nil {
log.Printf("Error creating config: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to create config: %v", err))
@@ -125,10 +150,27 @@ func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
return
}
// Process skipProcessedFiles value (now using pointer)
skipProcessedValue := c.Request.FormValue("skip_processed_files") == "true"
// Process Boolean fields
skipProcessedVal := c.Request.FormValue("skip_processed_files")
skipProcessedValue := skipProcessedVal == "on" || skipProcessedVal == "true"
config.SkipProcessedFiles = &skipProcessedValue
archiveEnabledVal := c.Request.FormValue("archive_enabled")
archiveEnabledValue := archiveEnabledVal == "on" || archiveEnabledVal == "true"
config.ArchiveEnabled = &archiveEnabledValue
deleteAfterTransferVal := c.Request.FormValue("delete_after_transfer")
deleteAfterTransferValue := deleteAfterTransferVal == "on" || deleteAfterTransferVal == "true"
config.DeleteAfterTransfer = &deleteAfterTransferValue
sourcePassiveModeVal := c.Request.FormValue("source_passive_mode")
sourcePassiveModeValue := sourcePassiveModeVal == "on" || sourcePassiveModeVal == "true"
config.SourcePassiveMode = &sourcePassiveModeValue
destPassiveModeVal := c.Request.FormValue("dest_passive_mode")
destPassiveModeValue := destPassiveModeVal == "on" || destPassiveModeVal == "true"
config.DestPassiveMode = &destPassiveModeValue
// Preserve fields that shouldn't be updated
config.CreatedBy = oldConfig.CreatedBy
@@ -341,7 +341,7 @@ func TestHandleDeleteConfig(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: configWithJob.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.Create(job).Error; err != nil {
@@ -37,7 +37,7 @@ func setupDashboardTest(t *testing.T) (*Handlers, *gin.Engine, *db.DB) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job).Error; err != nil {
@@ -16,6 +16,10 @@ type FileMetadataHandler struct {
DB *db.DB
}
type UserIDKey string
const userIDKey UserIDKey = "userID"
// ListFileMetadata displays a list of file metadata with pagination and filtering options
func (h *FileMetadataHandler) ListFileMetadata(c *gin.Context) {
userID := c.GetUint("userID")
@@ -322,9 +326,6 @@ func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
return
}
// Create context for template
ctx := components.CreateTemplateContext(c)
// Render the file metadata search template
data := components.FileMetadataSearchData{
Files: fileMetadata,
@@ -348,7 +349,7 @@ func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
}
// Add HTMX request checking and conditional rendering
ctx = context.WithValue(c.Request.Context(), "userID", userID)
ctx := context.WithValue(c.Request.Context(), userIDKey, userID)
// Check if this is an HTMX request
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
@@ -626,7 +627,7 @@ func (h *FileMetadataHandler) HandleFileMetadataSearchPartial(c *gin.Context) {
data.TotalPages++
}
ctx := context.WithValue(c.Request.Context(), "userID", userID)
ctx := context.WithValue(c.Request.Context(), userIDKey, userID)
c.Header("Content-Type", "text/html")
components.FileMetadataSearchContent(data).Render(ctx, c.Writer)
}
@@ -43,7 +43,7 @@ func setupFileMetadataHandlers(t *testing.T) (*FileMetadataHandler, *gin.Engine,
Name: "Test Job for File Metadata",
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run hourly
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = handlers.DB.CreateJob(testJob)
+380
View File
@@ -0,0 +1,380 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// HandleGDriveAuth initiates the Google Drive authentication process
func (h *Handlers) HandleGDriveAuth(c *gin.Context) {
// Get the config ID from the query parameter
configIDStr := c.Param("id")
if configIDStr == "" {
RenderErrorPage(c, "Missing configuration ID", "")
return
}
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
RenderErrorPage(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())
return
}
// Ensure it's a Google Drive configuration
if config.DestinationType != "gdrive" {
RenderErrorPage(c, "Not a Google Drive configuration", "The selected configuration is not set up for Google Drive")
return
}
// Prepare for OAuth
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
// 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")
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())
return
}
tempConfigPath := filepath.Join(tempConfigDir, fmt.Sprintf("gdrive_auth_%d.conf", config.ID))
// Store the temporary config path in a cookie
c.SetCookie("gdrive_temp_config", 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/configs/gdrive-callback", baseURL)
// Attempt to get GDRIVE_CLIENT_ID and GDRIVE_CLIENT_SECRET from ENV
clientID := os.Getenv("GDRIVE_CLIENT_ID")
clientSecret := os.Getenv("GDRIVE_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
// Check if we have client credentials in the existing config file
existingClientID, existingClientSecret := h.DB.GetGDriveCredentialsFromConfig(config)
if existingClientID != "" && existingClientSecret != "" {
// Use credentials from existing config
clientID = existingClientID
clientSecret = existingClientSecret
} else {
// fallback to rclone client ID and secret
clientID = "202264815644.apps.googleusercontent.com"
clientSecret = "X4Z3ca8xfWDb1Voo-F9a7ZxJ"
}
}
if config.DestClientID != "" && config.DestClientSecret == "" {
// If user provided just client ID but no secret, try to find the secret in the config
_, existingClientSecret := h.DB.GetGDriveCredentialsFromConfig(config)
if existingClientSecret != "" {
// Use the secret from the existing config with the provided client ID
clientSecret = existingClientSecret
} else {
// If we still can't find a matching secret, show an error
RenderErrorPage(c, "Missing client secret", "You provided a custom client ID but no client secret. Both are required for Google Drive authentication.")
return
}
}
// Generate state parameter for security (to prevent CSRF)
state := fmt.Sprintf("gomft_%d_%d", config.ID, time.Now().Unix())
// Store state in cookie for validation during callback
c.SetCookie("gdrive_auth_state", state, 3600, "/", "", false, true)
// Store config ID in cookie for use during callback
c.SetCookie("gdrive_config_id", configIDStr, 3600, "/", "", false, true)
// Create a config file with redirect URI-based auth
configContent := fmt.Sprintf(`[temp_gdrive]
type = drive
client_id = %s
client_secret = %s
redirect_url = %s
`, 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
scope := url.QueryEscape("https://www.googleapis.com/auth/drive")
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)
}
// HandleGDriveAuthCallback handles the callback from Google OAuth
func (h *Handlers) 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")
return
}
// Verify state parameter to prevent CSRF
state := c.Query("state")
storedState, err := c.Cookie("gdrive_auth_state")
if err != nil || state != storedState {
RenderErrorPage(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")
return
}
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
RenderErrorPage(c, "Invalid configuration ID", err.Error())
return
}
// Get the temp config path from cookie
tempConfigPath, err := c.Cookie("gdrive_temp_config")
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/configs/gdrive-callback", baseURL)
// Get the configuration to retrieve client ID and secret
config, err := h.DB.GetTransferConfig(uint(configID))
if err != nil {
RenderErrorPage(c, "Failed to get configuration", err.Error())
return
}
// Attempt to get GDRIVE_CLIENT_ID and GDRIVE_CLIENT_SECRET from ENV
clientID := os.Getenv("GDRIVE_CLIENT_ID")
clientSecret := os.Getenv("GDRIVE_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
// Check if we have client credentials in the existing config file
existingClientID, existingClientSecret := h.DB.GetGDriveCredentialsFromConfig(config)
if existingClientID != "" && existingClientSecret != "" {
// Use credentials from existing config
clientID = existingClientID
clientSecret = existingClientSecret
} else {
// fallback to rclone client ID and secret
clientID = "202264815644.apps.googleusercontent.com"
clientSecret = "X4Z3ca8xfWDb1Voo-F9a7ZxJ"
}
}
if config.DestClientID != "" && config.DestClientSecret == "" {
// If user provided just client ID but no secret, try to find the secret in the config
_, existingClientSecret := h.DB.GetGDriveCredentialsFromConfig(config)
if existingClientSecret != "" {
// Use the secret from the existing config with the provided client ID
clientSecret = existingClientSecret
} else {
// If we still can't find a matching secret, show an error
RenderErrorPage(c, "Missing client secret", "You provided a custom client ID but no client secret. Both are required for Google Drive authentication.")
return
}
}
// 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 configuration as authenticated in the database
config.SetGoogleDriveAuthenticated(true)
if err := h.DB.UpdateTransferConfig(config); err != nil {
RenderErrorPage(c, "Failed to update configuration", err.Error())
return
}
// Generate the rclone config file with the token
if err := h.DB.GenerateRcloneConfigWithToken(config, tokenJSON); err != nil {
RenderErrorPage(c, "Failed to generate rclone configuration", err.Error())
return
}
// Clean up the temporary file
os.Remove(tempConfigPath)
// Clear cookies
c.SetCookie("gdrive_temp_config", "", -1, "/", "", false, true)
c.SetCookie("gdrive_auth_state", "", -1, "/", "", false, true)
c.SetCookie("gdrive_config_id", "", -1, "/", "", false, true)
// Redirect to the config list with a success message
c.Redirect(http.StatusFound, "/configs?status=gdrive_auth_success")
}
// HandleGDriveTokenProcess processes a Google Drive token directly from a URL parameter
func (h *Handlers) HandleGDriveTokenProcess(c *gin.Context) {
// Get the parameters
configID := c.Query("config_id")
if configID == "" {
RenderErrorPage(c, "Missing configuration ID", "")
return
}
token := c.Query("token")
if token == "" {
RenderErrorPage(c, "Missing token", "")
return
}
// Parse config ID
configIDUint, err := strconv.ParseUint(configID, 10, 64)
if err != nil {
RenderErrorPage(c, "Invalid configuration ID", err.Error())
return
}
// Get the configuration
config, err := h.DB.GetTransferConfig(uint(configIDUint))
if err != nil {
RenderErrorPage(c, "Configuration not found", err.Error())
return
}
// Ensure it's a Google Drive configuration
if config.DestinationType != "gdrive" {
RenderErrorPage(c, "Not a Google Drive configuration", "")
return
}
// Mark the configuration as authenticated
config.SetGoogleDriveAuthenticated(true)
if err := h.DB.UpdateTransferConfig(config); err != nil {
RenderErrorPage(c, "Failed to update configuration", err.Error())
return
}
// Generate the rclone config with the token
if err := h.DB.GenerateRcloneConfigWithToken(config, token); err != nil {
RenderErrorPage(c, "Failed to generate rclone configuration", err.Error())
return
}
// Redirect to the config list with success
c.Redirect(http.StatusFound, "/configs?status=gdrive_auth_success")
}
// RenderErrorPage renders an error page with the given message
func RenderErrorPage(c *gin.Context, title string, details string) {
// Here we'd typically use a component for error display
// For now, we'll just redirect to the configs page with an error in the query string
errorURL := "/configs?error=" + url.QueryEscape(title)
if details != "" {
errorURL += "&details=" + url.QueryEscape(details)
}
c.Redirect(http.StatusFound, errorURL)
}
+4 -4
View File
@@ -35,7 +35,7 @@ func TestHandleImportJobsFixed(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up middleware to add the user to the context
@@ -99,7 +99,7 @@ func TestHandleImportJobsFixed(t *testing.T) {
ConfigID: testJobs[0].ConfigID,
ConfigIDs: testJobs[0].ConfigIDs,
Schedule: testJobs[0].Schedule,
Enabled: testJobs[0].Enabled,
Enabled: BoolPtr(testJobs[0].Enabled),
CreatedBy: testJobs[0].CreatedBy,
}
@@ -138,7 +138,7 @@ func TestHandleImportJobsFromFileFixed(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up middleware to add the user to the context - must be done BEFORE registering routes
@@ -218,7 +218,7 @@ func TestHandleImportJobsFromFileFixed(t *testing.T) {
ConfigID: testJobs[0].ConfigID,
ConfigIDs: testJobs[0].ConfigIDs,
Schedule: testJobs[0].Schedule,
Enabled: testJobs[0].Enabled,
Enabled: BoolPtr(testJobs[0].Enabled),
CreatedBy: testJobs[0].CreatedBy,
}
+34
View File
@@ -214,6 +214,23 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) {
// Set the config IDs list
job.SetConfigIDsList(configIDsList)
// Set the boolean fields - handle both "on" and "true" values for checkboxes
enabledVal := c.Request.FormValue("enabled")
jobEnabledValue := enabledVal == "on" || enabledVal == "true"
job.SetEnabled(jobEnabledValue)
webhookEnabledVal := c.Request.FormValue("webhook_enabled")
webhookEnabledValue := webhookEnabledVal == "on" || webhookEnabledVal == "true"
job.SetWebhookEnabled(webhookEnabledValue)
notifySuccessVal := c.Request.FormValue("notify_on_success")
notifyOnSuccessValue := notifySuccessVal == "on" || notifySuccessVal == "true"
job.SetNotifyOnSuccess(notifyOnSuccessValue)
notifyFailureVal := c.Request.FormValue("notify_on_failure")
notifyOnFailureValue := notifyFailureVal == "on" || notifyFailureVal == "true"
job.SetNotifyOnFailure(notifyOnFailureValue)
// Set created by user
job.CreatedBy = userID
@@ -315,6 +332,23 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
// Set the config IDs list
job.SetConfigIDsList(configIDsList)
// Set the boolean fields - handle both "on" and "true" values for checkboxes
enabledVal := c.Request.FormValue("enabled")
jobEnabledValue := enabledVal == "on" || enabledVal == "true"
job.SetEnabled(jobEnabledValue)
webhookEnabledVal := c.Request.FormValue("webhook_enabled")
webhookEnabledValue := webhookEnabledVal == "on" || webhookEnabledVal == "true"
job.SetWebhookEnabled(webhookEnabledValue)
notifySuccessVal := c.Request.FormValue("notify_on_success")
notifyOnSuccessValue := notifySuccessVal == "on" || notifySuccessVal == "true"
job.SetNotifyOnSuccess(notifyOnSuccessValue)
notifyFailureVal := c.Request.FormValue("notify_on_failure")
notifyOnFailureValue := notifyFailureVal == "on" || notifyFailureVal == "true"
job.SetNotifyOnFailure(notifyOnFailureValue)
// Preserve fields that shouldn't be updated
job.CreatedBy = oldJob.CreatedBy
job.ID = oldJob.ID
+34 -34
View File
@@ -25,7 +25,7 @@ func setupJobsTest(t *testing.T) (*Handlers, *gin.Engine, *db.DB, *db.User, *db.
user := &db.User{
Email: "jobtest@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(user)
@@ -34,7 +34,7 @@ func setupJobsTest(t *testing.T) (*Handlers, *gin.Engine, *db.DB, *db.User, *db.
adminUser := &db.User{
Email: "jobadmin@example.com",
PasswordHash: "hashedpassword",
IsAdmin: true,
IsAdmin: BoolPtr(true),
LastPasswordChange: time.Now(),
}
database.Create(adminUser)
@@ -99,7 +99,7 @@ func TestHandleJobs(t *testing.T) {
Name: "Test Job 1",
Schedule: "*/5 * * * *",
ConfigID: 1,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job1)
@@ -108,7 +108,7 @@ func TestHandleJobs(t *testing.T) {
Name: "Test Job 2",
Schedule: "*/10 * * * *",
ConfigID: 1,
Enabled: false,
Enabled: BoolPtr(false),
CreatedBy: user.ID,
}
database.Create(job2)
@@ -117,7 +117,7 @@ func TestHandleJobs(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -126,7 +126,7 @@ func TestHandleJobs(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: 1,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
@@ -178,7 +178,7 @@ func TestHandleEditJob(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -187,7 +187,7 @@ func TestHandleEditJob(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -196,7 +196,7 @@ func TestHandleEditJob(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
@@ -232,7 +232,7 @@ func TestHandleEditJob(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(adminOtherJob)
@@ -287,13 +287,13 @@ func TestHandleCreateJob(t *testing.T) {
assert.Equal(t, jobName, job.Name)
assert.Equal(t, "*/15 * * * *", job.Schedule)
assert.Equal(t, config.ID, job.ConfigID)
assert.True(t, job.Enabled)
assert.True(t, job.GetEnabled())
// Test case 2: Try to use another user's config
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -420,7 +420,7 @@ func TestHandleUpdateJob(t *testing.T) {
Name: jobName,
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
@@ -435,7 +435,7 @@ func TestHandleUpdateJob(t *testing.T) {
assert.NoError(t, err, "Should find the newly created job")
assert.Equal(t, jobName, createdJob.Name, "Created job should have the expected name")
assert.Equal(t, "*/5 * * * *", createdJob.Schedule, "Created job should have the expected schedule")
assert.True(t, createdJob.Enabled, "Created job should be enabled")
assert.True(t, createdJob.GetEnabled(), "Created job should be enabled")
// Add route
router.PUT("/jobs/:id", handlers.HandleUpdateJob)
@@ -482,7 +482,7 @@ func TestHandleUpdateJob(t *testing.T) {
// Verify individual fields one by one
assert.Equal(t, updatedName, updatedJob.Name, "Job name should be updated")
assert.Equal(t, "0 0 * * *", updatedJob.Schedule, "Job schedule should be updated")
assert.False(t, updatedJob.Enabled, "Enabled status should be false")
assert.False(t, updatedJob.GetEnabled(), "Enabled status should be false")
// Make sure the ConfigIDs are still correct
configIDs := updatedJob.GetConfigIDsList()
@@ -493,7 +493,7 @@ func TestHandleUpdateJob(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
result = database.Create(otherUser)
@@ -504,7 +504,7 @@ func TestHandleUpdateJob(t *testing.T) {
Name: "Other User Job " + time.Now().Format("20060102150405"),
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
// Make sure the other job also has a config list set
@@ -557,7 +557,7 @@ func TestHandleUpdateJobWithMultipleConfigs(t *testing.T) {
Name: "Test Job for Multi-config Update",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
// Set initial configs (just config1)
@@ -596,7 +596,7 @@ func TestHandleUpdateJobWithMultipleConfigs(t *testing.T) {
assert.Equal(t, "Updated Multi-Config Job", updatedJob.Name)
assert.Equal(t, "0 * * * *", updatedJob.Schedule)
assert.True(t, updatedJob.Enabled)
assert.True(t, updatedJob.GetEnabled())
// The primary ConfigID should be updated to the first config in the new list
assert.Equal(t, config2.ID, updatedJob.ConfigID)
@@ -623,7 +623,7 @@ func TestHandleDeleteJob(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -651,7 +651,7 @@ func TestHandleDeleteJob(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -660,7 +660,7 @@ func TestHandleDeleteJob(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
@@ -683,7 +683,7 @@ func TestHandleRunJob(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -711,7 +711,7 @@ func TestHandleRunJob(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -720,7 +720,7 @@ func TestHandleRunJob(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
@@ -744,7 +744,7 @@ func TestHandleJobRunDetails(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -791,7 +791,7 @@ func TestHandleJobsFilter(t *testing.T) {
Name: "Test Job 1",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job1)
@@ -800,7 +800,7 @@ func TestHandleJobsFilter(t *testing.T) {
Name: "Test Job 2",
Schedule: "*/10 * * * *",
ConfigID: config.ID,
Enabled: false,
Enabled: BoolPtr(false),
CreatedBy: user.ID,
}
database.Create(job2)
@@ -809,7 +809,7 @@ func TestHandleJobsFilter(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -818,7 +818,7 @@ func TestHandleJobsFilter(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
@@ -881,7 +881,7 @@ func TestHandleJobHistory(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -976,7 +976,7 @@ func TestHandleJobSchedule(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -1060,7 +1060,7 @@ func TestHandleJobSchedule(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -1069,7 +1069,7 @@ func TestHandleJobSchedule(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
+239
View File
@@ -0,0 +1,239 @@
package handlers
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// GoogleDriveAuthHandler initiates the Google Drive OAuth flow
func (h *Handlers) HandleGoogleDriveAuth(c *gin.Context) {
configID := c.Query("config_id")
if configID == "" {
RenderErrorPage(c, "Missing config_id parameter", "")
return
}
// Prepare for the OAuth flow
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
// Ensure oauth directory exists
oauthDir := filepath.Join(dataDir, "oauth")
if err := os.MkdirAll(oauthDir, 0755); err != nil {
RenderErrorPage(c, "Failed to create oauth directory", err.Error())
return
}
// Get rclone path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
// Set up a temporary rclone config
tempConfigPath := filepath.Join(oauthDir, fmt.Sprintf("temp_gdrive_%s.conf", configID))
// Build rclone command to get auth URL
cmd := exec.Command(
rclonePath,
"config",
"create",
"temp_gdrive",
"drive",
"--config",
tempConfigPath,
)
// Set a timeout context
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
// Run the command with proper context handling
// We can't use exec.CommandContext directly since we're creating the command differently
// So we'll use a goroutine with the context's Done() channel to handle cancellation
go func() {
<-ctx.Done() // Wait for context to be done (timeout or cancellation)
if cmd.Process != nil {
if err := cmd.Process.Kill(); err != nil {
RenderErrorPage(c, "Failed to kill rclone process", err.Error())
}
}
}()
// Run the command to get the browser URL (this will fail in a specific way)
output, err := cmd.CombinedOutput()
if err != nil {
outputStr := string(output)
// Look for the URL in the output
authURL := extractAuthURL(outputStr)
if authURL == "" {
RenderErrorPage(c, "Failed to get Google Drive authentication URL", outputStr)
return
}
// Store the config ID in the session
session := sessions.Default(c)
session.Set("gdrive_config_id", configID)
session.Set("gdrive_temp_config", tempConfigPath)
if err := session.Save(); err != nil {
RenderErrorPage(c, "Failed to save session", err.Error())
return
}
// Use component rendering instead of HTML template
// This would typically use a component like:
// components.GDriveAuth(c.Request.Context(), components.GDriveAuthData{
// AuthURL: authURL,
// ConfigID: configID,
// }).Render(c.Request.Context(), c.Writer)
// For now, we'll redirect to the configs page with the auth URL and config ID
c.Redirect(http.StatusFound, fmt.Sprintf("/configs/%s/gdrive-auth?auth_url=%s",
configID, url.QueryEscape(authURL)))
return
}
// If we get here, something unexpected happened
RenderErrorPage(c, "Unexpected result from rclone", string(output))
}
// HandleGoogleDriveCallback handles the manual entry of the OAuth code
func (h *Handlers) HandleGoogleDriveCallback(c *gin.Context) {
// Get the auth code from form submission
authCode := c.PostForm("auth_code")
if authCode == "" {
RenderErrorPage(c, "Missing authentication code", "")
return
}
// Get the config ID from the session
session := sessions.Default(c)
configID := session.Get("gdrive_config_id")
tempConfigPath := session.Get("gdrive_temp_config")
if configID == nil || tempConfigPath == nil {
RenderErrorPage(c, "Session expired or invalid. Please try again.", "")
return
}
// Get rclone path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
// Complete the OAuth flow with the provided code
cmd := exec.Command(
rclonePath,
"config",
"reconnect",
"temp_gdrive:",
"--config",
tempConfigPath.(string),
)
// Create a pipe for stdin
stdin, err := cmd.StdinPipe()
if err != nil {
RenderErrorPage(c, "Failed to create stdin pipe", err.Error())
return
}
// Start the command
if err := cmd.Start(); err != nil {
RenderErrorPage(c, "Failed to start rclone command", err.Error())
return
}
// Write the auth code to stdin
fmt.Fprintln(stdin, authCode)
stdin.Close()
// Wait for the command to complete
if err := cmd.Wait(); err != nil {
RenderErrorPage(c, "Failed to complete Google Drive authentication", err.Error())
return
}
// Read the token from the config file
configData, err := ioutil.ReadFile(tempConfigPath.(string))
if err != nil {
RenderErrorPage(c, "Failed to read token from config file", err.Error())
return
}
// Extract token from config
token := extractToken(string(configData))
if token == "" {
RenderErrorPage(c, "Failed to extract token from config", "")
return
}
// Store the token in the database
configIDStr := configID.(string)
if err := h.DB.StoreGoogleDriveToken(configIDStr, token); err != nil {
RenderErrorPage(c, "Failed to save token", err.Error())
return
}
// Clean up temporary config
os.Remove(tempConfigPath.(string))
// Clear session data
session.Delete("gdrive_config_id")
session.Delete("gdrive_temp_config")
if err := session.Save(); err != nil {
RenderErrorPage(c, "Failed to save session", err.Error())
return
}
// Redirect to the configs page
c.Redirect(http.StatusFound, "/configs?status=gdrive_auth_success")
}
// Helper function to extract the authentication URL from rclone output
func extractAuthURL(output string) string {
// This is a simplified version - you may need to improve the regex
// to handle different output formats from rclone
lines := strings.Split(output, "\n")
for _, line := range lines {
if strings.Contains(line, "http") && strings.Contains(line, "accounts.google.com") {
// Extract the URL - this is a simplified approach
words := strings.Fields(line)
for _, word := range words {
if strings.HasPrefix(word, "http") {
return word
}
}
}
}
return ""
}
// Helper function to extract token from rclone config
func extractToken(configData string) string {
// Look for the token JSON in the config
lines := strings.Split(configData, "\n")
for _, line := range lines {
if strings.Contains(line, "token") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
}
return ""
}
+6
View File
@@ -31,6 +31,12 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.PUT("/configs/:id", h.HandleUpdateConfig)
authorized.POST("/configs/:id", h.HandleUpdateConfig)
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
// Google Drive authentication routes
authorized.GET("/configs/:id/gdrive-auth", h.HandleGDriveAuth)
authorized.GET("/configs/gdrive-callback", h.HandleGDriveAuthCallback)
authorized.GET("/configs/gdrive-token", h.HandleGDriveTokenProcess)
authorized.GET("/jobs", h.HandleJobs)
authorized.GET("/jobs/new", h.HandleNewJob)
authorized.GET("/jobs/:id", h.HandleEditJob)
+1 -1
View File
@@ -81,8 +81,8 @@ func setupTestDB(t *testing.T) *db.DB {
admin := db.User{
Email: testEmail,
PasswordHash: string(hashedPassword),
IsAdmin: true,
}
admin.SetIsAdmin(true)
if err := gormDB.Create(&admin).Error; err != nil {
t.Fatalf("Failed to create test admin user: %v", err)
+6 -5
View File
@@ -57,9 +57,9 @@ func (h *Handlers) HandleCreateUser(c *gin.Context) {
user := db.User{
Email: email,
PasswordHash: string(hashedPassword),
IsAdmin: isAdmin,
LastPasswordChange: time.Now(),
}
user.SetIsAdmin(isAdmin)
if err := h.DB.Create(&user).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to create user")
@@ -130,21 +130,22 @@ func (h *Handlers) HandleRegister(c *gin.Context) {
return
}
// Create the admin user
// Create the user
user := db.User{
Email: email,
PasswordHash: string(hashedPassword),
IsAdmin: true,
LastPasswordChange: time.Now(),
}
// Set as regular user (not admin)
user.SetIsAdmin(false)
if err := h.DB.Create(&user).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to create user")
return
}
// Generate JWT
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
// Generate JWT token
token, err := h.GenerateJWT(user.ID, user.Email, user.GetIsAdmin())
if err != nil {
c.String(http.StatusInternalServerError, "Failed to generate token")
return
+1 -1
View File
@@ -299,7 +299,7 @@ func TestHandleRegister(t *testing.T) {
err := database.Where("email = ?", formData.Get("email")).First(&user).Error
assert.NoError(t, err)
assert.Equal(t, formData.Get("email"), user.Email)
assert.True(t, user.IsAdmin)
assert.True(t, user.GetIsAdmin())
// Verify JWT cookie was set
cookies := resp.Result().Cookies()
+17 -13
View File
@@ -57,12 +57,12 @@ func TestWebhookConfiguration(t *testing.T) {
require.NoError(t, err)
// Verify webhook settings were saved correctly
assert.True(t, job.WebhookEnabled)
assert.True(t, job.GetWebhookEnabled())
assert.Equal(t, "https://example.com/webhook", job.WebhookURL)
assert.Equal(t, "test-secret", job.WebhookSecret)
assert.Equal(t, `{"X-Test-Header": "test-value"}`, job.WebhookHeaders)
assert.True(t, job.NotifyOnSuccess)
assert.True(t, job.NotifyOnFailure)
assert.True(t, job.GetNotifyOnSuccess())
assert.True(t, job.GetNotifyOnFailure())
}
// TestWebhookEditConfiguration tests editing webhook configuration
@@ -75,8 +75,8 @@ func TestWebhookEditConfiguration(t *testing.T) {
Name: "Initial Job",
ConfigID: config.ID,
Schedule: "*/30 * * * *",
Enabled: true,
WebhookEnabled: false, // Initially disabled
Enabled: BoolPtr(true),
WebhookEnabled: BoolPtr(false), // Initially disabled
CreatedBy: user.ID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -116,12 +116,12 @@ func TestWebhookEditConfiguration(t *testing.T) {
require.NoError(t, err)
// Verify webhook settings were updated correctly
assert.True(t, updatedJob.WebhookEnabled)
assert.True(t, updatedJob.GetWebhookEnabled())
assert.Equal(t, "https://example.com/webhook", updatedJob.WebhookURL)
assert.Equal(t, "new-secret", updatedJob.WebhookSecret)
assert.Equal(t, `{"X-Api-Key": "12345"}`, updatedJob.WebhookHeaders)
assert.True(t, updatedJob.NotifyOnSuccess)
assert.False(t, updatedJob.NotifyOnFailure)
assert.True(t, updatedJob.GetNotifyOnSuccess())
assert.False(t, updatedJob.GetNotifyOnFailure())
}
// TestDisablingWebhook tests disabling a previously enabled webhook
@@ -134,13 +134,13 @@ func TestDisablingWebhook(t *testing.T) {
Name: "Webhook Enabled Job",
ConfigID: config.ID,
Schedule: "*/30 * * * *",
Enabled: true,
WebhookEnabled: true,
Enabled: BoolPtr(true),
WebhookEnabled: BoolPtr(true),
WebhookURL: "https://example.com/webhook",
WebhookSecret: "secret",
WebhookHeaders: `{"X-Test": "test"}`,
NotifyOnSuccess: true,
NotifyOnFailure: true,
NotifyOnSuccess: BoolPtr(true),
NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -178,7 +178,7 @@ func TestDisablingWebhook(t *testing.T) {
require.NoError(t, err)
// Verify webhook was disabled
assert.False(t, updatedJob.WebhookEnabled)
assert.False(t, updatedJob.GetWebhookEnabled())
// Other fields should remain unchanged
assert.Equal(t, "https://example.com/webhook", updatedJob.WebhookURL)
@@ -241,3 +241,7 @@ func TestWebhookValidation(t *testing.T) {
assert.NotEqual(t, http.StatusFound, resp.Code)
assert.Contains(t, resp.Body.String(), "valid JSON")
}
func BoolPtr(b bool) *bool {
return &b
}
+2 -2
View File
@@ -71,11 +71,11 @@ func main() {
// Create admin user
adminUser := &db.User{
Email: "admin@example.com",
Email: "admin@example.com",
PasswordHash: string(hashedPassword),
IsAdmin: true,
LastPasswordChange: time.Now(),
}
adminUser.SetIsAdmin(true)
if err := database.CreateUser(adminUser); err != nil {
log.Fatalf("Failed to create admin user: %v", err)