mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-11 09:00:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83be259754 | ||
|
|
1995d19c3d | ||
|
|
2258bb1f70 | ||
|
|
4254b529ef | ||
|
|
a2971e3053 | ||
|
|
a7d912aa44 | ||
|
|
3dea48c691 | ||
|
|
ef4bee88b8 | ||
|
|
08d5f0edfc | ||
|
|
1d43a7582d | ||
|
|
5e69aff21e | ||
|
|
0c0c62b1f7 | ||
|
|
d085bac227 | ||
|
|
b1db53b89e | ||
|
|
522699d96a | ||
|
|
cab1de3306 | ||
|
|
15058a7247 | ||
|
|
f1fd33c619 | ||
|
|
d68a80bd78 | ||
|
|
8607f2098a | ||
|
|
0275de0f07 | ||
|
|
a954023800 |
@@ -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 showing active transfers and system status*
|
||||
|
||||

|
||||
*Dashboard dark mode showing active transfers and system status*
|
||||
|
||||
### Configuration Interface
|
||||

|
||||
*Setting up transfer configurations with multiple storage options*
|
||||
|
||||
### Job Management
|
||||

|
||||
*Scheduling transfers with flexible cron expressions*
|
||||
|
||||
### File Metadata
|
||||

|
||||
*Track and manage file metadata with detailed information about transferred files*
|
||||
|
||||
### User Management
|
||||

|
||||
*Create user accounts and manage them*
|
||||
|
||||
### Admin Tools
|
||||

