Compare commits

..
14 Commits
Author SHA1 Message Date
StarFleetCPTN 3dea48c691 Merge pull request #32 from StarFleetCPTN/development
refactor: Remove outdated migration for built-in auth fields and add …
2025-03-16 17:53:57 -07:00
StarFleetCPTN ef4bee88b8 refactor: Remove outdated migration for built-in auth fields and add new migration for updating Google Drive type
- Deleted the migration that updated the use_builtin_auth field to separate source and destination fields.
- Introduced a new migration to update source_type and destination_type from 'google_drive' to 'gdrive' in transfer_configs.
2025-03-16 17:50:43 -07:00
StarFleetCPTN 08d5f0edfc Merge pull request #31 from StarFleetCPTN/development
refactor: Update initial schema migration to correct table naming con…
2025-03-16 17:23:08 -07:00
StarFleetCPTN 1d43a7582d refactor: Update initial schema migration to correct table naming conventions
- Remove renaming logic for job_histories and password_histories tables, ensuring consistency with plural naming.
- Adjust CREATE TABLE statements to reflect the correct plural forms for job_histories and password_histories.
- Update drop table order to match the new naming conventions, maintaining foreign key constraints.
2025-03-16 17:14:14 -07:00
StarFleetCPTN 5e69aff21e Merge pull request #30 from StarFleetCPTN/development
chore: Revise README warning message for clarity
2025-03-16 16:30:39 -07:00
StarFleetCPTN 0c0c62b1f7 chore: Revise README warning message for clarity
- Replace the previous warning message with a more concise and clear statement regarding the active development status of GoMFT.
- Emphasize the potential for rapid changes in configurations and the importance of reviewing release notes before updates.
2025-03-16 16:29:18 -07:00
StarFleetCPTN d085bac227 Merge pull request #29 from StarFleetCPTN/development
New Logo, UI Updates, and Bug Fixes
2025-03-16 16:14:25 -07:00
StarFleetCPTN b1db53b89e feat: Refactor Google Drive and Google Photos authentication handling
- Separate built-in authentication options for source and destination in configuration forms.
- Update UI components to reflect changes in authentication handling for Google Drive and Google Photos.
- Implement path validation for local directories in the configuration forms, enhancing user experience.
- Revise README to clarify supported source and destination types, including updated terminology for Google Drive.
- Introduce new API endpoint for path validation, improving error handling and user feedback.
2025-03-16 16:12:42 -07:00
StarFleetCPTN 522699d96a refactor: Update UI components for improved styling and accessibility
- Change background and text colors in configuration forms to enhance visibility and consistency with dark mode.
- Update button classes and layout for better responsiveness and user experience.
- Revise authentication prompts and information sections for Google Drive and Google Photos to clarify the authentication process.
- Ensure all text elements are styled appropriately for both light and dark themes, improving overall accessibility.
2025-03-16 11:53:22 -07:00
StarFleetCPTN cab1de3306 feat: Update README and UI with new logo and warning message
- Add a new logo for GoMFT in SVG and PNG formats.
- Center the application title in the README and enhance the layout with a warning message about active development.
- Update the home page template to display the new logo prominently.
- Revise the screenshots section to improve visual presentation and clarity.
2025-03-16 11:06:07 -07:00
StarFleetCPTN 15058a7247 Merge pull request #26 from StarFleetCPTN/development
Added Google Drive and Google Photos and More Tests
2025-03-15 23:42:57 -07:00
StarFleetCPTN f1fd33c619 feat: Enhance README with Google Drive and Google Photos configuration details
- Add Google Drive and Google Photos as supported storage options in the features section.
- Include Google OAuth configuration parameters in the environment variable section.
- Update transfer configuration options to specify Google Photos and Google Drive specific settings.
- Improve documentation clarity for file options and performance settings.
2025-03-15 23:42:24 -07:00
StarFleetCPTN d68a80bd78 feat: Add Google Photos support for transfer configurations
- Introduce Google Photos as a source and destination option in the configuration forms.
- Implement authentication handling and configuration management for Google Photos.
- Update database schema and migrations to include new fields related to Google Photos.
- Enhance UI components to support Google Photos-specific settings and forms.
- Add tests for Google Photos integration, ensuring proper functionality and authentication flow.
- Limit concurrent transfers for Google Photos to ensure compliance with API restrictions.
2025-03-15 23:16:49 -07:00
StarFleetCPTN 8607f2098a 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.
2025-03-15 17:36:36 -07:00
70 changed files with 5136 additions and 767 deletions
+69 -35
View File
@@ -1,39 +1,39 @@
# GoMFT - Go Managed File Transfer
<p align="center">
<img src="static/img/logo.svg" alt="GoMFT Logo" width="200">
</p>
<h1 align="center">GoMFT - Go Managed File Transfer</h1>
GoMFT is a web-based managed file transfer application built with Go, leveraging rclone for robust file transfer capabilities. It provides a user-friendly interface for configuring, scheduling, and monitoring file transfers across various storage providers.
> [!WARNING]
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
## Screenshots
### Dashboard
![Dashboard Overview](screenshots/dashboard.gomft.png)
*Dashboard showing active transfers and system status*
![Dashboard Overview Dark](screenshots/dashboard.dark.gomft.png)
*Dashboard dark mode showing active transfers and system status*
### Configuration Interface
![Transfer Configuration](screenshots/new.configuration.gomft.png)
*Setting up transfer configurations with multiple storage options*
### Job Management
![Job Scheduling](screenshots/new.job.gomft.png)
*Scheduling transfers with flexible cron expressions*
### File Metadata
![File Metadata](screenshots/file.metadata.gomft.png)
*Track and manage file metadata with detailed information about transferred files*
### User Management
![User Management](screenshots/user.management.gomft.png)
*Create user accounts and manage them*
### Admin Tools
![Admin Tools](screenshots/admin.tools.gomft.png)
*Admin dashboard with log viewer and system management tools*
<table>
<tr>
<td width="33%">
<a href="screenshots/dashboard.gomft.png">
<img src="screenshots/dashboard.gomft.png" alt="Dashboard Overview" width="100%">
</a>
<p align="center"><em>Dashboard showing active transfers</em></p>
</td>
<td width="33%">
<a href="screenshots/dashboard.dark.gomft.png">
<img src="screenshots/dashboard.dark.gomft.png" alt="Dashboard Dark Mode" width="100%">
</a>
<p align="center"><em>Dashboard dark mode</em></p>
</td>
</tr>
</table>
## Features
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
- Google Drive
- Google Photos
- Amazon S3
- MinIO
- NextCloud
@@ -150,6 +150,10 @@ services:
- BACKUP_DIR=/app/backups
- JWT_SECRET=change_this_to_a_secure_random_string
- BASE_URL=http://localhost:8080
# Google OAuth configuration (optional)
- GOOGLE_CLIENT_ID=your_google_client_id
- GOOGLE_CLIENT_SECRET=your_google_client_secret
# Email configuration
- EMAIL_ENABLED=true
- EMAIL_HOST=smtp.example.com
- EMAIL_PORT=587
@@ -206,6 +210,10 @@ BACKUP_DIR=/app/backups
JWT_SECRET=change_this_to_a_secure_random_string
BASE_URL=http://localhost:8080
# Google OAuth configuration (optional, for built-in authentication)
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Email configuration
EMAIL_ENABLED=true
EMAIL_HOST=smtp.example.com
@@ -226,6 +234,9 @@ EMAIL_PASSWORD=smtp_password
- `BACKUP_DIR`: Directory for storing database backups
- `JWT_SECRET`: Secret key for JWT token generation
- `BASE_URL`: Base URL for generating links in emails (e.g., password reset links)
- Google OAuth configuration for built-in authentication:
- `GOOGLE_CLIENT_ID`: Your Google OAuth client ID
- `GOOGLE_CLIENT_SECRET`: Your Google OAuth client secret
- Email configuration settings for system notifications and password resets:
- `EMAIL_ENABLED`: Set to `true` to enable email functionality
- `EMAIL_HOST`: SMTP server hostname
@@ -332,6 +343,8 @@ User management features:
### Transfer Configuration Options
1. **Source/Destination Types**:
- Google Drive
- Google Photos
- Local filesystem
- Amazon S3
- MinIO (S3-compatible storage)
@@ -343,32 +356,46 @@ User management features:
2. **Connection Options**:
- Host/server addresses
- Authentication (username/password or key files)
- OAuth2 authentication for Google services
- Port configurations
- Cloud credentials (access keys, secret keys)
- Bucket and region settings
- Custom endpoints
- Custom rclone flags
3. **File Options**:
3. **Google Photos Specific Options**:
- Read-only mode for safer operations
- Start year filter for historical photos
- Include/exclude archived media
- Album path configuration
- Built-in or custom OAuth authentication
4. **Google Drive Specific Options**:
- Folder ID for specific directory access
- Team/Shared Drive ID support
- Built-in or custom OAuth authentication
- Path-based navigation
5. **File Options**:
- File patterns for filtering (e.g., `*.txt`, `data_*.csv`)
- Output patterns for dynamic naming
- Archive options for transferred files
- Skip already processed files to avoid duplicates
- Concurrent file transfers (configurable per job)
4. **Performance Options**:
6. **Performance Options**:
- **Multi-threaded File Transfers**: Process multiple files simultaneously for higher throughput
- Configurable concurrency level (1-32 concurrent transfers)
- Per-job concurrency settings to optimize for different storage types
- Automatic transfer queue management to prevent overloading systems
- Adaptive processing based on source/destination capabilities
5. **Schedule Options**:
7. **Schedule Options**:
- Cron expressions for flexible scheduling
- Manual execution
- Enable/disable schedules
6. **Webhook Notifications**:
8. **Webhook Notifications**:
- **Webhook Integration**: Send notifications to external systems when jobs complete
- **Secure Authentication**: HMAC-SHA256 signature for webhook verification
- **Custom Headers**: Add custom HTTP headers to webhook requests
@@ -533,10 +560,6 @@ air
4. Push to the branch
5. Create a Pull Request
## License
MIT License - see LICENSE file for details
## Directory Structure
GoMFT uses the following directory structure:
@@ -556,3 +579,14 @@ volumes:
```
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
## License
[MIT License](LICENSE) - see the full license terms
The GoMFT logo is licensed under the Creative Commons Attribution 4.0 International Public License.
The gopher design is from https://github.com/egonelbre/gophers.
The original Go gopher was designed by Renee French (http://reneefrench.blogspot.com/).
+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");
}
+106 -18
View File
@@ -7,6 +7,7 @@ import (
"github.com/starfleetcptn/gomft/components/providers/source"
"github.com/starfleetcptn/gomft/components/providers/destination"
"github.com/starfleetcptn/gomft/components/providers/common"
"strconv"
)
type ConfigFormData struct {
@@ -44,6 +45,10 @@ func getInitialData(config *db.TransferConfig) string {
sourceClientSecret := ""
sourceDriveId := ""
sourceTeamDrive := ""
// Google Photos source fields
sourceReadOnly := false
sourceStartYear, _ := strconv.Atoi(getCurrentYear())
sourceIncludeArchived := false
filePattern := ""
outputPattern := "${filename}"
@@ -68,6 +73,10 @@ func getInitialData(config *db.TransferConfig) string {
destClientSecret := ""
destDriveId := ""
destTeamDrive := ""
// Google Photos destination fields
destReadOnly := false
destStartYear, _ := strconv.Atoi(getCurrentYear()) // Default to current year int
destIncludeArchived := false
archivePath := ""
archiveEnabled := false
@@ -75,6 +84,8 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles := true
maxConcurrentTransfers := 4
rcloneFlags := ""
useBuiltinAuthSource := true
useBuiltinAuthDest := true
// If editing an existing config, populate with those values
if config != nil {
@@ -96,12 +107,21 @@ 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
sourceTeamDrive = config.SourceTeamDrive
// Google Photos source fields
if config.SourceReadOnly != nil {
sourceReadOnly = *config.SourceReadOnly
}
sourceStartYear = config.SourceStartYear
if config.SourceIncludeArchived != nil {
sourceIncludeArchived = *config.SourceIncludeArchived
}
filePattern = config.FilePattern
outputPattern = config.OutputPattern
@@ -122,21 +142,40 @@ 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
// Google Photos destination fields
if config.DestReadOnly != nil {
destReadOnly = *config.DestReadOnly
}
destStartYear = config.DestStartYear
if config.DestIncludeArchived != nil {
destIncludeArchived = *config.DestIncludeArchived
}
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 config.UseBuiltinAuthSource != nil {
useBuiltinAuthSource = *config.UseBuiltinAuthSource
} else if sourceClientId != "" || sourceClientSecret != "" {
useBuiltinAuthSource = false
}
if config.UseBuiltinAuthDest != nil {
useBuiltinAuthDest = *config.UseBuiltinAuthDest
} else if destClientId != "" || destClientSecret != "" {
useBuiltinAuthDest = false
}
}
// Return the JSON-formatted string with all the data
// Return the JSON-formatted string with all the data, add new path validation states
return fmt.Sprintf(`{
name: '%s',
sourceType: '%s',
@@ -159,6 +198,9 @@ func getInitialData(config *db.TransferConfig) string {
sourceClientSecret: '%s',
sourceDriveId: '%s',
sourceTeamDrive: '%s',
sourceReadOnly: %v,
sourceStartYear: %d,
sourceIncludeArchived: %v,
filePattern: '%s',
outputPattern: '%s',
@@ -183,6 +225,12 @@ func getInitialData(config *db.TransferConfig) string {
destClientSecret: '%s',
destDriveId: '%s',
destTeamDrive: '%s',
destReadOnly: %v,
destStartYear: %d,
destIncludeArchived: %v,
useBuiltinAuthSource: %v,
useBuiltinAuthDest: %v,
archivePath: '%s',
archiveEnabled: %v,
@@ -190,31 +238,60 @@ func getInitialData(config *db.TransferConfig) string {
skipProcessedFiles: %v,
maxConcurrentTransfers: %d,
rcloneFlags: '%s',
// Path validation states
sourcePathValid: null,
sourcePathError: '',
destPathValid: null,
destPathError: '',
// Methods for path validation
checkPath(path, type) {
if (!path) {
this[type + 'PathValid'] = false;
this[type + 'PathError'] = 'Path cannot be empty';
return;
}
fetch('/check-path?path=' + encodeURIComponent(path))
.then(response => response.json())
.then(data => {
this[type + 'PathValid'] = data.valid;
this[type + 'PathError'] = data.error || '';
})
.catch(error => {
this[type + 'PathValid'] = false;
this[type + 'PathError'] = 'Error checking path: ' + error.message;
});
}
}`,
name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceAuthType,
sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint, sourceShare, sourceDomain, sourcePassiveMode,
sourceClientId, sourceClientSecret, sourceDriveId, sourceTeamDrive,
sourceReadOnly, sourceStartYear, sourceIncludeArchived,
filePattern, outputPattern,
destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile, destAuthType,
destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, destShare, destDomain, destPassiveMode,
destClientId, destClientSecret, destDriveId, destTeamDrive,
destReadOnly, destStartYear, destIncludeArchived,
useBuiltinAuthSource, useBuiltinAuthDest,
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags)
}
templ ConfigForm(ctx context.Context, data ConfigFormData) {
@LayoutWithContext(getConfigFormTitle(data.IsNew), ctx) {
<div class="flex flex-col items-center justify-center min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-3xl w-full bg-white rounded-lg shadow-md p-8">
<div class="flex flex-col items-center justify-center min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-3xl w-full bg-white dark:bg-secondary-800 rounded-lg shadow-md p-8 border border-secondary-200 dark:border-secondary-700">
<div class="flex justify-center mb-6">
<div class="p-4 bg-blue-100 rounded-full">
<i class="fas fa-cog text-blue-500 text-2xl"></i>
<div class="p-4 bg-primary-100 dark:bg-primary-900 rounded-full">
<i class="fas fa-cog text-primary-500 dark:text-primary-400 text-2xl"></i>
</div>
</div>
<h1 class="mt-2 text-center text-3xl font-extrabold text-gray-900">
<h1 class="mt-2 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100">
{ getConfigFormTitle(data.IsNew) }
</h1>
<p class="mt-2 text-center text-sm text-gray-600">
<p class="mt-2 text-center text-sm text-secondary-600 dark:text-secondary-400">
Set up your file transfer configuration
</p>
@@ -277,6 +354,13 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
@source.NextCloudSourceForm()
</template>
<template x-if="sourceType === 'gdrive'">
@source.GoogleDriveSourceForm()
</template>
<template x-if="sourceType === 'gphotos'">
@source.GooglePhotosSourceForm()
</template>
<!-- File pattern fields -->
@common.FilePatternFields()
@@ -316,7 +400,14 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<template x-if="destinationType === 'webdav'">
@destination.WebDAVDestinationForm()
</template>
<template x-if="destinationType === 'gdrive'">
@destination.GoogleDriveDestinationForm()
</template>
<template x-if="destinationType === 'gphotos'">
@destination.GooglePhotosDestinationForm()
</template>
<!-- Archive options -->
@common.ArchiveOptions()
@@ -327,17 +418,14 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<!-- Form actions -->
<div class="flex justify-end mt-8">
<a href="/configs"
class="btn-secondary mr-4 px-6 py-3 rounded-lg flex items-center0">
class="btn-secondary mr-4">
Cancel
</a>
<button
type="submit"
class="btn-primary px-6 py-3 rounded-lg flex items-center"
>
class="btn-primary">
<span class="flex items-center">
<template>
<i class="fas fa-save mr-2"></i>
</template>
<i class="fas fa-save mr-2"></i>
if data.IsNew {
Create Configuration
} else {
@@ -349,7 +437,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
</form>
</div>
<div class="mt-6 text-center text-xs text-gray-500">
<div class="mt-6 text-center text-xs text-secondary-500 dark:text-secondary-400">
<p>Configure your file transfer settings carefully for optimal performance</p>
</div>
</div>
+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.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
<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.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() {
<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.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="btn-warning btn-sm">
<i class="fab fa-google 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 mr-1 text-blue-500 inline-flex items-center"></i>
Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.
</p>
</div>
</div>
</div>
}
+2 -2
View File
@@ -95,8 +95,8 @@ templ Home(ctx context.Context) {
</div>
} else {
<div class="max-w-md mx-auto card p-8 hover:shadow-lg transition-shadow duration-300">
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-20 h-20 flex items-center justify-center mb-6 mx-auto">
<i class="fas fa-sign-in-alt text-primary-600 dark:text-primary-300 text-3xl"></i>
<div class="inline-flex items-center justify-center w-64 h-64 mb-4">
<img src="/static/img/logo.svg" alt="GoMFT Logo" class="w-full h-full"/>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Get Started</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6">Log in to access the file transfer management system.</p>
+30 -23
View File
@@ -191,16 +191,17 @@ templ JobForm(ctx context.Context, data JobFormData) {
<div class="flex items-center">
<input
type="checkbox"
id="enabled"
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>
@@ -215,12 +216,15 @@ templ JobForm(ctx context.Context, data JobFormData) {
<div class="flex items-center">
<input
type="checkbox"
id="webhook_enabled"
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
@@ -434,18 +442,17 @@ templ JobForm(ctx context.Context, data JobFormData) {
<div class="flex items-center">
<input
type="checkbox"
id="enabled"
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>
@@ -460,15 +467,15 @@ templ JobForm(ctx context.Context, data JobFormData) {
<div class="flex items-center">
<input
type="checkbox"
id="webhook_enabled"
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>
+29 -25
View File
@@ -49,7 +49,7 @@ templ FilePatternFields() {
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
</p>
<div>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Pattern for filenames. Available variables:<br />
${`filename`} - Original filename without extension (e.g., "report")<br />
${`ext`} - Original file extension (e.g., "csv")<br />
@@ -65,8 +65,8 @@ templ FilePatternFields() {
}
templ ArchiveOptions() {
<div class="border border-gray-200 rounded-lg p-6 bg-gray-50 shadow-sm">
<h3 class="text-lg font-medium text-gray-900 mb-4">Archive &amp; Delete Options</h3>
<div class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-6 bg-secondary-50 dark:bg-secondary-800/50 shadow-sm">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Archive &amp; Delete Options</h3>
<div class="mb-4">
<label for="archive_enabled" class="flex items-center cursor-pointer">
@@ -75,29 +75,29 @@ templ ArchiveOptions() {
class="sr-only"
:value="archiveEnabled ? 'true' : 'false'"
/>
<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"
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="archiveEnabled ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Enable archiving
</div>
</label>
</div>
<div class="mb-4" x-show="archiveEnabled">
<label for="archive_path" class="block text-sm font-medium text-gray-700 mb-1">Archive Path</label>
<label for="archive_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Archive Path</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-gray-400"></i>
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
</span>
<input id="archive_path" name="archive_path" type="text"
class="pl-10 pr-10 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Path to archive files"
x-model="archivePath"
/>
</div>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Files will be moved here after successful transfer
</p>
</div>
@@ -109,15 +109,15 @@ templ ArchiveOptions() {
class="sr-only"
:value="deleteAfterTransfer ? 'true' : 'false'"
/>
<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"
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="deleteAfterTransfer ? 'transform translate-x-6 bg-red-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Delete source files after transfer
</div>
</label>
<p class="mt-1 ml-14 text-xs text-red-500" x-show="deleteAfterTransfer">
<p class="mt-1 ml-14 text-xs text-red-500 dark:text-red-400" x-show="deleteAfterTransfer">
<i class="fas fa-exclamation-triangle mr-1"></i> Warning: This will permanently delete the original files
</p>
</div>
@@ -129,30 +129,30 @@ templ ArchiveOptions() {
class="sr-only"
:value="skipProcessedFiles ? 'true' : 'false'"
/>
<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"
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="skipProcessedFiles ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Skip files that have already been processed
</div>
</label>
<p class="mt-1 ml-14 text-xs text-gray-500">
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Files with the same hash that have been successfully processed before will be skipped
</p>
</div>
<div class="mb-4">
<label for="max_concurrent_transfers" class="block text-sm font-medium text-gray-700 mb-1">Concurrent Transfers</label>
<label for="max_concurrent_transfers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Concurrent Transfers</label>
<div class="flex items-center">
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
class="form-range w-2/3 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
class="form-range w-2/3 h-2 bg-secondary-200 dark:bg-secondary-700 rounded-lg appearance-none cursor-pointer"
min="1" max="32" step="1"
x-model="maxConcurrentTransfers"
/>
<span class="ml-4 text-gray-700" x-text="maxConcurrentTransfers"></span>
<span class="ml-4 text-secondary-700 dark:text-secondary-300" x-text="maxConcurrentTransfers"></span>
</div>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Number of files to transfer simultaneously (higher values may improve performance but increase resource usage)
</p>
</div>
@@ -161,16 +161,16 @@ templ ArchiveOptions() {
templ RcloneFlags() {
<div>
<label for="rclone_flags" class="block text-sm font-medium text-gray-700">Rclone Flags</label>
<label for="rclone_flags" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300">Rclone Flags</label>
<div class="mt-1 relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-flag text-gray-400"></i>
<i class="fas fa-flag text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" name="rclone_flags" id="rclone_flags" x-model="rcloneFlags"
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="--transfers 4 --checkers 8" />
</div>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional: Additional rclone flags for fine-tuning the transfer.
</p>
</div>
@@ -194,6 +194,8 @@ templ SourceSelection() {
<option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option>
<option value="gdrive">Google Drive</option>
<option value="gphotos">Google Photos</option>
</select>
</div>
</div>
@@ -217,6 +219,8 @@ templ DestinationSelection() {
<option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option>
<option value="gdrive">Google Drive</option>
<option value="gphotos">Google Photos</option>
</select>
</div>
</div>
@@ -0,0 +1,175 @@
package destination
templ GoogleDriveDestinationForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
if(value) {
destClientId = '';
destClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
class="sr-only"
x-model="useBuiltinAuthDest"
:value="useBuiltinAuthDest ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</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': useBuiltinAuthDest }">
<div>
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<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="useBuiltinAuthDest"
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="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<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="useBuiltinAuthDest"
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 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Drive:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthDest">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<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="!useBuiltinAuthDest">
<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 dark:text-blue-400 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 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,214 @@
package destination
templ GooglePhotosDestinationForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
if(value) {
destClientId = '';
destClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
class="sr-only"
x-model="useBuiltinAuthDest"
:value="useBuiltinAuthDest ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</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': useBuiltinAuthDest }">
<div>
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<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="useBuiltinAuthDest"
placeholder="Google Photos 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="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<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="useBuiltinAuthDest"
placeholder="Google Photos 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_read_only" class="flex items-center cursor-pointer">
<div class="relative">
<input id="dest_read_only" name="dest_read_only" type="checkbox"
class="sr-only"
x-model="destReadOnly"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="destReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Read-only mode
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Only request read-only access to your photos
</p>
</div>
<div class="mt-4">
<label for="dest_start_year" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Start Year (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-calendar text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="number" id="dest_start_year" name="dest_start_year" x-model="destStartYear"
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="Only include photos after this year (default: 2000)" min="1900" max="2100" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Only include photos uploaded after this year
</p>
</div>
<div class="mt-4">
<label for="dest_include_archived" class="flex items-center cursor-pointer">
<div class="relative">
<input id="dest_include_archived" name="dest_include_archived" type="checkbox"
class="sr-only"
x-model="destIncludeArchived"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="destIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Include archived media
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Include archived photos and videos in media listings
</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">Album 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-images 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="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within Google Photos where files will be uploaded.
</p>
</div>
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Photos:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthDest">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<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="!useBuiltinAuthDest">
<div>
<p>To use Google Photos 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 dark:text-blue-400 underline">Google Cloud Console</a></li>
<li>Create a project and enable the Google Photos API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>All media items uploaded to Google Photos with rclone are stored in full resolution at original quality. These uploads will count towards storage in your Google Account.</p>
</div>
</div>
</div>
</div>
</div>
}
+19 -6
View File
@@ -6,7 +6,7 @@ templ LocalDestinationForm() {
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
@@ -14,13 +14,26 @@ templ LocalDestinationForm() {
id="destination_path"
x-model="destinationPath"
required
aria-describedby="destination_path_help"
:class="{ 'border-red-300 dark:border-red-700': destPathValid === false, 'border-green-300 dark:border-green-700': destPathValid === true }"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/destination"/>
placeholder="Local directory path (e.g., /path/to/destination)"
/>
</div>
<p id="destination_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Absolute path to the local directory where files will be saved.
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Full path to the local directory where files will be saved
</p>
<!-- Path validation message -->
<template x-if="destPathValid !== null">
<p x-show="destPathError" class="mt-1 text-sm" :class="destPathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="destPathError"></p>
</template>
</div>
<button
type="button"
class="btn-secondary h-10 whitespace-nowrap"
@click="checkPath(destinationPath, 'dest')"
>
<i class="fas fa-check-circle mr-2"></i>
Check Location
</button>
</div>
}
}
+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 {
+175
View File
@@ -0,0 +1,175 @@
package source
templ GoogleDriveSourceForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', 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="useBuiltinAuthSource"
:value="useBuiltinAuthSource ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</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': useBuiltinAuthSource }">
<div>
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<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="useBuiltinAuthSource"
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="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<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="useBuiltinAuthSource"
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 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Drive:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthSource">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<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="!useBuiltinAuthSource">
<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 dark:text-blue-400 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 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
}
+214
View File
@@ -0,0 +1,214 @@
package source
templ GooglePhotosSourceForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', 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="useBuiltinAuthSource"
:value="useBuiltinAuthSource ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</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': useBuiltinAuthSource }">
<div>
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<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="useBuiltinAuthSource"
placeholder="Google Photos 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="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<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="useBuiltinAuthSource"
placeholder="Google Photos 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_read_only" class="flex items-center cursor-pointer">
<div class="relative">
<input id="source_read_only" name="source_read_only" type="checkbox"
class="sr-only"
x-model="sourceReadOnly"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="sourceReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Read-only mode
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Only request read-only access to your photos
</p>
</div>
<div class="mt-4">
<label for="source_start_year" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Start Year (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-calendar text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="number" id="source_start_year" name="source_start_year" x-model="sourceStartYear"
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="Only include photos after this year (default: 2000)" min="1900" max="2100" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Only include photos uploaded after this year
</p>
</div>
<div class="mt-4">
<label for="source_include_archived" class="flex items-center cursor-pointer">
<div class="relative">
<input id="source_include_archived" name="source_include_archived" type="checkbox"
class="sr-only"
x-model="sourceIncludeArchived"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="sourceIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Include archived media
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Include archived photos and videos in media listings
</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">Album 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-images 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="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within Google Photos to download files from
</p>
</div>
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Photos:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthSource">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<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="!useBuiltinAuthSource">
<div>
<p>To use Google Photos 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 dark:text-blue-400 underline">Google Cloud Console</a></li>
<li>Create a project and enable the Google Photos API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
</div>
</div>
</div>
</div>
</div>
}
+19 -6
View File
@@ -6,7 +6,7 @@ templ LocalSourceForm() {
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
@@ -14,13 +14,26 @@ templ LocalSourceForm() {
id="source_path"
x-model="sourcePath"
required
aria-describedby="source_path_help"
:class="{ 'border-red-300 dark:border-red-700': sourcePathValid === false, 'border-green-300 dark:border-green-700': sourcePathValid === true }"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/source"/>
placeholder="Local directory path (e.g., /path/to/files)"
/>
</div>
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Absolute path to the local directory containing the files to transfer.
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Full path to the local directory containing your files
</p>
<!-- Path validation message -->
<template x-if="sourcePathValid !== null">
<p x-show="sourcePathError" class="mt-1 text-sm" :class="sourcePathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="sourcePathError"></p>
</template>
</div>
<button
type="button"
class="btn-secondary h-10 whitespace-nowrap"
@click="checkPath(sourcePath, 'source')"
>
<i class="fas fa-check-circle mr-2"></i>
Check Location
</button>
</div>
}
}
+1 -1
View File
@@ -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=
+14 -22
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)
@@ -719,7 +711,7 @@ func handleListHistory(database *db.DB) gin.HandlerFunc {
// For now, just return the most recent 100 history entries for the user's jobs
var history []db.JobHistory
err := database.DB.
Joins("JOIN jobs ON job_histories.job_id = jobs.id").
Joins("JOIN jobs ON job_history.job_id = jobs.id").
Where("jobs.created_by = ?", userID).
Order("start_time DESC").
Limit(100).
+609 -24
View File
@@ -5,12 +5,13 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/glebarez/sqlite"
"github.com/starfleetcptn/gomft/internal/auth"
"github.com/starfleetcptn/gomft/internal/db/migrations"
"gorm.io/gorm"
)
@@ -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,12 +68,16 @@ 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
SourceDriveID string `form:"source_drive_id"` // For OneDrive
SourceTeamDrive string `form:"source_team_drive"` // For Google Drive
// Google Photos source fields
SourceReadOnly *bool `form:"source_read_only"` // For Google Photos
SourceStartYear int `form:"source_start_year"` // For Google Photos
SourceIncludeArchived *bool `form:"source_include_archived"` // For Google Photos
// General fields
FilePattern string `gorm:"default:'*'" form:"file_pattern"`
OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables
@@ -93,17 +98,25 @@ 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 Photos destination fields
DestReadOnly *bool `form:"dest_read_only"` // For Google Photos
DestStartYear int `form:"dest_start_year"` // For Google Photos
DestIncludeArchived *bool `form:"dest_include_archived"` // For Google Photos
// Security fields
UseBuiltinAuthSource *bool `form:"use_builtin_auth_source"` // For Google and other OAuth services
UseBuiltinAuthDest *bool `form:"use_builtin_auth_dest"` // For Google and other OAuth services
GoogleDriveAuthenticated *bool // Whether Google Drive auth is completed
// General fields
ArchivePath string `form:"archive_path"`
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 +132,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
@@ -238,10 +251,10 @@ func Initialize(dbPath string) (*DB, error) {
return nil, fmt.Errorf("failed to connect to database: %v", err)
}
// Auto migrate the schema
err = db.AutoMigrate(&User{}, &auth.PasswordHistory{}, &PasswordResetToken{}, &TransferConfig{}, &Job{}, &JobHistory{}, &FileMetadata{})
if err != nil {
return nil, fmt.Errorf("failed to migrate database: %v", err)
// Initialize and run migrations
m := migrations.InitMigrations(db)
if err := m.Migrate(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %v", err)
}
return &DB{DB: db}, nil
@@ -558,7 +571,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")
}
@@ -615,7 +628,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
}
case "google_drive":
case "gdrive":
args := []string{
"config", "create", sourceName, "drive",
"client_id", config.SourceClientID,
@@ -629,6 +642,40 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
args = append(args, "team_drive", config.SourceTeamDrive)
}
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)
}
case "gphotos":
args := []string{
"config", "create", sourceName, "google photos",
"--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.SourceClientID != "" && config.SourceClientSecret != "" {
args = append(args, "client_id", config.SourceClientID)
args = append(args, "client_secret", config.SourceClientSecret)
}
// Add read_only option if specified
if config.SourceReadOnly != nil && *config.SourceReadOnly {
args = append(args, "read_only", "true")
}
// Add start_year if specified
if config.SourceStartYear > 0 {
args = append(args, "start_year", strconv.Itoa(config.SourceStartYear))
}
// Add include_archived if specified
if config.SourceIncludeArchived != nil && *config.SourceIncludeArchived {
args = append(args, "include_archived", "true")
}
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)
@@ -746,7 +793,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")
}
@@ -803,20 +850,63 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
case "google_drive":
case "gdrive":
args := []string{
"config", "create", destName, "drive",
"client_id", config.DestClientID,
"client_secret", config.DestClientSecret,
"--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)
}
case "gphotos":
args := []string{
"config", "create", destName, "google photos",
"--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)
}
// Add read_only option if specified
if config.DestReadOnly != nil && *config.DestReadOnly {
args = append(args, "read_only", "true")
}
// Add start_year if specified
if config.DestStartYear > 0 {
args = append(args, "start_year", strconv.Itoa(config.DestStartYear))
}
// Add include_archived if specified
if config.DestIncludeArchived != nil && *config.DestIncludeArchived {
args = append(args, "include_archived", "true")
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
@@ -837,12 +927,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 +977,496 @@ 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 {
// Get the config path
configPath := db.GetConfigRclonePath(config)
if configPath == "" {
return fmt.Errorf("failed to get config path")
}
// Clean up the token to ensure it's a single line JSON
token = strings.TrimSpace(token)
token = strings.ReplaceAll(token, "\n", "")
token = strings.ReplaceAll(token, "\r", "")
// Determine if this is a source or destination config
var configType, section, clientID, clientSecret string
var readOnly, includeArchived *bool
var startYear int
if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" {
configType = config.DestinationType
section = "dest"
clientID = config.DestClientID
clientSecret = config.DestClientSecret
readOnly = config.DestReadOnly
startYear = config.DestStartYear
includeArchived = config.DestIncludeArchived
} else if config.SourceType == "gdrive" || config.SourceType == "gphotos" {
configType = config.SourceType
section = "source"
clientID = config.SourceClientID
clientSecret = config.SourceClientSecret
readOnly = config.SourceReadOnly
startYear = config.SourceStartYear
includeArchived = config.SourceIncludeArchived
} else {
return fmt.Errorf("config is not for Google Drive or Google Photos")
}
// Read the existing config
content, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config file: %v", err)
}
// Prepare the section content
var sectionContent string
if configType == "gdrive" {
sectionContent = fmt.Sprintf("[%s_%d]\ntype = drive\n", section, config.ID)
if clientID != "" {
sectionContent += fmt.Sprintf("client_id = %s\n", clientID)
}
if clientSecret != "" {
sectionContent += fmt.Sprintf("client_secret = %s\n", clientSecret)
}
sectionContent += fmt.Sprintf("token = %s\n", token)
// Add team drive if specified
if section == "source" && config.SourceTeamDrive != "" {
sectionContent += fmt.Sprintf("team_drive = %s\n", config.SourceTeamDrive)
} else if section == "dest" && config.DestTeamDrive != "" {
sectionContent += fmt.Sprintf("team_drive = %s\n", config.DestTeamDrive)
}
// Add read-only flag if specified
if readOnly != nil && *readOnly {
sectionContent += "read_only = true\n"
}
} else if configType == "gphotos" {
sectionContent = fmt.Sprintf("[%s_%d]\ntype = google photos\n", section, config.ID)
if clientID != "" {
sectionContent += fmt.Sprintf("client_id = %s\n", clientID)
}
if clientSecret != "" {
sectionContent += fmt.Sprintf("client_secret = %s\n", clientSecret)
}
sectionContent += fmt.Sprintf("token = %s\n", token)
// Add read-only flag if specified
if readOnly != nil && *readOnly {
sectionContent += "read_only = true\n"
}
// Add start year if specified
if startYear > 0 {
sectionContent += fmt.Sprintf("start_year = %d\n", startYear)
}
// Add include_archived flag if specified and true
if includeArchived != nil && *includeArchived {
sectionContent += "include_archived = true\n"
}
}
// Find the section in the existing config
sectionPattern := regexp.MustCompile(fmt.Sprintf(`\[%s_%d\][^\[]*`, section, config.ID))
if sectionPattern.MatchString(string(content)) {
// Replace the existing section
newContent := sectionPattern.ReplaceAllString(string(content), sectionContent)
err = os.WriteFile(configPath, []byte(newContent), 0644)
if err != nil {
return fmt.Errorf("failed to write updated config file: %v", err)
}
} else {
// Append the section to the config
file, err := os.OpenFile(configPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open config file for appending: %v", err)
}
defer file.Close()
_, err = file.WriteString("\n" + sectionContent)
if err != nil {
return fmt.Errorf("failed to append to config file: %v", err)
}
}
// Update the authentication status
authenticated := true
if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" {
config.SetGoogleAuthenticated(authenticated)
} else if config.SourceType == "gdrive" || config.SourceType == "gphotos" {
config.SetGoogleAuthenticated(authenticated)
}
return nil
}
// 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 whether the transfer config has been authenticated with Google Drive
func (tc *TransferConfig) GetGoogleDriveAuthenticated() bool {
return tc.GoogleDriveAuthenticated != nil && *tc.GoogleDriveAuthenticated
}
// SetGoogleDriveAuthenticated sets the Google Drive authentication status
func (tc *TransferConfig) SetGoogleDriveAuthenticated(value bool) {
tc.GoogleDriveAuthenticated = &value
}
// GetGoogleAuthenticated is an alias for GetGoogleDriveAuthenticated for better semantics when working with Google Photos
func (tc *TransferConfig) GetGoogleAuthenticated() bool {
return tc.GetGoogleDriveAuthenticated()
}
// SetGoogleAuthenticated is an alias for SetGoogleDriveAuthenticated for better semantics when working with Google Photos
func (tc *TransferConfig) SetGoogleAuthenticated(value bool) {
tc.SetGoogleDriveAuthenticated(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 "", ""
}
// GetUseBuiltinAuthSource returns the value of UseBuiltinAuthSource with a default if nil
func (tc *TransferConfig) GetUseBuiltinAuthSource() bool {
if tc.UseBuiltinAuthSource == nil {
return true // Default to true if not set
}
return *tc.UseBuiltinAuthSource
}
// SetUseBuiltinAuthSource sets the UseBuiltinAuthSource field
func (tc *TransferConfig) SetUseBuiltinAuthSource(value bool) {
tc.UseBuiltinAuthSource = &value
}
// GetUseBuiltinAuthDest returns the value of UseBuiltinAuthDest with a default if nil
func (tc *TransferConfig) GetUseBuiltinAuthDest() bool {
if tc.UseBuiltinAuthDest == nil {
return true // Default to true if not set
}
return *tc.UseBuiltinAuthDest
}
// SetUseBuiltinAuthDest sets the UseBuiltinAuthDest field
func (tc *TransferConfig) SetUseBuiltinAuthDest(value bool) {
tc.UseBuiltinAuthDest = &value
}
+941 -13
View File
File diff suppressed because it is too large Load Diff
+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,255 @@
package migrations
import (
"fmt"
"os"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func InitialSchema() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "001_initial_schema",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Create backup file with timestamp
backupFile := fmt.Sprintf("%s.backup.%s", dbPath, time.Now().Format("20060102_150405"))
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Disable foreign key constraints while creating tables
if err := tx.Exec("PRAGMA foreign_keys = OFF").Error; err != nil {
return fmt.Errorf("failed to disable foreign key constraints: %v", err)
}
defer func() {
if err := tx.Exec("PRAGMA foreign_keys = ON").Error; err != nil {
fmt.Printf("Warning: failed to re-enable foreign key constraints: %v\n", err)
}
}()
// Create Users table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_admin BOOLEAN DEFAULT FALSE,
last_password_change DATETIME,
failed_login_attempts INTEGER DEFAULT 0,
account_locked BOOLEAN DEFAULT FALSE,
lockout_until DATETIME,
theme VARCHAR(255) DEFAULT 'light',
created_at DATETIME,
updated_at DATETIME
)`).Error; err != nil {
return err
}
// Create PasswordHistory table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS password_histories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create PasswordResetToken table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create TransferConfigs table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS transfer_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
source_type VARCHAR(255) NOT NULL,
source_path TEXT NOT NULL,
source_host VARCHAR(255),
source_port INTEGER DEFAULT 22,
source_user VARCHAR(255),
source_key_file TEXT,
source_bucket VARCHAR(255),
source_region VARCHAR(255),
source_access_key VARCHAR(255),
source_endpoint VARCHAR(255),
source_share VARCHAR(255),
source_domain VARCHAR(255),
source_passive_mode BOOLEAN DEFAULT TRUE,
source_client_id VARCHAR(255),
source_drive_id VARCHAR(255),
source_team_drive VARCHAR(255),
source_read_only BOOLEAN,
source_start_year INTEGER,
source_include_archived BOOLEAN,
file_pattern VARCHAR(255) DEFAULT '*',
output_pattern TEXT,
destination_type VARCHAR(255) NOT NULL,
destination_path TEXT NOT NULL,
dest_host VARCHAR(255),
dest_port INTEGER DEFAULT 22,
dest_user VARCHAR(255),
dest_key_file TEXT,
dest_bucket VARCHAR(255),
dest_region VARCHAR(255),
dest_access_key VARCHAR(255),
dest_endpoint VARCHAR(255),
dest_share VARCHAR(255),
dest_domain VARCHAR(255),
dest_passive_mode BOOLEAN DEFAULT TRUE,
dest_client_id VARCHAR(255),
dest_drive_id VARCHAR(255),
dest_team_drive VARCHAR(255),
dest_read_only BOOLEAN,
dest_start_year INTEGER,
dest_include_archived BOOLEAN,
use_builtin_auth_source BOOLEAN DEFAULT TRUE,
use_builtin_auth_dest BOOLEAN DEFAULT TRUE,
google_drive_authenticated BOOLEAN,
archive_path TEXT,
archive_enabled BOOLEAN DEFAULT FALSE,
rclone_flags TEXT,
delete_after_transfer BOOLEAN DEFAULT FALSE,
skip_processed_files BOOLEAN DEFAULT TRUE,
max_concurrent_transfers INTEGER DEFAULT 4,
created_by INTEGER,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (created_by) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create Jobs table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255),
config_id INTEGER NOT NULL,
config_ids TEXT,
schedule VARCHAR(255) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
last_run DATETIME,
next_run DATETIME,
webhook_enabled BOOLEAN DEFAULT FALSE,
webhook_url TEXT,
webhook_secret TEXT,
webhook_headers TEXT,
notify_on_success BOOLEAN DEFAULT TRUE,
notify_on_failure BOOLEAN DEFAULT TRUE,
created_by INTEGER,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (config_id) REFERENCES transfer_configs(id),
FOREIGN KEY (created_by) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create JobHistory table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS job_histories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
config_id INTEGER DEFAULT 0,
start_time DATETIME NOT NULL,
end_time DATETIME,
status VARCHAR(255) NOT NULL,
bytes_transferred INTEGER,
files_transferred INTEGER,
error_message TEXT,
FOREIGN KEY (job_id) REFERENCES jobs(id)
)`).Error; err != nil {
return err
}
// Create FileMetadata table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS file_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
config_id INTEGER DEFAULT 0,
file_name VARCHAR(255) NOT NULL,
original_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_hash VARCHAR(255),
creation_time DATETIME,
mod_time DATETIME,
processed_time DATETIME NOT NULL,
destination_path TEXT NOT NULL,
status VARCHAR(255) NOT NULL,
error_message TEXT,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (job_id) REFERENCES jobs(id)
)`).Error; err != nil {
return err
}
// Re-enable foreign key constraints and verify integrity
if err := tx.Exec("PRAGMA foreign_key_check").Error; err != nil {
return fmt.Errorf("foreign key integrity check failed: %v", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop tables in reverse order to handle foreign key constraints
tables := []string{
"file_metadata",
"job_histories",
"jobs",
"transfer_configs",
"password_reset_tokens",
"password_histories",
"users",
}
for _, table := range tables {
if err := tx.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", table)).Error; err != nil {
return err
}
}
return nil
},
}
}
@@ -0,0 +1,31 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// UpdateGDriveType updates the source_type and destination_type from 'google_drive' to 'gdrive'
func UpdateGDriveType() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "002_update_gdrive_type",
Migrate: func(tx *gorm.DB) error {
// Update source_type
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'gdrive' WHERE source_type = 'google_drive'`).Error; err != nil {
return err
}
// Update destination_type
return tx.Exec(`UPDATE transfer_configs SET destination_type = 'gdrive' WHERE destination_type = 'google_drive'`).Error
},
Rollback: func(tx *gorm.DB) error {
// Revert source_type
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'google_drive' WHERE source_type = 'gdrive'`).Error; err != nil {
return err
}
// Revert destination_type
return tx.Exec(`UPDATE transfer_configs SET destination_type = 'google_drive' WHERE destination_type = 'gdrive'`).Error
},
}
}
@@ -1,63 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddCloudStorageFields adds fields for WebDAV, NextCloud, OneDrive, and Google Drive
func AddCloudStorageFields() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "add_cloud_storage_fields",
Migrate: func(tx *gorm.DB) error {
// Add source fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_client_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_drive_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_team_drive VARCHAR(255)").Error; err != nil {
return err
}
// Add destination fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_client_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_drive_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_team_drive VARCHAR(255)").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop source fields
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_client_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_drive_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_team_drive").Error; err != nil {
return err
}
// Drop destination fields
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_client_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_drive_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_team_drive").Error; err != nil {
return err
}
return nil
},
}
}
@@ -1,19 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddDeleteAfterTransferColumn adds the delete_after_transfer column to transfer_configs table
func AddDeleteAfterTransferColumn() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "add_delete_after_transfer_column",
Migrate: func(tx *gorm.DB) error {
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN delete_after_transfer BOOLEAN NOT NULL DEFAULT false").Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN delete_after_transfer").Error
},
}
}
@@ -1,21 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddMaxConcurrentTransfersColumn adds the max_concurrent_transfers column to transfer_configs table
func AddMaxConcurrentTransfersColumn() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250311_add_max_concurrent_transfers",
Migrate: func(tx *gorm.DB) error {
// Add max_concurrent_transfers column with default value of 4
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN max_concurrent_transfers INTEGER DEFAULT 4").Error
},
Rollback: func(tx *gorm.DB) error {
// Drop the column if needed
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN max_concurrent_transfers").Error
},
}
}
@@ -1,49 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddMultiConfigSupport adds support for multiple configurations per job
func AddMultiConfigSupport() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250315_add_multi_config_support",
Migrate: func(tx *gorm.DB) error {
// Add config_ids column to jobs table
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN config_ids TEXT").Error; err != nil {
return err
}
// Add config_id column to job_histories table
if err := tx.Exec("ALTER TABLE job_histories ADD COLUMN config_id INTEGER").Error; err != nil {
return err
}
// Add config_id column to file_metadata table
if err := tx.Exec("ALTER TABLE file_metadata ADD COLUMN config_id INTEGER").Error; err != nil {
return err
}
// Update existing jobs to set the config_ids field to match the current config_id
if err := tx.Exec("UPDATE jobs SET config_ids = config_id WHERE config_id > 0").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop the config_id columns from job_histories and file_metadata
if err := tx.Exec("ALTER TABLE job_histories DROP COLUMN config_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE file_metadata DROP COLUMN config_id").Error; err != nil {
return err
}
// Drop the config_ids column from jobs
return tx.Exec("ALTER TABLE jobs DROP COLUMN config_ids").Error
},
}
}
@@ -1,21 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddSkipProcessedFilesColumn adds the skip_processed_files column to transfer_configs table
func AddSkipProcessedFilesColumn() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250310_add_skip_processed_files",
Migrate: func(tx *gorm.DB) error {
// Add skip_processed_files column with default value of true
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN skip_processed_files BOOLEAN DEFAULT true").Error
},
Rollback: func(tx *gorm.DB) error {
// Drop the column if needed
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN skip_processed_files").Error
},
}
}
@@ -1,61 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddWebhookSupport adds webhook notification fields to the jobs table
func AddWebhookSupport() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20240618_add_webhook_support",
Migrate: func(tx *gorm.DB) error {
// Add webhook URL field
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_enabled BOOLEAN DEFAULT false").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_url VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_secret VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN webhook_headers TEXT").Error; err != nil {
return err
}
// Add notification settings
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN notify_on_success BOOLEAN DEFAULT true").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs ADD COLUMN notify_on_failure BOOLEAN DEFAULT true").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop the webhook fields from jobs
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_enabled").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_url").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_secret").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN webhook_headers").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN notify_on_success").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE jobs DROP COLUMN notify_on_failure").Error; err != nil {
return err
}
return nil
},
}
}
+2 -8
View File
@@ -8,14 +8,8 @@ import (
// InitMigrations initializes the migrations
func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
migrations := []*gormigrate.Migration{
// ... existing migrations
AddDeleteAfterTransferColumn(),
AddCloudStorageFields(),
AddSkipProcessedFilesColumn(),
AddMaxConcurrentTransfersColumn(),
AddMultiConfigSupport(),
UpdateSkipProcessedFilesToNullable(),
AddWebhookSupport(),
InitialSchema(),
UpdateGDriveType(),
}
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
@@ -1,70 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// UpdateSkipProcessedFilesToNullable changes the skip_processed_files column to be nullable
func UpdateSkipProcessedFilesToNullable() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20250515_update_skip_processed_files_to_nullable",
Migrate: func(tx *gorm.DB) error {
// SQLite specific command - this would need to be adjusted for other databases
return tx.Exec("ALTER TABLE transfer_configs RENAME TO transfer_configs_old; " +
"CREATE TABLE transfer_configs (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"name VARCHAR(255) NOT NULL, " +
"source_type VARCHAR(255) NOT NULL, " +
"source_path VARCHAR(255) NOT NULL, " +
"source_host VARCHAR(255), " +
"source_port INTEGER DEFAULT 22, " +
"source_user VARCHAR(255), " +
"source_key_file VARCHAR(255), " +
"source_bucket VARCHAR(255), " +
"source_region VARCHAR(255), " +
"source_access_key VARCHAR(255), " +
"source_endpoint VARCHAR(255), " +
"source_share VARCHAR(255), " +
"source_domain VARCHAR(255), " +
"source_passive_mode BOOLEAN DEFAULT true, " +
"source_client_id VARCHAR(255), " +
"source_drive_id VARCHAR(255), " +
"source_team_drive VARCHAR(255), " +
"file_pattern VARCHAR(255) DEFAULT '*', " +
"output_pattern VARCHAR(255), " +
"destination_type VARCHAR(255) NOT NULL, " +
"destination_path VARCHAR(255) NOT NULL, " +
"dest_host VARCHAR(255), " +
"dest_port INTEGER DEFAULT 22, " +
"dest_user VARCHAR(255), " +
"dest_key_file VARCHAR(255), " +
"dest_bucket VARCHAR(255), " +
"dest_region VARCHAR(255), " +
"dest_access_key VARCHAR(255), " +
"dest_endpoint VARCHAR(255), " +
"dest_share VARCHAR(255), " +
"dest_domain VARCHAR(255), " +
"dest_passive_mode BOOLEAN DEFAULT true, " +
"dest_client_id VARCHAR(255), " +
"dest_drive_id VARCHAR(255), " +
"dest_team_drive VARCHAR(255), " +
"archive_path VARCHAR(255), " +
"archive_enabled BOOLEAN DEFAULT false, " +
"rclone_flags VARCHAR(255), " +
"delete_after_transfer BOOLEAN DEFAULT false, " +
"skip_processed_files BOOLEAN DEFAULT true, " + // Keep as BOOLEAN, but now it's nullable
"max_concurrent_transfers INTEGER DEFAULT 4, " +
"created_by INTEGER, " +
"created_at DATETIME, " +
"updated_at DATETIME" +
"); " +
"INSERT INTO transfer_configs SELECT * FROM transfer_configs_old; " +
"DROP TABLE transfer_configs_old;").Error
},
Rollback: func(tx *gorm.DB) error {
// No need to rollback as the data structure remains compatible
return nil
},
}
}
+116 -2
View File
@@ -3,6 +3,7 @@ package db
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
@@ -71,7 +72,7 @@ func TestGenerateRcloneConfigWithoutRclone(t *testing.T) {
assert.NoError(t, err)
// Test configs for different source types
sourceTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "google_drive"}
sourceTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "gdrive"}
for _, sourceType := range sourceTypes {
testConfig := &TransferConfig{
@@ -101,7 +102,7 @@ func TestGenerateRcloneConfigWithoutRclone(t *testing.T) {
}
// Test configs for different destination types
destTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "google_drive"}
destTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "gdrive"}
for _, destType := range destTypes {
testConfig := &TransferConfig{
@@ -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: "gdrive",
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: "gdrive",
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})
+13 -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
}
@@ -535,6 +535,12 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
if maxConcurrent < 1 {
maxConcurrent = 1 // Default to 1 if not set
}
// Limit Google Photos to 1 concurrent transfers
if config.SourceType == "gphotos" || config.DestinationType == "gphotos" {
maxConcurrent = 1
}
s.log.LogInfo("Using %d concurrent transfers for job %d, config %d", maxConcurrent, job.ID, config.ID)
// Create wait group for concurrent processing
@@ -783,7 +789,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 +834,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 +1032,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)
+49 -40
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) {
@@ -152,16 +161,16 @@ func (h *Handlers) HandleLoginPage(c *gin.Context) {
c.Redirect(http.StatusFound, "/dashboard")
return
}
// 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.)
message := c.Query("message")
// User is not logged in, show login page
if message != "" {
components.Login(ctx, message).Render(c.Request.Context(), c.Writer)
@@ -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)
@@ -201,18 +210,18 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
// Increment failed login attempts
user.FailedLoginAttempts++
// 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)
components.Login(components.CreateTemplateContext(c), "Account is locked due to too many failed login attempts. Please try again later.").Render(c, c.Writer)
return
}
h.DB.Save(&user)
components.Login(components.CreateTemplateContext(c), "Invalid credentials").Render(c, c.Writer)
return
@@ -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)
@@ -276,7 +285,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
return
}
claims, err := auth.ValidateToken(tokenCookie, h.JWTSecret)
if err != nil {
if c.GetHeader("HX-Request") == "true" {
@@ -290,12 +299,12 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
return
}
userID := claims.UserID
// Get form values
currentPassword := c.PostForm("current_password")
newPassword := c.PostForm("new_password")
confirmPassword := c.PostForm("confirm_password")
// Validate new password matches confirmation
if newPassword != confirmPassword {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
@@ -303,7 +312,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</div>`))
return
}
// Get user
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
@@ -312,7 +321,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</div>`))
return
}
// Verify current password
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
@@ -320,7 +329,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</div>`))
return
}
// Validate password against policy
policy := auth.DefaultPasswordPolicy()
if err := auth.ValidatePassword(newPassword, policy); err != nil {
@@ -330,7 +339,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
c.Data(http.StatusOK, "text/html", []byte(errorMsg))
return
}
// Check password history
if err := auth.CheckPasswordHistory(user.ID, newPassword, user.PasswordHash, h.DB.DB, policy); err != nil {
errorMsg := `<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
@@ -339,7 +348,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
c.Data(http.StatusOK, "text/html", []byte(errorMsg))
return
}
// Hash the new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
@@ -348,7 +357,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</div>`))
return
}
// Update password history
if err := auth.UpdatePasswordHistory(user.ID, string(hashedPassword), h.DB.DB, policy); err != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
@@ -356,7 +365,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</div>`))
return
}
// Update user's password
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
@@ -366,7 +375,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</div>`))
return
}
// Return success message
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Password updated successfully!</span>
@@ -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,33 +420,33 @@ 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
}
// Send password reset email
err = h.Email.SendPasswordResetEmail(user.Email, user.Email, token)
if err != nil {
// If email sending fails, log the error but don't expose this to the user
log.Printf("Error sending password reset email: %v", err)
// If email is disabled, log the reset link
if strings.Contains(err.Error(), "email service is disabled") {
log.Printf("Email service is disabled, reset link: %v", err)
}
}
// 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)
+98 -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)
}
@@ -63,6 +71,7 @@ func (h *Handlers) HandleEditConfig(c *gin.Context) {
// HandleCreateConfig handles the POST /configs route
func (h *Handlers) HandleCreateConfig(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBind(&config); err != nil {
log.Printf("Error binding config form: %v", err)
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
@@ -72,10 +81,52 @@ 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
// Google Photos specific fields
destReadOnlyVal := c.Request.FormValue("dest_read_only")
destReadOnlyValue := destReadOnlyVal == "on" || destReadOnlyVal == "true"
config.DestReadOnly = &destReadOnlyValue
sourceReadOnlyVal := c.Request.FormValue("source_read_only")
sourceReadOnlyValue := sourceReadOnlyVal == "on" || sourceReadOnlyVal == "true"
config.SourceReadOnly = &sourceReadOnlyValue
destIncludeArchivedVal := c.Request.FormValue("dest_include_archived")
destIncludeArchivedValue := destIncludeArchivedVal == "on" || destIncludeArchivedVal == "true"
config.DestIncludeArchived = &destIncludeArchivedValue
sourceIncludeArchivedVal := c.Request.FormValue("source_include_archived")
sourceIncludeArchivedValue := sourceIncludeArchivedVal == "on" || sourceIncludeArchivedVal == "true"
config.SourceIncludeArchived = &sourceIncludeArchivedValue
useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source")
useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true"
config.UseBuiltinAuthSource = &useBuiltinAuthSourceValue
useBuiltinAuthDestVal := c.Request.FormValue("use_builtin_auth_dest")
useBuiltinAuthDestValue := useBuiltinAuthDestVal == "on" || useBuiltinAuthDestVal == "true"
config.UseBuiltinAuthDest = &useBuiltinAuthDestValue
if err := h.DB.Create(&config).Error; err != nil {
log.Printf("Error creating config: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to create config: %v", err))
@@ -125,10 +176,52 @@ 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
// Google Photos specific fields
destReadOnlyVal := c.Request.FormValue("dest_read_only")
destReadOnlyValue := destReadOnlyVal == "on" || destReadOnlyVal == "true"
config.DestReadOnly = &destReadOnlyValue
sourceReadOnlyVal := c.Request.FormValue("source_read_only")
sourceReadOnlyValue := sourceReadOnlyVal == "on" || sourceReadOnlyVal == "true"
config.SourceReadOnly = &sourceReadOnlyValue
destIncludeArchivedVal := c.Request.FormValue("dest_include_archived")
destIncludeArchivedValue := destIncludeArchivedVal == "on" || destIncludeArchivedVal == "true"
config.DestIncludeArchived = &destIncludeArchivedValue
sourceIncludeArchivedVal := c.Request.FormValue("source_include_archived")
sourceIncludeArchivedValue := sourceIncludeArchivedVal == "on" || sourceIncludeArchivedVal == "true"
config.SourceIncludeArchived = &sourceIncludeArchivedValue
useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source")
useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true"
config.UseBuiltinAuthSource = &useBuiltinAuthSourceValue
useBuiltinAuthDestVal := c.Request.FormValue("use_builtin_auth_dest")
useBuiltinAuthDestValue := useBuiltinAuthDestVal == "on" || useBuiltinAuthDestVal == "true"
config.UseBuiltinAuthDest = &useBuiltinAuthDestValue
// Preserve fields that shouldn't be updated
config.CreatedBy = oldConfig.CreatedBy
@@ -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)
+400
View File
@@ -0,0 +1,400 @@
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 or Google Photos configuration
if config.SourceType != "gdrive" && config.DestinationType != "gdrive" && config.SourceType != "gphotos" && config.DestinationType != "gphotos" {
RenderErrorPage(c, "Not a Google configuration", "The selected configuration is not set up for Google Drive or Google Photos")
return
}
// 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 GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from ENV
clientID := os.Getenv("GOOGLE_CLIENT_ID")
clientSecret := os.Getenv("GOOGLE_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 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)
// Determine the appropriate scope based on destination type
var scope string
if config.DestinationType == "gphotos" {
// Read-only access is handled elsewhere in the config; here we need the full auth scope
scope = url.QueryEscape("https://www.googleapis.com/auth/photoslibrary")
} else {
// Default to Google Drive scope
scope = url.QueryEscape("https://www.googleapis.com/auth/drive")
}
// Create a config file with redirect URI-based auth
configType := "drive"
if config.DestinationType == "gphotos" {
configType = "google photos"
}
configContent := fmt.Sprintf(`[temp_%s]
type = %s
client_id = %s
client_secret = %s
redirect_url = %s
`, config.DestinationType, configType, clientID, clientSecret, redirectURI)
// Write the config file
if err := os.WriteFile(tempConfigPath, []byte(configContent), 0644); err != nil {
RenderErrorPage(c, "Failed to create temporary config file", err.Error())
return
}
// Direct Google OAuth URL with our redirect
authURL := fmt.Sprintf("https://accounts.google.com/o/oauth2/auth?client_id=%s&redirect_uri=%s&scope=%s&response_type=code&access_type=offline&state=%s",
url.QueryEscape(clientID),
url.QueryEscape(redirectURI),
scope,
url.QueryEscape(state))
// Redirect the user to Google's auth page directly
c.Redirect(http.StatusFound, authURL)
}
// 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 GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from ENV
clientID := os.Getenv("GOOGLE_CLIENT_ID")
clientSecret := os.Getenv("GOOGLE_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 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
var successParam string
if config.DestinationType == "gphotos" {
successParam = "gphotos_auth_success"
} else {
successParam = "gdrive_auth_success"
}
c.Redirect(http.StatusFound, fmt.Sprintf("/configs?status=%s", successParam))
}
// 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 or Google Photos configuration
if config.SourceType != "gdrive" && config.DestinationType != "gdrive" && config.SourceType != "gphotos" && config.DestinationType != "gphotos" {
RenderErrorPage(c, "Not a Google Drive configuration", "")
return
}
// 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)
}
@@ -0,0 +1,429 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// DBInterface defines the methods we need to mock for our tests
type DBInterface interface {
GetTransferConfig(id uint) (*db.TransferConfig, error)
GetConfigRclonePath(config *db.TransferConfig) string
GenerateRcloneConfigWithToken(config *db.TransferConfig, token string) error
GetGDriveCredentialsFromConfig(config *db.TransferConfig) (string, string)
}
// MockDB is a mock implementation of the DB interface for testing
type MockDB struct {
mock.Mock
}
// Implement the necessary methods from the DB interface for our tests
func (m *MockDB) GetTransferConfig(id uint) (*db.TransferConfig, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*db.TransferConfig), args.Error(1)
}
func (m *MockDB) GetConfigRclonePath(config *db.TransferConfig) string {
args := m.Called(config)
return args.String(0)
}
func (m *MockDB) GenerateRcloneConfigWithToken(config *db.TransferConfig, token string) error {
args := m.Called(config, token)
return args.Error(0)
}
func (m *MockDB) GetGDriveCredentialsFromConfig(config *db.TransferConfig) (string, string) {
args := m.Called(config)
return args.String(0), args.String(1)
}
// MockHandlers is a modified version of Handlers that accepts our mock DB
type MockHandlers struct {
DB DBInterface
}
// HandleGDriveAuth is a copy of the original method but using our interface
func (h *MockHandlers) HandleGDriveAuth(c *gin.Context) {
// Get the config ID from the query parameter
configIDStr := c.Param("id")
if configIDStr == "" {
RenderErrorPageTest(c, "Missing configuration ID", "")
return
}
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
RenderErrorPageTest(c, "Invalid configuration ID", err.Error())
return
}
// Get the configuration
config, err := h.DB.GetTransferConfig(uint(configID))
if err != nil {
RenderErrorPageTest(c, "Configuration not found", err.Error())
return
}
// Ensure it's a Google Drive or Google Photos configuration
if config.DestinationType != "gdrive" && config.DestinationType != "gphotos" {
RenderErrorPageTest(c, "Not a Google configuration", "The selected configuration is not set up for Google Drive or Google Photos")
return
}
// Prepare for OAuth
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
// Get Rclone Config Path
rcloneConfigPath := h.DB.GetConfigRclonePath(config)
if rcloneConfigPath == "" {
RenderErrorPageTest(c, "Rclone config not found", "The selected configuration does not have a valid rclone config")
return
}
// Create a temporary config file for authentication
tempConfigDir := filepath.Join(dataDir, "temp")
if err := os.MkdirAll(tempConfigDir, 0755); err != nil {
RenderErrorPageTest(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 GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from ENV
clientID := os.Getenv("GOOGLE_CLIENT_ID")
clientSecret := os.Getenv("GOOGLE_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"
}
}
// 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)
// Determine the appropriate scope based on destination type
var scope string
if config.DestinationType == "gphotos" {
// Read-only access is handled elsewhere in the config; here we need the full auth scope
scope = url.QueryEscape("https://www.googleapis.com/auth/photoslibrary")
} else {
// Default to Google Drive scope
scope = url.QueryEscape("https://www.googleapis.com/auth/drive")
}
// Direct Google OAuth URL with our redirect
authURL := fmt.Sprintf("https://accounts.google.com/o/oauth2/auth?client_id=%s&redirect_uri=%s&scope=%s&response_type=code&access_type=offline&state=%s",
url.QueryEscape(clientID),
url.QueryEscape(redirectURI),
scope,
url.QueryEscape(state))
// Redirect the user to Google's auth page directly
c.Redirect(http.StatusFound, authURL)
}
// HandleGDriveAuthCallback handles the callback from Google OAuth
func (h *MockHandlers) HandleGDriveAuthCallback(c *gin.Context) {
// Get auth code from query parameters
authCode := c.Query("code")
if authCode == "" {
RenderErrorPageTest(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 {
RenderErrorPageTest(c, "Authentication failed", "Invalid state parameter")
return
}
// Get config ID from cookie
configIDStr, err := c.Cookie("gdrive_config_id")
if err != nil {
RenderErrorPageTest(c, "Authentication failed", "Unable to retrieve configuration ID")
return
}
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
RenderErrorPageTest(c, "Invalid configuration ID", err.Error())
return
}
// Get the configuration
config, err := h.DB.GetTransferConfig(uint(configID))
if err != nil {
RenderErrorPageTest(c, "Failed to get configuration", err.Error())
return
}
// For testing purposes, we'll simulate a successful token exchange
// In a real implementation, we would exchange the auth code for a token
mockToken := `{"access_token":"test_access_token","refresh_token":"test_refresh_token","expiry":"2023-12-31T23:59:59Z"}`
// Update the config with the token
err = h.DB.GenerateRcloneConfigWithToken(config, mockToken)
if err != nil {
RenderErrorPageTest(c, "Failed to update configuration", err.Error())
return
}
// Redirect to the config edit page
c.Redirect(http.StatusFound, fmt.Sprintf("/configs/edit/%d", config.ID))
}
func setupTestRouter() (*gin.Engine, *MockDB) {
gin.SetMode(gin.TestMode)
router := gin.New()
mockDB := new(MockDB)
handlers := &MockHandlers{
DB: mockDB,
}
router.GET("/configs/gdrive/:id", handlers.HandleGDriveAuth)
router.GET("/configs/gdrive-callback", handlers.HandleGDriveAuthCallback)
return router, mockDB
}
func TestHandleGDriveAuth_GoogleDrive(t *testing.T) {
// Setup
router, mockDB := setupTestRouter()
// Create a test config
testConfig := &db.TransferConfig{
ID: 1,
DestinationType: "gdrive",
}
// Set up mock expectations
mockDB.On("GetTransferConfig", uint(1)).Return(testConfig, nil)
mockDB.On("GetConfigRclonePath", testConfig).Return("/path/to/rclone.conf")
mockDB.On("GetGDriveCredentialsFromConfig", testConfig).Return("test_client_id", "test_client_secret")
// Create test request
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/configs/gdrive/1", nil)
router.ServeHTTP(w, req)
// Assertions
assert.Equal(t, http.StatusFound, w.Code)
// Verify the redirect URL
location := w.Header().Get("Location")
assert.Contains(t, location, "accounts.google.com/o/oauth2/auth")
assert.Contains(t, location, "drive")
assert.Contains(t, location, "test_client_id")
// Verify cookies were set
cookies := w.Result().Cookies()
assert.GreaterOrEqual(t, len(cookies), 3)
// Check if state cookie exists
stateFound := false
for _, cookie := range cookies {
if cookie.Name == "gdrive_auth_state" {
stateFound = true
break
}
}
assert.True(t, stateFound)
}
func TestHandleGDriveAuth_GooglePhotos(t *testing.T) {
// Setup
router, mockDB := setupTestRouter()
// Create a test config
testConfig := &db.TransferConfig{
ID: 2,
DestinationType: "gphotos",
}
// Set up mock expectations
mockDB.On("GetTransferConfig", uint(2)).Return(testConfig, nil)
mockDB.On("GetConfigRclonePath", testConfig).Return("/path/to/rclone.conf")
mockDB.On("GetGDriveCredentialsFromConfig", testConfig).Return("test_client_id", "test_client_secret")
// Create test request
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/configs/gdrive/2", nil)
router.ServeHTTP(w, req)
// Assertions
assert.Equal(t, http.StatusFound, w.Code)
// Verify the redirect URL
location := w.Header().Get("Location")
assert.Contains(t, location, "accounts.google.com/o/oauth2/auth")
assert.Contains(t, location, "photoslibrary")
assert.Contains(t, location, "test_client_id")
// Verify cookies were set
cookies := w.Result().Cookies()
assert.GreaterOrEqual(t, len(cookies), 3)
// Check if state cookie exists
stateFound := false
for _, cookie := range cookies {
if cookie.Name == "gdrive_auth_state" {
stateFound = true
break
}
}
assert.True(t, stateFound)
}
func TestHandleGDriveAuthCallback(t *testing.T) {
// Setup test environment
router, mockDB := setupTestRouter()
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "gdrive-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
// Create a temporary config file
tempConfigPath := filepath.Join(tempDir, "temp_config.conf")
if err := os.WriteFile(tempConfigPath, []byte("test config"), 0644); err != nil {
t.Fatal(err)
}
// Test state and config ID
testState := "gomft_1_12345"
testConfigID := "1"
// Create a test config
testConfig := &db.TransferConfig{
ID: 1,
DestinationType: "gphotos",
}
// Set up mock expectations
mockDB.On("GetTransferConfig", uint(1)).Return(testConfig, nil)
mockDB.On("GenerateRcloneConfigWithToken", testConfig, mock.Anything).Return(nil)
// Create test request with auth code and state
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/configs/gdrive-callback?code=test_auth_code&state="+testState, nil)
// Add required cookies to the request
req.AddCookie(&http.Cookie{Name: "gdrive_auth_state", Value: testState})
req.AddCookie(&http.Cookie{Name: "gdrive_config_id", Value: testConfigID})
req.AddCookie(&http.Cookie{Name: "gdrive_temp_config", Value: tempConfigPath})
// Send the request
router.ServeHTTP(w, req)
// We expect a redirect on successful auth
assert.Equal(t, http.StatusFound, w.Code)
// Should redirect to the config edit page
location := w.Header().Get("Location")
assert.Contains(t, location, "/configs/edit/1")
}
func TestHandleGDriveAuth_InvalidConfig(t *testing.T) {
// Setup
router, mockDB := setupTestRouter()
// Set up mock expectations for a non-existent config
mockDB.On("GetTransferConfig", uint(999)).Return(nil, fmt.Errorf("config not found"))
// Create test request
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/configs/gdrive/999", nil)
router.ServeHTTP(w, req)
// Assertions - should render error page
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Configuration not found")
}
func TestHandleGDriveAuth_NonGoogleConfig(t *testing.T) {
// Setup
router, mockDB := setupTestRouter()
// Create a non-Google test config
testConfig := &db.TransferConfig{
ID: 3,
DestinationType: "s3", // Not Google Drive or Photos
}
// Set up mock expectations
mockDB.On("GetTransferConfig", uint(3)).Return(testConfig, nil)
// Create test request
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/configs/gdrive/3", nil)
router.ServeHTTP(w, req)
// Assertions - should render error page
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Not a Google configuration")
}
// RenderErrorPageTest renders an error page with the given message
func RenderErrorPageTest(c *gin.Context, title string, details string) {
// Here we'd typically use a component for error display
// For now, we'll just render a simple HTML error page for testing
errorHTML := fmt.Sprintf("<html><body><h1>Error: %s</h1><p>%s</p></body></html>", title, details)
c.Data(http.StatusOK, "text/html", []byte(errorHTML))
}
+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 ""
}
+76
View File
@@ -0,0 +1,76 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
)
// HandleCheckPath validates if a given path exists and is accessible
func (h *Handlers) HandleCheckPath(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{
"valid": false,
"error": "No path provided",
})
return
}
// Clean and resolve the path
path = filepath.Clean(path)
absPath, err := filepath.Abs(path)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Invalid path format",
})
return
}
// Check if path exists
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Path does not exist",
})
return
}
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Error accessing path: " + err.Error(),
})
return
}
// Check if it's a directory
if !info.IsDir() {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Path exists but is not a directory",
})
return
}
// Check if we have read access
testFile := filepath.Join(absPath, ".gomft_test")
f, err := os.OpenFile(testFile, os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"error": "Directory exists but is not writable",
})
return
}
f.Close()
os.Remove(testFile)
c.JSON(http.StatusOK, gin.H{
"valid": true,
"error": "",
})
}
+9
View File
@@ -31,6 +31,15 @@ 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)
// Path validation endpoint
authorized.GET("/check-path", h.HandleCheckPath)
// Google Drive authentication routes
authorized.GET("/configs/:id/gdrive-auth", h.HandleGDriveAuth)
authorized.GET("/configs/gdrive-callback", h.HandleGDriveAuthCallback)
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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+450
View File
@@ -0,0 +1,450 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="113.424mm"
height="135.52705mm"
viewBox="0 0 113.424 135.52705"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
sodipodi:docname="gomft_golpher.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="true"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1"
inkscape:cx="182"
inkscape:cy="298"
inkscape:window-width="1512"
inkscape:window-height="831"
inkscape:window-x="0"
inkscape:window-y="38"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showborder="true" /><defs
id="defs1"><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath3542"><rect
style="fill:#0000ff;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="rect3544"
width="1093.424"
height="1518.2074"
x="-1174.7097"
y="-295.40738" /></clipPath></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-48.260679,-4.7323055)"
style="display:inline"><g
style="enable-background:new"
id="g1"
transform="matrix(0.28222222,0,0,0.28222222,43.26068,-0.26769346)"
inkscape:label="golpher"><g
id="layer11"
inkscape:label="background"
style="display:inline"
transform="translate(-159.49345,-229.70978)" /><g
id="layer5"
inkscape:label="gopher-body"
style="display:inline;opacity:1"
transform="matrix(0.99955497,0,0,0.99955497,-159.32544,-229.49282)"><g
id="g4815"
transform="matrix(-0.32879267,0.17361606,0.20143296,0.28338802,93.068619,282.82497)"
style="opacity:1"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#96d6ff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -626.54672,402.3529 c 2.22767,10.86299 0.34493,21.82632 -3.86747,31.42527 -4.21252,9.59894 -10.55173,17.86115 -17.72096,24.29983 -7.1694,6.43883 -15.25476,11.10591 -24.5716,13.61353 -9.31698,2.50761 -20.94966,4.46936 -31.63903,1.98398 -10.68939,-2.48537 -18.0688,-9.22838 -24.09401,-15.89285 -6.02508,-6.66442 -12.35923,-14.47524 -22.96531,-22.06805 -10.60584,-7.59266 -20.8648,-15.59839 -25.16123,-23.3775 -4.29632,-7.77931 -7.008,-15.66934 -7.81517,-23.39095 -0.80717,-7.7215 0.35908,-14.55922 3.12288,-20.54462 2.76393,-5.98548 7.12557,-11.1208 12.7854,-15.40902 5.65998,-4.28811 12.61751,-7.73606 20.64204,-10.24271 8.02465,-2.50651 17.11262,-4.07552 27.13941,-4.41504 10.0268,-0.3395 20.06604,0.59388 29.76158,2.87504 9.69543,2.2813 19.05511,5.92037 27.47739,11.02309 8.42215,5.10286 15.89307,11.69212 21.60465,19.6287 5.71147,7.93674 13.0738,19.62846 15.30143,30.4913 z"
id="path4806"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
transform="matrix(13.851095,0,0,13.851095,10133.213,-6001.611)"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m -784.21409,457.33922 c -0.56136,0.0656 -1.08141,0.1809 -1.55606,0.33615 -0.63289,0.20699 -1.18396,0.48516 -1.6349,0.82686 -0.45093,0.3417 -0.80184,0.74659 -1.02778,1.21891 -0.22595,0.47234 -0.32669,1.01119 -0.27449,1.62035 0.0522,0.60917 0.25282,1.23371 0.57968,1.84938 0.32687,0.61567 0.98957,1.25218 1.83531,1.84156 0.84574,0.58937 1.35671,1.20529 1.82543,1.72857 0.46713,0.52147 1.13451,0.85371 2.02424,0.92674 0.10253,0.008 0.12328,-0.30471 0.0344,-0.32876 -0.78083,-0.20262 -1.25826,-0.72023 -1.71877,-1.11076 -0.4254,-0.46645 -0.87231,-1.01406 -1.62104,-1.54604 -0.74871,-0.53197 -1.47289,-1.09304 -1.77689,-1.63886 -0.30398,-0.54584 -0.49685,-1.10009 -0.55469,-1.64239 -0.0579,-0.54231 0.0245,-1.0222 0.21918,-1.44322 0.19469,-0.42103 0.50198,-0.78371 0.90168,-1.08623 0.39973,-0.30252 0.89062,-0.54587 1.4577,-0.7237 0.28355,-0.0889 0.5872,-0.16119 0.90722,-0.21465 0.32002,-0.0535 0.6576,-0.0885 1.01178,-0.10163 0.70839,-0.0255 1.4163,0.0392 2.10043,0.1987 0.68412,0.15947 1.34499,0.41522 1.93838,0.77329 0.59338,0.35806 1.11885,0.81986 1.52108,1.37653 0.40222,0.55667 0.92117,1.37523 1.07925,2.13677 0.12981,0.62539 0.0734,1.25844 -0.13288,1.83379 -0.0385,0.10712 0.4977,0.29416 0.62787,-0.0111 0.24265,-0.5698 0.23445,-1.24057 0.1026,-1.8741 -0.17834,-0.85666 -0.69031,-1.76937 -1.13671,-2.40019 -0.4464,-0.6308 -1.03123,-1.15292 -1.68895,-1.55276 -0.65772,-0.39984 -1.38674,-0.68003 -2.14271,-0.85021 -0.75599,-0.17016 -1.54036,-0.23166 -2.32498,-0.19142 -0.19617,0.0101 -0.38815,0.0268 -0.57528,0.0484 z"
id="path4808"
inkscape:connector-curvature="0"
sodipodi:nodetypes="csssccscsccscscccsccscsssscscscc" /><path
sodipodi:nodetypes="cssscsssc"
inkscape:connector-curvature="0"
id="path4810"
d="m -753.77185,413.0219 c -0.13663,-2.61847 2.18018,-4.94804 7.2193,-6.20054 7.65443,-1.90257 20.03831,1.84566 27.93811,5.67152 4.33357,2.09883 8.88981,3.89076 12.66635,7.19411 1.28185,1.12133 2.51799,2.28349 3.36855,4.40869 -1.65849,0.577 -4.10492,-0.92134 -5.87278,-2.13046 -6.96771,-4.76531 -14.69502,-8.08983 -22.67695,-9.12646 -6.71591,-0.87187 -8.86923,-3.11022 -14.75541,-2.56175 -3.72583,0.34716 -4.90626,2.13878 -7.88716,2.74489 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="sssssssss"
inkscape:connector-curvature="0"
id="path4813"
d="m -720.16989,411.68353 c 0.28532,-2.32502 0.86962,3.90377 -0.31886,5.45995 -4.46007,5.84 -8.20289,12.32072 -12.42083,18.36519 -1.37385,1.96787 -3.29463,0.0414 -2.42738,-2.09874 0.88118,-2.1739 2.06053,-3.99898 3.34915,-5.8153 1.20809,-1.70147 2.81353,-3.0576 3.88834,-4.85958 2.06619,-3.46267 2.39577,-6.62873 4.25443,-10.2393 0.63712,-1.23818 3.5225,0.42546 3.67386,-0.80905 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /></g><g
style="display:inline;enable-background:new"
id="g4710"
transform="matrix(-0.69719495,0.22290406,0.41767232,0.92198706,562.8644,-101.63567)"><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4706"
d="m 423.50332,581.83521 c -0.004,4.40048 -1.19837,7.58856 -3.37524,9.82844 -2.17687,2.23987 -5.33154,3.55156 -9.14619,4.44292 -3.81465,0.89135 -8.28246,1.39523 -13.05675,1.83828 -4.77428,0.44304 -9.85163,0.79076 -14.95001,1.09928 -5.09838,0.30851 -9.94541,0.34741 -14.40217,0.0862 -4.45676,-0.26122 -8.52354,-0.79908 -11.99271,-1.71189 -3.46915,-0.91282 -6.33736,-2.21356 -8.3562,-4.09288 -2.01885,-1.87935 -3.18709,-4.34475 -3.25466,-7.51083 -0.0676,-3.16607 0.9983,-5.4859 2.92534,-7.0838 1.92703,-1.5979 4.71248,-2.46394 8.09977,-2.84688 3.38729,-0.38293 7.37282,-0.28336 11.77044,-0.16051 4.39762,0.12284 9.21051,0.23456 14.33166,-0.12202 5.12115,-0.35659 10.27171,-1.47349 15.16022,-2.54099 4.88852,-1.06749 9.50395,-2.05149 13.43823,-2.27114 3.9343,-0.21967 7.17754,0.32322 9.39823,2.04598 2.22069,1.72276 3.41425,4.59936 3.41004,8.99986 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d6b9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="ssscsscssssssssssssssssssssccsss"
inkscape:connector-curvature="0"
id="path4708"
d="m 411.91406,568.54883 c -3.75011,-0.0271 -8.08701,0.53975 -12.76172,1.28711 -5.34251,0.85413 -11.10706,1.92059 -17.00976,2.32617 -5.9027,0.40562 -11.41103,0.38326 -16.44727,0.41406 -5.03624,0.0309 -9.6045,0.1607 -13.50781,0.85938 -3.9033,0.69867 -7.13503,1.96743 -9.4082,3.96875 -2.27316,2.00131 -3.58535,4.71676 -3.65235,8.17578 -0.067,3.45901 1.21821,6.3073 3.54297,8.58008 2.32476,2.27278 5.68789,3.9795 9.76172,5.25 4.07385,1.27051 8.85237,2.11894 14.05664,2.59765 5.20427,0.47871 10.83381,0.56134 16.70313,0.22266 5.86931,-0.33868 11.47146,-0.78653 16.60547,-1.34961 5.13399,-0.56309 9.79334,-1.22365 13.70703,-2.34375 1.48913,-0.4262 2.86677,-0.9287 4.12695,-1.51953 2.54507,-1.19325 2.05015,-6.17249 -0.0996,-4.54102 -1.99172,1.51153 -4.55969,2.50355 -7.57031,3.20703 -3.66893,0.85731 -7.96668,1.34146 -12.5586,1.76758 -4.59191,0.42612 -9.47527,0.75991 -14.3789,1.05664 -4.90363,0.29673 -9.56506,0.33523 -13.85156,0.084 -4.28652,-0.25124 -8.19851,-0.76855 -11.53516,-1.64649 -3.33664,-0.87795 -6.09539,-2.12996 -8.03711,-3.9375 -1.94173,-1.80756 -3.06587,-4.17751 -3.13086,-7.22265 -0.065,-3.04513 0.96102,-5.2776 2.81445,-6.81446 1.85342,-1.53686 4.53117,-2.36997 7.78907,-2.73828 3.2579,-0.36831 7.09262,-0.27244 11.32226,-0.1543 4.22963,0.11816 8.85767,0.22578 13.7832,-0.11718 4.92553,-0.34297 9.88026,-1.41664 14.58204,-2.44336 4.70178,-1.02671 9.13982,-1.97234 12.92382,-2.1836 0.473,-0.0264 0.93707,-0.0422 1.38868,-0.0449 1.16046,-0.007 2.25007,0.0442 3.25,0.23633 1.15313,0.22156 2.31543,-2.86146 -0.83789,-2.92773 -0.51177,-0.0108 -1.03459,-0.045 -1.57032,-0.0488 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /></g><g
style="display:inline;opacity:1"
id="g4682"
transform="matrix(-0.11933103,0.60316395,1.3605999,0.30916083,88.824519,44.52189)"><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4678"
d="m 767.29926,387.32674 c 11.1235,7.96555 31.77795,11.29978 44.73159,15.54502 12.95363,4.24526 18.14889,9.35948 22.12936,13.37285 3.98046,4.01338 5.94428,7.14463 4.71807,9.52723 -1.2262,2.38259 -5.54351,3.99405 -14.00119,4.81166 -8.45765,0.81761 -15.90978,0.12055 -23.02358,-1.72572 -7.11381,-1.84628 -13.80694,-4.86649 -21.70559,-8.603 -7.89866,-3.73649 -17.3272,-8.0507 -25.81115,-14.18439 -8.48395,-6.13369 -17.62324,-13.90003 -23.14238,-24.13356 -5.51915,-10.23352 -5.78201,-21.34406 -5.37146,-30.88264 0.41055,-9.53859 1.51092,-17.55377 2.71572,-23.74931 1.20482,-6.19553 2.71509,-10.67437 4.77102,-13.66952 2.05591,-2.99513 4.65165,-4.52673 7.71923,-4.52673 3.06759,0 5.70357,1.83092 7.62535,5.49926 1.9218,3.66832 3.04778,9.24444 3.28639,16.76004 0.23861,7.51561 -0.67126,17.08072 0.34029,27.19831 1.01155,10.1176 3.89485,20.79494 15.01833,28.7605 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4680"
d="m 760.81735,387.61463 c 8.35351,7.22933 23.40419,11.34465 36.92829,14.85447 13.52408,3.50986 21.76315,7.50998 26.41399,11.29491 4.65086,3.78492 7.04347,6.96136 6.89289,9.28045 -0.15059,2.31908 -3.07202,3.85186 -9.99413,4.53735 -6.92209,0.68549 -13.12478,-0.17957 -19.18856,-2.15841 -6.06375,-1.97886 -12.01277,-5.06603 -19.62326,-8.64782 -7.61047,-3.5818 -16.94465,-7.61787 -24.98938,-13.21535 -8.04472,-5.59749 -15.82286,-12.65396 -20.9022,-21.24583 -5.07935,-8.59186 -6.01346,-17.801 -5.99188,-25.91871 0.0216,-8.1177 0.93462,-15.14861 1.86635,-20.66954 0.93173,-5.52092 2.01706,-9.59713 3.38259,-12.30465 1.36554,-2.70753 3.03466,-4.06947 5.01979,-4.01398 1.98511,0.0555 3.57672,1.84704 4.61437,5.2751 1.03765,3.42807 1.44745,8.54444 1.4737,15.15288 0.0262,6.60845 -0.43638,14.76057 0.91317,23.27473 1.34954,8.51418 4.83074,17.27506 13.18427,24.5044 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d6b9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /></g><g
style="display:inline"
id="g4533-2"
transform="matrix(-0.60102903,0.32221978,0.53870829,0.77401445,526.12645,47.501077)" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.42763;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 367.21812,281.2111 c 1.26906,4.8e-4 2.53948,0.0178 3.81226,0.0513 42.77724,1.12555 90.70354,5.69495 122.10388,40.54871 32.84028,36.45207 50.86245,87.73981 42.4768,134.73998 -3.70559,20.76925 -10.98111,49.47352 -17.74195,62.40674 -7.11403,13.60881 -19.80467,12.64104 -20.03271,14.6001 -0.95849,8.23388 28.65775,0.0351 45.92102,17.56348 14.06339,14.27947 18.47143,40.7184 18.81226,59.65026 0.0821,4.5609 -0.0281,9.27804 -0.3772,14.04236 -14.49094,35.53359 -47.68736,57.07915 -104.82056,72.23694 -16.35254,4.33843 -32.98332,6.84124 -49.34143,8.0127 -33.94749,-1.39556 -67.79597,-6.13242 -100.13854,-17.26685 -32.65579,-11.24226 -56.145,-40.89976 -74.77295,-73.88489 -29.47508,-52.19252 -35.755,-116.16519 -27.31567,-176.46789 7.06058,-50.45097 11.6574,-86.98057 52.22351,-116.77185 32.34621,-23.75469 69.85025,-39.47592 109.19128,-39.46106 z"
id="path4609"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cssssccscscsssssc" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#96d6ff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.42763;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 367.42685,286.88188 c 42.99236,0.29098 91.45399,4.58065 122.97913,39.77783 29.3149,32.72949 43.70846,77.17043 40.47912,119.64477 -1.85014,24.33459 -4.2527,46.89446 -15.33809,68.52471 -7.40578,14.45044 -25.66197,16.94779 -25.75077,21.45698 -0.0598,3.03823 12.8172,2.29092 36.45996,9.33838 18.23589,5.43578 21.08736,15.45128 27.9657,34.84863 3.54426,9.99504 3.63562,21.40294 3.86353,32.07459 0.0987,4.62319 -0.16021,9.28578 -0.71778,13.93066 -15.76071,31.8161 -48.31041,51.69261 -101.98425,65.93262 -19.90398,5.28064 -40.22223,7.84987 -59.95606,8.5968 -23.42101,-1.57782 -46.67598,-5.15666 -69.29992,-11.41663 -36.53114,-10.10803 -65.44663,-39.43363 -86.5155,-75.75622 -29.60753,-51.04315 -36.18916,-113.60515 -29.37012,-173.2251 5.79548,-50.67089 11.02944,-86.89891 51.97998,-115.98999 31.47751,-22.36148 67.20785,-37.99521 105.20507,-37.73803 z"
id="path4611"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssscssccscsssss" /><path
style="opacity:1;fill:#394655;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.633166"
d="m 550.04547,566.15505 c -8.01527,-17.95204 -33.2969,-29.54939 -59.90815,-26.35079 -36.4344,4.37932 -107.00869,29.17307 -108.83839,27.45259 32.95965,-15.3268 80.70019,-26.25455 112.65433,-35.45314 11.18054,-3.21853 19.35455,-9.69456 24.10347,-20.91405 0,0 -4.41927,9.99274 -4.75073,10.24133 -0.33145,0.24859 -7.78922,5.96622 -7.78922,5.96622 0,0 -6.79486,3.48029 -7.29204,3.72887 -0.49719,0.2486 -4.80611,3.39744 -4.80611,3.39744 0,0 2.15446,1.57441 2.9831,1.823 0.82864,0.24859 2.98311,1.65729 4.64039,1.82301 1.65728,0.16573 7.12631,0.66292 11.93243,1.4087 4.80611,0.74577 15.08126,3.23169 17.48432,4.39179 2.40305,1.16009 7.62349,4.06034 7.87208,4.47466 0.2486,0.41432 11.71452,18.01037 11.71452,18.01037 z"
id="path4629"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cscscccccsccccc" /><g
style="display:inline;opacity:1"
transform="matrix(-0.19755642,-0.64741346,0.92085299,-0.16781481,-30.330781,1045.2275)"
id="g4674"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d6b9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 423.50332,581.83521 c -0.004,4.40048 -1.19837,7.58856 -3.37524,9.82844 -2.17687,2.23987 -5.33154,3.55156 -9.14619,4.44292 -3.81465,0.89135 -8.28246,1.39523 -13.05675,1.83828 -4.77428,0.44304 -9.85163,0.79076 -14.95001,1.09928 -5.09838,0.30851 -9.94541,0.34741 -14.40217,0.0862 -4.45676,-0.26122 -8.52354,-0.79908 -11.99271,-1.71189 -3.46915,-0.91282 -6.33736,-2.21356 -8.3562,-4.09288 -2.01885,-1.87935 -3.18709,-4.34475 -3.25466,-7.51083 -0.0676,-3.16607 0.9983,-5.4859 2.92534,-7.0838 1.92703,-1.5979 4.71248,-2.46394 8.09977,-2.84688 3.38729,-0.38293 7.37282,-0.28336 11.77044,-0.16051 4.39762,0.12284 9.21051,0.23456 14.33166,-0.12202 5.12115,-0.35659 10.27171,-1.47349 15.16022,-2.54099 4.88852,-1.06749 9.50395,-2.05149 13.43823,-2.27114 3.9343,-0.21967 7.17754,0.32322 9.39823,2.04598 2.22069,1.72276 3.41425,4.59936 3.41004,8.99986 z"
id="path4670"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 411.91406,568.54883 c -3.75011,-0.0271 -8.08701,0.53975 -12.76172,1.28711 -5.34251,0.85413 -11.10706,1.92059 -17.00976,2.32617 -5.9027,0.40562 -11.41103,0.38326 -16.44727,0.41406 -5.03624,0.0309 -9.6045,0.1607 -13.50781,0.85938 -3.9033,0.69867 -7.13503,1.96743 -9.4082,3.96875 -2.27316,2.00131 -3.58535,4.71676 -3.65235,8.17578 -0.067,3.45901 1.21821,6.3073 3.54297,8.58008 2.32476,2.27278 5.68789,3.9795 9.76172,5.25 4.07385,1.27051 8.85237,2.11894 14.05664,2.59765 5.20427,0.47871 10.83381,0.56134 16.70313,0.22266 5.86931,-0.33868 11.47146,-0.78653 16.60547,-1.34961 5.13399,-0.56309 9.79334,-1.22365 13.70703,-2.34375 1.48913,-0.4262 2.86677,-0.9287 4.12695,-1.51953 2.54507,-1.19325 2.05015,-6.17249 -0.0996,-4.54102 -1.99172,1.51153 -4.14364,1.68162 -7.15735,2.35061 -3.67269,0.81527 -8.18136,0.99111 -12.55008,1.3428 -4.3687,0.35167 -8.7789,1.78431 -13.31332,2.07736 -4.53444,0.29304 -8.86787,0.32801 -12.93181,0.0702 -4.06396,-0.25785 -7.85651,-0.78075 -11.12475,-1.64296 -3.26823,-0.86221 -5.99695,-2.08037 -7.8846,-3.81399 -1.88765,-1.73365 -2.92537,-3.9871 -2.97865,-6.80086 -0.0533,-2.81374 0.90176,-4.8192 2.66881,-6.10562 1.76704,-1.28641 5.61732,-0.58475 8.69196,-0.71399 3.07463,-0.12925 6.90624,-0.54484 10.78772,-0.41733 3.88147,0.12754 6.54592,-0.48119 11.04844,-1.2139 4.50252,-0.73264 9.15212,-2.3434 13.88736,-3.72101 4.73523,-1.37761 9.22461,-2.34259 13.00861,-2.55385 0.473,-0.0264 0.93707,-0.0422 1.38868,-0.0449 1.16046,-0.007 2.25007,0.0442 3.25,0.23633 1.15313,0.22156 2.31543,-2.86146 -0.83789,-2.92773 -0.51177,-0.0108 -1.03459,-0.045 -1.57032,-0.0488 z"
id="path4672"
inkscape:connector-curvature="0"
sodipodi:nodetypes="csscsscssssssssssssssssssssccsssc" /></g><g
style="opacity:1"
transform="matrix(0.38329319,-0.18164886,0.18164886,0.38329319,415.72605,54.494616)"
id="g4804"><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4796"
d="m -626.57295,401.69566 c 2.24713,11.35067 0.36741,22.38948 -3.843,32.03835 -4.21053,9.64886 -10.54997,17.90531 -17.7192,24.34399 -7.1694,6.43883 -15.25457,11.1106 -24.57171,13.61082 -9.31727,2.5002 -20.94956,4.47176 -31.64526,1.82793 -10.69571,-2.64383 -18.09209,-9.81214 -24.14818,-17.25062 -6.05597,-7.43843 -12.44269,-16.56671 -23.09665,-25.35944 -10.65372,-8.79255 -20.95218,-17.78817 -25.30072,-26.87318 -4.34843,-9.08528 -7.1154,-18.36084 -7.98,-27.52156 -0.86459,-9.1606 0.24716,-17.36404 2.9617,-24.58398 2.71467,-7.22004 7.03243,-13.45488 12.66059,-18.5369 5.6283,-5.08191 12.56665,-9.01064 20.59229,-11.48936 8.02576,-2.47858 17.13537,-3.50537 27.20916,-2.66707 10.0738,0.83832 20.1809,3.47234 29.95223,7.6529 9.77122,4.18068 19.21426,9.9086 27.71179,16.89733 8.49741,6.98886 16.03465,15.24007 21.79567,24.41557 5.7609,9.17565 13.1742,22.14471 15.42129,33.49522 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#96d6ff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
transform="matrix(13.851095,0,0,13.851095,10133.213,-6001.611)"
sodipodi:nodetypes="csssccscsccscscccsccscsssscscscc"
inkscape:connector-curvature="0"
id="path4798"
d="m -784.27135,455.90422 c -0.56339,0.0147 -1.08437,0.10666 -1.55902,0.26191 -0.63289,0.20699 -1.18231,0.52669 -1.63059,0.93484 -0.44828,0.40815 -0.79558,0.90361 -1.01756,1.4752 -0.22199,0.5716 -0.31844,1.21792 -0.26185,1.93717 0.0566,0.71926 0.26134,1.4471 0.59196,2.157 0.33063,0.7099 0.99621,1.41858 1.84494,2.08284 0.84872,0.66425 1.36325,1.36931 1.83382,1.93901 0.46898,0.56774 1.13678,0.9105 2.02675,0.98962 0.10256,0.009 0.12294,-0.31321 0.034,-0.33899 -0.78143,-0.21746 -1.26048,-0.77583 -1.72293,-1.21489 -0.42768,-0.5236 -0.87838,-1.16625 -1.63058,-1.78505 -0.75217,-0.61879 -1.47924,-1.25213 -1.78697,-1.89162 -0.30772,-0.63951 -0.50455,-1.29287 -0.56648,-1.9378 -0.062,-0.64492 0.0165,-1.22191 0.20772,-1.73042 0.1912,-0.50852 0.49539,-0.94884 0.89287,-1.30706 0.3975,-0.35822 0.88707,-0.63484 1.45426,-0.80994 0.2836,-0.0875 0.58767,-0.1494 0.90851,-0.1822 0.32084,-0.0328 0.65966,-0.0369 1.01552,-0.008 0.71174,0.0585 1.42446,0.24383 2.11396,0.53794 0.6895,0.29412 1.35628,0.69807 1.95502,1.19025 0.59873,0.49218 1.12894,1.07271 1.53474,1.71893 0.4058,0.64623 0.9285,1.5589 1.08808,2.35795 0.13104,0.65619 0.075,1.29927 -0.13103,1.88026 -0.0384,0.10817 0.49808,0.30362 0.62824,-0.002 0.24262,-0.57052 0.23429,-1.24452 0.10166,-1.89748 -0.17938,-0.88293 -0.69436,-1.871 -1.14416,-2.58711 -0.44981,-0.71609 -1.03943,-1.35821 -1.70275,-1.89855 -0.66333,-0.54034 -1.3987,-0.97968 -2.16052,-1.29649 -0.76184,-0.31679 -1.55154,-0.51173 -2.33984,-0.56369 -0.19709,-0.013 -0.38986,-0.0163 -0.57767,-0.0116 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="cssscsssc"
inkscape:connector-curvature="0"
id="path4800"
d="m -730.27274,382.91266 c 1.8068,-2.76405 6.31309,-3.63001 13.24575,-1.6171 10.53068,3.05761 22.43414,14.97755 28.94834,24.04709 3.57338,4.97534 7.6424,9.78266 9.64772,15.62449 0.68055,1.98294 1.27611,3.97774 0.68898,6.70435 -2.4056,-0.49416 -4.1871,-3.62313 -5.37952,-6.01329 -4.69962,-9.4202 -11.38574,-17.86492 -20.09536,-24.13889 -7.3284,-5.27852 -8.20487,-8.9719 -15.61502,-12.25742 -4.69053,-2.07967 -7.44128,-1.02076 -11.44089,-2.34923 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="sssssssss"
inkscape:connector-curvature="0"
id="path4802"
d="m -689.31909,403.49962 c 2.08771,-2.1886 -1.9021,4.5559 -4.48533,5.36905 -9.69439,3.05157 -19.01784,7.22624 -28.57811,10.64488 -3.11327,1.11257 -3.94795,-2.11026 -1.30738,-3.72982 2.68251,-1.64492 5.45711,-2.73872 8.35507,-3.75217 2.71578,-0.94874 5.64428,-1.2851 8.27731,-2.4236 5.06052,-2.18718 7.83343,-5.20599 12.75841,-7.67984 1.68866,-0.84854 3.86766,2.73608 4.97603,1.5739 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /></g></g><g
id="layer6"
inkscape:label="gopher-body-shadow"
style="opacity:0.07"
transform="translate(-22.468606,-107.81499)"><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:3.53553;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 410.73438,402.67383 c -22.12212,5.91178 -47.244,11.63106 -70.61524,16.37305 -48.79037,9.89949 -67.17652,36.06247 -78.49023,54.44726 l 6.87695,-1.67969 c 36.64463,-15.30695 75.75822,-23.23183 113.48437,-35.26953 12.09814,-3.47331 21.06002,-13.5287 25.50391,-25.00976 1.18561,-2.92243 2.25783,-5.87876 3.24024,-8.86133 z"
transform="scale(0.93749999)"
id="path4829"
inkscape:connector-curvature="0" /><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 176.74023,191.19336 c -12.02297,5.35584 -23.55765,11.8602 -34.44531,19.05273 -5.41851,4.11086 -12.04772,7.05917 -16.45703,12.31055 0.0894,0.61993 -0.23486,1.1597 -0.71484,1.54883 -0.66228,4.1883 -1.36755,8.37707 -2.08594,12.50781 -5.65684,32.5269 -33.941548,3.53554 -50.91211,0 -10.52665,-2.19305 -19.680537,-11.20026 -16.179688,-32.73437 -1.579179,1.23479 -3.051377,2.63841 -4.392578,4.22461 -5.278878,6.02984 -6.059769,15.29458 -3.236328,22.56836 4.226081,7.59968 10.487816,15.22544 19.425782,17.11523 7.922144,1.96176 15.28729,5.43641 22.820312,8.4375 3.386589,1.47244 7.752358,0.61137 10.76562,2.42773 -2.433965,1.64193 -6.471862,1.23181 -7.285151,4.85743 -9.479196,22.45139 -12.471269,46.96783 -15.552735,70.95117 -3.621752,29.34547 -3.810268,59.0771 -1.185546,88.50976 5.062186,45.87599 20.801047,91.1762 49.363282,127.80469 19.95688,26.39173 47.3522,48.60821 80.10937,56.24414 22.47395,5.53265 45.44736,9.51266 68.57813,10.69727 8.5266,-0.40063 17.18762,-0.50715 25.58008,-2.02539 0.38196,-2.81994 -2.61781,-5.57722 -3.4961,-8.2461 -1.44653,-3.42348 -4.19801,-6.88649 -4.1582,-10.63867 2.13967,-1.54285 1.84049,-4.80023 4.6582,-5.89648 5.55455,-3.57415 12.83988,-2.42032 18.94141,-1.75782 5.75306,0.52405 7.14734,7.19265 9.875,11.22852 2.12351,3.09028 2.41953,7.87377 5.55859,10.0625 26.17162,-6.52384 52.36044,-14.89116 75.51172,-29.04492 17.05316,-10.54934 31.72418,-25.28626 40.90821,-43.20117 -0.321,-7.86722 0.0972,-15.83004 0.0234,-23.74805 -36.4662,9.9142 -97.74588,26.5181 -128.4375,31.87695 -44.54772,7.77818 -96.87505,9.19053 -150.61515,-30.40744 -65.69966,-48.41028 -44.54795,-229.80948 -35.35555,-265.16482 4.79814,-18.45438 17.9071,-38.64402 42.39062,-59.56055 z"
transform="scale(0.93749999)"
id="path4834"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccssccccccccccccccccccccccsssc" /><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 221.25,457.42331 c 0,0 15,15.23437 22.03125,18.04687 7.03125,2.8125 20.85937,11.01563 28.125,14.76563 7.26562,3.75 7.73437,10.07812 22.96875,4.21875 15.23437,-5.85938 20.39062,-10.07813 30.9375,-11.71875 10.54687,-1.64063 18.04687,-0.9375 25.07812,-3.98438 7.03125,-3.04687 23.90625,-10.78125 28.59375,-13.35937 4.6875,-2.57813 9.60938,-2.57813 13.82813,-3.98438 4.21875,-1.40625 17.10937,-0.23437 19.45312,-3.75 2.34375,-3.51562 3.75,-3.98437 3.04688,-6.5625 -0.70313,-2.57812 -7.03125,-15 -15,-16.875 -7.96875,-1.875 -14.76563,-2.34375 -36.5625,-1.875 -21.79688,0.46875 -18.75,-1.17187 -39.14063,4.45313 -20.39062,5.625 -14.76562,10.78125 -37.26562,12.89062 -22.5,2.10938 -17.10938,2.10938 -29.53125,1.64063 -12.42188,-0.46875 -11.71875,-0.23438 -19.6875,-1.64063 -7.96875,-1.40625 -16.875,7.73438 -16.875,7.73438 z"
id="path4842"
inkscape:connector-curvature="0" /><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:4.6875;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 364.92187,465.15768 c 15,5.85938 16.875,39.60938 16.875,39.60938 0,0 3.98438,3.28125 4.21875,-0.70313 5.3692,-7.3069 -8.34035,-45.03528 -13.82812,-41.48437 z"
id="path4844"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" /><path
style="display:inline;opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:0.883883;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
d="m 280.10156,140.0293 c -3.31241,1.49194 -6.45666,3.36696 -9.3457,5.52929 -5.80006,4.43544 -10.87913,10.55536 -12.1543,17.92774 -0.29285,2.25732 -0.37829,4.57376 -0.32617,6.82812 0.54553,0.71731 1.76636,0.2809 2.5957,0.47852 3.60567,0.16435 7.21143,0.36527 10.8125,0.60937 -3.09385,-7.05751 -2.40538,-18.24537 8.41797,-31.37304 z"
transform="scale(0.93749999)"
id="path4846"
inkscape:connector-curvature="0" /></g><g
id="layer2"
inkscape:label="gopher-face"
style="display:inline"
transform="translate(-159.49345,-229.70978)"><g
transform="matrix(-0.70545801,0,0,0.70545801,653.3373,199.47893)"
id="g4639"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 547.42756,318.16456 c -0.44046,14.77191 -4.12869,29.02667 -10.38967,42.25266 -6.26099,13.22599 -15.09198,25.42687 -25.80466,35.99686 -10.71268,10.57 -23.30432,19.50822 -37.11826,26.08983 -13.81394,6.58161 -28.85103,10.80263 -44.50193,11.8618 -15.65091,1.05917 -30.4406,-1.15844 -43.81781,-6.16756 -13.37721,-5.00911 -25.3405,-12.8075 -35.30087,-22.80416 -9.96037,-9.99666 -17.91599,-22.19037 -23.26581,-35.90798 -5.34983,-13.71761 -8.0915,-28.95913 -7.64195,-44.98105 0.44955,-16.02192 4.04447,-31.2937 10.1422,-45.07896 6.09773,-13.78526 14.69591,-26.08175 25.16951,-36.25747 10.4736,-10.17571 22.82245,-18.23043 36.46168,-23.66123 13.63924,-5.4308 28.57214,-8.24285 44.22923,-8.02541 15.6571,0.21745 30.56095,3.42714 44.11009,8.94154 13.54914,5.5144 25.7404,13.33722 35.92568,22.91495 10.18529,9.57774 18.36233,20.91345 23.87736,33.53282 5.51504,12.61936 8.36566,26.52144 7.92521,41.29336 z"
id="path4631"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 539.72249,314.79002 c 10e-4,13.89984 -3.01572,27.53808 -8.51346,40.35257 -5.49774,12.81449 -13.48047,24.80543 -23.37659,35.2527 -9.89612,10.44726 -21.70519,19.34133 -34.78531,25.87862 -13.08011,6.53727 -27.4256,10.71236 -42.3773,11.7667 -14.9517,1.05435 -29.09103,-1.11258 -41.85904,-5.93108 -12.76803,-4.81852 -24.16883,-12.28715 -33.66552,-21.79076 -9.49671,-9.50362 -17.08979,-21.04298 -22.23241,-33.95465 -5.14261,-12.91166 -7.83328,-27.19561 -7.52333,-42.13595 0.30995,-14.94034 3.58995,-29.10832 9.22975,-41.85842 5.63981,-12.7501 13.63743,-24.08168 23.39638,-33.47108 9.75897,-9.38941 21.27795,-16.83842 34.00359,-21.94183 12.72563,-5.10342 26.66067,-7.86812 41.28534,-7.94317 14.62467,-0.0751 28.55938,2.53224 41.26083,7.24431 12.70145,4.71207 24.16709,11.5339 33.81555,20.03646 9.64847,8.50257 17.47884,18.68937 22.90117,30.21241 5.42232,11.52304 8.43889,24.38332 8.44035,38.28317 z"
id="path4633"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><circle
r="30.809652"
cy="314.25491"
cx="420.48679"
id="circle4635"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394455;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><circle
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="circle4637"
cx="406.84973"
cy="300.11276"
r="15.152287" /></g><g
id="g4651"
transform="matrix(-0.3718816,-0.33035833,-0.33035833,0.3718816,807.54151,424.47556)"><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4643"
d="m 549.05152,326.95134 c -3.75547,13.70752 -9.94485,27.49753 -18.134,40.75742 -8.18917,13.2599 -18.55963,25.88871 -30.48613,36.73483 -11.9265,10.84612 -25.26726,19.91416 -38.40631,26.7046 -13.13906,6.79044 -26.00962,11.35394 -38.37684,13.00513 -12.36722,1.6512 -23.25032,0.18545 -33.06847,-4.02278 -9.81817,-4.20823 -22.90634,-9.21469 -29.99096,-18.16657 -7.08461,-8.95188 -12.84054,-20.18257 -16.5035,-33.03389 -3.66297,-12.85133 -5.229,-27.32914 -3.92417,-42.72858 1.30484,-15.39944 5.36688,-30.24976 11.81788,-43.75488 6.45101,-13.5051 15.29008,-25.65823 26.00811,-35.78271 10.71803,-10.12447 28.44246,-20.29305 42.24879,-25.86698 13.80633,-5.57394 28.83304,-8.62768 44.20973,-8.80364 15.3767,-0.17594 29.62737,2.52591 41.94358,7.37479 12.31622,4.84887 22.69735,11.85058 30.35956,20.34718 7.66222,8.49661 12.72593,18.56023 14.82358,29.82149 2.09769,11.26125 1.23461,23.70707 -2.52085,37.41459 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><path
sodipodi:nodetypes="sssssssssssssssss"
inkscape:connector-curvature="0"
id="path4645"
d="m 543.48686,325.96521 c -3.05527,12.95406 -8.32379,26.18736 -15.65713,38.96025 -7.33335,12.77289 -16.92422,24.96643 -28.32938,35.27691 -11.40514,10.31051 -24.49416,18.73032 -37.45951,24.88997 -12.96533,6.15964 -25.70084,10.13776 -37.71064,11.47666 -12.00982,1.33889 -22.34344,-0.13174 -31.49437,-4.07588 -9.15093,-3.94412 -22.12504,-8.81495 -28.38237,-17.0256 -6.25732,-8.21068 -11.24381,-18.53447 -14.30417,-30.37519 -3.06035,-11.84072 -4.18965,-25.20221 -2.68634,-39.42576 1.5033,-14.22354 5.50837,-27.94818 11.67956,-40.43838 6.17119,-12.4902 14.50792,-23.74111 24.54768,-33.13895 10.03978,-9.39782 26.99021,-19.0621 39.83566,-24.2929 12.84546,-5.2308 26.78412,-8.15811 41.0009,-8.45853 14.21678,-0.30038 27.34319,2.03758 38.64284,6.33106 11.29965,4.29349 20.72984,10.51935 27.81498,18.21491 7.08513,7.69557 11.8105,16.84485 13.92296,27.20306 2.11247,10.35821 1.63459,21.92432 -1.42067,34.87837 z"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><circle
transform="rotate(9.4590451)"
r="27.721321"
cy="221.94495"
cx="451.74091"
id="circle4647"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394455;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><circle
transform="rotate(9.4590451)"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="circle4649"
cx="444.28159"
cy="208.59291"
r="13.633434" /></g></g><g
id="layer8"
inkscape:label="gopher-eye-shadow"
style="opacity:0.07"
transform="translate(-22.468606,-107.81499)"><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 183.98633,257.10938 c -12.96514,6.80368 -24.09238,16.98928 -31.2461,29.85742 -6.62306,11.80983 -9.18947,25.58787 -7.96289,39.02734 1.67308,22.69639 14.45708,43.56009 31.54688,58.10156 14.06811,11.68944 31.68858,19.74781 50.07226,20.84571 11.72752,0.49232 23.74714,-0.51041 34.5586,-5.42579 7.00377,-3.01395 13.43915,-7.17995 19.12109,-12.20898 -12.36895,1.55403 -17.4885,4.99626 -41.57617,5.11133 -34.01431,0.16249 -53,-11.5 -68.5,-36.5 -14.1749,-22.86275 -23.28438,-60.80338 13.98633,-98.80859 z"
transform="scale(0.93749999)"
id="path4853"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccssc" /><path
style="opacity:1;fill:#1e1e1e;fill-opacity:1;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 407.00195,240.87891 c -9.28173,2.40304 -16.98081,9.77947 -21.58398,18.09375 -9.07687,16.02983 -11.91205,35.42287 -8.52539,53.46875 3.18065,14.4141 9.49376,28.957 20.97265,38.67382 5.72719,4.40424 12.92201,6.42783 19.83399,8.13477 6.81141,1.14046 13.43258,-1.454 19.35351,-4.83008 -10.63686,0.15673 -18.09098,-0.62412 -29.80273,-7.00195 -16.87155,-9.18769 -20.86545,-23.47661 -24.25,-43.5 -2.91982,-17.27395 -3.79826,-39.70251 24.00195,-63.03906 z"
transform="scale(0.93749999)"
id="path4855"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccssc" /></g><g
id="layer7"
inkscape:label="gopher-mouth"
style="display:inline"
transform="translate(-159.49345,-229.70978)"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#1e1e1e;fill-opacity:0.0854271;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 479.16003,446.53263 c -0.5609,0.0108 -1.13139,0.0418 -1.70837,0.0934 -2.30796,0.20633 -4.51646,0.72546 -6.51307,1.48133 -1.9966,0.75587 -3.78067,1.74769 -5.22583,2.89856 -1.0355,0.82461 -1.88701,1.73417 -2.51953,2.69165 -0.49936,0.12274 -1.0048,0.23296 -1.49781,0.36987 -3.81399,1.05919 -7.40798,2.54289 -10.40404,4.35608 -2.99605,1.81321 -5.4585,3.90209 -6.89576,6.26037 -1.4722,2.41563 -1.97188,5.13866 -1.80359,7.29493 0.19753,2.5307 1.75758,4.81955 3.97339,6.88111 2.21579,2.06154 5.27574,3.88827 8.8202,5.38513 1.41204,0.59632 2.90966,1.1203 4.4403,1.59484 l -0.83679,5.84107 -1.56738,7.92297 c -0.28234,1.42686 0.0936,2.6844 0.78552,3.59985 0.69199,0.91542 1.75347,1.43692 3.00476,1.46668 l 4.17114,0.10071 8.25256,-0.19226 c 1.58268,-0.0369 3.05271,-0.65206 4.13086,-1.59668 1.07816,-0.94464 1.71703,-2.22901 1.84571,-3.63098 l 0.72143,-7.86255 0.29663,-3.98255 c 3.44589,-0.52696 6.56272,-1.40104 9.31274,-2.44995 3.13519,-1.19582 5.76942,-2.61386 7.84425,-4.18763 2.07486,-1.57375 3.60911,-3.3186 4.57763,-5.26427 0.96853,-1.94567 1.36942,-4.09866 1.01807,-6.41968 -0.35134,-2.32102 -1.43875,-4.58369 -3.11829,-6.66321 -1.39778,-1.73064 -3.21452,-3.31999 -5.30822,-4.7168 0.0111,-0.55256 -0.0197,-1.11781 -0.13001,-1.69922 -0.26781,-1.4117 -0.89157,-2.76339 -1.83655,-3.9862 -0.94497,-1.22282 -2.2106,-2.31507 -3.73901,-3.20068 -1.52841,-0.88563 -3.32124,-1.56509 -5.31189,-1.96839 -1.49298,-0.30248 -3.09635,-0.44985 -4.77905,-0.41748 z"
id="path4863"
inkscape:connector-curvature="0" /><g
id="g4823"
transform="translate(12.595339,-3.9774756)"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#2e3436;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 467.31675,478.77402 -7.09437,0.0922 -4.13468,-0.0915 c -1.31882,-0.0292 -2.49364,0.59671 -3.34977,1.51944 -0.85615,0.92274 -1.27744,2.20064 -1.47679,3.59515 l -1.12865,7.8943 -1.5677,7.92258 c -0.28234,1.42686 0.0937,2.68467 0.78566,3.60012 0.69199,0.91542 1.75323,1.43698 3.00452,1.46674 l 4.17197,0.0993 8.25189,-0.19228 c 1.58268,-0.0369 3.05287,-0.65192 4.13103,-1.59654 1.07815,-0.94464 1.7158,-2.22919 1.84447,-3.63117 l 0.72151,-7.86154 0.58517,-7.8491 c 0.10417,-1.3971 -0.52004,-2.64858 -1.39674,-3.54475 -0.8767,-0.89618 -2.06937,-1.43077 -3.34749,-1.42293 z"
id="path4686"
inkscape:connector-curvature="0"
sodipodi:nodetypes="scssscssscssscsss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 466.02149,481.16374 -5.78995,0.0778 -2.75744,-0.11307 c -1.04748,-0.043 -2.01463,0.4856 -2.74214,1.24096 -0.72751,0.75535 -1.15228,1.80234 -1.35005,2.94507 l -1.11703,6.45444 -1.39816,6.47122 c -0.24964,1.15537 0.002,2.19546 0.54523,2.94311 0.5435,0.74764 1.41108,1.15214 2.44988,1.1934 l 2.83204,0.11253 6.55718,-0.1421 c 1.24167,-0.027 2.42344,-0.526 3.31255,-1.29718 0.88911,-0.77118 1.45844,-1.82149 1.60367,-2.96928 l 0.81341,-6.4286 0.70226,-6.42074 c 0.12544,-1.14351 -0.31817,-2.1697 -0.99356,-2.90365 -0.67536,-0.73395 -1.62282,-1.178 -2.66787,-1.16394 z"
id="path4688"
inkscape:connector-curvature="0"
sodipodi:nodetypes="scssscssscssscsss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 433.73526,472.08025 c 0.19752,2.5307 1.75649,4.82026 3.9723,6.88181 2.21579,2.06155 5.27599,3.88893 8.82045,5.38579 3.54446,1.49687 7.54259,2.64799 11.59243,3.26302 4.04985,0.61505 8.15288,0.68338 12.18571,0.19715 4.03281,-0.48626 7.64449,-1.45413 10.77968,-2.64995 3.13518,-1.19582 5.76946,-2.61453 7.84429,-4.18829 2.07486,-1.57376 3.60891,-3.31701 4.57744,-5.26269 0.96853,-1.94567 1.37034,-4.09954 1.01899,-6.42056 -0.35134,-2.32102 -1.4393,-4.58444 -3.11884,-6.66396 -1.67955,-2.07951 -3.93835,-3.97272 -6.59166,-5.54818 -2.6533,-1.57546 -5.69659,-2.83225 -9.05614,-3.65611 -3.35955,-0.82385 -7.03889,-1.21547 -11.06436,-1.08264 -4.02547,0.13285 -8.04246,0.77372 -11.85647,1.83293 -3.814,1.05919 -7.40852,2.54378 -10.40458,4.35697 -2.99604,1.81321 -5.45804,3.90231 -6.89529,6.26059 -1.4722,2.41563 -1.97224,5.13785 -1.80395,7.29412 z"
id="path4690"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d0cb;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 442.25761,471.51199 c 0.34559,2.01022 1.52164,3.88762 3.32749,5.56973 1.80585,1.68211 4.23932,3.17214 7.00457,4.36112 2.76524,1.18899 5.84856,2.06805 8.99234,2.52986 3.14378,0.46181 6.35142,0.50432 9.50541,0.11936 3.15398,-0.38496 5.97075,-1.14729 8.41503,-2.12187 2.44428,-0.97459 4.50648,-2.15972 6.12437,-3.48208 1.61791,-1.32236 2.79548,-2.78732 3.48967,-4.38292 0.69419,-1.59559 0.90742,-3.32616 0.54576,-5.15905 -0.36166,-1.8329 -1.26917,-3.59361 -2.60863,-5.20009 -1.33945,-1.60649 -3.10784,-3.05657 -5.19076,-4.25685 -2.08293,-1.20029 -4.4781,-2.15007 -7.11901,-2.76427 -2.6409,-0.61421 -5.52795,-0.89311 -8.65369,-0.76365 -3.12573,0.12948 -6.20924,0.65323 -9.12183,1.4947 -2.91261,0.84148 -5.65005,2.00273 -7.96189,3.4111 -2.31186,1.40839 -4.18867,3.06443 -5.38415,4.87182 -1.19548,1.8074 -1.71029,3.76288 -1.36468,5.77309 z"
id="path4692"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:11.1413;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 453.44155,459.24387 c 0.50394,1.4405 1.59973,2.71976 3.11582,3.79941 1.51609,1.07966 3.44645,1.96175 5.54093,2.63816 2.09447,0.67641 4.34429,1.14922 6.54588,1.40092 2.20159,0.25168 4.3549,0.28101 6.37262,0.0579 2.01772,-0.22314 3.7207,-0.67675 5.162,-1.30838 1.44132,-0.63166 2.6208,-1.44105 3.55014,-2.39383 0.92933,-0.95279 1.60959,-2.05056 2.00449,-3.26657 0.39491,-1.21603 0.50374,-2.55168 0.23592,-3.96339 -0.26781,-1.41171 -0.89241,-2.76478 -1.83739,-3.98759 -0.94498,-1.22282 -2.20943,-2.31451 -3.73784,-3.20012 -1.52841,-0.88562 -3.32073,-1.5647 -5.31138,-1.968 -1.99064,-0.40331 -4.17987,-0.53049 -6.48783,-0.32418 -2.30795,0.20632 -4.51621,0.7257 -6.51282,1.48157 -1.9966,0.75587 -3.78172,1.74809 -5.22688,2.89895 -1.44517,1.15085 -2.55022,2.46103 -3.16907,3.84701 -0.61885,1.38597 -0.74853,2.84764 -0.24459,4.28815 z"
id="path4694"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /></g></g><g
id="layer3"
inkscape:label="gopher-eye-lashes"
style="display:inline"
transform="translate(-159.49345,-229.70978)" /><g
id="g2"
transform="matrix(0.29908034,-0.07349009,0.08149446,0.24234368,225.1279,272.80177)"
inkscape:label="folder"
style="display:inline"><path
id="SVGCleanerId_0"
style="fill:#ffc36e"
d="M 183.295,123.586 H 55.05 c -6.687,0 -12.801,-3.778 -15.791,-9.76 L 26.483,88.276 39.259,62.726 c 2.99,-5.982 9.103,-9.76 15.791,-9.76 h 128.246 c 6.687,0 12.801,3.778 15.791,9.76 l 12.775,25.55 -12.776,25.55 c -2.99,5.982 -9.103,9.76 -15.791,9.76 z" /><g
id="g1-9">
<path
id="SVGCleanerId_0_1_"
style="fill:#ffc36e"
d="M 183.295,123.586 H 55.05 c -6.687,0 -12.801,-3.778 -15.791,-9.76 L 26.483,88.276 39.259,62.726 c 2.99,-5.982 9.103,-9.76 15.791,-9.76 h 128.246 c 6.687,0 12.801,3.778 15.791,9.76 l 12.775,25.55 -12.776,25.55 c -2.99,5.982 -9.103,9.76 -15.791,9.76 z" />
</g><path
style="fill:#eff2fa"
d="M 485.517,70.621 H 26.483 c -4.875,0 -8.828,3.953 -8.828,8.828 v 44.138 h 476.69 V 79.448 c 0,-4.875 -3.953,-8.827 -8.828,-8.827 z"
id="path1" /><rect
x="17.655001"
y="105.931"
style="fill:#e1e6f2"
width="476.69"
height="17.655001"
id="rect1" /><path
style="fill:#ffd782"
d="M 494.345,88.276 H 217.318 c -3.343,0 -6.4,1.889 -7.895,4.879 l -10.336,20.671 c -2.99,5.982 -9.105,9.76 -15.791,9.76 H 55.05 c -6.687,0 -12.801,-3.778 -15.791,-9.76 L 28.922,93.155 C 27.427,90.165 24.37,88.276 21.027,88.276 H 17.655 C 7.904,88.276 0,96.18 0,105.931 v 335.448 c 0,9.751 7.904,17.655 17.655,17.655 h 476.69 c 9.751,0 17.655,-7.904 17.655,-17.655 V 105.931 c 0,-9.751 -7.904,-17.655 -17.655,-17.655 z"
id="path2" /><path
style="fill:#ffc36e"
d="M 485.517,441.379 H 26.483 c -4.875,0 -8.828,-3.953 -8.828,-8.828 v 0 c 0,-4.875 3.953,-8.828 8.828,-8.828 h 459.034 c 4.875,0 8.828,3.953 8.828,8.828 v 0 c 0,4.876 -3.953,8.828 -8.828,8.828 z"
id="path3" /><path
style="fill:#eff2fa"
d="m 326.621,220.69 h 132.414 c 4.875,0 8.828,-3.953 8.828,-8.828 v -70.621 c 0,-4.875 -3.953,-8.828 -8.828,-8.828 H 326.621 c -4.875,0 -8.828,3.953 -8.828,8.828 v 70.621 c 0,4.875 3.953,8.828 8.828,8.828 z"
id="path4" /><path
style="fill:#c7cfe2"
d="m 441.379,167.724 h -97.103 c -4.875,0 -8.828,-3.953 -8.828,-8.828 v 0 c 0,-4.875 3.953,-8.828 8.828,-8.828 h 97.103 c 4.875,0 8.828,3.953 8.828,8.828 v 0 c 0,4.876 -3.953,8.828 -8.828,8.828 z"
id="path5" /><path
style="fill:#d7deed"
d="m 441.379,203.034 h -97.103 c -4.875,0 -8.828,-3.953 -8.828,-8.828 v 0 c 0,-4.875 3.953,-8.828 8.828,-8.828 h 97.103 c 4.875,0 8.828,3.953 8.828,8.828 v 0 c 0,4.876 -3.953,8.828 -8.828,8.828 z"
id="path6" /></g><g
id="layer12"
inkscape:label="gopher-hands"
style="display:inline"
transform="translate(-159.49345,-229.70978)"><g
transform="matrix(-0.73022337,-0.0504068,0.04754695,1.0110636,636.13477,-6.0140033)"
id="g4702"><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d6b9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 423.50332,581.83521 c -0.004,4.40048 -1.19837,7.58856 -3.37524,9.82844 -2.17687,2.23987 -5.33154,3.55156 -9.14619,4.44292 -3.81465,0.89135 -8.28246,1.39523 -13.05675,1.83828 -4.77428,0.44304 -9.85163,0.79076 -14.95001,1.09928 -5.09838,0.30851 -9.94541,0.34741 -14.40217,0.0862 -4.45676,-0.26122 -8.52354,-0.79908 -11.99271,-1.71189 -3.46915,-0.91282 -6.33736,-2.21356 -8.3562,-4.09288 -2.01885,-1.87935 -3.18709,-4.34475 -3.25466,-7.51083 -0.0676,-3.16607 0.9983,-5.4859 2.92534,-7.0838 1.92703,-1.5979 4.71248,-2.46394 8.09977,-2.84688 3.38729,-0.38293 7.37282,-0.28336 11.77044,-0.16051 4.39762,0.12284 9.21051,0.23456 14.33166,-0.12202 5.12115,-0.35659 10.27171,-1.47349 15.16022,-2.54099 4.88852,-1.06749 9.50395,-2.05149 13.43823,-2.27114 3.9343,-0.21967 7.17754,0.32322 9.39823,2.04598 2.22069,1.72276 3.41425,4.59936 3.41004,8.99986 z"
id="path4698"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssssssssssss" /><path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 411.91406,568.54883 c -3.75011,-0.0271 -8.08701,0.53975 -12.76172,1.28711 -5.34251,0.85413 -11.10706,1.92059 -17.00976,2.32617 -5.9027,0.40562 -11.41103,0.38326 -16.44727,0.41406 -5.03624,0.0309 -9.6045,0.1607 -13.50781,0.85938 -3.9033,0.69867 -7.13503,1.96743 -9.4082,3.96875 -2.27316,2.00131 -3.58535,4.71676 -3.65235,8.17578 -0.067,3.45901 1.21821,6.3073 3.54297,8.58008 2.32476,2.27278 5.68789,3.9795 9.76172,5.25 4.07385,1.27051 8.85237,2.11894 14.05664,2.59765 5.20427,0.47871 10.83381,0.56134 16.70313,0.22266 5.86931,-0.33868 11.47146,-0.78653 16.60547,-1.34961 5.13399,-0.56309 9.79334,-1.22365 13.70703,-2.34375 1.48913,-0.4262 2.86677,-0.9287 4.12695,-1.51953 2.54507,-1.19325 2.05015,-6.17249 -0.0996,-4.54102 -1.99172,1.51153 -4.55969,2.50355 -7.57031,3.20703 -3.66893,0.85731 -7.96668,1.34146 -12.5586,1.76758 -4.59191,0.42612 -9.47527,0.75991 -14.3789,1.05664 -4.90363,0.29673 -9.56506,0.33523 -13.85156,0.084 -4.28652,-0.25124 -8.19851,-0.76855 -11.53516,-1.64649 -3.33664,-0.87795 -6.09539,-2.12996 -8.03711,-3.9375 -1.94173,-1.80756 -3.06587,-4.17751 -3.13086,-7.22265 -0.065,-3.04513 0.96102,-5.2776 2.81445,-6.81446 1.85342,-1.53686 4.53117,-2.36997 7.78907,-2.73828 3.2579,-0.36831 7.09262,-0.27244 11.32226,-0.1543 4.22963,0.11816 8.85767,0.22578 13.7832,-0.11718 4.92553,-0.34297 9.88026,-1.41664 14.58204,-2.44336 4.70178,-1.02671 9.13982,-1.97234 12.92382,-2.1836 0.473,-0.0264 0.93707,-0.0422 1.38868,-0.0449 1.16046,-0.007 2.25007,0.0442 3.25,0.23633 1.15313,0.22156 2.31543,-2.86146 -0.83789,-2.92773 -0.51177,-0.0108 -1.03459,-0.045 -1.57032,-0.0488 z"
id="path4700"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ssscsscssssssssssssssssssssccsss" /></g></g><g
id="layer4"
inkscape:label="palette"
style="display:none"
transform="translate(-159.49345,-229.70978)"><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394655;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4162"
width="40.789474"
height="40.789474"
x="779.60529"
y="21.967466" /><rect
y="21.967466"
x="824.60529"
height="40.789474"
width="40.789474"
id="rect4170"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#bce8ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4208"
width="40.789474"
height="40.789474"
x="779.60529"
y="86.967468" /><rect
y="-127.75694"
x="824.60529"
height="40.789474"
width="40.789474"
id="rect4223"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#abccd9;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
transform="scale(1,-1)" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#c3b0cb;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4227"
width="40.789474"
height="40.789474"
x="779.60529"
y="131.96747" /><rect
y="131.96747"
x="824.60529"
height="40.789474"
width="40.789474"
id="rect4231"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e1d0cb;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f5c3d2;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4233"
width="40.789474"
height="40.789474"
x="869.60529"
y="131.96747" /><rect
y="176.96747"
x="779.60529"
height="40.789474"
width="40.789474"
id="rect4248"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#cec4ad;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><rect
transform="scale(1,-1)"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#96d6ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4263"
width="40.789474"
height="40.789474"
x="869.60529"
y="-127.75694" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2ce;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4267"
width="40.789474"
height="40.789474"
x="824.60529"
y="176.96747" /><rect
y="-327.75693"
x="779.60529"
height="40.789474"
width="40.789474"
id="rect4280"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#24b8eb;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
transform="scale(1,-1)" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8aa9ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4284"
width="40.789474"
height="40.789474"
x="824.60529"
y="286.96747" /><rect
y="331.96747"
x="779.60529"
height="40.789474"
width="40.789474"
id="rect4297"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#d4edf1;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#394d54;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4301"
width="40.789474"
height="40.789474"
x="779.60529"
y="241.96747" /><rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#d6e2ff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:9.21053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect4303"
width="40.789474"
height="40.789474"
x="824.60529"
y="331.96747" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 72 KiB