Compare commits

...
23 Commits
Author SHA1 Message Date
StarFleetCPTN bee14b7de0 Merge pull request #16 from StarFleetCPTN/development
Fixes for S3 and build errors
2025-03-08 16:54:00 -08:00
StarFleetCPTN 6c96cdbec1 chore: Remove OneDrive and Google Drive storage options from config form
- Comment out OneDrive and Google Drive options in source and destination storage type selects
- Remove corresponding form templates for OneDrive and Google Drive authentication
- Temporarily disable these cloud storage options from the configuration interface
2025-03-08 16:49:52 -08:00
StarFleetCPTN 339299e08e feat: Improve S3-compatible storage path handling in rclone operations
- Update executeJob method to handle source and destination paths for S3, MinIO, and B2
- Add bucket support for size, list, transfer, and archive operations
- Ensure correct path construction for different storage types
- Remove duplicate error logging in archive operation
2025-03-08 15:55:41 -08:00
StarFleetCPTN 1b1d043d50 feat: Add support for WebDAV, NextCloud, OneDrive, and Google Drive storage
- Update config_form.templ to include form fields for new storage types
- Modify db.go to support generating rclone configs for WebDAV, NextCloud, OneDrive, and Google Drive
- Add migration to include new cloud storage fields in transfer_configs table
- Update migrations.go to include new cloud storage migration
2025-03-08 15:11:47 -08:00
StarFleetCPTN c3a22333f7 chore: Add components package marker file
- Create components.go to ensure proper Go package recognition
- Update .gitignore to exclude all Go files except components.go
2025-03-08 14:12:43 -08:00
StarFleetCPTN ea057f14dc Merge pull request #13 from StarFleetCPTN/development
feat: Implement embedded static file serving
2025-03-08 13:20:29 -08:00
StarFleetCPTN 0da968fd5a feat: Implement embedded static file serving
- Use Go's embed.FS to serve static files directly from binary
- Remove explicit static directory creation
- Update static file serving method to use embedded filesystem
- Improve static file serving configuration with error handling
2025-03-08 13:17:33 -08:00
StarFleetCPTN 7ab3403bca Merge pull request #12 from StarFleetCPTN/development
Switch to glebarez/sqlite and add migration support
2025-03-08 12:49:33 -08:00
StarFleetCPTN 9a5ad3ec26 Merge pull request #11 from StarFleetCPTN/main
Merge main to Dev
2025-03-08 12:47:40 -08:00
StarFleetCPTN 8f06776b53 build: Disable CGO for cross-platform builds 2025-03-08 12:46:25 -08:00
StarFleetCPTN 248f9cce51 chore: Switch to glebarez/sqlite and add migration support
- Replace mattn/go-sqlite3 with glebarez/sqlite for better cross-platform compatibility
- Add gormigrate for database migration support
- Update go.mod and go.sum with new dependencies
- Modify database initialization to use the new SQLite driver
- Ignore compiled binary in .gitignore
2025-03-08 12:41:53 -08:00
StarFleetCPTN 9316eb0a53 Delete gomft 2025-03-08 12:34:37 -08:00
StarFleetCPTN a01edc13cb Merge pull request #10 from StarFleetCPTN/development
build: Enable CGO for cross-platform builds
2025-03-08 12:34:15 -08:00
StarFleetCPTN 2b2c305ccb build: Enable CGO for cross-platform builds 2025-03-08 12:31:13 -08:00
StarFleetCPTN 6f1e877341 Merge pull request #8 from StarFleetCPTN/development
docs: Add Docker deployment instructions and support details to README
2025-03-08 11:25:50 -08:00
StarFleetCPTN a6828f4c0e docs: Add Docker deployment instructions and support details to README 2025-03-08 11:24:39 -08:00
StarFleetCPTN 6e19df099c Merge pull request #5 from eltociear/patch-1
docs: update README.md
2025-03-08 11:10:00 -08:00
StarFleetCPTN 6503c19898 Merge pull request #7 from StarFleetCPTN/development
Development
2025-03-08 11:08:01 -08:00
StarFleetCPTN 81557840dd Add delete after transfer option and enhanced job/config deletion UX 2025-03-08 11:07:02 -08:00
Ikko Eltociear Ashimine 2017cd8b7d docs: update README.md
Mangement -> Management
2025-03-09 00:50:51 +09:00
StarFleetCPTN f4c8c2121c Add run now button for jobs with HTMX 2025-03-08 07:50:44 -08:00
StarFleetCPTN 2fbbb41f3f Merge pull request #4 from StarFleetCPTN/development
Github Action docker publish fix
2025-03-08 07:16:21 -08:00
StarFleetCPTN 30ded57293 Github Action docker publish fix 2025-03-08 07:14:12 -08:00
20 changed files with 1642 additions and 51 deletions
View File
@@ -44,7 +44,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
images: starfleetcptn/gomft
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
+13 -1
View File
@@ -44,9 +44,21 @@ Thumbs.db
# Ignore all Go files in the components directory
components/*.go
!components/components.go
# Ignore the data directory
data/
# Ignore the tmp directory
tmp/
tmp/
# Ignore the configs directory
configs/
# Ignore Dirs
source/
destination/
archive/
# Ignore binaries
gomft
+56 -3
View File
@@ -19,8 +19,8 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
![Job Scheduling](screenshots/new.job.gomft.png)
*Scheduling transfers with flexible cron expressions*
### User Mangement
![User Mangement](screenshots/user.management.gomft.png)
### User Management
![User Management](screenshots/user.management.gomft.png)
*Create user accounts and manage them*
@@ -49,6 +49,8 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
- **Password Recovery**: Self-service password reset via email with secure token-based authentication
- **User Profile Management**: Personal settings including theme preferences
- **Modern UI**: Built with Templ, HTMX and Tailwind CSS for a responsive experience
- **Docker Support**: Easy deployment with Docker images and Docker Compose support
- **Portable Deployment**: Run on any platform that supports Docker or Go
## Prerequisites
@@ -58,6 +60,8 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
## Installation
### Standard Installation
1. Clone the repository:
```bash
git clone https://github.com/starfleetcptn/gomft.git
@@ -74,6 +78,54 @@ go mod download
go build -o gomft
```
### Docker Installation
GoMFT is available as a Docker image for quick and easy deployment.
1. Pull the latest image from Docker Hub:
```bash
docker pull starfleetcptn/gomft:latest
```
2. Run the container:
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
starfleetcptn/gomft:latest
```
3. Access the web interface at `http://localhost:8080`
#### Docker Compose Example
For production deployments, you can use Docker Compose:
```yaml
version: '3'
services:
gomft:
image: starfleetcptn/gomft:latest
container_name: gomft
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./backups:/app/data/gomft/backups
environment:
- TZ=UTC
```
Save this as `docker-compose.yml` and run:
```bash
docker-compose up -d
```
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
## Configuration
GoMFT uses a configuration file located at `./data/gomft/config.json`. On first run, a default configuration will be created:
@@ -244,6 +296,7 @@ To configure email functionality:
- **Authentication**: JWT (JSON Web Tokens)
- **Database**: GORM with SQLite
- **File Transfer**: rclone
- **Deployment**: Docker containerization and traditional installation
### Building from Source
@@ -273,4 +326,4 @@ air
## License
MIT License - see LICENSE file for details
MIT License - see LICENSE file for details
+3
View File
@@ -0,0 +1,3 @@
// Package components contains the UI components for the GoMFT application.
// This file serves as a marker for the components package to ensure it's properly recognized by Go.
package components
+622 -6
View File
@@ -38,6 +38,11 @@ func getInitialData(config *db.TransferConfig) string {
sourceDomain := ""
// FTP fields
sourcePassiveMode := true
// OneDrive and Google Drive fields
sourceClientId := ""
sourceClientSecret := ""
sourceDriveId := ""
sourceTeamDrive := ""
filePattern := "*"
outputPattern := ""
destinationType := "local"
@@ -58,8 +63,14 @@ func getInitialData(config *db.TransferConfig) string {
destDomain := ""
// FTP fields
destPassiveMode := true
// OneDrive and Google Drive fields
destClientId := ""
destClientSecret := ""
destDriveId := ""
destTeamDrive := ""
archivePath := ""
archiveEnabled := false
deleteAfterTransfer := false
rcloneFlags := ""
if config != nil {
@@ -82,6 +93,11 @@ func getInitialData(config *db.TransferConfig) string {
sourceDomain = config.SourceDomain
// FTP fields
sourcePassiveMode = config.SourcePassiveMode
// OneDrive and Google Drive fields
sourceClientId = config.SourceClientID
sourceClientSecret = config.SourceClientSecret
sourceDriveId = config.SourceDriveID
sourceTeamDrive = config.SourceTeamDrive
filePattern = config.FilePattern
outputPattern = config.OutputPattern
destinationType = config.DestinationType
@@ -102,8 +118,14 @@ func getInitialData(config *db.TransferConfig) string {
destDomain = config.DestDomain
// FTP fields
destPassiveMode = config.DestPassiveMode
// OneDrive and Google Drive fields
destClientId = config.DestClientID
destClientSecret = config.DestClientSecret
destDriveId = config.DestDriveID
destTeamDrive = config.DestTeamDrive
archivePath = config.ArchivePath
archiveEnabled = config.ArchiveEnabled
deleteAfterTransfer = config.DeleteAfterTransfer
rcloneFlags = config.RcloneFlags
}
@@ -124,6 +146,10 @@ func getInitialData(config *db.TransferConfig) string {
sourceShare: '%s',
sourceDomain: '%s',
sourcePassiveMode: %v,
sourceClientId: '%s',
sourceClientSecret: '%s',
sourceDriveId: '%s',
sourceTeamDrive: '%s',
filePattern: '%s',
outputPattern: '%s',
destinationType: '%s',
@@ -144,11 +170,18 @@ func getInitialData(config *db.TransferConfig) string {
destDomain: '%s',
// FTP fields
destPassiveMode: %v,
// OneDrive and Google Drive fields
destClientId: '%s',
destClientSecret: '%s',
destDriveId: '%s',
destTeamDrive: '%s',
// Existing fields
archivePath: '%s',
archiveEnabled: %v,
deleteAfterTransfer: %v,
rcloneFlags: '%s',
loading: false,
validate() {
if (this.sourceType === 'sftp') {
if (!this.sourceHost || !this.sourceUser || (!this.sourcePassword && !this.sourceKeyFile)) {
@@ -174,6 +207,22 @@ func getInitialData(config *db.TransferConfig) string {
if (!this.sourceHost || !this.sourceUser || !this.sourcePassword) {
return false;
}
} else if (this.sourceType === 'webdav') {
if (!this.sourceHost || !this.sourceUser || !this.sourcePassword) {
return false;
}
} else if (this.sourceType === 'nextcloud') {
if (!this.sourceHost || !this.sourceUser || !this.sourcePassword) {
return false;
}
} else if (this.sourceType === 'onedrive') {
if (!this.sourceClientId || !this.sourceClientSecret) {
return false;
}
} else if (this.sourceType === 'google_drive') {
if (!this.sourceClientId || !this.sourceClientSecret) {
return false;
}
}
if (this.destinationType === 'sftp') {
@@ -200,16 +249,34 @@ func getInitialData(config *db.TransferConfig) string {
if (!this.destHost || !this.destUser || !this.destPassword) {
return false;
}
} else if (this.destinationType === 'webdav') {
if (!this.destHost || !this.destUser || !this.destPassword) {
return false;
}
} else if (this.destinationType === 'nextcloud') {
if (!this.destHost || !this.destUser || !this.destPassword) {
return false;
}
} else if (this.destinationType === 'onedrive') {
if (!this.destClientId || !this.destClientSecret) {
return false;
}
} else if (this.destinationType === 'google_drive') {
if (!this.destClientId || !this.destClientSecret) {
return false;
}
}
return this.name && this.sourcePath && this.filePattern && this.destinationPath && (!this.archiveEnabled || this.archivePath);
}
}`, name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile,
sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint,
sourceShare, sourceDomain, sourcePassiveMode,
sourceClientId, sourceClientSecret, sourceDriveId, sourceTeamDrive,
filePattern, outputPattern, destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile,
destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint,
destShare, destDomain, destPassiveMode,
archivePath, archiveEnabled, rcloneFlags)
destClientId, destClientSecret, destDriveId, destTeamDrive,
archivePath, archiveEnabled, deleteAfterTransfer, rcloneFlags)
}
templ ConfigForm(ctx context.Context, data ConfigFormData) {
@@ -281,6 +348,10 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<option value="b2">Backblaze B2</option>
<option value="smb">SMB</option>
<option value="ftp">FTP</option>
<option value="webdav">WebDAV</option>
<option value="nextcloud">NextCloud</option>
//<option value="onedrive">OneDrive</option>
//<option value="google_drive">Google Drive</option>
</select>
</div>
</div>
@@ -725,6 +796,268 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
</div>
</template>
<template x-if="sourceType === 'webdav'">
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_host"
id="source_host"
x-model="sourceHost"
required
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="https://webdav.example.com/remote.php/webdav/"/>
</div>
</div>
<div class="sm:col-span-4">
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_user"
id="source_user"
x-model="sourceUser"
required
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"/>
</div>
</div>
<div class="sm:col-span-4">
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">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"
name="source_password"
id="source_password"
x-model="sourcePassword"
required
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="Password"/>
</div>
<input type="hidden" name="source_password" :value="sourcePassword"/>
</div>
</div>
</template>
<template x-if="sourceType === 'nextcloud'">
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_host"
id="source_host"
x-model="sourceHost"
required
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="https://nextcloud.example.com"/>
</div>
</div>
<div class="sm:col-span-4">
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_user"
id="source_user"
x-model="sourceUser"
required
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"/>
</div>
</div>
<div class="sm:col-span-4">
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password or App 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"
name="source_password"
id="source_password"
x-model="sourcePassword"
required
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="Password"/>
</div>
<input type="hidden" name="source_password" :value="sourcePassword"/>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">Using an app password is recommended for security</p>
</div>
</div>
</template>
// <template x-if="sourceType === 'onedrive'">
// <div class="sm:col-span-6 space-y-6">
// <div class="relative p-4 border border-yellow-300 bg-yellow-50 dark:bg-yellow-900/30 dark:border-yellow-700 rounded-lg mb-4">
// <div class="flex">
// <div class="flex-shrink-0">
// <i class="fas fa-info-circle text-yellow-400"></i>
// </div>
// <div class="ml-3">
// <h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">OneDrive Authentication</h3>
// <div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
// <p>OneDrive requires OAuth authentication. You'll need to:</p>
// <ol class="list-decimal ml-4 mt-1 space-y-1">
// <li>Register an application in the Microsoft Azure portal</li>
// <li>Add redirect URI: http://localhost:53682/</li>
// <li>Save the application details below</li>
// </ol>
// </div>
// </div>
// </div>
// </div>
// <div class="sm:col-span-4">
// <label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Client ID</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"
// name="source_client_id"
// id="source_client_id"
// x-model="sourceClientId"
// required
// 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="Azure application client ID"/>
// </div>
// </div>
// <div class="sm:col-span-4">
// <label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Client Secret</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"
// name="source_client_secret"
// id="source_client_secret"
// x-model="sourceClientSecret"
// required
// 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="Azure application client secret"/>
// </div>
// <input type="hidden" name="source_client_secret" :value="sourceClientSecret"/>
// </div>
// <div class="sm:col-span-4">
// <label for="source_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">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-hdd text-secondary-400 dark:text-secondary-600"></i>
// </div>
// <input
// type="text"
// name="source_drive_id"
// id="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="OneDrive drive ID (leave empty for default)"/>
// </div>
// <p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">For shared or business drives</p>
// </div>
// </div>
// </template>
// <template x-if="sourceType === 'google_drive'">
// <div class="sm:col-span-6 space-y-6">
// <div class="relative p-4 border border-yellow-300 bg-yellow-50 dark:bg-yellow-900/30 dark:border-yellow-700 rounded-lg mb-4">
// <div class="flex">
// <div class="flex-shrink-0">
// <i class="fas fa-info-circle text-yellow-400"></i>
// </div>
// <div class="ml-3">
// <h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Google Drive Authentication</h3>
// <div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
// <p>Google Drive requires OAuth authentication. You'll need to:</p>
// <ol class="list-decimal ml-4 mt-1 space-y-1">
// <li>Create a project in Google Cloud Console</li>
// <li>Enable Google Drive API</li>
// <li>Create OAuth credentials</li>
// <li>Add redirect URI: http://localhost:53682/</li>
// <li>Save the client details below</li>
// </ol>
// </div>
// </div>
// </div>
// </div>
// <div class="sm:col-span-4">
// <label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Client ID</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"
// name="source_client_id"
// id="source_client_id"
// x-model="sourceClientId"
// required
// 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 OAuth client ID"/>
// </div>
// </div>
// <div class="sm:col-span-4">
// <label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Client Secret</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"
// name="source_client_secret"
// id="source_client_secret"
// x-model="sourceClientSecret"
// required
// 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 OAuth client secret"/>
// </div>
// <input type="hidden" name="source_client_secret" :value="sourceClientSecret"/>
// </div>
// <div class="sm:col-span-4">
// <label for="source_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">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"
// name="source_team_drive"
// id="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="Team Drive ID (leave empty for personal drive)"/>
// </div>
// </div>
// </div>
// </template>
<div class="sm:col-span-6">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Source Path</label>
<div class="relative">
@@ -811,6 +1144,10 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<option value="b2">Backblaze B2</option>
<option value="smb">SMB</option>
<option value="ftp">FTP</option>
<option value="webdav">WebDAV</option>
<option value="nextcloud">NextCloud</option>
// <option value="onedrive">OneDrive</option>
// <option value="google_drive">Google Drive</option>
</select>
</div>
</div>
@@ -1255,15 +1592,277 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
</div>
</template>
<template x-if="destinationType === 'webdav'">
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="dest_host"
id="dest_host"
x-model="destHost"
required
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="https://webdav.example.com/remote.php/webdav/"/>
</div>
</div>
<div class="sm:col-span-4">
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="dest_user"
id="dest_user"
x-model="destUser"
required
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"/>
</div>
</div>
<div class="sm:col-span-4">
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">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"
name="dest_password"
id="dest_password"
x-model="destPassword"
required
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="Password"/>
</div>
<input type="hidden" name="dest_password" :value="destPassword"/>
</div>
</div>
</template>
<template x-if="destinationType === 'nextcloud'">
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="dest_host"
id="dest_host"
x-model="destHost"
required
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="https://nextcloud.example.com"/>
</div>
</div>
<div class="sm:col-span-4">
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="dest_user"
id="dest_user"
x-model="destUser"
required
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"/>
</div>
</div>
<div class="sm:col-span-4">
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password or App 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"
name="dest_password"
id="dest_password"
x-model="destPassword"
required
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="Password"/>
</div>
<input type="hidden" name="dest_password" :value="destPassword"/>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">Using an app password is recommended for security</p>
</div>
</div>
</template>
// <template x-if="destinationType === 'onedrive'">
// <div class="sm:col-span-6 space-y-6">
// <div class="relative p-4 border border-yellow-300 bg-yellow-50 dark:bg-yellow-900/30 dark:border-yellow-700 rounded-lg mb-4">
// <div class="flex">
// <div class="flex-shrink-0">
// <i class="fas fa-info-circle text-yellow-400"></i>
// </div>
// <div class="ml-3">
// <h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">OneDrive Authentication</h3>
// <div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
// <p>OneDrive requires OAuth authentication. You'll need to:</p>
// <ol class="list-decimal ml-4 mt-1 space-y-1">
// <li>Register an application in the Microsoft Azure portal</li>
// <li>Add redirect URI: http://localhost:53682/</li>
// <li>Save the application details below</li>
// </ol>
// </div>
// </div>
// </div>
// </div>
// <div class="sm:col-span-4">
// <label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Client ID</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"
// name="dest_client_id"
// id="dest_client_id"
// x-model="destClientId"
// required
// 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="Azure application client ID"/>
// </div>
// </div>
// <div class="sm:col-span-4">
// <label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Client Secret</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"
// name="dest_client_secret"
// id="dest_client_secret"
// x-model="destClientSecret"
// required
// 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="Azure application client secret"/>
// </div>
// <input type="hidden" name="dest_client_secret" :value="destClientSecret"/>
// </div>
// <div class="sm:col-span-4">
// <label for="dest_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">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-hdd text-secondary-400 dark:text-secondary-600"></i>
// </div>
// <input
// type="text"
// name="dest_drive_id"
// id="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="OneDrive drive ID (leave empty for default)"/>
// </div>
// <p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">For shared or business drives</p>
// </div>
// </div>
// </template>
// <template x-if="destinationType === 'google_drive'">
// <div class="sm:col-span-6 space-y-6">
// <div class="relative p-4 border border-yellow-300 bg-yellow-50 dark:bg-yellow-900/30 dark:border-yellow-700 rounded-lg mb-4">
// <div class="flex">
// <div class="flex-shrink-0">
// <i class="fas fa-info-circle text-yellow-400"></i>
// </div>
// <div class="ml-3">
// <h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Google Drive Authentication</h3>
// <div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
// <p>Google Drive requires OAuth authentication. You'll need to:</p>
// <ol class="list-decimal ml-4 mt-1 space-y-1">
// <li>Create a project in Google Cloud Console</li>
// <li>Enable Google Drive API</li>
// <li>Create OAuth credentials</li>
// <li>Add redirect URI: http://localhost:53682/</li>
// <li>Save the client details below</li>
// </ol>
// </div>
// </div>
// </div>
// </div>
// <div class="sm:col-span-4">
// <label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Client ID</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"
// name="dest_client_id"
// id="dest_client_id"
// x-model="destClientId"
// required
// 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 OAuth client ID"/>
// </div>
// </div>
// <div class="sm:col-span-4">
// <label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Client Secret</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"
// name="dest_client_secret"
// id="dest_client_secret"
// x-model="destClientSecret"
// required
// 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 OAuth client secret"/>
// </div>
// <input type="hidden" name="dest_client_secret" :value="destClientSecret"/>
// </div>
// <div class="sm:col-span-4">
// <label for="dest_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">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"
// name="dest_team_drive"
// id="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="Team Drive ID (leave empty for personal drive)"/>
// </div>
// </div>
// </div>
// </template>
<div class="sm:col-span-6">
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Destination 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"
name="destination_path"
<input
type="text"
name="destination_path"
id="destination_path"
x-model="destinationPath"
required
@@ -1299,10 +1898,28 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
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"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">Location to move files after successful transfer</p>
</div>
</div>
<div class="sm:col-span-6 mt-4">
<div class="flex items-center">
<input
type="checkbox"
id="delete_after_transfer"
x-model="deleteAfterTransfer"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
<input type="hidden" name="delete_after_transfer" :value="deleteAfterTransfer.toString()"/>
<label for="delete_after_transfer" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
Delete files from source after transfer
</label>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-exclamation-triangle text-yellow-500 mr-1"></i>
Original files will be permanently deleted from source after successful transfer
<span x-show="archiveEnabled">and archiving</span>
</p>
</div>
<div class="sm:col-span-6">
<label for="rclone_flags" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Rclone Flags</label>
<div class="relative">
@@ -1317,7 +1934,6 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
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-2 text-sm text-secondary-500 dark:text-secondary-400">Optional: Additional rclone flags for fine-tuning the transfer.</p>
</div>
</div>
</div>
+205 -4
View File
@@ -6,12 +6,204 @@ import (
"github.com/starfleetcptn/gomft/internal/db"
)
// Dialog component for confirmation dialogs
templ ConfigDialog(id string, title string, message string, confirmClass string, confirmText string, action string, configID uint, configName string) {
<div id={ id } 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-exclamation-triangle text-yellow-400 text-3xl"></i>
</div>
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
{ title }
</h3>
</div>
<div class="px-6 py-4 text-center">
<p class="text-secondary-700 dark:text-secondary-300">
{ message }
</p>
</div>
<div class="px-6 py-4 flex justify-end space-x-3">
<button type="button" class="btn-secondary" onclick={ hideConfigDialog(id) }>
Cancel
</button>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/configs/%d", configID) }
hx-target="closest li"
hx-swap="delete"
data-config-name={ configName }
data-config-id={ fmt.Sprint(configID) }
id={ fmt.Sprintf("delete-config-btn-%d", configID) }
onclick={ triggerConfigDelete(id, configID, configName) }>
{ confirmText }
</button>
</div>
</div>
</div>
}
script hideConfigDialog(id string) {
document.getElementById(id).classList.add("hidden");
}
script showConfigDialog(id string) {
document.getElementById(id).classList.remove("hidden");
}
script triggerConfigDelete(dialogId string, configID uint, configName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
// Store data in a way that's accessible to event handlers
window.lastDeletedConfig = {
id: configID,
name: configName
};
// Add custom marker to track this deletion
window.currentlyDeletingConfig = true;
}
type ConfigsData struct {
Configs []db.TransferConfig
}
templ Configs(ctx context.Context, data ConfigsData) {
@LayoutWithContext("Transfer Configurations", ctx) {
<script>
// Debug notification system
console.log("Configs template loaded, setting up notification system");
// Create a global notyf instance if it doesn't exist yet
if (!window.notyf) {
window.notyf = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'top',
},
types: [
{
type: 'success',
background: '#38c172',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
background: '#e3342f',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
});
console.log("Notyf initialized:", window.notyf);
}
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
// Check if this is a DELETE request by examining the URL and method
const path = event.detail.path;
const method = event.detail.verb;
// Pattern match for config deletions (e.g., /configs/123)
if (path && method === 'DELETE' && path.match(/^\/configs\/\d+$/)) {
// This is definitely a delete request - store this information
window.isConfigDeleteRequest = true;
}
});
// Track HTMX after-request events for config deletion
document.addEventListener('htmx:afterRequest', function(event) {
// Check for config deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerConfigDelete function
window.currentlyDeletingConfig ||
// Check flag from beforeRequest handler
window.isConfigDeleteRequest ||
// Check URL pattern directly from this event
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+$/) &&
event.detail.verb === 'DELETE');
// If this is a successful delete request, show notification
if (isDeleteRequest && event.detail.successful) {
let configName = "Unknown";
// Try multiple sources for config name
if (event.detail.elt && event.detail.elt.getAttribute) {
configName = event.detail.elt.getAttribute('data-config-name') || configName;
}
if (configName === "Unknown" && window.lastDeletedConfig) {
// Fallback to our stored config info
configName = window.lastDeletedConfig.name;
}
window.notyf.success(`Configuration "${configName}" deleted successfully`);
// Clear flags
window.currentlyDeletingConfig = false;
window.isConfigDeleteRequest = false;
window.lastDeletedConfig = null;
}
});
// Track HTMX error events for config deletion
document.addEventListener('htmx:responseError', function(event) {
// Similar logic as success but for errors
const isDeleteRequest =
window.currentlyDeletingConfig ||
window.isConfigDeleteRequest ||
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+$/) &&
event.detail.verb === 'DELETE');
if (isDeleteRequest) {
let configName = "Unknown";
// Try multiple sources for config name
if (event.detail.elt && event.detail.elt.getAttribute) {
configName = event.detail.elt.getAttribute('data-config-name') || configName;
}
if (configName === "Unknown" && window.lastDeletedConfig) {
// Fallback to our stored config info
configName = window.lastDeletedConfig.name;
}
let errorMsg = `Failed to delete configuration "${configName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = event.detail.xhr.responseText
// error message is a json object
const error = JSON.parse(errorMsg);
errorMsg = `Error: ${error.error}`;
}
window.notyf.error(errorMsg);
// Clear flags
window.currentlyDeletingConfig = false;
window.isConfigDeleteRequest = false;
window.lastDeletedConfig = null;
}
});
</script>
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
@@ -58,11 +250,20 @@ templ Configs(ctx context.Context, data ConfigsData) {
<i class="fas fa-edit mr-1"></i>
Edit
</a>
<!-- Add delete dialog for each configuration -->
@ConfigDialog(
fmt.Sprintf("delete-config-dialog-%d", config.ID),
"Delete Configuration",
fmt.Sprintf("Are you sure you want to delete the configuration '%s'? This cannot be undone.", config.Name),
"btn-danger",
"Delete",
"delete",
config.ID,
config.Name,
)
<button
hx-delete={ fmt.Sprintf("/configs/%d", config.ID) }
hx-confirm="Are you sure you want to delete this configuration?"
hx-target="closest li"
hx-swap="outerHTML"
type="button"
onclick={ showConfigDialog(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
class="btn-danger btn-sm">
<i class="fas fa-trash-alt mr-1"></i>
Delete
+262 -4
View File
@@ -6,12 +6,243 @@ import (
"github.com/starfleetcptn/gomft/internal/db"
)
// Dialog component for confirmation dialogs - copied from admin_tools.templ
templ JobDialog(id string, title string, message string, confirmClass string, confirmText string, action string, jobID uint, jobName string) {
<div id={ id } 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-exclamation-triangle text-yellow-400 text-3xl"></i>
</div>
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
{ title }
</h3>
</div>
<div class="px-6 py-4 text-center">
<p class="text-secondary-700 dark:text-secondary-300">
{ message }
</p>
</div>
<div class="px-6 py-4 flex justify-end space-x-3">
<button type="button" class="btn-secondary" onclick={ hideJobDialog(id) }>
Cancel
</button>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/jobs/%d", jobID) }
hx-target="closest li"
hx-swap="delete"
data-job-name={ jobName }
data-job-id={ fmt.Sprint(jobID) }
id={ fmt.Sprintf("delete-btn-%d", jobID) }
onclick={ triggerJobDelete(id, jobID, jobName) }>
{ confirmText }
</button>
</div>
</div>
</div>
}
script hideJobDialog(id string) {
document.getElementById(id).classList.add("hidden");
}
script showJobDialog(id string) {
document.getElementById(id).classList.remove("hidden");
}
script triggerJobDelete(dialogId string, jobID uint, jobName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
// Add debugging info
console.log(`Job deletion triggered for: ${jobName} (ID: ${jobID})`);
// Store data in a way that's accessible to event handlers
window.lastDeletedJob = {
id: jobID,
name: jobName
};
// Add custom marker to track this deletion
window.currentlyDeletingJob = true;
}
type JobsData struct {
Jobs []db.Job
}
templ Jobs(ctx context.Context, data JobsData) {
@LayoutWithContext("Transfer Jobs", ctx) {
<script>
// Debug notification system
console.log("Jobs template loaded, setting up notification system");
// Create a global notyf instance immediately
window.notyf = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'top',
},
types: [
{
type: 'success',
background: '#38c172',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
background: '#e3342f',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
});
console.log("Notyf initialized:", window.notyf);
// Global function to handle job running
window.runJob = function(button) {
// Get job data from button attributes
const jobId = button.getAttribute('data-job-id');
const jobName = button.getAttribute('data-job-name') || `Job #${jobId}`;
console.log(`Run job clicked for: ${jobName} (ID: ${jobId})`);
// No loading notification - just listen for the response
button.addEventListener('htmx:afterRequest', function(event) {
console.log("Run job request completed:", event.detail);
if (event.detail.successful) {
let displayName = jobName;
// Try to extract job name from response headers
if (event.detail.headers && event.detail.headers['HX-Job-Name']) {
displayName = event.detail.headers['HX-Job-Name'];
}
console.log(`Showing success notification for job: ${displayName}`);
window.notyf.success(`Job "${displayName}" started successfully!`);
} else {
let errorMsg = `Failed to start job "${jobName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
console.log(`Showing error notification: ${errorMsg}`);
window.notyf.error(errorMsg);
}
}, { once: true });
};
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
console.log("HTMX before request:", event.detail);
// Check if this is a DELETE request by examining the URL and method
const path = event.detail.path;
const method = event.detail.verb;
console.log(`Request path: ${path}, method: ${method}`);
// Pattern match for job deletions (e.g., /jobs/123)
if (path && method === 'DELETE' && path.match(/^\/jobs\/\d+$/)) {
console.log("Detected job deletion request via URL pattern");
// This is definitely a delete request - store this information
window.isJobDeleteRequest = true;
}
});
// Track HTMX after-request events for job deletion
document.addEventListener('htmx:afterRequest', function(event) {
console.log("HTMX after request:", event.detail);
// Check for job deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerJobDelete function
window.currentlyDeletingJob ||
// Check flag from beforeRequest handler
window.isJobDeleteRequest ||
// Check URL pattern directly from this event
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/jobs\/\d+$/) &&
event.detail.verb === 'DELETE');
console.log(`Is delete request: ${isDeleteRequest}`);
// If this is a successful delete request, show notification
if (isDeleteRequest && event.detail.successful) {
console.log("Delete request was successful");
let jobName = "Unknown";
// Try multiple sources for job name
if (event.detail.elt && event.detail.elt.getAttribute) {
jobName = event.detail.elt.getAttribute('data-job-name') || jobName;
}
if (jobName === "Unknown" && window.lastDeletedJob) {
// Fallback to our stored job info
jobName = window.lastDeletedJob.name;
}
console.log(`Showing success notification for deleted job: ${jobName}`);
window.notyf.success(`Job "${jobName}" deleted successfully`);
// Clear flags
window.currentlyDeletingJob = false;
window.isJobDeleteRequest = false;
window.lastDeletedJob = null;
}
});
// Track HTMX error events for job deletion
document.addEventListener('htmx:responseError', function(event) {
console.log("HTMX response error:", event.detail);
// Similar logic as success but for errors
const isDeleteRequest =
window.currentlyDeletingJob ||
window.isJobDeleteRequest ||
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/jobs\/\d+$/) &&
event.detail.verb === 'DELETE');
if (isDeleteRequest) {
console.log("Delete request failed");
let jobName = "Unknown";
// Try multiple sources for job name
if (event.detail.elt && event.detail.elt.getAttribute) {
jobName = event.detail.elt.getAttribute('data-job-name') || jobName;
}
if (jobName === "Unknown" && window.lastDeletedJob) {
// Fallback to our stored job info
jobName = window.lastDeletedJob.name;
}
let errorMsg = `Failed to delete job "${jobName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
console.log(`Showing error notification: ${errorMsg}`);
window.notyf.error(errorMsg);
// Clear flags
window.currentlyDeletingJob = false;
window.isJobDeleteRequest = false;
window.lastDeletedJob = null;
}
});
</script>
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
@@ -68,15 +299,34 @@ templ Jobs(ctx context.Context, data JobsData) {
}
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<button
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
hx-swap="none"
class="btn-primary btn-sm"
data-job-id={ fmt.Sprint(job.ID) }
data-job-name={ job.Name }
onclick="window.runJob(this)">
<i class="fas fa-play mr-1"></i>
Run Now
</button>
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="btn-secondary btn-sm">
<i class="fas fa-edit mr-1"></i>
Edit
</a>
<!-- Add delete dialog for each job -->
@JobDialog(
fmt.Sprintf("delete-job-dialog-%d", job.ID),
"Delete Job",
fmt.Sprintf("Are you sure you want to delete the job '%s'? This cannot be undone.", determineJobName(job)),
"btn-danger",
"Delete",
"delete",
job.ID,
determineJobName(job),
)
<button
hx-delete={ fmt.Sprintf("/jobs/%d", job.ID) }
hx-confirm="Are you sure you want to delete this job?"
hx-target="closest li"
hx-swap="outerHTML"
type="button"
onclick={ showJobDialog(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
class="btn-danger btn-sm">
<i class="fas fa-trash-alt mr-1"></i>
Delete
@@ -128,4 +378,12 @@ templ Jobs(ctx context.Context, data JobsData) {
</div>
</div>
}
}
// Helper function to determine the job name (reuse this logic to keep it consistent)
func determineJobName(job db.Job) string {
if job.Name != "" {
return job.Name
}
return job.Config.Name
}
+39
View File
@@ -47,6 +47,42 @@ templ LayoutWithContext(title string, ctx context.Context) {
<script src="/static/js/app.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<link rel="stylesheet" href="/static/css/app.css"/>
<!-- Notyf Toast Notifications -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.css" />
<script src="https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.js"></script>
<script>
// Initialize Notyf and make it available globally
document.addEventListener('DOMContentLoaded', function() {
window.notyfInstance = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'bottom',
},
types: [
{
type: 'success',
className: 'notyf__toast--success',
background: '#10B981',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
className: 'notyf__toast--error',
background: '#EF4444',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
});
});
</script>
<script>
tailwind.config = {
darkMode: 'class',
@@ -377,6 +413,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
</div>
}
<main class="flex-grow w-full max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 animate-fadeIn pb-mobile-nav">
<!-- Hidden notification area for HTMX targets -->
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
{ children... }
</main>
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
+10 -2
View File
@@ -5,10 +5,11 @@ go 1.24.0
require (
github.com/a-h/templ v0.3.833
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/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.35.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
@@ -16,22 +17,25 @@ require (
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
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
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/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.14.0 // indirect
@@ -40,4 +44,8 @@ require (
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+23 -4
View File
@@ -11,12 +11,20 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
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/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=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-gormigrate/gormigrate/v2 v2.1.3 h1:ei3Vq/rpPI/jCJY9mRHJAKg5vU+EhZyWhBAkaAomQuw=
github.com/go-gormigrate/gormigrate/v2 v2.1.3/go.mod h1:VJ9FIOBAur+NmQ8c4tDVwOuiJcgupTG105FexPFrXzA=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -32,6 +40,10 @@ 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/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/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=
@@ -46,8 +58,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -57,6 +67,9 @@ 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/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=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -93,8 +106,14 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
BIN
View File
Binary file not shown.
+147 -2
View File
@@ -8,7 +8,7 @@ import (
"time"
"github.com/starfleetcptn/gomft/internal/auth"
"gorm.io/driver/sqlite"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
@@ -66,6 +66,11 @@ type TransferConfig struct {
SourceDomain string `form:"source_domain"`
// FTP source fields
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
// General fields
FilePattern string `gorm:"default:'*'" form:"file_pattern"`
OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables
@@ -87,10 +92,16 @@ type TransferConfig struct {
DestDomain string `form:"dest_domain"`
// FTP destination fields
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
// General fields
ArchivePath string `form:"archive_path"`
ArchiveEnabled bool `gorm:"default:false" form:"archive_enabled"`
RcloneFlags string `form:"rclone_flags"`
DeleteAfterTransfer bool `gorm:"default:false" form:"delete_after_transfer"`
CreatedBy uint
User User `gorm:"foreignkey:CreatedBy"`
CreatedAt time.Time
@@ -135,7 +146,7 @@ func Initialize(dbPath string) (*DB, error) {
return nil, fmt.Errorf("failed to create database directory: %v", err)
}
// Open database connection
// Open database connection with modernc.org/sqlite driver
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %v", err)
@@ -425,6 +436,73 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
args = append(args, "passive", "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)
}
case "webdav":
args := []string{
"config", "create", sourceName, "webdav",
"url", config.SourceHost,
"user", config.SourceUser,
"pass", config.SourcePassword,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
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 "nextcloud":
args := []string{
"config", "create", sourceName, "webdav",
"url", config.SourceHost,
"user", config.SourceUser,
"pass", config.SourcePassword,
"vendor", "nextcloud",
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
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 "onedrive":
args := []string{
"config", "create", sourceName, "onedrive",
"client_id", config.SourceClientID,
"client_secret", config.SourceClientSecret,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.SourceDriveID != "" {
args = append(args, "drive_id", config.SourceDriveID)
}
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 "google_drive":
args := []string{
"config", "create", sourceName, "drive",
"client_id", config.SourceClientID,
"client_secret", config.SourceClientSecret,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.SourceTeamDrive != "" {
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)
@@ -546,6 +624,73 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
args = append(args, "passive", "true")
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
case "webdav":
args := []string{
"config", "create", destName, "webdav",
"url", config.DestHost,
"user", config.DestUser,
"pass", config.DestPassword,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
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 "nextcloud":
args := []string{
"config", "create", destName, "webdav",
"url", config.DestHost,
"user", config.DestUser,
"pass", config.DestPassword,
"vendor", "nextcloud",
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
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 "onedrive":
args := []string{
"config", "create", destName, "onedrive",
"client_id", config.DestClientID,
"client_secret", config.DestClientSecret,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.DestDriveID != "" {
args = append(args, "drive_id", config.DestDriveID)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
case "google_drive":
args := []string{
"config", "create", destName, "drive",
"client_id", config.DestClientID,
"client_secret", config.DestClientSecret,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.DestTeamDrive != "" {
args = append(args, "team_drive", config.DestTeamDrive)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
@@ -0,0 +1,63 @@
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
},
}
}
@@ -0,0 +1,19 @@
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
},
}
}
+17
View File
@@ -0,0 +1,17 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// InitMigrations initializes the migrations
func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
migrations := []*gormigrate.Migration{
// ... existing migrations
AddDeleteAfterTransferColumn(),
AddCloudStorageFields(),
}
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
}
+80 -8
View File
@@ -161,17 +161,37 @@ func (s *Scheduler) executeJob(jobID uint) {
"--config", configPath,
"size",
"--include", job.Config.FilePattern,
job.Config.SourcePath,
}
// Add source path with bucket for S3-compatible storage
var sourceSizePath string
if job.Config.SourceType == "s3" || job.Config.SourceType == "minio" || job.Config.SourceType == "b2" {
sourceSizePath = fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourceBucket)
if job.Config.SourcePath != "" && job.Config.SourcePath != "/" {
sourceSizePath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.SourcePath)
}
} else {
sourceSizePath = fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath)
}
sizeArgs = append(sizeArgs, sourceSizePath)
// Get the rclone path from the environment variable or use the default path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
output, err := exec.Command(rclonePath, sizeArgs...).CombinedOutput()
fmt.Printf("Running rclone size: %v\nOutput: %s\n", sizeArgs, output)
fmt.Printf("Running rclone size: %s %s\nOutput: %s\n", rclonePath, strings.Join(sizeArgs, " "), output)
if err != nil {
fmt.Printf("Error running rclone size: %v\nOutput: %s\n", err, output)
// Update job history with error
history.Status = "failed"
history.ErrorMessage = fmt.Sprintf("Size calculation error: %v\nOutput: %s", err, string(output))
history.EndTime = &startTime // Use start time as end time for a quick failure
if err := s.db.UpdateJobHistory(history); err != nil {
fmt.Printf("Error updating job history for job %d: %v\n", jobID, err)
}
return
}
@@ -195,9 +215,21 @@ func (s *Scheduler) executeJob(jobID uint) {
"--config", configPath,
"lsf",
"--include", job.Config.FilePattern,
fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath),
}
// Add source path with bucket for S3-compatible storage
var sourceLsPath string
if job.Config.SourceType == "s3" || job.Config.SourceType == "minio" || job.Config.SourceType == "b2" {
sourceLsPath = fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourceBucket)
if job.Config.SourcePath != "" && job.Config.SourcePath != "/" {
sourceLsPath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.SourcePath)
}
} else {
sourceLsPath = fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath)
}
listArgs = append(listArgs, sourceLsPath)
fmt.Printf("Listing files for job %d: rclone %s\n", jobID, strings.Join(listArgs, " "))
// Get the rclone path from the environment variable or use the default path
rclonePath := os.Getenv("RCLONE_PATH")
@@ -232,7 +264,7 @@ func (s *Scheduler) executeJob(jobID uint) {
// Prepare moveto command for transfer
transferArgs := []string{
"--config", configPath,
"moveto",
"copyto",
"--progress",
"--stats-one-line",
"--verbose",
@@ -240,8 +272,26 @@ func (s *Scheduler) executeJob(jobID uint) {
}
// Source and destination paths
sourcePath := fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourcePath, file)
destPath := fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, file)
var sourcePath, destPath string
// For S3, MinIO, and B2, include the bucket in the path
if job.Config.SourceType == "s3" || job.Config.SourceType == "minio" || job.Config.SourceType == "b2" {
sourcePath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourceBucket, file)
if job.Config.SourcePath != "" && job.Config.SourcePath != "/" {
sourcePath = fmt.Sprintf("source_%d:%s/%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.SourcePath, file)
}
} else {
sourcePath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourcePath, file)
}
if job.Config.DestinationType == "s3" || job.Config.DestinationType == "minio" || job.Config.DestinationType == "b2" {
destPath = fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestBucket, file)
if job.Config.DestinationPath != "" && job.Config.DestinationPath != "/" {
destPath = fmt.Sprintf("dest_%d:%s/%s/%s", job.Config.ID, job.Config.DestBucket, job.Config.DestinationPath, file)
}
} else {
destPath = fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, file)
}
// Add output filename pattern if specified
if job.Config.OutputPattern != "" {
@@ -292,9 +342,18 @@ func (s *Scheduler) executeJob(jobID uint) {
"--config", configPath,
"copyto",
sourcePath,
fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.ArchivePath, file),
}
// Construct archive path with bucket if needed
var archiveDest string
if job.Config.SourceType == "s3" || job.Config.SourceType == "minio" || job.Config.SourceType == "b2" {
archiveDest = fmt.Sprintf("source_%d:%s/%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.ArchivePath, file)
} else {
archiveDest = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.ArchivePath, file)
}
archiveArgs = append(archiveArgs, archiveDest)
fmt.Printf("Executing rclone archive command for job %d, file %s: rclone %s\n",
jobID, file, strings.Join(archiveArgs, " "))
// Get the rclone path from the environment variable or use the default path
@@ -313,8 +372,21 @@ func (s *Scheduler) executeJob(jobID uint) {
fmt.Printf("Warning: Error archiving file %s for job %d: %v\n", file, jobID, archiveErr)
transferErrors = append(transferErrors,
fmt.Sprintf("Archive error for file %s: %v", file, archiveErr))
}
}
if job.Config.DeleteAfterTransfer {
fmt.Printf("Deleting file %s for job %d\n", file, jobID)
deleteArgs := []string{
"--config", configPath,
"deletefile",
sourcePath, }
deleteCmd := exec.Command(rclonePath, deleteArgs...)
deleteOutput, deleteErr := deleteCmd.CombinedOutput()
fmt.Printf("Output for file %s: %s\n", file, string(deleteOutput))
if deleteErr != nil {
fmt.Printf("Error deleting file %s for job %d: %v\n", file, jobID, deleteErr)
transferErrors = append(transferErrors,
fmt.Sprintf("Archive error for file %s: %v", file, archiveErr))
fmt.Sprintf("Delete error for file %s: %v", file, deleteErr))
}
}
}
+42 -2
View File
@@ -248,8 +248,48 @@ func (h *Handlers) HandleAPIDeleteJob(c *gin.Context) {
// HandleAPIRunJob handles the API run job request
func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API run job handler stub"})
id := c.Param("id")
userID := c.GetUint("userID")
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user owns this job
if job.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to run this job"})
return
}
}
// Determine job name for response
jobName := job.Name
if jobName == "" {
// If job name is empty, try to get config name
var config db.TransferConfig
if err := h.DB.First(&config, job.ConfigID).Error; err == nil {
jobName = config.Name
} else {
jobName = fmt.Sprintf("Job #%d", job.ID)
}
}
// Run the job immediately using the scheduler
if err := h.Scheduler.RunJobNow(job.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run job: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Job started successfully",
"jobId": job.ID,
"jobName": jobName,
})
}
// HandleAPIHistory handles the API history request
+27 -7
View File
@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
@@ -274,7 +275,8 @@ func (h *Handlers) HandleRunJob(c *gin.Context) {
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
c.Header("Content-Type", "text/html")
c.String(http.StatusNotFound, "<script>window.notyfInstance.error('Job not found')</script>")
return
}
@@ -283,19 +285,37 @@ func (h *Handlers) HandleRunJob(c *gin.Context) {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to run this job"})
c.Header("Content-Type", "text/html")
c.String(http.StatusForbidden, "<script>window.notyfInstance.error('You do not have permission to run this job')</script>")
return
}
}
// Determine job name for response
jobName := job.Name
if jobName == "" {
// If job name is empty, try to get config name
var config db.TransferConfig
if err := h.DB.First(&config, job.ConfigID).Error; err == nil {
jobName = config.Name
} else {
jobName = fmt.Sprintf("Job #%d", job.ID)
}
}
// Run the job immediately using the scheduler
if err := h.Scheduler.RunJobNow(job.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run job: " + err.Error()})
c.Header("Content-Type", "text/html")
errorMsg := fmt.Sprintf("<script>window.notyfInstance.error('Failed to run job: %s')</script>", err.Error())
c.String(http.StatusInternalServerError, errorMsg)
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Job started successfully",
"jobId": job.ID,
})
// Set custom header with job name for HTMX to use in the toast notification
c.Header("HX-Job-Name", jobName)
c.Header("Content-Type", "text/html")
// Return HTML with JavaScript to trigger the notification
successScript := fmt.Sprintf("<script>window.notyfInstance.success('Job \"%s\" has been started successfully')</script>", jobName)
c.String(http.StatusOK, successScript)
}
+13 -7
View File
@@ -1,8 +1,11 @@
package main
import (
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"time"
@@ -16,6 +19,9 @@ import (
"golang.org/x/crypto/bcrypt"
)
//go:embed static
var staticFiles embed.FS
func main() {
// Set Gin to release mode
gin.SetMode(gin.ReleaseMode)
@@ -34,10 +40,6 @@ func main() {
dirs := []string{
cfg.DataDir,
cfg.BackupDir,
// "templates",
"static",
"static/css",
"static/js",
}
for _, dir := range dirs {
@@ -101,9 +103,13 @@ func main() {
}))
router.Use(gin.Recovery())
// Serve static files
router.Static("/static", "./static")
log.Printf("Static file serving configured")
// Serve embedded static files
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
log.Fatalf("Failed to create sub-filesystem: %v", err)
}
router.StaticFS("/static", http.FS(staticFS))
log.Printf("Embedded static files configured for serving")
// Initialize web handlers
webHandler, err := web.NewHandler(database, scheduler, cfg.JWTSecret, dbPath, cfg.BackupDir, cfg)