|
||||
*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,12 @@ 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
|
||||
# Two-Factor Authentication configuration
|
||||
- TOTP_ENCRYPTION_KEY=your_32_byte_secure_encryption_key
|
||||
# Email configuration
|
||||
- EMAIL_ENABLED=true
|
||||
- EMAIL_HOST=smtp.example.com
|
||||
- EMAIL_PORT=587
|
||||
@@ -206,6 +212,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
|
||||
@@ -217,6 +227,9 @@ EMAIL_ENABLE_TLS=true
|
||||
EMAIL_REQUIRE_AUTH=true
|
||||
EMAIL_USERNAME=smtp_username
|
||||
EMAIL_PASSWORD=smtp_password
|
||||
|
||||
# Two-Factor Authentication configuration
|
||||
TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
@@ -226,6 +239,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
|
||||
@@ -238,6 +254,14 @@ EMAIL_PASSWORD=smtp_password
|
||||
- `EMAIL_ENABLE_TLS`: Set to `true` to use TLS for secure email transmission
|
||||
- `EMAIL_REQUIRE_AUTH`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
|
||||
|
||||
- Two-Factor Authentication (2FA) configuration:
|
||||
- `TOTP_ENCRYPTION_KEY`: Secret key used to encrypt/decrypt TOTP secrets (for 2FA)
|
||||
- Should be exactly 32 bytes (characters) for optimal security
|
||||
- If not set, a default development key will be used (not secure for production)
|
||||
- If shorter than 32 bytes, it will be automatically padded (less secure)
|
||||
- If longer than 32 bytes, it will be truncated to 32 bytes
|
||||
- Example: `TOTP_ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456`
|
||||
|
||||
### Logging Configuration
|
||||
|
||||
GoMFT provides configurable logging with rotation support through the following environment variables:
|
||||
@@ -329,9 +353,48 @@ User management features:
|
||||
- JWT-based authentication with tokens
|
||||
- User theme preference settings (light/dark)
|
||||
|
||||
### Two-Factor Authentication (2FA) Implementation
|
||||
|
||||
#### Overview
|
||||
This implementation adds TOTP-based (Time-based One-Time Password) two-factor authentication support to the application, compatible with standard authenticator apps like Google Authenticator, Authy, and others.
|
||||
|
||||
#### Features
|
||||
- TOTP-based authentication (RFC 6238 compliant)
|
||||
- QR code setup for easy enrollment
|
||||
- Backup codes for account recovery
|
||||
- Rate-limited verification attempts
|
||||
- Secure secret storage
|
||||
|
||||
#### Database Changes
|
||||
The following fields have been added to the `users` table:
|
||||
- `two_factor_secret`: Stores the TOTP secret key
|
||||
- `two_factor_enabled`: Boolean flag indicating if 2FA is enabled
|
||||
- `backup_codes`: Stores recovery backup codes
|
||||
|
||||
#### Setup Process
|
||||
1. Navigate to `/profile/2fa/setup`
|
||||
2. Scan the displayed QR code with your authenticator app
|
||||
3. Enter the verification code to confirm setup
|
||||
4. Save your backup codes in a secure location
|
||||
|
||||
#### Login Flow
|
||||
1. Enter email and password as usual
|
||||
2. If 2FA is enabled:
|
||||
- Enter the 6-digit code from your authenticator app
|
||||
- Alternatively, use a backup code if you can't access your authenticator
|
||||
|
||||
#### Security Considerations
|
||||
- The TOTP secrets are encrypted using AES-256-GCM
|
||||
- You must set the `TOTP_ENCRYPTION_KEY` environment variable in production
|
||||
- This key should be 32 bytes (characters) long and kept confidential
|
||||
- Changing this key after users have set up 2FA will invalidate their existing 2FA configurations
|
||||
- For high-security deployments, store this key in a secure vault and inject it at runtime
|
||||
|
||||
### Transfer Configuration Options
|
||||
|
||||
1. **Source/Destination Types**:
|
||||
- Google Drive
|
||||
- Google Photos
|
||||
- Local filesystem
|
||||
- Amazon S3
|
||||
- MinIO (S3-compatible storage)
|
||||
@@ -343,32 +406,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 +610,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 +629,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/).
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+146
-1
@@ -5,8 +5,112 @@ import (
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// Dialog component for 2FA disable confirmation
|
||||
templ TwoFactorDisableDialog() {
|
||||
<div id="disable-2fa-dialog" class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||
<div class="px-6 pt-5 pb-3 text-center">
|
||||
<div class="flex justify-center mb-2">
|
||||
<i class="fas fa-shield-alt text-yellow-400 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
||||
Disable Two-Factor Authentication
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<p class="text-secondary-700 dark:text-secondary-300 mb-4">
|
||||
Are you sure you want to disable two-factor authentication? This will make your account less secure.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="current-password-2fa" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<i class="fas fa-lock mr-1"></i> Current Password
|
||||
</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="current-password-2fa"
|
||||
name="current_password"
|
||||
class="form-input pl-10 w-full"
|
||||
placeholder="Enter your current password"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="disable-2fa-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 flex justify-end space-x-3">
|
||||
<button type="button" class="btn-secondary" onclick="hideDisable2FADialog()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
onclick="submitDisable2FA()">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Profile(ctx context.Context, user db.User) {
|
||||
@LayoutWithContext("Profile", ctx) {
|
||||
<script>
|
||||
// Initialize dialog functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Initializing 2FA dialog functionality');
|
||||
|
||||
// Global functions for dialog control
|
||||
window.hideDisable2FADialog = function() {
|
||||
document.getElementById('disable-2fa-dialog').classList.add('hidden');
|
||||
document.getElementById('current-password-2fa').value = '';
|
||||
document.getElementById('disable-2fa-result').innerHTML = '';
|
||||
};
|
||||
|
||||
window.showDisable2FADialog = function() {
|
||||
console.log('Showing 2FA disable dialog');
|
||||
document.getElementById('disable-2fa-dialog').classList.remove('hidden');
|
||||
};
|
||||
|
||||
window.submitDisable2FA = function() {
|
||||
const password = document.getElementById('current-password-2fa').value;
|
||||
if (!password) {
|
||||
document.getElementById('disable-2fa-result').innerHTML = `
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded" role="alert">
|
||||
<span class="block sm:inline">Current password is required</span>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
htmx.ajax('POST', '/profile/2fa/disable', {
|
||||
target: '#disable-2fa-result',
|
||||
swap: 'innerHTML',
|
||||
values: { current_password: password }
|
||||
});
|
||||
};
|
||||
|
||||
// Close dialog when clicking outside
|
||||
document.getElementById('disable-2fa-dialog').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
hideDisable2FADialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Close dialog on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && !document.getElementById('disable-2fa-dialog').classList.contains('hidden')) {
|
||||
hideDisable2FADialog();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@TwoFactorDisableDialog()
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
@@ -34,7 +138,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>
|
||||
@@ -45,6 +149,47 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<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">Two-Factor Authentication</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||
if user.TwoFactorEnabled {
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="badge badge-success">
|
||||
<i class="fas fa-shield-alt mr-1"></i> Enabled
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/profile/2fa/backup-codes" class="btn-secondary btn-sm">
|
||||
<i class="fas fa-key mr-1"></i>
|
||||
Manage Backup Codes
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger btn-sm"
|
||||
onclick="showDisable2FADialog()">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="badge badge-warning">
|
||||
<i class="fas fa-shield-alt mr-1"></i> Disabled
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/profile/2fa/setup" class="btn-primary btn-sm">
|
||||
<i class="fas fa-lock mr-1"></i>
|
||||
Enable 2FA
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<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">Theme</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||
|
||||
@@ -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 & 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 & 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,216 @@
|
||||
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"
|
||||
:value="destReadOnly ? '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="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"
|
||||
:value="destIncludeArchived ? '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="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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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"
|
||||
:value="sourceReadOnly ? '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="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"
|
||||
:value="sourceIncludeArchived ? '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="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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
import "strings"
|
||||
|
||||
type TwoFactorBackupCodesData struct {
|
||||
BackupCodes []string
|
||||
ErrorMessage string
|
||||
SuccessMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorBackupCodes(ctx context.Context, data TwoFactorBackupCodesData) {
|
||||
@LayoutWithContext("Two-Factor Authentication Backup Codes", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">2FA Backup Codes</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">These codes can be used to login if you lose access to your authenticator app</p>
|
||||
</div>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if data.SuccessMessage != "" {
|
||||
if (strings.Contains(data.SuccessMessage, "IMPORTANT")) {
|
||||
// Show important messages with a different style and icon
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900 border border-yellow-400 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
<span class="block sm:inline font-bold">{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="space-y-8">
|
||||
if len(data.BackupCodes) > 0 {
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Your Backup Codes</h3>
|
||||
|
||||
<div class="p-4 border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 rounded-lg mb-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-shield-alt text-amber-600 dark:text-amber-400 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h4 class="text-sm font-medium text-amber-800 dark:text-amber-300">Security information</h4>
|
||||
<div class="mt-1 text-sm text-amber-700 dark:text-amber-400 space-y-2">
|
||||
<p><strong>These codes are your backup method to access your account.</strong></p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Each code can only be used once.</li>
|
||||
<li>Store these codes in a secure password manager.</li>
|
||||
<li>These codes allow access to your account - keep them safe!</li>
|
||||
<li>For security, you can only view codes immediately after generating them.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
for _, code := range data.BackupCodes {
|
||||
if strings.Contains(code, "REDACTED") {
|
||||
<div class="p-2 bg-secondary-200 dark:bg-secondary-700 rounded font-mono text-sm text-center text-secondary-500 dark:text-secondary-400">
|
||||
{ code }
|
||||
</div>
|
||||
} else {
|
||||
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center font-bold">
|
||||
{ code }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-center space-x-4 mt-6">
|
||||
if !strings.Contains(data.BackupCodes[0], "REDACTED") {
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onclick="downloadBackupCodes(this)">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download Backup Codes
|
||||
</button>
|
||||
}
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes" class="inline">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Generate New Codes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function downloadBackupCodes(button) {
|
||||
// Get backup codes from the displayed elements that don't contain "REDACTED"
|
||||
const codes = Array.from(
|
||||
document.querySelectorAll('.bg-secondary-100.dark\\:bg-secondary-700')
|
||||
).map(el => el.textContent.trim());
|
||||
|
||||
// Create content for the file
|
||||
const content =
|
||||
"2FA BACKUP CODES - KEEP THESE SAFE!\n" +
|
||||
"=====================================\n\n" +
|
||||
codes.join("\n") +
|
||||
"\n\n" +
|
||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
||||
"SECURITY WARNINGS:\n" +
|
||||
"* These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
||||
"* Each code can only be used once.\n" +
|
||||
"* Keep these codes in a secure location like a password manager.\n" +
|
||||
"* Treat these codes with the same security as your password.";
|
||||
|
||||
// Create blob and download link
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes-secure.txt';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
} else {
|
||||
<div class="text-center py-8">
|
||||
<div class="rounded-full bg-secondary-100 dark:bg-secondary-700 p-4 mx-auto w-16 h-16 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-key text-secondary-500 dark:text-secondary-400 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-2">No Backup Codes Available</h3>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6">You don't have any backup codes. Generate new ones for account recovery.</p>
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Generate Backup Codes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<a href="/profile" class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
|
||||
type BackupCodeVerifyData struct {
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorBackupVerify(ctx context.Context, data BackupCodeVerifyData) {
|
||||
@LayoutWithContext("Backup Code Verification", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-amber-100 dark:bg-amber-900 mb-4">
|
||||
<i class="fas fa-key text-amber-600 dark:text-amber-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Backup Code Verification</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter one of your backup codes</p>
|
||||
</div>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/login/verify"
|
||||
class="space-y-6"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Backup Code
|
||||
</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="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="form-input pl-10 w-full"
|
||||
placeholder="Enter backup code"
|
||||
required
|
||||
autocomplete="one-time-code"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Remember that each backup code can only be used once!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full"
|
||||
x-bind:disabled="!code.trim() || loading">
|
||||
<span x-show="!loading">Verify</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login/verify" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
|
||||
Use authenticator app instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
|
||||
type TwoFactorSetupData struct {
|
||||
QRCodeURL string
|
||||
Secret string
|
||||
BackupCodes []string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorSetup(ctx context.Context, data TwoFactorSetupData) {
|
||||
@LayoutWithContext("Two-Factor Authentication Setup", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Set Up Two-Factor Authentication</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enhance your account security with 2FA</p>
|
||||
</div>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">1. Scan QR Code</h3>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
||||
</p>
|
||||
<div class="flex justify-center mb-4">
|
||||
<img src={ data.QRCodeURL } alt="QR Code" class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-2 bg-white"/>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Can't scan the QR code? Use this code instead:
|
||||
</p>
|
||||
<code class="block mt-2 p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm">
|
||||
{ data.Secret }
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">2. Verify Setup</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="/profile/2fa/verify"
|
||||
class="space-y-4"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="form-input block w-full"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
required/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full"
|
||||
x-bind:disabled="code.length !== 6 || loading">
|
||||
<span x-show="!loading">Verify and Enable 2FA</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
if len(data.BackupCodes) > 0 {
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">3. Save Backup Codes</h3>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
Store these backup codes in a safe place. You can use them to access your account if you lose your authenticator device.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
for _, code := range data.BackupCodes {
|
||||
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center">
|
||||
{ code }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onclick="downloadBackupCodes(this)">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download Backup Codes
|
||||
</button>
|
||||
<script>
|
||||
function downloadBackupCodes(button) {
|
||||
// Get backup codes from the displayed elements
|
||||
const codes = Array.from(
|
||||
document.querySelectorAll('.bg-secondary-100.dark\\:bg-secondary-700')
|
||||
).map(el => el.textContent.trim());
|
||||
|
||||
// Create content for the file
|
||||
const content =
|
||||
"2FA Backup Codes - Keep these safe!\n" +
|
||||
"=====================================\n\n" +
|
||||
codes.join("\n") +
|
||||
"\n\n" +
|
||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
||||
"These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
||||
"Each code can only be used once. Keep these codes safe and secure.";
|
||||
|
||||
// Create blob and download link
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes.txt';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
|
||||
type TwoFactorVerifyData struct {
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
|
||||
@LayoutWithContext("Two-Factor Authentication", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||
<i class="fas fa-shield-alt text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Two-Factor Authentication</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter the code from your authenticator app</p>
|
||||
</div>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/login/verify"
|
||||
class="space-y-6"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Authentication Code
|
||||
</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="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="form-input pl-10 w-full"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="Enter 6-digit code"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full"
|
||||
x-bind:disabled="code.length !== 6 || loading">
|
||||
<span x-show="!loading">Verify</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login/backup-code" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
|
||||
Use a backup code instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Lost your device?
|
||||
<br/>
|
||||
You can use one of your backup codes instead of the 6-digit code.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -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,11 +4,13 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.833
|
||||
github.com/gin-contrib/sessions v1.0.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.3
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.35.0
|
||||
@@ -17,6 +19,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/sonic v1.12.9 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
@@ -30,6 +33,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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
|
||||
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ=
|
||||
github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
@@ -15,6 +17,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=
|
||||
@@ -40,10 +44,18 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
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=
|
||||
@@ -69,6 +81,8 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
|
||||
+14
-22
@@ -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).
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
// "github.com/pquerna/otp/base32"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/starfleetcptn/gomft/internal/config"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
// IssuerName is the name of the issuer that appears in authenticator apps
|
||||
IssuerName = "GoMFT"
|
||||
// SecretSize is the size of the TOTP secret in bytes
|
||||
SecretSize = 20
|
||||
// BackupCodeCount is the number of backup codes to generate
|
||||
BackupCodeCount = 8
|
||||
// BackupCodeLength is the length of each backup code
|
||||
BackupCodeLength = 8
|
||||
)
|
||||
|
||||
// EncryptTOTPSecret encrypts the TOTP secret with AES-256-GCM
|
||||
func EncryptTOTPSecret(secret string) (string, error) {
|
||||
// Get encryption key from config or environment variable
|
||||
var key []byte
|
||||
appConfig, err := config.Load()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Use the configured encryption key
|
||||
key = []byte(appConfig.TOTPEncryptKey)
|
||||
|
||||
// If empty for some reason, log warning and use development key
|
||||
if len(key) == 0 {
|
||||
fmt.Println("WARNING: Using development encryption key for TOTP. Set TOTP_ENCRYPTION_KEY for production.")
|
||||
key = []byte("this-is-a-dev-key-not-for-production!")
|
||||
}
|
||||
|
||||
// Ensure key is exactly 32 bytes (AES-256)
|
||||
if len(key) < 32 {
|
||||
// If key is too short, pad it to 32 bytes
|
||||
paddedKey := make([]byte, 32)
|
||||
copy(paddedKey, key)
|
||||
for i := len(key); i < 32; i++ {
|
||||
paddedKey[i] = byte(i % 256) // Simple padding pattern
|
||||
}
|
||||
key = paddedKey
|
||||
fmt.Println("WARNING: TOTP encryption key was padded to 32 bytes. This is insecure.")
|
||||
} else if len(key) > 32 {
|
||||
// If key is too long, truncate to 32 bytes
|
||||
key = key[:32]
|
||||
fmt.Println("WARNING: TOTP encryption key was truncated to 32 bytes.")
|
||||
}
|
||||
|
||||
// Create a new cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %v", err)
|
||||
}
|
||||
|
||||
// Create a new GCM
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %v", err)
|
||||
}
|
||||
|
||||
// Create a nonce
|
||||
nonce := make([]byte, aesGCM.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt the data
|
||||
ciphertext := aesGCM.Seal(nil, nonce, []byte(secret), nil)
|
||||
|
||||
// Combine nonce and ciphertext and encode as base64
|
||||
result := base64.StdEncoding.EncodeToString(append(nonce, ciphertext...))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecryptTOTPSecret decrypts the TOTP secret with AES-256-GCM
|
||||
func DecryptTOTPSecret(encryptedSecret string) (string, error) {
|
||||
// Get encryption key from config or environment variable
|
||||
var key []byte
|
||||
appConfig, err := config.Load()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Use the configured encryption key
|
||||
key = []byte(appConfig.TOTPEncryptKey)
|
||||
|
||||
// If empty for some reason, log warning and use development key
|
||||
if len(key) == 0 {
|
||||
fmt.Println("WARNING: Using development encryption key for TOTP. Set TOTP_ENCRYPTION_KEY for production.")
|
||||
key = []byte("this-is-a-dev-key-not-for-production!")
|
||||
}
|
||||
|
||||
// Ensure key is exactly 32 bytes (AES-256)
|
||||
if len(key) < 32 {
|
||||
// If key is too short, pad it to 32 bytes
|
||||
paddedKey := make([]byte, 32)
|
||||
copy(paddedKey, key)
|
||||
for i := len(key); i < 32; i++ {
|
||||
paddedKey[i] = byte(i % 256) // Simple padding pattern
|
||||
}
|
||||
key = paddedKey
|
||||
fmt.Println("WARNING: TOTP encryption key was padded to 32 bytes. This is insecure.")
|
||||
} else if len(key) > 32 {
|
||||
// If key is too long, truncate to 32 bytes
|
||||
key = key[:32]
|
||||
fmt.Println("WARNING: TOTP encryption key was truncated to 32 bytes.")
|
||||
}
|
||||
|
||||
// Decode the base64 string
|
||||
decoded, err := base64.StdEncoding.DecodeString(encryptedSecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64 secret: %v", err)
|
||||
}
|
||||
|
||||
// Create a new cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %v", err)
|
||||
}
|
||||
|
||||
// Create a new GCM
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %v", err)
|
||||
}
|
||||
|
||||
// Get the nonce size
|
||||
nonceSize := aesGCM.NonceSize()
|
||||
if len(decoded) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
// Extract nonce and ciphertext
|
||||
nonce, ciphertext := decoded[:nonceSize], decoded[nonceSize:]
|
||||
|
||||
// Decrypt the data
|
||||
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt data: %v", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// GenerateTOTPSecret generates a new TOTP secret for a user
|
||||
func GenerateTOTPSecret(email string) (string, string, error) {
|
||||
// Generate TOTP key using the library
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: IssuerName,
|
||||
AccountName: email,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate TOTP key: %v", err)
|
||||
}
|
||||
|
||||
// Generate QR code image
|
||||
var buf bytes.Buffer
|
||||
img, err := key.Image(256, 256)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate QR code image: %v", err)
|
||||
}
|
||||
|
||||
// Encode image as PNG and convert to base64
|
||||
err = png.Encode(&buf, img)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to encode QR code image: %v", err)
|
||||
}
|
||||
|
||||
// Create data URL
|
||||
dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
|
||||
|
||||
return key.Secret(), dataURL, nil
|
||||
}
|
||||
|
||||
// ValidateTOTPCode validates a TOTP code against an encrypted secret
|
||||
func ValidateTOTPCode(encryptedSecret string, code string) bool {
|
||||
// Remove any spaces from the code
|
||||
code = strings.ReplaceAll(code, " ", "")
|
||||
|
||||
// Decrypt the secret
|
||||
secret, err := DecryptTOTPSecret(encryptedSecret)
|
||||
if err != nil {
|
||||
// Log the error but fail silently to the user
|
||||
fmt.Printf("Error decrypting TOTP secret: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Use the library's Validate function
|
||||
return totp.Validate(code, secret)
|
||||
}
|
||||
|
||||
// BackupCodePair represents a backup code and its hash
|
||||
type BackupCodePair struct {
|
||||
PlainCode string
|
||||
HashedCode string
|
||||
}
|
||||
|
||||
// GenerateBackupCodes generates a set of backup codes
|
||||
// Returns both plaintext codes (to show to user) and hashed codes (to store in DB)
|
||||
func GenerateBackupCodes() ([]string, string, error) {
|
||||
plainCodes := make([]string, BackupCodeCount)
|
||||
hashedCodes := make([]string, BackupCodeCount)
|
||||
|
||||
for i := 0; i < BackupCodeCount; i++ {
|
||||
// Generate random bytes
|
||||
bytes := make([]byte, BackupCodeLength/2)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate backup code: %v", err)
|
||||
}
|
||||
|
||||
// Convert to hex string
|
||||
plainCodes[i] = fmt.Sprintf("%x", bytes)
|
||||
|
||||
// Hash the code for storage
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plainCodes[i]), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to hash backup code: %v", err)
|
||||
}
|
||||
|
||||
// Store the hashed version
|
||||
hashedCodes[i] = string(hash)
|
||||
}
|
||||
|
||||
// Return plaintext codes for display and hashed codes for storage
|
||||
return plainCodes, strings.Join(hashedCodes, ","), nil
|
||||
}
|
||||
|
||||
// ValidateBackupCode validates a backup code against a list of hashed codes
|
||||
func ValidateBackupCode(providedCode string, storedHashedCodes string) bool {
|
||||
if storedHashedCodes == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove any spaces and convert to lowercase
|
||||
providedCode = strings.ToLower(strings.ReplaceAll(providedCode, " ", ""))
|
||||
|
||||
// Split stored hashed codes
|
||||
hashedCodes := strings.Split(storedHashedCodes, ",")
|
||||
|
||||
// Check if the provided code matches any stored hashed code
|
||||
for _, hashedCode := range hashedCodes {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedCode), []byte(providedCode)); err == nil {
|
||||
// If the code matches (no error from bcrypt), return true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// RemoveBackupCode removes a used backup code from the list
|
||||
func RemoveBackupCode(usedCode string, storedHashedCodes string) string {
|
||||
if storedHashedCodes == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
usedCode = strings.ToLower(strings.ReplaceAll(usedCode, " ", ""))
|
||||
hashedCodes := strings.Split(storedHashedCodes, ",")
|
||||
|
||||
var remainingHashedCodes []string
|
||||
for _, hashedCode := range hashedCodes {
|
||||
// Only add the code back to the list if it doesn't match the used code
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedCode), []byte(usedCode)); err != nil {
|
||||
// If there's an error, this isn't the used code, so keep it
|
||||
remainingHashedCodes = append(remainingHashedCodes, hashedCode)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(remainingHashedCodes, ",")
|
||||
}
|
||||
|
||||
// GenerateQRCodeURL generates a QR code URL for an existing secret
|
||||
func GenerateQRCodeURL(secret string, email string) (string, error) {
|
||||
// Decode the base32 secret
|
||||
secretBytes, err := base32.StdEncoding.DecodeString(secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode secret: %v", err)
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: IssuerName,
|
||||
AccountName: email,
|
||||
Secret: secretBytes,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate TOTP key: %v", err)
|
||||
}
|
||||
|
||||
// Generate QR code image
|
||||
var buf bytes.Buffer
|
||||
img, err := key.Image(256, 256)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate QR code image: %v", err)
|
||||
}
|
||||
|
||||
// Encode image as PNG and convert to base64
|
||||
err = png.Encode(&buf, img)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode QR code image: %v", err)
|
||||
}
|
||||
|
||||
// Create data URL
|
||||
dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
|
||||
|
||||
return dataURL, nil
|
||||
}
|
||||
+23
-11
@@ -9,12 +9,13 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerAddress string `json:"server_address"`
|
||||
DataDir string `json:"data_dir"`
|
||||
BackupDir string `json:"backup_dir"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Email EmailConfig `json:"email"`
|
||||
BaseURL string `json:"base_url"` // Base URL for generating links in emails
|
||||
ServerAddress string `json:"server_address"`
|
||||
DataDir string `json:"data_dir"`
|
||||
BackupDir string `json:"backup_dir"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Email EmailConfig `json:"email"`
|
||||
BaseURL string `json:"base_url"` // Base URL for generating links in emails
|
||||
TOTPEncryptKey string `json:"totp_encrypt_key"` // Encryption key for TOTP secrets
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
@@ -33,11 +34,12 @@ type EmailConfig struct {
|
||||
func Load() (*Config, error) {
|
||||
// Default configuration
|
||||
cfg := &Config{
|
||||
ServerAddress: ":8080",
|
||||
DataDir: "./data",
|
||||
BackupDir: "./backups",
|
||||
JWTSecret: "change_this_to_a_secure_random_string",
|
||||
BaseURL: "http://localhost:8080",
|
||||
ServerAddress: ":8080",
|
||||
DataDir: "./data",
|
||||
BackupDir: "./backups",
|
||||
JWTSecret: "change_this_to_a_secure_random_string",
|
||||
BaseURL: "http://localhost:8080",
|
||||
TOTPEncryptKey: "this-is-a-dev-key-not-for-production!", // Default development key
|
||||
Email: EmailConfig{
|
||||
Enabled: false,
|
||||
Host: "smtp.example.com",
|
||||
@@ -80,6 +82,9 @@ func Load() (*Config, error) {
|
||||
if baseURL := os.Getenv("BASE_URL"); baseURL != "" {
|
||||
cfg.BaseURL = baseURL
|
||||
}
|
||||
if totpKey := os.Getenv("TOTP_ENCRYPTION_KEY"); totpKey != "" {
|
||||
cfg.TOTPEncryptKey = totpKey
|
||||
}
|
||||
|
||||
// Email configuration
|
||||
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
|
||||
@@ -125,6 +130,13 @@ func Load() (*Config, error) {
|
||||
"JWT_SECRET=" + cfg.JWTSecret,
|
||||
"BASE_URL=" + cfg.BaseURL,
|
||||
"",
|
||||
"# Google OAuth configuration (optional, for built-in authentication)",
|
||||
"GOOGLE_CLIENT_ID=your_google_client_id",
|
||||
"GOOGLE_CLIENT_SECRET=your_google_client_secret",
|
||||
"",
|
||||
"# Two-Factor Authentication configuration",
|
||||
"TOTP_ENCRYPTION_KEY=" + cfg.TOTPEncryptKey,
|
||||
"",
|
||||
"# Email configuration",
|
||||
"EMAIL_ENABLED=" + strconv.FormatBool(cfg.Email.Enabled),
|
||||
"EMAIL_HOST=" + cfg.Email.Host,
|
||||
|
||||
+612
-24
@@ -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,12 +19,15 @@ 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'"`
|
||||
TwoFactorSecret string `gorm:"type:varchar(32)"`
|
||||
TwoFactorEnabled bool `gorm:"default:false"`
|
||||
BackupCodes string `gorm:"type:text"` // Comma-separated backup codes
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -42,7 +46,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 +71,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 +101,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 +135,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 +254,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 +574,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 +631,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 +645,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 +796,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 +853,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 +930,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 +980,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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Add2FA creates a migration for adding Two-Factor Authentication fields
|
||||
func Add2FA() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "003_add_2fa",
|
||||
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)
|
||||
}
|
||||
|
||||
// Add new columns for 2FA - one at a time for SQLite compatibility
|
||||
if err := tx.Exec(`ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(32)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN DEFAULT FALSE`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE users ADD COLUMN backup_codes TEXT`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
// Remove 2FA columns - one at a time for SQLite compatibility
|
||||
if err := tx.Exec(`ALTER TABLE users DROP COLUMN two_factor_secret`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE users DROP COLUMN two_factor_enabled`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE users DROP COLUMN backup_codes`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,9 @@ import (
|
||||
// InitMigrations initializes the migrations
|
||||
func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
|
||||
migrations := []*gormigrate.Migration{
|
||||
// ... existing migrations
|
||||
AddDeleteAfterTransferColumn(),
|
||||
AddCloudStorageFields(),
|
||||
AddSkipProcessedFilesColumn(),
|
||||
AddMaxConcurrentTransfersColumn(),
|
||||
AddMultiConfigSupport(),
|
||||
UpdateSkipProcessedFilesToNullable(),
|
||||
AddWebhookSupport(),
|
||||
InitialSchema(),
|
||||
UpdateGDriveType(),
|
||||
Add2FA(),
|
||||
}
|
||||
|
||||
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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -17,6 +18,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) {
|
||||
@@ -47,11 +57,21 @@ func (h *Handlers) AuthMiddleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Safely extract claims with type assertions and defaults
|
||||
userID, _ := claims["user_id"].(float64)
|
||||
email, _ := claims["email"].(string)
|
||||
username, _ := claims["username"].(string)
|
||||
isAdmin, _ := claims["is_admin"].(bool)
|
||||
|
||||
// Set user information in the context
|
||||
c.Set("userID", uint(claims["user_id"].(float64)))
|
||||
c.Set("email", claims["email"].(string))
|
||||
c.Set("username", claims["username"].(string))
|
||||
c.Set("isAdmin", claims["is_admin"].(bool))
|
||||
c.Set("userID", uint(userID))
|
||||
if email != "" {
|
||||
c.Set("email", email)
|
||||
}
|
||||
if username != "" {
|
||||
c.Set("username", username)
|
||||
}
|
||||
c.Set("isAdmin", isAdmin)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
@@ -133,10 +153,11 @@ func (h *Handlers) APIAdminMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// GenerateJWT generates a JWT token for the given user
|
||||
func (h *Handlers) GenerateJWT(userID uint, username string, isAdmin bool) (string, error) {
|
||||
func (h *Handlers) GenerateJWT(userID uint, email string, isAdmin bool) (string, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"username": strings.Split(email, "@")[0], // Use email prefix as username
|
||||
"is_admin": isAdmin,
|
||||
"exp": time.Now().Add(time.Hour * 24).Unix(),
|
||||
})
|
||||
@@ -152,16 +173,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 +204,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 +222,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 +241,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)
|
||||
|
||||
@@ -234,24 +255,30 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token with all necessary user information
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
"username": strings.Split(user.Email, "@")[0], // Use email prefix as username
|
||||
"is_admin": user.IsAdmin,
|
||||
"exp": time.Now().Add(time.Hour * 24).Unix(),
|
||||
})
|
||||
// Check if 2FA is enabled
|
||||
if user.TwoFactorEnabled {
|
||||
// Store user ID temporarily for 2FA verification
|
||||
c.SetCookie("temp_user_id", fmt.Sprintf("%d", user.ID), 300, "/", "", false, true) // 5 minutes expiry
|
||||
|
||||
// Sign the token
|
||||
tokenString, err := token.SignedString([]byte(h.JWTSecret))
|
||||
// Redirect to 2FA verification page
|
||||
c.Redirect(http.StatusFound, "/login/verify")
|
||||
return
|
||||
}
|
||||
|
||||
// If 2FA is not enabled, proceed with normal login
|
||||
// Generate JWT token with all necessary user information
|
||||
isAdmin := false
|
||||
if user.IsAdmin != nil {
|
||||
isAdmin = *user.IsAdmin
|
||||
}
|
||||
token, err := h.GenerateJWT(user.ID, user.Email, isAdmin)
|
||||
if err != nil {
|
||||
components.Login(components.CreateTemplateContext(c), "Authentication error").Render(c, c.Writer)
|
||||
return
|
||||
}
|
||||
|
||||
// Set token in cookie
|
||||
c.SetCookie("jwt_token", tokenString, 86400, "/", "", false, true)
|
||||
c.SetCookie("jwt_token", token, 86400, "/", "", false, true)
|
||||
c.Redirect(http.StatusFound, "/dashboard")
|
||||
}
|
||||
|
||||
@@ -276,7 +303,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 +317,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 +330,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 +339,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 +347,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 +357,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 +366,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 +375,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 +383,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Update user's password
|
||||
user.PasswordHash = string(hashedPassword)
|
||||
user.LastPasswordChange = time.Now()
|
||||
@@ -366,7 +393,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 +402,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 +410,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 +421,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 +430,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 +438,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 +484,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 +500,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 +537,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 +547,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,15 +81,51 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
config.CreatedBy = userID
|
||||
|
||||
// print entire form data
|
||||
fmt.Println("Form data:", c.Request.Form)
|
||||
|
||||
// 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
|
||||
|
||||
fmt.Println("Skip processed files:", config.SkipProcessedFiles)
|
||||
fmt.Println("Config:", config)
|
||||
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)
|
||||
@@ -131,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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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": "",
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,9 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||
router.GET("/", h.HandleHome)
|
||||
router.GET("/login", h.HandleLoginPage)
|
||||
router.POST("/login", h.HandleLogin)
|
||||
router.GET("/login/verify", h.Handle2FAVerifyPage)
|
||||
router.POST("/login/verify", h.Handle2FAVerify)
|
||||
router.GET("/login/backup-code", h.Handle2FABackupCodePage)
|
||||
router.GET("/forgot-password", h.HandleForgotPasswordPage)
|
||||
router.POST("/forgot-password", h.HandleForgotPassword)
|
||||
router.GET("/reset-password", h.HandleResetPasswordPage)
|
||||
@@ -22,6 +25,13 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||
// Password change route - only accessed from profile page
|
||||
authorized.POST("/change-password", h.HandleChangePassword)
|
||||
|
||||
// 2FA routes - under profile
|
||||
authorized.GET("/profile/2fa/setup", h.Handle2FASetup)
|
||||
authorized.POST("/profile/2fa/verify", h.Handle2FAVerifySetup)
|
||||
authorized.POST("/profile/2fa/disable", h.Handle2FADisable)
|
||||
authorized.GET("/profile/2fa/backup-codes", h.Handle2FABackupCodes)
|
||||
authorized.POST("/profile/2fa/regenerate-codes", h.Handle2FARegenerateCodes)
|
||||
|
||||
{
|
||||
authorized.GET("/dashboard", h.HandleDashboard)
|
||||
authorized.GET("/configs", h.HandleConfigs)
|
||||
@@ -31,6 +41,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/starfleetcptn/gomft/components"
|
||||
"github.com/starfleetcptn/gomft/internal/auth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Handle2FASetup handles the GET /profile/2fa/setup route
|
||||
func (h *Handlers) Handle2FASetup(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
var user struct {
|
||||
Email string
|
||||
TwoFactorEnabled bool
|
||||
}
|
||||
|
||||
if err := h.DB.Table("users").Select("email, two_factor_enabled").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if 2FA is already enabled
|
||||
if user.TwoFactorEnabled {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate TOTP secret and QR code URL
|
||||
secret, qrCodeURL, err := auth.GenerateTOTPSecret(user.Email)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate 2FA secret")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate backup codes - now returns both plain codes and hashed codes
|
||||
backupCodesPlain, backupCodesHashed, err := auth.GenerateBackupCodes()
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate backup codes")
|
||||
return
|
||||
}
|
||||
|
||||
// Store secret and backup codes in session temporarily
|
||||
// We store the plain secret in the cookie since it's temporary and will be encrypted before DB storage
|
||||
c.SetCookie("2fa_setup_secret", secret, 3600, "/", "", false, true)
|
||||
c.SetCookie("2fa_setup_backup_codes_hashed", backupCodesHashed, 3600, "/", "", false, true)
|
||||
|
||||
// Render setup page
|
||||
data := components.TwoFactorSetupData{
|
||||
QRCodeURL: qrCodeURL,
|
||||
Secret: secret,
|
||||
BackupCodes: backupCodesPlain, // Show plain codes to the user
|
||||
ErrorMessage: "",
|
||||
}
|
||||
components.TwoFactorSetup(c.Request.Context(), data).Render(c, c.Writer)
|
||||
}
|
||||
|
||||
// Handle2FAVerifySetup handles the POST /profile/2fa/verify route
|
||||
func (h *Handlers) Handle2FAVerifySetup(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
var user struct {
|
||||
Email string
|
||||
}
|
||||
if err := h.DB.Table("users").Select("email").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
// Get secret from session
|
||||
secret, err := c.Cookie("2fa_setup_secret")
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Setup session expired")
|
||||
return
|
||||
}
|
||||
|
||||
// Get backup codes from session - now using the hashed version
|
||||
backupCodesHashed, err := c.Cookie("2fa_setup_backup_codes_hashed")
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Setup session expired")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the code
|
||||
code := c.PostForm("code")
|
||||
// For verification during setup, we use the plain secret since it's not yet encrypted
|
||||
if !totp.Validate(code, secret) {
|
||||
// Regenerate QR code URL using the existing secret
|
||||
qrCodeURL, err := auth.GenerateQRCodeURL(secret, user.Email)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate QR code")
|
||||
return
|
||||
}
|
||||
|
||||
// For display, we need to generate new plain-text codes
|
||||
// but we'll keep the same hashed codes for storage
|
||||
backupCodesPlain := []string{}
|
||||
if backupCodesHashed != "" {
|
||||
// Create placeholder codes since we can't recover the original codes
|
||||
// We'll use placeholder text that indicates these were already generated
|
||||
for i := 0; i < auth.BackupCodeCount; i++ {
|
||||
backupCodesPlain = append(backupCodesPlain, "[BACKUP CODE ALREADY GENERATED]")
|
||||
}
|
||||
}
|
||||
|
||||
data := components.TwoFactorSetupData{
|
||||
QRCodeURL: qrCodeURL,
|
||||
Secret: secret,
|
||||
BackupCodes: backupCodesPlain,
|
||||
ErrorMessage: "Invalid verification code. Please try again.",
|
||||
}
|
||||
components.TwoFactorSetup(c.Request.Context(), data).Render(c, c.Writer)
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt the secret before storing in database
|
||||
encryptedSecret, err := auth.EncryptTOTPSecret(secret)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to secure 2FA secret")
|
||||
return
|
||||
}
|
||||
|
||||
// Update user with 2FA settings
|
||||
if err := h.DB.Table("users").Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"two_factor_secret": encryptedSecret, // Store the encrypted secret
|
||||
"two_factor_enabled": true,
|
||||
"backup_codes": backupCodesHashed, // Store the hashed codes
|
||||
}).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to enable 2FA")
|
||||
return
|
||||
}
|
||||
|
||||
// Clear setup cookies
|
||||
c.SetCookie("2fa_setup_secret", "", -1, "/", "", false, true)
|
||||
c.SetCookie("2fa_setup_backup_codes_hashed", "", -1, "/", "", false, true)
|
||||
|
||||
// Redirect to profile with success message
|
||||
c.Redirect(http.StatusFound, "/profile?message=2FA+enabled+successfully")
|
||||
}
|
||||
|
||||
// Handle2FAVerifyPage handles the GET /login/verify route
|
||||
func (h *Handlers) Handle2FAVerifyPage(c *gin.Context) {
|
||||
// Check if we have a temporary user ID
|
||||
_, err := c.Cookie("temp_user_id")
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
// Render verification page
|
||||
data := components.TwoFactorVerifyData{
|
||||
ErrorMessage: "",
|
||||
}
|
||||
components.TwoFactorVerify(c.Request.Context(), data).Render(c, c.Writer)
|
||||
}
|
||||
|
||||
// Handle2FAVerify handles the POST /login/verify route
|
||||
func (h *Handlers) Handle2FAVerify(c *gin.Context) {
|
||||
// Get user ID from cookie
|
||||
tempUserID, err := c.Cookie("temp_user_id")
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse user ID
|
||||
var userID uint
|
||||
if _, err := fmt.Sscanf(tempUserID, "%d", &userID); err != nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
var user struct {
|
||||
TwoFactorSecret string
|
||||
BackupCodes string
|
||||
Email string
|
||||
IsAdmin *bool
|
||||
}
|
||||
if err := h.DB.Table("users").Select("two_factor_secret, backup_codes, email, is_admin").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
code := c.PostForm("code")
|
||||
|
||||
// First try TOTP code
|
||||
if auth.ValidateTOTPCode(user.TwoFactorSecret, code) {
|
||||
// Generate new JWT token and set cookie
|
||||
isAdmin := false
|
||||
if user.IsAdmin != nil {
|
||||
isAdmin = *user.IsAdmin
|
||||
}
|
||||
token, err := h.GenerateJWT(userID, user.Email, isAdmin)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
c.SetCookie("jwt_token", token, 86400, "/", "", false, true)
|
||||
|
||||
// Clear temporary user ID cookie
|
||||
c.SetCookie("temp_user_id", "", -1, "/", "", false, true)
|
||||
|
||||
c.Redirect(http.StatusFound, "/dashboard")
|
||||
return
|
||||
}
|
||||
|
||||
// Then try backup code
|
||||
if auth.ValidateBackupCode(code, user.BackupCodes) {
|
||||
// Remove used backup code
|
||||
newBackupCodes := auth.RemoveBackupCode(code, user.BackupCodes)
|
||||
if err := h.DB.Table("users").Where("id = ?", userID).Update("backup_codes", newBackupCodes).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to update backup codes")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new JWT token and set cookie
|
||||
isAdmin := false
|
||||
if user.IsAdmin != nil {
|
||||
isAdmin = *user.IsAdmin
|
||||
}
|
||||
token, err := h.GenerateJWT(userID, user.Email, isAdmin)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
c.SetCookie("jwt_token", token, 86400, "/", "", false, true)
|
||||
|
||||
// Clear temporary user ID cookie
|
||||
c.SetCookie("temp_user_id", "", -1, "/", "", false, true)
|
||||
|
||||
c.Redirect(http.StatusFound, "/dashboard")
|
||||
return
|
||||
}
|
||||
|
||||
// If neither code is valid, show error
|
||||
data := components.TwoFactorVerifyData{
|
||||
ErrorMessage: "Invalid verification code. Please try again.",
|
||||
}
|
||||
components.TwoFactorVerify(c.Request.Context(), data).Render(c, c.Writer)
|
||||
}
|
||||
|
||||
// Handle2FADisable handles the POST /profile/2fa/disable route
|
||||
func (h *Handlers) Handle2FADisable(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
// Get current password from form
|
||||
currentPassword := c.PostForm("current_password")
|
||||
if currentPassword == "" {
|
||||
c.Data(http.StatusBadRequest, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<span class="block sm:inline">Current password is required</span>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
var user struct {
|
||||
PasswordHash string
|
||||
TwoFactorEnabled bool
|
||||
}
|
||||
if err := h.DB.Table("users").Select("password_hash, two_factor_enabled").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.Data(http.StatusInternalServerError, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<span class="block sm:inline">Failed to get user information</span>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)); err != nil {
|
||||
c.Data(http.StatusBadRequest, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<span class="block sm:inline">Current password is incorrect</span>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if 2FA is already disabled
|
||||
if !user.TwoFactorEnabled {
|
||||
c.Data(http.StatusBadRequest, "text/html", []byte(`<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<span class="block sm:inline">Two-factor authentication is already disabled</span>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
// Disable 2FA
|
||||
if err := h.DB.Table("users").Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"two_factor_enabled": false,
|
||||
"two_factor_secret": nil,
|
||||
"backup_codes": nil,
|
||||
}).Error; err != nil {
|
||||
c.Data(http.StatusInternalServerError, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<span class="block sm:inline">Failed to disable two-factor authentication</span>
|
||||
</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">Two-factor authentication has been disabled</span>
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
</script>
|
||||
</div>`))
|
||||
}
|
||||
|
||||
// Handle2FABackupCodes handles the GET /profile/2fa/backup-codes route
|
||||
func (h *Handlers) Handle2FABackupCodes(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
var user struct {
|
||||
BackupCodes string
|
||||
TwoFactorEnabled bool
|
||||
Email string // We need email to generate new backup codes display
|
||||
}
|
||||
|
||||
if err := h.DB.Table("users").Select("backup_codes, two_factor_enabled, email").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled
|
||||
if !user.TwoFactorEnabled {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// If user just generated new codes, we need to show them
|
||||
// We can't derive the plain codes from the hashed ones, so we'll generate new ones for display
|
||||
message := c.Query("message")
|
||||
successMessage := ""
|
||||
if message != "" {
|
||||
successMessage = message
|
||||
}
|
||||
|
||||
// For backup codes display, we need to check where we are in the flow
|
||||
var backupCodes []string
|
||||
|
||||
// If the user just regenerated codes or they're viewing for the first time
|
||||
// we need to generate new codes to display, as we can't recover the hashed ones
|
||||
// We'll generate new temporary codes for display only, keeping the hashed ones in the database
|
||||
if strings.Contains(successMessage, "New backup codes generated") {
|
||||
// Generate new codes to display
|
||||
newCodes, _, err := auth.GenerateBackupCodes()
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate backup codes for display")
|
||||
return
|
||||
}
|
||||
backupCodes = newCodes
|
||||
|
||||
// Add a special warning about these being the only time they'll see these codes
|
||||
successMessage = "New backup codes generated successfully. IMPORTANT: Save these codes now. You won't be able to see them again!"
|
||||
} else {
|
||||
// If they're just viewing an existing page, show a message explaining
|
||||
// that backup codes are securely stored and they need to regenerate to view new ones
|
||||
backupCodes = []string{}
|
||||
if user.BackupCodes != "" {
|
||||
// Count how many backup codes the user has by counting commas+1
|
||||
codeCount := 1
|
||||
if user.BackupCodes != "" {
|
||||
codeCount = strings.Count(user.BackupCodes, ",") + 1
|
||||
}
|
||||
|
||||
// Show a placeholder message for existing codes
|
||||
for i := 0; i < codeCount; i++ {
|
||||
backupCodes = append(backupCodes, "[REDACTED FOR SECURITY]")
|
||||
}
|
||||
|
||||
// Set a message explaining why codes are hidden
|
||||
successMessage = "For security, backup codes are not displayed after initial generation. Generate new codes to replace existing ones."
|
||||
}
|
||||
}
|
||||
|
||||
// Render backup codes page
|
||||
data := components.TwoFactorBackupCodesData{
|
||||
BackupCodes: backupCodes,
|
||||
SuccessMessage: successMessage,
|
||||
}
|
||||
components.TwoFactorBackupCodes(c.Request.Context(), data).Render(c, c.Writer)
|
||||
}
|
||||
|
||||
// Handle2FARegenerateCodes handles the POST /profile/2fa/regenerate-codes route
|
||||
func (h *Handlers) Handle2FARegenerateCodes(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
var user struct {
|
||||
TwoFactorEnabled bool
|
||||
}
|
||||
|
||||
if err := h.DB.Table("users").Select("two_factor_enabled").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled
|
||||
if !user.TwoFactorEnabled {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new backup codes - we only need the hashed version for storage
|
||||
_, backupCodesHashed, err := auth.GenerateBackupCodes()
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate backup codes")
|
||||
return
|
||||
}
|
||||
|
||||
// Store new backup codes (hashed version)
|
||||
if err := h.DB.Table("users").Where("id = ?", userID).Update("backup_codes", backupCodesHashed).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to update backup codes")
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect back to backup codes page with success message
|
||||
c.Redirect(http.StatusFound, "/profile/2fa/backup-codes?message=New backup codes generated successfully")
|
||||
}
|
||||
|
||||
// Handle2FABackupCodePage handles the GET /login/backup-code route
|
||||
func (h *Handlers) Handle2FABackupCodePage(c *gin.Context) {
|
||||
// Check if we have a temporary user ID
|
||||
_, err := c.Cookie("temp_user_id")
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
// Render backup code verification page
|
||||
data := components.BackupCodeVerifyData{
|
||||
ErrorMessage: "",
|
||||
}
|
||||
components.TwoFactorBackupVerify(c.Request.Context(), data).Render(c, c.Writer)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
Reference in New Issue
Block a user