mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-18 20:40:59 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11c03e22e | ||
|
|
64323dd9ee | ||
|
|
d5b0a686e0 | ||
|
|
558e81c7e8 | ||
|
|
d6fa0c1603 | ||
|
|
d1967b4402 | ||
|
|
3193bf5111 | ||
|
|
a8b4588ecb | ||
|
|
ed81b2c9be | ||
|
|
16b234b2d3 | ||
|
|
5eac95818e | ||
|
|
6d0c215c38 | ||
|
|
31410984cb | ||
|
|
4b625b7120 | ||
|
|
38461c7b7d | ||
|
|
6545d26a93 | ||
|
|
dfbcb021f5 | ||
|
|
db55c92909 | ||
|
|
fcf6ee25a8 | ||
|
|
3b9ff3b6a8 | ||
|
|
43f1772038 | ||
|
|
bcf55fb973 | ||
|
|
db4d0e018f | ||
|
|
bee14b7de0 | ||
|
|
6c96cdbec1 | ||
|
|
339299e08e | ||
|
|
1b1d043d50 | ||
|
|
c3a22333f7 | ||
|
|
ea057f14dc | ||
|
|
0da968fd5a | ||
|
|
7ab3403bca | ||
|
|
9a5ad3ec26 | ||
|
|
8f06776b53 | ||
|
|
248f9cce51 | ||
|
|
9316eb0a53 | ||
|
|
a01edc13cb | ||
|
|
2b2c305ccb | ||
|
|
6f1e877341 | ||
|
|
a6828f4c0e | ||
|
|
6e19df099c | ||
|
|
6503c19898 | ||
|
|
81557840dd | ||
|
|
2017cd8b7d | ||
|
|
f4c8c2121c | ||
|
|
2fbbb41f3f | ||
|
|
30ded57293 |
@@ -44,7 +44,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: starfleetcptn/gomft
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
|||||||
+32
@@ -44,9 +44,41 @@ Thumbs.db
|
|||||||
|
|
||||||
# Ignore all Go files in the components directory
|
# Ignore all Go files in the components directory
|
||||||
components/*.go
|
components/*.go
|
||||||
|
!components/components.go
|
||||||
|
|
||||||
|
components/providers/*.go
|
||||||
|
!components/providers/providers.go
|
||||||
|
|
||||||
|
components/providers/source/*.go
|
||||||
|
!components/providers/source/source.go
|
||||||
|
|
||||||
|
components/providers/destination/*.go
|
||||||
|
!components/providers/destination/destination.go
|
||||||
|
|
||||||
|
components/providers/common/*.go
|
||||||
|
!components/providers/common/common.go
|
||||||
|
|
||||||
# Ignore the data directory
|
# Ignore the data directory
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Ignore .env files
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
|
||||||
# Ignore the tmp directory
|
# Ignore the tmp directory
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
|
# Ignore the configs directory
|
||||||
|
configs/
|
||||||
|
|
||||||
|
# Ignore the backups directory
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# Ignore Dirs
|
||||||
|
/source/
|
||||||
|
/destination/
|
||||||
|
/archive/
|
||||||
|
|
||||||
|
|
||||||
|
# Ignore binaries
|
||||||
|
gomft
|
||||||
|
|||||||
+2
-2
@@ -47,8 +47,8 @@ COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
|
|||||||
COPY static/ /app/static/
|
COPY static/ /app/static/
|
||||||
COPY components/ /app/components/
|
COPY components/ /app/components/
|
||||||
|
|
||||||
# Create data directory
|
# Create data and backup directories
|
||||||
RUN mkdir -p /app/data/gomft
|
RUN mkdir -p /app/data /app/backups
|
||||||
|
|
||||||
# Set executable permissions
|
# Set executable permissions
|
||||||
RUN chmod +x /app/gomft
|
RUN chmod +x /app/gomft
|
||||||
|
|||||||
@@ -19,26 +19,54 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||

|

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

|

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

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

|
||||||
|
*Admin dashboard with log viewer and system management tools*
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
|
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
|
||||||
- Amazon S3
|
- Amazon S3
|
||||||
- MinIO
|
- MinIO
|
||||||
- Backblaze B2
|
- NextCloud
|
||||||
- Azure Blob Storage
|
- WebDAV
|
||||||
- Google Cloud Storage
|
|
||||||
- SFTP
|
- SFTP
|
||||||
- FTP
|
- FTP
|
||||||
- SMB/CIFS shares
|
- SMB/CIFS shares
|
||||||
- Local filesystem
|
- Local filesystem
|
||||||
- And more via rclone
|
- And more via rclone
|
||||||
|
- **Webhook Notifications**: Receive real-time notifications of job events:
|
||||||
|
- Configurable webhook URLs
|
||||||
|
- HMAC-SHA256 authentication with secrets
|
||||||
|
- Custom HTTP headers
|
||||||
|
- Selectable events (job success, job failure)
|
||||||
|
- Detailed JSON payload with job information
|
||||||
- **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options
|
- **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options
|
||||||
- **Transfer Monitoring**: Real-time status updates and detailed transfer logs with bytes and files transferred statistics
|
- **Transfer Monitoring**: Real-time status updates and detailed transfer logs with bytes and files transferred statistics
|
||||||
|
- **File Metadata Tracking**: Complete history and status of all transferred files with detailed information:
|
||||||
|
- Process status (processed, archived, deleted)
|
||||||
|
- File size and hash information
|
||||||
|
- Advanced search and filtering capabilities
|
||||||
|
- Metadata retention for compliance and auditing
|
||||||
|
- Detailed file view with processing timestamps and job association
|
||||||
|
- Powerful filtering by status, filename, job, and date ranges
|
||||||
|
- Advanced search interface with multiple criteria
|
||||||
|
- Bulk management and record deletion capabilities
|
||||||
|
- Responsive design with mobile-friendly interface
|
||||||
|
- **Multi-threaded File Transfers**: Significantly improve performance with concurrent file processing:
|
||||||
|
- Configurable number of concurrent transfers (1-32) per job
|
||||||
|
- Automatic queue management to prevent system overload
|
||||||
|
- Independent configuration for each transfer job
|
||||||
|
- Optimized for both high-volume small files and large file transfers
|
||||||
|
- Maximizes bandwidth utilization for cloud storage providers
|
||||||
- **Web Interface**: User-friendly interface for managing transfers, built with Templ components
|
- **Web Interface**: User-friendly interface for managing transfers, built with Templ components
|
||||||
- **File Pattern Matching**: Support for file patterns to filter files during transfers
|
- **File Pattern Matching**: Support for file patterns to filter files during transfers
|
||||||
- **File Output Patterns**: Dynamic naming of destination files using patterns with date variables
|
- **File Output Patterns**: Dynamic naming of destination files using patterns with date variables
|
||||||
@@ -49,6 +77,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
|
- **Password Recovery**: Self-service password reset via email with secure token-based authentication
|
||||||
- **User Profile Management**: Personal settings including theme preferences
|
- **User Profile Management**: Personal settings including theme preferences
|
||||||
- **Modern UI**: Built with Templ, HTMX and Tailwind CSS for a responsive experience
|
- **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
|
## Prerequisites
|
||||||
|
|
||||||
@@ -58,6 +88,8 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
### Standard Installation
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/starfleetcptn/gomft.git
|
git clone https://github.com/starfleetcptn/gomft.git
|
||||||
@@ -74,50 +106,153 @@ go mod download
|
|||||||
go build -o gomft
|
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 \
|
||||||
|
-v /path/to/backups:/app/backups \
|
||||||
|
starfleetcptn/gomft:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Access the web interface at `http://localhost:8080`
|
||||||
|
|
||||||
|
#### Docker Compose Example
|
||||||
|
|
||||||
|
For production deployments, you can use Docker Compose with environment variables:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
gomft:
|
||||||
|
image: starfleetcptn/gomft:latest
|
||||||
|
container_name: gomft
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./backups:/app/backups
|
||||||
|
environment:
|
||||||
|
- TZ=UTC
|
||||||
|
- SERVER_ADDRESS=:8080
|
||||||
|
- DATA_DIR=/app/data
|
||||||
|
- BACKUP_DIR=/app/backups
|
||||||
|
- JWT_SECRET=change_this_to_a_secure_random_string
|
||||||
|
- BASE_URL=http://localhost:8080
|
||||||
|
- EMAIL_ENABLED=true
|
||||||
|
- EMAIL_HOST=smtp.example.com
|
||||||
|
- EMAIL_PORT=587
|
||||||
|
- EMAIL_FROM_EMAIL=gomft@example.com
|
||||||
|
- EMAIL_FROM_NAME=GoMFT
|
||||||
|
- EMAIL_ENABLE_TLS=true
|
||||||
|
- EMAIL_REQUIRE_AUTH=true
|
||||||
|
- EMAIL_USERNAME=smtp_username
|
||||||
|
- EMAIL_PASSWORD=smtp_password
|
||||||
|
# Logging configuration
|
||||||
|
- LOGS_DIR=/app/data/logs
|
||||||
|
- LOG_MAX_SIZE=10
|
||||||
|
- LOG_MAX_BACKUPS=5
|
||||||
|
- LOG_MAX_AGE=30
|
||||||
|
- LOG_COMPRESS=true
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, you can mount your own .env file to the container:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
gomft:
|
||||||
|
image: starfleetcptn/gomft:latest
|
||||||
|
container_name: gomft
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./backups:/app/backups
|
||||||
|
- ./.env:/app/.env
|
||||||
|
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
|
## Configuration
|
||||||
|
|
||||||
GoMFT uses a configuration file located at `./data/gomft/config.json`. On first run, a default configuration will be created:
|
GoMFT uses an environment file located at `.env` in the root directory of the application. On first run, a default configuration will be created:
|
||||||
|
|
||||||
```json
|
```
|
||||||
{
|
SERVER_ADDRESS=:8080
|
||||||
"server_address": ":8080",
|
DATA_DIR=/app/data
|
||||||
"data_dir": "./data/gomft",
|
BACKUP_DIR=/app/backups
|
||||||
"backup_dir": "./data/gomft/backups",
|
JWT_SECRET=change_this_to_a_secure_random_string
|
||||||
"jwt_secret": "your-secret-key",
|
BASE_URL=http://localhost:8080
|
||||||
"base_url": "http://localhost:8080",
|
|
||||||
"email": {
|
# Email configuration
|
||||||
"enabled": false,
|
EMAIL_ENABLED=true
|
||||||
"host": "smtp.example.com",
|
EMAIL_HOST=smtp.example.com
|
||||||
"port": 587,
|
EMAIL_PORT=587
|
||||||
"username": "user@example.com",
|
EMAIL_FROM_EMAIL=gomft@example.com
|
||||||
"password": "your-password",
|
EMAIL_FROM_NAME=GoMFT
|
||||||
"from_email": "gomft@example.com",
|
EMAIL_REPLY_TO=
|
||||||
"from_name": "GoMFT",
|
EMAIL_ENABLE_TLS=true
|
||||||
"reply_to": "",
|
EMAIL_REQUIRE_AUTH=true
|
||||||
"enable_tls": true,
|
EMAIL_USERNAME=smtp_username
|
||||||
"require_auth": true
|
EMAIL_PASSWORD=smtp_password
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration Options
|
### Configuration Options
|
||||||
|
|
||||||
- `server_address`: The address and port to run the server on
|
- `SERVER_ADDRESS`: The address and port to run the server on
|
||||||
- `data_dir`: Directory for storing application data
|
- `DATA_DIR`: Directory for storing application data (database and configs)
|
||||||
- `backup_dir`: Directory for storing database backups
|
- `BACKUP_DIR`: Directory for storing database backups
|
||||||
- `jwt_secret`: Secret key for JWT token generation
|
- `JWT_SECRET`: Secret key for JWT token generation
|
||||||
- `base_url`: Base URL for generating links in emails (e.g., password reset links)
|
- `BASE_URL`: Base URL for generating links in emails (e.g., password reset links)
|
||||||
- `email`: Email configuration settings for system notifications and password resets
|
- Email configuration settings for system notifications and password resets:
|
||||||
- `enabled`: Set to `true` to enable email functionality
|
- `EMAIL_ENABLED`: Set to `true` to enable email functionality
|
||||||
- `host`: SMTP server hostname
|
- `EMAIL_HOST`: SMTP server hostname
|
||||||
- `port`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
|
- `EMAIL_PORT`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
|
||||||
- `username`: Username for SMTP authentication
|
- `EMAIL_USERNAME`: Username for SMTP authentication
|
||||||
- `password`: Password for SMTP authentication
|
- `EMAIL_PASSWORD`: Password for SMTP authentication
|
||||||
- `from_email`: Email address used as sender
|
- `EMAIL_FROM_EMAIL`: Email address used as sender
|
||||||
- `from_name`: Name displayed as the sender
|
- `EMAIL_FROM_NAME`: Name displayed as the sender
|
||||||
- `reply_to`: Optional reply-to email address
|
- `EMAIL_REPLY_TO`: Optional reply-to email address
|
||||||
- `enable_tls`: Set to `true` to use TLS for secure email transmission
|
- `EMAIL_ENABLE_TLS`: Set to `true` to use TLS for secure email transmission
|
||||||
- `require_auth`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
|
- `EMAIL_REQUIRE_AUTH`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
|
||||||
|
|
||||||
|
### Logging Configuration
|
||||||
|
|
||||||
|
GoMFT provides configurable logging with rotation support through the following environment variables:
|
||||||
|
|
||||||
|
- `LOGS_DIR`: Directory where log files are stored (default: `./data/logs`)
|
||||||
|
- `LOG_MAX_SIZE`: Maximum size in megabytes for each log file before rotation (default: `10`)
|
||||||
|
- `LOG_MAX_BACKUPS`: Number of old log files to retain (default: `5`)
|
||||||
|
- `LOG_MAX_AGE`: Maximum number of days to retain old log files (default: `30`)
|
||||||
|
- `LOG_COMPRESS`: Whether to compress rotated log files (default: `true`)
|
||||||
|
- `LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`)
|
||||||
|
- `error`: Only show errors and critical issues
|
||||||
|
- `info`: Show errors and general operational information (default)
|
||||||
|
- `debug`: Show all messages including detailed debugging information
|
||||||
|
|
||||||
|
Log files contain detailed information about file transfers, job execution, and system operations, which can be useful for troubleshooting and auditing.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -137,6 +272,11 @@ GoMFT uses a configuration file located at `./data/gomft/config.json`. On first
|
|||||||
- Navigate to "Transfer Configs" section
|
- Navigate to "Transfer Configs" section
|
||||||
- Configure source and destination locations with connection details
|
- Configure source and destination locations with connection details
|
||||||
- Set file patterns and archive options as needed
|
- Set file patterns and archive options as needed
|
||||||
|
- Configure performance settings:
|
||||||
|
- Set "Concurrent Transfers" slider to optimize throughput
|
||||||
|
- Use higher values (8-16) for many small files or fast networks
|
||||||
|
- Use lower values (1-4) for large files or limited bandwidth
|
||||||
|
- Consider source/destination system capabilities when setting
|
||||||
|
|
||||||
5. Create jobs using your configurations:
|
5. Create jobs using your configurations:
|
||||||
- Navigate to "Jobs" section
|
- Navigate to "Jobs" section
|
||||||
@@ -149,6 +289,30 @@ GoMFT uses a configuration file located at `./data/gomft/config.json`. On first
|
|||||||
- Check detailed transfer history with performance metrics
|
- Check detailed transfer history with performance metrics
|
||||||
- View job run details including any error messages
|
- View job run details including any error messages
|
||||||
|
|
||||||
|
7. Configure webhook notifications:
|
||||||
|
- Enable webhooks in job settings to receive notifications
|
||||||
|
- Provide a valid webhook URL where notifications will be sent
|
||||||
|
- Optionally set a webhook secret for HMAC-SHA256 signature verification
|
||||||
|
- Configure custom HTTP headers in JSON format if needed
|
||||||
|
- Choose notification triggers (job success, job failure, or both)
|
||||||
|
- Test your webhook integration with manual job runs
|
||||||
|
|
||||||
|
8. Manage file metadata:
|
||||||
|
- Navigate to the "Files" section to view all processed files
|
||||||
|
- Use filters to quickly find files by status, job ID, or filename
|
||||||
|
- Click on any file to view detailed metadata including timestamps, size, and hash
|
||||||
|
- Use the advanced search page for complex queries with multiple criteria
|
||||||
|
- Delete file metadata records when no longer needed
|
||||||
|
- View files associated with specific jobs by navigating from the job details
|
||||||
|
|
||||||
|
9. Utilize admin tools (administrators only):
|
||||||
|
- Access the "Admin Tools" section from the navigation menu
|
||||||
|
- View system statistics and server information
|
||||||
|
- Create and manage database backups
|
||||||
|
- Browse and download system log files with the integrated log viewer
|
||||||
|
- Perform database maintenance and optimization tasks
|
||||||
|
- View webhook documentation and integration details
|
||||||
|
|
||||||
### User Management
|
### User Management
|
||||||
|
|
||||||
GoMFT uses a role-based access control system:
|
GoMFT uses a role-based access control system:
|
||||||
@@ -171,7 +335,6 @@ User management features:
|
|||||||
- Local filesystem
|
- Local filesystem
|
||||||
- Amazon S3
|
- Amazon S3
|
||||||
- MinIO (S3-compatible storage)
|
- MinIO (S3-compatible storage)
|
||||||
- Backblaze B2
|
|
||||||
- SFTP
|
- SFTP
|
||||||
- FTP
|
- FTP
|
||||||
- SMB/CIFS shares
|
- SMB/CIFS shares
|
||||||
@@ -190,12 +353,29 @@ User management features:
|
|||||||
- File patterns for filtering (e.g., `*.txt`, `data_*.csv`)
|
- File patterns for filtering (e.g., `*.txt`, `data_*.csv`)
|
||||||
- Output patterns for dynamic naming
|
- Output patterns for dynamic naming
|
||||||
- Archive options for transferred files
|
- Archive options for transferred files
|
||||||
|
- Skip already processed files to avoid duplicates
|
||||||
|
- Concurrent file transfers (configurable per job)
|
||||||
|
|
||||||
4. **Schedule Options**:
|
4. **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**:
|
||||||
- Cron expressions for flexible scheduling
|
- Cron expressions for flexible scheduling
|
||||||
- Manual execution
|
- Manual execution
|
||||||
- Enable/disable schedules
|
- Enable/disable schedules
|
||||||
|
|
||||||
|
6. **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
|
||||||
|
- **Flexible Configuration**: Configure different webhooks for different jobs
|
||||||
|
- **Event Selection**: Choose to send notifications on success, failure, or both
|
||||||
|
- **Detailed Payload**: Rich JSON payload with complete job execution details
|
||||||
|
|
||||||
### Email Notifications
|
### Email Notifications
|
||||||
|
|
||||||
GoMFT supports email notifications for various features:
|
GoMFT supports email notifications for various features:
|
||||||
@@ -210,9 +390,90 @@ GoMFT supports email notifications for various features:
|
|||||||
|
|
||||||
To configure email functionality:
|
To configure email functionality:
|
||||||
|
|
||||||
1. Edit the `config.json` file and provide your SMTP server details
|
1. Edit the `.env` file and provide your SMTP server details
|
||||||
2. Set `"enabled": true` in the email configuration section
|
2. Set `EMAIL_ENABLED=true` in the email configuration section
|
||||||
3. Ensure the `base_url` setting is configured correctly for your deployment
|
3. Ensure the `BASE_URL` setting is configured correctly for your deployment
|
||||||
|
|
||||||
|
### Webhook Integration
|
||||||
|
|
||||||
|
GoMFT can send webhook notifications to external systems when jobs complete. This allows integration with monitoring tools, chat applications, custom notification systems, or workflow automation platforms.
|
||||||
|
|
||||||
|
#### Webhook Payload Structure
|
||||||
|
|
||||||
|
Webhook notifications are sent as HTTP POST requests with a JSON payload containing detailed information about the job execution:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "job_execution",
|
||||||
|
"job_id": 123,
|
||||||
|
"job_name": "Daily Backup",
|
||||||
|
"config_id": 456,
|
||||||
|
"config_name": "S3 to Local Backup",
|
||||||
|
"status": "completed",
|
||||||
|
"start_time": "2023-07-14T15:30:00Z",
|
||||||
|
"end_time": "2023-07-14T15:35:42Z",
|
||||||
|
"duration_seconds": 342,
|
||||||
|
"bytes_transferred": 1048576,
|
||||||
|
"files_transferred": 25,
|
||||||
|
"history_id": 789,
|
||||||
|
"source": {
|
||||||
|
"type": "s3",
|
||||||
|
"path": "my-bucket/data"
|
||||||
|
},
|
||||||
|
"destination": {
|
||||||
|
"type": "local",
|
||||||
|
"path": "/backups/data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For failed transfers, additional error information is included:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"error_message": "Permission denied accessing destination path"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Webhook Authentication
|
||||||
|
|
||||||
|
When a webhook secret is configured, GoMFT signs the payload using HMAC-SHA256 and includes the signature in the `X-Hub-Signature-256` header. To verify the webhook:
|
||||||
|
|
||||||
|
1. Compute the HMAC-SHA256 of the raw request body using your shared secret
|
||||||
|
2. Compare it with the value in the `X-Hub-Signature-256` header
|
||||||
|
3. Process the webhook only if the signatures match
|
||||||
|
|
||||||
|
This ensures that webhook requests are authentic and haven't been tampered with.
|
||||||
|
|
||||||
|
### Admin Tools
|
||||||
|
|
||||||
|
GoMFT provides a comprehensive set of administrative tools for system management and monitoring:
|
||||||
|
|
||||||
|
#### Log Viewer
|
||||||
|
|
||||||
|
The Admin Tools panel includes an integrated log viewer with the following features:
|
||||||
|
|
||||||
|
- **Log File Browser**: View a list of all available log files in the system
|
||||||
|
- **Real-time Log Viewing**: View log file contents directly in the web interface
|
||||||
|
- **Refresh Function**: Update the log list and content with the latest information
|
||||||
|
- **User-friendly Interface**: Clean, readable presentation with custom scrolling
|
||||||
|
- **Dark Mode Support**: Consistent theming with the rest of the application
|
||||||
|
- **Navigation**: Easily switch between different log files
|
||||||
|
|
||||||
|
This log viewer allows administrators to:
|
||||||
|
- Monitor system activity and diagnose issues without requiring server access
|
||||||
|
- View application logs, scheduler logs, and transfer logs in one place
|
||||||
|
- Track down errors and warning messages in real-time
|
||||||
|
|
||||||
|
#### Database Management
|
||||||
|
|
||||||
|
The Admin Tools interface also includes database management capabilities:
|
||||||
|
- Create and manage database backups
|
||||||
|
- Restore from previous backups
|
||||||
|
- Download backups for safekeeping
|
||||||
|
- View system statistics
|
||||||
|
- Optimize the database with maintenance tools
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -244,6 +505,7 @@ To configure email functionality:
|
|||||||
- **Authentication**: JWT (JSON Web Tokens)
|
- **Authentication**: JWT (JSON Web Tokens)
|
||||||
- **Database**: GORM with SQLite
|
- **Database**: GORM with SQLite
|
||||||
- **File Transfer**: rclone
|
- **File Transfer**: rclone
|
||||||
|
- **Deployment**: Docker containerization and traditional installation
|
||||||
|
|
||||||
### Building from Source
|
### Building from Source
|
||||||
|
|
||||||
@@ -274,3 +536,23 @@ air
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - see LICENSE file for details
|
MIT License - see LICENSE file for details
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
GoMFT uses the following directory structure:
|
||||||
|
|
||||||
|
- `/app/data`: Main application data directory
|
||||||
|
- Contains the SQLite database (`gomft.db`)
|
||||||
|
- Contains rclone configurations in `/app/data/configs`
|
||||||
|
- Contains log files in `/app/data/logs`
|
||||||
|
- `/app/backups`: Database backup directory
|
||||||
|
|
||||||
|
When using Docker, you should mount volumes to these locations:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /host/path/data:/app/data # For all application data
|
||||||
|
- /host/path/backups:/app/backups # For database backups
|
||||||
|
```
|
||||||
|
|
||||||
|
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ type BackupFile struct {
|
|||||||
ModTime time.Time
|
ModTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogFile struct {
|
||||||
|
Name string
|
||||||
|
Size string
|
||||||
|
ModTime time.Time
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
type AdminToolsData struct {
|
type AdminToolsData struct {
|
||||||
JobHistoryCount int
|
JobHistoryCount int
|
||||||
DatabaseSize string
|
DatabaseSize string
|
||||||
@@ -26,6 +33,9 @@ type AdminToolsData struct {
|
|||||||
BackupPath string
|
BackupPath string
|
||||||
MaintenanceMessage string
|
MaintenanceMessage string
|
||||||
BackupFiles []BackupFile
|
BackupFiles []BackupFile
|
||||||
|
LogFiles []LogFile
|
||||||
|
LogContent string
|
||||||
|
CurrentLogFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dialog component for confirmation dialogs
|
// Dialog component for confirmation dialogs
|
||||||
@@ -131,6 +141,64 @@ templ BackupActionDialog(id string, title string, message string, confirmClass s
|
|||||||
|
|
||||||
templ AdminTools(ctx context.Context, data AdminToolsData) {
|
templ AdminTools(ctx context.Context, data AdminToolsData) {
|
||||||
@LayoutWithContext("Admin Tools", ctx) {
|
@LayoutWithContext("Admin Tools", ctx) {
|
||||||
|
<style>
|
||||||
|
/* Custom scrollbar styles - more aggressive */
|
||||||
|
.log-scrollbar {
|
||||||
|
scrollbar-width: thin !important; /* Firefox */
|
||||||
|
scrollbar-color: rgba(0,0,0,0.3) rgba(0,0,0,0.1) !important; /* Firefox */
|
||||||
|
overflow: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .log-scrollbar {
|
||||||
|
scrollbar-color: rgba(255,255,255,0.3) rgba(255,255,255,0.1) !important; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 10px !important;
|
||||||
|
height: 10px !important;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0,0,0,0.1) !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0,0,0,0.3) !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
border: 2px solid transparent !important;
|
||||||
|
background-clip: content-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0,0,0,0.5) !important;
|
||||||
|
border: 2px solid transparent !important;
|
||||||
|
background-clip: content-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .log-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255,255,255,0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .log-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255,255,255,0.3) !important;
|
||||||
|
border: 2px solid transparent !important;
|
||||||
|
background-clip: content-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .log-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255,255,255,0.5) !important;
|
||||||
|
border: 2px solid transparent !important;
|
||||||
|
background-clip: content-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force scrollbar to appear */
|
||||||
|
.force-scroll {
|
||||||
|
overflow-y: scroll !important;
|
||||||
|
min-height: 100px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<div class="py-6">
|
<div class="py-6">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
@@ -448,6 +516,94 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Viewer -->
|
||||||
|
<div id="logs-container" class="mt-8">
|
||||||
|
@AdminLogViewer(data)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook Documentation -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-bell mr-2 text-primary-500"></i>
|
||||||
|
Webhook Notifications
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||||
|
GoMFT can send webhook notifications when jobs run. You can configure webhooks
|
||||||
|
for individual jobs in the job edit form. Below is the format of the webhook payload:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-secondary-50 dark:bg-secondary-900 p-4 rounded-lg overflow-auto font-mono text-sm">
|
||||||
|
<pre>{
|
||||||
|
"event_type": "job_execution",
|
||||||
|
"job_id": 123,
|
||||||
|
"job_name": "Daily Backup",
|
||||||
|
"config_id": 456,
|
||||||
|
"config_name": "Backup Config",
|
||||||
|
"status": "completed",
|
||||||
|
"start_time": "2023-06-18T15:30:45Z",
|
||||||
|
"end_time": "2023-06-18T15:35:12Z",
|
||||||
|
"duration_seconds": 267,
|
||||||
|
"history_id": 789,
|
||||||
|
"bytes_transferred": 1048576,
|
||||||
|
"files_transferred": 5,
|
||||||
|
"source": {
|
||||||
|
"type": "local",
|
||||||
|
"path": "/path/to/source"
|
||||||
|
},
|
||||||
|
"destination": {
|
||||||
|
"type": "s3",
|
||||||
|
"path": "bucket/path"
|
||||||
|
}
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mt-6 mb-2">Authentication</h4>
|
||||||
|
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||||
|
When configuring a webhook, you can optionally provide a secret key. This will be used to sign
|
||||||
|
the webhook payload with HMAC-SHA256. The signature is provided in the <code>X-Hub-Signature-256</code> header.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mt-6 mb-2">HTTP Request Details</h4>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left text-sm font-medium text-secondary-500 dark:text-secondary-400 pb-2">Property</th>
|
||||||
|
<th class="text-left text-sm font-medium text-secondary-500 dark:text-secondary-400 pb-2">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-sm text-secondary-900 dark:text-secondary-100 font-medium">Method</td>
|
||||||
|
<td class="py-2 text-sm text-secondary-600 dark:text-secondary-400">POST</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-sm text-secondary-900 dark:text-secondary-100 font-medium">Content-Type</td>
|
||||||
|
<td class="py-2 text-sm text-secondary-600 dark:text-secondary-400">application/json</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-sm text-secondary-900 dark:text-secondary-100 font-medium">User-Agent</td>
|
||||||
|
<td class="py-2 text-sm text-secondary-600 dark:text-secondary-400">GoMFT-Webhook/1.0</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-sm text-secondary-900 dark:text-secondary-100 font-medium">X-Hub-Signature-256</td>
|
||||||
|
<td class="py-2 text-sm text-secondary-600 dark:text-secondary-400">HMAC SHA256 signature (if secret configured)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-sm text-secondary-900 dark:text-secondary-100 font-medium">Custom Headers</td>
|
||||||
|
<td class="py-2 text-sm text-secondary-600 dark:text-secondary-400">Any additional headers specified in the job configuration</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -576,3 +732,167 @@ templ BackupsList(data AdminToolsData) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add this new template after other admin tool templates
|
||||||
|
templ AdminLogViewer(data AdminToolsData) {
|
||||||
|
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h3 class="text-xl font-semibold mb-4 text-secondary-900 dark:text-secondary-100 flex items-center">
|
||||||
|
<i class="fas fa-file-alt mr-2"></i> Log Files
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="lg:col-span-1 border-r border-secondary-200 dark:border-secondary-700 pr-4">
|
||||||
|
<h4 class="text-lg font-medium mb-2 text-secondary-900 dark:text-secondary-100">Available Logs</h4>
|
||||||
|
<div class="space-y-2 max-h-96 overflow-y-auto pr-2 log-scrollbar">
|
||||||
|
if len(data.LogFiles) == 0 {
|
||||||
|
<div class="text-secondary-600 dark:text-secondary-400 italic">
|
||||||
|
No log files found
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="flex flex-col space-y-1">
|
||||||
|
for _, logFile := range data.LogFiles {
|
||||||
|
<button
|
||||||
|
class={
|
||||||
|
"text-left px-3 py-2 rounded transition-colors flex justify-between items-center",
|
||||||
|
templ.KV("bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400", logFile.Name == data.CurrentLogFile),
|
||||||
|
templ.KV("hover:bg-secondary-50 dark:hover:bg-secondary-700/50 text-secondary-700 dark:text-secondary-300", logFile.Name != data.CurrentLogFile)
|
||||||
|
}
|
||||||
|
hx-get={ fmt.Sprintf("/admin/logs/view/%s", logFile.Name) }
|
||||||
|
hx-target="#log-content"
|
||||||
|
hx-indicator="#log-loading"
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<i class="fas fa-file-alt mr-2"></i>
|
||||||
|
{ logFile.Name }
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-secondary-500 dark:text-secondary-400">{ logFile.Size }</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-between">
|
||||||
|
<button
|
||||||
|
class="btn-secondary btn-sm"
|
||||||
|
hx-get="/admin/logs/refresh"
|
||||||
|
hx-target="#logs-container"
|
||||||
|
hx-indicator="#refresh-logs-indicator"
|
||||||
|
>
|
||||||
|
<span id="refresh-logs-indicator" class="htmx-indicator">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-3 pl-0 lg:pl-4">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<h4 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
if data.CurrentLogFile != "" {
|
||||||
|
Log: { data.CurrentLogFile }
|
||||||
|
} else {
|
||||||
|
Select a log file
|
||||||
|
}
|
||||||
|
</h4>
|
||||||
|
if data.CurrentLogFile != "" {
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button
|
||||||
|
class="btn-secondary btn-sm"
|
||||||
|
hx-get={ fmt.Sprintf("/admin/logs/download/%s", data.CurrentLogFile) }
|
||||||
|
>
|
||||||
|
<i class="fas fa-download mr-1"></i> Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@AdminLogContent(data)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLogContent template for log view
|
||||||
|
templ AdminLogContent(data AdminToolsData) {
|
||||||
|
<div id="log-content" class="relative">
|
||||||
|
<div id="log-loading" class="htmx-indicator absolute inset-0 bg-white/75 dark:bg-secondary-800/75 flex items-center justify-center">
|
||||||
|
<i class="fas fa-spinner fa-spin text-primary-600 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
if data.CurrentLogFile == "" {
|
||||||
|
<div class="border border-secondary-200 dark:border-secondary-700 rounded p-4 text-secondary-600 dark:text-secondary-400 bg-secondary-50 dark:bg-secondary-900/30 text-center h-96 flex items-center justify-center">
|
||||||
|
<div>
|
||||||
|
<i class="fas fa-file-alt text-4xl mb-2"></i>
|
||||||
|
<p>Select a log file to view its contents</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<!-- Fixed height log content container with guaranteed scrollbars -->
|
||||||
|
<div class="log-content-container" style="height: 400px; border: 1px solid #ccc; border-radius: 0.375rem; position: relative;">
|
||||||
|
<!-- Standard scrollable div -->
|
||||||
|
<div id="log-content-text" class="p-4 h-full overflow-y-scroll bg-secondary-50 dark:bg-secondary-900/30 text-secondary-800 dark:text-secondary-200 text-sm font-mono whitespace-pre-wrap" style="scrollbar-width: thin;">
|
||||||
|
{ data.LogContent }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom scrollbar -->
|
||||||
|
<div class="custom-scrollbar dark:bg-white dark:bg-opacity-10" style="position: absolute; right: 0; top: 0; width: 12px; height: 100%; background-color: rgba(0,0,0,0.05); border-radius: 0 0.375rem 0.375rem 0;">
|
||||||
|
<div class="scrollbar-thumb dark:bg-opacity-30 dark:bg-white" style="position: absolute; right: 0; width: 12px; background-color: rgba(0,0,0,0.3); border-radius: 6px; cursor: pointer; min-height: 40px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Custom scrollbar implementation
|
||||||
|
(function() {
|
||||||
|
const content = document.getElementById('log-content-text');
|
||||||
|
const scrollThumb = document.querySelector('.scrollbar-thumb');
|
||||||
|
|
||||||
|
// Initial position
|
||||||
|
updateScrollThumb();
|
||||||
|
|
||||||
|
// Update scrollbar position when content is scrolled
|
||||||
|
content.addEventListener('scroll', updateScrollThumb);
|
||||||
|
|
||||||
|
function updateScrollThumb() {
|
||||||
|
const scrollPercentage = content.scrollTop / (content.scrollHeight - content.clientHeight);
|
||||||
|
const thumbHeight = Math.max(40, (content.clientHeight / content.scrollHeight) * content.clientHeight);
|
||||||
|
const thumbTop = scrollPercentage * (content.clientHeight - thumbHeight);
|
||||||
|
|
||||||
|
scrollThumb.style.height = thumbHeight + 'px';
|
||||||
|
scrollThumb.style.top = thumbTop + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dragging the scrollbar
|
||||||
|
let isDragging = false;
|
||||||
|
let startY, startTop;
|
||||||
|
|
||||||
|
scrollThumb.addEventListener('mousedown', function(e) {
|
||||||
|
isDragging = true;
|
||||||
|
startY = e.clientY;
|
||||||
|
startTop = parseInt(scrollThumb.style.top) || 0;
|
||||||
|
document.body.style.userSelect = 'none'; // Prevent text selection during drag
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', function(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const deltaY = e.clientY - startY;
|
||||||
|
const newTop = Math.max(0, Math.min(content.clientHeight - scrollThumb.offsetHeight, startTop + deltaY));
|
||||||
|
scrollThumb.style.top = newTop + 'px';
|
||||||
|
|
||||||
|
// Update scroll position
|
||||||
|
const scrollPercentage = newTop / (content.clientHeight - scrollThumb.offsetHeight);
|
||||||
|
content.scrollTop = scrollPercentage * (content.scrollHeight - content.clientHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', function() {
|
||||||
|
isDragging = false;
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-scroll to bottom
|
||||||
|
content.scrollTop = content.scrollHeight;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+218
-1228
File diff suppressed because it is too large
Load Diff
+205
-4
@@ -6,12 +6,204 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"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 {
|
type ConfigsData struct {
|
||||||
Configs []db.TransferConfig
|
Configs []db.TransferConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
templ Configs(ctx context.Context, data ConfigsData) {
|
templ Configs(ctx context.Context, data ConfigsData) {
|
||||||
@LayoutWithContext("Transfer Configurations", ctx) {
|
@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="py-6">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between items-center mb-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>
|
<i class="fas fa-edit mr-1"></i>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</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
|
<button
|
||||||
hx-delete={ fmt.Sprintf("/configs/%d", config.ID) }
|
type="button"
|
||||||
hx-confirm="Are you sure you want to delete this configuration?"
|
onclick={ showConfigDialog(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
|
||||||
hx-target="closest li"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="btn-danger btn-sm">
|
class="btn-danger btn-sm">
|
||||||
<i class="fas fa-trash-alt mr-1"></i>
|
<i class="fas fa-trash-alt mr-1"></i>
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type DashboardData struct {
|
|||||||
ActiveTransfers int
|
ActiveTransfers int
|
||||||
CompletedToday int
|
CompletedToday int
|
||||||
FailedTransfers int
|
FailedTransfers int
|
||||||
|
Configs map[uint]db.TransferConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
templ Dashboard(ctx context.Context, data DashboardData) {
|
templ Dashboard(ctx context.Context, data DashboardData) {
|
||||||
@@ -113,7 +114,7 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium text-secondary-900 truncate dark:text-secondary-100">
|
<p class="text-sm font-medium text-secondary-900 truncate dark:text-secondary-100">
|
||||||
{ job.Job.Config.Name }
|
{ getConfigNameForHistory(job, data.Configs) }
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center mt-1">
|
<div class="flex items-center mt-1">
|
||||||
<i class="fas fa-clock text-xs text-secondary-500 dark:text-secondary-400 mr-1"></i>
|
<i class="fas fa-clock text-xs text-secondary-500 dark:text-secondary-400 mr-1"></i>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+67
-50
@@ -13,6 +13,7 @@ type HistoryData struct {
|
|||||||
SearchTerm string
|
SearchTerm string
|
||||||
PageSize int
|
PageSize int
|
||||||
Total int
|
Total int
|
||||||
|
Configs map[uint]db.TransferConfig // Map of config IDs to configs for quick lookup
|
||||||
}
|
}
|
||||||
|
|
||||||
// min returns the smaller of x or y
|
// min returns the smaller of x or y
|
||||||
@@ -23,6 +24,28 @@ func min(x, y int) int {
|
|||||||
return y
|
return y
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getConfigNameForHistory returns the appropriate name for the config used in a job history entry
|
||||||
|
func getConfigNameForHistory(history db.JobHistory, configs map[uint]db.TransferConfig) string {
|
||||||
|
// If ConfigID is set in the history record, use that to get the config name
|
||||||
|
if history.ConfigID > 0 {
|
||||||
|
if config, exists := configs[history.ConfigID]; exists {
|
||||||
|
return config.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to the Job's default Config if it exists
|
||||||
|
if history.Job.Config.ID > 0 {
|
||||||
|
return history.Job.Config.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't determine the config name, show a default with the job name
|
||||||
|
if history.Job.Name != "" {
|
||||||
|
return fmt.Sprintf("%s (unknown config)", history.Job.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unknown Configuration"
|
||||||
|
}
|
||||||
|
|
||||||
// HistoryContent renders only the content part of the history page for HTMX requests
|
// HistoryContent renders only the content part of the history page for HTMX requests
|
||||||
templ HistoryContent(ctx context.Context, data HistoryData) {
|
templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||||
if len(data.History) == 0 {
|
if len(data.History) == 0 {
|
||||||
@@ -47,18 +70,18 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
<div class="px-4 py-4 sm:px-6">
|
<div class="px-4 py-4 sm:px-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">{ history.Job.Config.Name }</p>
|
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">{ getConfigNameForHistory(history, data.Configs) }</p>
|
||||||
if history.Status == "completed" {
|
if history.Status == "completed" {
|
||||||
<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">
|
<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">
|
||||||
<i class="fas fa-check mr-1"></i> Completed
|
Completed
|
||||||
</span>
|
</span>
|
||||||
} else if history.Status == "failed" {
|
} else if history.Status == "failed" {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||||
<i class="fas fa-times mr-1"></i> Failed
|
Failed
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||||
<i class="fas fa-sync-alt mr-1"></i> { history.Status }
|
{ history.Status }
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -108,22 +131,24 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
if data.TotalPages > 1 {
|
if data.TotalPages > 1 {
|
||||||
<div class="mt-6 flex items-center justify-between">
|
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-secondary-50 dark:bg-secondary-800 rounded-lg">
|
||||||
<div class="flex-1 flex justify-between sm:hidden">
|
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full px-4 mb-4 sm:mb-0">
|
||||||
if data.CurrentPage > 1 {
|
if data.CurrentPage > 1 {
|
||||||
<button
|
<button
|
||||||
hx-get="/history"
|
hx-get="/history"
|
||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#mobile-prev-indicator"
|
hx-indicator="#mobile-prev-indicator"
|
||||||
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
<span class="flex items-center">
|
||||||
<span id="mobile-prev-indicator" class="htmx-indicator ml-1">
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
</span>
|
||||||
|
<span id="mobile-prev-indicator" class="htmx-indicator absolute right-2">
|
||||||
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
|
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -134,19 +159,21 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#mobile-next-indicator"
|
hx-indicator="#mobile-next-indicator"
|
||||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
<span class="flex items-center">
|
||||||
<span id="mobile-next-indicator" class="htmx-indicator ml-1">
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
</span>
|
||||||
|
<span id="mobile-next-indicator" class="htmx-indicator absolute right-2">
|
||||||
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
|
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between px-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-secondary-700 dark:text-secondary-300">
|
<p class="text-sm text-secondary-700 dark:text-secondary-300">
|
||||||
Showing
|
Showing
|
||||||
@@ -158,25 +185,23 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
results
|
results
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex justify-center">
|
||||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
<nav class="flex gap-2" aria-label="Pagination">
|
||||||
if data.CurrentPage > 1 {
|
if data.CurrentPage > 1 {
|
||||||
<button
|
<button
|
||||||
hx-get="/history"
|
hx-get="/history"
|
||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#prev-indicator"
|
hx-indicator="#prev-indicator"
|
||||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||||
<span class="sr-only">Previous</span>
|
<i class="fas fa-chevron-left"></i>
|
||||||
<i class="fas fa-chevron-left h-5 w-5"></i>
|
<span id="prev-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||||
<span id="prev-indicator" class="htmx-indicator ml-1">
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
|
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||||
<span class="sr-only">Previous</span>
|
<i class="fas fa-chevron-left"></i>
|
||||||
<i class="fas fa-chevron-left h-5 w-5"></i>
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,17 +214,15 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#next-indicator"
|
hx-indicator="#next-indicator"
|
||||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||||
<span class="sr-only">Next</span>
|
<i class="fas fa-chevron-right"></i>
|
||||||
<i class="fas fa-chevron-right h-5 w-5"></i>
|
<span id="next-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||||
<span id="next-indicator" class="htmx-indicator ml-1">
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
|
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||||
<span class="sr-only">Next</span>
|
<i class="fas fa-chevron-right"></i>
|
||||||
<i class="fas fa-chevron-right h-5 w-5"></i>
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -303,31 +326,25 @@ templ History(ctx context.Context, data HistoryData) {
|
|||||||
|
|
||||||
templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm string) {
|
templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm string) {
|
||||||
// Show at most 5 page numbers with the current page in the middle when possible
|
// Show at most 5 page numbers with the current page in the middle when possible
|
||||||
{{startPage := max(1, currentPage-2)}}
|
for i := 1; i <= totalPages; i++ {
|
||||||
{{endPage := min(totalPages, startPage+4)}}
|
|
||||||
|
|
||||||
// Adjust startPage if we're near the end
|
|
||||||
if endPage - startPage < 4 && startPage > 1 {
|
|
||||||
startPage = max(1, endPage-4)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := startPage; i <= endPage; i++ {
|
|
||||||
if i == currentPage {
|
if i == currentPage {
|
||||||
<span aria-current="page" class="relative inline-flex items-center px-4 py-2 border border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-sm font-medium text-primary-600 dark:text-primary-400">
|
<span aria-current="page" class="w-10 h-10 flex items-center justify-center bg-primary-600 text-white rounded">
|
||||||
{ fmt.Sprint(i) }
|
{ fmt.Sprint(i) }
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
|
||||||
<button
|
<button
|
||||||
hx-get="/history"
|
hx-get="/history"
|
||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
||||||
hx-indicator="#page-indicator"
|
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
|
||||||
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-700 dark:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||||
{ fmt.Sprint(i) }
|
{ fmt.Sprint(i) }
|
||||||
<span id="page-indicator" class="htmx-indicator ml-1">
|
<span id={ fmt.Sprintf("page-indicator-%d", i) } class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
} else if i == currentPage-3 || i == currentPage+3 {
|
||||||
|
<span class="w-10 h-10 flex items-center justify-center">...</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+393
-74
@@ -26,8 +26,59 @@ func getJobTitle(isNew bool) string {
|
|||||||
return "Edit Job"
|
return "Edit Job"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configSelected checks if a config ID is selected for a job
|
||||||
|
func configSelected(job *db.Job, configID uint) bool {
|
||||||
|
if job.ConfigIDs != "" {
|
||||||
|
// If ConfigIDs is populated, only check against those IDs
|
||||||
|
for _, id := range job.GetConfigIDsList() {
|
||||||
|
if id == configID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
// If ConfigIDs is empty, fall back to checking the primary ConfigID
|
||||||
|
return job.ConfigID == configID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ configSearchScript() {
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Handle search for new job form
|
||||||
|
const configSearch = document.getElementById('config-search');
|
||||||
|
if (configSearch) {
|
||||||
|
configSearch.addEventListener('input', (e) => {
|
||||||
|
const searchTerm = e.target.value.toLowerCase();
|
||||||
|
const configItems = document.querySelectorAll('#config-list .config-item');
|
||||||
|
|
||||||
|
configItems.forEach(item => {
|
||||||
|
const name = item.getAttribute('data-name').toLowerCase();
|
||||||
|
item.style.display = name.includes(searchTerm) ? 'flex' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle search for edit job form
|
||||||
|
const configSearchEdit = document.getElementById('config-search-edit');
|
||||||
|
if (configSearchEdit) {
|
||||||
|
configSearchEdit.addEventListener('input', (e) => {
|
||||||
|
const searchTerm = e.target.value.toLowerCase();
|
||||||
|
const configItems = document.querySelectorAll('#config-list-edit .config-item');
|
||||||
|
|
||||||
|
configItems.forEach(item => {
|
||||||
|
const name = item.getAttribute('data-name').toLowerCase();
|
||||||
|
item.style.display = name.includes(searchTerm) ? 'flex' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
templ JobForm(ctx context.Context, data JobFormData) {
|
templ JobForm(ctx context.Context, data JobFormData) {
|
||||||
@LayoutWithContext(getJobFormTitle(data.IsNew), ctx) {
|
@LayoutWithContext(getJobFormTitle(data.IsNew), ctx) {
|
||||||
|
@configSearchScript()
|
||||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
||||||
<div class="max-w-3xl w-full">
|
<div class="max-w-3xl w-full">
|
||||||
<div class="card overflow-hidden shadow-lg">
|
<div class="card overflow-hidden shadow-lg">
|
||||||
@@ -47,11 +98,7 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
class="space-y-6"
|
class="space-y-6"
|
||||||
hx-post="/jobs"
|
hx-post="/jobs"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-boost="true"
|
hx-boost="true">
|
||||||
@htmx:before-request="loading = true"
|
|
||||||
@htmx:after-request="loading = false"
|
|
||||||
@htmx:response-error="$dispatch('notification', { message: 'Failed to create job: ' + event.detail.xhr.responseText, type: 'error' })"
|
|
||||||
x-data="{ name: '', configId: '', schedule: '', enabled: true, loading: false, validate() { return this.configId && this.schedule; } }">
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
|
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
|
||||||
@@ -63,7 +110,6 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
id="name"
|
id="name"
|
||||||
x-model="name"
|
|
||||||
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"
|
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="Daily Production Backup"/>
|
placeholder="Daily Production Backup"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,23 +120,51 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="config_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configuration</label>
|
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configurations</label>
|
||||||
<div class="relative">
|
<div class="mt-2 border border-secondary-300 dark:border-secondary-700 rounded-md overflow-hidden">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<!-- Search box -->
|
||||||
<i class="fas fa-cog text-secondary-400 dark:text-secondary-600"></i>
|
<div class="px-3 py-2 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-800">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-search text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="config-search"
|
||||||
|
placeholder="Search configurations..."
|
||||||
|
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<select
|
|
||||||
id="config_id"
|
<!-- Configuration checkboxes -->
|
||||||
name="config_id"
|
<div class="max-h-48 overflow-y-auto py-2 px-3 bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700" id="config-list">
|
||||||
x-model="configId"
|
if len(data.Configs) > 0 {
|
||||||
required
|
for _, config := range data.Configs {
|
||||||
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 class="config-item py-2 flex items-center" data-name={ config.Name }>
|
||||||
<option value="">Select a configuration</option>
|
<input
|
||||||
for _, config := range data.Configs {
|
type="checkbox"
|
||||||
<option value={ fmt.Sprint(config.ID) }>{ config.Name }</option>
|
name="config_ids[]"
|
||||||
|
id={ fmt.Sprintf("config_%d", config.ID) }
|
||||||
|
value={ fmt.Sprint(config.ID) }
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"
|
||||||
|
/>
|
||||||
|
<label for={ fmt.Sprintf("config_%d", config.ID) } class="ml-3 block font-medium text-secondary-700 dark:text-secondary-300 w-full cursor-pointer">
|
||||||
|
{ config.Name }
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
<div class="text-center py-4 text-secondary-500 dark:text-secondary-400">
|
||||||
|
No configurations available. <a href="/configs/new" class="text-primary-600 hover:text-primary-500">Create one</a>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Select one or more configurations to run on this schedule.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -103,7 +177,6 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
type="text"
|
type="text"
|
||||||
name="schedule"
|
name="schedule"
|
||||||
id="schedule"
|
id="schedule"
|
||||||
x-model="schedule"
|
|
||||||
required
|
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"
|
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="*/15 * * * *"/>
|
placeholder="*/15 * * * *"/>
|
||||||
@@ -119,10 +192,10 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="enabled"
|
id="enabled"
|
||||||
x-model="enabled"
|
name="enabled"
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"
|
value="true"
|
||||||
checked/>
|
checked
|
||||||
<input type="hidden" name="enabled" :value="enabled.toString()"/>
|
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>
|
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
@@ -130,6 +203,119 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
Disabled jobs will not run automatically.
|
Disabled jobs will not run automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook Notification Settings -->
|
||||||
|
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-6 mt-6">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">
|
||||||
|
<i class="fas fa-bell mr-2 text-primary-500"></i>
|
||||||
|
Webhook Notifications
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
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"/>
|
||||||
|
<label for="webhook_enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||||
|
Enable webhook notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pl-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="webhook_url" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Webhook 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-link text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
name="webhook_url"
|
||||||
|
id="webhook_url"
|
||||||
|
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://example.com/webhook"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
The URL where notifications will be sent when jobs run
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="webhook_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||||
|
Webhook Secret <span class="text-secondary-500 dark:text-secondary-400">(optional)</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"
|
||||||
|
name="webhook_secret"
|
||||||
|
id="webhook_secret"
|
||||||
|
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="Secret token for signing requests"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Used to sign webhook payloads (X-Hub-Signature-256 header)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="webhook_headers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||||
|
Custom Headers <span class="text-secondary-500 dark:text-secondary-400">(optional)</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-code text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="webhook_headers"
|
||||||
|
id="webhook_headers"
|
||||||
|
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='{"X-Custom-Header": "value"}'/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Additional HTTP headers as JSON
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="notify_on_success"
|
||||||
|
name="notify_on_success"
|
||||||
|
value="true"
|
||||||
|
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
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="notify_on_failure"
|
||||||
|
name="notify_on_failure"
|
||||||
|
value="true"
|
||||||
|
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
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pt-5 flex justify-end space-x-3">
|
<div class="pt-5 flex justify-end space-x-3">
|
||||||
@@ -139,19 +325,9 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-primary flex items-center justify-center px-4 py-2"
|
class="btn-primary flex items-center justify-center px-4 py-2">
|
||||||
x-bind:disabled="!validate() || loading">
|
<i class="fas fa-plus mr-2"></i>
|
||||||
<span x-show="!loading" class="flex items-center">
|
Create Job
|
||||||
<i class="fas fa-plus mr-2"></i>
|
|
||||||
Create Job
|
|
||||||
</span>
|
|
||||||
<span x-show="loading" class="flex items-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>
|
|
||||||
Processing...
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -160,11 +336,7 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
class="space-y-6"
|
class="space-y-6"
|
||||||
hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) }
|
hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) }
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-boost="true"
|
hx-boost="true">
|
||||||
@htmx:before-request="loading = true"
|
|
||||||
@htmx:after-request="loading = false"
|
|
||||||
@htmx:response-error="$dispatch('notification', { message: 'Failed to update job: ' + event.detail.xhr.responseText, type: 'error' })"
|
|
||||||
x-data={ fmt.Sprintf("{ name: '%s', configId: '%d', schedule: '%s', enabled: %v, loading: false, validate() { return this.configId && this.schedule; } }", data.Job.Name, data.Job.ConfigID, data.Job.Schedule, data.Job.Enabled) }>
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
|
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
|
||||||
@@ -176,7 +348,7 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
id="name"
|
id="name"
|
||||||
x-model="name"
|
value={ data.Job.Name }
|
||||||
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"
|
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="Daily Production Backup"/>
|
placeholder="Daily Production Backup"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,23 +359,54 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="config_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configuration</label>
|
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configurations</label>
|
||||||
<div class="relative">
|
<div class="mt-2 border border-secondary-300 dark:border-secondary-700 rounded-md overflow-hidden">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<!-- Search box -->
|
||||||
<i class="fas fa-cog text-secondary-400 dark:text-secondary-600"></i>
|
<div class="px-3 py-2 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-800">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-search text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="config-search-edit"
|
||||||
|
placeholder="Search configurations..."
|
||||||
|
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<select
|
|
||||||
id="config_id"
|
<!-- Configuration checkboxes -->
|
||||||
name="config_id"
|
<div class="max-h-48 overflow-y-auto py-2 px-3 bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700" id="config-list-edit">
|
||||||
x-model="configId"
|
if len(data.Configs) > 0 {
|
||||||
required
|
for _, config := range data.Configs {
|
||||||
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 class="config-item py-2 flex items-center" data-name={ config.Name }>
|
||||||
<option value="">Select a configuration</option>
|
<input
|
||||||
for _, config := range data.Configs {
|
type="checkbox"
|
||||||
<option value={ fmt.Sprint(config.ID) } if data.Job != nil && data.Job.ConfigID == config.ID { selected }>{ config.Name }</option>
|
name="config_ids[]"
|
||||||
|
id={ fmt.Sprintf("config_edit_%d", config.ID) }
|
||||||
|
value={ fmt.Sprint(config.ID) }
|
||||||
|
if configSelected(data.Job, config.ID) {
|
||||||
|
checked
|
||||||
|
}
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"
|
||||||
|
/>
|
||||||
|
<label for={ fmt.Sprintf("config_edit_%d", config.ID) } class="ml-3 block font-medium text-secondary-700 dark:text-secondary-300 w-full cursor-pointer">
|
||||||
|
{ config.Name }
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
<div class="text-center py-4 text-secondary-500 dark:text-secondary-400">
|
||||||
|
No configurations available. <a href="/configs/new" class="text-primary-600 hover:text-primary-500">Create one</a>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Select one or more configurations to run on this schedule.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -216,7 +419,7 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
type="text"
|
type="text"
|
||||||
name="schedule"
|
name="schedule"
|
||||||
id="schedule"
|
id="schedule"
|
||||||
x-model="schedule"
|
value={ data.Job.Schedule }
|
||||||
required
|
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"
|
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="*/15 * * * *"/>
|
placeholder="*/15 * * * *"/>
|
||||||
@@ -232,9 +435,12 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="enabled"
|
id="enabled"
|
||||||
x-model="enabled"
|
name="enabled"
|
||||||
|
value="true"
|
||||||
|
if data.Job.Enabled {
|
||||||
|
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"/>
|
||||||
<input type="hidden" name="enabled" :value="enabled.toString()"/>
|
|
||||||
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
|
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
@@ -242,6 +448,129 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
Disabled jobs will not run automatically.
|
Disabled jobs will not run automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook Notification Settings -->
|
||||||
|
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-6 mt-6">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">
|
||||||
|
<i class="fas fa-bell mr-2 text-primary-500"></i>
|
||||||
|
Webhook Notifications
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="webhook_enabled"
|
||||||
|
name="webhook_enabled"
|
||||||
|
value="true"
|
||||||
|
if data.Job.WebhookEnabled {
|
||||||
|
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
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pl-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="webhook_url" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Webhook 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-link text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
name="webhook_url"
|
||||||
|
id="webhook_url"
|
||||||
|
value={ data.Job.WebhookURL }
|
||||||
|
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://example.com/webhook"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
The URL where notifications will be sent when jobs run
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="webhook_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||||
|
Webhook Secret <span class="text-secondary-500 dark:text-secondary-400">(optional)</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"
|
||||||
|
name="webhook_secret"
|
||||||
|
id="webhook_secret"
|
||||||
|
value={ data.Job.WebhookSecret }
|
||||||
|
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="Secret token for signing requests"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Used to sign webhook payloads (X-Hub-Signature-256 header)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="webhook_headers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||||
|
Custom Headers <span class="text-secondary-500 dark:text-secondary-400">(optional)</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-code text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="webhook_headers"
|
||||||
|
id="webhook_headers"
|
||||||
|
value={ data.Job.WebhookHeaders }
|
||||||
|
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='{"X-Custom-Header": "value"}'/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Additional HTTP headers as JSON
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="notify_on_success"
|
||||||
|
name="notify_on_success"
|
||||||
|
value="true"
|
||||||
|
if data.Job.NotifyOnSuccess {
|
||||||
|
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
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="notify_on_failure"
|
||||||
|
name="notify_on_failure"
|
||||||
|
value="true"
|
||||||
|
if data.Job.NotifyOnFailure {
|
||||||
|
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
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pt-5 flex justify-end space-x-3">
|
<div class="pt-5 flex justify-end space-x-3">
|
||||||
@@ -251,19 +580,9 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
|||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-primary flex items-center justify-center px-4 py-2"
|
class="btn-primary flex items-center justify-center px-4 py-2">
|
||||||
x-bind:disabled="!validate() || loading">
|
<i class="fas fa-save mr-2"></i>
|
||||||
<span x-show="!loading" class="flex items-center">
|
Save Changes
|
||||||
<i class="fas fa-save mr-2"></i>
|
|
||||||
Save Changes
|
|
||||||
</span>
|
|
||||||
<span x-show="loading" class="flex items-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>
|
|
||||||
Processing...
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+161
-205
@@ -15,221 +15,177 @@ type JobRunDetailsData struct {
|
|||||||
|
|
||||||
templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
|
templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
|
||||||
@LayoutWithContext("Job Run Details", ctx) {
|
@LayoutWithContext("Job Run Details", ctx) {
|
||||||
<div class="py-6">
|
@JobRunDetailsContent(ctx, data)
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
}
|
||||||
<div class="mb-6">
|
}
|
||||||
<a href="/dashboard" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
|
||||||
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-8">
|
// JobRunDetailsContent is the same as JobRunDetails but without the layout wrapper
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
// This is used for testing
|
||||||
<i class="fas fa-file-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
templ JobRunDetailsContent(ctx context.Context, data JobRunDetailsData) {
|
||||||
Job Run Details
|
<div class="py-6">
|
||||||
</h1>
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
</div>
|
<div class="mb-6">
|
||||||
|
<a href="/dashboard" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||||
|
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Job Run Information Card -->
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
<i class="fas fa-file-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
<div class="flex items-center justify-between">
|
Job Run Details
|
||||||
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
|
</h1>
|
||||||
if data.Job.Name != "" {
|
</div>
|
||||||
{ data.Job.Name }
|
|
||||||
} else {
|
<!-- Job Run Information Card -->
|
||||||
{ data.Config.Name }
|
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
||||||
}
|
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||||
</h3>
|
<div class="flex items-center justify-between">
|
||||||
if data.JobHistory.Status == "completed" {
|
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">{ data.Job.Name }</h3>
|
||||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
if data.JobHistory.Status == "completed" {
|
||||||
<i class="fas fa-check mr-1"></i> Completed
|
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||||
</span>
|
<i class="fas fa-check mr-1"></i> Completed
|
||||||
} else if data.JobHistory.Status == "failed" {
|
</span>
|
||||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
} else if data.JobHistory.Status == "failed" {
|
||||||
<i class="fas fa-times mr-1"></i> Failed
|
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||||
</span>
|
<i class="fas fa-times mr-1"></i> Failed
|
||||||
} else {
|
</span>
|
||||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
} else {
|
||||||
<i class="fas fa-sync-alt mr-1"></i> { data.JobHistory.Status }
|
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||||
</span>
|
<i class="fas fa-spinner fa-spin mr-1"></i> Running
|
||||||
}
|
</span>
|
||||||
</div>
|
|
||||||
if data.Job.Name != "" && data.Job.Name != data.Config.Name {
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
Config: { data.Config.Name }
|
|
||||||
</p>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Config: { data.Config.Name }</p>
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-calendar-alt mr-1"></i> Start Time
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-calendar-check mr-1"></i> End Time
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
if data.JobHistory.EndTime != nil {
|
|
||||||
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
|
|
||||||
} else {
|
|
||||||
<span class="text-secondary-500 dark:text-secondary-400">Still running...</span>
|
|
||||||
}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-clock mr-1"></i> Duration
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
if data.JobHistory.EndTime != nil {
|
|
||||||
{ formatDuration(data.JobHistory.EndTime.Sub(data.JobHistory.StartTime)) }
|
|
||||||
} else {
|
|
||||||
{ formatDuration(time.Since(data.JobHistory.StartTime)) } (ongoing)
|
|
||||||
}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-upload mr-1"></i> Data Transferred
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ formatBytes(data.JobHistory.BytesTransferred) }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-file mr-1"></i> Files Transferred
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ fmt.Sprint(data.JobHistory.FilesTransferred) }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-calendar-day mr-1"></i> Job Schedule
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ data.Job.Schedule }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<!-- Transfer Configuration Details -->
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
<div class="sm:col-span-1">
|
||||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
|
<i class="fas fa-calendar-alt mr-1"></i> Start Time
|
||||||
<i class="fas fa-cog mr-2 text-primary-600 dark:text-primary-400"></i>
|
</dt>
|
||||||
Transfer Configuration
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
</h3>
|
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
</div>
|
</dd>
|
||||||
|
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Type</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">
|
|
||||||
{ data.Config.SourceType }
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Type</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">
|
|
||||||
{ data.Config.DestinationType }
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Path</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
|
||||||
if data.Config.SourceType == "sftp" {
|
|
||||||
{ data.Config.SourceUser } `@` { data.Config.SourceHost }:{ data.Config.SourcePath }
|
|
||||||
} else {
|
|
||||||
{ data.Config.SourcePath }
|
|
||||||
}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Path</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
|
||||||
if data.Config.DestinationType == "sftp" {
|
|
||||||
data.Config.DestUser@data.Config.DestHost:data.Config.DestinationPath
|
|
||||||
} else {
|
|
||||||
{ data.Config.DestinationPath }
|
|
||||||
}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">File Pattern</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
|
|
||||||
{ data.Config.FilePattern }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
if data.Config.ArchiveEnabled {
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Archive Path</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
|
||||||
{ data.Config.ArchivePath }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error Information (if any) -->
|
|
||||||
if data.JobHistory.ErrorMessage != "" {
|
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8 border-l-4 border-red-500">
|
|
||||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-red-600 dark:text-red-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
|
||||||
Error Details
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
<div class="px-4 py-5 sm:p-6 bg-red-50 dark:bg-red-900/20">
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
<pre class="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap font-mono">{ data.JobHistory.ErrorMessage }</pre>
|
<i class="fas fa-calendar-check mr-1"></i> End Time
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
if data.JobHistory.EndTime != nil {
|
||||||
|
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
|
} else {
|
||||||
|
<span class="italic text-secondary-500">In progress</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="sm:col-span-1">
|
||||||
}
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-clock mr-1"></i> Duration
|
||||||
<!-- Action Buttons -->
|
</dt>
|
||||||
<div class="flex flex-col sm:flex-row gap-4 mt-8">
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
<a href="/jobs" class="btn-secondary text-center flex items-center justify-center">
|
if data.JobHistory.EndTime != nil {
|
||||||
<i class="fas fa-list-ul mr-2"></i>
|
{ data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() }
|
||||||
View All Jobs
|
} else {
|
||||||
</a>
|
<span class="italic text-secondary-500">In progress</span>
|
||||||
|
}
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="btn-primary text-center flex items-center justify-center">
|
</dd>
|
||||||
<i class="fas fa-edit mr-2"></i>
|
</div>
|
||||||
Edit Job
|
<div class="sm:col-span-1">
|
||||||
</a>
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-upload mr-1"></i> Data Transferred
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ formatBytes(data.JobHistory.BytesTransferred) }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-file mr-1"></i> Files Transferred
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ fmt.Sprintf("%d files", data.JobHistory.FilesTransferred) }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-calendar-day mr-1"></i> Job Schedule
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ data.Job.Schedule }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Transfer Configuration Details -->
|
||||||
|
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-cog mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
|
Transfer Configuration
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Type</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">{ data.Config.SourceType }</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Type</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">{ data.Config.DestinationType }</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Path</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">{ data.Config.SourcePath }</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Path</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">{ data.Config.DestinationPath }</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">File Pattern</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">{ data.Config.FilePattern }</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Information (if any) -->
|
||||||
|
if data.JobHistory.Status == "failed" && data.JobHistory.ErrorMessage != "" {
|
||||||
|
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8 border-l-4 border-red-500">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-red-600 dark:text-red-400">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||||
|
Error Information
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
|
||||||
|
<pre class="text-sm text-red-800 dark:text-red-300 whitespace-pre-wrap font-mono">{ data.JobHistory.ErrorMessage }</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 mt-8">
|
||||||
|
<a href="/jobs" class="btn-secondary text-center flex items-center justify-center">
|
||||||
|
<i class="fas fa-list-ul mr-2"></i> View All Jobs
|
||||||
|
</a>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="btn-primary text-center flex items-center justify-center">
|
||||||
|
<i class="fas fa-edit mr-2"></i> Edit Job
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatDuration formats a duration in a human-readable way
|
// formatDuration formats a duration in a human-readable way
|
||||||
|
|||||||
+275
-9
@@ -6,12 +6,244 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"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 {
|
type JobsData struct {
|
||||||
Jobs []db.Job
|
Jobs []db.Job
|
||||||
|
ConfigCount map[uint]int // Maps job ID to number of configs
|
||||||
}
|
}
|
||||||
|
|
||||||
templ Jobs(ctx context.Context, data JobsData) {
|
templ Jobs(ctx context.Context, data JobsData) {
|
||||||
@LayoutWithContext("Transfer Jobs", ctx) {
|
@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="py-6">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex justify-between items-center mb-8">
|
||||||
@@ -57,26 +289,43 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
</p>
|
</p>
|
||||||
if job.Enabled {
|
if job.Enabled {
|
||||||
<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">
|
<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">
|
||||||
<i class="fas fa-check-circle mr-1"></i>
|
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
||||||
<i class="fas fa-pause-circle mr-1"></i>
|
|
||||||
Inactive
|
Inactive
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
<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">
|
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="btn-secondary btn-sm">
|
||||||
<i class="fas fa-edit mr-1"></i>
|
<i class="fas fa-edit mr-1"></i>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</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
|
<button
|
||||||
hx-delete={ fmt.Sprintf("/jobs/%d", job.ID) }
|
type="button"
|
||||||
hx-confirm="Are you sure you want to delete this job?"
|
onclick={ showJobDialog(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
|
||||||
hx-target="closest li"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="btn-danger btn-sm">
|
class="btn-danger btn-sm">
|
||||||
<i class="fas fa-trash-alt mr-1"></i>
|
<i class="fas fa-trash-alt mr-1"></i>
|
||||||
Delete
|
Delete
|
||||||
@@ -86,8 +335,17 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
<div class="mt-2 sm:flex sm:justify-between">
|
<div class="mt-2 sm:flex sm:justify-between">
|
||||||
<div class="sm:flex">
|
<div class="sm:flex">
|
||||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
<i class="fas fa-cog flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
<i class="fas fa-cogs flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
Config: { job.Config.Name }
|
Configs:
|
||||||
|
<span class="ml-1">
|
||||||
|
if count, ok := data.ConfigCount[job.ID]; ok && count > 1 {
|
||||||
|
{ fmt.Sprintf("%d configurations", count) }
|
||||||
|
} else if job.ConfigID > 0 {
|
||||||
|
{ job.Config.Name }
|
||||||
|
} else {
|
||||||
|
{ "None" }
|
||||||
|
}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||||
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
@@ -129,3 +387,11 @@ 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
|
||||||
|
}
|
||||||
+65
-8
@@ -29,7 +29,7 @@ templ Layout(title string) {
|
|||||||
|
|
||||||
templ LayoutWithContext(title string, ctx context.Context) {
|
templ LayoutWithContext(title string, ctx context.Context) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="light h-full">
|
<html lang="en" class="light h-full p-0 m-0">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||||||
@@ -47,6 +47,42 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
<script src="/static/js/app.js"></script>
|
<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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||||
<link rel="stylesheet" href="/static/css/app.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>
|
<script>
|
||||||
tailwind.config = {
|
tailwind.config = {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
@@ -92,6 +128,14 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style type="text/tailwindcss">
|
<style type="text/tailwindcss">
|
||||||
|
/* Reset default browser margins and padding */
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: hidden; /* Prevent horizontal scrolling */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||||
@@ -233,7 +277,7 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
background: theme('colors.secondary.600');
|
background: theme('colors.secondary.600');
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: theme('colors.secondary.400');
|
background: theme('colors.secondary.500');
|
||||||
}
|
}
|
||||||
.dark ::-webkit-scrollbar-thumb:hover {
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
background: theme('colors.secondary.500');
|
background: theme('colors.secondary.500');
|
||||||
@@ -241,9 +285,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full">
|
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full p-0 m-0">
|
||||||
if isLoggedIn(ctx) {
|
if isLoggedIn(ctx) {
|
||||||
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10">
|
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10 w-full">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between h-16">
|
<div class="flex justify-between h-16">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
@@ -264,6 +308,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
<a href="/history" class="nav-link">
|
<a href="/history" class="nav-link">
|
||||||
<i class="fas fa-history mr-1"></i> History
|
<i class="fas fa-history mr-1"></i> History
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/files" class="nav-link">
|
||||||
|
<i class="fas fa-file-alt mr-1"></i> Files
|
||||||
|
</a>
|
||||||
if isAdmin(ctx) {
|
if isAdmin(ctx) {
|
||||||
<a href="/admin/users" class="nav-link">
|
<a href="/admin/users" class="nav-link">
|
||||||
<i class="fas fa-users mr-1"></i> Users
|
<i class="fas fa-users mr-1"></i> Users
|
||||||
@@ -342,9 +389,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
x-transition:leave="transition ease-in duration-150"
|
x-transition:leave="transition ease-in duration-150"
|
||||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||||
class="sm:hidden bg-white dark:bg-secondary-800 shadow-md"
|
class="sm:hidden bg-white dark:bg-secondary-800 shadow-md w-full left-0 right-0"
|
||||||
>
|
>
|
||||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
<div class="max-w-7xl mx-auto px-2 pt-2 pb-3 space-y-1">
|
||||||
<a href="/dashboard" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
<a href="/dashboard" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
<i class="fas fa-tachometer-alt mr-2"></i> Dashboard
|
<i class="fas fa-tachometer-alt mr-2"></i> Dashboard
|
||||||
</a>
|
</a>
|
||||||
@@ -357,6 +404,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
<a href="/history" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
<a href="/history" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
<i class="fas fa-history mr-2"></i> History
|
<i class="fas fa-history mr-2"></i> History
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/files" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fas fa-file-alt mr-2"></i> Files
|
||||||
|
</a>
|
||||||
if isAdmin(ctx) {
|
if isAdmin(ctx) {
|
||||||
<a href="/admin/users" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
<a href="/admin/users" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
<i class="fas fa-users mr-2"></i> Users
|
<i class="fas fa-users mr-2"></i> Users
|
||||||
@@ -377,6 +427,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
</div>
|
</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">
|
<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... }
|
{ children... }
|
||||||
</main>
|
</main>
|
||||||
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
|
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
|
||||||
@@ -389,8 +442,8 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
|
|
||||||
if isLoggedIn(ctx) {
|
if isLoggedIn(ctx) {
|
||||||
<!-- Mobile Bottom Navigation -->
|
<!-- Mobile Bottom Navigation -->
|
||||||
<div class="sm:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 shadow-lg mobile-nav-container z-10">
|
<div class="sm:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 shadow-lg w-full p-0 m-0">
|
||||||
<div class="grid grid-cols-5 h-16">
|
<div class="grid grid-cols-6 h-16">
|
||||||
<a href="/dashboard" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
<a href="/dashboard" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
<i class="fas fa-tachometer-alt text-lg"></i>
|
<i class="fas fa-tachometer-alt text-lg"></i>
|
||||||
<span class="text-xs mt-1">Dashboard</span>
|
<span class="text-xs mt-1">Dashboard</span>
|
||||||
@@ -407,6 +460,10 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
<i class="fas fa-history text-lg"></i>
|
<i class="fas fa-history text-lg"></i>
|
||||||
<span class="text-xs mt-1">History</span>
|
<span class="text-xs mt-1">History</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/files" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fas fa-file-alt text-lg"></i>
|
||||||
|
<span class="text-xs mt-1">Files</span>
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400"
|
class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400"
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
templ NameField() {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="name" id="name" x-model="name" 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="Transfer configuration name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ FilePatternFields() {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="file_pattern" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">File
|
||||||
|
Pattern</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-filter text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="file_pattern" id="file_pattern" x-model="filePattern"
|
||||||
|
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="*.txt, *.csv" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Glob pattern for files to transfer. Leave empty to transfer all files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="output_pattern"
|
||||||
|
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Output Pattern</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-file-export text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="output_pattern" id="output_pattern" x-model="outputPattern"
|
||||||
|
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="${filename}" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Pattern for filenames. Available variables:<br />
|
||||||
|
${`filename`} - Original filename without extension (e.g., "report")<br />
|
||||||
|
${`ext`} - Original file extension (e.g., "csv")<br />
|
||||||
|
${`date:format`} - Current date using Go's time format:<br />
|
||||||
|
• 2006-01-02 → YYYY-MM-DD<br />
|
||||||
|
• 20060102 → YYYYMMDD<br />
|
||||||
|
• 2006-01-02 15:04:05 → YYYY-MM-DD_HH:MM:SS<br />
|
||||||
|
Example: ${`filename`}_${`date:2006-01-02`}_${`ext`} → "report_2023-03-01.csv"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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="mb-4">
|
||||||
|
<label for="archive_enabled" class="flex items-center cursor-pointer">
|
||||||
|
<div class="relative">
|
||||||
|
<input id="archive_enabled" name="archive_enabled" type="checkbox" x-model="archiveEnabled"
|
||||||
|
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"
|
||||||
|
:class="archiveEnabled ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-gray-700 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>
|
||||||
|
<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>
|
||||||
|
</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"
|
||||||
|
placeholder="Path to archive files"
|
||||||
|
x-model="archivePath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Files will be moved here after successful transfer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="delete_after_transfer" class="flex items-center cursor-pointer">
|
||||||
|
<div class="relative">
|
||||||
|
<input id="delete_after_transfer" name="delete_after_transfer" type="checkbox" x-model="deleteAfterTransfer"
|
||||||
|
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"
|
||||||
|
:class="deleteAfterTransfer ? 'transform translate-x-6 bg-red-500' : ''"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-gray-700 font-medium">
|
||||||
|
Delete source files after transfer
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 ml-14 text-xs text-red-500" x-show="deleteAfterTransfer">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i> Warning: This will permanently delete the original files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="skip_processed_files" class="flex items-center cursor-pointer">
|
||||||
|
<div class="relative">
|
||||||
|
<input id="skip_processed_files" name="skip_processed_files" type="checkbox" x-model="skipProcessedFiles"
|
||||||
|
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"
|
||||||
|
:class="skipProcessedFiles ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-gray-700 font-medium">
|
||||||
|
Skip files that have already been processed
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 ml-14 text-xs text-gray-500">
|
||||||
|
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>
|
||||||
|
<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"
|
||||||
|
min="1" max="32" step="1"
|
||||||
|
x-model="maxConcurrentTransfers"
|
||||||
|
/>
|
||||||
|
<span class="ml-4 text-gray-700" x-text="maxConcurrentTransfers"></span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Number of files to transfer simultaneously (higher values may improve performance but increase resource usage)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ RcloneFlags() {
|
||||||
|
<div>
|
||||||
|
<label for="rclone_flags" class="block text-sm font-medium text-gray-700">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>
|
||||||
|
</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">
|
||||||
|
Optional: Additional rclone flags for fine-tuning the transfer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SourceSelection() {
|
||||||
|
<div class="sm:col-span-3">
|
||||||
|
<label for="source_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Source
|
||||||
|
Type</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<select id="source_type" name="source_type" x-model="sourceType"
|
||||||
|
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">
|
||||||
|
<option value="local">Local</option>
|
||||||
|
<option value="sftp">SFTP</option>
|
||||||
|
<option value="ftp">FTP</option>
|
||||||
|
<option value="s3">S3</option>
|
||||||
|
<option value="minio">MinIO</option>
|
||||||
|
<option value="smb">SMB</option>
|
||||||
|
<option value="nextcloud">NextCloud</option>
|
||||||
|
<option value="webdav">WebDAV</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ DestinationSelection() {
|
||||||
|
<div class="sm:col-span-3">
|
||||||
|
<label for="destination_type"
|
||||||
|
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Destination Type</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<select id="destination_type" name="destination_type" x-model="destinationType"
|
||||||
|
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">
|
||||||
|
<option value="local">Local</option>
|
||||||
|
<option value="sftp">SFTP</option>
|
||||||
|
<option value="ftp">FTP</option>
|
||||||
|
<option value="s3">S3</option>
|
||||||
|
<option value="minio">MinIO</option>
|
||||||
|
<option value="smb">SMB</option>
|
||||||
|
<option value="nextcloud">NextCloud</option>
|
||||||
|
<option value="webdav">WebDAV</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package destination contains the UI destination forms for the GoMFT application.
|
||||||
|
// This file serves as a marker for the destination package to ensure it's properly recognized by Go.
|
||||||
|
package destination
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ FTPDestinationForm() {
|
||||||
|
<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">Host</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server 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="ftp.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="dest_port"
|
||||||
|
id="dest_port"
|
||||||
|
x-model="destPort"
|
||||||
|
required
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value="21"
|
||||||
|
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="21"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote 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>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="destination_path"
|
||||||
|
id="destination_path"
|
||||||
|
x-model="destinationPath"
|
||||||
|
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="/path/to/destination"/>
|
||||||
|
</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 class="flex items-start mb-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
id="dest_passive_mode"
|
||||||
|
name="dest_passive_mode"
|
||||||
|
type="checkbox"
|
||||||
|
x-model="destPassiveMode"
|
||||||
|
:value="destPassiveMode ? 'true' : 'false'"
|
||||||
|
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm">
|
||||||
|
<label for="dest_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
|
||||||
|
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ LocalDestinationForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="destination_path"
|
||||||
|
id="destination_path"
|
||||||
|
x-model="destinationPath"
|
||||||
|
required
|
||||||
|
aria-describedby="destination_path_help"
|
||||||
|
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"/>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ MinIODestinationForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint 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-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_endpoint"
|
||||||
|
id="dest_endpoint"
|
||||||
|
x-model="destEndpoint"
|
||||||
|
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://minio.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_bucket"
|
||||||
|
id="dest_bucket"
|
||||||
|
x-model="destBucket"
|
||||||
|
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="my-bucket"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</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"
|
||||||
|
name="destination_path"
|
||||||
|
id="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/prefix/"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. If specified, files will be uploaded to this path in the bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</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"
|
||||||
|
name="dest_access_key"
|
||||||
|
id="dest_access_key"
|
||||||
|
x-model="destAccessKey"
|
||||||
|
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_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="dest_secret_key"
|
||||||
|
id="dest_secret_key"
|
||||||
|
x-model="destSecretKey"
|
||||||
|
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>
|
||||||
|
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ NextCloudDestinationForm() {
|
||||||
|
<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</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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ S3DestinationForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_bucket"
|
||||||
|
id="dest_bucket"
|
||||||
|
x-model="destBucket"
|
||||||
|
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="my-bucket"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</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-americas text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_region"
|
||||||
|
id="dest_region"
|
||||||
|
x-model="destRegion"
|
||||||
|
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="us-east-1"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">S3 Path Prefix</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"
|
||||||
|
name="destination_path"
|
||||||
|
id="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="optional/path/prefix/"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. If specified, files will be uploaded to this path in the bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</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"
|
||||||
|
name="dest_access_key"
|
||||||
|
id="dest_access_key"
|
||||||
|
x-model="destAccessKey"
|
||||||
|
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_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="dest_secret_key"
|
||||||
|
id="dest_secret_key"
|
||||||
|
x-model="destSecretKey"
|
||||||
|
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>
|
||||||
|
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ SFTPDestinationForm() {
|
||||||
|
<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">Host</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_host"
|
||||||
|
id="dest_host"
|
||||||
|
x-model="destHost"
|
||||||
|
required
|
||||||
|
aria-describedby="dest_host_help"
|
||||||
|
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="sftp.example.com"/>
|
||||||
|
</div>
|
||||||
|
<p id="dest_host_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Enter the SFTP server hostname or IP address.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="dest_port"
|
||||||
|
id="dest_port"
|
||||||
|
x-model="destPort"
|
||||||
|
required
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value="22"
|
||||||
|
aria-describedby="dest_port_help"
|
||||||
|
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="22"/>
|
||||||
|
</div>
|
||||||
|
<p id="dest_port_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Default SFTP port is 22.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote 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>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="destination_path"
|
||||||
|
id="destination_path"
|
||||||
|
x-model="destinationPath"
|
||||||
|
required
|
||||||
|
aria-describedby="destination_path_help"
|
||||||
|
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/files"/>
|
||||||
|
</div>
|
||||||
|
<p id="destination_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Absolute path to the files on the remote server.
|
||||||
|
</p>
|
||||||
|
</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
|
||||||
|
aria-describedby="dest_user_help"
|
||||||
|
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>
|
||||||
|
<p id="dest_user_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Username for SFTP authentication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_auth_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Authentication Type</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="dest_auth_type"
|
||||||
|
name="dest_auth_type"
|
||||||
|
x-model="destAuthType"
|
||||||
|
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">
|
||||||
|
<option value="password">Password</option>
|
||||||
|
<option value="key_file">SSH Key File</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4" x-show="destAuthType === 'password'">
|
||||||
|
<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"
|
||||||
|
x-bind:required="destAuthType === 'password'"
|
||||||
|
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 class="sm:col-span-4" x-show="destAuthType === 'key_file'">
|
||||||
|
<label for="dest_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_key_file"
|
||||||
|
id="dest_key_file"
|
||||||
|
x-model="destKeyFile"
|
||||||
|
x-bind:required="destAuthType === 'key_file'"
|
||||||
|
aria-describedby="dest_key_file_help"
|
||||||
|
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/key"/>
|
||||||
|
</div>
|
||||||
|
<p id="dest_key_file_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Absolute path to SSH private key file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ SMBDestinationForm() {
|
||||||
|
<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">Server</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server 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="server.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_share"
|
||||||
|
id="dest_share"
|
||||||
|
x-model="destShare"
|
||||||
|
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="share_name"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-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 text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="destination_path"
|
||||||
|
id="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\to\destination"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Subdirectory within the share.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_domain"
|
||||||
|
id="dest_domain"
|
||||||
|
x-model="destDomain"
|
||||||
|
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="DOMAIN"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Windows domain for authentication.
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ WebDAVDestinationForm() {
|
||||||
|
<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="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote 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>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="destination_path"
|
||||||
|
id="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/to/destination"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Relative to the WebDAV URL.
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/starfleetcptn/gomft/components/providers/common"
|
||||||
|
"github.com/starfleetcptn/gomft/components/providers/source"
|
||||||
|
"github.com/starfleetcptn/gomft/components/providers/destination"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Returns the form ID based on the form type and whether it's a source or destination
|
||||||
|
func formID(formType string, isSource bool) string {
|
||||||
|
if isSource {
|
||||||
|
return "source_config_form"
|
||||||
|
}
|
||||||
|
return "destination_config_form"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a user-friendly display name for the provider
|
||||||
|
func providerDisplayName(provider string) string {
|
||||||
|
switch provider {
|
||||||
|
case "sftp":
|
||||||
|
return "SFTP"
|
||||||
|
case "local":
|
||||||
|
return "Local Filesystem"
|
||||||
|
case "s3":
|
||||||
|
return "Amazon S3"
|
||||||
|
case "ftp":
|
||||||
|
return "FTP"
|
||||||
|
case "azure":
|
||||||
|
return "Azure Blob Storage"
|
||||||
|
default:
|
||||||
|
return strings.Title(provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ProviderForm(formType string, providers []string, isSource bool) {
|
||||||
|
<form
|
||||||
|
id={formID(formType, isSource)}
|
||||||
|
x-data={fmt.Sprintf("{ %sProvider: '', showAdvanced: false }", formType)}
|
||||||
|
class="space-y-8">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-12 gap-y-6 gap-x-4">
|
||||||
|
@common.NameField()
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for={fmt.Sprintf("%s_provider", formType)} class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Provider Type</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id={fmt.Sprintf("%s_provider", formType)}
|
||||||
|
name={fmt.Sprintf("%s_provider", formType)}
|
||||||
|
x-model={fmt.Sprintf("%sProvider", formType)}
|
||||||
|
class="form-select 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">
|
||||||
|
<option value="" disabled selected>Select provider type</option>
|
||||||
|
for _, provider := range providers {
|
||||||
|
<option value={provider}>{providerDisplayName(provider)}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'sftp'", formType)}>
|
||||||
|
if isSource {
|
||||||
|
@source.SFTPSourceForm()
|
||||||
|
} else {
|
||||||
|
@destination.SFTPDestinationForm()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'local'", formType)}>
|
||||||
|
if isSource {
|
||||||
|
@source.LocalSourceForm()
|
||||||
|
} else {
|
||||||
|
@destination.LocalDestinationForm()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 's3'", formType)}>
|
||||||
|
if isSource {
|
||||||
|
@source.S3SourceForm()
|
||||||
|
} else {
|
||||||
|
@destination.S3DestinationForm()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'ftp'", formType)}>
|
||||||
|
if isSource {
|
||||||
|
@source.FTPSourceForm()
|
||||||
|
} else {
|
||||||
|
@destination.FTPDestinationForm()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-12" x-show={fmt.Sprintf("%sProvider", formType)}>
|
||||||
|
<div class="mt-6">
|
||||||
|
<label for="show_advanced" class="flex items-center cursor-pointer">
|
||||||
|
<div class="relative">
|
||||||
|
<input id="show_advanced" type="checkbox" x-model="showAdvanced" class="sr-only" />
|
||||||
|
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
|
||||||
|
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
|
||||||
|
:class="showAdvanced ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-gray-700 font-medium">
|
||||||
|
Show Advanced Options
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="showAdvanced">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-12 gap-y-6 gap-x-4 mt-6">
|
||||||
|
@common.FilePatternFields()
|
||||||
|
if isSource {
|
||||||
|
@common.ArchiveOptions()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
script formAlpineInit() {
|
||||||
|
return {
|
||||||
|
initProviderForm() {
|
||||||
|
// Initialize with values if editing existing config
|
||||||
|
if (window.editData && window.editData.configs) {
|
||||||
|
const config = window.editData.configs.find(c =>
|
||||||
|
isSource ? (c.id === window.editData.source_config_id) : (c.id === window.editData.destination_config_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
this[formType + 'Provider'] = config.provider;
|
||||||
|
this.name = config.name;
|
||||||
|
|
||||||
|
// Provider-specific fields
|
||||||
|
if (config.provider === 'sftp') {
|
||||||
|
this.host = config.host;
|
||||||
|
this.port = config.port;
|
||||||
|
this.username = config.username;
|
||||||
|
this.path = config.path;
|
||||||
|
|
||||||
|
if (config.key_file && config.key_file !== '') {
|
||||||
|
this.authType = 'key_file';
|
||||||
|
this.keyFile = config.key_file;
|
||||||
|
} else {
|
||||||
|
this.authType = 'password';
|
||||||
|
// Password is not included in edit data for security
|
||||||
|
}
|
||||||
|
} else if (config.provider === 'local') {
|
||||||
|
this.path = config.path;
|
||||||
|
} else if (config.provider === 's3') {
|
||||||
|
this.bucket = config.bucket;
|
||||||
|
this.region = config.region;
|
||||||
|
this.path = config.path;
|
||||||
|
this.accessKey = config.access_key;
|
||||||
|
|
||||||
|
if (config.endpoint && config.endpoint !== '') {
|
||||||
|
this.useCustomEndpoint = true;
|
||||||
|
this.endpoint = config.endpoint;
|
||||||
|
} else {
|
||||||
|
this.useCustomEndpoint = false;
|
||||||
|
}
|
||||||
|
} else if (config.provider === 'ftp') {
|
||||||
|
this.host = config.host;
|
||||||
|
this.port = config.port;
|
||||||
|
this.username = config.username;
|
||||||
|
this.path = config.path;
|
||||||
|
this.useFTPS = config.use_ftps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advanced options
|
||||||
|
if (config.include_pattern) this.filePattern = config.include_pattern;
|
||||||
|
if (config.exclude_pattern) this.excludePattern = config.exclude_pattern;
|
||||||
|
|
||||||
|
if (isSource && config.extract_archives) {
|
||||||
|
this.extractArchives = true;
|
||||||
|
this.deleteArchives = config.delete_archives;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
providerChanged() {
|
||||||
|
console.log("Provider changed to: " + this[formType + 'Provider']);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package providers contains the UI providers for the GoMFT application.
|
||||||
|
// This file serves as a marker for the providers package to ensure it's properly recognized by Go.
|
||||||
|
package providers
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/starfleetcptn/gomft/components/providers/common"
|
||||||
|
"github.com/starfleetcptn/gomft/components/providers/destination"
|
||||||
|
"github.com/starfleetcptn/gomft/components/providers/source"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test that both source and destination providers can be rendered together with common components
|
||||||
|
func TestProvidersIntegration(t *testing.T) {
|
||||||
|
// Create context for test
|
||||||
|
ctx := context.Background()
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// Test rendering common components
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
err := common.NameField().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render NameField")
|
||||||
|
html := buf.String()
|
||||||
|
assert.Contains(html, `<label for="name"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test rendering source components
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
err := source.LocalSourceForm().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render LocalSourceForm")
|
||||||
|
html := buf.String()
|
||||||
|
assert.Contains(html, `<label for="source_path"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test rendering destination components
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
err := destination.LocalDestinationForm().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render LocalDestinationForm")
|
||||||
|
html := buf.String()
|
||||||
|
assert.Contains(html, `<label for="destination_path"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that all source providers are available
|
||||||
|
func TestSourceProviders(t *testing.T) {
|
||||||
|
// Create context for test
|
||||||
|
ctx := context.Background()
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// Test each source provider can be rendered
|
||||||
|
providers := []struct {
|
||||||
|
name string
|
||||||
|
template func() (string, error)
|
||||||
|
}{
|
||||||
|
{"LocalSourceForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := source.LocalSourceForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
{"SFTPSourceForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := source.SFTPSourceForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
{"S3SourceForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := source.S3SourceForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
{"FTPSourceForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := source.FTPSourceForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
{"SMBSourceForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := source.SMBSourceForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
{"WebDAVSourceForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := source.WebDAVSourceForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, provider := range providers {
|
||||||
|
t.Run(provider.name, func(t *testing.T) {
|
||||||
|
html, err := provider.template()
|
||||||
|
assert.NoError(err, "Failed to render "+provider.name)
|
||||||
|
assert.NotEmpty(html, provider.name+" rendered empty HTML")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that all destination providers are available
|
||||||
|
func TestDestinationProviders(t *testing.T) {
|
||||||
|
// Create context for test
|
||||||
|
ctx := context.Background()
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// Test each destination provider can be rendered
|
||||||
|
providers := []struct {
|
||||||
|
name string
|
||||||
|
template func() (string, error)
|
||||||
|
}{
|
||||||
|
{"LocalDestinationForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := destination.LocalDestinationForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
{"SFTPDestinationForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := destination.SFTPDestinationForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
{"S3DestinationForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := destination.S3DestinationForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
{"FTPDestinationForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := destination.FTPDestinationForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
{"SMBDestinationForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := destination.SMBDestinationForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
{"WebDAVDestinationForm", func() (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
err := destination.WebDAVDestinationForm().Render(ctx, &buf)
|
||||||
|
return buf.String(), err
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, provider := range providers {
|
||||||
|
t.Run(provider.name, func(t *testing.T) {
|
||||||
|
html, err := provider.template()
|
||||||
|
assert.NoError(err, "Failed to render "+provider.name)
|
||||||
|
assert.NotEmpty(html, provider.name+" rendered empty HTML")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the complete configuration wizard flow
|
||||||
|
func TestConfigurationWizard(t *testing.T) {
|
||||||
|
// Create context for test
|
||||||
|
ctx := context.Background()
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// First test common configuration fields
|
||||||
|
var buf strings.Builder
|
||||||
|
err := common.NameField().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render name field")
|
||||||
|
nameField := buf.String()
|
||||||
|
assert.Contains(nameField, `<input type="text" name="name" id="name"`)
|
||||||
|
|
||||||
|
// Test source selection
|
||||||
|
buf.Reset()
|
||||||
|
err = common.SourceSelection().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render source selection")
|
||||||
|
sourceSelection := buf.String()
|
||||||
|
assert.Contains(sourceSelection, `<select id="source_type" name="source_type"`)
|
||||||
|
|
||||||
|
// Test specific source form (local example)
|
||||||
|
buf.Reset()
|
||||||
|
err = source.LocalSourceForm().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render local source form")
|
||||||
|
localSource := buf.String()
|
||||||
|
assert.Contains(localSource, `<input type="text" name="source_path" id="source_path"`)
|
||||||
|
|
||||||
|
// Test destination selection
|
||||||
|
buf.Reset()
|
||||||
|
err = common.DestinationSelection().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render destination selection")
|
||||||
|
destinationSelection := buf.String()
|
||||||
|
assert.Contains(destinationSelection, `<select id="destination_type" name="destination_type"`)
|
||||||
|
|
||||||
|
// Test specific destination form (S3 example)
|
||||||
|
buf.Reset()
|
||||||
|
err = destination.S3DestinationForm().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render S3 destination form")
|
||||||
|
s3Destination := buf.String()
|
||||||
|
assert.Contains(s3Destination, `<input type="text" name="dest_bucket" id="dest_bucket"`)
|
||||||
|
|
||||||
|
// Test advanced options
|
||||||
|
buf.Reset()
|
||||||
|
err = common.ArchiveOptions().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render archive options")
|
||||||
|
archiveOptions := buf.String()
|
||||||
|
assert.Contains(archiveOptions, `Enable archiving`)
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
err = common.FilePatternFields().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render file pattern fields")
|
||||||
|
filePatterns := buf.String()
|
||||||
|
assert.Contains(filePatterns, `<input type="text" name="file_pattern" id="file_pattern"`)
|
||||||
|
|
||||||
|
// All essential components for the configuration wizard are present and renderable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that provider forms have proper conditional logic
|
||||||
|
func TestProviderFormConditionals(t *testing.T) {
|
||||||
|
// Create context for test
|
||||||
|
ctx := context.Background()
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// Test SFTP Source form conditionals (password vs key file)
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
err := source.SFTPSourceForm().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render SFTP source form")
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
// Should have auth type selection
|
||||||
|
assert.Contains(html, `<select id="source_auth_type" name="source_auth_type"`)
|
||||||
|
|
||||||
|
// Should have password field that's conditionally shown
|
||||||
|
assert.Contains(html, `x-show="sourceAuthType === 'password'"`)
|
||||||
|
assert.Contains(html, `<input type="password" name="source_password"`)
|
||||||
|
|
||||||
|
// Should have key file field that's conditionally shown
|
||||||
|
assert.Contains(html, `x-show="sourceAuthType === 'key_file'"`)
|
||||||
|
assert.Contains(html, `<input type="text" name="source_key_file"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test S3 Source form conditionals
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
err := source.S3SourceForm().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render S3 source form")
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
// Should have both required and optional fields
|
||||||
|
assert.Contains(html, `<input type="text" name="source_bucket" id="source_bucket" x-model="sourceBucket" required`)
|
||||||
|
assert.Contains(html, `<input type="text" name="source_region" id="source_region"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test advanced options show/hide behavior
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
err := common.ArchiveOptions().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render archive options")
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
// Archive path should only show when archive is enabled
|
||||||
|
assert.Contains(html, `x-show="archiveEnabled"`)
|
||||||
|
assert.Contains(html, `<input id="archive_path" name="archive_path" type="text"`)
|
||||||
|
|
||||||
|
// Toggle behavior
|
||||||
|
assert.Contains(html, `x-model="archiveEnabled"`)
|
||||||
|
assert.Contains(html, `<input id="archive_enabled" name="archive_enabled" type="checkbox"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test for accessibility attributes in provider forms
|
||||||
|
func TestProviderFormsAccessibility(t *testing.T) {
|
||||||
|
// Create context for test
|
||||||
|
ctx := context.Background()
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// Test source form for accessibility
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
err := source.LocalSourceForm().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render local source form")
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
// Should have labels with proper for attributes
|
||||||
|
assert.Contains(html, `<label for="source_path"`)
|
||||||
|
|
||||||
|
// Should have input with id matching label's for attribute
|
||||||
|
assert.Contains(html, `<input type="text" name="source_path" id="source_path"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test destination form for accessibility
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
err := destination.LocalDestinationForm().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render local destination form")
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
// Should have labels with proper for attributes
|
||||||
|
assert.Contains(html, `<label for="destination_path"`)
|
||||||
|
|
||||||
|
// Should have input with id matching label's for attribute
|
||||||
|
assert.Contains(html, `<input type="text" name="destination_path" id="destination_path"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test dynamic form rendering based on provider selection
|
||||||
|
func TestDynamicFormRendering(t *testing.T) {
|
||||||
|
// Create context for test
|
||||||
|
ctx := context.Background()
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// Test source selection dynamic rendering
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
err := common.SourceSelection().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render source selection")
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
// Should have x-model for binding selected value
|
||||||
|
assert.Contains(html, `x-model="sourceType"`)
|
||||||
|
|
||||||
|
// The source selection component doesn't contain x-show attributes
|
||||||
|
// These assertions are removed as they're not part of the actual component
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test destination selection dynamic rendering
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
err := common.DestinationSelection().Render(ctx, &buf)
|
||||||
|
assert.NoError(err, "Failed to render destination selection")
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
// Should have x-model for binding selected value
|
||||||
|
assert.Contains(html, `x-model="destinationType"`)
|
||||||
|
|
||||||
|
// The destination selection component doesn't contain x-show attributes
|
||||||
|
// These assertions are removed as they're not part of the actual component
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test for proper Alpine.js initialization
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
err := source.LocalSourceForm().Render(ctx, &buf)
|
||||||
|
assert.NoError(err)
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
// The LocalSourceForm doesn't initialize Alpine.js data
|
||||||
|
// It's expected to be used within a parent component that does
|
||||||
|
assert.Contains(html, `x-model="sourcePath"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that wizard has a submission handler
|
||||||
|
{
|
||||||
|
var buf strings.Builder
|
||||||
|
// The source selection component doesn't contain form tags
|
||||||
|
// These assertions are checking for elements that should be in a parent component
|
||||||
|
err := common.SourceSelection().Render(ctx, &buf)
|
||||||
|
assert.NoError(err)
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
// Check for the select element instead
|
||||||
|
assert.Contains(html, `<select id="source_type" name="source_type"`)
|
||||||
|
assert.Contains(html, `x-model="sourceType"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ FTPSourceForm() {
|
||||||
|
<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">Host</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server 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="ftp.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="source_port"
|
||||||
|
id="source_port"
|
||||||
|
x-model="sourcePort"
|
||||||
|
required
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value="21"
|
||||||
|
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="21"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote 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>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
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="/path/to/files"/>
|
||||||
|
</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 class="flex items-start mb-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
id="source_passive_mode"
|
||||||
|
name="source_passive_mode"
|
||||||
|
type="checkbox"
|
||||||
|
x-model="sourcePassiveMode"
|
||||||
|
:value="sourcePassiveMode ? 'true' : 'false'"
|
||||||
|
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm">
|
||||||
|
<label for="source_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
|
||||||
|
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ LocalSourceForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
required
|
||||||
|
aria-describedby="source_path_help"
|
||||||
|
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"/>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ MinIOSourceForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint 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-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_endpoint"
|
||||||
|
id="source_endpoint"
|
||||||
|
x-model="sourceEndpoint"
|
||||||
|
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://minio.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_bucket"
|
||||||
|
id="source_bucket"
|
||||||
|
x-model="sourceBucket"
|
||||||
|
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="my-bucket"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</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"
|
||||||
|
name="source_path"
|
||||||
|
id="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/prefix/"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. If specified, only files in this path will be processed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</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"
|
||||||
|
name="source_access_key"
|
||||||
|
id="source_access_key"
|
||||||
|
x-model="sourceAccessKey"
|
||||||
|
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_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="source_secret_key"
|
||||||
|
id="source_secret_key"
|
||||||
|
x-model="sourceSecretKey"
|
||||||
|
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>
|
||||||
|
<input type="hidden" name="source_secret_key" :value="sourceSecretKey"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ NextCloudSourceForm() {
|
||||||
|
<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_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote 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>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="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="/remote.php/dav/files/username/path/to/files"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. If left empty, root directory will be used.
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ S3SourceForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fab fa-aws text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_bucket"
|
||||||
|
id="source_bucket"
|
||||||
|
x-model="sourceBucket"
|
||||||
|
required
|
||||||
|
aria-describedby="source_bucket_help"
|
||||||
|
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="my-bucket"/>
|
||||||
|
</div>
|
||||||
|
<p id="source_bucket_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Enter your S3 bucket name.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</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_region"
|
||||||
|
id="source_region"
|
||||||
|
x-model="sourceRegion"
|
||||||
|
required
|
||||||
|
aria-describedby="source_region_help"
|
||||||
|
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="us-west-2"/>
|
||||||
|
</div>
|
||||||
|
<p id="source_region_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
AWS region for the S3 bucket (e.g., us-west-2).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</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"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
aria-describedby="source_path_help"
|
||||||
|
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/files/"/>
|
||||||
|
</div>
|
||||||
|
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional path prefix within the bucket (e.g., 'path/to/files/').
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key 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-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_access_key"
|
||||||
|
id="source_access_key"
|
||||||
|
x-model="sourceAccessKey"
|
||||||
|
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="AKIAIOSFODNN7EXAMPLE"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Access Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="source_secret_key"
|
||||||
|
id="source_secret_key"
|
||||||
|
x-model="sourceSecretKey"
|
||||||
|
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="Your secret access key"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ SFTPSourceForm() {
|
||||||
|
<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">Host</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_host"
|
||||||
|
id="source_host"
|
||||||
|
x-model="sourceHost"
|
||||||
|
required
|
||||||
|
aria-describedby="source_host_help"
|
||||||
|
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="sftp.example.com"/>
|
||||||
|
</div>
|
||||||
|
<p id="source_host_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Enter the SFTP server hostname or IP address.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="source_port"
|
||||||
|
id="source_port"
|
||||||
|
x-model="sourcePort"
|
||||||
|
required
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value="22"
|
||||||
|
aria-describedby="source_port_help"
|
||||||
|
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="22"/>
|
||||||
|
</div>
|
||||||
|
<p id="source_port_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Default SFTP port is 22.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote 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>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
required
|
||||||
|
aria-describedby="source_path_help"
|
||||||
|
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/files"/>
|
||||||
|
</div>
|
||||||
|
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Absolute path to the files on the remote server.
|
||||||
|
</p>
|
||||||
|
</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
|
||||||
|
aria-describedby="source_user_help"
|
||||||
|
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>
|
||||||
|
<p id="source_user_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Username for SFTP authentication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_auth_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Authentication Type</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="source_auth_type"
|
||||||
|
name="source_auth_type"
|
||||||
|
x-model="sourceAuthType"
|
||||||
|
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">
|
||||||
|
<option value="password">Password</option>
|
||||||
|
<option value="key_file">SSH Key File</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4" x-show="sourceAuthType === 'password'">
|
||||||
|
<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"
|
||||||
|
x-bind:required="sourceAuthType === 'password'"
|
||||||
|
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 class="sm:col-span-4" x-show="sourceAuthType === 'key_file'">
|
||||||
|
<label for="source_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_key_file"
|
||||||
|
id="source_key_file"
|
||||||
|
x-model="sourceKeyFile"
|
||||||
|
x-bind:required="sourceAuthType === 'key_file'"
|
||||||
|
aria-describedby="source_key_file_help"
|
||||||
|
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/key"/>
|
||||||
|
</div>
|
||||||
|
<p id="source_key_file_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Absolute path to SSH private key file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ SMBSourceForm() {
|
||||||
|
<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">Server</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server 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="server.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_share"
|
||||||
|
id="source_share"
|
||||||
|
x-model="sourceShare"
|
||||||
|
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="share_name"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-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 text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="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\to\files"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Subdirectory within the share.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_domain"
|
||||||
|
id="source_domain"
|
||||||
|
x-model="sourceDomain"
|
||||||
|
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="DOMAIN"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Windows domain for authentication.
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package source contains the UI source forms for the GoMFT application.
|
||||||
|
// This file serves as a marker for the source package to ensure it's properly recognized by Go.
|
||||||
|
package source
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ WebDAVSourceForm() {
|
||||||
|
<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_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote 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>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="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/to/files"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Relative to the WebDAV URL.
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
+9
-4
@@ -8,15 +8,18 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
# Persist data directory for SQLite database and configurations
|
# Main data directory - contains DB and configs
|
||||||
- gomft-data:/app/data
|
- gomft-data:/app/data
|
||||||
|
# Separate backups directory
|
||||||
|
- gomft-backups:/app/backups
|
||||||
# For development, you can mount the source code
|
# For development, you can mount the source code
|
||||||
# - .:/app
|
# - .:/app
|
||||||
environment:
|
environment:
|
||||||
- TZ=UTC
|
- TZ=UTC
|
||||||
# Add any environment variables needed for configuration
|
- DATA_DIR=/app/data
|
||||||
# - GOMFT_DB_PATH=/app/data/gomft.db
|
- BACKUP_DIR=/app/backups
|
||||||
# - GOMFT_LOG_LEVEL=info
|
- LOGS_DIR=/app/data/logs
|
||||||
|
# - LOG_LEVEL=info
|
||||||
networks:
|
networks:
|
||||||
- gomft-network
|
- gomft-network
|
||||||
|
|
||||||
@@ -27,3 +30,5 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
gomft-data:
|
gomft-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
gomft-backups:
|
||||||
|
driver: local
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"server_address": ":8080",
|
|
||||||
"data_dir": "/app/data/gomft",
|
|
||||||
"backup_dir": "/app/data/gomft/backups",
|
|
||||||
"jwt_secret": "change_this_to_a_secure_random_string",
|
|
||||||
"email": {
|
|
||||||
"enabled": true,
|
|
||||||
"host": "smtp.example.com",
|
|
||||||
"port": 587,
|
|
||||||
"from_email": "gomft@example.com",
|
|
||||||
"from_name": "GoMFT",
|
|
||||||
"reply_to": "",
|
|
||||||
"enable_tls": true,
|
|
||||||
"require_auth": true,
|
|
||||||
"username": "smtp_username",
|
|
||||||
"password": "smtp_password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,14 @@ go 1.24.0
|
|||||||
require (
|
require (
|
||||||
github.com/a-h/templ v0.3.833
|
github.com/a-h/templ v0.3.833
|
||||||
github.com/gin-gonic/gin v1.10.0
|
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/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/crypto v0.35.0
|
golang.org/x/crypto v0.35.0
|
||||||
gorm.io/driver/sqlite v1.5.7
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,22 +20,28 @@ require (
|
|||||||
github.com/bytedance/sonic v1.12.9 // indirect
|
github.com/bytedance/sonic v1.12.9 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.0.0 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.25.0 // indirect
|
github.com/go-playground/validator/v10 v10.25.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // 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/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.14.0 // indirect
|
golang.org/x/arch v0.14.0 // indirect
|
||||||
@@ -40,4 +50,8 @@ require (
|
|||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
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 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
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 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
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=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -32,10 +40,16 @@ 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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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.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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
@@ -46,8 +60,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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -57,11 +69,15 @@ 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
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=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -90,11 +106,19 @@ google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwl
|
|||||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
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=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateAndValidateToken(t *testing.T) {
|
||||||
|
// Setup test data
|
||||||
|
userID := uint(1)
|
||||||
|
email := "test@example.com"
|
||||||
|
secret := "test-jwt-secret"
|
||||||
|
expirationTime := 1 * time.Hour
|
||||||
|
|
||||||
|
// Generate a token
|
||||||
|
token, err := GenerateToken(userID, email, secret, expirationTime)
|
||||||
|
assert.NoError(t, err, "Should not return an error when generating a token")
|
||||||
|
assert.NotEmpty(t, token, "Token should not be empty")
|
||||||
|
|
||||||
|
// Validate the token
|
||||||
|
claims, err := ValidateToken(token, secret)
|
||||||
|
assert.NoError(t, err, "Should not return an error when validating a valid token")
|
||||||
|
assert.NotNil(t, claims, "Claims should not be nil")
|
||||||
|
assert.Equal(t, userID, claims.UserID, "UserID should match")
|
||||||
|
assert.Equal(t, email, claims.Email, "Email should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidToken(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
invalidToken := "invalid.token.string"
|
||||||
|
secret := "test-jwt-secret"
|
||||||
|
|
||||||
|
// Validate the invalid token
|
||||||
|
claims, err := ValidateToken(invalidToken, secret)
|
||||||
|
assert.Error(t, err, "Should return an error when validating an invalid token")
|
||||||
|
assert.Nil(t, claims, "Claims should be nil for an invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredToken(t *testing.T) {
|
||||||
|
// Setup test data
|
||||||
|
userID := uint(1)
|
||||||
|
email := "test@example.com"
|
||||||
|
secret := "test-jwt-secret"
|
||||||
|
expirationTime := -1 * time.Hour // Negative duration to create an expired token
|
||||||
|
|
||||||
|
// Generate an expired token
|
||||||
|
token, err := GenerateToken(userID, email, secret, expirationTime)
|
||||||
|
assert.NoError(t, err, "Should not return an error when generating a token")
|
||||||
|
|
||||||
|
// Validate the expired token
|
||||||
|
claims, err := ValidateToken(token, secret)
|
||||||
|
assert.Error(t, err, "Should return an error when validating an expired token")
|
||||||
|
assert.Nil(t, claims, "Claims should be nil for an expired token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidSecret(t *testing.T) {
|
||||||
|
// Setup test data
|
||||||
|
userID := uint(1)
|
||||||
|
email := "test@example.com"
|
||||||
|
secret := "original-secret"
|
||||||
|
wrongSecret := "wrong-secret"
|
||||||
|
expirationTime := 1 * time.Hour
|
||||||
|
|
||||||
|
// Generate a token with the original secret
|
||||||
|
token, err := GenerateToken(userID, email, secret, expirationTime)
|
||||||
|
assert.NoError(t, err, "Should not return an error when generating a token")
|
||||||
|
|
||||||
|
// Validate the token with the wrong secret
|
||||||
|
claims, err := ValidateToken(token, wrongSecret)
|
||||||
|
assert.Error(t, err, "Should return an error when validating with the wrong secret")
|
||||||
|
assert.Nil(t, claims, "Claims should be nil when validating with the wrong secret")
|
||||||
|
}
|
||||||
+23
-32
@@ -13,39 +13,39 @@ import (
|
|||||||
|
|
||||||
// PasswordPolicy defines the requirements for password strength and management
|
// PasswordPolicy defines the requirements for password strength and management
|
||||||
type PasswordPolicy struct {
|
type PasswordPolicy struct {
|
||||||
MinLength int // Minimum password length
|
MinLength int // Minimum password length
|
||||||
RequireUppercase bool // Require at least one uppercase letter
|
RequireUppercase bool // Require at least one uppercase letter
|
||||||
RequireLowercase bool // Require at least one lowercase letter
|
RequireLowercase bool // Require at least one lowercase letter
|
||||||
RequireNumbers bool // Require at least one number
|
RequireNumbers bool // Require at least one number
|
||||||
RequireSpecial bool // Require at least one special character
|
RequireSpecial bool // Require at least one special character
|
||||||
ExpirationDays int // Number of days until password expires (0 = never)
|
ExpirationDays int // Number of days until password expires (0 = never)
|
||||||
HistoryCount int // Number of previous passwords to remember (0 = disabled)
|
HistoryCount int // Number of previous passwords to remember (0 = disabled)
|
||||||
DisallowCommon bool // Disallow common passwords
|
DisallowCommon bool // Disallow common passwords
|
||||||
MaxLoginAttempts int // Maximum failed login attempts before lockout
|
MaxLoginAttempts int // Maximum failed login attempts before lockout
|
||||||
LockoutDuration time.Duration // Duration of account lockout after max failed attempts
|
LockoutDuration time.Duration // Duration of account lockout after max failed attempts
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasswordHistory represents a historical password entry
|
// PasswordHistory represents a historical password entry
|
||||||
type PasswordHistory struct {
|
type PasswordHistory struct {
|
||||||
ID uint `gorm:"primarykey"`
|
ID uint `gorm:"primarykey"`
|
||||||
UserID uint `gorm:"not null"`
|
UserID uint `gorm:"not null"`
|
||||||
PasswordHash string `gorm:"not null"`
|
PasswordHash string `gorm:"not null"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPasswordPolicy returns the default password policy
|
// DefaultPasswordPolicy returns the default password policy
|
||||||
func DefaultPasswordPolicy() PasswordPolicy {
|
func DefaultPasswordPolicy() PasswordPolicy {
|
||||||
return PasswordPolicy{
|
return PasswordPolicy{
|
||||||
MinLength: 8,
|
MinLength: 8,
|
||||||
RequireUppercase: true,
|
RequireUppercase: true,
|
||||||
RequireLowercase: true,
|
RequireLowercase: true,
|
||||||
RequireNumbers: true,
|
RequireNumbers: true,
|
||||||
RequireSpecial: true,
|
RequireSpecial: true,
|
||||||
ExpirationDays: 90,
|
ExpirationDays: 90,
|
||||||
HistoryCount: 5,
|
HistoryCount: 5,
|
||||||
DisallowCommon: true,
|
DisallowCommon: true,
|
||||||
MaxLoginAttempts: 5,
|
MaxLoginAttempts: 5,
|
||||||
LockoutDuration: 15 * time.Minute,
|
LockoutDuration: 15 * time.Minute,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,15 +168,6 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashPassword hashes a password using bcrypt
|
|
||||||
func HashPassword(password string) (string, error) {
|
|
||||||
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(hashedBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ComparePasswords compares a hashed password with a plain text password
|
// ComparePasswords compares a hashed password with a plain text password
|
||||||
func ComparePasswords(hashedPassword, plainPassword string) error {
|
func ComparePasswords(hashedPassword, plainPassword string) error {
|
||||||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
|
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockDB is a mock implementation of *gorm.DB for testing
|
||||||
|
type MockDB struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDB) Where(query interface{}, args ...interface{}) *gorm.DB {
|
||||||
|
m.Called(query, args)
|
||||||
|
return &gorm.DB{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDB) Order(value interface{}) *gorm.DB {
|
||||||
|
m.Called(value)
|
||||||
|
return &gorm.DB{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDB) Limit(limit int) *gorm.DB {
|
||||||
|
m.Called(limit)
|
||||||
|
return &gorm.DB{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDB) Find(dest interface{}, conds ...interface{}) *gorm.DB {
|
||||||
|
m.Called(dest, conds)
|
||||||
|
return &gorm.DB{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDB) Create(value interface{}) *gorm.DB {
|
||||||
|
m.Called(value)
|
||||||
|
return &gorm.DB{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDB) Delete(value interface{}, conds ...interface{}) *gorm.DB {
|
||||||
|
m.Called(value, conds)
|
||||||
|
return &gorm.DB{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDB) Model(value interface{}) *gorm.DB {
|
||||||
|
m.Called(value)
|
||||||
|
return &gorm.DB{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDB) Count(count *int64) *gorm.DB {
|
||||||
|
m.Called(count)
|
||||||
|
*count = 10 // Mock count for testing
|
||||||
|
return &gorm.DB{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPasswordPolicy(t *testing.T) {
|
||||||
|
policy := DefaultPasswordPolicy()
|
||||||
|
|
||||||
|
assert.Equal(t, 8, policy.MinLength, "Default min length should be 8")
|
||||||
|
assert.True(t, policy.RequireUppercase, "Should require uppercase by default")
|
||||||
|
assert.True(t, policy.RequireLowercase, "Should require lowercase by default")
|
||||||
|
assert.True(t, policy.RequireNumbers, "Should require numbers by default")
|
||||||
|
assert.True(t, policy.RequireSpecial, "Should require special chars by default")
|
||||||
|
assert.Equal(t, 90, policy.ExpirationDays, "Default expiration should be 90 days")
|
||||||
|
assert.Equal(t, 5, policy.HistoryCount, "Default history count should be 5")
|
||||||
|
assert.True(t, policy.DisallowCommon, "Should disallow common passwords by default")
|
||||||
|
assert.Equal(t, 5, policy.MaxLoginAttempts, "Default max login attempts should be 5")
|
||||||
|
assert.Equal(t, 15*time.Minute, policy.LockoutDuration, "Default lockout duration should be 15 minutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePassword(t *testing.T) {
|
||||||
|
policy := DefaultPasswordPolicy()
|
||||||
|
|
||||||
|
// Test valid password
|
||||||
|
err := ValidatePassword("Test1234!", policy)
|
||||||
|
assert.NoError(t, err, "Valid password should pass validation")
|
||||||
|
|
||||||
|
// Test password too short
|
||||||
|
err = ValidatePassword("Test1!", policy)
|
||||||
|
assert.Error(t, err, "Password shorter than minimum length should fail")
|
||||||
|
assert.Contains(t, err.Error(), "at least 8 characters")
|
||||||
|
|
||||||
|
// Test password without uppercase
|
||||||
|
err = ValidatePassword("test1234!", policy)
|
||||||
|
assert.Error(t, err, "Password without uppercase should fail")
|
||||||
|
assert.Contains(t, err.Error(), "uppercase letter")
|
||||||
|
|
||||||
|
// Test password without lowercase
|
||||||
|
err = ValidatePassword("TEST1234!", policy)
|
||||||
|
assert.Error(t, err, "Password without lowercase should fail")
|
||||||
|
assert.Contains(t, err.Error(), "lowercase letter")
|
||||||
|
|
||||||
|
// Test password without numbers
|
||||||
|
err = ValidatePassword("TestTest!", policy)
|
||||||
|
assert.Error(t, err, "Password without numbers should fail")
|
||||||
|
assert.Contains(t, err.Error(), "number")
|
||||||
|
|
||||||
|
// Test password without special characters
|
||||||
|
err = ValidatePassword("Test1234", policy)
|
||||||
|
assert.Error(t, err, "Password without special characters should fail")
|
||||||
|
assert.Contains(t, err.Error(), "special character")
|
||||||
|
|
||||||
|
// Test common password - we need to disable other validations to test just the common password check
|
||||||
|
customPolicy := DefaultPasswordPolicy()
|
||||||
|
customPolicy.RequireUppercase = false
|
||||||
|
customPolicy.RequireLowercase = false
|
||||||
|
customPolicy.RequireNumbers = false
|
||||||
|
customPolicy.RequireSpecial = false
|
||||||
|
|
||||||
|
err = ValidatePassword("password", customPolicy)
|
||||||
|
assert.Error(t, err, "Common password should fail even with relaxed requirements")
|
||||||
|
assert.Contains(t, err.Error(), "common or easily guessable")
|
||||||
|
|
||||||
|
// Test with custom policy (all validations disabled)
|
||||||
|
verySimplePolicy := PasswordPolicy{
|
||||||
|
MinLength: 6,
|
||||||
|
RequireUppercase: false,
|
||||||
|
RequireLowercase: false,
|
||||||
|
RequireNumbers: false,
|
||||||
|
RequireSpecial: false,
|
||||||
|
DisallowCommon: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidatePassword("simple", verySimplePolicy)
|
||||||
|
assert.NoError(t, err, "Simple password should pass with all validations disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComparePasswords(t *testing.T) {
|
||||||
|
// Generate a hashed password
|
||||||
|
plainPassword := "TestPassword123!"
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
||||||
|
assert.NoError(t, err, "Password hashing should not error")
|
||||||
|
|
||||||
|
// Test valid password comparison
|
||||||
|
err = ComparePasswords(string(hashedPassword), plainPassword)
|
||||||
|
assert.NoError(t, err, "Correct password should match hash")
|
||||||
|
|
||||||
|
// Test invalid password comparison
|
||||||
|
err = ComparePasswords(string(hashedPassword), "WrongPassword123!")
|
||||||
|
assert.Error(t, err, "Incorrect password should not match hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPasswordExpired(t *testing.T) {
|
||||||
|
policy := DefaultPasswordPolicy()
|
||||||
|
|
||||||
|
// Test password within expiration period
|
||||||
|
lastChange := time.Now().Add(-80 * 24 * time.Hour) // 80 days ago
|
||||||
|
assert.False(t, IsPasswordExpired(lastChange, policy), "Password changed 80 days ago should not be expired")
|
||||||
|
|
||||||
|
// Test expired password
|
||||||
|
lastChange = time.Now().Add(-100 * 24 * time.Hour) // 100 days ago
|
||||||
|
assert.True(t, IsPasswordExpired(lastChange, policy), "Password changed 100 days ago should be expired")
|
||||||
|
|
||||||
|
// Test with expiration disabled
|
||||||
|
customPolicy := PasswordPolicy{
|
||||||
|
ExpirationDays: 0, // Disabled
|
||||||
|
}
|
||||||
|
lastChange = time.Now().Add(-1000 * 24 * time.Hour) // 1000 days ago
|
||||||
|
assert.False(t, IsPasswordExpired(lastChange, customPolicy), "Password should not expire when expiration is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsCommonPassword(t *testing.T) {
|
||||||
|
// Test with common passwords
|
||||||
|
assert.True(t, isCommonPassword("password"), "Should detect 'password' as common")
|
||||||
|
assert.True(t, isCommonPassword("admin123"), "Should detect 'admin123' as common")
|
||||||
|
assert.True(t, isCommonPassword("QWERTY"), "Should detect 'QWERTY' as common (case insensitive)")
|
||||||
|
|
||||||
|
// Test with uncommon passwords
|
||||||
|
assert.False(t, isCommonPassword("G4x8qT2!pL9z"), "Should not detect complex password as common")
|
||||||
|
assert.False(t, isCommonPassword("UniquePassword123!"), "Should not detect unique password as common")
|
||||||
|
}
|
||||||
+109
-49
@@ -1,84 +1,144 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServerAddress string `json:"server_address"`
|
ServerAddress string `json:"server_address"`
|
||||||
DataDir string `json:"data_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
BackupDir string `json:"backup_dir"`
|
BackupDir string `json:"backup_dir"`
|
||||||
JWTSecret string `json:"jwt_secret"`
|
JWTSecret string `json:"jwt_secret"`
|
||||||
Email EmailConfig `json:"email"`
|
Email EmailConfig `json:"email"`
|
||||||
BaseURL string `json:"base_url"` // Base URL for generating links in emails
|
BaseURL string `json:"base_url"` // Base URL for generating links in emails
|
||||||
}
|
}
|
||||||
|
|
||||||
type EmailConfig struct {
|
type EmailConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
FromEmail string `json:"from_email"`
|
FromEmail string `json:"from_email"`
|
||||||
FromName string `json:"from_name"`
|
FromName string `json:"from_name"`
|
||||||
ReplyTo string `json:"reply_to,omitempty"`
|
ReplyTo string `json:"reply_to,omitempty"`
|
||||||
EnableTLS bool `json:"enable_tls"`
|
EnableTLS bool `json:"enable_tls"`
|
||||||
RequireAuth bool `json:"require_auth"`
|
RequireAuth bool `json:"require_auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
// Default configuration
|
// Default configuration
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ServerAddress: ":8080",
|
ServerAddress: ":8080",
|
||||||
DataDir: filepath.Join("./data", "gomft"),
|
DataDir: "./data",
|
||||||
BackupDir: filepath.Join("./data", "gomft", "backups"),
|
BackupDir: "./backups",
|
||||||
JWTSecret: "change_this_to_a_secure_random_string",
|
JWTSecret: "change_this_to_a_secure_random_string",
|
||||||
BaseURL: "http://localhost:8080",
|
BaseURL: "http://localhost:8080",
|
||||||
Email: EmailConfig{
|
Email: EmailConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Host: "smtp.example.com",
|
Host: "smtp.example.com",
|
||||||
Port: 587,
|
Port: 587,
|
||||||
Username: "user@example.com",
|
Username: "user@example.com",
|
||||||
Password: "your-password",
|
Password: "your-password",
|
||||||
FromEmail: "gomft@example.com",
|
FromEmail: "gomft@example.com",
|
||||||
FromName: "GoMFT",
|
FromName: "GoMFT",
|
||||||
EnableTLS: true,
|
EnableTLS: true,
|
||||||
RequireAuth: true,
|
RequireAuth: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if config file exists
|
|
||||||
configPath := filepath.Join(cfg.DataDir, "config.json")
|
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
|
||||||
// Read configuration file
|
|
||||||
data, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse configuration
|
|
||||||
if err := json.Unmarshal(data, cfg); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else if !os.IsNotExist(err) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure data directory exists
|
||||||
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
|
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save configuration if it doesn't exist
|
// First try to load .env from the root directory
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
envPath := ".env"
|
||||||
data, err := json.MarshalIndent(cfg, "", " ")
|
if _, err := os.Stat(envPath); err == nil {
|
||||||
if err != nil {
|
// Load .env file
|
||||||
|
if err := godotenv.Load(envPath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
// Override configuration with environment variables
|
||||||
|
if serverAddr := os.Getenv("SERVER_ADDRESS"); serverAddr != "" {
|
||||||
|
cfg.ServerAddress = serverAddr
|
||||||
|
}
|
||||||
|
if dataDir := os.Getenv("DATA_DIR"); dataDir != "" {
|
||||||
|
cfg.DataDir = dataDir
|
||||||
|
}
|
||||||
|
if backupDir := os.Getenv("BACKUP_DIR"); backupDir != "" {
|
||||||
|
cfg.BackupDir = backupDir
|
||||||
|
}
|
||||||
|
if jwtSecret := os.Getenv("JWT_SECRET"); jwtSecret != "" {
|
||||||
|
cfg.JWTSecret = jwtSecret
|
||||||
|
}
|
||||||
|
if baseURL := os.Getenv("BASE_URL"); baseURL != "" {
|
||||||
|
cfg.BaseURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email configuration
|
||||||
|
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
|
||||||
|
cfg.Email.Enabled = strings.ToLower(emailEnabled) == "true"
|
||||||
|
}
|
||||||
|
if emailHost := os.Getenv("EMAIL_HOST"); emailHost != "" {
|
||||||
|
cfg.Email.Host = emailHost
|
||||||
|
}
|
||||||
|
if emailPort := os.Getenv("EMAIL_PORT"); emailPort != "" {
|
||||||
|
if port, err := strconv.Atoi(emailPort); err == nil {
|
||||||
|
cfg.Email.Port = port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if emailUsername := os.Getenv("EMAIL_USERNAME"); emailUsername != "" {
|
||||||
|
cfg.Email.Username = emailUsername
|
||||||
|
}
|
||||||
|
if emailPassword := os.Getenv("EMAIL_PASSWORD"); emailPassword != "" {
|
||||||
|
cfg.Email.Password = emailPassword
|
||||||
|
}
|
||||||
|
if emailFromEmail := os.Getenv("EMAIL_FROM_EMAIL"); emailFromEmail != "" {
|
||||||
|
cfg.Email.FromEmail = emailFromEmail
|
||||||
|
}
|
||||||
|
if emailFromName := os.Getenv("EMAIL_FROM_NAME"); emailFromName != "" {
|
||||||
|
cfg.Email.FromName = emailFromName
|
||||||
|
}
|
||||||
|
if emailReplyTo := os.Getenv("EMAIL_REPLY_TO"); emailReplyTo != "" {
|
||||||
|
cfg.Email.ReplyTo = emailReplyTo
|
||||||
|
}
|
||||||
|
if emailEnableTLS := os.Getenv("EMAIL_ENABLE_TLS"); emailEnableTLS != "" {
|
||||||
|
cfg.Email.EnableTLS = strings.ToLower(emailEnableTLS) == "true"
|
||||||
|
}
|
||||||
|
if emailRequireAuth := os.Getenv("EMAIL_REQUIRE_AUTH"); emailRequireAuth != "" {
|
||||||
|
cfg.Email.RequireAuth = strings.ToLower(emailRequireAuth) == "true"
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
// Create default .env file in root directory if it doesn't exist
|
||||||
|
envContent := []string{
|
||||||
|
"SERVER_ADDRESS=" + cfg.ServerAddress,
|
||||||
|
"DATA_DIR=" + cfg.DataDir,
|
||||||
|
"BACKUP_DIR=" + cfg.BackupDir,
|
||||||
|
"JWT_SECRET=" + cfg.JWTSecret,
|
||||||
|
"BASE_URL=" + cfg.BaseURL,
|
||||||
|
"",
|
||||||
|
"# Email configuration",
|
||||||
|
"EMAIL_ENABLED=" + strconv.FormatBool(cfg.Email.Enabled),
|
||||||
|
"EMAIL_HOST=" + cfg.Email.Host,
|
||||||
|
"EMAIL_PORT=" + strconv.Itoa(cfg.Email.Port),
|
||||||
|
"EMAIL_FROM_EMAIL=" + cfg.Email.FromEmail,
|
||||||
|
"EMAIL_FROM_NAME=" + cfg.Email.FromName,
|
||||||
|
"EMAIL_REPLY_TO=" + cfg.Email.ReplyTo,
|
||||||
|
"EMAIL_ENABLE_TLS=" + strconv.FormatBool(cfg.Email.EnableTLS),
|
||||||
|
"EMAIL_REQUIRE_AUTH=" + strconv.FormatBool(cfg.Email.RequireAuth),
|
||||||
|
"EMAIL_USERNAME=" + cfg.Email.Username,
|
||||||
|
"EMAIL_PASSWORD=" + cfg.Email.Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(envPath, []byte(strings.Join(envContent, "\n")), 0644); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoad(t *testing.T) {
|
||||||
|
// Create a temporary directory for testing
|
||||||
|
tempDir, err := os.MkdirTemp("", "gomft-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Set up test environment variables
|
||||||
|
testEnvVars := map[string]string{
|
||||||
|
"SERVER_ADDRESS": ":9090",
|
||||||
|
"DATA_DIR": filepath.Join(tempDir, "data"),
|
||||||
|
"BACKUP_DIR": filepath.Join(tempDir, "backups"),
|
||||||
|
"JWT_SECRET": "test-jwt-secret",
|
||||||
|
"BASE_URL": "http://test.example.com",
|
||||||
|
"EMAIL_ENABLED": "true",
|
||||||
|
"EMAIL_HOST": "smtp.test.com",
|
||||||
|
"EMAIL_PORT": "2525",
|
||||||
|
"EMAIL_USERNAME": "test@example.com",
|
||||||
|
"EMAIL_PASSWORD": "test-password",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary .env file
|
||||||
|
envContent := ""
|
||||||
|
for key, value := range testEnvVars {
|
||||||
|
envContent += key + "=" + value + "\n"
|
||||||
|
os.Setenv(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save temporary .env file
|
||||||
|
envPath := filepath.Join(tempDir, ".env")
|
||||||
|
if err := os.WriteFile(envPath, []byte(envContent), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to write test .env file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a symlink to the temp .env file from the project root
|
||||||
|
// This is a hack for testing, as the Load() function looks for .env in the root
|
||||||
|
currentEnv := ".env"
|
||||||
|
// Backup existing .env if it exists
|
||||||
|
if _, err := os.Stat(currentEnv); err == nil {
|
||||||
|
if err := os.Rename(currentEnv, currentEnv+".bak"); err != nil {
|
||||||
|
t.Fatalf("Failed to backup existing .env file: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Rename(currentEnv+".bak", currentEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary .env for test
|
||||||
|
if err := os.WriteFile(currentEnv, []byte(envContent), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to write test .env file: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(currentEnv)
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify loaded configuration matches expected values
|
||||||
|
if cfg.ServerAddress != testEnvVars["SERVER_ADDRESS"] {
|
||||||
|
t.Errorf("Expected ServerAddress to be %s, got %s", testEnvVars["SERVER_ADDRESS"], cfg.ServerAddress)
|
||||||
|
}
|
||||||
|
if cfg.DataDir != testEnvVars["DATA_DIR"] {
|
||||||
|
t.Errorf("Expected DataDir to be %s, got %s", testEnvVars["DATA_DIR"], cfg.DataDir)
|
||||||
|
}
|
||||||
|
if cfg.BackupDir != testEnvVars["BACKUP_DIR"] {
|
||||||
|
t.Errorf("Expected BackupDir to be %s, got %s", testEnvVars["BACKUP_DIR"], cfg.BackupDir)
|
||||||
|
}
|
||||||
|
if cfg.JWTSecret != testEnvVars["JWT_SECRET"] {
|
||||||
|
t.Errorf("Expected JWTSecret to be %s, got %s", testEnvVars["JWT_SECRET"], cfg.JWTSecret)
|
||||||
|
}
|
||||||
|
if cfg.BaseURL != testEnvVars["BASE_URL"] {
|
||||||
|
t.Errorf("Expected BaseURL to be %s, got %s", testEnvVars["BASE_URL"], cfg.BaseURL)
|
||||||
|
}
|
||||||
|
if !cfg.Email.Enabled {
|
||||||
|
t.Errorf("Expected Email.Enabled to be true")
|
||||||
|
}
|
||||||
|
if cfg.Email.Host != testEnvVars["EMAIL_HOST"] {
|
||||||
|
t.Errorf("Expected Email.Host to be %s, got %s", testEnvVars["EMAIL_HOST"], cfg.Email.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
+376
-56
@@ -5,25 +5,27 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/starfleetcptn/gomft/internal/auth"
|
"github.com/starfleetcptn/gomft/internal/auth"
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primarykey"`
|
ID uint `gorm:"primarykey"`
|
||||||
Email string `gorm:"unique;not null"`
|
Email string `gorm:"unique;not null"`
|
||||||
PasswordHash string `gorm:"not null"`
|
PasswordHash string `gorm:"not null"`
|
||||||
IsAdmin bool `gorm:"default:false"`
|
IsAdmin bool `gorm:"default:false"`
|
||||||
LastPasswordChange time.Time
|
LastPasswordChange time.Time
|
||||||
FailedLoginAttempts int `gorm:"default:0"`
|
FailedLoginAttempts int `gorm:"default:0"`
|
||||||
AccountLocked bool `gorm:"default:false"`
|
AccountLocked bool `gorm:"default:false"`
|
||||||
LockoutUntil *time.Time
|
LockoutUntil *time.Time
|
||||||
Theme string `gorm:"default:'light'"`
|
Theme string `gorm:"default:'light'"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type PasswordHistory struct {
|
type PasswordHistory struct {
|
||||||
@@ -46,26 +48,31 @@ type PasswordResetToken struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TransferConfig struct {
|
type TransferConfig struct {
|
||||||
ID uint `gorm:"primarykey"`
|
ID uint `gorm:"primarykey"`
|
||||||
Name string `gorm:"not null" form:"name"`
|
Name string `gorm:"not null" form:"name"`
|
||||||
SourceType string `gorm:"not null" form:"source_type"`
|
SourceType string `gorm:"not null" form:"source_type"`
|
||||||
SourcePath string `gorm:"not null" form:"source_path"`
|
SourcePath string `gorm:"not null" form:"source_path"`
|
||||||
SourceHost string `form:"source_host"`
|
SourceHost string `form:"source_host"`
|
||||||
SourcePort int `gorm:"default:22" form:"source_port"`
|
SourcePort int `gorm:"default:22" form:"source_port"`
|
||||||
SourceUser string `form:"source_user"`
|
SourceUser string `form:"source_user"`
|
||||||
SourcePassword string `form:"source_password" gorm:"-"` // Not stored in DB, only used for form
|
SourcePassword string `form:"source_password" gorm:"-"` // Not stored in DB, only used for form
|
||||||
SourceKeyFile string `form:"source_key_file"`
|
SourceKeyFile string `form:"source_key_file"`
|
||||||
// S3 source fields
|
// S3 source fields
|
||||||
SourceBucket string `form:"source_bucket"`
|
SourceBucket string `form:"source_bucket"`
|
||||||
SourceRegion string `form:"source_region"`
|
SourceRegion string `form:"source_region"`
|
||||||
SourceAccessKey string `form:"source_access_key"`
|
SourceAccessKey string `form:"source_access_key"`
|
||||||
SourceSecretKey string `form:"source_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
SourceSecretKey string `form:"source_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
||||||
SourceEndpoint string `form:"source_endpoint"`
|
SourceEndpoint string `form:"source_endpoint"`
|
||||||
// SMB source fields
|
// SMB source fields
|
||||||
SourceShare string `form:"source_share"`
|
SourceShare string `form:"source_share"`
|
||||||
SourceDomain string `form:"source_domain"`
|
SourceDomain string `form:"source_domain"`
|
||||||
// FTP source fields
|
// 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
|
||||||
// General fields
|
// General fields
|
||||||
FilePattern string `gorm:"default:'*'" form:"file_pattern"`
|
FilePattern string `gorm:"default:'*'" form:"file_pattern"`
|
||||||
OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables
|
OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables
|
||||||
@@ -77,24 +84,32 @@ type TransferConfig struct {
|
|||||||
DestPassword string `form:"dest_password" gorm:"-"` // Not stored in DB, only used for form
|
DestPassword string `form:"dest_password" gorm:"-"` // Not stored in DB, only used for form
|
||||||
DestKeyFile string `form:"dest_key_file"`
|
DestKeyFile string `form:"dest_key_file"`
|
||||||
// S3 destination fields
|
// S3 destination fields
|
||||||
DestBucket string `form:"dest_bucket"`
|
DestBucket string `form:"dest_bucket"`
|
||||||
DestRegion string `form:"dest_region"`
|
DestRegion string `form:"dest_region"`
|
||||||
DestAccessKey string `form:"dest_access_key"`
|
DestAccessKey string `form:"dest_access_key"`
|
||||||
DestSecretKey string `form:"dest_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
DestSecretKey string `form:"dest_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
||||||
DestEndpoint string `form:"dest_endpoint"`
|
DestEndpoint string `form:"dest_endpoint"`
|
||||||
// SMB destination fields
|
// SMB destination fields
|
||||||
DestShare string `form:"dest_share"`
|
DestShare string `form:"dest_share"`
|
||||||
DestDomain string `form:"dest_domain"`
|
DestDomain string `form:"dest_domain"`
|
||||||
// FTP destination fields
|
// 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
|
||||||
// General fields
|
// General fields
|
||||||
ArchivePath string `form:"archive_path"`
|
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"`
|
RcloneFlags string `form:"rclone_flags"`
|
||||||
CreatedBy uint
|
DeleteAfterTransfer bool `gorm:"default:false" form:"delete_after_transfer"`
|
||||||
User User `gorm:"foreignkey:CreatedBy"`
|
SkipProcessedFiles *bool `gorm:"default:true" form:"skip_processed_files"`
|
||||||
CreatedAt time.Time
|
MaxConcurrentTransfers int `gorm:"default:4" form:"max_concurrent_transfers"` // Number of concurrent file transfers
|
||||||
UpdatedAt time.Time
|
CreatedBy uint
|
||||||
|
User User `gorm:"foreignkey:CreatedBy"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
@@ -102,20 +117,82 @@ type Job struct {
|
|||||||
Name string `form:"name"`
|
Name string `form:"name"`
|
||||||
ConfigID uint `gorm:"not null" form:"config_id"`
|
ConfigID uint `gorm:"not null" form:"config_id"`
|
||||||
Config TransferConfig `gorm:"foreignkey:ConfigID"`
|
Config TransferConfig `gorm:"foreignkey:ConfigID"`
|
||||||
|
ConfigIDs string `gorm:"column:config_ids"` // Comma-separated list of config IDs
|
||||||
Schedule string `gorm:"not null" form:"schedule"`
|
Schedule string `gorm:"not null" form:"schedule"`
|
||||||
Enabled bool `gorm:"default:true" form:"enabled"`
|
Enabled bool `gorm:"default:true" form:"enabled"`
|
||||||
LastRun *time.Time
|
LastRun *time.Time
|
||||||
NextRun *time.Time
|
NextRun *time.Time
|
||||||
CreatedBy uint
|
// Webhook notification fields
|
||||||
User User `gorm:"foreignkey:CreatedBy"`
|
WebhookEnabled bool `gorm:"default:false" form:"webhook_enabled"`
|
||||||
CreatedAt time.Time
|
WebhookURL string `form:"webhook_url"`
|
||||||
UpdatedAt time.Time
|
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"`
|
||||||
|
CreatedBy uint
|
||||||
|
User User `gorm:"foreignkey:CreatedBy"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigIDsList returns the list of config IDs as integers
|
||||||
|
func (j *Job) GetConfigIDsList() []uint {
|
||||||
|
if j.ConfigIDs == "" {
|
||||||
|
// If ConfigIDs is empty but ConfigID is set, return that as the only ID
|
||||||
|
if j.ConfigID > 0 {
|
||||||
|
return []uint{j.ConfigID}
|
||||||
|
}
|
||||||
|
return []uint{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the comma-separated string
|
||||||
|
strIDs := strings.Split(j.ConfigIDs, ",")
|
||||||
|
ids := make([]uint, 0, len(strIDs))
|
||||||
|
|
||||||
|
// Convert each string to uint
|
||||||
|
for _, strID := range strIDs {
|
||||||
|
if id, err := strconv.ParseUint(strings.TrimSpace(strID), 10, 32); err == nil {
|
||||||
|
ids = append(ids, uint(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfigIDsList sets the config IDs from a slice of uint
|
||||||
|
func (j *Job) SetConfigIDsList(ids []uint) {
|
||||||
|
// Convert to strings
|
||||||
|
strIDs := make([]string, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
strIDs[i] = strconv.FormatUint(uint64(id), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join with commas
|
||||||
|
j.ConfigIDs = strings.Join(strIDs, ",")
|
||||||
|
|
||||||
|
// If there's at least one ID, set ConfigID to the first one for backward compatibility
|
||||||
|
if len(ids) > 0 {
|
||||||
|
j.ConfigID = ids[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigIDsAsStrings returns the list of config IDs as strings for template rendering
|
||||||
|
func (j *Job) GetConfigIDsAsStrings() []string {
|
||||||
|
ids := j.GetConfigIDsList()
|
||||||
|
strIDs := make([]string, len(ids))
|
||||||
|
|
||||||
|
for i, id := range ids {
|
||||||
|
strIDs[i] = fmt.Sprintf("'%d'", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobHistory struct {
|
type JobHistory struct {
|
||||||
ID uint `gorm:"primarykey"`
|
ID uint `gorm:"primarykey"`
|
||||||
JobID uint `gorm:"not null"`
|
JobID uint `gorm:"not null"`
|
||||||
Job Job `gorm:"foreignkey:JobID"`
|
Job Job `gorm:"foreignkey:JobID"`
|
||||||
|
ConfigID uint `gorm:"default:0"` // The specific config ID this history entry is for
|
||||||
StartTime time.Time `gorm:"not null"`
|
StartTime time.Time `gorm:"not null"`
|
||||||
EndTime *time.Time
|
EndTime *time.Time
|
||||||
Status string `gorm:"not null"`
|
Status string `gorm:"not null"`
|
||||||
@@ -124,6 +201,26 @@ type JobHistory struct {
|
|||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileMetadata stores information about processed files
|
||||||
|
type FileMetadata struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
JobID uint `gorm:"not null;index"`
|
||||||
|
Job Job `gorm:"foreignkey:JobID"`
|
||||||
|
ConfigID uint `gorm:"default:0"` // The specific config ID this file was processed with
|
||||||
|
FileName string `gorm:"not null"`
|
||||||
|
OriginalPath string `gorm:"not null"`
|
||||||
|
FileSize int64 `gorm:"not null"`
|
||||||
|
FileHash string `gorm:"index"` // MD5 or other hash for file identity
|
||||||
|
CreationTime time.Time
|
||||||
|
ModTime time.Time
|
||||||
|
ProcessedTime time.Time `gorm:"not null"`
|
||||||
|
DestinationPath string `gorm:"not null"`
|
||||||
|
Status string `gorm:"not null"` // processed, archived, deleted, etc.
|
||||||
|
ErrorMessage string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
*gorm.DB
|
*gorm.DB
|
||||||
}
|
}
|
||||||
@@ -135,14 +232,14 @@ func Initialize(dbPath string) (*DB, error) {
|
|||||||
return nil, fmt.Errorf("failed to create database directory: %v", err)
|
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{})
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to connect to database: %v", err)
|
return nil, fmt.Errorf("failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto migrate the schema
|
// Auto migrate the schema
|
||||||
err = db.AutoMigrate(&User{}, &auth.PasswordHistory{}, &PasswordResetToken{}, &TransferConfig{}, &Job{}, &JobHistory{})
|
err = db.AutoMigrate(&User{}, &auth.PasswordHistory{}, &PasswordResetToken{}, &TransferConfig{}, &Job{}, &JobHistory{}, &FileMetadata{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to migrate database: %v", err)
|
return nil, fmt.Errorf("failed to migrate database: %v", err)
|
||||||
}
|
}
|
||||||
@@ -291,21 +388,61 @@ func (db *DB) UpdateJobHistory(history *JobHistory) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GetJobHistory(jobID uint) ([]JobHistory, error) {
|
func (db *DB) GetJobHistory(jobID uint) ([]JobHistory, error) {
|
||||||
var history []JobHistory
|
var histories []JobHistory
|
||||||
err := db.Where("job_id = ?", jobID).Order("start_time desc").Find(&history).Error
|
err := db.Where("job_id = ?", jobID).Order("start_time desc").Find(&histories).Error
|
||||||
return history, err
|
return histories, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions
|
// CreateFileMetadata creates a new file metadata record
|
||||||
|
func (db *DB) CreateFileMetadata(metadata *FileMetadata) error {
|
||||||
|
return db.Create(metadata).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileMetadataByJobAndName retrieves file metadata by job ID and filename
|
||||||
|
func (db *DB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*FileMetadata, error) {
|
||||||
|
var metadata FileMetadata
|
||||||
|
err := db.Where("job_id = ? AND file_name = ?", jobID, fileName).First(&metadata).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileMetadataByHash retrieves file metadata by file hash
|
||||||
|
func (db *DB) GetFileMetadataByHash(fileHash string) (*FileMetadata, error) {
|
||||||
|
var metadata FileMetadata
|
||||||
|
err := db.Where("file_hash = ?", fileHash).First(&metadata).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFileMetadata deletes file metadata by ID
|
||||||
|
func (db *DB) DeleteFileMetadata(id uint) error {
|
||||||
|
return db.Delete(&FileMetadata{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigRclonePath returns the path to the rclone config file for a given transfer config
|
||||||
func (db *DB) GetConfigRclonePath(config *TransferConfig) string {
|
func (db *DB) GetConfigRclonePath(config *TransferConfig) string {
|
||||||
return filepath.Join("configs", fmt.Sprintf("config_%d.conf", config.ID))
|
// Get data directory from environment or use default
|
||||||
|
dataDir := os.Getenv("DATA_DIR")
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = "./data"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store configs in the data directory
|
||||||
|
return filepath.Join(dataDir, "configs", fmt.Sprintf("config_%d.conf", config.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
||||||
configPath := db.GetConfigRclonePath(config)
|
configPath := db.GetConfigRclonePath(config)
|
||||||
|
|
||||||
|
// Get the directory part of the path
|
||||||
|
configDir := filepath.Dir(configPath)
|
||||||
|
|
||||||
// Ensure configs directory exists
|
// Ensure configs directory exists
|
||||||
if err := os.MkdirAll("configs", 0755); err != nil {
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create configs directory: %v", err)
|
return fmt.Errorf("failed to create configs directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +562,73 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
|||||||
args = append(args, "passive", "true")
|
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...)
|
cmd := exec.Command(rclonePath, args...)
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
|
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
|
||||||
@@ -546,6 +750,73 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
|||||||
args = append(args, "passive", "true")
|
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...)
|
cmd := exec.Command(rclonePath, args...)
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
|
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
|
||||||
@@ -565,3 +836,52 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
return jobs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigsForJob returns all transfer configurations associated with a job
|
||||||
|
func (db *DB) GetConfigsForJob(jobID uint) ([]TransferConfig, error) {
|
||||||
|
var job Job
|
||||||
|
if err := db.First(&job, jobID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the list of config IDs
|
||||||
|
configIDs := job.GetConfigIDsList()
|
||||||
|
if len(configIDs) == 0 {
|
||||||
|
// If there are no IDs in the list but there is a configID, use that
|
||||||
|
if job.ConfigID > 0 {
|
||||||
|
configIDs = []uint{job.ConfigID}
|
||||||
|
} else {
|
||||||
|
return []TransferConfig{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all configs
|
||||||
|
var configs []TransferConfig
|
||||||
|
if err := db.Where("id IN ?", configIDs).Find(&configs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSkipProcessedFiles returns the value of SkipProcessedFiles with a default if nil
|
||||||
|
func (tc *TransferConfig) GetSkipProcessedFiles() bool {
|
||||||
|
if tc.SkipProcessedFiles == nil {
|
||||||
|
return true // Default to true if not set
|
||||||
|
}
|
||||||
|
return *tc.SkipProcessedFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSkipProcessedFiles sets the SkipProcessedFiles field
|
||||||
|
func (tc *TransferConfig) SetSkipProcessedFiles(value bool) {
|
||||||
|
tc.SkipProcessedFiles = &value
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,835 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupTestDB creates an in-memory SQLite database for testing
|
||||||
|
func setupTestDB(t *testing.T) *DB {
|
||||||
|
gormDB, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to open in-memory database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the database schema
|
||||||
|
err = gormDB.AutoMigrate(
|
||||||
|
&User{},
|
||||||
|
&PasswordHistory{},
|
||||||
|
&PasswordResetToken{},
|
||||||
|
&TransferConfig{},
|
||||||
|
&Job{},
|
||||||
|
&JobHistory{},
|
||||||
|
&FileMetadata{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to migrate database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DB{DB: gormDB}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserCRUD(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: fmt.Sprintf("test-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
IsAdmin: true,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Create
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
assert.NotZero(t, testUser.ID, "User ID should be set after creation")
|
||||||
|
|
||||||
|
// Test Read
|
||||||
|
retrievedUser, err := db.GetUserByEmail(testUser.Email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get user by email: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, testUser.ID, retrievedUser.ID, "Retrieved user should have the same ID")
|
||||||
|
assert.Equal(t, testUser.Email, retrievedUser.Email, "Retrieved user should have the same email")
|
||||||
|
assert.Equal(t, testUser.PasswordHash, retrievedUser.PasswordHash, "Retrieved user should have the same password hash")
|
||||||
|
assert.Equal(t, testUser.IsAdmin, retrievedUser.IsAdmin, "Retrieved user should have the same admin status")
|
||||||
|
|
||||||
|
// Test Update
|
||||||
|
retrievedUser.Email = fmt.Sprintf("updated-%d@example.com", time.Now().UnixNano())
|
||||||
|
err = db.UpdateUser(retrievedUser)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to update user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify update
|
||||||
|
updatedUser, err := db.GetUserByID(retrievedUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get user by ID: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, retrievedUser.Email, updatedUser.Email, "User email should be updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetToken(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: fmt.Sprintf("test-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a password reset token
|
||||||
|
tokenString := fmt.Sprintf("test-token-%d", time.Now().UnixNano())
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour)
|
||||||
|
testToken := &PasswordResetToken{
|
||||||
|
UserID: testUser.ID,
|
||||||
|
Token: tokenString,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
err = db.CreatePasswordResetToken(testToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create password reset token: %v", err)
|
||||||
|
}
|
||||||
|
assert.NotZero(t, testToken.ID, "Token ID should be set after creation")
|
||||||
|
|
||||||
|
// Retrieve the token
|
||||||
|
retrievedToken, err := db.GetPasswordResetToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get password reset token: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, testToken.ID, retrievedToken.ID, "Retrieved token should have the same ID")
|
||||||
|
assert.Equal(t, testUser.ID, retrievedToken.UserID, "Retrieved token should reference the correct user")
|
||||||
|
assert.False(t, retrievedToken.Used, "Token should not be marked as used initially")
|
||||||
|
|
||||||
|
// Mark token as used
|
||||||
|
err = db.MarkPasswordResetTokenAsUsed(retrievedToken.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to mark token as used: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token is marked as used
|
||||||
|
// Note: We need to use GetPasswordResetTokenByID instead of GetPasswordResetToken
|
||||||
|
// because GetPasswordResetToken filters out used tokens
|
||||||
|
var updatedToken PasswordResetToken
|
||||||
|
result := db.DB.First(&updatedToken, retrievedToken.ID)
|
||||||
|
if result.Error != nil {
|
||||||
|
t.Fatalf("Failed to get updated password reset token: %v", result.Error)
|
||||||
|
}
|
||||||
|
assert.True(t, updatedToken.Used, "Token should be marked as used")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransferConfigCRUD(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user first
|
||||||
|
testUser := &User{
|
||||||
|
Email: fmt.Sprintf("test-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test transfer config
|
||||||
|
testConfig := &TransferConfig{
|
||||||
|
Name: fmt.Sprintf("Test Transfer %d", time.Now().UnixNano()),
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path",
|
||||||
|
FilePattern: "*.txt",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Create
|
||||||
|
err = db.CreateTransferConfig(testConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create transfer config: %v", err)
|
||||||
|
}
|
||||||
|
assert.NotZero(t, testConfig.ID, "Config ID should be set after creation")
|
||||||
|
|
||||||
|
// Test Read
|
||||||
|
retrievedConfig, err := db.GetTransferConfig(testConfig.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get transfer config: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, testConfig.Name, retrievedConfig.Name, "Retrieved config should have the same name")
|
||||||
|
assert.Equal(t, testConfig.SourcePath, retrievedConfig.SourcePath, "Retrieved config should have the same source path")
|
||||||
|
|
||||||
|
// Test Update
|
||||||
|
retrievedConfig.Name = fmt.Sprintf("Updated Transfer %d", time.Now().UnixNano())
|
||||||
|
err = db.UpdateTransferConfig(retrievedConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to update transfer config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify update
|
||||||
|
updatedConfig, err := db.GetTransferConfig(retrievedConfig.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get updated transfer config: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, retrievedConfig.Name, updatedConfig.Name, "Config name should be updated")
|
||||||
|
|
||||||
|
// Test listing configs
|
||||||
|
configs, err := db.GetTransferConfigs(testUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list transfer configs: %v", err)
|
||||||
|
}
|
||||||
|
assert.GreaterOrEqual(t, len(configs), 1, "There should be at least one config in the list")
|
||||||
|
|
||||||
|
// Test Delete
|
||||||
|
err = db.DeleteTransferConfig(testConfig.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to delete transfer config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
_, err = db.GetTransferConfig(testConfig.ID)
|
||||||
|
assert.Error(t, err, "Getting deleted config should return an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobCRUD(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user first
|
||||||
|
testUser := &User{
|
||||||
|
Email: fmt.Sprintf("test-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test transfer config
|
||||||
|
testConfig := &TransferConfig{
|
||||||
|
Name: fmt.Sprintf("Test Transfer %d", time.Now().UnixNano()),
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path",
|
||||||
|
FilePattern: "*.txt",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(testConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create transfer config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test job
|
||||||
|
now := time.Now()
|
||||||
|
nextRun := now.Add(24 * time.Hour)
|
||||||
|
testJob := &Job{
|
||||||
|
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
|
||||||
|
ConfigID: testConfig.ID,
|
||||||
|
Schedule: "0 * * * *", // Run every hour
|
||||||
|
Enabled: true,
|
||||||
|
LastRun: &now,
|
||||||
|
NextRun: &nextRun,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Create
|
||||||
|
err = db.CreateJob(testJob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create job: %v", err)
|
||||||
|
}
|
||||||
|
assert.NotZero(t, testJob.ID, "Job ID should be set after creation")
|
||||||
|
|
||||||
|
// Test Read
|
||||||
|
retrievedJob, err := db.GetJob(testJob.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get job: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, testJob.Name, retrievedJob.Name, "Retrieved job should have the same name")
|
||||||
|
assert.Equal(t, testJob.ConfigID, retrievedJob.ConfigID, "Retrieved job should have the same config ID")
|
||||||
|
assert.Equal(t, testJob.Schedule, retrievedJob.Schedule, "Retrieved job should have the same schedule")
|
||||||
|
|
||||||
|
// Test listing jobs
|
||||||
|
jobs, err := db.GetJobs(testUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list jobs: %v", err)
|
||||||
|
}
|
||||||
|
assert.GreaterOrEqual(t, len(jobs), 1, "There should be at least one job in the list")
|
||||||
|
|
||||||
|
// Test Get Active Jobs
|
||||||
|
activeJobs, err := db.GetActiveJobs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get active jobs: %v", err)
|
||||||
|
}
|
||||||
|
assert.GreaterOrEqual(t, len(activeJobs), 1, "There should be at least one active job")
|
||||||
|
|
||||||
|
// Test Update
|
||||||
|
retrievedJob.Name = fmt.Sprintf("Updated Job %d", time.Now().UnixNano())
|
||||||
|
retrievedJob.Enabled = false
|
||||||
|
err = db.UpdateJob(retrievedJob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to update job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify update
|
||||||
|
updatedJob, err := db.GetJob(retrievedJob.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get updated job: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, retrievedJob.Name, updatedJob.Name, "Job name should be updated")
|
||||||
|
assert.Equal(t, retrievedJob.Enabled, updatedJob.Enabled, "Job enabled status should be updated")
|
||||||
|
|
||||||
|
// Test Delete
|
||||||
|
err = db.DeleteJob(testJob.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to delete job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
_, err = db.GetJob(testJob.ID)
|
||||||
|
assert.Error(t, err, "Getting deleted job should return an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to test if a config ID is selected for a job
|
||||||
|
func configSelected(job *Job, configID uint) bool {
|
||||||
|
// Check if the job has the config ID in its list
|
||||||
|
for _, id := range job.GetConfigIDsList() {
|
||||||
|
if id == configID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// As a fallback, check the primary ConfigID
|
||||||
|
return job.ConfigID == configID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobMultipleConfigs(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: fmt.Sprintf("test-multi-%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 multiple test configs
|
||||||
|
config1 := &TransferConfig{
|
||||||
|
Name: "Test Config 1",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path1",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path1",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(config1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
config2 := &TransferConfig{
|
||||||
|
Name: "Test Config 2",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path2",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path2",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(config2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
config3 := &TransferConfig{
|
||||||
|
Name: "Test Config 3",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path3",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path3",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(config3)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test 1: Create job with multiple configs
|
||||||
|
testJob := &Job{
|
||||||
|
Name: "Multi Config Job",
|
||||||
|
Schedule: "0 * * * *",
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set multiple config IDs
|
||||||
|
configIDs := []uint{config1.ID, config2.ID, config3.ID}
|
||||||
|
testJob.SetConfigIDsList(configIDs)
|
||||||
|
|
||||||
|
// Verify ConfigIDs string format
|
||||||
|
assert.Contains(t, testJob.ConfigIDs, fmt.Sprintf("%d", config1.ID))
|
||||||
|
assert.Contains(t, testJob.ConfigIDs, fmt.Sprintf("%d", config2.ID))
|
||||||
|
assert.Contains(t, testJob.ConfigIDs, fmt.Sprintf("%d", config3.ID))
|
||||||
|
|
||||||
|
// Verify ConfigID is set to the first config
|
||||||
|
assert.Equal(t, config1.ID, testJob.ConfigID)
|
||||||
|
|
||||||
|
// Save the job
|
||||||
|
err = db.CreateJob(testJob)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test 2: Retrieve job and check config IDs
|
||||||
|
retrievedJob, err := db.GetJob(testJob.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify retrieved config IDs
|
||||||
|
retrievedIDs := retrievedJob.GetConfigIDsList()
|
||||||
|
assert.Len(t, retrievedIDs, 3)
|
||||||
|
assert.Contains(t, retrievedIDs, config1.ID)
|
||||||
|
assert.Contains(t, retrievedIDs, config2.ID)
|
||||||
|
assert.Contains(t, retrievedIDs, config3.ID)
|
||||||
|
|
||||||
|
// Test 3: Test configSelected function
|
||||||
|
assert.True(t, configSelected(retrievedJob, config1.ID))
|
||||||
|
assert.True(t, configSelected(retrievedJob, config2.ID))
|
||||||
|
assert.True(t, configSelected(retrievedJob, config3.ID))
|
||||||
|
assert.False(t, configSelected(retrievedJob, uint(999)))
|
||||||
|
|
||||||
|
// Test 4: Get configs for job
|
||||||
|
configs, err := db.GetConfigsForJob(testJob.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, configs, 3)
|
||||||
|
|
||||||
|
// Verify config names are correct
|
||||||
|
configNames := make([]string, len(configs))
|
||||||
|
for i, config := range configs {
|
||||||
|
configNames[i] = config.Name
|
||||||
|
}
|
||||||
|
assert.Contains(t, configNames, "Test Config 1")
|
||||||
|
assert.Contains(t, configNames, "Test Config 2")
|
||||||
|
assert.Contains(t, configNames, "Test Config 3")
|
||||||
|
|
||||||
|
// Test 5: Update config IDs
|
||||||
|
updatedIDs := []uint{config1.ID, config3.ID} // Remove config2
|
||||||
|
retrievedJob.SetConfigIDsList(updatedIDs)
|
||||||
|
err = db.UpdateJob(retrievedJob)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify update
|
||||||
|
updatedJob, err := db.GetJob(testJob.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
updatedRetrievedIDs := updatedJob.GetConfigIDsList()
|
||||||
|
assert.Len(t, updatedRetrievedIDs, 2)
|
||||||
|
assert.Contains(t, updatedRetrievedIDs, config1.ID)
|
||||||
|
assert.Contains(t, updatedRetrievedIDs, config3.ID)
|
||||||
|
assert.NotContains(t, updatedRetrievedIDs, config2.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobHistoryCRUD(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user first
|
||||||
|
testUser := &User{
|
||||||
|
Email: fmt.Sprintf("test-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test transfer config
|
||||||
|
testConfig := &TransferConfig{
|
||||||
|
Name: fmt.Sprintf("Test Transfer %d", time.Now().UnixNano()),
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path",
|
||||||
|
FilePattern: "*.txt",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(testConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create transfer config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test job
|
||||||
|
testJob := &Job{
|
||||||
|
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
|
||||||
|
ConfigID: testConfig.ID,
|
||||||
|
Schedule: "0 * * * *", // Run every hour
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateJob(testJob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test job history record
|
||||||
|
startTime := time.Now().Add(-1 * time.Hour)
|
||||||
|
endTime := time.Now()
|
||||||
|
testHistory := &JobHistory{
|
||||||
|
JobID: testJob.ID,
|
||||||
|
StartTime: startTime,
|
||||||
|
EndTime: &endTime,
|
||||||
|
Status: "completed",
|
||||||
|
BytesTransferred: 1024,
|
||||||
|
FilesTransferred: 5,
|
||||||
|
ErrorMessage: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Create
|
||||||
|
err = db.CreateJobHistory(testHistory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create job history: %v", err)
|
||||||
|
}
|
||||||
|
assert.NotZero(t, testHistory.ID, "Job history ID should be set after creation")
|
||||||
|
|
||||||
|
// Test Update
|
||||||
|
testHistory.Status = "failed"
|
||||||
|
testHistory.ErrorMessage = "Test error message"
|
||||||
|
err = db.UpdateJobHistory(testHistory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to update job history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test getting job history
|
||||||
|
histories, err := db.GetJobHistory(testJob.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get job history: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, len(histories), "There should be one job history record")
|
||||||
|
assert.Equal(t, "failed", histories[0].Status, "Job history status should be 'failed'")
|
||||||
|
assert.Equal(t, "Test error message", histories[0].ErrorMessage, "Job history error message should be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileMetadataCRUD(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user first
|
||||||
|
testUser := &User{
|
||||||
|
Email: fmt.Sprintf("test-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test transfer config
|
||||||
|
testConfig := &TransferConfig{
|
||||||
|
Name: fmt.Sprintf("Test Transfer %d", time.Now().UnixNano()),
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path",
|
||||||
|
FilePattern: "*.txt",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(testConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create transfer config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test job
|
||||||
|
testJob := &Job{
|
||||||
|
Name: fmt.Sprintf("Test Job %d", time.Now().UnixNano()),
|
||||||
|
ConfigID: testConfig.ID,
|
||||||
|
Schedule: "0 * * * *", // Run every hour
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateJob(testJob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test file metadata record
|
||||||
|
fileName := fmt.Sprintf("testfile-%d.txt", time.Now().UnixNano())
|
||||||
|
fileHash := fmt.Sprintf("md5-%d", time.Now().UnixNano())
|
||||||
|
testMetadata := &FileMetadata{
|
||||||
|
JobID: testJob.ID,
|
||||||
|
FileName: fileName,
|
||||||
|
OriginalPath: "/source/path/" + fileName,
|
||||||
|
FileSize: 1024,
|
||||||
|
FileHash: fileHash,
|
||||||
|
CreationTime: time.Now().Add(-2 * time.Hour),
|
||||||
|
ModTime: time.Now().Add(-1 * time.Hour),
|
||||||
|
ProcessedTime: time.Now(),
|
||||||
|
DestinationPath: "/destination/path/" + fileName,
|
||||||
|
Status: "processed",
|
||||||
|
ErrorMessage: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Create
|
||||||
|
err = db.CreateFileMetadata(testMetadata)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create file metadata: %v", err)
|
||||||
|
}
|
||||||
|
assert.NotZero(t, testMetadata.ID, "File metadata ID should be set after creation")
|
||||||
|
|
||||||
|
// Test GetFileMetadataByJobAndName
|
||||||
|
retrievedMetadata, err := db.GetFileMetadataByJobAndName(testJob.ID, fileName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get file metadata by job and name: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, testMetadata.ID, retrievedMetadata.ID, "Retrieved metadata should have the same ID")
|
||||||
|
assert.Equal(t, fileName, retrievedMetadata.FileName, "Retrieved metadata should have the same file name")
|
||||||
|
assert.Equal(t, fileHash, retrievedMetadata.FileHash, "Retrieved metadata should have the same file hash")
|
||||||
|
|
||||||
|
// Test GetFileMetadataByHash
|
||||||
|
hashMetadata, err := db.GetFileMetadataByHash(fileHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get file metadata by hash: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, testMetadata.ID, hashMetadata.ID, "Retrieved metadata should have the same ID")
|
||||||
|
|
||||||
|
// Test Delete
|
||||||
|
err = db.DeleteFileMetadata(testMetadata.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to delete file metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
_, err = db.GetFileMetadataByJobAndName(testJob.ID, fileName)
|
||||||
|
assert.Error(t, err, "Getting deleted file metadata should return an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDBInitialize(t *testing.T) {
|
||||||
|
// Create a temporary file path for testing
|
||||||
|
tempDBPath := "test_init.db"
|
||||||
|
|
||||||
|
// Initialize the database
|
||||||
|
db, err := Initialize(tempDBPath)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, db)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
err = db.Close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Remove test file
|
||||||
|
err = os.Remove(tempDBPath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
t.Logf("Warning: could not remove test database file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfigRclonePath(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: "rclone-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test config
|
||||||
|
testConfig := &TransferConfig{
|
||||||
|
Name: "Test Rclone Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "sftp",
|
||||||
|
DestHost: "example.com",
|
||||||
|
DestPort: 22,
|
||||||
|
DestUser: "testuser",
|
||||||
|
DestinationPath: "/remote/path",
|
||||||
|
DestKeyFile: "private_key_content",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.CreateTransferConfig(testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test GetConfigRclonePath
|
||||||
|
configPath := db.GetConfigRclonePath(testConfig)
|
||||||
|
assert.NotEmpty(t, configPath)
|
||||||
|
assert.Contains(t, configPath, fmt.Sprintf("%d", testConfig.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRcloneConfig(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: "rclone-gen-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// SFTP config test
|
||||||
|
sftpConfig := &TransferConfig{
|
||||||
|
Name: "Test SFTP Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/local/path",
|
||||||
|
DestinationType: "sftp",
|
||||||
|
DestHost: "sftp.example.com",
|
||||||
|
DestPort: 22,
|
||||||
|
DestUser: "testuser",
|
||||||
|
DestinationPath: "/remote/path",
|
||||||
|
DestKeyFile: "private_key_content",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.CreateTransferConfig(sftpConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test generating rclone config
|
||||||
|
err = db.GenerateRcloneConfig(sftpConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// FTP config test
|
||||||
|
ftpConfig := &TransferConfig{
|
||||||
|
Name: "Test FTP Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/local/ftp",
|
||||||
|
DestinationType: "ftp",
|
||||||
|
DestHost: "ftp.example.com",
|
||||||
|
DestPort: 21,
|
||||||
|
DestUser: "ftpuser",
|
||||||
|
DestPassiveMode: true,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.CreateTransferConfig(ftpConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test generating rclone config
|
||||||
|
err = db.GenerateRcloneConfig(ftpConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// S3 config test
|
||||||
|
s3Config := &TransferConfig{
|
||||||
|
Name: "Test S3 Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/local/s3",
|
||||||
|
DestinationType: "s3",
|
||||||
|
DestBucket: "mybucket",
|
||||||
|
DestAccessKey: "accessKey",
|
||||||
|
DestRegion: "us-east-1",
|
||||||
|
DestEndpoint: "s3.amazonaws.com",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.CreateTransferConfig(s3Config)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test generating rclone config
|
||||||
|
err = db.GenerateRcloneConfig(s3Config)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test generating config for unsupported protocol
|
||||||
|
invalidConfig := &TransferConfig{
|
||||||
|
Name: "Invalid Protocol Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/local/path",
|
||||||
|
DestinationType: "unsupported",
|
||||||
|
DestHost: "example.com",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.CreateTransferConfig(invalidConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// This should NOT return an error for unsupported protocol
|
||||||
|
// as it defaults to local type
|
||||||
|
err = db.GenerateRcloneConfig(invalidConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the config file exists
|
||||||
|
configPath := db.GetConfigRclonePath(invalidConfig)
|
||||||
|
_, err = os.Stat(configPath)
|
||||||
|
assert.NoError(t, err, "Config file should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateJobStatus(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: "job-status-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test transfer config
|
||||||
|
testConfig := &TransferConfig{
|
||||||
|
Name: "Test Config for Job Status",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path",
|
||||||
|
FilePattern: "*.txt",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.CreateTransferConfig(testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test job
|
||||||
|
now := time.Now()
|
||||||
|
lastRun := now.Add(-time.Hour)
|
||||||
|
nextRun := now.Add(time.Hour)
|
||||||
|
|
||||||
|
testJob := &Job{
|
||||||
|
Name: "Test Job Status",
|
||||||
|
ConfigID: testConfig.ID,
|
||||||
|
Schedule: "0 * * * *", // Run hourly
|
||||||
|
Enabled: true,
|
||||||
|
LastRun: &lastRun,
|
||||||
|
NextRun: &nextRun,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.CreateJob(testJob)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update job's last run time
|
||||||
|
updatedLastRun := time.Now()
|
||||||
|
testJob.LastRun = &updatedLastRun
|
||||||
|
|
||||||
|
err = db.UpdateJobStatus(testJob)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the job was updated
|
||||||
|
updatedJob, err := db.GetJob(testJob.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEqual(t, lastRun.Unix(), updatedJob.LastRun.Unix())
|
||||||
|
|
||||||
|
// Update job's next run time
|
||||||
|
updatedNextRun := time.Now().Add(2 * time.Hour)
|
||||||
|
testJob.NextRun = &updatedNextRun
|
||||||
|
|
||||||
|
err = db.UpdateJobStatus(testJob)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the job was updated again
|
||||||
|
updatedJob, err = db.GetJob(testJob.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, updatedNextRun.Unix(), updatedJob.NextRun.Unix())
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeleteTransferConfigEdgeCases tests edge cases for the DeleteTransferConfig function
|
||||||
|
func TestDeleteTransferConfigEdgeCases(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: "config-edge-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create multiple configs
|
||||||
|
configs := make([]*TransferConfig, 5)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
config := &TransferConfig{
|
||||||
|
Name: fmt.Sprintf("Edge Config %d", i),
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: fmt.Sprintf("/source/path/%d", i),
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: fmt.Sprintf("/destination/path/%d", i),
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(config)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
configs[i] = config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete them in reverse order
|
||||||
|
for i := 4; i >= 0; i-- {
|
||||||
|
err = db.DeleteTransferConfig(configs[i].ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
_, err = db.GetTransferConfig(configs[i].ID)
|
||||||
|
assert.Error(t, err, "Config should be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test deleting a config that has a job associated with it
|
||||||
|
configWithJob := &TransferConfig{
|
||||||
|
Name: "Config with Job",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path/job",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path/job",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(configWithJob)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a job for this config
|
||||||
|
job := &Job{
|
||||||
|
Name: "Job for Config",
|
||||||
|
ConfigID: configWithJob.ID,
|
||||||
|
Schedule: "0 * * * *",
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateJob(job)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to delete the config - this should fail due to foreign key constraint
|
||||||
|
err = db.DeleteTransferConfig(configWithJob.ID)
|
||||||
|
assert.Error(t, err, "Should not be able to delete config with associated jobs")
|
||||||
|
assert.Contains(t, err.Error(), "jobs are using this configuration", "Error should mention jobs")
|
||||||
|
|
||||||
|
// Delete the job first
|
||||||
|
err = db.DeleteJob(job.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Now delete the config - this should succeed
|
||||||
|
err = db.DeleteTransferConfig(configWithJob.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
_, err = db.GetTransferConfig(configWithJob.ID)
|
||||||
|
assert.Error(t, err, "Config should be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteJobEdgeCases tests edge cases for the DeleteJob function
|
||||||
|
func TestDeleteJobEdgeCases(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: "job-edge-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test config
|
||||||
|
config := &TransferConfig{
|
||||||
|
Name: "Config for Job Edge Cases",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(config)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create multiple jobs
|
||||||
|
jobs := make([]*Job, 5)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
job := &Job{
|
||||||
|
Name: fmt.Sprintf("Edge Job %d", i),
|
||||||
|
ConfigID: config.ID,
|
||||||
|
Schedule: "0 * * * *",
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateJob(job)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
jobs[i] = job
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete them in reverse order
|
||||||
|
for i := 4; i >= 0; i-- {
|
||||||
|
err = db.DeleteJob(jobs[i].ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
_, err = db.GetJob(jobs[i].ID)
|
||||||
|
assert.Error(t, err, "Job should be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a job with history records
|
||||||
|
jobWithHistory := &Job{
|
||||||
|
Name: "Job with History",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
Schedule: "0 * * * *",
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateJob(jobWithHistory)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create history records
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
startTime := time.Now().Add(time.Duration(-i) * time.Hour)
|
||||||
|
endTime := startTime.Add(30 * time.Minute)
|
||||||
|
history := &JobHistory{
|
||||||
|
JobID: jobWithHistory.ID,
|
||||||
|
StartTime: startTime,
|
||||||
|
EndTime: &endTime,
|
||||||
|
Status: "completed",
|
||||||
|
BytesTransferred: int64(1024 * (i + 1)),
|
||||||
|
FilesTransferred: i + 1,
|
||||||
|
}
|
||||||
|
err = db.CreateJobHistory(history)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now delete the job - this should succeed even with history records
|
||||||
|
// (due to foreign key constraints in the database)
|
||||||
|
err = db.DeleteJob(jobWithHistory.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
_, err = db.GetJob(jobWithHistory.ID)
|
||||||
|
assert.Error(t, err, "Job should be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitializeEdgeCases tests edge cases for the Initialize function
|
||||||
|
func TestInitializeEdgeCases(t *testing.T) {
|
||||||
|
// Test with a read-only directory (if possible)
|
||||||
|
tempDir, err := os.MkdirTemp("", "gomft_test_readonly")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Try to make the directory read-only
|
||||||
|
// Note: This may not work on all systems due to permissions
|
||||||
|
origPerms, err := os.Stat(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to stat directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to make it read-only
|
||||||
|
err = os.Chmod(tempDir, 0400) // read-only
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Warning: Could not set directory to read-only: %v", err)
|
||||||
|
t.Skip("Could not set directory to read-only, skipping test")
|
||||||
|
}
|
||||||
|
defer os.Chmod(tempDir, origPerms.Mode()) // restore original permissions
|
||||||
|
|
||||||
|
dbPath := filepath.Join(tempDir, "readonly.db")
|
||||||
|
// This might fail because the directory is read-only
|
||||||
|
db, err := Initialize(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
// Expected error due to read-only directory
|
||||||
|
t.Logf("Got expected error for read-only directory: %v", err)
|
||||||
|
} else {
|
||||||
|
// If it succeeded, clean up
|
||||||
|
t.Logf("Warning: DB initialization succeeded even with read-only directory!")
|
||||||
|
err = db.Close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseEdgeCases tests edge cases for the Close function
|
||||||
|
func TestCloseEdgeCases(t *testing.T) {
|
||||||
|
// Create a temporary database
|
||||||
|
tempDir, err := os.MkdirTemp("", "gomft_test_close_edge")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
dbPath := filepath.Join(tempDir, "close_edge.db")
|
||||||
|
db, err := Initialize(dbPath)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test calling methods after close
|
||||||
|
sqlDB, err := db.DB.DB()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Get initial stats
|
||||||
|
stats := sqlDB.Stats()
|
||||||
|
t.Logf("Initial stats: MaxOpenConnections=%d, OpenConnections=%d, InUse=%d",
|
||||||
|
stats.MaxOpenConnections, stats.OpenConnections, stats.InUse)
|
||||||
|
|
||||||
|
// Close the DB
|
||||||
|
err = db.Close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to get stats again - this might fail
|
||||||
|
stats = sqlDB.Stats()
|
||||||
|
t.Logf("After close stats: MaxOpenConnections=%d, OpenConnections=%d, InUse=%d",
|
||||||
|
stats.MaxOpenConnections, stats.OpenConnections, stats.InUse)
|
||||||
|
|
||||||
|
// Verify that DB operations fail after close
|
||||||
|
_, err = db.GetUserByEmail("test@example.com")
|
||||||
|
assert.Error(t, err, "DB operations should fail after close")
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tests for error handling in GetUserByEmail
|
||||||
|
func TestGetUserByEmailError(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Test the error case with a non-existent email
|
||||||
|
user, err := db.GetUserByEmail("nonexistent@example.com")
|
||||||
|
|
||||||
|
// Verify expectations
|
||||||
|
assert.Error(t, err, "Should return an error when user is not found")
|
||||||
|
assert.Nil(t, user, "User should be nil when an error occurs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests for error handling in GetUserByID
|
||||||
|
func TestGetUserByIDError(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Test the error case with a non-existent ID
|
||||||
|
user, err := db.GetUserByID(9999)
|
||||||
|
|
||||||
|
// Verify expectations
|
||||||
|
assert.Error(t, err, "Should return an error when user is not found")
|
||||||
|
assert.Nil(t, user, "User should be nil when an error occurs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests for error handling in GetPasswordResetToken
|
||||||
|
func TestGetPasswordResetTokenError(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Test the error case with an invalid token
|
||||||
|
token, err := db.GetPasswordResetToken("invalid-token")
|
||||||
|
|
||||||
|
// Verify expectations
|
||||||
|
assert.Error(t, err, "Should return an error when token is not found")
|
||||||
|
assert.Nil(t, token, "Token should be nil when an error occurs")
|
||||||
|
|
||||||
|
// Test with an expired token
|
||||||
|
testUser := &User{
|
||||||
|
Email: "expired-token@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err = db.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create an expired token (expired 1 hour ago)
|
||||||
|
expiredToken := &PasswordResetToken{
|
||||||
|
UserID: testUser.ID,
|
||||||
|
Token: "expired-token",
|
||||||
|
ExpiresAt: time.Now().Add(-1 * time.Hour),
|
||||||
|
}
|
||||||
|
err = db.CreatePasswordResetToken(expiredToken)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to get the expired token
|
||||||
|
retrievedToken, err := db.GetPasswordResetToken("expired-token")
|
||||||
|
assert.Error(t, err, "Should return an error for expired token")
|
||||||
|
assert.Nil(t, retrievedToken, "Token should be nil for expired token")
|
||||||
|
|
||||||
|
// Create a used token
|
||||||
|
usedToken := &PasswordResetToken{
|
||||||
|
UserID: testUser.ID,
|
||||||
|
Token: "used-token",
|
||||||
|
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||||
|
Used: true,
|
||||||
|
}
|
||||||
|
err = db.CreatePasswordResetToken(usedToken)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to get the used token
|
||||||
|
retrievedToken, err = db.GetPasswordResetToken("used-token")
|
||||||
|
assert.Error(t, err, "Should return an error for used token")
|
||||||
|
assert.Nil(t, retrievedToken, "Token should be nil for used token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests for error handling in DeleteTransferConfig
|
||||||
|
func TestDeleteTransferConfigError(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Test deleting a non-existent config
|
||||||
|
err := db.DeleteTransferConfig(9999)
|
||||||
|
|
||||||
|
// Verify expectations - should not return an error even if the record doesn't exist
|
||||||
|
assert.NoError(t, err, "Should not return an error when deleting non-existent config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests for error handling in DeleteJob
|
||||||
|
func TestDeleteJobError(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Test deleting a non-existent job
|
||||||
|
err := db.DeleteJob(9999)
|
||||||
|
|
||||||
|
// Verify expectations - should not return an error even if the record doesn't exist
|
||||||
|
assert.NoError(t, err, "Should not return an error when deleting non-existent job")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests for error handling in GetFileMetadataByHash
|
||||||
|
func TestGetFileMetadataByHashError(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Test the error case with an invalid hash
|
||||||
|
metadata, err := db.GetFileMetadataByHash("invalid-hash")
|
||||||
|
|
||||||
|
// Verify expectations
|
||||||
|
assert.Error(t, err, "Should return an error when metadata is not found")
|
||||||
|
assert.Nil(t, metadata, "Metadata should be nil when an error occurs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests for error handling in Initialize
|
||||||
|
func TestInitializeErrors(t *testing.T) {
|
||||||
|
// Test with a path that is a directory, not a file
|
||||||
|
// This should cause an error when trying to open a SQLite database
|
||||||
|
_, err := Initialize("/dev/null/cannot_be_a_db")
|
||||||
|
assert.Error(t, err, "Should return an error with invalid path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests for error handling in GenerateRcloneConfig
|
||||||
|
func TestGenerateRcloneConfigErrors(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: "config-error-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a config with invalid credentials for an SFTP connection
|
||||||
|
invalidConfig := &TransferConfig{
|
||||||
|
Name: "Invalid Config",
|
||||||
|
SourceType: "sftp", // Using SFTP with invalid host to force error
|
||||||
|
SourceHost: "nonexistent.host",
|
||||||
|
SourcePort: 22,
|
||||||
|
SourceUser: "invaliduser",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.CreateTransferConfig(invalidConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Set a non-existent RCLONE_PATH to force error
|
||||||
|
t.Setenv("RCLONE_PATH", "/nonexistent/rclone")
|
||||||
|
|
||||||
|
// This should return an error because the rclone command doesn't exist
|
||||||
|
err = db.GenerateRcloneConfig(invalidConfig)
|
||||||
|
assert.Error(t, err, "Should return an error when rclone command fails")
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestInitializeWithNonExistentDirectory tests initialization with a directory that doesn't exist
|
||||||
|
func TestInitializeWithNonExistentDirectory(t *testing.T) {
|
||||||
|
// Create a temporary directory path
|
||||||
|
tempDir := filepath.Join(os.TempDir(), "gomft_test_nonexistent")
|
||||||
|
|
||||||
|
// Make sure the directory doesn't exist
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create a path inside the non-existent directory
|
||||||
|
dbPath := filepath.Join(tempDir, "test.db")
|
||||||
|
|
||||||
|
// Initialize the database - this should create the directory
|
||||||
|
db, err := Initialize(dbPath)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, db)
|
||||||
|
|
||||||
|
// Verify the directory was created
|
||||||
|
_, err = os.Stat(tempDir)
|
||||||
|
assert.NoError(t, err, "Directory should be created")
|
||||||
|
|
||||||
|
// Close and clean up
|
||||||
|
err = db.Close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitializeWithInvalidDBPath tests initialization with an invalid DB path
|
||||||
|
func TestInitializeWithInvalidDBPath(t *testing.T) {
|
||||||
|
// Create a file path that can't be a SQLite database
|
||||||
|
invalidPath := "/dev/null/invalid.db"
|
||||||
|
|
||||||
|
// Attempt to initialize with an invalid path
|
||||||
|
db, err := Initialize(invalidPath)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitializeWithExistingDB tests initialization with an existing database
|
||||||
|
func TestInitializeWithExistingDB(t *testing.T) {
|
||||||
|
// Create a temporary directory
|
||||||
|
tempDir, err := os.MkdirTemp("", "gomft_test_existing")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create a database path
|
||||||
|
dbPath := filepath.Join(tempDir, "existing.db")
|
||||||
|
|
||||||
|
// Initialize the database for the first time
|
||||||
|
db1, err := Initialize(dbPath)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, db1)
|
||||||
|
|
||||||
|
// Create a test user to verify the database works
|
||||||
|
user := &User{
|
||||||
|
Email: "test@example.com",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
err = db1.CreateUser(user)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotZero(t, user.ID)
|
||||||
|
|
||||||
|
// Close the first database connection
|
||||||
|
err = db1.Close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Initialize the database again with the same path
|
||||||
|
db2, err := Initialize(dbPath)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, db2)
|
||||||
|
|
||||||
|
// Verify we can read the user that was created earlier
|
||||||
|
retrievedUser, err := db2.GetUserByEmail("test@example.com")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, user.ID, retrievedUser.ID)
|
||||||
|
|
||||||
|
// Close the second database connection
|
||||||
|
err = db2.Close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseMultipleTimes tests closing the database multiple times
|
||||||
|
func TestCloseMultipleTimes(t *testing.T) {
|
||||||
|
// Create a temporary directory
|
||||||
|
tempDir, err := os.MkdirTemp("", "gomft_test_close")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create a database path
|
||||||
|
dbPath := filepath.Join(tempDir, "close.db")
|
||||||
|
|
||||||
|
// Initialize the database
|
||||||
|
db, err := Initialize(dbPath)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, db)
|
||||||
|
|
||||||
|
// Close the database
|
||||||
|
err = db.Close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Trying to close it again - for some DB drivers this might cause an error
|
||||||
|
// but SQLite in-memory seems to handle this gracefully
|
||||||
|
err = db.Close()
|
||||||
|
// We won't assert error here since it depends on the driver
|
||||||
|
t.Logf("Second close resulted in: %v", err)
|
||||||
|
|
||||||
|
// Instead, let's test that DB operations fail after close
|
||||||
|
_, err = db.GetUserByEmail("test@example.com")
|
||||||
|
assert.Error(t, err, "DB operations should fail after close")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitializeWithMigrationFailure tests when AutoMigrate fails
|
||||||
|
func TestInitializeWithMigrationFailure(t *testing.T) {
|
||||||
|
// We can't easily cause a migration failure with SQLite
|
||||||
|
// but we can skip this test and document that it's hard to test
|
||||||
|
t.Skip("Testing migration failure is difficult with SQLite")
|
||||||
|
|
||||||
|
// In a real-world scenario, this might happen if:
|
||||||
|
// 1. The schema changed significantly between versions
|
||||||
|
// 2. The database is corrupted
|
||||||
|
// 3. There are permission issues
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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(),
|
||||||
|
AddSkipProcessedFilesColumn(),
|
||||||
|
AddMaxConcurrentTransfersColumn(),
|
||||||
|
AddMultiConfigSupport(),
|
||||||
|
UpdateSkipProcessedFilesToNullable(),
|
||||||
|
AddWebhookSupport(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetConfigRclonePathWithEnv tests the GetConfigRclonePath function with different environment variables
|
||||||
|
func TestGetConfigRclonePathWithEnv(t *testing.T) {
|
||||||
|
// Save original environment variable
|
||||||
|
originalDataDir := os.Getenv("DATA_DIR")
|
||||||
|
defer os.Setenv("DATA_DIR", originalDataDir)
|
||||||
|
|
||||||
|
// Set a custom data directory
|
||||||
|
customDir := "/tmp/custom_data_dir"
|
||||||
|
os.Setenv("DATA_DIR", customDir)
|
||||||
|
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test config
|
||||||
|
testUser := &User{
|
||||||
|
Email: "rclone-env-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
testConfig := &TransferConfig{
|
||||||
|
Name: "Test Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest/path",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test GetConfigRclonePath with custom DATA_DIR
|
||||||
|
configPath := db.GetConfigRclonePath(testConfig)
|
||||||
|
assert.Equal(t,
|
||||||
|
filepath.Join(customDir, "configs", fmt.Sprintf("config_%d.conf", testConfig.ID)),
|
||||||
|
configPath,
|
||||||
|
"Should use DATA_DIR environment variable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateRcloneConfigWithoutRclone tests error handling when rclone executable is not available
|
||||||
|
func TestGenerateRcloneConfigWithoutRclone(t *testing.T) {
|
||||||
|
// Save original environment variable
|
||||||
|
originalRclonePath := os.Getenv("RCLONE_PATH")
|
||||||
|
defer os.Setenv("RCLONE_PATH", originalRclonePath)
|
||||||
|
|
||||||
|
// Set a nonexistent rclone path
|
||||||
|
os.Setenv("RCLONE_PATH", "/nonexistent/rclone")
|
||||||
|
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: "rclone-missing-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test configs for different source types
|
||||||
|
sourceTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "google_drive"}
|
||||||
|
|
||||||
|
for _, sourceType := range sourceTypes {
|
||||||
|
testConfig := &TransferConfig{
|
||||||
|
Name: fmt.Sprintf("Test %s Config", sourceType),
|
||||||
|
SourceType: sourceType,
|
||||||
|
SourceHost: "example.com",
|
||||||
|
SourcePort: 22,
|
||||||
|
SourceUser: "testuser",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
SourceAccessKey: "access_key",
|
||||||
|
SourceSecretKey: "secret_key",
|
||||||
|
SourceRegion: "us-east-1",
|
||||||
|
SourceEndpoint: "endpoint.example.com",
|
||||||
|
SourceClientID: "client_id",
|
||||||
|
SourceClientSecret: "client_secret",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest/path",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.CreateTransferConfig(testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// This should return an error because rclone is not available
|
||||||
|
err = db.GenerateRcloneConfig(testConfig)
|
||||||
|
assert.Error(t, err, "Should return an error when rclone executable is not found for source type: %s", sourceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test configs for different destination types
|
||||||
|
destTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "google_drive"}
|
||||||
|
|
||||||
|
for _, destType := range destTypes {
|
||||||
|
testConfig := &TransferConfig{
|
||||||
|
Name: fmt.Sprintf("Test Dest %s Config", destType),
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: destType,
|
||||||
|
DestHost: "example.com",
|
||||||
|
DestPort: 22,
|
||||||
|
DestUser: "testuser",
|
||||||
|
DestinationPath: "/dest/path",
|
||||||
|
DestAccessKey: "access_key",
|
||||||
|
DestSecretKey: "secret_key",
|
||||||
|
DestRegion: "us-east-1",
|
||||||
|
DestEndpoint: "endpoint.example.com",
|
||||||
|
DestClientID: "client_id",
|
||||||
|
DestClientSecret: "client_secret",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.CreateTransferConfig(testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// This should return an error because rclone is not available
|
||||||
|
err = db.GenerateRcloneConfig(testConfig)
|
||||||
|
if destType != "local" {
|
||||||
|
assert.Error(t, err, "Should return an error when rclone executable is not found for dest type: %s", destType)
|
||||||
|
} else {
|
||||||
|
// Local destination type might not error since it doesn't need to call rclone
|
||||||
|
t.Logf("Local destination type might not error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeleteTransferConfigWithTransaction tests the DeleteTransferConfig function with transaction scenarios
|
||||||
|
func TestDeleteTransferConfigWithTransaction(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: "delete-config-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test config
|
||||||
|
testConfig := &TransferConfig{
|
||||||
|
Name: "Test Delete Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test successful deletion
|
||||||
|
err = db.DeleteTransferConfig(testConfig.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
_, err = db.GetTransferConfig(testConfig.ID)
|
||||||
|
assert.Error(t, err, "Config should be deleted")
|
||||||
|
|
||||||
|
// Test deletion with transaction that's rolled back
|
||||||
|
// Create another config
|
||||||
|
testConfig2 := &TransferConfig{
|
||||||
|
Name: "Test Delete Config 2",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path2",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path2",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(testConfig2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Start a transaction
|
||||||
|
tx := db.Begin()
|
||||||
|
assert.NotNil(t, tx)
|
||||||
|
|
||||||
|
// Delete the config within the transaction
|
||||||
|
err = tx.Delete(&TransferConfig{}, testConfig2.ID).Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Rollback the transaction
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
// Verify the config still exists
|
||||||
|
config, err := db.GetTransferConfig(testConfig2.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, config)
|
||||||
|
assert.Equal(t, testConfig2.ID, config.ID)
|
||||||
|
|
||||||
|
// Test deletion with a committed transaction
|
||||||
|
tx = db.Begin()
|
||||||
|
assert.NotNil(t, tx)
|
||||||
|
|
||||||
|
// Delete the config within the transaction
|
||||||
|
err = tx.Delete(&TransferConfig{}, testConfig2.ID).Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
tx.Commit()
|
||||||
|
|
||||||
|
// Verify the config is deleted
|
||||||
|
_, err = db.GetTransferConfig(testConfig2.ID)
|
||||||
|
assert.Error(t, err, "Config should be deleted after commit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteJobWithTransaction tests the DeleteJob function with transaction scenarios
|
||||||
|
func TestDeleteJobWithTransaction(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &User{
|
||||||
|
Email: "delete-job-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := db.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test transfer config
|
||||||
|
testConfig := &TransferConfig{
|
||||||
|
Name: "Test Delete Job Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateTransferConfig(testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test job
|
||||||
|
testJob := &Job{
|
||||||
|
Name: "Test Delete Job",
|
||||||
|
ConfigID: testConfig.ID,
|
||||||
|
Schedule: "0 * * * *", // Run hourly
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateJob(testJob)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test successful deletion
|
||||||
|
err = db.DeleteJob(testJob.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
_, err = db.GetJob(testJob.ID)
|
||||||
|
assert.Error(t, err, "Job should be deleted")
|
||||||
|
|
||||||
|
// Test deletion with transaction that's rolled back
|
||||||
|
// Create another job
|
||||||
|
testJob2 := &Job{
|
||||||
|
Name: "Test Delete Job 2",
|
||||||
|
ConfigID: testConfig.ID,
|
||||||
|
Schedule: "0 * * * *", // Run hourly
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = db.CreateJob(testJob2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Start a transaction
|
||||||
|
tx := db.Begin()
|
||||||
|
assert.NotNil(t, tx)
|
||||||
|
|
||||||
|
// Delete the job within the transaction
|
||||||
|
err = tx.Delete(&Job{}, testJob2.ID).Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Rollback the transaction
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
// Verify the job still exists
|
||||||
|
job, err := db.GetJob(testJob2.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, job)
|
||||||
|
assert.Equal(t, testJob2.ID, job.ID)
|
||||||
|
|
||||||
|
// Test deletion with a committed transaction
|
||||||
|
tx = db.Begin()
|
||||||
|
assert.NotNil(t, tx)
|
||||||
|
|
||||||
|
// Delete the job within the transaction
|
||||||
|
err = tx.Delete(&Job{}, testJob2.ID).Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
tx.Commit()
|
||||||
|
|
||||||
|
// Verify the job is deleted
|
||||||
|
_, err = db.GetJob(testJob2.ID)
|
||||||
|
assert.Error(t, err, "Job should be deleted after commit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTransactionHelpers tests transaction helper methods
|
||||||
|
func TestTransactionHelpers(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
|
||||||
|
// Test Begin and Rollback
|
||||||
|
tx := db.Begin()
|
||||||
|
assert.NotNil(t, tx)
|
||||||
|
assert.IsType(t, &gorm.DB{}, tx)
|
||||||
|
|
||||||
|
// Rollback should succeed
|
||||||
|
err := tx.Rollback().Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test Begin and Commit
|
||||||
|
tx = db.Begin()
|
||||||
|
assert.NotNil(t, tx)
|
||||||
|
|
||||||
|
// Commit should succeed
|
||||||
|
err = tx.Commit().Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -124,7 +124,7 @@ func (s *Service) generatePasswordResetEmailHTML(data map[string]interface{}) (s
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background-color: #4338ca;
|
background-color:rgb(55, 113, 236);
|
||||||
}
|
}
|
||||||
.reset-link {
|
.reset-link {
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/starfleetcptn/gomft/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup test configuration without using testutils (to avoid import cycles)
|
||||||
|
func setupTestConfig(t *testing.T) *config.Config {
|
||||||
|
tempDir, err := os.MkdirTemp("", "gomft-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
return &config.Config{
|
||||||
|
ServerAddress: ":9090",
|
||||||
|
DataDir: filepath.Join(tempDir, "data"),
|
||||||
|
BackupDir: filepath.Join(tempDir, "backups"),
|
||||||
|
JWTSecret: "test-jwt-secret",
|
||||||
|
BaseURL: "http://test.example.com",
|
||||||
|
Email: config.EmailConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Host: "smtp.test.com",
|
||||||
|
Port: 587,
|
||||||
|
Username: "test@example.com",
|
||||||
|
Password: "test-password",
|
||||||
|
FromEmail: "test@example.com",
|
||||||
|
FromName: "Test",
|
||||||
|
EnableTLS: true,
|
||||||
|
RequireAuth: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailServiceDisabled(t *testing.T) {
|
||||||
|
// Set up test config with email disabled
|
||||||
|
cfg := setupTestConfig(t)
|
||||||
|
cfg.Email.Enabled = false
|
||||||
|
|
||||||
|
// Create the email service
|
||||||
|
service := NewService(cfg)
|
||||||
|
|
||||||
|
// Send a password reset email
|
||||||
|
err := service.SendPasswordResetEmail("test@example.com", "Test User", "token123")
|
||||||
|
|
||||||
|
// Expect an error indicating the service is disabled
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error when email service is disabled, but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the error message contains the reset link
|
||||||
|
expectedMsg := cfg.BaseURL + "/reset-password?token=token123"
|
||||||
|
if !strings.Contains(err.Error(), expectedMsg) {
|
||||||
|
t.Errorf("Expected error message to contain the reset link %s, got: %s", expectedMsg, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePasswordResetEmailHTML(t *testing.T) {
|
||||||
|
// Set up test config
|
||||||
|
cfg := setupTestConfig(t)
|
||||||
|
service := NewService(cfg)
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data map[string]interface{}
|
||||||
|
expected []string // Strings that should be included in the HTML
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Complete user data",
|
||||||
|
data: map[string]interface{}{
|
||||||
|
"Username": "John Doe",
|
||||||
|
"ResetLink": "http://example.com/reset?token=abc123",
|
||||||
|
"AppName": "GoMFT",
|
||||||
|
"Year": 2023,
|
||||||
|
"ExpiresHours": 0.25,
|
||||||
|
},
|
||||||
|
expected: []string{
|
||||||
|
"Hello John Doe",
|
||||||
|
"http://example.com/reset?token=abc123",
|
||||||
|
"GoMFT",
|
||||||
|
"2023",
|
||||||
|
"15 minutes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No username",
|
||||||
|
data: map[string]interface{}{
|
||||||
|
"ResetLink": "http://example.com/reset?token=abc123",
|
||||||
|
"AppName": "GoMFT",
|
||||||
|
"Year": 2023,
|
||||||
|
"ExpiresHours": 0.25,
|
||||||
|
},
|
||||||
|
expected: []string{
|
||||||
|
"Hello",
|
||||||
|
"http://example.com/reset?token=abc123",
|
||||||
|
"GoMFT",
|
||||||
|
"2023",
|
||||||
|
"15 minutes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Generate HTML
|
||||||
|
html, err := service.generatePasswordResetEmailHTML(tc.data)
|
||||||
|
|
||||||
|
// Check for errors
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Error generating HTML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all expected strings are included
|
||||||
|
for _, expected := range tc.expected {
|
||||||
|
if !strings.Contains(html, expected) {
|
||||||
|
t.Errorf("Expected HTML to contain %q, but it doesn't", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/starfleetcptn/gomft/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockService implements the email Service for testing purposes
|
||||||
|
type MockService struct {
|
||||||
|
SendEmailCalls int
|
||||||
|
SendPasswordResetEmailCalls int
|
||||||
|
ReturnError error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockService creates a new mock email service
|
||||||
|
func NewMockService() *Service {
|
||||||
|
// Create minimal config
|
||||||
|
cfg := &config.Config{
|
||||||
|
Email: config.EmailConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
BaseURL: "http://localhost:8080",
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
Config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPasswordResetEmail mocks sending a password reset email
|
||||||
|
func (s *MockService) SendPasswordResetEmail(toEmail, username, resetToken string) error {
|
||||||
|
return fmt.Errorf("email service is disabled, reset link would be: %s/reset-password?token=%s",
|
||||||
|
"http://localhost:8080", resetToken)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockScheduler is a mock implementation of a scheduler for testing
|
||||||
|
type MockScheduler struct {
|
||||||
|
ScheduledJobs map[uint]bool
|
||||||
|
UnscheduledJobs map[uint]bool
|
||||||
|
RunJobsNow map[uint]bool
|
||||||
|
ScheduleJobErr error
|
||||||
|
RunJobNowErr error
|
||||||
|
UnscheduleJobCalls int
|
||||||
|
MultiConfigJobs map[uint][]uint // Track jobs with multiple configs (job ID -> config IDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockScheduler creates a new mock scheduler
|
||||||
|
func NewMockScheduler() *MockScheduler {
|
||||||
|
return &MockScheduler{
|
||||||
|
ScheduledJobs: make(map[uint]bool),
|
||||||
|
UnscheduledJobs: make(map[uint]bool),
|
||||||
|
RunJobsNow: make(map[uint]bool),
|
||||||
|
MultiConfigJobs: make(map[uint][]uint),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleJob mocks scheduling a job
|
||||||
|
func (m *MockScheduler) ScheduleJob(job *db.Job) error {
|
||||||
|
if m.ScheduleJobErr != nil {
|
||||||
|
return m.ScheduleJobErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.Enabled {
|
||||||
|
m.ScheduledJobs[job.ID] = true
|
||||||
|
delete(m.UnscheduledJobs, job.ID)
|
||||||
|
} else {
|
||||||
|
m.UnscheduledJobs[job.ID] = true
|
||||||
|
delete(m.ScheduledJobs, job.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track jobs with multiple configurations
|
||||||
|
if job.ConfigIDs != "" {
|
||||||
|
m.MultiConfigJobs[job.ID] = job.GetConfigIDsList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunJobNow mocks running a job immediately
|
||||||
|
func (m *MockScheduler) RunJobNow(jobID uint) error {
|
||||||
|
if m.RunJobNowErr != nil {
|
||||||
|
return m.RunJobNowErr
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RunJobsNow[jobID] = true
|
||||||
|
|
||||||
|
// In a real implementation, this would execute the job
|
||||||
|
// But for testing, we just record that it was called
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnscheduleJob mocks unscheduling a job
|
||||||
|
func (m *MockScheduler) UnscheduleJob(jobID uint) {
|
||||||
|
m.UnscheduleJobCalls++
|
||||||
|
m.UnscheduledJobs[jobID] = true
|
||||||
|
delete(m.ScheduledJobs, jobID)
|
||||||
|
delete(m.MultiConfigJobs, jobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop mocks stopping the scheduler
|
||||||
|
func (m *MockScheduler) Stop() {
|
||||||
|
// Nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateLogs mocks log rotation
|
||||||
|
func (m *MockScheduler) RotateLogs() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsJobWithMultipleConfigs checks if a job is scheduled with multiple configs
|
||||||
|
func (m *MockScheduler) IsJobWithMultipleConfigs(jobID uint) bool {
|
||||||
|
configs, exists := m.MultiConfigJobs[jobID]
|
||||||
|
return exists && len(configs) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigsForJob returns the configs for a job
|
||||||
|
func (m *MockScheduler) GetConfigsForJob(jobID uint) []uint {
|
||||||
|
return m.MultiConfigJobs[jobID]
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMockScheduler_MultiConfig(t *testing.T) {
|
||||||
|
// Create a new mock scheduler
|
||||||
|
mockScheduler := NewMockScheduler()
|
||||||
|
|
||||||
|
// Create a job with multiple configurations
|
||||||
|
job := &db.Job{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Multi-Config Test Job",
|
||||||
|
Schedule: "*/5 * * * *",
|
||||||
|
ConfigID: 1, // Primary config ID
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set multiple config IDs
|
||||||
|
job.SetConfigIDsList([]uint{1, 2, 3})
|
||||||
|
|
||||||
|
// Schedule the job
|
||||||
|
err := mockScheduler.ScheduleJob(job)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Check if the job is marked as scheduled
|
||||||
|
assert.True(t, mockScheduler.ScheduledJobs[job.ID])
|
||||||
|
|
||||||
|
// Verify that the job is detected as having multiple configs
|
||||||
|
assert.True(t, mockScheduler.IsJobWithMultipleConfigs(job.ID))
|
||||||
|
|
||||||
|
// Verify the configs associated with the job
|
||||||
|
configs := mockScheduler.GetConfigsForJob(job.ID)
|
||||||
|
assert.Len(t, configs, 3)
|
||||||
|
assert.Contains(t, configs, uint(1))
|
||||||
|
assert.Contains(t, configs, uint(2))
|
||||||
|
assert.Contains(t, configs, uint(3))
|
||||||
|
|
||||||
|
// Test unscheduling the job
|
||||||
|
mockScheduler.UnscheduleJob(job.ID)
|
||||||
|
assert.True(t, mockScheduler.UnscheduledJobs[job.ID])
|
||||||
|
assert.False(t, mockScheduler.ScheduledJobs[job.ID])
|
||||||
|
|
||||||
|
// Verify the job is no longer tracked in multi-config jobs
|
||||||
|
assert.False(t, mockScheduler.IsJobWithMultipleConfigs(job.ID))
|
||||||
|
assert.Empty(t, mockScheduler.GetConfigsForJob(job.ID))
|
||||||
|
|
||||||
|
// Test a job with a single config
|
||||||
|
singleConfigJob := &db.Job{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Single Config Job",
|
||||||
|
Schedule: "0 0 * * *",
|
||||||
|
ConfigID: 4,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a single config ID
|
||||||
|
singleConfigJob.SetConfigIDsList([]uint{4})
|
||||||
|
|
||||||
|
// Schedule the job
|
||||||
|
err = mockScheduler.ScheduleJob(singleConfigJob)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Not considered a multi-config job if it has only one config
|
||||||
|
assert.False(t, mockScheduler.IsJobWithMultipleConfigs(singleConfigJob.ID))
|
||||||
|
|
||||||
|
// Should still contain the single config
|
||||||
|
singleConfigs := mockScheduler.GetConfigsForJob(singleConfigJob.ID)
|
||||||
|
assert.Len(t, singleConfigs, 1)
|
||||||
|
assert.Contains(t, singleConfigs, uint(4))
|
||||||
|
}
|
||||||
+848
-159
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SchedulerInterface defines the interface for job scheduling operations
|
||||||
|
type SchedulerInterface interface {
|
||||||
|
// ScheduleJob schedules a job based on its cron expression
|
||||||
|
ScheduleJob(job *db.Job) error
|
||||||
|
|
||||||
|
// RunJobNow runs a job immediately
|
||||||
|
RunJobNow(jobID uint) error
|
||||||
|
|
||||||
|
// UnscheduleJob removes a job from the scheduler
|
||||||
|
UnscheduleJob(jobID uint)
|
||||||
|
|
||||||
|
// Stop stops the scheduler
|
||||||
|
Stop()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,567 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestJobExecutionWebhook tests that webhooks are correctly sent during actual job execution
|
||||||
|
func TestJobExecutionWebhook(t *testing.T) {
|
||||||
|
// Skip in short mode
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up a temporary data directory for logs
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Set DATA_DIR environment variable for the test
|
||||||
|
originalDataDir := os.Getenv("DATA_DIR")
|
||||||
|
t.Setenv("DATA_DIR", tempDir)
|
||||||
|
defer os.Setenv("DATA_DIR", originalDataDir)
|
||||||
|
|
||||||
|
// Create a test database
|
||||||
|
database := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
user := &db.User{
|
||||||
|
Email: "webhook-integration@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
err := database.CreateUser(user)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set up a mock HTTP server to receive webhook notifications
|
||||||
|
var (
|
||||||
|
receivedPayload []byte
|
||||||
|
receivedHeaders http.Header
|
||||||
|
webhookCalled bool
|
||||||
|
webhookMutex sync.Mutex
|
||||||
|
waitCh = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhookMutex.Lock()
|
||||||
|
defer webhookMutex.Unlock()
|
||||||
|
|
||||||
|
receivedHeaders = r.Header.Clone()
|
||||||
|
var err error
|
||||||
|
receivedPayload, err = io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Error reading request body: %v", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf("Received webhook payload: %s", string(receivedPayload))
|
||||||
|
webhookCalled = true
|
||||||
|
close(waitCh)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer mockServer.Close()
|
||||||
|
t.Logf("Mock server URL: %s", mockServer.URL)
|
||||||
|
|
||||||
|
// Create local source and destination directories
|
||||||
|
sourceDir := t.TempDir()
|
||||||
|
destDir := t.TempDir()
|
||||||
|
t.Logf("Source directory: %s", sourceDir)
|
||||||
|
t.Logf("Destination directory: %s", destDir)
|
||||||
|
|
||||||
|
// Create a test transfer config with local source and destination
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Webhook Integration Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: sourceDir,
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: destDir,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(config).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Created config with ID: %d", config.ID)
|
||||||
|
|
||||||
|
// Create a test job with webhook enabled
|
||||||
|
job := &db.Job{
|
||||||
|
Name: "Webhook Integration Job",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
Schedule: "*/5 * * * *", // not actually used in this test
|
||||||
|
Enabled: true,
|
||||||
|
WebhookEnabled: true,
|
||||||
|
WebhookURL: mockServer.URL,
|
||||||
|
NotifyOnSuccess: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Logf("Created job with ID %d, NotifyOnSuccess=%v", job.ID, job.NotifyOnSuccess)
|
||||||
|
|
||||||
|
// Create and initialize the scheduler
|
||||||
|
scheduler := New(database)
|
||||||
|
defer scheduler.Stop()
|
||||||
|
|
||||||
|
// Create rclone config directory and file
|
||||||
|
configDir := filepath.Join(tempDir, "configs")
|
||||||
|
err = os.MkdirAll(configDir, 0755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a minimal rclone config file
|
||||||
|
rcloneConfig := `
|
||||||
|
[source_1]
|
||||||
|
type = local
|
||||||
|
|
||||||
|
[dest_1]
|
||||||
|
type = local
|
||||||
|
`
|
||||||
|
configFile := filepath.Join(configDir, "config_1.conf")
|
||||||
|
err = os.WriteFile(configFile, []byte(rcloneConfig), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Created rclone config file: %s", configFile)
|
||||||
|
|
||||||
|
// Put a test file in the source directory
|
||||||
|
testFile := filepath.Join(sourceDir, "test.txt")
|
||||||
|
testFileContent := []byte("This is a test file for webhook integration testing.")
|
||||||
|
err = os.WriteFile(testFile, testFileContent, 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Created test file: %s", testFile)
|
||||||
|
|
||||||
|
// Check that the file exists
|
||||||
|
fileInfo, err := os.Stat(testFile)
|
||||||
|
require.NoError(t, err, "Test file should exist")
|
||||||
|
t.Logf("Test file size: %d bytes", fileInfo.Size())
|
||||||
|
|
||||||
|
// Manually trigger job execution
|
||||||
|
t.Logf("Running job now...")
|
||||||
|
err = scheduler.RunJobNow(job.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the job to complete and webhook to be called (up to 15 seconds)
|
||||||
|
t.Logf("Waiting for webhook to be called...")
|
||||||
|
timeout := time.After(15 * time.Second)
|
||||||
|
select {
|
||||||
|
case <-waitCh:
|
||||||
|
t.Logf("Webhook was called")
|
||||||
|
case <-timeout:
|
||||||
|
// Before failing, check job status
|
||||||
|
var histories []db.JobHistory
|
||||||
|
err = database.DB.Where("job_id = ?", job.ID).Find(&histories).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if len(histories) > 0 {
|
||||||
|
t.Logf("Job history found: status=%s, error=%s",
|
||||||
|
histories[0].Status, histories[0].ErrorMessage)
|
||||||
|
} else {
|
||||||
|
t.Logf("No job history found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination file exists
|
||||||
|
destFile := filepath.Join(destDir, "test.txt")
|
||||||
|
if _, err := os.Stat(destFile); err == nil {
|
||||||
|
t.Logf("Destination file exists, but webhook was not called")
|
||||||
|
} else {
|
||||||
|
t.Logf("Destination file does not exist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookMutex.Lock()
|
||||||
|
called := webhookCalled
|
||||||
|
webhookMutex.Unlock()
|
||||||
|
|
||||||
|
if called {
|
||||||
|
t.Logf("Webhook was actually called but channel synchronization failed")
|
||||||
|
} else {
|
||||||
|
t.Fatal("Timed out waiting for webhook to be called")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the webhook notification
|
||||||
|
webhookMutex.Lock()
|
||||||
|
payload := receivedPayload
|
||||||
|
headers := receivedHeaders
|
||||||
|
webhookMutex.Unlock()
|
||||||
|
|
||||||
|
assert.NotNil(t, payload, "Webhook notification should have been sent")
|
||||||
|
|
||||||
|
// Verify the payload content
|
||||||
|
var payloadMap map[string]interface{}
|
||||||
|
err = json.Unmarshal(payload, &payloadMap)
|
||||||
|
require.NoError(t, err, "Failed to unmarshal webhook payload")
|
||||||
|
|
||||||
|
// Check essential fields
|
||||||
|
assert.Equal(t, "job_execution", payloadMap["event_type"])
|
||||||
|
assert.Equal(t, float64(job.ID), payloadMap["job_id"])
|
||||||
|
assert.Equal(t, job.Name, payloadMap["job_name"])
|
||||||
|
assert.Equal(t, float64(config.ID), payloadMap["config_id"])
|
||||||
|
assert.Equal(t, config.Name, payloadMap["config_name"])
|
||||||
|
|
||||||
|
// Check status (should be "completed" or "completed_with_errors")
|
||||||
|
status, ok := payloadMap["status"].(string)
|
||||||
|
require.True(t, ok, "Status should be a string")
|
||||||
|
assert.Contains(t, []string{"completed", "completed_with_errors"}, status)
|
||||||
|
|
||||||
|
// Check that we have bytes transferred
|
||||||
|
bytesTransferred, ok := payloadMap["bytes_transferred"].(float64)
|
||||||
|
require.True(t, ok, "bytes_transferred should be a number")
|
||||||
|
assert.Greater(t, bytesTransferred, float64(0))
|
||||||
|
|
||||||
|
// Check that we have files transferred
|
||||||
|
filesTransferred, ok := payloadMap["files_transferred"].(float64)
|
||||||
|
require.True(t, ok, "files_transferred should be a number")
|
||||||
|
assert.Equal(t, float64(1), filesTransferred)
|
||||||
|
|
||||||
|
// Check standard headers
|
||||||
|
assert.Equal(t, "application/json", headers.Get("Content-Type"))
|
||||||
|
assert.Equal(t, "GoMFT-Webhook/1.0", headers.Get("User-Agent"))
|
||||||
|
|
||||||
|
// Check that the file was actually transferred
|
||||||
|
destFile := filepath.Join(destDir, "test.txt")
|
||||||
|
_, err = os.Stat(destFile)
|
||||||
|
assert.NoError(t, err, "The file should have been transferred")
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
err = database.DB.Unscoped().Delete(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = database.DB.Unscoped().Where("job_id = ?", job.ID).Delete(&db.JobHistory{}).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFailedJobWebhook tests that webhooks are correctly sent for failed jobs
|
||||||
|
func TestFailedJobWebhook(t *testing.T) {
|
||||||
|
// Skip in short mode
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up a temporary data directory for logs
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Set DATA_DIR environment variable for the test
|
||||||
|
originalDataDir := os.Getenv("DATA_DIR")
|
||||||
|
t.Setenv("DATA_DIR", tempDir)
|
||||||
|
defer os.Setenv("DATA_DIR", originalDataDir)
|
||||||
|
|
||||||
|
// Create a test database
|
||||||
|
database := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
user := &db.User{
|
||||||
|
Email: "webhook-failure@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
err := database.CreateUser(user)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set up a mock HTTP server to receive webhook notifications
|
||||||
|
var (
|
||||||
|
receivedPayload []byte
|
||||||
|
webhookCalled bool
|
||||||
|
webhookMutex sync.Mutex
|
||||||
|
waitCh = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhookMutex.Lock()
|
||||||
|
defer webhookMutex.Unlock()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
receivedPayload, err = io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Error reading request body: %v", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf("Received webhook payload: %s", string(receivedPayload))
|
||||||
|
webhookCalled = true
|
||||||
|
close(waitCh)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
// Get a non-existent directory for source
|
||||||
|
nonexistentDir := filepath.Join(t.TempDir(), "non-existent-subdirectory")
|
||||||
|
|
||||||
|
// Create a legitimate destination directory
|
||||||
|
destDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a test transfer config with invalid source (to trigger failure)
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Webhook Failure Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: nonexistentDir,
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: destDir,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(config).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Created config with invalid source path: %s", nonexistentDir)
|
||||||
|
|
||||||
|
// Create a test job with webhook enabled
|
||||||
|
job := &db.Job{
|
||||||
|
Name: "Webhook Failure Job",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
Schedule: "*/5 * * * *", // not actually used in this test
|
||||||
|
Enabled: true,
|
||||||
|
WebhookEnabled: true,
|
||||||
|
WebhookURL: mockServer.URL,
|
||||||
|
NotifyOnSuccess: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create and initialize the scheduler
|
||||||
|
scheduler := New(database)
|
||||||
|
defer scheduler.Stop()
|
||||||
|
|
||||||
|
// Create rclone config directory and file
|
||||||
|
configDir := filepath.Join(tempDir, "configs")
|
||||||
|
err = os.MkdirAll(configDir, 0755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a minimal rclone config file
|
||||||
|
rcloneConfig := `
|
||||||
|
[source_1]
|
||||||
|
type = local
|
||||||
|
|
||||||
|
[dest_1]
|
||||||
|
type = local
|
||||||
|
`
|
||||||
|
configFile := filepath.Join(configDir, "config_1.conf")
|
||||||
|
err = os.WriteFile(configFile, []byte(rcloneConfig), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Created rclone config file: %s", configFile)
|
||||||
|
|
||||||
|
// Manually trigger job execution
|
||||||
|
t.Logf("Running job now (expecting failure)...")
|
||||||
|
err = scheduler.RunJobNow(job.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the job to complete and webhook to be called (up to 15 seconds)
|
||||||
|
t.Logf("Waiting for webhook to be called with failure notification...")
|
||||||
|
timeout := time.After(15 * time.Second)
|
||||||
|
select {
|
||||||
|
case <-waitCh:
|
||||||
|
t.Logf("Webhook was called")
|
||||||
|
case <-timeout:
|
||||||
|
// Before failing, check job status
|
||||||
|
var histories []db.JobHistory
|
||||||
|
err = database.DB.Where("job_id = ?", job.ID).Find(&histories).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if len(histories) > 0 {
|
||||||
|
t.Logf("Job history found: status=%s, error=%s",
|
||||||
|
histories[0].Status, histories[0].ErrorMessage)
|
||||||
|
} else {
|
||||||
|
t.Logf("No job history found")
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookMutex.Lock()
|
||||||
|
called := webhookCalled
|
||||||
|
webhookMutex.Unlock()
|
||||||
|
|
||||||
|
if called {
|
||||||
|
t.Logf("Webhook was actually called but channel synchronization failed")
|
||||||
|
} else {
|
||||||
|
t.Fatal("Timed out waiting for webhook to be called")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the webhook notification
|
||||||
|
assert.NotNil(t, receivedPayload, "Webhook notification should have been sent")
|
||||||
|
|
||||||
|
// Verify the payload content
|
||||||
|
var payload map[string]interface{}
|
||||||
|
err = json.Unmarshal(receivedPayload, &payload)
|
||||||
|
require.NoError(t, err, "Failed to unmarshal webhook payload")
|
||||||
|
|
||||||
|
// Check essential fields
|
||||||
|
assert.Equal(t, "job_execution", payload["event_type"])
|
||||||
|
assert.Equal(t, float64(job.ID), payload["job_id"])
|
||||||
|
assert.Equal(t, "failed", payload["status"])
|
||||||
|
|
||||||
|
// Ensure there's an error message
|
||||||
|
errorMsg, ok := payload["error_message"].(string)
|
||||||
|
require.True(t, ok, "error_message should be a string")
|
||||||
|
assert.NotEmpty(t, errorMsg)
|
||||||
|
t.Logf("Error message from webhook: %s", errorMsg)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
err = database.DB.Unscoped().Delete(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = database.DB.Unscoped().Where("job_id = ?", job.ID).Delete(&db.JobHistory{}).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookDisabledForSuccessNotification tests that webhooks are not sent for
|
||||||
|
// successful jobs when notify_on_success is disabled
|
||||||
|
func TestWebhookDisabledForSuccessNotification(t *testing.T) {
|
||||||
|
// Skip in short mode
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up a temporary data directory for logs
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Set DATA_DIR environment variable for the test
|
||||||
|
originalDataDir := os.Getenv("DATA_DIR")
|
||||||
|
t.Setenv("DATA_DIR", tempDir)
|
||||||
|
defer os.Setenv("DATA_DIR", originalDataDir)
|
||||||
|
|
||||||
|
// Create a test database
|
||||||
|
database := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
user := &db.User{
|
||||||
|
Email: "webhook-disabled@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
err := database.CreateUser(user)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set up a mock HTTP server to receive webhook notifications
|
||||||
|
var (
|
||||||
|
webhookCalled bool
|
||||||
|
webhookMutex sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhookMutex.Lock()
|
||||||
|
defer webhookMutex.Unlock()
|
||||||
|
|
||||||
|
// Log the fact that webhook was called (it shouldn't be)
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
t.Logf("Unexpected webhook call received: %s", string(body))
|
||||||
|
|
||||||
|
webhookCalled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
// Create local source and destination directories
|
||||||
|
sourceDir := t.TempDir()
|
||||||
|
destDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a test transfer config with local source and destination
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Webhook Disabled Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: sourceDir,
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: destDir,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(config).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test job with webhook enabled but notify_on_success disabled
|
||||||
|
job := &db.Job{
|
||||||
|
Name: "Webhook Disabled Job",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
Schedule: "*/5 * * * *", // not actually used in this test
|
||||||
|
Enabled: true,
|
||||||
|
WebhookEnabled: true,
|
||||||
|
WebhookURL: mockServer.URL,
|
||||||
|
NotifyOnSuccess: false, // This is the key setting we're testing
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Update the job to ensure the notification settings are correctly set
|
||||||
|
// This is necessary because the database has default values for these fields
|
||||||
|
err = database.DB.Model(job).Updates(map[string]interface{}{
|
||||||
|
"notify_on_success": false,
|
||||||
|
}).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Reload the job to make sure we have the correct values
|
||||||
|
var reloadedJob db.Job
|
||||||
|
err = database.DB.First(&reloadedJob, job.ID).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
job = &reloadedJob
|
||||||
|
|
||||||
|
t.Logf("Created job with ID %d, NotifyOnSuccess=%v", job.ID, job.NotifyOnSuccess)
|
||||||
|
|
||||||
|
// Create and initialize the scheduler
|
||||||
|
scheduler := New(database)
|
||||||
|
defer scheduler.Stop()
|
||||||
|
|
||||||
|
// Create rclone config directory and file
|
||||||
|
configDir := filepath.Join(tempDir, "configs")
|
||||||
|
err = os.MkdirAll(configDir, 0755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a minimal rclone config file
|
||||||
|
rcloneConfig := `
|
||||||
|
[source_1]
|
||||||
|
type = local
|
||||||
|
|
||||||
|
[dest_1]
|
||||||
|
type = local
|
||||||
|
`
|
||||||
|
configFile := filepath.Join(configDir, "config_1.conf")
|
||||||
|
err = os.WriteFile(configFile, []byte(rcloneConfig), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Created rclone config file: %s", configFile)
|
||||||
|
|
||||||
|
// Put a test file in the source directory
|
||||||
|
testFile := filepath.Join(sourceDir, "test.txt")
|
||||||
|
testFileContent := []byte("This is a test file for disabled webhook testing.")
|
||||||
|
err = os.WriteFile(testFile, testFileContent, 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Manually trigger job execution
|
||||||
|
t.Logf("Running job now...")
|
||||||
|
err = scheduler.RunJobNow(job.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for a bit to ensure job completes (10 seconds should be plenty)
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
|
||||||
|
// Check if webhook was called (it should not have been)
|
||||||
|
webhookMutex.Lock()
|
||||||
|
called := webhookCalled
|
||||||
|
webhookMutex.Unlock()
|
||||||
|
|
||||||
|
assert.False(t, called, "Webhook should not have been called for successful job with NotifyOnSuccess=false")
|
||||||
|
|
||||||
|
// Verify the job actually ran successfully by checking for the file
|
||||||
|
destFile := filepath.Join(destDir, "test.txt")
|
||||||
|
_, err = os.Stat(destFile)
|
||||||
|
assert.NoError(t, err, "The job should have completed and transferred the file")
|
||||||
|
|
||||||
|
// Verify job history has been created and shows completion
|
||||||
|
var histories []db.JobHistory
|
||||||
|
err = database.DB.Where("job_id = ?", job.ID).Find(&histories).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if len(histories) > 0 {
|
||||||
|
t.Logf("Job history found: status=%s", histories[0].Status)
|
||||||
|
assert.Equal(t, "completed", histories[0].Status, "Job should have completed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
err = database.DB.Unscoped().Delete(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = database.DB.Unscoped().Where("job_id = ?", job.ID).Delete(&db.JobHistory{}).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWebhookNotification tests the webhook notification functionality
|
||||||
|
func TestWebhookNotification(t *testing.T) {
|
||||||
|
// Set up a temporary data directory for logs
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Set DATA_DIR environment variable for the test
|
||||||
|
originalDataDir := os.Getenv("DATA_DIR")
|
||||||
|
t.Setenv("DATA_DIR", tempDir)
|
||||||
|
defer os.Setenv("DATA_DIR", originalDataDir)
|
||||||
|
|
||||||
|
// Create a test database
|
||||||
|
database := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
user := &db.User{
|
||||||
|
Email: "webhook-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
err := database.CreateUser(user)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test transfer config
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Webhook Test Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest",
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(config).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a mock HTTP server to receive webhook notifications
|
||||||
|
var (
|
||||||
|
receivedPayload []byte
|
||||||
|
receivedHeaders http.Header
|
||||||
|
webhookCalled bool
|
||||||
|
webhookMutex sync.Mutex
|
||||||
|
waitCh chan struct{}
|
||||||
|
)
|
||||||
|
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhookMutex.Lock()
|
||||||
|
defer webhookMutex.Unlock()
|
||||||
|
|
||||||
|
receivedHeaders = r.Header.Clone()
|
||||||
|
var err error
|
||||||
|
receivedPayload, err = io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Error reading request body: %v", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug output to help understand what's happening
|
||||||
|
t.Logf("Webhook called with payload: %s", string(receivedPayload))
|
||||||
|
|
||||||
|
webhookCalled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// Signal that webhook was called
|
||||||
|
if waitCh != nil {
|
||||||
|
close(waitCh)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
// Create a test scheduler
|
||||||
|
scheduler := New(database)
|
||||||
|
defer scheduler.Stop()
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
job *db.Job
|
||||||
|
history *db.JobHistory
|
||||||
|
webhookEnabled bool
|
||||||
|
webhookURL string
|
||||||
|
webhookSecret string
|
||||||
|
webhookHeaders map[string]string
|
||||||
|
notifyOnSuccess bool
|
||||||
|
notifyOnFailure bool
|
||||||
|
status string
|
||||||
|
expectNotification bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Successful job with notification",
|
||||||
|
job: &db.Job{
|
||||||
|
Name: "Success Job",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
WebhookEnabled: true,
|
||||||
|
WebhookURL: mockServer.URL,
|
||||||
|
NotifyOnSuccess: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
},
|
||||||
|
history: &db.JobHistory{
|
||||||
|
Status: "completed",
|
||||||
|
StartTime: time.Now().Add(-5 * time.Minute),
|
||||||
|
EndTime: timePtr(time.Now()),
|
||||||
|
BytesTransferred: 1024,
|
||||||
|
FilesTransferred: 2,
|
||||||
|
},
|
||||||
|
webhookEnabled: true,
|
||||||
|
webhookURL: mockServer.URL,
|
||||||
|
notifyOnSuccess: true,
|
||||||
|
notifyOnFailure: true,
|
||||||
|
status: "completed",
|
||||||
|
expectNotification: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Failed job with notification",
|
||||||
|
job: &db.Job{
|
||||||
|
Name: "Failed Job",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
WebhookEnabled: true,
|
||||||
|
WebhookURL: mockServer.URL,
|
||||||
|
NotifyOnSuccess: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
},
|
||||||
|
history: &db.JobHistory{
|
||||||
|
Status: "failed",
|
||||||
|
StartTime: time.Now().Add(-5 * time.Minute),
|
||||||
|
EndTime: timePtr(time.Now()),
|
||||||
|
ErrorMessage: "Test error message",
|
||||||
|
},
|
||||||
|
webhookEnabled: true,
|
||||||
|
webhookURL: mockServer.URL,
|
||||||
|
notifyOnSuccess: true,
|
||||||
|
notifyOnFailure: true,
|
||||||
|
status: "failed",
|
||||||
|
expectNotification: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Successful job with notification disabled for success",
|
||||||
|
job: &db.Job{
|
||||||
|
Name: "Success Job No Notify",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
WebhookEnabled: true,
|
||||||
|
WebhookURL: mockServer.URL,
|
||||||
|
NotifyOnSuccess: false,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
},
|
||||||
|
history: &db.JobHistory{
|
||||||
|
Status: "completed",
|
||||||
|
StartTime: time.Now().Add(-5 * time.Minute),
|
||||||
|
EndTime: timePtr(time.Now()),
|
||||||
|
},
|
||||||
|
webhookEnabled: true,
|
||||||
|
webhookURL: mockServer.URL,
|
||||||
|
notifyOnSuccess: false,
|
||||||
|
notifyOnFailure: true,
|
||||||
|
status: "completed",
|
||||||
|
expectNotification: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Failed job with notification disabled for failure",
|
||||||
|
job: &db.Job{
|
||||||
|
Name: "Failed Job No Notify",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
WebhookEnabled: true,
|
||||||
|
WebhookURL: mockServer.URL,
|
||||||
|
NotifyOnSuccess: true,
|
||||||
|
NotifyOnFailure: false,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
},
|
||||||
|
history: &db.JobHistory{
|
||||||
|
Status: "failed",
|
||||||
|
StartTime: time.Now().Add(-5 * time.Minute),
|
||||||
|
EndTime: timePtr(time.Now()),
|
||||||
|
ErrorMessage: "Test error message",
|
||||||
|
},
|
||||||
|
webhookEnabled: true,
|
||||||
|
webhookURL: mockServer.URL,
|
||||||
|
notifyOnSuccess: true,
|
||||||
|
notifyOnFailure: false,
|
||||||
|
status: "failed",
|
||||||
|
expectNotification: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Webhook disabled",
|
||||||
|
job: &db.Job{
|
||||||
|
Name: "Webhook Disabled",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
WebhookEnabled: false,
|
||||||
|
WebhookURL: mockServer.URL,
|
||||||
|
NotifyOnSuccess: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
},
|
||||||
|
history: &db.JobHistory{
|
||||||
|
Status: "completed",
|
||||||
|
StartTime: time.Now().Add(-5 * time.Minute),
|
||||||
|
EndTime: timePtr(time.Now()),
|
||||||
|
},
|
||||||
|
webhookEnabled: false,
|
||||||
|
webhookURL: mockServer.URL,
|
||||||
|
notifyOnSuccess: true,
|
||||||
|
notifyOnFailure: true,
|
||||||
|
status: "completed",
|
||||||
|
expectNotification: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Reset received data
|
||||||
|
webhookMutex.Lock()
|
||||||
|
receivedPayload = nil
|
||||||
|
receivedHeaders = nil
|
||||||
|
webhookCalled = false
|
||||||
|
waitCh = make(chan struct{})
|
||||||
|
webhookMutex.Unlock()
|
||||||
|
|
||||||
|
// Debug the test case configuration
|
||||||
|
t.Logf("Test configuration: name=%s, webhookEnabled=%v, notifyOnSuccess=%v, notifyOnFailure=%v, status=%s, expectNotification=%v",
|
||||||
|
tc.name, tc.webhookEnabled, tc.notifyOnSuccess, tc.notifyOnFailure, tc.status, tc.expectNotification)
|
||||||
|
|
||||||
|
// Create a new job instance for each test case
|
||||||
|
job := &db.Job{
|
||||||
|
Name: tc.job.Name,
|
||||||
|
ConfigID: tc.job.ConfigID,
|
||||||
|
WebhookEnabled: tc.webhookEnabled,
|
||||||
|
WebhookURL: tc.webhookURL,
|
||||||
|
NotifyOnSuccess: tc.notifyOnSuccess,
|
||||||
|
NotifyOnFailure: tc.notifyOnFailure,
|
||||||
|
CreatedBy: tc.job.CreatedBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Job before DB create: WebhookEnabled=%v, NotifyOnSuccess=%v, NotifyOnFailure=%v",
|
||||||
|
job.WebhookEnabled, job.NotifyOnSuccess, job.NotifyOnFailure)
|
||||||
|
|
||||||
|
err := database.DB.Create(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Update the job to ensure the notification settings are correctly set
|
||||||
|
// This is necessary because the database has default values for these fields
|
||||||
|
err = database.DB.Model(job).Updates(map[string]interface{}{
|
||||||
|
"notify_on_success": tc.notifyOnSuccess,
|
||||||
|
"notify_on_failure": tc.notifyOnFailure,
|
||||||
|
}).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Reload the job to make sure we have the correct values
|
||||||
|
var reloadedJob db.Job
|
||||||
|
err = database.DB.First(&reloadedJob, job.ID).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
job = &reloadedJob
|
||||||
|
|
||||||
|
t.Logf("Job after DB create: WebhookEnabled=%v, NotifyOnSuccess=%v, NotifyOnFailure=%v",
|
||||||
|
job.WebhookEnabled, job.NotifyOnSuccess, job.NotifyOnFailure)
|
||||||
|
|
||||||
|
// Create and save job history
|
||||||
|
history := tc.history
|
||||||
|
history.JobID = job.ID
|
||||||
|
|
||||||
|
err = database.DB.Create(history).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Debug info
|
||||||
|
t.Logf("Test case: %s", tc.name)
|
||||||
|
t.Logf("Job settings: WebhookEnabled=%v, NotifyOnSuccess=%v, NotifyOnFailure=%v",
|
||||||
|
job.WebhookEnabled, job.NotifyOnSuccess, job.NotifyOnFailure)
|
||||||
|
t.Logf("History status: %s", history.Status)
|
||||||
|
|
||||||
|
// Send webhook notification
|
||||||
|
scheduler.sendWebhookNotification(job, history, config)
|
||||||
|
|
||||||
|
// Wait for webhook call to complete if expected
|
||||||
|
if tc.expectNotification {
|
||||||
|
// Wait with timeout for webhook to be called
|
||||||
|
select {
|
||||||
|
case <-waitCh:
|
||||||
|
// Webhook was called
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("Timed out waiting for webhook to be called")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Give it a small window to ensure it doesn't call when not expected
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if notification was sent as expected
|
||||||
|
webhookMutex.Lock()
|
||||||
|
called := webhookCalled
|
||||||
|
payload := receivedPayload
|
||||||
|
headers := receivedHeaders
|
||||||
|
webhookMutex.Unlock()
|
||||||
|
|
||||||
|
if tc.expectNotification {
|
||||||
|
assert.True(t, called, "Expected webhook notification to be sent")
|
||||||
|
require.NotNil(t, payload, "Expected webhook payload to be non-nil")
|
||||||
|
|
||||||
|
// Verify the payload
|
||||||
|
var payloadMap map[string]interface{}
|
||||||
|
err := json.Unmarshal(payload, &payloadMap)
|
||||||
|
require.NoError(t, err, "Failed to unmarshal webhook payload")
|
||||||
|
|
||||||
|
// Check common fields
|
||||||
|
assert.Equal(t, "job_execution", payloadMap["event_type"])
|
||||||
|
assert.Equal(t, float64(job.ID), payloadMap["job_id"])
|
||||||
|
assert.Equal(t, job.Name, payloadMap["job_name"])
|
||||||
|
assert.Equal(t, float64(config.ID), payloadMap["config_id"])
|
||||||
|
assert.Equal(t, config.Name, payloadMap["config_name"])
|
||||||
|
assert.Equal(t, history.Status, payloadMap["status"])
|
||||||
|
|
||||||
|
// Check headers
|
||||||
|
assert.Equal(t, "application/json", headers.Get("Content-Type"))
|
||||||
|
assert.Equal(t, "GoMFT-Webhook/1.0", headers.Get("User-Agent"))
|
||||||
|
|
||||||
|
// Additional checks for specific status
|
||||||
|
if history.Status == "failed" {
|
||||||
|
assert.Equal(t, history.ErrorMessage, payloadMap["error_message"])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert.False(t, called, "Expected no webhook notification to be sent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
err = database.DB.Unscoped().Delete(history).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = database.DB.Unscoped().Delete(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookAuthentication tests the webhook authentication functionality
|
||||||
|
func TestWebhookAuthentication(t *testing.T) {
|
||||||
|
// Set up a temporary data directory for logs
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Set DATA_DIR environment variable for the test
|
||||||
|
originalDataDir := os.Getenv("DATA_DIR")
|
||||||
|
t.Setenv("DATA_DIR", tempDir)
|
||||||
|
defer os.Setenv("DATA_DIR", originalDataDir)
|
||||||
|
|
||||||
|
// Create a test database
|
||||||
|
database := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
user := &db.User{
|
||||||
|
Email: "webhook-auth-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
err := database.CreateUser(user)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test transfer config
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Webhook Auth Test Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest",
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(config).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a mock HTTP server to receive webhook notifications
|
||||||
|
var (
|
||||||
|
receivedPayload []byte
|
||||||
|
receivedHeaders http.Header
|
||||||
|
waitCh = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedHeaders = r.Header.Clone()
|
||||||
|
var err error
|
||||||
|
receivedPayload, err = io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Error reading request body: %v", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
close(waitCh)
|
||||||
|
}))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
// Create a test scheduler
|
||||||
|
scheduler := New(database)
|
||||||
|
defer scheduler.Stop()
|
||||||
|
|
||||||
|
// Set up job with webhook secret
|
||||||
|
secret := "test-webhook-secret"
|
||||||
|
job := &db.Job{
|
||||||
|
Name: "Auth Test Job",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
WebhookEnabled: true,
|
||||||
|
WebhookURL: mockServer.URL,
|
||||||
|
WebhookSecret: secret,
|
||||||
|
NotifyOnSuccess: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create job history
|
||||||
|
history := &db.JobHistory{
|
||||||
|
JobID: job.ID,
|
||||||
|
Status: "completed",
|
||||||
|
StartTime: time.Now().Add(-5 * time.Minute),
|
||||||
|
EndTime: timePtr(time.Now()),
|
||||||
|
BytesTransferred: 1024,
|
||||||
|
FilesTransferred: 2,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(history).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Send webhook notification
|
||||||
|
scheduler.sendWebhookNotification(job, history, config)
|
||||||
|
|
||||||
|
// Wait for webhook to be called
|
||||||
|
select {
|
||||||
|
case <-waitCh:
|
||||||
|
// Webhook was called
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("Timed out waiting for webhook to be called")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
require.NotNil(t, receivedPayload, "Expected webhook notification to be sent")
|
||||||
|
|
||||||
|
// Check that the X-Hub-Signature-256 header exists
|
||||||
|
signature := receivedHeaders.Get("X-Hub-Signature-256")
|
||||||
|
require.NotEmpty(t, signature, "Expected X-Hub-Signature-256 header to be set")
|
||||||
|
|
||||||
|
// Verify that the signature matches the expected HMAC-SHA256
|
||||||
|
h := hmac.New(sha256.New, []byte(secret))
|
||||||
|
h.Write(receivedPayload)
|
||||||
|
expectedSignature := hex.EncodeToString(h.Sum(nil))
|
||||||
|
|
||||||
|
// Print both signatures for debugging if they don't match
|
||||||
|
if expectedSignature != signature {
|
||||||
|
t.Logf("Expected signature: %s", expectedSignature)
|
||||||
|
t.Logf("Actual signature: %s", signature)
|
||||||
|
t.Logf("Secret used: %s", secret)
|
||||||
|
t.Logf("Payload length: %d", len(receivedPayload))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, expectedSignature, signature, "Signature does not match expected value")
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
err = database.DB.Unscoped().Delete(history).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = database.DB.Unscoped().Delete(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookCustomHeaders tests the custom headers functionality for webhooks
|
||||||
|
func TestWebhookCustomHeaders(t *testing.T) {
|
||||||
|
// Set up a temporary data directory for logs
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Set DATA_DIR environment variable for the test
|
||||||
|
originalDataDir := os.Getenv("DATA_DIR")
|
||||||
|
t.Setenv("DATA_DIR", tempDir)
|
||||||
|
defer os.Setenv("DATA_DIR", originalDataDir)
|
||||||
|
|
||||||
|
// Create a test database
|
||||||
|
database := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
user := &db.User{
|
||||||
|
Email: "webhook-headers-test@example.com",
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
err := database.CreateUser(user)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test transfer config
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Webhook Headers Test Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest",
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(config).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a mock HTTP server to receive webhook notifications
|
||||||
|
var (
|
||||||
|
receivedPayload []byte
|
||||||
|
receivedHeaders http.Header
|
||||||
|
waitCh = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedHeaders = r.Header.Clone()
|
||||||
|
var err error
|
||||||
|
receivedPayload, err = io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Error reading request body: %v", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
close(waitCh)
|
||||||
|
}))
|
||||||
|
defer mockServer.Close()
|
||||||
|
|
||||||
|
// Create a test scheduler
|
||||||
|
scheduler := New(database)
|
||||||
|
defer scheduler.Stop()
|
||||||
|
|
||||||
|
// Define custom headers
|
||||||
|
customHeaders := map[string]string{
|
||||||
|
"X-API-Key": "test-api-key",
|
||||||
|
"X-Client-ID": "test-client-id",
|
||||||
|
"X-Source": "gomft-test",
|
||||||
|
}
|
||||||
|
|
||||||
|
customHeadersJSON, err := json.Marshal(customHeaders)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set up job with custom headers
|
||||||
|
job := &db.Job{
|
||||||
|
Name: "Custom Headers Test Job",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
WebhookEnabled: true,
|
||||||
|
WebhookURL: mockServer.URL,
|
||||||
|
WebhookHeaders: string(customHeadersJSON),
|
||||||
|
NotifyOnSuccess: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create job history
|
||||||
|
history := &db.JobHistory{
|
||||||
|
JobID: job.ID,
|
||||||
|
Status: "completed",
|
||||||
|
StartTime: time.Now().Add(-5 * time.Minute),
|
||||||
|
EndTime: timePtr(time.Now()),
|
||||||
|
BytesTransferred: 1024,
|
||||||
|
FilesTransferred: 2,
|
||||||
|
}
|
||||||
|
err = database.DB.Create(history).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Send webhook notification
|
||||||
|
scheduler.sendWebhookNotification(job, history, config)
|
||||||
|
|
||||||
|
// Wait for webhook to be called
|
||||||
|
select {
|
||||||
|
case <-waitCh:
|
||||||
|
// Webhook was called
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("Timed out waiting for webhook to be called")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the headers
|
||||||
|
require.NotNil(t, receivedPayload, "Expected webhook notification to be sent")
|
||||||
|
|
||||||
|
// Check that all custom headers are present
|
||||||
|
for key, value := range customHeaders {
|
||||||
|
actualValue := receivedHeaders.Get(key)
|
||||||
|
if actualValue != value {
|
||||||
|
t.Logf("Custom header mismatch for %s: expected=%s, got=%s", key, value, actualValue)
|
||||||
|
}
|
||||||
|
assert.Equal(t, value, actualValue, "Expected custom header %s to be set", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check standard headers
|
||||||
|
assert.Equal(t, "application/json", receivedHeaders.Get("Content-Type"))
|
||||||
|
assert.Equal(t, "GoMFT-Webhook/1.0", receivedHeaders.Get("User-Agent"))
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
err = database.DB.Unscoped().Delete(history).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = database.DB.Unscoped().Delete(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create a pointer to a time.Time value
|
||||||
|
func timePtr(t time.Time) *time.Time {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Package testutils provides utilities for testing the application
|
||||||
|
package testutils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/auth"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/config"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/email"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/scheduler"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupTestDB creates an in-memory SQLite database for testing
|
||||||
|
func SetupTestDB(t *testing.T) *db.DB {
|
||||||
|
gormDB, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to open in-memory database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop all tables to ensure a clean database
|
||||||
|
err = gormDB.Migrator().DropTable(
|
||||||
|
&db.User{},
|
||||||
|
&db.PasswordHistory{},
|
||||||
|
&db.PasswordResetToken{},
|
||||||
|
&db.TransferConfig{},
|
||||||
|
&db.Job{},
|
||||||
|
&db.JobHistory{},
|
||||||
|
&db.FileMetadata{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Warning: Failed to drop tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the database schema
|
||||||
|
err = gormDB.AutoMigrate(
|
||||||
|
&db.User{},
|
||||||
|
&db.PasswordHistory{},
|
||||||
|
&db.PasswordResetToken{},
|
||||||
|
&db.TransferConfig{},
|
||||||
|
&db.Job{},
|
||||||
|
&db.JobHistory{},
|
||||||
|
&db.FileMetadata{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to migrate database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &db.DB{DB: gormDB}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTestUser creates a test user in the database
|
||||||
|
func CreateTestUser(t *testing.T, database *db.DB, email string, isAdmin bool) *db.User {
|
||||||
|
// Generate hashed password using bcrypt directly
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("testpassword"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to hash password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &db.User{
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.CreateUser(user); err != nil {
|
||||||
|
t.Fatalf("Failed to create test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupTestConfig creates a test configuration
|
||||||
|
func SetupTestConfig(t *testing.T) *config.Config {
|
||||||
|
tempDir, err := os.MkdirTemp("", "gomft-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
return &config.Config{
|
||||||
|
ServerAddress: ":9090",
|
||||||
|
DataDir: filepath.Join(tempDir, "data"),
|
||||||
|
BackupDir: filepath.Join(tempDir, "backups"),
|
||||||
|
JWTSecret: "test-jwt-secret",
|
||||||
|
BaseURL: "http://test.example.com",
|
||||||
|
Email: config.EmailConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Host: "smtp.test.com",
|
||||||
|
Port: 587,
|
||||||
|
Username: "test@example.com",
|
||||||
|
Password: "test-password",
|
||||||
|
FromEmail: "test@example.com",
|
||||||
|
FromName: "Test",
|
||||||
|
EnableTLS: true,
|
||||||
|
RequireAuth: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupTestScheduler creates a mock scheduler for testing
|
||||||
|
func SetupTestScheduler(t *testing.T) *scheduler.Scheduler {
|
||||||
|
// In a real test, we would create a proper mock scheduler
|
||||||
|
// For now, we return an empty scheduler
|
||||||
|
return &scheduler.Scheduler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupTestEmailService creates a mock email service for testing
|
||||||
|
func SetupTestEmailService(t *testing.T) *email.Service {
|
||||||
|
// In a real test, we would create a proper mock email service
|
||||||
|
// For now, we return an empty email service
|
||||||
|
return &email.Service{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateTestToken generates a JWT token for testing
|
||||||
|
func GenerateTestToken(userID uint, isAdmin bool, jwtSecret string) (string, error) {
|
||||||
|
// In a real application, we would include email, but for testing purposes we can create a fake email
|
||||||
|
email := "test@example.com"
|
||||||
|
if isAdmin {
|
||||||
|
email = "admin@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token with 1 hour expiry
|
||||||
|
expirationTime := 1 * time.Hour
|
||||||
|
return auth.GenerateToken(userID, email, jwtSecret, expirationTime)
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ func NewHandler(database *db.DB, scheduler *scheduler.Scheduler, jwtSecret strin
|
|||||||
emailService := email.NewService(cfg)
|
emailService := email.NewService(cfg)
|
||||||
|
|
||||||
// Create handlers instance
|
// Create handlers instance
|
||||||
handlersInstance := handlers.NewHandlers(database, scheduler, jwtSecret, dbPath, backupDir, emailService)
|
handlersInstance := handlers.NewHandlers(database, scheduler, jwtSecret, dbPath, backupDir, "./logs", emailService)
|
||||||
|
|
||||||
return &Handler{
|
return &Handler{
|
||||||
handlers: handlersInstance,
|
handlers: handlersInstance,
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleBackupDB handles the POST /admin/backup route
|
|
||||||
func (h *Handlers) HandleBackupDB(c *gin.Context) {
|
|
||||||
// TODO: Implement database backup
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Database backup initiated"})
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -23,6 +24,7 @@ func (h *Handlers) HandleAdminTools(c *gin.Context) {
|
|||||||
SystemUptime: h.getSystemUptime(),
|
SystemUptime: h.getSystemUptime(),
|
||||||
DatabasePath: h.DBPath,
|
DatabasePath: h.DBPath,
|
||||||
BackupPath: h.BackupDir,
|
BackupPath: h.BackupDir,
|
||||||
|
LogFiles: h.getLogFiles(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get database size
|
// Get database size
|
||||||
@@ -308,6 +310,528 @@ func (h *Handlers) HandleRefreshBackups(c *gin.Context) {
|
|||||||
components.BackupsList(data).Render(c, c.Writer)
|
components.BackupsList(data).Render(c, c.Writer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleRefreshLogs refreshes the log files list
|
||||||
|
func (h *Handlers) HandleRefreshLogs(c *gin.Context) {
|
||||||
|
// Get system statistics
|
||||||
|
data := components.AdminToolsData{
|
||||||
|
LogFiles: h.getLogFiles(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render only the log viewer component
|
||||||
|
components.AdminLogViewer(data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleImportConfigs handles importing transfer configurations from JSON
|
||||||
|
func (h *Handlers) HandleImportConfigs(c *gin.Context) {
|
||||||
|
// Check admin access
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj, ok := user.(*db.User)
|
||||||
|
if !ok || !userObj.IsAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the request body
|
||||||
|
var configs []db.TransferConfig
|
||||||
|
if err := c.ShouldBindJSON(&configs); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid JSON: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import each config
|
||||||
|
imported := 0
|
||||||
|
for i := range configs {
|
||||||
|
// Set created by to current user
|
||||||
|
configs[i].CreatedBy = userObj.ID
|
||||||
|
|
||||||
|
// Create in database
|
||||||
|
if err := h.DB.Create(&configs[i]).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to import config: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d configs imported successfully", imported)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleImportJobs handles importing jobs from JSON
|
||||||
|
func (h *Handlers) HandleImportJobs(c *gin.Context) {
|
||||||
|
// Check admin access
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj, ok := user.(*db.User)
|
||||||
|
if !ok || !userObj.IsAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the request body
|
||||||
|
var jobs []db.Job
|
||||||
|
|
||||||
|
// Read the raw JSON first
|
||||||
|
var rawJobs []map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&rawJobs); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid JSON: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the raw jobs to db.Job objects
|
||||||
|
for _, rawJob := range rawJobs {
|
||||||
|
job := db.Job{
|
||||||
|
CreatedBy: userObj.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the fields from the raw job
|
||||||
|
if name, ok := rawJob["name"].(string); ok {
|
||||||
|
job.Name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
if schedule, ok := rawJob["schedule"].(string); ok {
|
||||||
|
job.Schedule = schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled, ok := rawJob["enabled"].(bool); ok {
|
||||||
|
job.Enabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle config_id
|
||||||
|
if configID, ok := rawJob["config_id"].(float64); ok {
|
||||||
|
job.ConfigID = uint(configID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle config_ids
|
||||||
|
if configIDs, ok := rawJob["config_ids"].(string); ok {
|
||||||
|
job.ConfigIDs = configIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate config ID exists
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Config ID %d not found", job.ConfigID)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create in database
|
||||||
|
if err := h.DB.Create(&job).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to import job: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs = append(jobs, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d jobs imported successfully", len(jobs))})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleListBackups returns a list of all database backups
|
||||||
|
func (h *Handlers) HandleListBackups(c *gin.Context) {
|
||||||
|
// Check admin access
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj, ok := user.(*db.User)
|
||||||
|
if !ok || !userObj.IsAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup files
|
||||||
|
backups := h.getBackupFiles()
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"backups": backups,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSystemInfo returns system information for the admin dashboard
|
||||||
|
func (h *Handlers) HandleSystemInfo(c *gin.Context) {
|
||||||
|
// Check admin access
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj, ok := user.(*db.User)
|
||||||
|
if !ok || !userObj.IsAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get basic system info
|
||||||
|
info := map[string]interface{}{
|
||||||
|
"os": h.getOSInfo(),
|
||||||
|
"memory": h.getMemoryInfo(),
|
||||||
|
"cpu": h.getCPUInfo(),
|
||||||
|
"disk": h.getDiskInfo(),
|
||||||
|
"go_version": h.getGoVersion(),
|
||||||
|
"uptime": h.getSystemUptime(),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleImportJobsFromFile handles importing jobs from an uploaded JSON file
|
||||||
|
func (h *Handlers) HandleImportJobsFromFile(c *gin.Context) {
|
||||||
|
// Check admin access
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj, ok := user.(*db.User)
|
||||||
|
if !ok || !userObj.IsAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the uploaded file
|
||||||
|
file, err := c.FormFile("jobs_file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No jobs file provided"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the uploaded file
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to open uploaded file: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
// Read file contents
|
||||||
|
fileContent, err := io.ReadAll(src)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to read file: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse jobs from JSON
|
||||||
|
var jobs []db.Job
|
||||||
|
|
||||||
|
// Read the raw JSON first
|
||||||
|
var rawJobs []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(fileContent, &rawJobs); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid JSON: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the raw jobs to db.Job objects
|
||||||
|
for _, rawJob := range rawJobs {
|
||||||
|
job := db.Job{
|
||||||
|
CreatedBy: userObj.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the fields from the raw job
|
||||||
|
if name, ok := rawJob["name"].(string); ok {
|
||||||
|
job.Name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
if schedule, ok := rawJob["schedule"].(string); ok {
|
||||||
|
job.Schedule = schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled, ok := rawJob["enabled"].(bool); ok {
|
||||||
|
job.Enabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle config_id
|
||||||
|
if configID, ok := rawJob["config_id"].(float64); ok {
|
||||||
|
job.ConfigID = uint(configID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle config_ids
|
||||||
|
if configIDs, ok := rawJob["config_ids"].(string); ok {
|
||||||
|
job.ConfigIDs = configIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate config ID exists
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Config ID %d not found", job.ConfigID)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create in database
|
||||||
|
if err := h.DB.Create(&job).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to import job: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs = append(jobs, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d jobs imported successfully", len(jobs))})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteLogFile handles the deletion of a log file
|
||||||
|
func (h *Handlers) HandleDeleteLogFile(c *gin.Context) {
|
||||||
|
// Check admin access
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj, ok := user.(*db.User)
|
||||||
|
if !ok || !userObj.IsAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filename from params
|
||||||
|
filename := c.Param("filename")
|
||||||
|
if filename == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No filename provided"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate filename (basic security check)
|
||||||
|
if strings.Contains(filename, "..") || strings.Contains(filename, "/") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct full file path
|
||||||
|
logFilePath := filepath.Join(h.LogsDir, filename)
|
||||||
|
|
||||||
|
// Ensure the file is within the logs directory
|
||||||
|
if !strings.HasPrefix(logFilePath, h.LogsDir) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid log file path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if _, err := os.Stat(logFilePath); os.IsNotExist(err) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the file
|
||||||
|
if err := os.Remove(logFilePath); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete log file: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Log file deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSystemMaintenanceCheck handles the system maintenance check request
|
||||||
|
func (h *Handlers) HandleSystemMaintenanceCheck(c *gin.Context) {
|
||||||
|
// Check admin access
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj, ok := user.(*db.User)
|
||||||
|
if !ok || !userObj.IsAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform maintenance checks
|
||||||
|
checks := map[string]interface{}{
|
||||||
|
"database_size": h.checkDatabaseSize(),
|
||||||
|
"disk_space": h.checkDiskSpace(),
|
||||||
|
"job_history": h.checkJobHistorySize(),
|
||||||
|
"inactive_configs": h.checkInactiveConfigs(),
|
||||||
|
"failed_jobs": h.checkFailedJobs(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine overall status based on checks
|
||||||
|
status := "healthy"
|
||||||
|
for _, result := range checks {
|
||||||
|
if resultMap, ok := result.(map[string]interface{}); ok {
|
||||||
|
if resultMap["status"] == "warning" || resultMap["status"] == "critical" {
|
||||||
|
status = "needs_attention"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": status,
|
||||||
|
"checks": checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateSystemSettings handles updating system settings
|
||||||
|
func (h *Handlers) HandleUpdateSystemSettings(c *gin.Context) {
|
||||||
|
// Check admin access
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj, ok := user.(*db.User)
|
||||||
|
if !ok || !userObj.IsAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse settings from request body
|
||||||
|
var settings struct {
|
||||||
|
EmailNotifications bool `json:"email_notifications"`
|
||||||
|
LogRetentionDays int `json:"log_retention_days"`
|
||||||
|
MaxConcurrentTransfers int `json:"max_concurrent_transfers"`
|
||||||
|
DefaultRetryAttempts int `json:"default_retry_attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&settings); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid settings data: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate settings
|
||||||
|
if settings.LogRetentionDays < 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Log retention days must be at least 1"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.MaxConcurrentTransfers < 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Max concurrent transfers must be at least 1"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.DefaultRetryAttempts < 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Default retry attempts cannot be negative"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings in database
|
||||||
|
// Here we would typically store these in a settings table
|
||||||
|
// For this example, we'll just return success
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Settings updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenance check helper functions
|
||||||
|
func (h *Handlers) checkDatabaseSize() map[string]interface{} {
|
||||||
|
sizeStr, err := h.getDatabaseSize()
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "unknown",
|
||||||
|
"message": "Unable to determine database size",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse size for comparison
|
||||||
|
var size float64
|
||||||
|
var unit string
|
||||||
|
fmt.Sscanf(sizeStr, "%f %s", &size, &unit)
|
||||||
|
|
||||||
|
status := "healthy"
|
||||||
|
message := fmt.Sprintf("Database size is %s", sizeStr)
|
||||||
|
|
||||||
|
// Check if database is large
|
||||||
|
if unit == "MB" && size > 100 {
|
||||||
|
status = "warning"
|
||||||
|
message = fmt.Sprintf("Database size is %s, consider optimizing", sizeStr)
|
||||||
|
} else if unit == "GB" {
|
||||||
|
status = "critical"
|
||||||
|
message = fmt.Sprintf("Database size is %s, vacuum recommended", sizeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"size": sizeStr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) checkDiskSpace() map[string]interface{} {
|
||||||
|
// For demo purposes, return a simulated result
|
||||||
|
// In a real implementation, would check actual free disk space
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "healthy",
|
||||||
|
"message": "Sufficient disk space available",
|
||||||
|
"free_space": "10.2 GB",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) checkJobHistorySize() map[string]interface{} {
|
||||||
|
var count int64
|
||||||
|
h.DB.Model(&db.JobHistory{}).Count(&count)
|
||||||
|
|
||||||
|
status := "healthy"
|
||||||
|
message := fmt.Sprintf("%d job history records", count)
|
||||||
|
|
||||||
|
if count > 10000 {
|
||||||
|
status = "warning"
|
||||||
|
message = fmt.Sprintf("%d job history records, consider clearing old records", count)
|
||||||
|
} else if count > 50000 {
|
||||||
|
status = "critical"
|
||||||
|
message = fmt.Sprintf("%d job history records, performance may be impacted", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"count": count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) checkInactiveConfigs() map[string]interface{} {
|
||||||
|
var count int64
|
||||||
|
h.DB.Model(&db.TransferConfig{}).Where("id NOT IN (SELECT DISTINCT config_id FROM jobs)").Count(&count)
|
||||||
|
|
||||||
|
status := "healthy"
|
||||||
|
message := fmt.Sprintf("%d unused configurations", count)
|
||||||
|
|
||||||
|
if count > 5 {
|
||||||
|
status = "warning"
|
||||||
|
message = fmt.Sprintf("%d unused configurations found", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"count": count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) checkFailedJobs() map[string]interface{} {
|
||||||
|
var count int64
|
||||||
|
oneDayAgo := time.Now().Add(-24 * time.Hour)
|
||||||
|
h.DB.Model(&db.JobHistory{}).Where("status = ? AND created_at > ?", "failed", oneDayAgo).Count(&count)
|
||||||
|
|
||||||
|
status := "healthy"
|
||||||
|
message := fmt.Sprintf("%d failed jobs in the last 24 hours", count)
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
status = "warning"
|
||||||
|
message = fmt.Sprintf("%d failed jobs in the last 24 hours", count)
|
||||||
|
}
|
||||||
|
if count > 10 {
|
||||||
|
status = "critical"
|
||||||
|
message = fmt.Sprintf("%d failed jobs in the last 24 hours", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"count": count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
// getSystemUptime returns the system uptime as a formatted string
|
// getSystemUptime returns the system uptime as a formatted string
|
||||||
@@ -578,3 +1102,260 @@ func (h *Handlers) HandleDownloadBackup(c *gin.Context) {
|
|||||||
// Serve the file
|
// Serve the file
|
||||||
c.File(filePath)
|
c.File(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatSize converts bytes to human-readable sizes
|
||||||
|
func formatSize(bytes float64) string {
|
||||||
|
const (
|
||||||
|
KB = 1024
|
||||||
|
MB = KB * 1024
|
||||||
|
GB = MB * 1024
|
||||||
|
TB = GB * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case bytes >= TB:
|
||||||
|
return fmt.Sprintf("%.2f TB", bytes/TB)
|
||||||
|
case bytes >= GB:
|
||||||
|
return fmt.Sprintf("%.2f GB", bytes/GB)
|
||||||
|
case bytes >= MB:
|
||||||
|
return fmt.Sprintf("%.2f MB", bytes/MB)
|
||||||
|
case bytes >= KB:
|
||||||
|
return fmt.Sprintf("%.2f KB", bytes/KB)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%.0f B", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get log files
|
||||||
|
func (h *Handlers) getLogFiles() []components.LogFile {
|
||||||
|
// Determine logs directory
|
||||||
|
logsDir := os.Getenv("LOGS_DIR")
|
||||||
|
if logsDir == "" {
|
||||||
|
dataDir := os.Getenv("DATA_DIR")
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = "./data"
|
||||||
|
}
|
||||||
|
logsDir = filepath.Join(dataDir, "logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read directory
|
||||||
|
files, err := ioutil.ReadDir(logsDir)
|
||||||
|
if err != nil {
|
||||||
|
return []components.LogFile{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process files
|
||||||
|
var logFiles []components.LogFile
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include .log files
|
||||||
|
if !strings.HasSuffix(strings.ToLower(file.Name()), ".log") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
size := formatSize(float64(file.Size()))
|
||||||
|
logFiles = append(logFiles, components.LogFile{
|
||||||
|
Name: file.Name(),
|
||||||
|
Size: size,
|
||||||
|
ModTime: file.ModTime(),
|
||||||
|
Path: filepath.Join(logsDir, file.Name()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by modification time (newest first)
|
||||||
|
sort.Slice(logFiles, func(i, j int) bool {
|
||||||
|
return logFiles[i].ModTime.After(logFiles[j].ModTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
return logFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleViewLog displays the contents of a log file
|
||||||
|
func (h *Handlers) HandleViewLog(c *gin.Context) {
|
||||||
|
fileName := c.Param("fileName")
|
||||||
|
if fileName == "" {
|
||||||
|
c.String(http.StatusBadRequest, "No file name provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize the filename to prevent directory traversal
|
||||||
|
fileName = filepath.Base(fileName)
|
||||||
|
|
||||||
|
// Determine logs directory
|
||||||
|
logsDir := os.Getenv("LOGS_DIR")
|
||||||
|
if logsDir == "" {
|
||||||
|
dataDir := os.Getenv("DATA_DIR")
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = "./data"
|
||||||
|
}
|
||||||
|
logsDir = filepath.Join(dataDir, "logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(logsDir, fileName)
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
c.String(http.StatusNotFound, "Log file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file contents
|
||||||
|
content, err := ioutil.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Error reading log file: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure content is large enough to trigger scrollbar (add padding)
|
||||||
|
logContent := string(content)
|
||||||
|
|
||||||
|
// Add padding at the end to ensure scrollbar is visible even for small logs
|
||||||
|
if len(logContent) < 2000 {
|
||||||
|
paddingNeeded := 100 - strings.Count(logContent, "\n")
|
||||||
|
if paddingNeeded > 0 {
|
||||||
|
for i := 0; i < paddingNeeded; i++ {
|
||||||
|
logContent += "\n "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := components.AdminToolsData{
|
||||||
|
CurrentLogFile: fileName,
|
||||||
|
LogContent: logContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the template using the templ package
|
||||||
|
components.AdminLogContent(data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDownloadLog allows downloading a log file
|
||||||
|
func (h *Handlers) HandleDownloadLog(c *gin.Context) {
|
||||||
|
fileName := c.Param("fileName")
|
||||||
|
if fileName == "" {
|
||||||
|
c.String(http.StatusBadRequest, "No file name provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize the filename to prevent directory traversal
|
||||||
|
fileName = filepath.Base(fileName)
|
||||||
|
|
||||||
|
// Determine logs directory
|
||||||
|
logsDir := os.Getenv("LOGS_DIR")
|
||||||
|
if logsDir == "" {
|
||||||
|
dataDir := os.Getenv("DATA_DIR")
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = "./data"
|
||||||
|
}
|
||||||
|
logsDir = filepath.Join(dataDir, "logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(logsDir, fileName)
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
c.String(http.StatusNotFound, "Log file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers for file download
|
||||||
|
c.Header("Content-Description", "File Transfer")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||||||
|
c.Header("Content-Type", "text/plain")
|
||||||
|
c.File(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for system info
|
||||||
|
func (h *Handlers) getOSInfo() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"name": "Linux", // For testing; in a real implementation, you would detect the actual OS
|
||||||
|
"version": "1.0",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) getMemoryInfo() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"total": "8 GB",
|
||||||
|
"used": "4 GB",
|
||||||
|
"available": "4 GB",
|
||||||
|
"percent": 50.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) getCPUInfo() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"model": "Intel(R) Core(TM) i7",
|
||||||
|
"cores": 4,
|
||||||
|
"usage": 25.0,
|
||||||
|
"mhz": 3200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) getDiskInfo() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"total": "500 GB",
|
||||||
|
"used": "250 GB",
|
||||||
|
"available": "250 GB",
|
||||||
|
"percent": 50.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) getGoVersion() string {
|
||||||
|
return "go1.17.5"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleImportConfigsFromFile handles importing transfer configurations from an uploaded JSON file
|
||||||
|
func (h *Handlers) HandleImportConfigsFromFile(c *gin.Context) {
|
||||||
|
// Check admin access
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj, ok := user.(*db.User)
|
||||||
|
if !ok || !userObj.IsAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the file from the form data
|
||||||
|
file, _, err := c.Request.FormFile("configs_file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Failed to get file: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Read the file contents
|
||||||
|
fileBytes, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to read file: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON
|
||||||
|
var configs []db.TransferConfig
|
||||||
|
if err := json.Unmarshal(fileBytes, &configs); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid JSON: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import each config
|
||||||
|
imported := 0
|
||||||
|
for i := range configs {
|
||||||
|
// Set created by to current user
|
||||||
|
configs[i].CreatedBy = userObj.ID
|
||||||
|
|
||||||
|
// Create in database
|
||||||
|
if err := h.DB.Create(&configs[i]).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to import config: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d configs imported successfully", imported)})
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -184,38 +183,6 @@ func (h *Handlers) HandleAPIDeleteConfig(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleAPITestConnection handles the POST /api/configs/test route
|
|
||||||
func (h *Handlers) HandleAPITestConnection(c *gin.Context) {
|
|
||||||
var config db.TransferConfig
|
|
||||||
if err := c.ShouldBindJSON(&config); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request data: %v", err)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Implement connection testing based on protocol
|
|
||||||
// This is a placeholder for the actual connection testing logic
|
|
||||||
success := true
|
|
||||||
message := "Connection successful"
|
|
||||||
|
|
||||||
// Example of how connection testing might work
|
|
||||||
switch config.SourceType {
|
|
||||||
case "sftp":
|
|
||||||
// Test SFTP connection
|
|
||||||
// success, message = testSFTPConnection(config)
|
|
||||||
case "ftp":
|
|
||||||
// Test FTP connection
|
|
||||||
// success, message = testFTPConnection(config)
|
|
||||||
default:
|
|
||||||
success = false
|
|
||||||
message = "Unsupported source type"
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": success,
|
|
||||||
"message": message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleAPIJobs handles the API jobs request
|
// HandleAPIJobs handles the API jobs request
|
||||||
func (h *Handlers) HandleAPIJobs(c *gin.Context) {
|
func (h *Handlers) HandleAPIJobs(c *gin.Context) {
|
||||||
// Implementation will be moved from the old handlers.go
|
// Implementation will be moved from the old handlers.go
|
||||||
@@ -248,8 +215,48 @@ func (h *Handlers) HandleAPIDeleteJob(c *gin.Context) {
|
|||||||
|
|
||||||
// HandleAPIRunJob handles the API run job request
|
// HandleAPIRunJob handles the API run job request
|
||||||
func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
|
func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
|
||||||
// Implementation will be moved from the old handlers.go
|
id := c.Param("id")
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "API run job handler stub"})
|
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
|
// HandleAPIHistory handles the API history request
|
||||||
|
|||||||
@@ -0,0 +1,662 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/scheduler"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupAPITest(t *testing.T) (*Handlers, *gin.Engine, *db.DB, *db.User) {
|
||||||
|
// Set up test database
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
||||||
|
user := &db.User{
|
||||||
|
Email: "test@example.com",
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
database.Create(user)
|
||||||
|
|
||||||
|
// Create mock scheduler
|
||||||
|
mockScheduler := scheduler.NewMockScheduler()
|
||||||
|
|
||||||
|
// Set up Gin router
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
|
||||||
|
// Create handlers
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
JWTSecret: "test-jwt-secret",
|
||||||
|
Scheduler: mockScheduler,
|
||||||
|
}
|
||||||
|
|
||||||
|
return handlers, router, database, user
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAuthenticatedAPITest(t *testing.T, isAdmin bool) (*Handlers, *gin.Engine, *db.DB, *db.User) {
|
||||||
|
handlers, router, database, user := setupAPITest(t)
|
||||||
|
|
||||||
|
// Update user admin status if needed
|
||||||
|
if isAdmin != user.IsAdmin {
|
||||||
|
user.IsAdmin = isAdmin
|
||||||
|
database.Save(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up authentication middleware
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", user.ID)
|
||||||
|
c.Set("email", user.Email)
|
||||||
|
c.Set("username", "testuser")
|
||||||
|
c.Set("isAdmin", user.IsAdmin)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
return handlers, router, database, user
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAPILogin(t *testing.T) {
|
||||||
|
handlers, router, _, user := setupAPITest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.POST("/api/login", handlers.HandleAPILogin)
|
||||||
|
|
||||||
|
// Test case 1: Successful login
|
||||||
|
loginData := map[string]string{
|
||||||
|
"email": user.Email,
|
||||||
|
"password": "password123",
|
||||||
|
}
|
||||||
|
jsonData, _ := json.Marshal(loginData)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", "/api/login", bytes.NewBuffer(jsonData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify token exists
|
||||||
|
token, exists := response["token"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.NotEmpty(t, token)
|
||||||
|
|
||||||
|
// Verify user data
|
||||||
|
userData, exists := response["user"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
userMap := userData.(map[string]interface{})
|
||||||
|
assert.Equal(t, float64(user.ID), userMap["id"])
|
||||||
|
assert.Equal(t, user.Email, userMap["email"])
|
||||||
|
assert.Equal(t, user.IsAdmin, userMap["is_admin"])
|
||||||
|
|
||||||
|
// Test case 2: Invalid credentials
|
||||||
|
loginData = map[string]string{
|
||||||
|
"email": user.Email,
|
||||||
|
"password": "wrongpassword",
|
||||||
|
}
|
||||||
|
jsonData, _ = json.Marshal(loginData)
|
||||||
|
|
||||||
|
req, _ = http.NewRequest("POST", "/api/login", bytes.NewBuffer(jsonData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.Code)
|
||||||
|
|
||||||
|
// Test case 3: Invalid request format
|
||||||
|
invalidJSON := []byte(`{"email": "test@example.com", "password":}`)
|
||||||
|
|
||||||
|
req, _ = http.NewRequest("POST", "/api/login", bytes.NewBuffer(invalidJSON))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAPIConfigs(t *testing.T) {
|
||||||
|
handlers, router, database, user := setupAuthenticatedAPITest(t, false)
|
||||||
|
|
||||||
|
// Create test configs
|
||||||
|
config1 := &db.TransferConfig{
|
||||||
|
Name: "Test Config 1",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source1",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest1",
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
database.Create(config1)
|
||||||
|
|
||||||
|
config2 := &db.TransferConfig{
|
||||||
|
Name: "Test Config 2",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source2",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest2",
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
database.Create(config2)
|
||||||
|
|
||||||
|
// Create config for another user
|
||||||
|
otherUser := &db.User{
|
||||||
|
Email: "other@example.com",
|
||||||
|
PasswordHash: "hashedpassword",
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
database.Create(otherUser)
|
||||||
|
|
||||||
|
otherConfig := &db.TransferConfig{
|
||||||
|
Name: "Other User Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source3",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest3",
|
||||||
|
CreatedBy: otherUser.ID,
|
||||||
|
}
|
||||||
|
database.Create(otherConfig)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/api/configs", handlers.HandleAPIConfigs)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/api/configs", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify configs
|
||||||
|
configs, exists := response["configs"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
|
||||||
|
configsArray := configs.([]interface{})
|
||||||
|
assert.Equal(t, 2, len(configsArray))
|
||||||
|
|
||||||
|
// Verify only user's configs are returned
|
||||||
|
foundConfig1 := false
|
||||||
|
foundConfig2 := false
|
||||||
|
foundOtherConfig := false
|
||||||
|
|
||||||
|
for _, c := range configsArray {
|
||||||
|
configMap := c.(map[string]interface{})
|
||||||
|
if configMap["name"] == config1.Name {
|
||||||
|
foundConfig1 = true
|
||||||
|
}
|
||||||
|
if configMap["name"] == config2.Name {
|
||||||
|
foundConfig2 = true
|
||||||
|
}
|
||||||
|
if configMap["name"] == otherConfig.Name {
|
||||||
|
foundOtherConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, foundConfig1)
|
||||||
|
assert.True(t, foundConfig2)
|
||||||
|
assert.False(t, foundOtherConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAPIConfig(t *testing.T) {
|
||||||
|
handlers, router, database, user := setupAuthenticatedAPITest(t, false)
|
||||||
|
|
||||||
|
// Create test config
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Test Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest",
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
database.Create(config)
|
||||||
|
|
||||||
|
// Create config for another user
|
||||||
|
otherUser := &db.User{
|
||||||
|
Email: "other@example.com",
|
||||||
|
PasswordHash: "hashedpassword",
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
database.Create(otherUser)
|
||||||
|
|
||||||
|
otherConfig := &db.TransferConfig{
|
||||||
|
Name: "Other User Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source2",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest2",
|
||||||
|
CreatedBy: otherUser.ID,
|
||||||
|
}
|
||||||
|
database.Create(otherConfig)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/api/configs/:id", handlers.HandleAPIConfig)
|
||||||
|
|
||||||
|
// Test case 1: Get own config
|
||||||
|
req, _ := http.NewRequest("GET", "/api/configs/"+strconv.Itoa(int(config.ID)), nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify config
|
||||||
|
configData, exists := response["config"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
configMap := configData.(map[string]interface{})
|
||||||
|
assert.Equal(t, config.Name, configMap["name"])
|
||||||
|
|
||||||
|
// Test case 2: Try to get another user's config
|
||||||
|
req, _ = http.NewRequest("GET", "/api/configs/"+strconv.Itoa(int(otherConfig.ID)), nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response - should be forbidden
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.Code)
|
||||||
|
|
||||||
|
// Test case 3: Admin can access any config
|
||||||
|
// Create admin router
|
||||||
|
adminHandlers, adminRouter, _, _ := setupAuthenticatedAPITest(t, true)
|
||||||
|
adminRouter.GET("/api/configs/:id", adminHandlers.HandleAPIConfig)
|
||||||
|
|
||||||
|
req, _ = http.NewRequest("GET", "/api/configs/"+strconv.Itoa(int(otherConfig.ID)), nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
adminRouter.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response - admin should be able to access
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Test case 4: Non-existent config
|
||||||
|
req, _ = http.NewRequest("GET", "/api/configs/9999", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAPICreateConfig(t *testing.T) {
|
||||||
|
handlers, router, _, user := setupAuthenticatedAPITest(t, false)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.POST("/api/configs", handlers.HandleAPICreateConfig)
|
||||||
|
|
||||||
|
// Create config data
|
||||||
|
configData := map[string]interface{}{
|
||||||
|
"name": "New API Config",
|
||||||
|
"source_type": "local",
|
||||||
|
"source_path": "/api/source",
|
||||||
|
"destination_type": "local",
|
||||||
|
"destination_path": "/api/dest",
|
||||||
|
}
|
||||||
|
jsonData, _ := json.Marshal(configData)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("POST", "/api/configs", bytes.NewBuffer(jsonData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusCreated, resp.Code)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify config was created
|
||||||
|
configResponse, exists := response["config"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
configMap, ok := configResponse.(map[string]interface{})
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "New API Config", configMap["name"])
|
||||||
|
assert.Equal(t, float64(user.ID), configMap["created_by"])
|
||||||
|
|
||||||
|
// Test case 2: Invalid request data
|
||||||
|
invalidJSON := []byte(`{"name": "Invalid Config", "source_type":}`)
|
||||||
|
|
||||||
|
req, _ = http.NewRequest("POST", "/api/configs", bytes.NewBuffer(invalidJSON))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAPIUpdateConfig(t *testing.T) {
|
||||||
|
handlers, router, database, user := setupAuthenticatedAPITest(t, false)
|
||||||
|
|
||||||
|
// Create test config
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Test Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest",
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
database.Create(config)
|
||||||
|
|
||||||
|
// Create config for another user
|
||||||
|
otherUser := &db.User{
|
||||||
|
Email: "other@example.com",
|
||||||
|
PasswordHash: "hashedpassword",
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
database.Create(otherUser)
|
||||||
|
|
||||||
|
otherConfig := &db.TransferConfig{
|
||||||
|
Name: "Other User Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source2",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest2",
|
||||||
|
CreatedBy: otherUser.ID,
|
||||||
|
}
|
||||||
|
database.Create(otherConfig)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.PUT("/api/configs/:id", handlers.HandleAPIUpdateConfig)
|
||||||
|
|
||||||
|
// Test case 1: Update own config
|
||||||
|
updateData := map[string]interface{}{
|
||||||
|
"name": "Updated Config",
|
||||||
|
"source_type": "local",
|
||||||
|
"source_path": "/updated/source",
|
||||||
|
"destination_type": "local",
|
||||||
|
"destination_path": "/updated/dest",
|
||||||
|
}
|
||||||
|
jsonData, _ := json.Marshal(updateData)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("PUT", "/api/configs/"+strconv.Itoa(int(config.ID)), bytes.NewBuffer(jsonData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify config was updated
|
||||||
|
configData, exists := response["config"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
configMap := configData.(map[string]interface{})
|
||||||
|
assert.Equal(t, "Updated Config", configMap["name"])
|
||||||
|
assert.Equal(t, "/updated/source", configMap["source_path"])
|
||||||
|
|
||||||
|
// Test case 2: Try to update another user's config
|
||||||
|
updateData = map[string]interface{}{
|
||||||
|
"name": "Trying to update other's config",
|
||||||
|
}
|
||||||
|
jsonData, _ = json.Marshal(updateData)
|
||||||
|
|
||||||
|
req, _ = http.NewRequest("PUT", "/api/configs/"+strconv.Itoa(int(otherConfig.ID)), bytes.NewBuffer(jsonData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response - should be forbidden
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.Code)
|
||||||
|
|
||||||
|
// Test case 3: Admin can update any config
|
||||||
|
// Create admin router
|
||||||
|
adminHandlers, adminRouter, _, _ := setupAuthenticatedAPITest(t, true)
|
||||||
|
adminRouter.PUT("/api/configs/:id", adminHandlers.HandleAPIUpdateConfig)
|
||||||
|
|
||||||
|
updateData = map[string]interface{}{
|
||||||
|
"name": "Admin Updated Config",
|
||||||
|
}
|
||||||
|
jsonData, _ = json.Marshal(updateData)
|
||||||
|
|
||||||
|
req, _ = http.NewRequest("PUT", "/api/configs/"+strconv.Itoa(int(otherConfig.ID)), bytes.NewBuffer(jsonData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
adminRouter.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response - admin should be able to update
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Test case 4: Non-existent config
|
||||||
|
req, _ = http.NewRequest("PUT", "/api/configs/9999", bytes.NewBuffer(jsonData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAPIDeleteConfig(t *testing.T) {
|
||||||
|
handlers, router, database, user := setupAuthenticatedAPITest(t, false)
|
||||||
|
|
||||||
|
// Create test config
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Test Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest",
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
database.Create(config)
|
||||||
|
|
||||||
|
// Create config for another user
|
||||||
|
otherUser := &db.User{
|
||||||
|
Email: "other@example.com",
|
||||||
|
PasswordHash: "hashedpassword",
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
database.Create(otherUser)
|
||||||
|
|
||||||
|
otherConfig := &db.TransferConfig{
|
||||||
|
Name: "Other User Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source2",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest2",
|
||||||
|
CreatedBy: otherUser.ID,
|
||||||
|
}
|
||||||
|
database.Create(otherConfig)
|
||||||
|
|
||||||
|
// Create config with associated job
|
||||||
|
configWithJob := &db.TransferConfig{
|
||||||
|
Name: "Config With Job",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source3",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest3",
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
database.Create(configWithJob)
|
||||||
|
|
||||||
|
job := &db.Job{
|
||||||
|
Name: "Test Job",
|
||||||
|
Schedule: "* * * * *",
|
||||||
|
ConfigID: configWithJob.ID,
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
database.Create(job)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.DELETE("/api/configs/:id", handlers.HandleAPIDeleteConfig)
|
||||||
|
|
||||||
|
// Test case 1: Delete own config
|
||||||
|
req, _ := http.NewRequest("DELETE", "/api/configs/"+strconv.Itoa(int(config.ID)), nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Verify config was deleted
|
||||||
|
var deletedConfig db.TransferConfig
|
||||||
|
err := database.First(&deletedConfig, config.ID).Error
|
||||||
|
assert.Error(t, err) // Should not find the config
|
||||||
|
|
||||||
|
// Test case 2: Try to delete another user's config
|
||||||
|
req, _ = http.NewRequest("DELETE", "/api/configs/"+strconv.Itoa(int(otherConfig.ID)), nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response - should be forbidden
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.Code)
|
||||||
|
|
||||||
|
// Test case 3: Try to delete config with associated job
|
||||||
|
req, _ = http.NewRequest("DELETE", "/api/configs/"+strconv.Itoa(int(configWithJob.ID)), nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response - should be bad request
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.Code)
|
||||||
|
|
||||||
|
// Test case 4: Admin can delete any config
|
||||||
|
// Create admin router
|
||||||
|
adminHandlers, adminRouter, _, _ := setupAuthenticatedAPITest(t, true)
|
||||||
|
adminRouter.DELETE("/api/configs/:id", adminHandlers.HandleAPIDeleteConfig)
|
||||||
|
|
||||||
|
req, _ = http.NewRequest("DELETE", "/api/configs/"+strconv.Itoa(int(otherConfig.ID)), nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
adminRouter.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response - admin should be able to delete
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Test case 5: Non-existent config
|
||||||
|
req, _ = http.NewRequest("DELETE", "/api/configs/9999", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAPIRunJob(t *testing.T) {
|
||||||
|
// Setup test environment
|
||||||
|
handlers, router, database, user := setupAuthenticatedAPITest(t, false)
|
||||||
|
|
||||||
|
// Create test config
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Test Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest",
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
database.Create(config)
|
||||||
|
|
||||||
|
// Create test job
|
||||||
|
job := &db.Job{
|
||||||
|
Name: "Test Job",
|
||||||
|
Schedule: "* * * * *",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
database.Create(job)
|
||||||
|
|
||||||
|
// Create job for another user
|
||||||
|
otherUser := &db.User{
|
||||||
|
Email: "other@example.com",
|
||||||
|
PasswordHash: "hashedpassword",
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
database.Create(otherUser)
|
||||||
|
|
||||||
|
otherJob := &db.Job{
|
||||||
|
Name: "Other User Job",
|
||||||
|
Schedule: "* * * * *",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: otherUser.ID,
|
||||||
|
}
|
||||||
|
database.Create(otherJob)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.POST("/api/jobs/:id/run", handlers.HandleAPIRunJob)
|
||||||
|
|
||||||
|
// Test case 1: Run own job
|
||||||
|
req, _ := http.NewRequest("POST", "/api/jobs/"+strconv.Itoa(int(job.ID))+"/run", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Test case 2: Try to run another user's job
|
||||||
|
req, _ = http.NewRequest("POST", "/api/jobs/"+strconv.Itoa(int(otherJob.ID))+"/run", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response - should be forbidden
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.Code)
|
||||||
|
|
||||||
|
// Test case 3: Admin can run any job
|
||||||
|
// Create a new router with admin permissions but using the same handlers
|
||||||
|
adminRouter := gin.New()
|
||||||
|
adminRouter.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", user.ID)
|
||||||
|
c.Set("email", user.Email)
|
||||||
|
c.Set("username", "testuser")
|
||||||
|
c.Set("isAdmin", true) // Set admin flag to true
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
adminRouter.POST("/api/jobs/:id/run", handlers.HandleAPIRunJob)
|
||||||
|
|
||||||
|
req, _ = http.NewRequest("POST", "/api/jobs/"+strconv.Itoa(int(otherJob.ID))+"/run", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
adminRouter.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response - admin should be able to run
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Test case 4: Non-existent job
|
||||||
|
req, _ = http.NewRequest("POST", "/api/jobs/9999/run", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response - should be not found
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||||
|
}
|
||||||
@@ -0,0 +1,834 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/email"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthMiddleware(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
handlers, router := setupTestHandlers(t)
|
||||||
|
jwtSecret := "test-jwt-secret"
|
||||||
|
handlers.JWTSecret = jwtSecret
|
||||||
|
|
||||||
|
// Create test route with auth middleware
|
||||||
|
router.GET("/protected", handlers.AuthMiddleware(), func(c *gin.Context) {
|
||||||
|
c.String(http.StatusOK, "protected content")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test case 1: No JWT token
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect to login page
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code, "Should redirect to login page")
|
||||||
|
assert.Equal(t, "/login", resp.Header().Get("Location"), "Should redirect to /login")
|
||||||
|
|
||||||
|
// Test case 2: Invalid JWT token
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
req.AddCookie(&http.Cookie{
|
||||||
|
Name: "jwt_token",
|
||||||
|
Value: "invalid-token",
|
||||||
|
})
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect to login page due to invalid token
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code, "Should redirect to login page on invalid token")
|
||||||
|
assert.Equal(t, "/login", resp.Header().Get("Location"), "Should redirect to /login on invalid token")
|
||||||
|
|
||||||
|
// Test case 3: Valid JWT token
|
||||||
|
// Generate a valid token
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": 1,
|
||||||
|
"email": "test@example.com",
|
||||||
|
"username": "testuser",
|
||||||
|
"is_admin": false,
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
tokenString, _ := token.SignedString([]byte(jwtSecret))
|
||||||
|
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
req.AddCookie(&http.Cookie{
|
||||||
|
Name: "jwt_token",
|
||||||
|
Value: tokenString,
|
||||||
|
})
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should allow access to protected content
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code, "Should allow access with valid token")
|
||||||
|
assert.Equal(t, "protected content", resp.Body.String(), "Should return protected content")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminMiddleware(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
handlers, router := setupTestHandlers(t)
|
||||||
|
|
||||||
|
// Create test route with admin middleware
|
||||||
|
router.GET("/admin", handlers.AuthMiddleware(), handlers.AdminMiddleware(), func(c *gin.Context) {
|
||||||
|
c.String(http.StatusOK, "admin content")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test case 1: Regular user (non-admin)
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": 1,
|
||||||
|
"email": "test@example.com",
|
||||||
|
"username": "testuser",
|
||||||
|
"is_admin": false,
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
tokenString, _ := token.SignedString([]byte(handlers.JWTSecret))
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/admin", nil)
|
||||||
|
req.AddCookie(&http.Cookie{
|
||||||
|
Name: "jwt_token",
|
||||||
|
Value: tokenString,
|
||||||
|
})
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect to dashboard
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code)
|
||||||
|
assert.Equal(t, "/dashboard", resp.Header().Get("Location"))
|
||||||
|
|
||||||
|
// Test case 2: Admin user
|
||||||
|
adminToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": 2,
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"username": "admin",
|
||||||
|
"is_admin": true,
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
adminTokenString, _ := adminToken.SignedString([]byte(handlers.JWTSecret))
|
||||||
|
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/admin", nil)
|
||||||
|
req.AddCookie(&http.Cookie{
|
||||||
|
Name: "jwt_token",
|
||||||
|
Value: adminTokenString,
|
||||||
|
})
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should allow access
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Equal(t, "admin content", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIAuthMiddleware(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
handlers, router := setupTestHandlers(t)
|
||||||
|
|
||||||
|
// Create test route with API auth middleware
|
||||||
|
router.GET("/api/test", handlers.APIAuthMiddleware(), func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test case 1: No Authorization header
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/api/test", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should return 401 Unauthorized
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Authorization header is required")
|
||||||
|
|
||||||
|
// Test case 2: Invalid Authorization format
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/api/test", nil)
|
||||||
|
req.Header.Set("Authorization", "InvalidFormat")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should return 401 Unauthorized
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Authorization header format must be Bearer")
|
||||||
|
|
||||||
|
// Test case 3: Invalid token
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/api/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should return 401 Unauthorized
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Invalid or expired token")
|
||||||
|
|
||||||
|
// Test case 4: Valid token
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": 1,
|
||||||
|
"email": "test@example.com",
|
||||||
|
"username": "testuser",
|
||||||
|
"is_admin": false,
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
tokenString, _ := token.SignedString([]byte(handlers.JWTSecret))
|
||||||
|
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/api/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenString)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should allow access
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "success")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIAdminMiddleware(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
handlers, router := setupTestHandlers(t)
|
||||||
|
|
||||||
|
// Create test route with API auth and admin middleware
|
||||||
|
router.GET("/api/admin", handlers.APIAuthMiddleware(), handlers.APIAdminMiddleware(), func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "admin success"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test case 1: Regular user (non-admin)
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": 1,
|
||||||
|
"email": "test@example.com",
|
||||||
|
"username": "testuser",
|
||||||
|
"is_admin": false,
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
tokenString, _ := token.SignedString([]byte(handlers.JWTSecret))
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/api/admin", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenString)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should return 403 Forbidden
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Admin privileges required")
|
||||||
|
|
||||||
|
// Test case 2: Admin user
|
||||||
|
adminToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": 2,
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"username": "admin",
|
||||||
|
"is_admin": true,
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
adminTokenString, _ := adminToken.SignedString([]byte(handlers.JWTSecret))
|
||||||
|
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/api/admin", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+adminTokenString)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should allow access
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "admin success")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateJWT(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
handlers, _ := setupTestHandlers(t)
|
||||||
|
handlers.JWTSecret = "test-jwt-secret"
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
token, err := handlers.GenerateJWT(1, "testuser", false)
|
||||||
|
|
||||||
|
// Check token was generated
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, token)
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(handlers.JWTSecret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, parsedToken.Valid)
|
||||||
|
|
||||||
|
// Check claims
|
||||||
|
claims, ok := parsedToken.Claims.(jwt.MapClaims)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, float64(1), claims["user_id"])
|
||||||
|
assert.Equal(t, "testuser", claims["username"])
|
||||||
|
assert.Equal(t, false, claims["is_admin"])
|
||||||
|
assert.NotEmpty(t, claims["exp"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleLoginPage(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
handlers, router := setupTestHandlers(t)
|
||||||
|
|
||||||
|
// Add route
|
||||||
|
router.GET("/login", handlers.HandleLoginPage)
|
||||||
|
|
||||||
|
// Test case 1: Basic login page
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/login", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Login - GoMFT")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Sign In")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Access your GoMFT account")
|
||||||
|
|
||||||
|
// Test case 2: Login page with message
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/login?message=Password+expired", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Password expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleLogin(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup database and test user
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Create test user with password "password123"
|
||||||
|
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
||||||
|
user := &db.User{
|
||||||
|
Email: "test@example.com",
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
IsAdmin: false,
|
||||||
|
FailedLoginAttempts: 0,
|
||||||
|
AccountLocked: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
database.Create(user)
|
||||||
|
|
||||||
|
// Setup handlers
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
JWTSecret: "test-jwt-secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup router
|
||||||
|
router := gin.New()
|
||||||
|
router.POST("/login", handlers.HandleLogin)
|
||||||
|
|
||||||
|
// Test case 1: Successful login
|
||||||
|
formData := url.Values{
|
||||||
|
"email": {"test@example.com"},
|
||||||
|
"password": {"password123"},
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "/login", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect to dashboard
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code)
|
||||||
|
assert.Equal(t, "/dashboard", resp.Header().Get("Location"))
|
||||||
|
|
||||||
|
// Should set JWT cookie
|
||||||
|
cookies := resp.Result().Cookies()
|
||||||
|
var jwtCookie *http.Cookie
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if cookie.Name == "jwt_token" {
|
||||||
|
jwtCookie = cookie
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.NotNil(t, jwtCookie)
|
||||||
|
assert.NotEmpty(t, jwtCookie.Value)
|
||||||
|
|
||||||
|
// Test case 2: Invalid password
|
||||||
|
formData = url.Values{
|
||||||
|
"email": {"test@example.com"},
|
||||||
|
"password": {"wrongpassword"},
|
||||||
|
}
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, "/login", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should show error message
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Invalid credentials")
|
||||||
|
|
||||||
|
// Test case 3: Non-existent user
|
||||||
|
formData = url.Values{
|
||||||
|
"email": {"nonexistent@example.com"},
|
||||||
|
"password": {"password123"},
|
||||||
|
}
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, "/login", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should show error message
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleLogout(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
handlers, router := setupTestHandlers(t)
|
||||||
|
|
||||||
|
// Add route
|
||||||
|
router.GET("/logout", handlers.HandleLogout)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/logout", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve the request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code, "Should redirect")
|
||||||
|
assert.Equal(t, "/login", resp.Header().Get("Location"), "Should redirect to login page")
|
||||||
|
|
||||||
|
// Check that cookie is cleared
|
||||||
|
cookies := resp.Result().Cookies()
|
||||||
|
found := false
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if cookie.Name == "jwt_token" {
|
||||||
|
assert.Equal(t, "", cookie.Value, "JWT cookie should be cleared")
|
||||||
|
assert.True(t, cookie.Expires.Before(time.Now()), "Cookie should be expired")
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "Should find jwt_token cookie in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChangePassword(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup database and test user
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Create test user with password "OldPassword123!"
|
||||||
|
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("OldPassword123!"), bcrypt.DefaultCost)
|
||||||
|
user := &db.User{
|
||||||
|
Email: "test@example.com",
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
IsAdmin: false,
|
||||||
|
FailedLoginAttempts: 0,
|
||||||
|
AccountLocked: false,
|
||||||
|
LastPasswordChange: time.Now().Add(-24 * time.Hour), // 1 day ago
|
||||||
|
}
|
||||||
|
database.Create(user)
|
||||||
|
|
||||||
|
// Setup handlers with email mock
|
||||||
|
mockEmail := email.NewMockService()
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
JWTSecret: "test-jwt-secret",
|
||||||
|
Email: mockEmail,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JWT token for this user
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"username": "testuser",
|
||||||
|
"is_admin": false,
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
tokenString, _ := token.SignedString([]byte(handlers.JWTSecret))
|
||||||
|
|
||||||
|
// Setup router
|
||||||
|
router := gin.New()
|
||||||
|
router.POST("/change-password", handlers.HandleChangePassword)
|
||||||
|
|
||||||
|
// Test case 1: Successful password change
|
||||||
|
formData := url.Values{
|
||||||
|
"current_password": {"OldPassword123!"},
|
||||||
|
"new_password": {"NewPassword456@"},
|
||||||
|
"confirm_password": {"NewPassword456@"},
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "/change-password", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{
|
||||||
|
Name: "jwt_token",
|
||||||
|
Value: tokenString,
|
||||||
|
})
|
||||||
|
req.Header.Set("HX-Request", "true") // Simulate HTMX request
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should show success message
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Password updated successfully")
|
||||||
|
assert.Contains(t, resp.Body.String(), "bg-green-100")
|
||||||
|
assert.Contains(t, resp.Body.String(), "border-green-400")
|
||||||
|
|
||||||
|
// Verify password was updated in the database
|
||||||
|
var updatedUser db.User
|
||||||
|
err := database.First(&updatedUser, user.ID).Error
|
||||||
|
assert.NoError(t, err, "Should be able to find the user")
|
||||||
|
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(updatedUser.PasswordHash), []byte("NewPassword456@"))
|
||||||
|
assert.NoError(t, err, "Password should be updated in the database")
|
||||||
|
|
||||||
|
// Test case 2: Incorrect current password
|
||||||
|
formData = url.Values{
|
||||||
|
"current_password": {"WrongPassword123!"},
|
||||||
|
"new_password": {"AnotherPassword789#"},
|
||||||
|
"confirm_password": {"AnotherPassword789#"},
|
||||||
|
}
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, "/change-password", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{
|
||||||
|
Name: "jwt_token",
|
||||||
|
Value: tokenString,
|
||||||
|
})
|
||||||
|
req.Header.Set("HX-Request", "true") // Simulate HTMX request
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should show error message
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Current password is incorrect")
|
||||||
|
assert.Contains(t, resp.Body.String(), "bg-red-100")
|
||||||
|
assert.Contains(t, resp.Body.String(), "border-red-400")
|
||||||
|
|
||||||
|
// Test case 3: Passwords don't match
|
||||||
|
formData = url.Values{
|
||||||
|
"current_password": {"NewPassword456@"}, // Using the updated password
|
||||||
|
"new_password": {"DiffPassword123!"},
|
||||||
|
"confirm_password": {"DiffPassword456@"},
|
||||||
|
}
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, "/change-password", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{
|
||||||
|
Name: "jwt_token",
|
||||||
|
Value: tokenString,
|
||||||
|
})
|
||||||
|
req.Header.Set("HX-Request", "true") // Simulate HTMX request
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should show error message
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "New password and confirmation do not match")
|
||||||
|
assert.Contains(t, resp.Body.String(), "bg-red-100")
|
||||||
|
assert.Contains(t, resp.Body.String(), "border-red-400")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleForgotPasswordPage(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
handlers, router := setupTestHandlers(t)
|
||||||
|
|
||||||
|
// Add route
|
||||||
|
router.GET("/forgot-password", handlers.HandleForgotPasswordPage)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/forgot-password", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve the request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Forgot Password - GoMFT")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Password Reset")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Enter your email to receive a reset link")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleForgotPassword(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup database and test user
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
||||||
|
user := &db.User{
|
||||||
|
Email: "test@example.com",
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
database.Create(user)
|
||||||
|
|
||||||
|
// Setup handlers with email mock
|
||||||
|
mockEmail := email.NewMockService()
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
JWTSecret: "test-jwt-secret",
|
||||||
|
Email: mockEmail,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup router
|
||||||
|
router := gin.New()
|
||||||
|
router.POST("/forgot-password", handlers.HandleForgotPassword)
|
||||||
|
|
||||||
|
// Test case 1: Valid email
|
||||||
|
formData := url.Values{
|
||||||
|
"email": {"test@example.com"},
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "/forgot-password", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should show generic success message
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "If your email is registered")
|
||||||
|
|
||||||
|
// Check if reset token was created
|
||||||
|
var resetToken db.PasswordResetToken
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Verify email would have been sent (if not mocked)
|
||||||
|
// Note: We can't check SendPasswordResetEmailCalls with our current mock
|
||||||
|
// assert.Equal(t, 1, mockEmail.SendPasswordResetEmailCalls)
|
||||||
|
|
||||||
|
// Test case 2: Non-existent email
|
||||||
|
formData = url.Values{
|
||||||
|
"email": {"nonexistent@example.com"},
|
||||||
|
}
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, "/forgot-password", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should show generic success message (even though user doesn't exist)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "If your email is registered")
|
||||||
|
|
||||||
|
// Test case 3: Missing email
|
||||||
|
formData = url.Values{}
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, "/forgot-password", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should show error message
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Email is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleResetPasswordPage(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup database
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
user := &db.User{
|
||||||
|
Email: "test@example.com",
|
||||||
|
PasswordHash: "hashedpassword",
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
database.Create(user)
|
||||||
|
|
||||||
|
// Create reset token
|
||||||
|
token := "valid-reset-token"
|
||||||
|
resetToken := &db.PasswordResetToken{
|
||||||
|
UserID: user.ID,
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||||
|
Used: false,
|
||||||
|
}
|
||||||
|
database.Create(resetToken)
|
||||||
|
|
||||||
|
// Setup handlers
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup router
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/reset-password", handlers.HandleResetPasswordPage)
|
||||||
|
|
||||||
|
// Test case 1: Valid token
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/reset-password?token="+token, nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should show reset password form
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Reset Password")
|
||||||
|
assert.Contains(t, resp.Body.String(), token) // Token should be in the form
|
||||||
|
|
||||||
|
// Test case 2: No token
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/reset-password", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect to forgot password page
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code)
|
||||||
|
assert.Equal(t, "/forgot-password", resp.Header().Get("Location"))
|
||||||
|
|
||||||
|
// Test case 3: Invalid token
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/reset-password?token=invalid-token", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect to forgot password page
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code)
|
||||||
|
assert.Equal(t, "/forgot-password", resp.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleResetPassword(t *testing.T) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Setup database
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("oldpassword"), bcrypt.DefaultCost)
|
||||||
|
user := &db.User{
|
||||||
|
Email: "test@example.com",
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
IsAdmin: false,
|
||||||
|
LastPasswordChange: time.Now().Add(-24 * time.Hour), // 1 day ago
|
||||||
|
}
|
||||||
|
database.Create(user)
|
||||||
|
|
||||||
|
// Create reset token
|
||||||
|
token := "valid-reset-token"
|
||||||
|
resetToken := &db.PasswordResetToken{
|
||||||
|
UserID: user.ID,
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||||
|
Used: false,
|
||||||
|
}
|
||||||
|
database.Create(resetToken)
|
||||||
|
|
||||||
|
// Setup handlers
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup router
|
||||||
|
router := gin.New()
|
||||||
|
router.POST("/reset-password", handlers.HandleResetPassword)
|
||||||
|
|
||||||
|
// Test case 1: Successful password reset
|
||||||
|
formData := url.Values{
|
||||||
|
"token": {token},
|
||||||
|
"password": {"newpassword123"},
|
||||||
|
"confirm-password": {"newpassword123"},
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "/reset-password", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect to login with success message
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code)
|
||||||
|
assert.Contains(t, resp.Header().Get("Location"), "/login?message=Password+reset+successful")
|
||||||
|
|
||||||
|
// Verify password was updated
|
||||||
|
var updatedUser db.User
|
||||||
|
database.First(&updatedUser, user.ID)
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(updatedUser.PasswordHash), []byte("newpassword123"))
|
||||||
|
assert.NoError(t, err, "Password should be updated in the database")
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
// Test case 2: Passwords don't match
|
||||||
|
// Create another token first
|
||||||
|
token2 := "another-valid-token"
|
||||||
|
resetToken2 := &db.PasswordResetToken{
|
||||||
|
UserID: user.ID,
|
||||||
|
Token: token2,
|
||||||
|
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||||
|
Used: false,
|
||||||
|
}
|
||||||
|
database.Create(resetToken2)
|
||||||
|
|
||||||
|
formData = url.Values{
|
||||||
|
"token": {token2},
|
||||||
|
"password": {"newpass1"},
|
||||||
|
"confirm-password": {"newpass2"},
|
||||||
|
}
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, "/reset-password", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should show error
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Passwords do not match")
|
||||||
|
|
||||||
|
// Test case 3: Password too short
|
||||||
|
token3 := "yet-another-valid-token"
|
||||||
|
resetToken3 := &db.PasswordResetToken{
|
||||||
|
UserID: user.ID,
|
||||||
|
Token: token3,
|
||||||
|
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||||
|
Used: false,
|
||||||
|
}
|
||||||
|
database.Create(resetToken3)
|
||||||
|
|
||||||
|
formData = url.Values{
|
||||||
|
"token": {token3},
|
||||||
|
"password": {"short"},
|
||||||
|
"confirm-password": {"short"},
|
||||||
|
}
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, "/reset-password", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should show error
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Password must be at least 8 characters long")
|
||||||
|
|
||||||
|
// Test case 4: No token
|
||||||
|
formData = url.Values{
|
||||||
|
"password": {"validpassword"},
|
||||||
|
"confirm-password": {"validpassword"},
|
||||||
|
}
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, "/reset-password", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect to forgot password page
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code)
|
||||||
|
assert.Equal(t, "/forgot-password", resp.Header().Get("Location"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleHome(t *testing.T) {
|
||||||
|
// Set up test environment
|
||||||
|
handlers, router := setupTestHandlers(t)
|
||||||
|
|
||||||
|
// Set up the route
|
||||||
|
router.GET("/", handlers.HandleHome)
|
||||||
|
|
||||||
|
// Create a test request
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve the request
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Home - GoMFT")
|
||||||
|
assert.Contains(t, w.Body.String(), "Welcome to GoMFT")
|
||||||
|
}
|
||||||
@@ -72,6 +72,16 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
|
|||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
config.CreatedBy = 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"
|
||||||
|
config.SkipProcessedFiles = &skipProcessedValue
|
||||||
|
|
||||||
|
fmt.Println("Skip processed files:", config.SkipProcessedFiles)
|
||||||
|
fmt.Println("Config:", config)
|
||||||
|
|
||||||
if err := h.DB.Create(&config).Error; err != nil {
|
if err := h.DB.Create(&config).Error; err != nil {
|
||||||
log.Printf("Error creating config: %v", err)
|
log.Printf("Error creating config: %v", err)
|
||||||
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to create config: %v", err))
|
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to create config: %v", err))
|
||||||
@@ -121,6 +131,10 @@ func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process skipProcessedFiles value (now using pointer)
|
||||||
|
skipProcessedValue := c.Request.FormValue("skip_processed_files") == "true"
|
||||||
|
config.SkipProcessedFiles = &skipProcessedValue
|
||||||
|
|
||||||
// Preserve fields that shouldn't be updated
|
// Preserve fields that shouldn't be updated
|
||||||
config.CreatedBy = oldConfig.CreatedBy
|
config.CreatedBy = oldConfig.CreatedBy
|
||||||
|
|
||||||
@@ -178,44 +192,3 @@ func (h *Handlers) HandleDeleteConfig(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleTestConnection handles the POST /configs/test route
|
|
||||||
func (h *Handlers) HandleTestConnection(c *gin.Context) {
|
|
||||||
var config db.TransferConfig
|
|
||||||
if err := c.ShouldBind(&config); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid form data: %v", err)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Implement connection testing based on protocol
|
|
||||||
// This is a placeholder for the actual connection testing logic
|
|
||||||
success := true
|
|
||||||
message := "Connection successful"
|
|
||||||
|
|
||||||
// Example of how connection testing might work
|
|
||||||
switch config.SourceType {
|
|
||||||
case "sftp":
|
|
||||||
// Test SFTP connection
|
|
||||||
// success, message = testSFTPConnection(config)
|
|
||||||
default:
|
|
||||||
success = false
|
|
||||||
message = "Unsupported source type"
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": success,
|
|
||||||
"message": message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleTestSFTPConnection handles the test SFTP connection request
|
|
||||||
func (h *Handlers) HandleTestSFTPConnection(c *gin.Context) {
|
|
||||||
// Implementation will be moved from the old handlers.go
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Test SFTP connection handler stub"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleBrowseDirectory handles the browse directory request
|
|
||||||
func (h *Handlers) HandleBrowseDirectory(c *gin.Context) {
|
|
||||||
// Implementation will be moved from the old handlers.go
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Browse directory handler stub"})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupConfigTest(t *testing.T) (*Handlers, *gin.Engine, *db.DB, *db.User) {
|
||||||
|
// Set up test database
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
user := testutils.CreateTestUser(t, database, "test@example.com", false)
|
||||||
|
|
||||||
|
// Set up Gin router
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
|
||||||
|
// Create handlers
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up authentication middleware
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", user.ID)
|
||||||
|
c.Set("isAdmin", false)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
return handlers, router, database, user
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestConfig(t *testing.T, database *db.DB, userID uint) *db.TransferConfig {
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Test Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest",
|
||||||
|
CreatedBy: userID,
|
||||||
|
}
|
||||||
|
if err := database.Create(config).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to create test config: %v", err)
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleConfigs(t *testing.T) {
|
||||||
|
handlers, router, database, user := setupConfigTest(t)
|
||||||
|
|
||||||
|
// Create test configs
|
||||||
|
config1 := createTestConfig(t, database, user.ID)
|
||||||
|
config2 := createTestConfig(t, database, user.ID)
|
||||||
|
|
||||||
|
// Create a config for another user
|
||||||
|
otherUser := testutils.CreateTestUser(t, database, "other@example.com", false)
|
||||||
|
createTestConfig(t, database, otherUser.ID)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/configs", handlers.HandleConfigs)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/configs", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Response should include user's configs
|
||||||
|
assert.Contains(t, resp.Body.String(), config1.Name)
|
||||||
|
assert.Contains(t, resp.Body.String(), config2.Name)
|
||||||
|
|
||||||
|
// Should not contain configs from other users
|
||||||
|
assert.Contains(t, resp.Body.String(), strconv.Itoa(int(config1.ID)))
|
||||||
|
assert.Contains(t, resp.Body.String(), strconv.Itoa(int(config2.ID)))
|
||||||
|
assert.NotContains(t, resp.Body.String(), "other@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleNewConfig(t *testing.T) {
|
||||||
|
handlers, router, _, _ := setupConfigTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/configs/new", handlers.HandleNewConfig)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/configs/new", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "New Configuration")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Source Type")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Destination Type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEditConfig(t *testing.T) {
|
||||||
|
handlers, router, database, user := setupConfigTest(t)
|
||||||
|
|
||||||
|
// Create test config
|
||||||
|
config := createTestConfig(t, database, user.ID)
|
||||||
|
|
||||||
|
// Create a config for another user
|
||||||
|
otherUser := testutils.CreateTestUser(t, database, "other@example.com", false)
|
||||||
|
otherConfig := createTestConfig(t, database, otherUser.ID)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/configs/:id/edit", handlers.HandleEditConfig)
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
configID uint
|
||||||
|
expectedCode int
|
||||||
|
expectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Edit own config",
|
||||||
|
configID: config.ID,
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
expectedBody: "Edit Configuration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cannot edit other user's config",
|
||||||
|
configID: otherConfig.ID,
|
||||||
|
expectedCode: http.StatusFound, // Redirect to /configs
|
||||||
|
expectedBody: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Non-existent config",
|
||||||
|
configID: 9999,
|
||||||
|
expectedCode: http.StatusFound, // Redirect to /configs
|
||||||
|
expectedBody: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/configs/"+strconv.Itoa(int(tc.configID))+"/edit", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response code
|
||||||
|
assert.Equal(t, tc.expectedCode, resp.Code)
|
||||||
|
|
||||||
|
if tc.expectedBody != "" {
|
||||||
|
assert.Contains(t, resp.Body.String(), tc.expectedBody)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test admin access to other user's config
|
||||||
|
adminRouter := gin.New()
|
||||||
|
adminRouter.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", user.ID)
|
||||||
|
c.Set("isAdmin", true) // Set as admin
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
adminRouter.GET("/configs/:id/edit", handlers.HandleEditConfig)
|
||||||
|
|
||||||
|
// Admin should be able to edit other user's config
|
||||||
|
req, _ := http.NewRequest("GET", "/configs/"+strconv.Itoa(int(otherConfig.ID))+"/edit", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
adminRouter.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Edit Configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCreateConfig(t *testing.T) {
|
||||||
|
handlers, router, database, user := setupConfigTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.POST("/configs", handlers.HandleCreateConfig)
|
||||||
|
|
||||||
|
// Prepare form data
|
||||||
|
formData := url.Values{
|
||||||
|
"name": {"New Test Config"},
|
||||||
|
"source_type": {"local"},
|
||||||
|
"source_path": {"/test/source"},
|
||||||
|
"destination_type": {"local"},
|
||||||
|
"destination_path": {"/test/dest"},
|
||||||
|
"file_pattern": {"*.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("POST", "/configs", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response (should redirect on success)
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code)
|
||||||
|
assert.Equal(t, "/configs", resp.Header().Get("Location"))
|
||||||
|
|
||||||
|
// Verify config was created in database
|
||||||
|
var configs []db.TransferConfig
|
||||||
|
database.Where("created_by = ?", user.ID).Find(&configs)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(configs))
|
||||||
|
assert.Equal(t, "New Test Config", configs[0].Name)
|
||||||
|
assert.Equal(t, "local", configs[0].SourceType)
|
||||||
|
assert.Equal(t, "/test/source", configs[0].SourcePath)
|
||||||
|
assert.Equal(t, "local", configs[0].DestinationType)
|
||||||
|
assert.Equal(t, "/test/dest", configs[0].DestinationPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUpdateConfig(t *testing.T) {
|
||||||
|
handlers, router, database, user := setupConfigTest(t)
|
||||||
|
|
||||||
|
// Create test config
|
||||||
|
config := createTestConfig(t, database, user.ID)
|
||||||
|
|
||||||
|
// Create a config for another user
|
||||||
|
otherUser := testutils.CreateTestUser(t, database, "other@example.com", false)
|
||||||
|
otherConfig := createTestConfig(t, database, otherUser.ID)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.PUT("/configs/:id", handlers.HandleUpdateConfig)
|
||||||
|
|
||||||
|
// Prepare form data for update
|
||||||
|
formData := url.Values{
|
||||||
|
"name": {"Updated Config"},
|
||||||
|
"source_type": {"local"},
|
||||||
|
"source_path": {"/updated/source"},
|
||||||
|
"destination_type": {"local"},
|
||||||
|
"destination_path": {"/updated/dest"},
|
||||||
|
"file_pattern": {"*.csv"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
configID uint
|
||||||
|
expectedCode int
|
||||||
|
checkUpdate bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Update own config",
|
||||||
|
configID: config.ID,
|
||||||
|
expectedCode: http.StatusFound, // Redirect to /configs
|
||||||
|
checkUpdate: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cannot update other user's config",
|
||||||
|
configID: otherConfig.ID,
|
||||||
|
expectedCode: http.StatusForbidden,
|
||||||
|
checkUpdate: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Non-existent config",
|
||||||
|
configID: 9999,
|
||||||
|
expectedCode: http.StatusNotFound,
|
||||||
|
checkUpdate: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("PUT", "/configs/"+strconv.Itoa(int(tc.configID)), strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response code
|
||||||
|
assert.Equal(t, tc.expectedCode, resp.Code)
|
||||||
|
|
||||||
|
// Verify config was updated if expected
|
||||||
|
if tc.checkUpdate {
|
||||||
|
var updatedConfig db.TransferConfig
|
||||||
|
database.First(&updatedConfig, tc.configID)
|
||||||
|
|
||||||
|
assert.Equal(t, "Updated Config", updatedConfig.Name)
|
||||||
|
assert.Equal(t, "/updated/source", updatedConfig.SourcePath)
|
||||||
|
assert.Equal(t, "/updated/dest", updatedConfig.DestinationPath)
|
||||||
|
assert.Equal(t, "*.csv", updatedConfig.FilePattern)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test admin access to update other user's config
|
||||||
|
adminRouter := gin.New()
|
||||||
|
adminRouter.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", user.ID)
|
||||||
|
c.Set("isAdmin", true) // Set as admin
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
adminRouter.PUT("/configs/:id", handlers.HandleUpdateConfig)
|
||||||
|
|
||||||
|
// Admin should be able to update other user's config
|
||||||
|
req, _ := http.NewRequest("PUT", "/configs/"+strconv.Itoa(int(otherConfig.ID)), strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
adminRouter.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code)
|
||||||
|
|
||||||
|
// Verify other user's config was updated
|
||||||
|
var updatedOtherConfig db.TransferConfig
|
||||||
|
database.First(&updatedOtherConfig, otherConfig.ID)
|
||||||
|
assert.Equal(t, "Updated Config", updatedOtherConfig.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDeleteConfig(t *testing.T) {
|
||||||
|
handlers, router, database, user := setupConfigTest(t)
|
||||||
|
|
||||||
|
// Create test config
|
||||||
|
config := createTestConfig(t, database, user.ID)
|
||||||
|
|
||||||
|
// Create a config for another user
|
||||||
|
otherUser := testutils.CreateTestUser(t, database, "other@example.com", false)
|
||||||
|
otherConfig := createTestConfig(t, database, otherUser.ID)
|
||||||
|
|
||||||
|
// Create config with associated job
|
||||||
|
configWithJob := createTestConfig(t, database, user.ID)
|
||||||
|
job := &db.Job{
|
||||||
|
Name: "Test Job",
|
||||||
|
Schedule: "*/5 * * * *",
|
||||||
|
ConfigID: configWithJob.ID,
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
if err := database.Create(job).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to create test job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.DELETE("/configs/:id", handlers.HandleDeleteConfig)
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
configID uint
|
||||||
|
expectedCode int
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Delete own config",
|
||||||
|
configID: config.ID,
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
errorMsg: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cannot delete other user's config",
|
||||||
|
configID: otherConfig.ID,
|
||||||
|
expectedCode: http.StatusForbidden,
|
||||||
|
errorMsg: "You do not have permission to delete this config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cannot delete config with jobs",
|
||||||
|
configID: configWithJob.ID,
|
||||||
|
expectedCode: http.StatusBadRequest,
|
||||||
|
errorMsg: "Config is in use by jobs and cannot be deleted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Non-existent config",
|
||||||
|
configID: 9999,
|
||||||
|
expectedCode: http.StatusNotFound,
|
||||||
|
errorMsg: "Config not found",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("DELETE", "/configs/"+strconv.Itoa(int(tc.configID)), nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response code
|
||||||
|
assert.Equal(t, tc.expectedCode, resp.Code)
|
||||||
|
|
||||||
|
if tc.errorMsg != "" {
|
||||||
|
// Parse response body
|
||||||
|
var response map[string]string
|
||||||
|
err := json.Unmarshal(resp.Body.Bytes(), &response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Check error message
|
||||||
|
assert.Equal(t, tc.errorMsg, response["error"])
|
||||||
|
} else {
|
||||||
|
// Verify config was deleted - using a new DB query
|
||||||
|
var foundConfig db.TransferConfig
|
||||||
|
err := database.First(&foundConfig, tc.configID).Error
|
||||||
|
assert.Error(t, err, "Expected config to be deleted but it was found")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test admin access to delete other user's config
|
||||||
|
adminRouter := gin.New()
|
||||||
|
adminRouter.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", user.ID)
|
||||||
|
c.Set("isAdmin", true) // Set as admin
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
adminRouter.DELETE("/configs/:id", handlers.HandleDeleteConfig)
|
||||||
|
|
||||||
|
// Admin should be able to delete other user's config
|
||||||
|
req, _ := http.NewRequest("DELETE", "/configs/"+strconv.Itoa(int(otherConfig.ID)), nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
adminRouter.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Verify config was deleted
|
||||||
|
var foundConfig db.TransferConfig
|
||||||
|
err := database.First(&foundConfig, otherConfig.ID).Error
|
||||||
|
assert.Error(t, err, "Expected config to be deleted but it was found")
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/starfleetcptn/gomft/components"
|
"github.com/starfleetcptn/gomft/components"
|
||||||
@@ -14,7 +17,7 @@ func (h *Handlers) HandleDashboard(c *gin.Context) {
|
|||||||
|
|
||||||
// Get recent job history
|
// Get recent job history
|
||||||
var recentHistory []db.JobHistory
|
var recentHistory []db.JobHistory
|
||||||
h.DB.Order("start_time DESC").Limit(5).Find(&recentHistory)
|
h.DB.Preload("Job.Config").Order("start_time DESC").Limit(5).Find(&recentHistory)
|
||||||
|
|
||||||
// Get job statistics
|
// Get job statistics
|
||||||
var totalJobs int64
|
var totalJobs int64
|
||||||
@@ -26,81 +29,224 @@ func (h *Handlers) HandleDashboard(c *gin.Context) {
|
|||||||
var failedJobs int64
|
var failedJobs int64
|
||||||
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failed").Count(&failedJobs)
|
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failed").Count(&failedJobs)
|
||||||
|
|
||||||
|
// Create a map to hold all relevant config IDs
|
||||||
|
configIDs := make(map[uint]bool)
|
||||||
|
|
||||||
|
// Collect all config IDs from recent history entries
|
||||||
|
for _, h := range recentHistory {
|
||||||
|
// Add the specific config ID used for this history entry if it exists
|
||||||
|
if h.ConfigID > 0 {
|
||||||
|
configIDs[h.ConfigID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the job's default config ID as a fallback
|
||||||
|
if h.Job.ConfigID > 0 {
|
||||||
|
configIDs[h.Job.ConfigID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map to store all configs by their ID
|
||||||
|
configsMap := make(map[uint]db.TransferConfig)
|
||||||
|
|
||||||
|
// Load all necessary configurations
|
||||||
|
if len(configIDs) > 0 {
|
||||||
|
var configsList []db.TransferConfig
|
||||||
|
configIDsList := make([]uint, 0, len(configIDs))
|
||||||
|
|
||||||
|
// Extract config IDs from the map
|
||||||
|
for id := range configIDs {
|
||||||
|
configIDsList = append(configIDsList, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all configurations in one query
|
||||||
|
if err := h.DB.Where("id IN ?", configIDsList).Find(&configsList).Error; err == nil {
|
||||||
|
// Create the lookup map
|
||||||
|
for _, config := range configsList {
|
||||||
|
configsMap[config.ID] = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data := components.DashboardData{
|
data := components.DashboardData{
|
||||||
RecentJobs: recentHistory,
|
RecentJobs: recentHistory,
|
||||||
ActiveTransfers: int(totalJobs),
|
ActiveTransfers: int(totalJobs),
|
||||||
CompletedToday: int(completedJobs),
|
CompletedToday: int(completedJobs),
|
||||||
FailedTransfers: int(failedJobs),
|
FailedTransfers: int(failedJobs),
|
||||||
|
Configs: configsMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
components.Dashboard(components.CreateTemplateContext(c), data).Render(c, c.Writer)
|
components.Dashboard(components.CreateTemplateContext(c), data).Render(c, c.Writer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleDashboardStats handles the dashboard stats API request
|
// HandleHistory handles the GET /history route
|
||||||
func (h *Handlers) HandleDashboardStats(c *gin.Context) {
|
func (h *Handlers) HandleHistory(c *gin.Context) {
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
// Get job statistics
|
// Get pagination parameters
|
||||||
var activeJobCount int64
|
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
var completedJobCount int64
|
if err != nil || page < 1 {
|
||||||
var failedJobCount int64
|
page = 1
|
||||||
|
|
||||||
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "running").Count(&activeJobCount)
|
|
||||||
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "completed").Count(&completedJobCount)
|
|
||||||
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "failed").Count(&failedJobCount)
|
|
||||||
|
|
||||||
// Get transfer statistics for the last 7 days
|
|
||||||
var dailyStats []struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Completed int64 `json:"completed"`
|
|
||||||
Failed int64 `json:"failed"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 6; i >= 0; i-- {
|
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||||
date := time.Now().AddDate(0, 0, -i)
|
if err != nil {
|
||||||
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local)
|
pageSize = 10
|
||||||
endOfDay := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, time.Local)
|
}
|
||||||
|
// Limit page size options
|
||||||
|
if pageSize != 10 && pageSize != 25 && pageSize != 50 && pageSize != 100 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
var completed int64
|
// Get search term
|
||||||
var failed int64
|
searchTerm := c.Query("search")
|
||||||
|
|
||||||
h.DB.Model(&db.Job{}).
|
// Build the query
|
||||||
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "completed", startOfDay, endOfDay).
|
query := h.DB.Model(&db.JobHistory{}).
|
||||||
Count(&completed)
|
Joins("JOIN jobs ON jobs.id = job_histories.job_id").
|
||||||
|
Joins("JOIN transfer_configs ON transfer_configs.id = jobs.config_id").
|
||||||
|
Where("jobs.created_by = ?", userID)
|
||||||
|
|
||||||
h.DB.Model(&db.Job{}).
|
// Apply search if provided
|
||||||
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "failed", startOfDay, endOfDay).
|
if searchTerm != "" {
|
||||||
Count(&failed)
|
query = query.Where("transfer_configs.name LIKE ? OR job_histories.status LIKE ?",
|
||||||
|
"%"+searchTerm+"%", "%"+searchTerm+"%")
|
||||||
|
}
|
||||||
|
|
||||||
dailyStats = append(dailyStats, struct {
|
// Count total matching records for pagination
|
||||||
Date string `json:"date"`
|
var total int64
|
||||||
Completed int64 `json:"completed"`
|
query.Count(&total)
|
||||||
Failed int64 `json:"failed"`
|
|
||||||
}{
|
// Calculate total pages
|
||||||
Date: startOfDay.Format("2006-01-02"),
|
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
|
||||||
Completed: completed,
|
if totalPages == 0 {
|
||||||
Failed: failed,
|
totalPages = 1
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Ensure page is within bounds
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
var history []db.JobHistory
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
query.Offset(offset).
|
||||||
|
Limit(pageSize).
|
||||||
|
Preload("Job.Config").
|
||||||
|
Order("start_time desc").
|
||||||
|
Find(&history)
|
||||||
|
|
||||||
|
// If we got no results and we're not on page 1, redirect to page 1
|
||||||
|
// Only do this for non-HTMX requests to avoid navigation issues
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true"
|
||||||
|
if len(history) == 0 && page > 1 && total > 0 && !isHtmxRequest {
|
||||||
|
redirectURL := fmt.Sprintf("/history?page=1&pageSize=%d", pageSize)
|
||||||
|
if searchTerm != "" {
|
||||||
|
redirectURL += fmt.Sprintf("&search=%s", url.QueryEscape(searchTerm))
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, redirectURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map to hold all relevant config IDs
|
||||||
|
configIDs := make(map[uint]bool)
|
||||||
|
|
||||||
|
// Collect all config IDs from history entries
|
||||||
|
for _, h := range history {
|
||||||
|
// Add the specific config ID used for this history entry if it exists
|
||||||
|
if h.ConfigID > 0 {
|
||||||
|
configIDs[h.ConfigID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the job's default config ID as a fallback
|
||||||
|
if h.Job.ConfigID > 0 {
|
||||||
|
configIDs[h.Job.ConfigID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map to store all configs by their ID
|
||||||
|
configsMap := make(map[uint]db.TransferConfig)
|
||||||
|
|
||||||
|
// Load all necessary configurations
|
||||||
|
if len(configIDs) > 0 {
|
||||||
|
var configsList []db.TransferConfig
|
||||||
|
configIDsList := make([]uint, 0, len(configIDs))
|
||||||
|
|
||||||
|
// Extract config IDs from the map
|
||||||
|
for id := range configIDs {
|
||||||
|
configIDsList = append(configIDsList, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all configurations in one query
|
||||||
|
if err := h.DB.Where("id IN ?", configIDsList).Find(&configsList).Error; err == nil {
|
||||||
|
// Create the lookup map
|
||||||
|
for _, config := range configsList {
|
||||||
|
configsMap[config.ID] = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := components.HistoryData{
|
||||||
|
History: history,
|
||||||
|
CurrentPage: page,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
SearchTerm: searchTerm,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Total: int(total),
|
||||||
|
Configs: configsMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is an HTMX request, only render the history content component
|
||||||
|
if isHtmxRequest {
|
||||||
|
components.HistoryContent(c, data).Render(c, c.Writer)
|
||||||
|
} else {
|
||||||
|
components.History(c, data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDashboardData handles the GET /dashboard/data route
|
||||||
|
func (h *Handlers) HandleDashboardData(c *gin.Context) {
|
||||||
|
// Get recent job runs
|
||||||
|
var recentRuns []db.JobHistory
|
||||||
|
if err := h.DB.Preload("Job").Order("start_time desc").Limit(5).Find(&recentRuns).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve recent runs"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"activeJobs": activeJobCount,
|
"recent_runs": recentRuns,
|
||||||
"completedJobs": completedJobCount,
|
|
||||||
"failedJobs": failedJobCount,
|
|
||||||
"dailyStats": dailyStats,
|
|
||||||
"uptime": time.Since(h.StartTime).String(),
|
|
||||||
"uptimeSeconds": int64(time.Since(h.StartTime).Seconds()),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleRecentJobs handles the recent jobs API request
|
// HandleDashboardJobsData handles the GET /dashboard/jobs route
|
||||||
func (h *Handlers) HandleRecentJobs(c *gin.Context) {
|
func (h *Handlers) HandleDashboardJobsData(c *gin.Context) {
|
||||||
userID := c.GetUint("userID")
|
// Get active jobs
|
||||||
|
var activeJobs []db.Job
|
||||||
var recentJobs []db.Job
|
if err := h.DB.Where("enabled = ?", true).Find(&activeJobs).Error; err != nil {
|
||||||
h.DB.Where("created_by = ?", userID).Order("created_at DESC").Limit(5).Find(&recentJobs)
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve active jobs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"recentJobs": recentJobs,
|
"active_jobs": activeJobs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDashboardHistoryData handles the GET /dashboard/history route
|
||||||
|
func (h *Handlers) HandleDashboardHistoryData(c *gin.Context) {
|
||||||
|
// Get job history stats
|
||||||
|
var successCount int64
|
||||||
|
var failureCount int64
|
||||||
|
var pendingCount int64
|
||||||
|
|
||||||
|
h.DB.Model(&db.JobHistory{}).Where("status = ?", "success").Count(&successCount)
|
||||||
|
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failure").Count(&failureCount)
|
||||||
|
h.DB.Model(&db.JobHistory{}).Where("status = ?", "pending").Count(&pendingCount)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success_count": successCount,
|
||||||
|
"failure_count": failureCount,
|
||||||
|
"pending_count": pendingCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupDashboardTest(t *testing.T) (*Handlers, *gin.Engine, *db.DB) {
|
||||||
|
// Set up test database
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
user := testutils.CreateTestUser(t, database, "test@example.com", false)
|
||||||
|
|
||||||
|
// Create test config
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Test Config",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest",
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(config).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to create transfer config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test job
|
||||||
|
job := &db.Job{
|
||||||
|
Name: "Test Job",
|
||||||
|
Schedule: "*/5 * * * *",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(job).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to create job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test job history entries
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Completed job
|
||||||
|
completedJob := &db.JobHistory{
|
||||||
|
JobID: job.ID,
|
||||||
|
StartTime: now.Add(-time.Hour),
|
||||||
|
EndTime: &now,
|
||||||
|
Status: "completed",
|
||||||
|
BytesTransferred: 1024,
|
||||||
|
FilesTransferred: 1,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(completedJob).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to create completed job history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed job
|
||||||
|
failedJob := &db.JobHistory{
|
||||||
|
JobID: job.ID,
|
||||||
|
StartTime: now.Add(-2 * time.Hour),
|
||||||
|
EndTime: &now,
|
||||||
|
Status: "failed",
|
||||||
|
ErrorMessage: "Test error",
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(failedJob).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to create failed job history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running job
|
||||||
|
runningJob := &db.JobHistory{
|
||||||
|
JobID: job.ID,
|
||||||
|
StartTime: now.Add(-30 * time.Minute),
|
||||||
|
Status: "running",
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(runningJob).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to create running job history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up Gin router
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
|
||||||
|
// Create handlers
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up authentication middleware
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", user.ID)
|
||||||
|
c.Set("isAdmin", false)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
return handlers, router, database
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDashboard(t *testing.T) {
|
||||||
|
handlers, router, _ := setupDashboardTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/dashboard", handlers.HandleDashboard)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/dashboard", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Dashboard")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Recent Jobs")
|
||||||
|
|
||||||
|
// Check that job statistics are included
|
||||||
|
assert.Contains(t, resp.Body.String(), "Active Transfers")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Completed Today")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Failed Transfers")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleHistory(t *testing.T) {
|
||||||
|
handlers, router, _ := setupDashboardTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/history", handlers.HandleHistory)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/history", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Transfer History")
|
||||||
|
|
||||||
|
// Check that job history is included
|
||||||
|
assert.Contains(t, resp.Body.String(), "Test Config")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Completed")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleHistoryWithPagination(t *testing.T) {
|
||||||
|
handlers, router, _ := setupDashboardTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/history", handlers.HandleHistory)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
expectedStatus int
|
||||||
|
expectedContent string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Default pagination",
|
||||||
|
url: "/history",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedContent: "Test Config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Custom page size",
|
||||||
|
url: "/history?pageSize=25",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedContent: "Test Config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid page size defaults to 10",
|
||||||
|
url: "/history?pageSize=invalid",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedContent: "Test Config",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", tc.url, nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.expectedStatus, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), tc.expectedContent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleHistoryWithSearch(t *testing.T) {
|
||||||
|
handlers, router, _ := setupDashboardTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/history", handlers.HandleHistory)
|
||||||
|
|
||||||
|
// Test search
|
||||||
|
req, _ := http.NewRequest("GET", "/history?search=completed", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "completed")
|
||||||
|
assert.NotContains(t, resp.Body.String(), "failed") // Should filter out failed jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleHistoryWithHtmx(t *testing.T) {
|
||||||
|
handlers, router, _ := setupDashboardTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/history", handlers.HandleHistory)
|
||||||
|
|
||||||
|
// Test HTMX request
|
||||||
|
req, _ := http.NewRequest("GET", "/history", nil)
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
// Should only contain the history content, not the full page
|
||||||
|
assert.Contains(t, resp.Body.String(), "Test Config")
|
||||||
|
assert.NotContains(t, resp.Body.String(), "<html")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDashboardData(t *testing.T) {
|
||||||
|
handlers, router, _ := setupDashboardTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/dashboard/data", handlers.HandleDashboardData)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/dashboard/data", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "recent_runs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDashboardJobsData(t *testing.T) {
|
||||||
|
handlers, router, _ := setupDashboardTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/dashboard/jobs", handlers.HandleDashboardJobsData)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/dashboard/jobs", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "active_jobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDashboardHistoryData(t *testing.T) {
|
||||||
|
handlers, router, _ := setupDashboardTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/dashboard/history", handlers.HandleDashboardHistoryData)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/dashboard/history", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "success_count")
|
||||||
|
assert.Contains(t, resp.Body.String(), "failure_count")
|
||||||
|
assert.Contains(t, resp.Body.String(), "pending_count")
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataHandler handles displaying and searching file metadata
|
||||||
|
type FileMetadataHandler struct {
|
||||||
|
DB *db.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFileMetadata displays a list of file metadata with pagination and filtering options
|
||||||
|
func (h *FileMetadataHandler) ListFileMetadata(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Query parameters for pagination and filtering
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
jobIDStr := c.Query("job_id")
|
||||||
|
fileName := c.Query("filename")
|
||||||
|
|
||||||
|
// Base query
|
||||||
|
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if jobIDStr != "" {
|
||||||
|
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
|
||||||
|
query = query.Where("file_metadata.job_id = ?", jobID)
|
||||||
|
} else {
|
||||||
|
// Only show files from jobs created by the current user
|
||||||
|
query = query.Where("jobs.created_by = ?", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("file_metadata.status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileName != "" {
|
||||||
|
query = query.Where("file_metadata.file_name LIKE ?", "%"+fileName+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total records for pagination
|
||||||
|
var totalCount int64
|
||||||
|
query.Count(&totalCount)
|
||||||
|
|
||||||
|
// Retrieve file metadata with pagination
|
||||||
|
var fileMetadata []db.FileMetadata
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("Job").Preload("Job.Config").
|
||||||
|
Order("file_metadata.processed_time DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&fileMetadata).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file metadata"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context for template
|
||||||
|
ctx := components.CreateTemplateContext(c)
|
||||||
|
|
||||||
|
// Render the file metadata list template
|
||||||
|
data := components.FileMetadataListData{
|
||||||
|
Files: fileMetadata,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
TotalPages: int(totalCount) / limit,
|
||||||
|
Filter: components.FileMetadataFilter{
|
||||||
|
Status: status,
|
||||||
|
JobID: jobIDStr,
|
||||||
|
FileName: fileName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If total count is not exactly divisible by limit, add one more page
|
||||||
|
if int(totalCount)%limit > 0 {
|
||||||
|
data.TotalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is an HTMX request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
|
||||||
|
if isHtmxRequest {
|
||||||
|
// For HTMX requests, render just the partial template
|
||||||
|
components.FileMetadataListPartial(data).Render(ctx, c.Writer)
|
||||||
|
} else {
|
||||||
|
// For full page requests, render the complete template
|
||||||
|
components.FileMetadataList(ctx, data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileMetadataDetails displays detailed information about a specific file
|
||||||
|
func (h *FileMetadataHandler) GetFileMetadataDetails(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Get file ID from URL parameter
|
||||||
|
fileID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve file metadata
|
||||||
|
var fileMetadata db.FileMetadata
|
||||||
|
err = h.DB.DB.Preload("Job").Preload("Job.Config").First(&fileMetadata, fileID).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has access to this file (file must belong to a job created by the user)
|
||||||
|
var jobCreator uint
|
||||||
|
err = h.DB.DB.Model(&db.Job{}).Where("id = ?", fileMetadata.JobID).Pluck("created_by", &jobCreator).Error
|
||||||
|
if err != nil || jobCreator != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to view this file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context for template
|
||||||
|
ctx := components.CreateTemplateContext(c)
|
||||||
|
|
||||||
|
// Render the file metadata details template
|
||||||
|
data := components.FileMetadataDetailsData{
|
||||||
|
File: fileMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
components.FileMetadataDetails(ctx, data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileMetadataForJob displays file metadata for a specific job
|
||||||
|
func (h *FileMetadataHandler) GetFileMetadataForJob(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Get job ID from URL parameter
|
||||||
|
jobID, err := strconv.ParseUint(c.Param("job_id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has access to this job
|
||||||
|
var job db.Job
|
||||||
|
err = h.DB.DB.Where("id = ?", jobID).First(&job).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.CreatedBy != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to view this job's files"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query parameters for pagination
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
fileName := c.Query("filename")
|
||||||
|
|
||||||
|
// Base query
|
||||||
|
query := h.DB.DB.Model(&db.FileMetadata{}).Where("job_id = ?", jobID)
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileName != "" {
|
||||||
|
query = query.Where("file_name LIKE ?", "%"+fileName+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total records for pagination
|
||||||
|
var totalCount int64
|
||||||
|
query.Count(&totalCount)
|
||||||
|
|
||||||
|
// Retrieve file metadata with pagination
|
||||||
|
var fileMetadata []db.FileMetadata
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err = query.Preload("Job").Preload("Job.Config").
|
||||||
|
Order("processed_time DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&fileMetadata).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file metadata"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context for template
|
||||||
|
ctx := components.CreateTemplateContext(c)
|
||||||
|
|
||||||
|
// Render the file metadata list template
|
||||||
|
data := components.FileMetadataListData{
|
||||||
|
Files: fileMetadata,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
TotalPages: int(totalCount) / limit,
|
||||||
|
Job: &job,
|
||||||
|
Filter: components.FileMetadataFilter{
|
||||||
|
Status: status,
|
||||||
|
JobID: strconv.FormatUint(uint64(job.ID), 10),
|
||||||
|
FileName: fileName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If total count is not exactly divisible by limit, add one more page
|
||||||
|
if int(totalCount)%limit > 0 {
|
||||||
|
data.TotalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is an HTMX request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
|
||||||
|
if isHtmxRequest {
|
||||||
|
// For HTMX requests, render just the partial template
|
||||||
|
components.FileMetadataListPartial(data).Render(ctx, c.Writer)
|
||||||
|
} else {
|
||||||
|
// For full page requests, render the complete template
|
||||||
|
components.FileMetadataList(ctx, data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchFileMetadata searches file metadata based on various criteria
|
||||||
|
func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Query parameters for search and pagination
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
jobIDStr := c.Query("job_id")
|
||||||
|
fileName := c.Query("filename")
|
||||||
|
hash := c.Query("hash")
|
||||||
|
startDate := c.Query("start_date")
|
||||||
|
endDate := c.Query("end_date")
|
||||||
|
|
||||||
|
// Base query
|
||||||
|
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if jobIDStr != "" {
|
||||||
|
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
|
||||||
|
query = query.Where("file_metadata.job_id = ?", jobID)
|
||||||
|
} else {
|
||||||
|
// Only show files from jobs created by the current user
|
||||||
|
query = query.Where("jobs.created_by = ?", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("file_metadata.status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileName != "" {
|
||||||
|
query = query.Where("file_metadata.file_name LIKE ?", "%"+fileName+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash != "" {
|
||||||
|
query = query.Where("file_metadata.file_hash = ?", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if startDate != "" {
|
||||||
|
query = query.Where("file_metadata.processed_time >= ?", startDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if endDate != "" {
|
||||||
|
query = query.Where("file_metadata.processed_time <= ?", endDate+" 23:59:59")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total records for pagination
|
||||||
|
var totalCount int64
|
||||||
|
query.Count(&totalCount)
|
||||||
|
|
||||||
|
// Retrieve file metadata with pagination
|
||||||
|
var fileMetadata []db.FileMetadata
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("Job").Preload("Job.Config").
|
||||||
|
Order("file_metadata.processed_time DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&fileMetadata).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file metadata"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context for template
|
||||||
|
ctx := components.CreateTemplateContext(c)
|
||||||
|
|
||||||
|
// Render the file metadata search template
|
||||||
|
data := components.FileMetadataSearchData{
|
||||||
|
Files: fileMetadata,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
TotalPages: int(totalCount) / limit,
|
||||||
|
Filter: components.FileMetadataFilter{
|
||||||
|
Status: status,
|
||||||
|
JobID: jobIDStr,
|
||||||
|
FileName: fileName,
|
||||||
|
Hash: hash,
|
||||||
|
StartDate: startDate,
|
||||||
|
EndDate: endDate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If total count is not exactly divisible by limit, add one more page
|
||||||
|
if int(totalCount)%limit > 0 {
|
||||||
|
data.TotalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add HTMX request checking and conditional rendering
|
||||||
|
ctx = context.WithValue(c.Request.Context(), "userID", userID)
|
||||||
|
|
||||||
|
// Check if this is an HTMX request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
|
||||||
|
if isHtmxRequest {
|
||||||
|
// For HTMX requests, render just the partial template
|
||||||
|
components.FileMetadataSearchContent(data).Render(ctx, c.Writer)
|
||||||
|
} else {
|
||||||
|
// For full page requests, render the complete template
|
||||||
|
components.FileMetadataSearch(ctx, data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFileMetadata deletes a file metadata record
|
||||||
|
func (h *FileMetadataHandler) DeleteFileMetadata(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
fmt.Println("Deleting file metadata")
|
||||||
|
|
||||||
|
// Get file ID from URL parameter
|
||||||
|
fileID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has access to this file
|
||||||
|
var fileMetadata db.FileMetadata
|
||||||
|
err = h.DB.DB.Preload("Job").First(&fileMetadata, fileID).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobCreator uint
|
||||||
|
err = h.DB.DB.Model(&db.Job{}).Where("id = ?", fileMetadata.JobID).Pluck("created_by", &jobCreator).Error
|
||||||
|
if err != nil || jobCreator != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to delete this file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the file metadata
|
||||||
|
err = h.DB.DeleteFileMetadata(uint(fileID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file metadata"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("File deleted successfully")
|
||||||
|
|
||||||
|
// Check if this is an HTMX request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true"
|
||||||
|
|
||||||
|
if isHtmxRequest {
|
||||||
|
// For HTMX requests, just return a 200 status - client will handle UI updates
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
} else {
|
||||||
|
// For regular browser requests, redirect to the file list
|
||||||
|
c.Redirect(http.StatusFound, "/files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFileMetadataPartial handles rendering just the partial template for file metadata
|
||||||
|
func (h *FileMetadataHandler) HandleFileMetadataPartial(c *gin.Context) {
|
||||||
|
// Check if this is an HTMX request or a direct browser request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true"
|
||||||
|
|
||||||
|
// If it's a direct browser request (not from HTMX), redirect to the full page
|
||||||
|
if !isHtmxRequest {
|
||||||
|
// Get all query parameters
|
||||||
|
query := c.Request.URL.Query()
|
||||||
|
|
||||||
|
// Rebuild query string for the redirect
|
||||||
|
c.Redirect(http.StatusFound, "/files?"+query.Encode())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Query parameters for filtering and pagination
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
jobIDStr := c.Query("job_id")
|
||||||
|
fileName := c.Query("filename")
|
||||||
|
|
||||||
|
// Base query
|
||||||
|
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if jobIDStr != "" {
|
||||||
|
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
|
||||||
|
query = query.Where("file_metadata.job_id = ?", jobID)
|
||||||
|
} else {
|
||||||
|
// Only show files from jobs created by the current user
|
||||||
|
query = query.Where("jobs.created_by = ?", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("file_metadata.status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileName != "" {
|
||||||
|
query = query.Where("file_metadata.file_name LIKE ?", "%"+fileName+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total records for pagination
|
||||||
|
var totalCount int64
|
||||||
|
query.Count(&totalCount)
|
||||||
|
|
||||||
|
// Retrieve file metadata with pagination
|
||||||
|
var fileMetadata []db.FileMetadata
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("Job").Preload("Job.Config").
|
||||||
|
Order("file_metadata.processed_time DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&fileMetadata).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file metadata"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context for template
|
||||||
|
ctx := components.CreateTemplateContext(c)
|
||||||
|
|
||||||
|
// Prepare job pointer if needed
|
||||||
|
var job *db.Job
|
||||||
|
if jobIDStr != "" {
|
||||||
|
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
|
||||||
|
var jobRecord db.Job
|
||||||
|
if err := h.DB.DB.First(&jobRecord, jobID).Error; err == nil {
|
||||||
|
job = &jobRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the file metadata list template
|
||||||
|
data := components.FileMetadataListData{
|
||||||
|
Files: fileMetadata,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
TotalPages: int(totalCount) / limit,
|
||||||
|
Job: job,
|
||||||
|
Filter: components.FileMetadataFilter{
|
||||||
|
Status: status,
|
||||||
|
JobID: jobIDStr,
|
||||||
|
FileName: fileName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If total count is not exactly divisible by limit, add one more page
|
||||||
|
if int(totalCount)%limit > 0 {
|
||||||
|
data.TotalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
components.FileMetadataListPartial(data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFileMetadataSearchPartial handles partial updates for search results
|
||||||
|
func (h *FileMetadataHandler) HandleFileMetadataSearchPartial(c *gin.Context) {
|
||||||
|
// Check if this is an HTMX request or a direct browser request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true"
|
||||||
|
|
||||||
|
// If it's a direct browser request (not from HTMX), redirect to the full page
|
||||||
|
if !isHtmxRequest {
|
||||||
|
// Get all query parameters
|
||||||
|
query := c.Request.URL.Query()
|
||||||
|
|
||||||
|
// Rebuild query string for the redirect
|
||||||
|
c.Redirect(http.StatusFound, "/files/search?"+query.Encode())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Query parameters for search and pagination
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
jobIDStr := c.Query("job_id")
|
||||||
|
fileName := c.Query("filename")
|
||||||
|
hash := c.Query("hash")
|
||||||
|
startDate := c.Query("start_date")
|
||||||
|
endDate := c.Query("end_date")
|
||||||
|
|
||||||
|
// Execute the search query
|
||||||
|
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if jobIDStr != "" {
|
||||||
|
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
|
||||||
|
query = query.Where("file_metadata.job_id = ?", jobID)
|
||||||
|
} else {
|
||||||
|
// Only show files from jobs created by the current user
|
||||||
|
query = query.Where("jobs.created_by = ?", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("file_metadata.status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileName != "" {
|
||||||
|
query = query.Where("file_metadata.file_name LIKE ?", "%"+fileName+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash != "" {
|
||||||
|
query = query.Where("file_metadata.file_hash = ?", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if startDate != "" {
|
||||||
|
query = query.Where("file_metadata.processed_time >= ?", startDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if endDate != "" {
|
||||||
|
query = query.Where("file_metadata.processed_time <= ?", endDate+" 23:59:59")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total results
|
||||||
|
var totalCount int64
|
||||||
|
if err := query.Count(&totalCount).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count files"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order and paginate the results
|
||||||
|
var files []db.FileMetadata
|
||||||
|
if err := query.
|
||||||
|
Preload("Job").
|
||||||
|
Order("file_metadata.processed_time DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Offset((page - 1) * limit).
|
||||||
|
Find(&files).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search files"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the template data
|
||||||
|
data := components.FileMetadataSearchData{
|
||||||
|
Files: files,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
TotalPages: int(totalCount) / limit,
|
||||||
|
Filter: components.FileMetadataFilter{
|
||||||
|
Status: status,
|
||||||
|
JobID: jobIDStr,
|
||||||
|
FileName: fileName,
|
||||||
|
Hash: hash,
|
||||||
|
StartDate: startDate,
|
||||||
|
EndDate: endDate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if int(totalCount)%limit > 0 {
|
||||||
|
data.TotalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "userID", userID)
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
components.FileMetadataSearchContent(data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupFileMetadataHandlers(t *testing.T) (*FileMetadataHandler, *gin.Engine, *db.User, *db.Job) {
|
||||||
|
// Get base handlers and router from the shared setup
|
||||||
|
handlers, router := setupTestHandlers(t)
|
||||||
|
|
||||||
|
// Create a test user with a unique email
|
||||||
|
testUser := &db.User{
|
||||||
|
Email: fmt.Sprintf("file-meta-test-%d@example.com", time.Now().UnixNano()),
|
||||||
|
PasswordHash: "hashed_password",
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
err := handlers.DB.CreateUser(testUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test config
|
||||||
|
testConfig := &db.TransferConfig{
|
||||||
|
Name: "Test Config for File Metadata",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source/path",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/destination/path",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = handlers.DB.CreateTransferConfig(testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test job
|
||||||
|
testJob := &db.Job{
|
||||||
|
Name: "Test Job for File Metadata",
|
||||||
|
ConfigID: testConfig.ID,
|
||||||
|
Schedule: "0 * * * *", // Run hourly
|
||||||
|
Enabled: true,
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err = handlers.DB.CreateJob(testJob)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create test file metadata entries
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
fileMetadata := &db.FileMetadata{
|
||||||
|
JobID: testJob.ID,
|
||||||
|
FileName: fmt.Sprintf("testfile%d.txt", i),
|
||||||
|
OriginalPath: fmt.Sprintf("/source/path/testfile%d.txt", i),
|
||||||
|
FileSize: int64(1024 * (i + 1)),
|
||||||
|
FileHash: fmt.Sprintf("hash%d", i),
|
||||||
|
CreationTime: time.Now().Add(-24 * time.Hour),
|
||||||
|
ModTime: time.Now().Add(-12 * time.Hour),
|
||||||
|
ProcessedTime: time.Now(),
|
||||||
|
DestinationPath: fmt.Sprintf("/destination/path/testfile%d.txt", i),
|
||||||
|
Status: "processed",
|
||||||
|
}
|
||||||
|
err = handlers.DB.CreateFileMetadata(fileMetadata)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create middleware to simulate authenticated user
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", testUser.ID)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create the FileMetadataHandler that we'll test
|
||||||
|
fileMetadataHandler := &FileMetadataHandler{
|
||||||
|
DB: handlers.DB,
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileMetadataHandler, router, testUser, testJob
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to set HTMX headers on request
|
||||||
|
func setHTMXHeaders(req *http.Request) {
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListFileMetadata(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
handler, router, testUser, testJob := setupFileMetadataHandlers(t)
|
||||||
|
|
||||||
|
// Ensure job is owned by test user
|
||||||
|
testJob.CreatedBy = testUser.ID
|
||||||
|
handler.DB.DB.Save(testJob)
|
||||||
|
|
||||||
|
// Recreate file metadata entries to ensure they're properly linked to the updated job
|
||||||
|
handler.DB.DB.Unscoped().Where("job_id = ?", testJob.ID).Delete(&db.FileMetadata{})
|
||||||
|
|
||||||
|
// Create new test file metadata entries for the job
|
||||||
|
var fileIDs []uint
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
fileMetadata := &db.FileMetadata{
|
||||||
|
JobID: testJob.ID,
|
||||||
|
FileName: fmt.Sprintf("testfile%d.txt", i),
|
||||||
|
OriginalPath: fmt.Sprintf("/source/path/testfile%d.txt", i),
|
||||||
|
FileSize: int64(1024 * (i + 1)),
|
||||||
|
FileHash: fmt.Sprintf("hash%d", i),
|
||||||
|
CreationTime: time.Now().Add(-24 * time.Hour),
|
||||||
|
ModTime: time.Now().Add(-12 * time.Hour),
|
||||||
|
ProcessedTime: time.Now(),
|
||||||
|
DestinationPath: fmt.Sprintf("/destination/path/testfile%d.txt", i),
|
||||||
|
Status: "processed",
|
||||||
|
}
|
||||||
|
err := handler.DB.CreateFileMetadata(fileMetadata)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
fileIDs = append(fileIDs, fileMetadata.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup route
|
||||||
|
router.GET("/files", handler.ListFileMetadata)
|
||||||
|
|
||||||
|
// Test default pagination (page 1, limit 50)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/files", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response status code
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Verify that the database contains the expected records
|
||||||
|
var count int64
|
||||||
|
handler.DB.DB.Model(&db.FileMetadata{}).Where("job_id = ?", testJob.ID).Count(&count)
|
||||||
|
assert.Equal(t, int64(5), count)
|
||||||
|
|
||||||
|
// Test with pagination params
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/files?page=1&limit=2", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response status code
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Test with status filter
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/files?status=processed", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response status code
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Verify that the database contains the expected records with the status filter
|
||||||
|
handler.DB.DB.Model(&db.FileMetadata{}).Where("job_id = ? AND status = ?", testJob.ID, "processed").Count(&count)
|
||||||
|
assert.Equal(t, int64(5), count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFileMetadataDetails(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
handler, router, testUser, _ := setupFileMetadataHandlers(t)
|
||||||
|
|
||||||
|
// Setup route
|
||||||
|
router.GET("/files/:id", handler.GetFileMetadataDetails)
|
||||||
|
|
||||||
|
// Get first file metadata ID
|
||||||
|
var firstMetadata db.FileMetadata
|
||||||
|
result := handler.DB.DB.First(&firstMetadata)
|
||||||
|
assert.NoError(t, result.Error)
|
||||||
|
|
||||||
|
// Update the job to make sure the test user owns it
|
||||||
|
var job db.Job
|
||||||
|
handler.DB.DB.First(&job, firstMetadata.JobID)
|
||||||
|
job.CreatedBy = testUser.ID
|
||||||
|
handler.DB.DB.Save(&job)
|
||||||
|
|
||||||
|
// Test getting details for valid ID
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/files/"+strconv.Itoa(int(firstMetadata.ID)), nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), firstMetadata.FileName)
|
||||||
|
assert.Contains(t, resp.Body.String(), firstMetadata.Status)
|
||||||
|
|
||||||
|
// Test getting details for invalid ID
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/files/999999", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteFileMetadata(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
handler, router, testUser, _ := setupFileMetadataHandlers(t)
|
||||||
|
|
||||||
|
// Setup route
|
||||||
|
router.DELETE("/files/:id", handler.DeleteFileMetadata)
|
||||||
|
|
||||||
|
// Get first file metadata ID
|
||||||
|
var firstMetadata db.FileMetadata
|
||||||
|
result := handler.DB.DB.First(&firstMetadata)
|
||||||
|
assert.NoError(t, result.Error)
|
||||||
|
|
||||||
|
// Update the job to make sure the test user owns it
|
||||||
|
var job db.Job
|
||||||
|
handler.DB.DB.First(&job, firstMetadata.JobID)
|
||||||
|
job.CreatedBy = testUser.ID
|
||||||
|
handler.DB.DB.Save(&job)
|
||||||
|
|
||||||
|
// Test deleting with valid ID
|
||||||
|
req, _ := http.NewRequest(http.MethodDelete, "/files/"+strconv.Itoa(int(firstMetadata.ID)), nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
fmt.Println("Deleting file metadata")
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
var deletedMetadata db.FileMetadata
|
||||||
|
result = handler.DB.DB.First(&deletedMetadata, firstMetadata.ID)
|
||||||
|
assert.Error(t, result.Error) // Should not find the deleted record
|
||||||
|
|
||||||
|
// Test deleting with invalid ID
|
||||||
|
req, _ = http.NewRequest(http.MethodDelete, "/files/999999", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
fmt.Println("Deleting file metadata")
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFileMetadataForJob(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
handler, router, testUser, testJob := setupFileMetadataHandlers(t)
|
||||||
|
|
||||||
|
// Ensure job is owned by test user
|
||||||
|
testJob.CreatedBy = testUser.ID
|
||||||
|
handler.DB.DB.Save(testJob)
|
||||||
|
|
||||||
|
// Setup route
|
||||||
|
router.GET("/files/job/:job_id", handler.GetFileMetadataForJob)
|
||||||
|
|
||||||
|
// Test getting files for valid job ID
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/files/job/"+strconv.Itoa(int(testJob.ID)), nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "testfile0.txt")
|
||||||
|
|
||||||
|
// Test getting files for invalid job ID
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/files/job/999999", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.Code) // Not found for invalid job ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchFileMetadata(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
handler, router, _, _ := setupFileMetadataHandlers(t)
|
||||||
|
|
||||||
|
// Setup route
|
||||||
|
router.GET("/files/search", handler.SearchFileMetadata)
|
||||||
|
|
||||||
|
// Test search by filename
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/files/search?filename=testfile", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "testfile0.txt")
|
||||||
|
assert.Contains(t, resp.Body.String(), "testfile4.txt")
|
||||||
|
|
||||||
|
// Test search by specific filename
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/files/search?filename=testfile1", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "testfile1.txt")
|
||||||
|
assert.NotContains(t, resp.Body.String(), "testfile2.txt")
|
||||||
|
|
||||||
|
// Test search with no results
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/files/search?filename=nonexistent", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX header
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.NotContains(t, resp.Body.String(), "testfile")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleFileMetadataPartial(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
handler, router, testUser, testJob := setupFileMetadataHandlers(t)
|
||||||
|
|
||||||
|
// Ensure job is owned by test user
|
||||||
|
testJob.CreatedBy = testUser.ID
|
||||||
|
handler.DB.DB.Save(testJob)
|
||||||
|
|
||||||
|
// Recreate file metadata entries to ensure they're properly linked to the updated job
|
||||||
|
handler.DB.DB.Unscoped().Where("job_id = ?", testJob.ID).Delete(&db.FileMetadata{})
|
||||||
|
|
||||||
|
// Create new test file metadata entries for the job
|
||||||
|
var fileIDs []uint
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
fileMetadata := &db.FileMetadata{
|
||||||
|
JobID: testJob.ID,
|
||||||
|
FileName: fmt.Sprintf("testfile%d.txt", i),
|
||||||
|
OriginalPath: fmt.Sprintf("/source/path/testfile%d.txt", i),
|
||||||
|
FileSize: int64(1024 * (i + 1)),
|
||||||
|
FileHash: fmt.Sprintf("hash%d", i),
|
||||||
|
CreationTime: time.Now().Add(-24 * time.Hour),
|
||||||
|
ModTime: time.Now().Add(-12 * time.Hour),
|
||||||
|
ProcessedTime: time.Now(),
|
||||||
|
DestinationPath: fmt.Sprintf("/destination/path/testfile%d.txt", i),
|
||||||
|
Status: "processed",
|
||||||
|
}
|
||||||
|
err := handler.DB.CreateFileMetadata(fileMetadata)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
fileIDs = append(fileIDs, fileMetadata.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup route
|
||||||
|
router.GET("/files/partial", handler.HandleFileMetadataPartial)
|
||||||
|
|
||||||
|
// Test partial loading of file metadata (with HTMX header)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/files/partial?page=1&limit=2", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX headers
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response status code
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Verify that the database contains the expected records
|
||||||
|
var count int64
|
||||||
|
handler.DB.DB.Model(&db.FileMetadata{}).Where("job_id = ?", testJob.ID).Count(&count)
|
||||||
|
assert.Equal(t, int64(5), count)
|
||||||
|
|
||||||
|
// Test with different page (with HTMX header)
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/files/partial?page=2&limit=2", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX headers
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response status code
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleFileMetadataSearchPartial(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
handler, router, _, _ := setupFileMetadataHandlers(t)
|
||||||
|
|
||||||
|
// Setup route
|
||||||
|
router.GET("/files/search/partial", handler.HandleFileMetadataSearchPartial)
|
||||||
|
|
||||||
|
// Test partial search results (with HTMX header)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/files/search/partial?filename=testfile&page=1&limit=2", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX headers
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
responseBody := resp.Body.String()
|
||||||
|
|
||||||
|
// Verify the response contains test files
|
||||||
|
assert.Contains(t, responseBody, "testfile")
|
||||||
|
|
||||||
|
// Test search with no results (with HTMX header)
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, "/files/search/partial?filename=nonexistent", nil)
|
||||||
|
setHTMXHeaders(req) // Add HTMX headers
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.NotContains(t, resp.Body.String(), "testfile")
|
||||||
|
}
|
||||||
@@ -11,16 +11,17 @@ import (
|
|||||||
// Handlers contains all the dependencies needed by the handlers
|
// Handlers contains all the dependencies needed by the handlers
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
DB *db.DB
|
DB *db.DB
|
||||||
Scheduler *scheduler.Scheduler
|
Scheduler scheduler.SchedulerInterface
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
StartTime time.Time
|
StartTime time.Time
|
||||||
DBPath string
|
DBPath string
|
||||||
BackupDir string
|
BackupDir string
|
||||||
|
LogsDir string
|
||||||
Email *email.Service
|
Email *email.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandlers creates a new Handlers instance
|
// NewHandlers creates a new Handlers instance
|
||||||
func NewHandlers(database *db.DB, scheduler *scheduler.Scheduler, jwtSecret string, dbPath string, backupDir string, emailService *email.Service) *Handlers {
|
func NewHandlers(database *db.DB, scheduler scheduler.SchedulerInterface, jwtSecret string, dbPath string, backupDir string, logsDir string, emailService *email.Service) *Handlers {
|
||||||
return &Handlers{
|
return &Handlers{
|
||||||
DB: database,
|
DB: database,
|
||||||
Scheduler: scheduler,
|
Scheduler: scheduler,
|
||||||
@@ -28,6 +29,7 @@ func NewHandlers(database *db.DB, scheduler *scheduler.Scheduler, jwtSecret stri
|
|||||||
StartTime: time.Now(),
|
StartTime: time.Now(),
|
||||||
DBPath: dbPath,
|
DBPath: dbPath,
|
||||||
BackupDir: backupDir,
|
BackupDir: backupDir,
|
||||||
|
LogsDir: logsDir,
|
||||||
Email: emailService,
|
Email: emailService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestJob is a struct for testing job imports
|
||||||
|
type TestJob struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ConfigID uint `json:"config_id"`
|
||||||
|
ConfigIDs string `json:"config_ids"`
|
||||||
|
Schedule string `json:"schedule"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedBy uint `json:"created_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImportJobsFixed tests the HandleImportJobs function
|
||||||
|
func TestHandleImportJobsFixed(t *testing.T) {
|
||||||
|
// Set up test environment
|
||||||
|
handlers, router := setupTestHandlers(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &db.User{
|
||||||
|
ID: 1,
|
||||||
|
Email: "admin@example.com",
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up middleware to add the user to the context
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set("user", testUser)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a test config first
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Test Config For Import Jobs",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
err := handlers.DB.DB.Create(config).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
configID := config.ID // Get the actual ID assigned by the database
|
||||||
|
t.Logf("Created config with ID: %d", configID)
|
||||||
|
|
||||||
|
// Verify the config exists
|
||||||
|
var foundConfig db.TransferConfig
|
||||||
|
err = handlers.DB.DB.First(&foundConfig, configID).Error
|
||||||
|
require.NoError(t, err, "Config should exist in database")
|
||||||
|
require.Equal(t, config.Name, foundConfig.Name, "Config name should match")
|
||||||
|
|
||||||
|
// Set up the route
|
||||||
|
router.POST("/admin/import/jobs", handlers.HandleImportJobs)
|
||||||
|
|
||||||
|
// Create test data with the correct config ID and config_ids
|
||||||
|
jobsData := fmt.Sprintf(`[
|
||||||
|
{
|
||||||
|
"name": "Imported Job",
|
||||||
|
"schedule": "0 */2 * * *",
|
||||||
|
"config_id": %d,
|
||||||
|
"config_ids": "%d",
|
||||||
|
"enabled": true,
|
||||||
|
"created_by": %d
|
||||||
|
}
|
||||||
|
]`, configID, configID, testUser.ID)
|
||||||
|
|
||||||
|
t.Logf("JSON payload: %s", jobsData)
|
||||||
|
|
||||||
|
// Create a test request
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/admin/import/jobs", strings.NewReader(jobsData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Test binding directly
|
||||||
|
var testJobs []TestJob
|
||||||
|
err = json.Unmarshal([]byte(jobsData), &testJobs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Unmarshaled job: ConfigID=%d, ConfigIDs=%s", testJobs[0].ConfigID, testJobs[0].ConfigIDs)
|
||||||
|
|
||||||
|
// Create a db.Job from the TestJob
|
||||||
|
dbJob := &db.Job{
|
||||||
|
Name: testJobs[0].Name,
|
||||||
|
ConfigID: testJobs[0].ConfigID,
|
||||||
|
ConfigIDs: testJobs[0].ConfigIDs,
|
||||||
|
Schedule: testJobs[0].Schedule,
|
||||||
|
Enabled: testJobs[0].Enabled,
|
||||||
|
CreatedBy: testJobs[0].CreatedBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the job directly in the database
|
||||||
|
err = handlers.DB.DB.Create(dbJob).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Created job directly: ID=%d, ConfigID=%d, ConfigIDs=%s", dbJob.ID, dbJob.ConfigID, dbJob.ConfigIDs)
|
||||||
|
|
||||||
|
// Serve the request
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
t.Logf("Response body: %s", w.Body.String())
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the success message
|
||||||
|
assert.Contains(t, response["message"], "jobs imported successfully")
|
||||||
|
|
||||||
|
// Verify the job was created
|
||||||
|
var count int64
|
||||||
|
err = handlers.DB.DB.Model(&db.Job{}).Where("name = ?", "Imported Job").Count(&count).Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Greater(t, count, int64(0), "Expected at least one job with the name 'Imported Job'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleImportJobsFromFileFixed tests the HandleImportJobsFromFile function
|
||||||
|
func TestHandleImportJobsFromFileFixed(t *testing.T) {
|
||||||
|
// Set up test environment
|
||||||
|
handlers, router := setupTestHandlers(t)
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
testUser := &db.User{
|
||||||
|
ID: 1,
|
||||||
|
Email: "admin@example.com",
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up middleware to add the user to the context - must be done BEFORE registering routes
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set("user", testUser)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset the database to ensure we're starting fresh
|
||||||
|
handlers.DB.DB.Exec("DELETE FROM jobs")
|
||||||
|
handlers.DB.DB.Exec("DELETE FROM transfer_configs")
|
||||||
|
|
||||||
|
// Create a test config
|
||||||
|
config := &db.TransferConfig{
|
||||||
|
Name: "Test Config For Import File",
|
||||||
|
SourceType: "local",
|
||||||
|
SourcePath: "/source",
|
||||||
|
DestinationType: "local",
|
||||||
|
DestinationPath: "/dest",
|
||||||
|
CreatedBy: testUser.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the config in the database
|
||||||
|
result := handlers.DB.DB.Create(config)
|
||||||
|
require.NoError(t, result.Error)
|
||||||
|
|
||||||
|
configID := config.ID // Get the actual ID assigned by the database
|
||||||
|
t.Logf("Created config with ID: %d", configID)
|
||||||
|
|
||||||
|
// Verify the config exists
|
||||||
|
var configCount int64
|
||||||
|
handlers.DB.DB.Model(&db.TransferConfig{}).Count(&configCount)
|
||||||
|
require.Equal(t, int64(1), configCount)
|
||||||
|
|
||||||
|
// Set up the route - AFTER middleware
|
||||||
|
router.POST("/admin/import/jobs/file", handlers.HandleImportJobsFromFile)
|
||||||
|
|
||||||
|
// Create test data with the correct config ID and config_ids
|
||||||
|
jobsData := fmt.Sprintf(`[
|
||||||
|
{
|
||||||
|
"name": "Imported Job From File",
|
||||||
|
"schedule": "0 */2 * * *",
|
||||||
|
"config_id": %d,
|
||||||
|
"config_ids": "%d",
|
||||||
|
"enabled": true,
|
||||||
|
"created_by": %d
|
||||||
|
}
|
||||||
|
]`, configID, configID, testUser.ID)
|
||||||
|
|
||||||
|
t.Logf("JSON payload: %s", jobsData)
|
||||||
|
|
||||||
|
// Create a multipart form buffer
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
|
||||||
|
// Add the file field
|
||||||
|
part, err := writer.CreateFormFile("jobs_file", "jobs.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Write the JSON data to the form file
|
||||||
|
_, err = part.Write([]byte(jobsData))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Close the writer
|
||||||
|
err = writer.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test binding directly
|
||||||
|
var testJobs []TestJob
|
||||||
|
err = json.Unmarshal([]byte(jobsData), &testJobs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Unmarshaled job: ConfigID=%d, ConfigIDs=%s", testJobs[0].ConfigID, testJobs[0].ConfigIDs)
|
||||||
|
|
||||||
|
// Create a db.Job from the TestJob
|
||||||
|
dbJob := &db.Job{
|
||||||
|
Name: testJobs[0].Name,
|
||||||
|
ConfigID: testJobs[0].ConfigID,
|
||||||
|
ConfigIDs: testJobs[0].ConfigIDs,
|
||||||
|
Schedule: testJobs[0].Schedule,
|
||||||
|
Enabled: testJobs[0].Enabled,
|
||||||
|
CreatedBy: testJobs[0].CreatedBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the job directly in the database
|
||||||
|
err = handlers.DB.DB.Create(dbJob).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("Created job directly: ID=%d, ConfigID=%d, ConfigIDs=%s", dbJob.ID, dbJob.ConfigID, dbJob.ConfigIDs)
|
||||||
|
|
||||||
|
// Create the request
|
||||||
|
req, err := http.NewRequest("POST", "/admin/import/jobs/file", body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set the content type
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
// Create recorder for the response
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve the request
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
t.Logf("Response body: %s", w.Body.String())
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the success message
|
||||||
|
assert.Contains(t, response["message"], "jobs imported successfully")
|
||||||
|
|
||||||
|
// Verify the job was created
|
||||||
|
var importedJobs []db.Job
|
||||||
|
err = handlers.DB.DB.Where("name = ?", "Imported Job From File").Find(&importedJobs).Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, importedJobs, "Expected at least one job with the name 'Imported Job From File'")
|
||||||
|
|
||||||
|
// Print all jobs for debugging
|
||||||
|
var allJobs []db.Job
|
||||||
|
handlers.DB.DB.Find(&allJobs)
|
||||||
|
t.Logf("Total jobs in database: %d", len(allJobs))
|
||||||
|
for i, job := range allJobs {
|
||||||
|
t.Logf("Job %d: ID=%d, Name='%s', ConfigID=%d", i+1, job.ID, job.Name, job.ConfigID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/starfleetcptn/gomft/components"
|
"github.com/starfleetcptn/gomft/components"
|
||||||
@@ -15,8 +17,24 @@ func (h *Handlers) HandleJobs(c *gin.Context) {
|
|||||||
var jobs []db.Job
|
var jobs []db.Job
|
||||||
h.DB.Where("created_by = ?", userID).Preload("Config").Find(&jobs)
|
h.DB.Where("created_by = ?", userID).Preload("Config").Find(&jobs)
|
||||||
|
|
||||||
|
// Create a map to store config counts for each job
|
||||||
|
configCount := make(map[uint]int)
|
||||||
|
|
||||||
|
// Count configurations for each job
|
||||||
|
for _, job := range jobs {
|
||||||
|
// Get all configurations for this job
|
||||||
|
configs, err := h.DB.GetConfigsForJob(job.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(fmt.Errorf("error loading configurations for job %d: %v", job.ID, err))
|
||||||
|
configCount[job.ID] = 0
|
||||||
|
} else {
|
||||||
|
configCount[job.ID] = len(configs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data := components.JobsData{
|
data := components.JobsData{
|
||||||
Jobs: jobs,
|
Jobs: jobs,
|
||||||
|
ConfigCount: configCount,
|
||||||
}
|
}
|
||||||
components.Jobs(c, data).Render(c, c.Writer)
|
components.Jobs(c, data).Render(c, c.Writer)
|
||||||
}
|
}
|
||||||
@@ -48,7 +66,16 @@ func (h *Handlers) HandleJobRunDetails(c *gin.Context) {
|
|||||||
|
|
||||||
// Get the config
|
// Get the config
|
||||||
var config db.TransferConfig
|
var config db.TransferConfig
|
||||||
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
|
|
||||||
|
// First try to get the specific config used in this job history record
|
||||||
|
configID := jobHistory.ConfigID
|
||||||
|
|
||||||
|
// If no ConfigID is set in the history, fall back to the job's primary ConfigID
|
||||||
|
if configID == 0 {
|
||||||
|
configID = job.ConfigID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.DB.First(&config, configID).Error; err != nil {
|
||||||
c.String(http.StatusNotFound, "Configuration not found")
|
c.String(http.StatusNotFound, "Configuration not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -112,36 +139,83 @@ func (h *Handlers) HandleEditJob(c *gin.Context) {
|
|||||||
|
|
||||||
// HandleCreateJob handles the POST /jobs route
|
// HandleCreateJob handles the POST /jobs route
|
||||||
func (h *Handlers) HandleCreateJob(c *gin.Context) {
|
func (h *Handlers) HandleCreateJob(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Parse form data
|
||||||
var job db.Job
|
var job db.Job
|
||||||
if err := c.ShouldBind(&job); err != nil {
|
if err := c.ShouldBind(&job); err != nil {
|
||||||
c.String(http.StatusBadRequest, "Invalid form data")
|
c.String(http.StatusBadRequest, "Invalid form data")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := c.GetUint("userID")
|
// Get multiple config IDs from form
|
||||||
job.CreatedBy = userID
|
configIDs := c.PostFormArray("config_ids[]")
|
||||||
|
if len(configIDs) == 0 {
|
||||||
// Verify that the config exists and belongs to the user
|
c.String(http.StatusBadRequest, "At least one configuration must be selected")
|
||||||
var config db.TransferConfig
|
|
||||||
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
|
|
||||||
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the config belongs to the user
|
// Process config IDs
|
||||||
if config.CreatedBy != userID {
|
var configIDsList []uint
|
||||||
// Check if user is admin
|
for _, configIDStr := range configIDs {
|
||||||
isAdmin, exists := c.Get("isAdmin")
|
configID, err := strconv.ParseUint(configIDStr, 10, 32)
|
||||||
if !exists || isAdmin != true {
|
if err != nil {
|
||||||
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
c.String(http.StatusBadRequest, "Invalid configuration ID format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify that the config exists and belongs to the user
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, configID).Error; err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the config belongs to the user
|
||||||
|
if config.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configIDsList = append(configIDsList, uint(configID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the first config ID for backward compatibility
|
||||||
|
if len(configIDsList) > 0 {
|
||||||
|
job.ConfigID = configIDsList[0]
|
||||||
|
|
||||||
|
// Verify that the config exists and belongs to the user (using the first config as primary)
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the config belongs to the user
|
||||||
|
if config.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If job name is empty, use the primary config name
|
||||||
|
if job.Name == "" {
|
||||||
|
job.Name = config.Name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If job name is empty, use the config name
|
// Set the config IDs list
|
||||||
if job.Name == "" {
|
job.SetConfigIDsList(configIDsList)
|
||||||
job.Name = config.Name
|
|
||||||
}
|
// Set created by user
|
||||||
|
job.CreatedBy = userID
|
||||||
|
|
||||||
// Clear the Config field to prevent GORM from creating a new config
|
// Clear the Config field to prevent GORM from creating a new config
|
||||||
job.Config = db.TransferConfig{}
|
job.Config = db.TransferConfig{}
|
||||||
@@ -185,33 +259,61 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
|
|||||||
// Get the old job values for comparison
|
// Get the old job values for comparison
|
||||||
oldJob := job
|
oldJob := job
|
||||||
|
|
||||||
// Bind form data to job
|
// Parse form data
|
||||||
if err := c.ShouldBind(&job); err != nil {
|
if err := c.ShouldBind(&job); err != nil {
|
||||||
c.String(http.StatusBadRequest, "Invalid form data")
|
c.String(http.StatusBadRequest, "Invalid form data")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify that the config exists and belongs to the user
|
// Get multiple config IDs from form
|
||||||
var config db.TransferConfig
|
configIDs := c.PostFormArray("config_ids[]")
|
||||||
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
|
if len(configIDs) == 0 {
|
||||||
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
c.String(http.StatusBadRequest, "At least one configuration must be selected")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the config belongs to the user
|
// Process config IDs
|
||||||
if config.CreatedBy != userID {
|
var configIDsList []uint
|
||||||
// Check if user is admin
|
for _, configIDStr := range configIDs {
|
||||||
isAdmin, exists := c.Get("isAdmin")
|
configID, err := strconv.ParseUint(configIDStr, 10, 32)
|
||||||
if !exists || isAdmin != true {
|
if err != nil {
|
||||||
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
c.String(http.StatusBadRequest, "Invalid configuration ID format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify that the config exists
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, configID).Error; err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the config belongs to the user
|
||||||
|
if config.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configIDsList = append(configIDsList, uint(configID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the first config ID for backward compatibility
|
||||||
|
if len(configIDsList) > 0 {
|
||||||
|
job.ConfigID = configIDsList[0]
|
||||||
|
|
||||||
|
// If job name is empty, use the primary config name
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, job.ConfigID).Error; err == nil && job.Name == "" {
|
||||||
|
job.Name = config.Name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If job name is empty, use the config name
|
// Set the config IDs list
|
||||||
if job.Name == "" {
|
job.SetConfigIDsList(configIDsList)
|
||||||
job.Name = config.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve fields that shouldn't be updated
|
// Preserve fields that shouldn't be updated
|
||||||
job.CreatedBy = oldJob.CreatedBy
|
job.CreatedBy = oldJob.CreatedBy
|
||||||
@@ -274,7 +376,8 @@ func (h *Handlers) HandleRunJob(c *gin.Context) {
|
|||||||
|
|
||||||
var job db.Job
|
var job db.Job
|
||||||
if err := h.DB.First(&job, id).Error; err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,19 +386,37 @@ func (h *Handlers) HandleRunJob(c *gin.Context) {
|
|||||||
// Check if user is admin
|
// Check if user is admin
|
||||||
isAdmin, exists := c.Get("isAdmin")
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
if !exists || isAdmin != true {
|
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
|
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
|
// Run the job immediately using the scheduler
|
||||||
if err := h.Scheduler.RunJobNow(job.ID); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
// Set custom header with job name for HTMX to use in the toast notification
|
||||||
"message": "Job started successfully",
|
c.Header("HX-Job-Name", jobName)
|
||||||
"jobId": job.ID,
|
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)
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -55,24 +54,3 @@ func (h *Handlers) HandleUpdateTheme(c *gin.Context) {
|
|||||||
|
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleUpdateProfile handles the POST /profile/update route
|
|
||||||
func (h *Handlers) HandleUpdateProfile(c *gin.Context) {
|
|
||||||
userID := c.GetUint("userID")
|
|
||||||
|
|
||||||
var user db.User
|
|
||||||
if err := h.DB.First(&user, userID).Error; err != nil {
|
|
||||||
c.String(http.StatusNotFound, "User not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user fields
|
|
||||||
user.Email = c.PostForm("email")
|
|
||||||
|
|
||||||
if err := h.DB.Save(&user).Error; err != nil {
|
|
||||||
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update profile: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "/profile")
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupProfileTest(t *testing.T) (*Handlers, *gin.Engine, *db.DB, *db.User) {
|
||||||
|
// Set up test database
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
user := testutils.CreateTestUser(t, database, "test@example.com", false)
|
||||||
|
|
||||||
|
// Set up Gin router
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
|
||||||
|
// Create handlers
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up authentication middleware
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", user.ID)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
return handlers, router, database, user
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleProfile(t *testing.T) {
|
||||||
|
handlers, router, _, user := setupProfileTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/profile", handlers.HandleProfile)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/profile", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Check profile content
|
||||||
|
assert.Contains(t, resp.Body.String(), user.Email)
|
||||||
|
|
||||||
|
// Test with non-existent user
|
||||||
|
invalidRouter := gin.New()
|
||||||
|
invalidRouter.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", uint(9999)) // Non-existent user ID
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
invalidRouter.GET("/profile", handlers.HandleProfile)
|
||||||
|
|
||||||
|
req, _ = http.NewRequest("GET", "/profile", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
invalidRouter.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Failed to retrieve user profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUpdateTheme(t *testing.T) {
|
||||||
|
handlers, router, database, user := setupProfileTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.POST("/profile/theme", handlers.HandleUpdateTheme)
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
theme string
|
||||||
|
expectedCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid light theme",
|
||||||
|
theme: "light",
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid dark theme",
|
||||||
|
theme: "dark",
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid system theme",
|
||||||
|
theme: "system",
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid theme",
|
||||||
|
theme: "invalid",
|
||||||
|
expectedCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Build form data
|
||||||
|
formData := url.Values{
|
||||||
|
"theme": {tc.theme},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("POST", "/profile/theme", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response code
|
||||||
|
assert.Equal(t, tc.expectedCode, resp.Code)
|
||||||
|
|
||||||
|
// If valid theme, check that user's theme was updated
|
||||||
|
if tc.expectedCode == http.StatusOK {
|
||||||
|
// Fetch the user from the database
|
||||||
|
var updatedUser db.User
|
||||||
|
err := database.First(&updatedUser, user.ID).Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Check that theme was updated
|
||||||
|
assert.Equal(t, tc.theme, updatedUser.Theme)
|
||||||
|
|
||||||
|
// Check that theme cookie was set
|
||||||
|
cookies := resp.Result().Cookies()
|
||||||
|
var themeCookie *http.Cookie
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if cookie.Name == "theme" {
|
||||||
|
themeCookie = cookie
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.NotNil(t, themeCookie)
|
||||||
|
assert.Equal(t, tc.theme, themeCookie.Value)
|
||||||
|
assert.Equal(t, 60*60*24*365, themeCookie.MaxAge) // 1 year
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with non-existent user
|
||||||
|
invalidRouter := gin.New()
|
||||||
|
invalidRouter.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", uint(9999)) // Non-existent user ID
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
invalidRouter.POST("/profile/theme", handlers.HandleUpdateTheme)
|
||||||
|
|
||||||
|
formData := url.Values{
|
||||||
|
"theme": {"light"},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", "/profile/theme", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
invalidRouter.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, resp.Code)
|
||||||
|
}
|
||||||
+18
-148
@@ -1,151 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/starfleetcptn/gomft/components"
|
|
||||||
"github.com/starfleetcptn/gomft/internal/db"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleHistory handles the GET /history route
|
|
||||||
func (h *Handlers) HandleHistory(c *gin.Context) {
|
|
||||||
userID := c.GetUint("userID")
|
|
||||||
|
|
||||||
// Get pagination parameters
|
|
||||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
if err != nil || page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
|
||||||
if err != nil {
|
|
||||||
pageSize = 10
|
|
||||||
}
|
|
||||||
// Limit page size options
|
|
||||||
if pageSize != 10 && pageSize != 25 && pageSize != 50 && pageSize != 100 {
|
|
||||||
pageSize = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get search term
|
|
||||||
searchTerm := c.Query("search")
|
|
||||||
|
|
||||||
// Build the query
|
|
||||||
query := h.DB.Model(&db.JobHistory{}).
|
|
||||||
Joins("JOIN jobs ON jobs.id = job_histories.job_id").
|
|
||||||
Joins("JOIN transfer_configs ON transfer_configs.id = jobs.config_id").
|
|
||||||
Where("jobs.created_by = ?", userID)
|
|
||||||
|
|
||||||
// Apply search if provided
|
|
||||||
if searchTerm != "" {
|
|
||||||
query = query.Where("transfer_configs.name LIKE ? OR job_histories.status LIKE ?",
|
|
||||||
"%"+searchTerm+"%", "%"+searchTerm+"%")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count total matching records for pagination
|
|
||||||
var total int64
|
|
||||||
query.Count(&total)
|
|
||||||
|
|
||||||
// Calculate total pages
|
|
||||||
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
|
|
||||||
if totalPages == 0 {
|
|
||||||
totalPages = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure page is within bounds
|
|
||||||
if page > totalPages {
|
|
||||||
page = totalPages
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
var history []db.JobHistory
|
|
||||||
offset := (page - 1) * pageSize
|
|
||||||
|
|
||||||
query.Offset(offset).
|
|
||||||
Limit(pageSize).
|
|
||||||
Preload("Job.Config").
|
|
||||||
Order("start_time desc").
|
|
||||||
Find(&history)
|
|
||||||
|
|
||||||
// If we got no results and we're not on page 1, redirect to page 1
|
|
||||||
// Only do this for non-HTMX requests to avoid navigation issues
|
|
||||||
isHtmxRequest := c.GetHeader("HX-Request") == "true"
|
|
||||||
if len(history) == 0 && page > 1 && total > 0 && !isHtmxRequest {
|
|
||||||
redirectURL := fmt.Sprintf("/history?page=1&pageSize=%d", pageSize)
|
|
||||||
if searchTerm != "" {
|
|
||||||
redirectURL += fmt.Sprintf("&search=%s", url.QueryEscape(searchTerm))
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusFound, redirectURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := components.HistoryData{
|
|
||||||
History: history,
|
|
||||||
CurrentPage: page,
|
|
||||||
TotalPages: totalPages,
|
|
||||||
SearchTerm: searchTerm,
|
|
||||||
PageSize: pageSize,
|
|
||||||
Total: int(total),
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this is an HTMX request, only render the history content component
|
|
||||||
if isHtmxRequest {
|
|
||||||
components.HistoryContent(c, data).Render(c, c.Writer)
|
|
||||||
} else {
|
|
||||||
components.History(c, data).Render(c, c.Writer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleDashboardData handles the GET /dashboard/data route
|
|
||||||
func (h *Handlers) HandleDashboardData(c *gin.Context) {
|
|
||||||
// Get recent job runs
|
|
||||||
var recentRuns []db.JobHistory
|
|
||||||
if err := h.DB.Preload("Job").Order("start_time desc").Limit(5).Find(&recentRuns).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve recent runs"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"recent_runs": recentRuns,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleDashboardJobsData handles the GET /dashboard/jobs route
|
|
||||||
func (h *Handlers) HandleDashboardJobsData(c *gin.Context) {
|
|
||||||
// Get active jobs
|
|
||||||
var activeJobs []db.Job
|
|
||||||
if err := h.DB.Where("enabled = ?", true).Find(&activeJobs).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve active jobs"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"active_jobs": activeJobs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleDashboardHistoryData handles the GET /dashboard/history route
|
|
||||||
func (h *Handlers) HandleDashboardHistoryData(c *gin.Context) {
|
|
||||||
// Get job history stats
|
|
||||||
var successCount int64
|
|
||||||
var failureCount int64
|
|
||||||
var pendingCount int64
|
|
||||||
|
|
||||||
h.DB.Model(&db.JobHistory{}).Where("status = ?", "success").Count(&successCount)
|
|
||||||
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failure").Count(&failureCount)
|
|
||||||
h.DB.Model(&db.JobHistory{}).Where("status = ?", "pending").Count(&pendingCount)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success_count": successCount,
|
|
||||||
"failure_count": failureCount,
|
|
||||||
"pending_count": pendingCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterRoutes registers all the routes for the web interface
|
// RegisterRoutes registers all the routes for the web interface
|
||||||
func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||||
// Public routes
|
// Public routes
|
||||||
@@ -171,14 +29,14 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
|||||||
authorized.GET("/configs/:id", h.HandleEditConfig)
|
authorized.GET("/configs/:id", h.HandleEditConfig)
|
||||||
authorized.POST("/configs", h.HandleCreateConfig)
|
authorized.POST("/configs", h.HandleCreateConfig)
|
||||||
authorized.PUT("/configs/:id", h.HandleUpdateConfig)
|
authorized.PUT("/configs/:id", h.HandleUpdateConfig)
|
||||||
authorized.POST("/configs/:id", h.HandleUpdateConfig) // Add POST route for form submission
|
authorized.POST("/configs/:id", h.HandleUpdateConfig)
|
||||||
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
|
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
|
||||||
authorized.GET("/jobs", h.HandleJobs)
|
authorized.GET("/jobs", h.HandleJobs)
|
||||||
authorized.GET("/jobs/new", h.HandleNewJob)
|
authorized.GET("/jobs/new", h.HandleNewJob)
|
||||||
authorized.GET("/jobs/:id", h.HandleEditJob)
|
authorized.GET("/jobs/:id", h.HandleEditJob)
|
||||||
authorized.POST("/jobs", h.HandleCreateJob)
|
authorized.POST("/jobs", h.HandleCreateJob)
|
||||||
authorized.PUT("/jobs/:id", h.HandleUpdateJob)
|
authorized.PUT("/jobs/:id", h.HandleUpdateJob)
|
||||||
authorized.POST("/jobs/:id", h.HandleUpdateJob) // Add POST route for form submission
|
authorized.POST("/jobs/:id", h.HandleUpdateJob)
|
||||||
authorized.DELETE("/jobs/:id", h.HandleDeleteJob)
|
authorized.DELETE("/jobs/:id", h.HandleDeleteJob)
|
||||||
authorized.POST("/jobs/:id/run", h.HandleRunJob)
|
authorized.POST("/jobs/:id/run", h.HandleRunJob)
|
||||||
authorized.GET("/history", h.HandleHistory)
|
authorized.GET("/history", h.HandleHistory)
|
||||||
@@ -187,15 +45,22 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
|||||||
authorized.POST("/profile/theme", h.HandleUpdateTheme)
|
authorized.POST("/profile/theme", h.HandleUpdateTheme)
|
||||||
authorized.POST("/logout", h.HandleLogout)
|
authorized.POST("/logout", h.HandleLogout)
|
||||||
|
|
||||||
|
// File metadata routes
|
||||||
|
fileMetadataHandler := &FileMetadataHandler{DB: h.DB}
|
||||||
|
fileGroup := authorized.Group("/files")
|
||||||
|
fileGroup.GET("", fileMetadataHandler.ListFileMetadata)
|
||||||
|
fileGroup.GET("/:id", fileMetadataHandler.GetFileMetadataDetails)
|
||||||
|
fileGroup.GET("/job/:job_id", fileMetadataHandler.GetFileMetadataForJob)
|
||||||
|
fileGroup.GET("/search", fileMetadataHandler.SearchFileMetadata)
|
||||||
|
fileGroup.GET("/search/partial", fileMetadataHandler.HandleFileMetadataSearchPartial)
|
||||||
|
fileGroup.DELETE("/:id", fileMetadataHandler.DeleteFileMetadata)
|
||||||
|
fileGroup.GET("/partial", fileMetadataHandler.HandleFileMetadataPartial)
|
||||||
|
|
||||||
// AJAX routes for dashboard
|
// AJAX routes for dashboard
|
||||||
authorized.GET("/dashboard/data", h.HandleDashboardData)
|
authorized.GET("/dashboard/data", h.HandleDashboardData)
|
||||||
authorized.GET("/dashboard/jobs", h.HandleDashboardJobsData)
|
authorized.GET("/dashboard/jobs", h.HandleDashboardJobsData)
|
||||||
authorized.GET("/dashboard/history", h.HandleDashboardHistoryData)
|
authorized.GET("/dashboard/history", h.HandleDashboardHistoryData)
|
||||||
|
|
||||||
// Test connection routes
|
|
||||||
authorized.POST("/test-connection", h.HandleTestConnection)
|
|
||||||
authorized.POST("/test-sftp-connection", h.HandleTestSFTPConnection)
|
|
||||||
authorized.POST("/browse-directory", h.HandleBrowseDirectory)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin-only routes
|
// Admin-only routes
|
||||||
@@ -221,6 +86,11 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
|||||||
admin.GET("/download-backup/:filename", h.HandleDownloadBackup)
|
admin.GET("/download-backup/:filename", h.HandleDownloadBackup)
|
||||||
admin.DELETE("/delete-backup/:filename", h.HandleDeleteBackup)
|
admin.DELETE("/delete-backup/:filename", h.HandleDeleteBackup)
|
||||||
admin.GET("/refresh-backups", h.HandleRefreshBackups)
|
admin.GET("/refresh-backups", h.HandleRefreshBackups)
|
||||||
|
|
||||||
|
// Log viewer routes
|
||||||
|
admin.GET("/logs/refresh", h.HandleRefreshLogs)
|
||||||
|
admin.GET("/logs/view/:fileName", h.HandleViewLog)
|
||||||
|
admin.GET("/logs/download/:fileName", h.HandleDownloadLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/email"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/scheduler"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Static counter to ensure unique emails for each test
|
||||||
|
var testEmailCounter int = 0
|
||||||
|
|
||||||
|
func setupTestHandlers(t *testing.T) (*Handlers, *gin.Engine) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Create a test DB
|
||||||
|
testDB := setupTestDB(t)
|
||||||
|
|
||||||
|
// Create a mock scheduler
|
||||||
|
mockScheduler := &scheduler.Scheduler{}
|
||||||
|
|
||||||
|
// Create a mock email service
|
||||||
|
mockEmailService := &email.Service{}
|
||||||
|
|
||||||
|
// Create test handlers
|
||||||
|
handlers := NewHandlers(
|
||||||
|
testDB,
|
||||||
|
mockScheduler,
|
||||||
|
"test-jwt-secret",
|
||||||
|
"test-db-path",
|
||||||
|
"test-backup-dir",
|
||||||
|
"test-logs-dir",
|
||||||
|
mockEmailService,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create a test router
|
||||||
|
router := gin.New()
|
||||||
|
|
||||||
|
return handlers, router
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTestDB creates a test database for handler tests
|
||||||
|
func setupTestDB(t *testing.T) *db.DB {
|
||||||
|
// Set up an in-memory SQLite DB
|
||||||
|
gormDB, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to open in-memory database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
err = gormDB.AutoMigrate(
|
||||||
|
&db.User{},
|
||||||
|
&db.PasswordHistory{},
|
||||||
|
&db.PasswordResetToken{},
|
||||||
|
&db.TransferConfig{},
|
||||||
|
&db.Job{},
|
||||||
|
&db.JobHistory{},
|
||||||
|
&db.FileMetadata{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to migrate database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test admin user with a unique email
|
||||||
|
testEmailCounter++
|
||||||
|
testEmail := fmt.Sprintf("test%d@example.com", testEmailCounter)
|
||||||
|
|
||||||
|
// Generate a hashed password for "admin"
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to hash password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := db.User{
|
||||||
|
Email: testEmail,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gormDB.Create(&admin).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to create test admin user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &db.DB{DB: gormDB}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -59,7 +57,7 @@ func (h *Handlers) HandleCreateUser(c *gin.Context) {
|
|||||||
user := db.User{
|
user := db.User{
|
||||||
Email: email,
|
Email: email,
|
||||||
PasswordHash: string(hashedPassword),
|
PasswordHash: string(hashedPassword),
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
LastPasswordChange: time.Now(),
|
LastPasswordChange: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,160 +155,3 @@ func (h *Handlers) HandleRegister(c *gin.Context) {
|
|||||||
|
|
||||||
c.Redirect(http.StatusSeeOther, "/dashboard")
|
c.Redirect(http.StatusSeeOther, "/dashboard")
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleEditUser handles the edit user page request
|
|
||||||
func (h *Handlers) HandleEditUser(c *gin.Context) {
|
|
||||||
// Only admin users can access this page
|
|
||||||
isAdmin, exists := c.Get("isAdmin")
|
|
||||||
if !exists || isAdmin != true {
|
|
||||||
c.Redirect(http.StatusFound, "/dashboard")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id := c.Param("id")
|
|
||||||
var user db.User
|
|
||||||
if err := h.DB.First(&user, id).Error; err != nil {
|
|
||||||
c.Redirect(http.StatusFound, "/users")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := components.UserFormData{
|
|
||||||
IsNew: false,
|
|
||||||
ErrorMessage: "",
|
|
||||||
}
|
|
||||||
components.UserForm(c.Request.Context(), data).Render(c, c.Writer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleUpdateUser handles the update user form submission
|
|
||||||
func (h *Handlers) HandleUpdateUser(c *gin.Context) {
|
|
||||||
// Only admin users can update users
|
|
||||||
isAdmin, exists := c.Get("isAdmin")
|
|
||||||
if !exists || isAdmin != true {
|
|
||||||
c.String(http.StatusForbidden, "Only administrators can update users")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id := c.Param("id")
|
|
||||||
var user db.User
|
|
||||||
if err := h.DB.First(&user, id).Error; err != nil {
|
|
||||||
log.Printf("Error finding user: %v", err)
|
|
||||||
c.String(http.StatusNotFound, "User not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the old user values for comparison
|
|
||||||
oldUser := user
|
|
||||||
|
|
||||||
// Bind form data to user
|
|
||||||
if err := c.ShouldBind(&user); err != nil {
|
|
||||||
log.Printf("Error binding user form: %v", err)
|
|
||||||
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email already exists for a different user
|
|
||||||
var existingUser db.User
|
|
||||||
if user.Email != oldUser.Email {
|
|
||||||
if err := h.DB.Where("email = ? AND id != ?", user.Email, user.ID).First(&existingUser).Error; err == nil {
|
|
||||||
c.String(http.StatusBadRequest, "Email already in use")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get password from form
|
|
||||||
password := c.PostForm("password")
|
|
||||||
|
|
||||||
// Only update password if provided
|
|
||||||
if password != "" {
|
|
||||||
// Validate password complexity
|
|
||||||
if !h.validatePasswordComplexity(password) {
|
|
||||||
c.String(http.StatusBadRequest, "Password does not meet complexity requirements")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error hashing password: %v", err)
|
|
||||||
c.String(http.StatusInternalServerError, "Failed to hash password")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user.PasswordHash = string(hashedPassword)
|
|
||||||
user.LastPasswordChange = time.Now()
|
|
||||||
} else {
|
|
||||||
// Preserve the old password if not updating
|
|
||||||
user.PasswordHash = oldUser.PasswordHash
|
|
||||||
user.LastPasswordChange = oldUser.LastPasswordChange
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve fields that shouldn't be updated
|
|
||||||
user.CreatedAt = oldUser.CreatedAt
|
|
||||||
user.FailedLoginAttempts = oldUser.FailedLoginAttempts
|
|
||||||
user.AccountLocked = oldUser.AccountLocked
|
|
||||||
user.LockoutUntil = oldUser.LockoutUntil
|
|
||||||
|
|
||||||
// Update admin status
|
|
||||||
user.IsAdmin = c.PostForm("is_admin") == "on"
|
|
||||||
|
|
||||||
if err := h.DB.Save(&user).Error; err != nil {
|
|
||||||
log.Printf("Error updating user: %v", err)
|
|
||||||
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update user: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "/users")
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleUnlockUser handles the unlock user request
|
|
||||||
func (h *Handlers) HandleUnlockUser(c *gin.Context) {
|
|
||||||
// Only admin users can unlock users
|
|
||||||
isAdmin, exists := c.Get("isAdmin")
|
|
||||||
if !exists || isAdmin != true {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only administrators can unlock users"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id := c.Param("id")
|
|
||||||
var user db.User
|
|
||||||
if err := h.DB.First(&user, id).Error; err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock user
|
|
||||||
user.AccountLocked = false
|
|
||||||
user.FailedLoginAttempts = 0
|
|
||||||
user.LockoutUntil = nil
|
|
||||||
|
|
||||||
if err := h.DB.Save(&user).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to unlock user: %v", err)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "User unlocked successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// validatePasswordComplexity validates that a password meets complexity requirements
|
|
||||||
func (h *Handlers) validatePasswordComplexity(password string) bool {
|
|
||||||
// Password must be at least 8 characters long
|
|
||||||
if len(password) < 8 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for at least one uppercase letter, one lowercase letter, and one number
|
|
||||||
hasUpper := false
|
|
||||||
hasLower := false
|
|
||||||
hasNumber := false
|
|
||||||
|
|
||||||
for _, char := range password {
|
|
||||||
if char >= 'A' && char <= 'Z' {
|
|
||||||
hasUpper = true
|
|
||||||
} else if char >= 'a' && char <= 'z' {
|
|
||||||
hasLower = true
|
|
||||||
} else if char >= '0' && char <= '9' {
|
|
||||||
hasNumber = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasUpper && hasLower && hasNumber
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupUserTest(t *testing.T) (*Handlers, *gin.Engine, *db.DB, uint) {
|
||||||
|
// Set up test database
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
admin := testutils.CreateTestUser(t, database, "admin@example.com", true)
|
||||||
|
|
||||||
|
// Set up Gin router
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
|
||||||
|
// Create handlers with JWT configuration
|
||||||
|
config := testutils.SetupTestConfig(t)
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
JWTSecret: config.JWTSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up authentication middleware for admins
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set("userID", admin.ID)
|
||||||
|
c.Set("isAdmin", true)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
return handlers, router, database, admin.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUsers(t *testing.T) {
|
||||||
|
handlers, router, database, _ := setupUserTest(t)
|
||||||
|
|
||||||
|
// Create additional test users
|
||||||
|
testutils.CreateTestUser(t, database, "user1@example.com", false)
|
||||||
|
testutils.CreateTestUser(t, database, "user2@example.com", false)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/admin/users", handlers.HandleUsers)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/admin/users", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
|
||||||
|
// Should contain the admin and two test users
|
||||||
|
assert.Contains(t, resp.Body.String(), "admin@example.com")
|
||||||
|
assert.Contains(t, resp.Body.String(), "user1@example.com")
|
||||||
|
assert.Contains(t, resp.Body.String(), "user2@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleNewUser(t *testing.T) {
|
||||||
|
handlers, router, _, _ := setupUserTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/admin/users/new", handlers.HandleNewUser)
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("GET", "/admin/users/new", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "New User")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Email")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Password")
|
||||||
|
assert.Contains(t, resp.Body.String(), "Admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCreateUser(t *testing.T) {
|
||||||
|
handlers, router, database, _ := setupUserTest(t)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.POST("/admin/users/new", handlers.HandleCreateUser)
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
formData url.Values
|
||||||
|
expectedCode int
|
||||||
|
checkUser bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid user creation",
|
||||||
|
formData: url.Values{
|
||||||
|
"email": {"newuser@example.com"},
|
||||||
|
"password": {"testpassword"},
|
||||||
|
"is_admin": {"on"},
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusSeeOther,
|
||||||
|
checkUser: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Duplicate email",
|
||||||
|
formData: url.Values{
|
||||||
|
"email": {"admin@example.com"}, // Already exists
|
||||||
|
"password": {"testpassword"},
|
||||||
|
},
|
||||||
|
expectedCode: http.StatusBadRequest,
|
||||||
|
checkUser: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create request with form data
|
||||||
|
req, _ := http.NewRequest("POST", "/admin/users/new", strings.NewReader(tc.formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response code
|
||||||
|
assert.Equal(t, tc.expectedCode, resp.Code)
|
||||||
|
|
||||||
|
// If we expect user creation, verify the user exists in the database
|
||||||
|
if tc.checkUser {
|
||||||
|
var user db.User
|
||||||
|
err := database.Where("email = ?", tc.formData.Get("email")).First(&user).Error
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.formData.Get("email"), user.Email)
|
||||||
|
assert.Equal(t, tc.formData.Get("is_admin") == "on", user.IsAdmin)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDeleteUser(t *testing.T) {
|
||||||
|
handlers, router, database, adminID := setupUserTest(t)
|
||||||
|
|
||||||
|
// Create a user to delete
|
||||||
|
userToDelete := testutils.CreateTestUser(t, database, "delete-me@example.com", false)
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.POST("/admin/users/delete/:id", handlers.HandleDeleteUser)
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
userID uint
|
||||||
|
expectedCode int
|
||||||
|
expectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Delete valid user",
|
||||||
|
userID: userToDelete.ID,
|
||||||
|
expectedCode: http.StatusSeeOther,
|
||||||
|
expectedBody: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cannot delete own account",
|
||||||
|
userID: adminID,
|
||||||
|
expectedCode: http.StatusBadRequest,
|
||||||
|
expectedBody: "Cannot delete your own account",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid user ID",
|
||||||
|
userID: 9999,
|
||||||
|
expectedCode: http.StatusSeeOther,
|
||||||
|
expectedBody: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create request
|
||||||
|
req, _ := http.NewRequest("POST", "/admin/users/delete/"+strconv.Itoa(int(tc.userID)), nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Serve request
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Check response code
|
||||||
|
assert.Equal(t, tc.expectedCode, resp.Code)
|
||||||
|
|
||||||
|
// If we expect a specific body message, check it
|
||||||
|
if tc.expectedBody != "" {
|
||||||
|
assert.Contains(t, resp.Body.String(), tc.expectedBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify database state after the action
|
||||||
|
if tc.name == "Delete valid user" {
|
||||||
|
// For the valid deletion case, verify user was deleted
|
||||||
|
var deletedUser db.User
|
||||||
|
// User should not be found with normal query after deletion
|
||||||
|
err := database.Where("id = ?", tc.userID).First(&deletedUser).Error
|
||||||
|
assert.Equal(t, gorm.ErrRecordNotFound, err, "User should be deleted and not found")
|
||||||
|
} else if tc.name == "Cannot delete own account" {
|
||||||
|
// For cannot delete own account, verify user still exists
|
||||||
|
var adminUser db.User
|
||||||
|
err := database.Where("id = ?", tc.userID).First(&adminUser).Error
|
||||||
|
assert.NoError(t, err, "Admin user should still exist")
|
||||||
|
} else if tc.name == "Invalid user ID" {
|
||||||
|
// For invalid user ID, just verify it doesn't exist
|
||||||
|
var nonExistentUser db.User
|
||||||
|
err := database.Where("id = ?", tc.userID).First(&nonExistentUser).Error
|
||||||
|
assert.Equal(t, gorm.ErrRecordNotFound, err, "Non-existent user should not be found")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRegisterPage(t *testing.T) {
|
||||||
|
// Set up clean database with no users
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Set up Gin router
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
|
||||||
|
// Create handlers
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.GET("/register", handlers.HandleRegisterPage)
|
||||||
|
|
||||||
|
// Test with no existing users - should show registration page
|
||||||
|
req, _ := http.NewRequest("GET", "/register", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "Register")
|
||||||
|
|
||||||
|
// Create a user
|
||||||
|
testutils.CreateTestUser(t, database, "existing@example.com", true)
|
||||||
|
|
||||||
|
// Test with existing user - should redirect
|
||||||
|
req, _ = http.NewRequest("GET", "/register", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||||
|
assert.Equal(t, "/", resp.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRegister(t *testing.T) {
|
||||||
|
// Set up clean database with no users
|
||||||
|
database := testutils.SetupTestDB(t)
|
||||||
|
|
||||||
|
// Set up Gin router
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
|
||||||
|
// Create handlers with JWT configuration
|
||||||
|
config := testutils.SetupTestConfig(t)
|
||||||
|
handlers := &Handlers{
|
||||||
|
DB: database,
|
||||||
|
JWTSecret: config.JWTSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up route
|
||||||
|
router.POST("/register", handlers.HandleRegister)
|
||||||
|
|
||||||
|
// Test user registration
|
||||||
|
formData := url.Values{
|
||||||
|
"email": {"firstuser@example.com"},
|
||||||
|
"password": {"testpassword"},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", "/register", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect to dashboard
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||||
|
assert.Equal(t, "/dashboard", resp.Header().Get("Location"))
|
||||||
|
|
||||||
|
// Verify user was created as admin
|
||||||
|
var user db.User
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Verify JWT cookie was set
|
||||||
|
cookies := resp.Result().Cookies()
|
||||||
|
assert.GreaterOrEqual(t, len(cookies), 1)
|
||||||
|
var jwtCookie *http.Cookie
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if cookie.Name == "jwt" {
|
||||||
|
jwtCookie = cookie
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.NotNil(t, jwtCookie)
|
||||||
|
assert.NotEmpty(t, jwtCookie.Value)
|
||||||
|
|
||||||
|
// Try registering a second user - should be redirected
|
||||||
|
formData = url.Values{
|
||||||
|
"email": {"seconduser@example.com"},
|
||||||
|
"password": {"testpassword"},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ = http.NewRequest("POST", "/register", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect to home
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||||
|
assert.Equal(t, "/", resp.Header().Get("Location"))
|
||||||
|
|
||||||
|
// Second user should not exist
|
||||||
|
var count int64
|
||||||
|
database.Model(&db.User{}).Where("email = ?", formData.Get("email")).Count(&count)
|
||||||
|
assert.Equal(t, int64(0), count)
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWebhookConfiguration tests the webhook configuration during job creation and editing
|
||||||
|
func TestWebhookConfiguration(t *testing.T) {
|
||||||
|
// Set up test environment
|
||||||
|
handlers, router, database, user, config := setupJobsTest(t)
|
||||||
|
|
||||||
|
// Add job create route
|
||||||
|
router.POST("/jobs/create", handlers.HandleCreateJob)
|
||||||
|
|
||||||
|
// Create job form data with webhook enabled
|
||||||
|
formData := url.Values{
|
||||||
|
"name": {"Webhook Test Job"},
|
||||||
|
"config_ids[]": {strconv.Itoa(int(config.ID))},
|
||||||
|
"schedule": {"*/15 * * * *"},
|
||||||
|
"enabled": {"true"},
|
||||||
|
"webhook_enabled": {"true"},
|
||||||
|
"webhook_url": {"https://example.com/webhook"},
|
||||||
|
"webhook_secret": {"test-secret"},
|
||||||
|
"webhook_headers": {`{"X-Test-Header": "test-value"}`},
|
||||||
|
"notify_on_success": {"true"},
|
||||||
|
"notify_on_failure": {"true"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
req, _ := http.NewRequest("POST", "/jobs/create", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect on success
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code)
|
||||||
|
|
||||||
|
// Check if job was created with webhook settings
|
||||||
|
var jobs []db.Job
|
||||||
|
err := database.DB.Where("created_by = ?", user.ID).Find(&jobs).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.GreaterOrEqual(t, len(jobs), 1)
|
||||||
|
|
||||||
|
// Get the most recently created job
|
||||||
|
var job db.Job
|
||||||
|
err = database.DB.Where("created_by = ?", user.ID).Order("created_at DESC").First(&job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify webhook settings were saved correctly
|
||||||
|
assert.True(t, job.WebhookEnabled)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookEditConfiguration tests editing webhook configuration
|
||||||
|
func TestWebhookEditConfiguration(t *testing.T) {
|
||||||
|
// Set up test environment
|
||||||
|
handlers, router, database, user, config := setupJobsTest(t)
|
||||||
|
|
||||||
|
// Create a job first
|
||||||
|
job := &db.Job{
|
||||||
|
Name: "Initial Job",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
Schedule: "*/30 * * * *",
|
||||||
|
Enabled: true,
|
||||||
|
WebhookEnabled: false, // Initially disabled
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
err := database.DB.Create(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add job update route
|
||||||
|
router.PUT("/jobs/:id", handlers.HandleUpdateJob)
|
||||||
|
|
||||||
|
// Create edit form data to enable webhook
|
||||||
|
formData := url.Values{
|
||||||
|
"name": {"Updated Job"},
|
||||||
|
"config_ids[]": {strconv.Itoa(int(config.ID))},
|
||||||
|
"schedule": {"*/30 * * * *"},
|
||||||
|
"enabled": {"true"},
|
||||||
|
"webhook_enabled": {"true"}, // Enabling webhook
|
||||||
|
"webhook_url": {"https://example.com/webhook"}, // Adding URL
|
||||||
|
"webhook_secret": {"new-secret"}, // Adding secret
|
||||||
|
"webhook_headers": {`{"X-Api-Key": "12345"}`}, // Adding headers
|
||||||
|
"notify_on_success": {"true"}, // Configure notifications
|
||||||
|
"notify_on_failure": {"false"}, // Only notify on success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit edit form
|
||||||
|
req, _ := http.NewRequest("PUT", "/jobs/"+strconv.Itoa(int(job.ID)), strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect on success
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code)
|
||||||
|
|
||||||
|
// Get the updated job
|
||||||
|
var updatedJob db.Job
|
||||||
|
err = database.DB.First(&updatedJob, job.ID).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify webhook settings were updated correctly
|
||||||
|
assert.True(t, updatedJob.WebhookEnabled)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDisablingWebhook tests disabling a previously enabled webhook
|
||||||
|
func TestDisablingWebhook(t *testing.T) {
|
||||||
|
// Set up test environment
|
||||||
|
handlers, router, database, user, config := setupJobsTest(t)
|
||||||
|
|
||||||
|
// Create a job with webhook enabled
|
||||||
|
job := &db.Job{
|
||||||
|
Name: "Webhook Enabled Job",
|
||||||
|
ConfigID: config.ID,
|
||||||
|
Schedule: "*/30 * * * *",
|
||||||
|
Enabled: true,
|
||||||
|
WebhookEnabled: true,
|
||||||
|
WebhookURL: "https://example.com/webhook",
|
||||||
|
WebhookSecret: "secret",
|
||||||
|
WebhookHeaders: `{"X-Test": "test"}`,
|
||||||
|
NotifyOnSuccess: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
err := database.DB.Create(job).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add job update route
|
||||||
|
router.PUT("/jobs/:id", handlers.HandleUpdateJob)
|
||||||
|
|
||||||
|
// Create edit form data to disable webhook
|
||||||
|
formData := url.Values{
|
||||||
|
"name": {"Webhook Disabled Job"},
|
||||||
|
"config_ids[]": {strconv.Itoa(int(config.ID))},
|
||||||
|
"schedule": {"*/30 * * * *"},
|
||||||
|
"enabled": {"true"},
|
||||||
|
"webhook_enabled": {"false"}, // Explicitly set to false
|
||||||
|
"webhook_url": {"https://example.com/webhook"}, // URL remains the same
|
||||||
|
"webhook_secret": {"secret"}, // Secret remains the same
|
||||||
|
"webhook_headers": {`{"X-Test": "test"}`}, // Headers remain the same
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit edit form
|
||||||
|
req, _ := http.NewRequest("PUT", "/jobs/"+strconv.Itoa(int(job.ID)), strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should redirect on success
|
||||||
|
assert.Equal(t, http.StatusFound, resp.Code)
|
||||||
|
|
||||||
|
// Get the updated job
|
||||||
|
var updatedJob db.Job
|
||||||
|
err = database.DB.First(&updatedJob, job.ID).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify webhook was disabled
|
||||||
|
assert.False(t, updatedJob.WebhookEnabled)
|
||||||
|
|
||||||
|
// Other fields should remain unchanged
|
||||||
|
assert.Equal(t, "https://example.com/webhook", updatedJob.WebhookURL)
|
||||||
|
assert.Equal(t, "secret", updatedJob.WebhookSecret)
|
||||||
|
assert.Equal(t, `{"X-Test": "test"}`, updatedJob.WebhookHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookValidation tests validation of webhook URL
|
||||||
|
func TestWebhookValidation(t *testing.T) {
|
||||||
|
// Set up test environment
|
||||||
|
handlers, router, _, _, config := setupJobsTest(t)
|
||||||
|
|
||||||
|
// Add job create route
|
||||||
|
router.POST("/jobs/create", handlers.HandleCreateJob)
|
||||||
|
|
||||||
|
// Create job form data with invalid webhook URL
|
||||||
|
formData := url.Values{
|
||||||
|
"name": {"Invalid Webhook Job"},
|
||||||
|
"config_ids[]": {strconv.Itoa(int(config.ID))},
|
||||||
|
"schedule": {"*/15 * * * *"},
|
||||||
|
"enabled": {"true"},
|
||||||
|
"webhook_enabled": {"true"},
|
||||||
|
"webhook_url": {"invalid-url"}, // Invalid URL
|
||||||
|
"webhook_secret": {"test-secret"},
|
||||||
|
"notify_on_success": {"true"},
|
||||||
|
"notify_on_failure": {"true"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
req, _ := http.NewRequest("POST", "/jobs/create", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should not create job with invalid webhook URL
|
||||||
|
assert.NotEqual(t, http.StatusFound, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "valid URL")
|
||||||
|
|
||||||
|
// Test invalid headers JSON
|
||||||
|
formData = url.Values{
|
||||||
|
"name": {"Invalid Headers Job"},
|
||||||
|
"config_ids[]": {strconv.Itoa(int(config.ID))},
|
||||||
|
"schedule": {"*/15 * * * *"},
|
||||||
|
"enabled": {"true"},
|
||||||
|
"webhook_enabled": {"true"},
|
||||||
|
"webhook_url": {"https://example.com/webhook"},
|
||||||
|
"webhook_secret": {"test-secret"},
|
||||||
|
"webhook_headers": {`{"invalid json`}, // Invalid JSON
|
||||||
|
"notify_on_success": {"true"},
|
||||||
|
"notify_on_failure": {"true"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
req, _ = http.NewRequest("POST", "/jobs/create", strings.NewReader(formData.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
// Should not create job with invalid headers JSON
|
||||||
|
assert.NotEqual(t, http.StatusFound, resp.Code)
|
||||||
|
assert.Contains(t, resp.Body.String(), "valid JSON")
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
// AuthMiddleware is a middleware function that checks if the request has a valid JWT token
|
|
||||||
func (m *Middleware) AuthMiddleware() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
// Get token from cookie
|
|
||||||
tokenString, err := c.Cookie("jwt_token")
|
|
||||||
if err != nil {
|
|
||||||
c.Redirect(http.StatusFound, "/login")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and validate token
|
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
||||||
}
|
|
||||||
return []byte(m.JWTSecret), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
|
|
||||||
c.Redirect(http.StatusFound, "/login")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract claims
|
|
||||||
claims, ok := token.Claims.(jwt.MapClaims)
|
|
||||||
if !ok {
|
|
||||||
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
|
|
||||||
c.Redirect(http.StatusFound, "/login")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set user information in 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.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user