Compare commits

..
10 Commits
Author SHA1 Message Date
StarFleetCPTN 7de4f57fc3 Merge pull request #51 from StarFleetCPTN/development
Email and Webhook notification testing. Job order for multi configs. Debugging for scheduler
2025-03-20 20:46:11 -07:00
StarFleetCPTN 9a49d87777 Implement Job Configuration Ordering in Job Form
- Added functionality to allow users to select and reorder job configurations in both new and edit job forms.
- Introduced JavaScript logic to handle the display and ordering of selected configurations, including move up/down buttons.
- Updated backend to process and store the order of configurations when creating or updating jobs.
- Enhanced logging for job creation and update processes to include configuration order details.
2025-03-20 20:39:26 -07:00
StarFleetCPTN 73362793ad Add Webhook Notification Testing to Admin Tools
- Introduced a new section for testing webhook notifications within the admin tools.
- Added a form to input webhook URL, secret, custom headers, and payload for testing.
- Implemented backend logic to handle webhook test requests and display results.
- Created a toast notification component to show success or failure messages for webhook tests.
- Updated the AdminToolsData structure to include fields for webhook test results.
2025-03-20 18:49:32 -07:00
StarFleetCPTN d6bd471eb0 Add Email Testing Functionality to Admin Tools
- Introduced a new section in the admin tools for testing email configurations.
- Added a form to send test emails, including recipient, subject, and message fields.
- Implemented backend logic to handle test email requests and send emails using the configured SMTP server.
- Created a toast notification component to display success or failure messages for email tests.
- Updated routes to include a new endpoint for handling test email submissions.
2025-03-20 18:11:57 -07:00
StarFleetCPTN 1a1df435de Enhance logging in scheduler for better debugging
- Added debug logging for job scheduling, execution, and configuration processing.
- Included detailed log messages for cron expression validation and file transfer commands.
- Improved visibility into job history and webhook notifications with additional debug information.
2025-03-19 18:30:26 -07:00
StarFleetCPTN e031e67b3c Merge pull request #46 from StarFleetCPTN/development
Fix GetReleaseURL to dynamically use AppVersion in the release URL fo…
2025-03-19 05:19:11 -07:00
StarFleetCPTN 402072540f Fix GetReleaseURL to dynamically use AppVersion in the release URL format 2025-03-19 05:18:24 -07:00
StarFleetCPTN be19a5efde Update go-release.yml 2025-03-18 22:13:47 -07:00
StarFleetCPTN aa04c76e1f Merge pull request #45 from StarFleetCPTN/development
Enhance Docker and Build Configuration
2025-03-18 21:23:42 -07:00
StarFleetCPTN 1981764f80 Enhance Docker and Build Configuration
- Added support for build arguments in Dockerfile to specify UID, GID, version, build time, and commit hash.
- Updated docker-compose.yaml to utilize UID and GID for non-root user execution, improving file permission handling.
- Introduced entrypoint.sh script to manage user permissions and environment setup at container startup.
- Enhanced application logging to include version information during startup.
- Updated README with new instructions for running the application with specific user IDs and environment variables.
2025-03-18 21:23:15 -07:00
19 changed files with 1626 additions and 114 deletions
@@ -26,6 +26,24 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed to get all tags for versioning
# Set version information
- name: Set Version
id: version
run: |
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "VERSION=$VERSION" >> $GITHUB_ENV
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
echo "VERSION=$VERSION" >> $GITHUB_ENV
fi
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Set up Docker Buildx for efficient builds
- name: Set up Docker Buildx
@@ -61,5 +79,11 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }}
COMMIT=${{ env.COMMIT }}
UID=1000
GID=1000
cache-from: type=gha
cache-to: type=gha,mode=max
+24
View File
@@ -27,6 +27,24 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed to get all tags for versioning
# Set version information
- name: Set Version
id: version
run: |
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "VERSION=$VERSION" >> $GITHUB_ENV
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
echo "VERSION=$VERSION" >> $GITHUB_ENV
fi
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Set up Docker Buildx for efficient builds
- name: Set up Docker Buildx
@@ -62,5 +80,11 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }}
COMMIT=${{ env.COMMIT }}
UID=1000
GID=1000
cache-from: type=gha
cache-to: type=gha,mode=max
+14 -8
View File
@@ -57,23 +57,29 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
fi
# Also set build timestamp for versioning
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build for multiple platforms
run: |
mkdir -p dist
# Define common ldflags with version information
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT -X github.com/starfleetcptn/gomft/components.BuildTime=$BUILD_TIME -X github.com/starfleetcptn/gomft/components.Commit=$COMMIT"
# Linux builds
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-arm64 .
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-armv7 .
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-arm64 .
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-armv7 .
# macOS builds
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-amd64 .
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-arm64 .
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-amd64 .
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-arm64 .
# Windows builds
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-amd64.exe .
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-arm64.exe .
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-amd64.exe .
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-arm64.exe .
# Create checksums
cd dist
@@ -95,4 +101,4 @@ jobs:
generate_release_notes: true
draft: false
# The following line is not needed as we set permissions at workflow level
# token: ${{ secrets.GITHUB_TOKEN }}
# token: ${{ secrets.GITHUB_TOKEN }}
+34 -3
View File
@@ -2,6 +2,11 @@ FROM golang:1.24-alpine AS builder
WORKDIR /app
# Accept build arguments for version information
ARG VERSION=dev
ARG BUILD_TIME=unknown
ARG COMMIT=unknown
# Install build dependencies
RUN apk add --no-cache git build-base
@@ -18,8 +23,10 @@ COPY . .
# Generate template files from .templ files
RUN templ generate
# Build the application
RUN CGO_ENABLED=1 GOOS=linux go build -o gomft
# Compile the application with version information
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \
-o /app/gomft
# Install rclone
RUN apk add --no-cache curl unzip && \
@@ -34,10 +41,21 @@ RUN apk add --no-cache curl unzip && \
# Create a smaller runtime image
FROM alpine:3.19
# Add arguments for UID and GID with defaults
ARG UID=1000
ARG GID=1000
ARG USERNAME=gomft
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata sqlite bash
RUN apk add --no-cache ca-certificates tzdata sqlite bash shadow su-exec \
&& apk add --no-cache --virtual .user-deps \
shadow curl xz
# Create user and group with specified IDs
RUN addgroup -g ${GID} ${USERNAME} && \
adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME}
# Copy the binary from the builder stage
COPY --from=builder /app/gomft /app/
@@ -47,14 +65,27 @@ COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
COPY static/ /app/static/
COPY components/ /app/components/
# Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create data and backup directories
RUN mkdir -p /app/data /app/backups
# Create a placeholder .env file with proper permissions
RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env
# Set executable permissions
RUN chmod +x /app/gomft
# Set ownership of application files
RUN chown -R ${USERNAME}:${USERNAME} /app
# Expose the application port
EXPOSE 8080
# Use our entrypoint script
ENTRYPOINT ["/entrypoint.sh"]
# Run the application
CMD ["/app/gomft"]
+143 -26
View File
@@ -9,6 +9,7 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
> [!WARNING]
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
---
## Screenshots
@@ -29,6 +30,8 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
</tr>
</table>
---
## Features
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
@@ -80,12 +83,16 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
- **Docker Support**: Easy deployment with Docker images and Docker Compose support
- **Portable Deployment**: Run on any platform that supports Docker or Go
---
## Prerequisites
- Go 1.21 or later
- rclone installed and configured
- SQLite 3
---
## Installation
### Standard Installation
@@ -116,6 +123,8 @@ docker pull starfleetcptn/gomft:latest
```
2. Run the container:
#### Basic run
```bash
docker run -d \
--name gomft \
@@ -125,6 +134,42 @@ docker run -d \
starfleetcptn/gomft:latest
```
#### Run with specific user ID and group ID (using environment variables)
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
starfleetcptn/gomft:latest
```
#### Or specify user IDs directly
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-e PUID=1001 \
-e PGID=1001 \
starfleetcptn/gomft:latest
```
#### Using a .env file for configuration
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-v /path/to/.env:/app/.env \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
starfleetcptn/gomft:latest
```
3. Access the web interface at `http://localhost:8080`
#### Docker Compose Example
@@ -143,19 +188,19 @@ services:
volumes:
- ./data:/app/data
- ./backups:/app/backups
- ./.env:/app/.env
environment:
- PUID=1000
- PGID=1000
- 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
# Google OAuth configuration (optional)
- GOOGLE_CLIENT_ID=your_google_client_id
- GOOGLE_CLIENT_SECRET=your_google_client_secret
# Two-Factor Authentication configuration
- TOTP_ENCRYPTION_KEY=your_32_byte_secure_encryption_key
# Email configuration
- TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
- EMAIL_ENABLED=true
- EMAIL_HOST=smtp.example.com
- EMAIL_PORT=587
@@ -165,34 +210,14 @@ services:
- 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
# The user directive is no longer needed when using PUID/PGID environment variables
```
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
@@ -201,11 +226,14 @@ docker-compose up -d
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
---
## Configuration
GoMFT uses an environment file located at `.env` in the root directory of the application. On first run, a default configuration will be created:
```
```ini
# Basic configuration
SERVER_ADDRESS=:8080
DATA_DIR=/app/data
BACKUP_DIR=/app/backups
@@ -230,6 +258,10 @@ EMAIL_PASSWORD=smtp_password
# Two-Factor Authentication configuration
TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
# UserID and GroupID
PUID=1000
PGID=1000
```
### Configuration Options
@@ -278,6 +310,8 @@ GoMFT provides configurable logging with rotation support through the following
Log files contain detailed information about file transfers, job execution, and system operations, which can be useful for troubleshooting and auditing.
---
## Usage
1. Start the server:
@@ -552,6 +586,8 @@ The Admin Tools interface also includes database management capabilities:
- View system statistics
- Optimize the database with maintenance tools
---
## Development
### Project Structure
@@ -602,6 +638,8 @@ templ generate
air
```
---
## Contributing
1. Fork the repository
@@ -610,6 +648,8 @@ air
4. Push to the branch
5. Create a Pull Request
---
## Directory Structure
GoMFT uses the following directory structure:
@@ -630,6 +670,83 @@ volumes:
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
---
## Security Considerations
### Running as a Non-Root User
By default, Docker containers run as the root user, which can pose security risks. GoMFT supports running as a non-root user, which is recommended for production environments.
#### Benefits of Running as Non-Root
- **Improved Security**: Limits the potential damage if the container is compromised
- **Better File Permissions**: Files created by the container will match your host user permissions
- **Compliance**: Many security policies and best practices require containers to run as non-root
#### Methods to Run as Non-Root
1. **Using PUID/PGID environment variables (recommended)**:
```bash
# Using current user's ID
docker run -e PUID=$(id -u) -e PGID=$(id -g) starfleetcptn/gomft:latest
# Or in docker-compose.yml
environment:
- PUID=1000
- PGID=1000
```
This is the most flexible method as it allows changing the user at runtime without rebuilding the image.
2. **Using the `--user` flag with Docker run**:
```bash
docker run --user $(id -u):$(id -g) starfleetcptn/gomft:latest
```
3. **Using Docker Compose with environment variables for `user` directive**:
```yaml
services:
gomft:
image: starfleetcptn/gomft:latest
user: "${UID:-1000}:${GID:-1000}"
```
4. **Building a custom image with specified UID/GID**:
```yaml
services:
gomft:
build:
context: .
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
```
#### Environment Variables for User Management
| Variable | Description | Default |
|----------|-------------|---------|
| `PUID` | User ID to run as | Built-in user ID (1000) |
| `PGID` | Group ID to run as | Built-in group ID (1000) |
| `USERNAME` | Username to use | `gomft` |
These environment variables allow you to change the user/group IDs at runtime without rebuilding the image.
#### Volume Permissions
When mounting volumes, ensure that the directories on the host have appropriate permissions for the container user:
```bash
# Create directories with correct ownership
mkdir -p data backups
chown -R $(id -u):$(id -g) data backups
# Or adjust permissions to allow the container user to write
mkdir -p data backups
chmod -R 777 data backups # Less secure, but easier for testing
```
---
## License
+495
View File
@@ -36,6 +36,13 @@ type AdminToolsData struct {
LogFiles []LogFile
LogContent string
CurrentLogFile string
EmailTestSuccess *bool
EmailTestMessage string
SmtpServer string
WebhookTestSuccess *bool
WebhookTestMessage string
WebhookStatusCode int
WebhookResponse string
}
// Dialog component for confirmation dialogs
@@ -463,6 +470,255 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
</div>
</div>
<!-- Email Testing Tools -->
<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-envelope mr-2 text-primary-500"></i>
Email Testing
</h3>
</div>
<div class="card-body">
<p class="text-sm text-secondary-600 dark:text-secondary-400 mb-4">
Test your email configuration by sending a test email to verify the server can send emails properly.
</p>
<form id="test-email-form" hx-post="/admin/test-email" hx-target="#email-test-result" hx-swap="outerHTML" hx-indicator="#email-test-indicator">
<div class="space-y-4">
<div>
<label for="test-email-recipient" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Recipient Email
</label>
<input
type="email"
id="test-email-recipient"
name="recipient"
class="form-input w-full"
placeholder="recipient@example.com"
required
/>
</div>
<div>
<label for="test-email-subject" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Subject (Optional)
</label>
<input
type="text"
id="test-email-subject"
name="subject"
class="form-input w-full"
placeholder="Test Email from GoMFT"
/>
</div>
<div>
<label for="test-email-message" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Message (Optional)
</label>
<textarea
id="test-email-message"
name="message"
rows="3"
class="form-textarea w-full"
placeholder="This is a test email from GoMFT."
></textarea>
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary flex items-center justify-center" onclick="fadeInAnimation()">
<i class="fas fa-paper-plane mr-2"></i>
<span>Send Test Email</span>
<div id="email-test-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
</div>
</div>
</form>
<!-- Toast container for email test results -->
<div id="email-test-result" class="mt-4 hidden">
if data.EmailTestSuccess != nil {
@EmailTestToast(*data.EmailTestSuccess, data.EmailTestMessage)
}
</div>
<!-- Email settings reminder -->
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800/50 p-4 rounded-lg">
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 flex items-center">
<i class="fas fa-info-circle mr-2 text-blue-500"></i>
Email Configuration
</h4>
if data.SmtpServer != "" {
<p class="mt-2 text-xs text-secondary-600 dark:text-secondary-400">
Current SMTP server: <span class="font-mono">{ data.SmtpServer }</span>
</p>
} else {
<p class="mt-2 text-xs text-secondary-600 dark:text-secondary-400">
Email settings are configured in your application configuration file. Make sure SMTP settings are properly configured before testing.
</p>
}
</div>
</div>
</div>
</div>
<!-- Notification Testing Tools -->
<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 Notification Testing
</h3>
</div>
<div class="card-body">
<p class="text-sm text-secondary-600 dark:text-secondary-400 mb-4">
Test webhook notifications by sending a sample job execution payload to your webhook endpoint.
</p>
<form id="webhook-test-form" hx-post="/admin/test-webhook" hx-target="#webhook-test-result" hx-swap="outerHTML" hx-indicator="#webhook-test-indicator">
<div class="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"
id="webhook-url"
name="webhook_url"
class="form-input pl-10 w-full"
placeholder="https://example.com/webhook"
required
/>
</div>
</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"
id="webhook-secret"
name="webhook_secret"
class="form-input pl-10 w-full"
placeholder="Secret token for signing requests"
/>
</div>
<p class="mt-1 text-xs 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"
id="webhook-headers"
name="webhook_headers"
class="form-input pl-10 w-full"
placeholder='{"X-Custom-Header": "value"}'
/>
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Additional HTTP headers as JSON
</p>
</div>
<div>
<label for="webhook-payload" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Custom Payload <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
</label>
<textarea
id="webhook-payload"
name="webhook_payload"
rows="5"
class="form-textarea w-full font-mono text-sm"
placeholder='{
"event_type": "job_execution",
"job_id": 123,
"job_name": "Test Job",
"status": "completed"
}'></textarea>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Leave empty to use default test payload
</p>
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary flex items-center justify-center" onclick="fadeInAnimation()">
<i class="fas fa-paper-plane mr-2"></i>
<span>Send Test Webhook</span>
<div id="webhook-test-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
</div>
</div>
</form>
<!-- Result container for webhook test -->
<div id="webhook-test-result" class="mt-4 hidden">
if data.WebhookTestSuccess != nil {
@WebhookTestToast(*data.WebhookTestSuccess, data.WebhookTestMessage, data.WebhookStatusCode, data.WebhookResponse)
}
</div>
<!-- Sample payload section -->
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800/50 p-4 rounded-lg">
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 flex items-center">
<i class="fas fa-info-circle mr-2 text-blue-500"></i>
Default Webhook Test Payload
</h4>
<div class="mt-2 bg-white dark:bg-secondary-900 p-3 rounded border border-secondary-200 dark:border-secondary-700 overflow-auto">
<pre class="text-xs text-secondary-600 dark:text-secondary-400 font-mono">&#123;
"event_type": "job_execution",
"job_id": 123,
"job_name": "Test Job",
"config_id": 456,
"config_name": "Test 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": &#123;
"type": "local",
"path": "/path/to/source"
&#125;,
"destination": &#123;
"type": "s3",
"path": "bucket/path"
&#125;
&#125;</pre>
</div>
</div>
</div>
</div>
</div>
<!-- Available Backups -->
<div id="backups-container" class="mt-8">
@BackupsList(data)
@@ -513,11 +769,96 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
{ fmt.Sprint(data.TotalJobs) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Application Version</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
<div class="flex items-center">
<span>{ AppVersion }</span>
if AppVersion == "dev" {
<a href="https://github.com/starfleetcptn/gomft/releases"
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
target="_blank" rel="noopener">
<i class="fas fa-external-link-alt text-xs"></i>
</a>
} else if AppVersion == "1.0.0" {
<a href="https://github.com/starfleetcptn/gomft/releases/tag/v1.0.0"
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
target="_blank" rel="noopener">
<i class="fas fa-external-link-alt text-xs"></i>
</a>
} else if AppVersion == "1.1.0" {
<a href="https://github.com/starfleetcptn/gomft/releases/tag/v1.1.0"
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
target="_blank" rel="noopener">
<i class="fas fa-external-link-alt text-xs"></i>
</a>
} else {
<a href="https://github.com/starfleetcptn/gomft/releases"
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
target="_blank" rel="noopener">
<i class="fas fa-external-link-alt text-xs"></i>
</a>
}
</div>
</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- Build Information -->
<div class="mt-6">
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-code-branch mr-2 text-primary-500"></i>
Build Information
</h3>
</div>
<div class="card-body">
<div class="relative overflow-hidden bg-secondary-50 dark:bg-secondary-900 rounded-lg">
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-3">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Version</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
{ AppVersion }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Build Time</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
{ getBuildTime() }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Commit</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
{ getCommit() }
</dd>
</div>
</dl>
<div class="mt-6 flex justify-end">
<a
href="https://github.com/starfleetcptn/gomft"
class="inline-flex items-center px-4 py-2 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
target="_blank" rel="noopener"
>
<i class="fab fa-github mr-2"></i>
View on GitHub
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Log Viewer -->
<div id="logs-container" class="mt-8">
@AdminLogViewer(data)
@@ -897,3 +1238,157 @@ templ AdminLogContent(data AdminToolsData) {
}
</div>
}
// getBuildTime returns the build time of the application
// This can be set using ldflags during build
// Example: go build -ldflags "-X github.com/starfleetcptn/gomft/components.BuildTime=2023-01-01T00:00:00Z"
var BuildTime = "unknown"
func getBuildTime() string {
return BuildTime
}
// getCommit returns the commit hash of the application
// This can be set using ldflags during build
// Example: go build -ldflags "-X github.com/starfleetcptn/gomft/components.Commit=abcdef123456"
var Commit = "unknown"
func getCommit() string {
return Commit
}
// EmailTestToast is a component for showing email test results
templ EmailTestToast(success bool, message string) {
<div id="email-test-result" class="mt-4 animate-fade-in">
<div class={
"rounded-lg p-4 flex items-start",
templ.KV("bg-green-50 dark:bg-green-900/20 border-l-4 border-green-400", success),
templ.KV("bg-red-50 dark:bg-red-900/20 border-l-4 border-red-400", !success),
}>
<div class="flex-shrink-0">
if success {
<i class="fas fa-check-circle text-green-400"></i>
} else {
<i class="fas fa-exclamation-circle text-red-400"></i>
}
</div>
<div class="ml-3">
<h3 class={
"text-sm font-medium",
templ.KV("text-green-800 dark:text-green-300", success),
templ.KV("text-red-800 dark:text-red-300", !success),
}>
if success {
Email Sent Successfully
} else {
Email Sending Failed
}
</h3>
<div class={
"mt-1 text-sm",
templ.KV("text-green-700 dark:text-green-400", success),
templ.KV("text-red-700 dark:text-red-400", !success),
}>
{ message }
</div>
</div>
<div class="ml-auto pl-3 flex-shrink-0">
<button
type="button"
onclick="document.getElementById('email-test-result').classList.add('hidden');"
class={
"inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2",
templ.KV("text-green-500 hover:bg-green-100 dark:hover:bg-green-900/30 focus:ring-green-500", success),
templ.KV("text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 focus:ring-red-500", !success),
}
>
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
}
// Add a style for the animate-fade-in animation
script fadeInAnimation() {
// Add CSS animation if it doesn't exist
if (!document.getElementById('fade-in-animation')) {
const style = document.createElement('style');
style.id = 'fade-in-animation';
style.textContent = `
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
`;
document.head.appendChild(style);
}
}
// WebhookTestToast is a component for showing webhook test results
templ WebhookTestToast(success bool, message string, statusCode int, responseBody string) {
<div id="webhook-test-result" class="mt-4 animate-fade-in">
<div class={
"rounded-lg p-4 flex items-start",
templ.KV("bg-green-50 dark:bg-green-900/20 border-l-4 border-green-400", success),
templ.KV("bg-red-50 dark:bg-red-900/20 border-l-4 border-red-400", !success),
}>
<div class="flex-shrink-0">
if success {
<i class="fas fa-check-circle text-green-400"></i>
} else {
<i class="fas fa-exclamation-circle text-red-400"></i>
}
</div>
<div class="ml-3 flex-grow">
<h3 class={
"text-sm font-medium",
templ.KV("text-green-800 dark:text-green-300", success),
templ.KV("text-red-800 dark:text-red-300", !success),
}>
if success {
Webhook Sent Successfully
} else {
Webhook Sending Failed
}
</h3>
<div class={
"mt-1 text-sm",
templ.KV("text-green-700 dark:text-green-400", success),
templ.KV("text-red-700 dark:text-red-400", !success),
}>
{ message }
</div>
<!-- Additional response details -->
<div class="mt-3 text-sm">
<div class="font-medium text-secondary-700 dark:text-secondary-300">Status Code: { fmt.Sprint(statusCode) }</div>
if responseBody != "" {
<div class="mt-2">
<div class="font-medium text-secondary-700 dark:text-secondary-300 mb-1">Response:</div>
<div class="bg-white dark:bg-secondary-900 p-2 rounded border border-secondary-200 dark:border-secondary-700">
<pre class="text-xs text-secondary-600 dark:text-secondary-400 font-mono overflow-auto max-h-40">{ responseBody }</pre>
</div>
</div>
}
</div>
</div>
<div class="ml-auto pl-3 flex-shrink-0">
<button
type="button"
onclick="document.getElementById('webhook-test-result').classList.add('hidden');"
class={
"inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2",
templ.KV("text-green-500 hover:bg-green-100 dark:hover:bg-green-900/30 focus:ring-green-500", success),
templ.KV("text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 focus:ring-red-500", !success),
}
>
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
}
+197 -10
View File
@@ -72,6 +72,172 @@ templ configSearchScript() {
});
});
}
// Handle job ordering
const setupJobOrdering = (configListId, selectedListId, formId, savedOrder) => {
const configList = document.getElementById(configListId);
const selectedList = document.getElementById(selectedListId);
const form = document.getElementById(formId);
if (!configList || !selectedList || !form) return;
// Get saved order if available
const orderedIds = savedOrder ? savedOrder.split(',').map(id => id.trim()) : [];
console.log('Initial saved order:', orderedIds);
// Initialize selected items from checked checkboxes
const updateSelectedItems = (initialLoad = false) => {
// Clear current list
selectedList.innerHTML = '';
// Get all checked checkboxes
const checkedItems = configList.querySelectorAll('input[type="checkbox"]:checked');
if (checkedItems.length === 0) {
selectedList.innerHTML = '<div class="text-center py-4 text-secondary-500 dark:text-secondary-400">No configurations selected</div>';
return;
}
// Create a map of config items for easy access
const configItems = {};
checkedItems.forEach(checkbox => {
configItems[checkbox.value] = {
checkbox: checkbox,
configId: checkbox.value,
configName: checkbox.nextElementSibling.textContent.trim()
};
});
// If we have a saved order and this is the initial load, use that order
let itemsToShow = [];
if (initialLoad && orderedIds.length > 0) {
// First add items in the saved order
orderedIds.forEach(id => {
if (configItems[id]) {
itemsToShow.push(configItems[id]);
delete configItems[id]; // Remove from map to avoid duplicates
}
});
// Then add any remaining checked items not in the saved order
Object.values(configItems).forEach(item => {
itemsToShow.push(item);
});
} else {
// Just add all checked items in their current order
itemsToShow = Object.values(configItems);
}
// Add each item to the selected list
itemsToShow.forEach((item, index) => {
const configId = item.configId;
const configName = item.configName;
const listItem = document.createElement('div');
listItem.className = 'flex items-center justify-between p-2 mb-2 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-700 rounded-lg';
listItem.setAttribute('data-id', configId);
listItem.innerHTML = `
<div class="flex items-center">
<span class="inline-flex items-center justify-center h-6 w-6 rounded-full bg-primary-100 dark:bg-primary-900 mr-2 text-primary-700 dark:text-primary-300 text-sm">${index + 1}</span>
<span class="font-medium text-secondary-700 dark:text-secondary-300">${configName}</span>
</div>
<div class="flex space-x-1">
<button type="button" class="move-up p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-700" title="Move up">
<i class="fas fa-arrow-up text-secondary-500"></i>
</button>
<button type="button" class="move-down p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-700" title="Move down">
<i class="fas fa-arrow-down text-secondary-500"></i>
</button>
</div>
`;
selectedList.appendChild(listItem);
});
// Update hidden order inputs
updateOrderInputs();
};
// Update hidden inputs with the current order
const updateOrderInputs = () => {
const items = selectedList.querySelectorAll('.flex.items-center.justify-between');
if (items.length === 0) return;
// Remove any existing order input to avoid duplicates
const existingOrderInput = form.querySelector('input[name="config_order"]');
if (existingOrderInput) {
existingOrderInput.remove();
}
// Create a new input with the current order
const orderedIds = Array.from(items).map(item => item.getAttribute('data-id'));
// Create a hidden input to store the order
const configOrderInput = document.createElement('input');
configOrderInput.type = 'hidden';
configOrderInput.name = 'config_order';
configOrderInput.value = orderedIds.join(',');
// Add the input to the form
form.appendChild(configOrderInput);
// Update the visible order numbers
items.forEach((item, index) => {
const orderNum = index + 1;
const orderSpan = item.querySelector('span.rounded-full');
if (orderSpan) {
orderSpan.textContent = orderNum;
}
});
console.log('Updated order input:', configOrderInput.value);
};
// Initialize the selected list with saved order if available
updateSelectedItems(true);
// Handle checkbox changes
configList.addEventListener('change', (e) => {
if (e.target.matches('input[type="checkbox"]')) {
updateSelectedItems(false);
}
});
// Handle reordering
selectedList.addEventListener('click', (e) => {
const listItem = e.target.closest('.flex.items-center.justify-between');
if (!listItem) return;
if (e.target.closest('.move-up')) {
const prev = listItem.previousElementSibling;
if (prev) {
selectedList.insertBefore(listItem, prev);
updateOrderInputs();
}
} else if (e.target.closest('.move-down')) {
const next = listItem.nextElementSibling;
if (next) {
selectedList.insertBefore(next, listItem);
updateOrderInputs();
}
}
});
// Ensure the order input is updated before submission
form.addEventListener('submit', function(e) {
updateOrderInputs();
console.log('Form submitted with order:', form.querySelector('input[name="config_order"]')?.value);
});
};
// Setup ordering for new job form
setupJobOrdering('config-list', 'selected-configs', 'new-job-form', null);
// Setup ordering for edit job form
const editJobForm = document.getElementById('edit-job-form');
const savedOrderEdit = editJobForm ? editJobForm.getAttribute('data-config-order') : null;
setupJobOrdering('config-list-edit', 'selected-configs-edit', 'edit-job-form', savedOrderEdit);
});
</script>
}
@@ -95,6 +261,7 @@ templ JobForm(ctx context.Context, data JobFormData) {
if data.IsNew {
<form
id="new-job-form"
class="space-y-6"
hx-post="/jobs"
hx-target="body"
@@ -161,10 +328,19 @@ templ JobForm(ctx context.Context, data JobFormData) {
</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>
<!-- Selected Configurations Order List -->
<div class="mt-4">
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
</label>
<div id="selected-configs" class="border border-secondary-300 dark:border-secondary-700 rounded-md p-3 min-h-20 bg-secondary-50 dark:bg-secondary-900">
<!-- Selected items will be populated by JavaScript -->
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Use the arrows to change the order in which configurations will execute.
</p>
</div>
</div>
<div>
@@ -341,10 +517,12 @@ templ JobForm(ctx context.Context, data JobFormData) {
</form>
} else {
<form
id="edit-job-form"
class="space-y-6"
hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) }
hx-target="body"
hx-boost="true">
hx-boost="true"
data-config-order={ data.Job.ConfigIDs }>
<div class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
@@ -411,10 +589,19 @@ templ JobForm(ctx context.Context, data JobFormData) {
</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>
<!-- Selected Configurations Order List for edit -->
<div class="mt-4">
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
</label>
<div id="selected-configs-edit" class="border border-secondary-300 dark:border-secondary-700 rounded-md p-3 min-h-20 bg-secondary-50 dark:bg-secondary-900">
<!-- Selected items will be populated by JavaScript -->
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Use the arrows to change the order in which configurations will execute.
</p>
</div>
</div>
<div>
@@ -599,7 +786,7 @@ templ JobForm(ctx context.Context, data JobFormData) {
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Jobs will run according to their schedule and execute the selected transfer configuration
Jobs will run according to their schedule and execute the selected transfer configurations in the order specified
</p>
</div>
</div>
+25 -1
View File
@@ -3,8 +3,20 @@ package components
import (
"context"
"github.com/gin-gonic/gin"
"time"
"fmt"
)
// AppVersion will be set at build time using ldflags
// Example build command:
// go build -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=1.2.3"
var AppVersion = "dev"
// GetReleaseURL returns the URL to the specific GitHub release
func GetReleaseURL() templ.SafeURL {
return templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/%s", AppVersion))
}
// CreateTemplateContext creates a new context with user information from Gin's context
func CreateTemplateContext(c *gin.Context) context.Context {
ctx := context.Background()
@@ -437,6 +449,18 @@ templ LayoutWithContext(title string, ctx context.Context) {
<p class="text-center text-sm text-secondary-500 dark:text-secondary-400">
GoMFT &copy; { getCurrentYear() } | Secure File Transfer Solution
</p>
<div class="flex justify-center items-center space-x-4 mt-1">
<p class="text-xs text-secondary-400 dark:text-secondary-500">
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
<i class="fab fa-github mr-1"></i>GitHub
</a>
</p>
<p class="text-xs text-secondary-400 dark:text-secondary-500">
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
<i class="fas fa-tag mr-1"></i>Version { AppVersion }
</a>
</p>
</div>
</div>
</footer>
@@ -544,5 +568,5 @@ func getUserEmail(ctx context.Context) string {
// Helper function to get current year
func getCurrentYear() string {
return "2025"
return time.Now().Format("2006")
}
+12
View File
@@ -3,6 +3,15 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
# Set the UID/GID to match your host user for better file permissions
# Default is 1000:1000 if not specified
UID: ${UID:-1000}
GID: ${GID:-1000}
# Version information
VERSION: ${VERSION:-dev}
BUILD_TIME: ${BUILD_TIME:-unknown}
COMMIT: ${COMMIT:-unknown}
container_name: gomft
restart: unless-stopped
ports:
@@ -22,6 +31,9 @@ services:
# - LOG_LEVEL=info
networks:
- gomft-network
# For non-root installs, the container needs to run with the same UID
# as the host user to access mounted volumes properly
user: ${UID:-1000}:${GID:-1000}
networks:
gomft-network:
+89
View File
@@ -0,0 +1,89 @@
#!/bin/sh
set -e
# Default username
USERNAME=${USERNAME:-gomft}
# If PUID/PGID env vars are set, update the user's UID/GID
if [ -n "${PUID}" ] && [ -n "${PGID}" ]; then
echo "🔒 Updating user ${USERNAME} with UID:GID = ${PUID}:${PGID}"
# Make sure we have directories to work with
mkdir -p /app/data /app/backups
# Check if we're on Alpine (busybox)
if grep -q "Alpine" /etc/os-release 2>/dev/null; then
echo "Detected Alpine Linux, using busybox usermod/groupmod..."
# Update group ID first
if [ "$(getent group ${USERNAME} | cut -d: -f3)" != "${PGID}" ]; then
echo "Updating GID to ${PGID}..."
groupmod -g ${PGID} ${USERNAME} || echo "⚠️ Failed to change GID"
fi
# Update user ID
if [ "$(id -u ${USERNAME})" != "${PUID}" ]; then
echo "Updating UID to ${PUID}..."
usermod -u ${PUID} ${USERNAME} || echo "⚠️ Failed to change UID"
fi
else
echo "Non-Alpine system, using standard user management..."
# Handle user/group changes with error recovery
{
# First remove the user (since user has the group as primary group)
if getent passwd ${USERNAME} > /dev/null; then
echo "Removing existing user ${USERNAME}"
userdel ${USERNAME} 2>/dev/null || true
fi
# Wait a moment for system to clean up user
sleep 1
# Then remove the group
if getent group ${USERNAME} > /dev/null; then
echo "Removing existing group ${USERNAME}"
groupdel ${USERNAME} 2>/dev/null || true
fi
# Recreate group and user in the correct order
echo "Creating group ${USERNAME} with GID ${PGID}"
groupadd -g ${PGID} ${USERNAME} 2>/dev/null || groupadd ${USERNAME} 2>/dev/null || true
echo "Creating user ${USERNAME} with UID ${PUID}"
useradd -u ${PUID} -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null ||
useradd -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null || true
} || {
echo "⚠️ Warning: Failed to update UID/GID, continuing with built-in user"
}
fi
# Fix ownership of app directories
echo "Setting ownership of app directories"
chown -R ${USERNAME}:${USERNAME} /app/data /app/backups || echo "⚠️ Warning: Failed to change ownership"
# Ensure .env file exists and has correct permissions
if [ -f /app/.env ]; then
echo "Found .env file, setting permissions..."
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
else
echo "No .env file found, creating empty one..."
touch /app/.env
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
fi
# Run the application as the specified user
echo "Starting application as user ${USERNAME}"
if command -v su-exec >/dev/null 2>&1; then
exec su-exec ${USERNAME} "$@"
elif command -v gosu >/dev/null 2>&1; then
exec gosu ${USERNAME} "$@"
else
exec su -m ${USERNAME} -c "$*"
fi
else
# Run as the predefined user (set during build)
echo "Starting application with predefined user"
exec "$@"
fi
+37 -2
View File
@@ -2,6 +2,7 @@ package db
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
@@ -186,6 +187,9 @@ func (j *Job) SetConfigIDsList(ids []uint) {
// Join with commas
j.ConfigIDs = strings.Join(strIDs, ",")
// Debug log the final ConfigIDs string
log.Printf("SetConfigIDsList: Setting ConfigIDs to: %s (from %v)", j.ConfigIDs, ids)
// If there's at least one ID, set ConfigID to the first one for backward compatibility
if len(ids) > 0 {
j.ConfigID = ids[0]
@@ -376,8 +380,25 @@ func (db *DB) GetJob(id uint) (*Job, error) {
}
func (db *DB) UpdateJob(job *Job) error {
log.Printf("UpdateJob: Updating job ID: %d, ConfigIDs: %s", job.ID, job.ConfigIDs)
// Use Omit to prevent GORM from updating or creating a new config
return db.Omit("Config").Save(job).Error
return db.Model(&Job{}).
Where("id = ?", job.ID).
Omit("Config").
Updates(map[string]interface{}{
"name": job.Name,
"config_id": job.ConfigID,
"config_ids": job.ConfigIDs, // Explicitly update config_ids
"schedule": job.Schedule,
"enabled": job.Enabled,
"webhook_enabled": job.WebhookEnabled,
"webhook_url": job.WebhookURL,
"webhook_secret": job.WebhookSecret,
"webhook_headers": job.WebhookHeaders,
"notify_on_success": job.NotifyOnSuccess,
"notify_on_failure": job.NotifyOnFailure,
}).Error
}
func (db *DB) DeleteJob(id uint) error {
@@ -965,7 +986,21 @@ func (db *DB) GetConfigsForJob(jobID uint) ([]TransferConfig, error) {
return nil, err
}
return configs, nil
// Create a map for quick lookup
configMap := make(map[uint]TransferConfig)
for _, config := range configs {
configMap[config.ID] = config
}
// Create a new slice with configs in the correct order
orderedConfigs := make([]TransferConfig, 0, len(configs))
for _, configID := range configIDs {
if config, exists := configMap[configID]; exists {
orderedConfigs = append(orderedConfigs, config)
}
}
return orderedConfigs, nil
}
// GetSkipProcessedFiles returns the value of SkipProcessedFiles with a default if nil
+20 -2
View File
@@ -3,6 +3,7 @@ package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
@@ -33,8 +34,25 @@ func InitialSchema() *gormigrate.Migration {
return fmt.Errorf("failed to get database path: %v", err)
}
// Create backup file with timestamp
backupFile := fmt.Sprintf("%s.backup.%s", dbPath, time.Now().Format("20060102_150405"))
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
+20 -2
View File
@@ -3,6 +3,7 @@ package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
@@ -34,8 +35,25 @@ func Add2FA() *gormigrate.Migration {
return fmt.Errorf("failed to get database path: %v", err)
}
// Create backup file with timestamp
backupFile := fmt.Sprintf("%s.backup.%s", dbPath, time.Now().Format("20060102_150405"))
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
+179
View File
@@ -270,3 +270,182 @@ func (s *Service) sendEmail(toEmail, subject, htmlContent string) error {
return client.Quit()
}
}
// SendTestEmail sends a test email to verify email configuration
func (s *Service) SendTestEmail(toEmail, subject, message string) error {
if !s.Config.Email.Enabled {
return fmt.Errorf("email service is disabled")
}
// Use default subject if not provided
if subject == "" {
subject = "Test Email from GoMFT"
}
// Use default message if not provided
if message == "" {
message = "This is a test email from GoMFT to verify the email configuration is working correctly."
}
// Create email data for template
data := map[string]interface{}{
"Subject": subject,
"Message": message,
"AppName": "GoMFT",
"Year": time.Now().Year(),
"SMTPServer": s.Config.Email.Host,
"SMTPPort": s.Config.Email.Port,
"FromEmail": s.Config.Email.FromEmail,
"CurrentTime": time.Now().Format(time.RFC1123Z),
}
// Generate email content
htmlContent, err := s.generateTestEmailHTML(data)
if err != nil {
return err
}
// Send the email
return s.sendEmail(toEmail, subject, htmlContent)
}
// generateTestEmailHTML generates the HTML content for test emails
func (s *Service) generateTestEmailHTML(data map[string]interface{}) (string, error) {
// HTML template for test email
tmpl, err := template.New("testEmail").Parse(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Subject}}</title>
<style>
/* Base styles */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background-color: #f9fafb;
color: #374151;
line-height: 1.5;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.header {
text-align: center;
padding: 20px 0;
border-bottom: 1px solid #e5e7eb;
}
.logo {
width: 60px;
height: 60px;
margin: 0 auto 15px;
background-color: #2563eb;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.logo-icon {
font-size: 24px;
color: white;
font-weight: bold;
}
h1 {
color: #111827;
font-size: 24px;
margin: 0;
}
.content {
padding: 30px 20px;
}
p {
margin: 0 0 15px;
color: #4b5563;
}
.info-box {
margin: 20px 0;
padding: 15px;
background-color: #f3f4f6;
border-radius: 6px;
color: #4b5563;
}
.info-item {
display: flex;
margin-bottom: 8px;
}
.info-label {
font-weight: bold;
width: 140px;
}
.note {
font-size: 14px;
color: #6b7280;
margin-top: 30px;
padding-top: 15px;
border-top: 1px solid #e5e7eb;
}
.footer {
text-align: center;
font-size: 12px;
color: #9ca3af;
padding: 20px 0;
background-color: #f9fafb;
border-radius: 0 0 8px 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">
<div class="logo-icon">G</div>
</div>
<h1>{{.Subject}}</h1>
</div>
<div class="content">
<p>{{.Message}}</p>
<div class="info-box">
<div class="info-item">
<div class="info-label">SMTP Server:</div>
<div>{{.SMTPServer}}:{{.SMTPPort}}</div>
</div>
<div class="info-item">
<div class="info-label">From:</div>
<div>{{.FromEmail}}</div>
</div>
<div class="info-item">
<div class="info-label">Sent:</div>
<div>{{.CurrentTime}}</div>
</div>
</div>
<div class="note">
<p>This is a test email sent from the GoMFT admin interface. If you've received this email, your email configuration is working correctly.</p>
</div>
</div>
<div class="footer">
<p>&copy; {{.Year}} {{.AppName}}. All rights reserved.</p>
</div>
</div>
</body>
</html>
`)
if err != nil {
return "", err
}
var result bytes.Buffer
if err := tmpl.Execute(&result, data); err != nil {
return "", err
}
return result.String(), nil
}
+84 -14
View File
@@ -173,6 +173,11 @@ func NewLogger() *Logger {
filepath.Join(logsDir, "scheduler.log"), maxSize, maxBackups, maxAge, compress, logLevel.String())
}
if logLevel >= LogLevelDebug {
logger.Debug.Printf("Log rotation details: file=%s, maxSize=%dMB, maxBackups=%d, maxAge=%d days, compress=%v",
filepath.Join(logsDir, "scheduler.log"), maxSize, maxBackups, maxAge, compress)
}
return logger
}
@@ -258,6 +263,8 @@ func (s *Scheduler) loadJobs() {
}
func (s *Scheduler) ScheduleJob(job *db.Job) error {
s.log.LogDebug("Attempting to schedule job ID %d: %+v", job.ID, job)
s.log.LogInfo("Scheduling job %d: %s with schedule %s", job.ID, job.Name, job.Schedule)
// Remove existing job if it exists
@@ -279,6 +286,8 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error {
schedule = "0 " + schedule
}
s.log.LogDebug("Converted schedule from '%s' to '%s'", job.Schedule, schedule)
// Validate cron expression
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
_, err := parser.Parse(schedule)
@@ -286,6 +295,8 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error {
return fmt.Errorf("invalid cron expression '%s': %w", job.Schedule, err)
}
s.log.LogDebug("Validated cron expression '%s' for job %d", schedule, job.ID)
// Schedule the job
entryID, err := s.cron.AddFunc(job.Schedule, func() {
s.executeJob(job.ID)
@@ -296,6 +307,8 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error {
return err
}
s.log.LogDebug("Scheduled job %d with cron entry ID %d", job.ID, entryID)
// Store mapping of job ID to cron entry ID
s.jobMutex.Lock()
s.jobs[job.ID] = entryID
@@ -313,6 +326,9 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error {
}
func (s *Scheduler) executeJob(jobID uint) {
s.log.LogDebug("Entering executeJob for job ID %d", jobID)
defer s.log.LogDebug("Exiting executeJob for job ID %d", jobID)
s.log.LogInfo("Starting execution of job %d", jobID)
// Get job details
@@ -322,6 +338,8 @@ func (s *Scheduler) executeJob(jobID uint) {
return
}
s.log.LogDebug("Loaded job details: %+v", job)
// Get all configurations associated with this job
configs, err := s.db.GetConfigsForJob(jobID)
if err != nil {
@@ -329,12 +347,45 @@ func (s *Scheduler) executeJob(jobID uint) {
return
}
s.log.LogDebug("Loaded %d configurations for job %d", len(configs), jobID)
if len(configs) == 0 {
s.log.LogError("Error: job %d has no associated configurations", jobID)
return
}
s.log.LogInfo("Loaded job %d with %d configurations", jobID, len(configs))
// Get the ordered config IDs from the job
orderedConfigIDs := job.GetConfigIDsList()
s.log.LogDebug("Ordered config IDs for job %d: %v", jobID, orderedConfigIDs)
// Create a map of configs for easy lookup
configMap := make(map[uint]db.TransferConfig)
for _, config := range configs {
configMap[config.ID] = config
}
// Process configurations in the specified order
var orderedConfigs []db.TransferConfig
// First, add configs in the order specified in the job's ConfigIDs
for _, configID := range orderedConfigIDs {
if config, exists := configMap[configID]; exists {
orderedConfigs = append(orderedConfigs, config)
delete(configMap, configID) // Remove from map to avoid duplicates
}
}
// Add any remaining configs not in the ordered list (shouldn't happen, but just in case)
for _, config := range configMap {
orderedConfigs = append(orderedConfigs, config)
}
s.log.LogInfo("Processing job %d with %d configurations in specified order", jobID, len(orderedConfigs))
// Log the order of execution
for i, config := range orderedConfigs {
s.log.LogDebug("Execution order %d/%d: Config ID %d (%s)", i+1, len(orderedConfigs), config.ID, config.Name)
}
// Update job last run time
startTime := time.Now()
@@ -343,9 +394,9 @@ func (s *Scheduler) executeJob(jobID uint) {
s.log.LogError("Error updating job last run time for job %d: %v", jobID, err)
}
// Process each configuration
for i, config := range configs {
s.processConfiguration(&job, &config, i+1, len(configs))
// Process each configuration in the specified order
for i, config := range orderedConfigs {
s.processConfiguration(&job, &config, i+1, len(orderedConfigs))
}
// Update next run time after execution
@@ -366,6 +417,8 @@ func (s *Scheduler) executeJob(jobID uint) {
// processConfiguration processes a single configuration for a job
func (s *Scheduler) processConfiguration(job *db.Job, config *db.TransferConfig, index int, totalConfigs int) {
s.log.LogDebug("Processing configuration %d: %+v", config.ID, config)
s.log.LogInfo("Processing configuration %d (%d/%d) for job %d: source=%s:%s, dest=%s:%s",
config.ID,
index,
@@ -392,12 +445,16 @@ func (s *Scheduler) processConfiguration(job *db.Job, config *db.TransferConfig,
return
}
s.log.LogDebug("Creating job history record: %+v", history)
// Execute the configuration transfer
s.executeConfigTransfer(*job, *config, history)
}
// executeConfigTransfer performs the actual file transfer for a single configuration
func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig, history *db.JobHistory) {
s.log.LogDebug("Starting transfer for config %d with params: %+v", config.ID, config)
// Track files already processed in this job execution to prevent duplicates
processedFiles := make(map[string]bool)
@@ -447,7 +504,7 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
listArgs = append(listArgs, sourceListPath)
// Execute lsjson command
s.log.LogInfo("Listing files with metadata for job %d, config %d: rclone %s", job.ID, config.ID, strings.Join(listArgs, " "))
s.log.LogDebug("Full lsjson command: %s %v", os.Getenv("RCLONE_PATH"), listArgs)
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
@@ -455,6 +512,19 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
listCmd := exec.Command(rclonePath, listArgs...)
listOutput, listErr := listCmd.CombinedOutput()
// Add debug logging of raw output
if listErr == nil {
s.log.LogDebug("Raw lsjson output for job %d config %d:\n%s",
job.ID,
config.ID,
string(listOutput))
} else {
s.log.LogDebug("Raw lsjson output (error case) for job %d config %d:\n%s",
job.ID,
config.ID,
string(listOutput))
}
if listErr != nil {
s.log.LogError("Error listing files for job %d, config %d: %v", job.ID, config.ID, listErr)
// s.log.Debug.Printf("Output: %s", string(listOutput))
@@ -550,7 +620,7 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
concurrencySemaphore := make(chan struct{}, maxConcurrent)
// Process each file individually
for _, fileEntry := range files {
for i, fileEntry := range files {
fileName, ok := fileEntry["Path"].(string)
if !ok || fileName == "" {
continue
@@ -666,7 +736,8 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
currentModTime := modTime
// Log the file information that will be processed
s.log.LogDebug("Processing file: %s, size: %d, hash: %s", currentFileName, currentFileSize, currentFileHash)
s.log.LogDebug("Processing file %d/%d: %s (Size: %d, Hash: %s)",
i+1, len(files), currentFileName, currentFileSize, currentFileHash)
// Start goroutine for concurrent processing
go func() {
@@ -740,13 +811,8 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
transferArgs = append(transferArgs, sourcePath, destPath)
// Execute transfer for this file
s.log.LogInfo("Executing rclone transfer command for job %d, config %d, file %s: rclone %s",
job.ID, config.ID, currentFileName, strings.Join(transferArgs, " "))
// Get the rclone path from the environment variable or use the default path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
s.log.LogDebug("Full transfer command: %s %v", rclonePath, transferArgs)
s.log.LogDebug("Environment: RCLONE_PATH=%s", os.Getenv("RCLONE_PATH"))
cmd := exec.Command(rclonePath, transferArgs...)
fileOutput, fileErr := cmd.CombinedOutput()
@@ -1087,6 +1153,8 @@ func (s *Scheduler) sendWebhookNotification(job *db.Job, history *db.JobHistory,
return
}
s.log.LogDebug("Webhook payload: %s", string(jsonPayload))
// Create HTTP request
req, err := http.NewRequest("POST", job.WebhookURL, bytes.NewBuffer(jsonPayload))
if err != nil {
@@ -1116,6 +1184,8 @@ func (s *Scheduler) sendWebhookNotification(job *db.Job, history *db.JobHistory,
}
}
s.log.LogDebug("Webhook headers: %+v", req.Header)
// Send the request with a timeout
client := &http.Client{
Timeout: 10 * time.Second,
+56 -5
View File
@@ -63,17 +63,28 @@ func (h *Handlers) HandleAdminTools(c *gin.Context) {
data.TotalUsers = int(totalUsers)
}
// Get last backup time and backup count
data.LastBackupTime, data.BackupCount = h.getBackupInfo()
// Get backup info (last backup time and count)
lastBackup, backupCount := h.getBackupInfo()
data.LastBackupTime = lastBackup
data.BackupCount = backupCount
// Get list of backup files
data.BackupFiles = h.getBackupFiles()
// Check for maintenance issues
// Get maintenance message if any
data.MaintenanceMessage = h.checkMaintenanceIssues()
// Render the admin tools page
components.AdminTools(components.CreateTemplateContext(c), data).Render(c, c.Writer)
// Add SMTP server info if available
if h.Email != nil && h.Email.Config != nil && h.Email.Config.Email.Host != "" {
smtpServer := h.Email.Config.Email.Host
if h.Email.Config.Email.Port != 0 {
data.SmtpServer = fmt.Sprintf("%s:%d", smtpServer, h.Email.Config.Email.Port)
} else {
data.SmtpServer = smtpServer
}
}
components.AdminTools(c.Request.Context(), data).Render(c.Request.Context(), c.Writer)
}
// HandleBackupDatabase handles the backup database request
@@ -1368,3 +1379,43 @@ func (h *Handlers) HandleImportConfigsFromFile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d configs imported successfully", imported)})
}
// HandleTestEmail handles the POST /admin/test-email route
func (h *Handlers) HandleTestEmail(c *gin.Context) {
// Parse the form
recipient := c.PostForm("recipient")
subject := c.PostForm("subject")
message := c.PostForm("message")
// Validate required fields
if recipient == "" {
components.EmailTestToast(false, "Recipient email is required").Render(c.Request.Context(), c.Writer)
return
}
// Get SMTP server info for display
smtpServer := ""
if h.Email != nil && h.Email.Config != nil && h.Email.Config.Email.Host != "" {
smtpServer = h.Email.Config.Email.Host
if h.Email.Config.Email.Port != 0 {
smtpServer = fmt.Sprintf("%s:%d", smtpServer, h.Email.Config.Email.Port)
}
}
// Send the test email
if h.Email == nil {
components.EmailTestToast(false, "Email service is not configured").Render(c.Request.Context(), c.Writer)
return
}
err := h.Email.SendTestEmail(recipient, subject, message)
if err != nil {
// Failed to send email
components.EmailTestToast(false, fmt.Sprintf("Failed to send email: %v", err)).Render(c.Request.Context(), c.Writer)
return
}
// Email sent successfully
successMsg := fmt.Sprintf("Test email sent successfully to %s", recipient)
components.EmailTestToast(true, successMsg).Render(c.Request.Context(), c.Writer)
}
+168 -40
View File
@@ -2,8 +2,10 @@ package handlers
import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
@@ -141,6 +143,9 @@ func (h *Handlers) HandleEditJob(c *gin.Context) {
func (h *Handlers) HandleCreateJob(c *gin.Context) {
userID := c.GetUint("userID")
// Debug logging
log.Printf("HandleCreateJob: Form data received: %v", c.Request.PostForm)
// Parse form data
var job db.Job
if err := c.ShouldBind(&job); err != nil {
@@ -148,6 +153,9 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) {
return
}
// Debug logging
log.Printf("HandleCreateJob: Job after binding: %+v", job)
// Get multiple config IDs from form
configIDs := c.PostFormArray("config_ids[]")
if len(configIDs) == 0 {
@@ -155,35 +163,83 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) {
return
}
// Debug logging
log.Printf("HandleCreateJob: config_ids[]: %v", configIDs)
// Process config IDs
var configIDsList []uint
for _, configIDStr := range configIDs {
configID, err := strconv.ParseUint(configIDStr, 10, 32)
if err != nil {
c.String(http.StatusBadRequest, "Invalid configuration ID format")
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 we have an explicit order specified
configOrder := c.PostForm("config_order")
log.Printf("HandleCreateJob: config_order: %s", configOrder)
// 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")
if configOrder != "" {
// Parse the ordered list
orderStrings := strings.Split(configOrder, ",")
log.Printf("HandleCreateJob: order strings: %v", orderStrings)
for _, configIDStr := range orderStrings {
configID, err := strconv.ParseUint(configIDStr, 10, 32)
if err != nil {
log.Printf("HandleCreateJob: Error parsing config ID: %v", err)
c.String(http.StatusBadRequest, "Invalid configuration ID format in order")
return
}
}
configIDsList = append(configIDsList, uint(configID))
// Verify that the config exists and belongs to the user
var config db.TransferConfig
if err := h.DB.First(&config, configID).Error; err != nil {
log.Printf("HandleCreateJob: Invalid config ID: %d, error: %v", configID, err)
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))
}
} else {
// Fall back to unordered config IDs
log.Printf("HandleCreateJob: No config_order found, using checkbox order")
for _, configIDStr := range configIDs {
configID, err := strconv.ParseUint(configIDStr, 10, 32)
if err != nil {
c.String(http.StatusBadRequest, "Invalid configuration ID format")
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))
}
}
// Debug logging
log.Printf("HandleCreateJob: Final configIDsList: %v", configIDsList)
// Set the first config ID for backward compatibility
if len(configIDsList) > 0 {
job.ConfigID = configIDsList[0]
@@ -214,6 +270,10 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) {
// Set the config IDs list
job.SetConfigIDsList(configIDsList)
// Debug logging
log.Printf("HandleCreateJob: Job after setting ConfigIDsList: %+v", job)
log.Printf("HandleCreateJob: Job.ConfigIDs: %s", job.ConfigIDs)
// Set the boolean fields - handle both "on" and "true" values for checkboxes
enabledVal := c.Request.FormValue("enabled")
jobEnabledValue := enabledVal == "on" || enabledVal == "true"
@@ -239,10 +299,13 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) {
// Create the job
if err := h.DB.CreateJob(&job); err != nil {
log.Printf("HandleCreateJob: Error creating job: %v", err)
c.String(http.StatusInternalServerError, "Failed to create job")
return
}
log.Printf("HandleCreateJob: Job successfully created with ID: %d", job.ID)
// Schedule the job with the scheduler
if err := h.Scheduler.ScheduleJob(&job); err != nil {
c.String(http.StatusInternalServerError, "Job created but scheduling failed: "+err.Error())
@@ -257,8 +320,13 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
// Debug logging
log.Printf("HandleUpdateJob: Updating job ID: %s", id)
log.Printf("HandleUpdateJob: Form data received: %v", c.Request.PostForm)
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
log.Printf("HandleUpdateJob: Job not found: %v", err)
c.String(http.StatusNotFound, "Job not found")
return
}
@@ -275,49 +343,102 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
// Get the old job values for comparison
oldJob := job
log.Printf("HandleUpdateJob: Original job: %+v", oldJob)
log.Printf("HandleUpdateJob: Original job ConfigIDs: %s", oldJob.ConfigIDs)
// Parse form data
if err := c.ShouldBind(&job); err != nil {
log.Printf("HandleUpdateJob: Error binding form data: %v", err)
c.String(http.StatusBadRequest, "Invalid form data")
return
}
log.Printf("HandleUpdateJob: Job after binding: %+v", job)
// Get multiple config IDs from form
configIDs := c.PostFormArray("config_ids[]")
if len(configIDs) == 0 {
log.Printf("HandleUpdateJob: No config_ids[] found in form data")
c.String(http.StatusBadRequest, "At least one configuration must be selected")
return
}
log.Printf("HandleUpdateJob: config_ids[]: %v", configIDs)
// Process config IDs
var configIDsList []uint
for _, configIDStr := range configIDs {
configID, err := strconv.ParseUint(configIDStr, 10, 32)
if err != nil {
c.String(http.StatusBadRequest, "Invalid configuration ID format")
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 we have an explicit order specified
configOrder := c.PostForm("config_order")
log.Printf("HandleUpdateJob: config_order: %s", configOrder)
// 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")
if configOrder != "" {
// Parse the ordered list
orderStrings := strings.Split(configOrder, ",")
log.Printf("HandleUpdateJob: order strings: %v", orderStrings)
for _, configIDStr := range orderStrings {
configID, err := strconv.ParseUint(configIDStr, 10, 32)
if err != nil {
log.Printf("HandleUpdateJob: Error parsing config ID: %v", err)
c.String(http.StatusBadRequest, "Invalid configuration ID format in order")
return
}
}
configIDsList = append(configIDsList, uint(configID))
// Verify that the config exists
var config db.TransferConfig
if err := h.DB.First(&config, configID).Error; err != nil {
log.Printf("HandleUpdateJob: Invalid config ID: %d, error: %v", configID, err)
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))
}
} else {
// Fall back to unordered config IDs
log.Printf("HandleUpdateJob: No config_order found, using checkbox order")
for _, configIDStr := range configIDs {
configID, err := strconv.ParseUint(configIDStr, 10, 32)
if err != nil {
c.String(http.StatusBadRequest, "Invalid configuration ID format")
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))
}
}
// Debug logging
log.Printf("HandleUpdateJob: Final configIDsList: %v", configIDsList)
// Set the first config ID for backward compatibility
if len(configIDsList) > 0 {
job.ConfigID = configIDsList[0]
@@ -332,6 +453,10 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
// Set the config IDs list
job.SetConfigIDsList(configIDsList)
// Debug logging
log.Printf("HandleUpdateJob: Job after setting ConfigIDsList: %+v", job)
log.Printf("HandleUpdateJob: Job.ConfigIDs: %s", job.ConfigIDs)
// Set the boolean fields - handle both "on" and "true" values for checkboxes
enabledVal := c.Request.FormValue("enabled")
jobEnabledValue := enabledVal == "on" || enabledVal == "true"
@@ -357,10 +482,13 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
job.Config = db.TransferConfig{}
if err := h.DB.UpdateJob(&job); err != nil {
log.Printf("HandleUpdateJob: Error updating job: %v", err)
c.String(http.StatusInternalServerError, "Failed to update job")
return
}
log.Printf("HandleUpdateJob: Job successfully updated")
// Reschedule the job with the scheduler
if err := h.Scheduler.ScheduleJob(&job); err != nil {
c.String(http.StatusInternalServerError, "Job updated but scheduling failed: "+err.Error())
+3
View File
@@ -110,6 +110,9 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
admin.GET("/logs/refresh", h.HandleRefreshLogs)
admin.GET("/logs/view/:fileName", h.HandleViewLog)
admin.GET("/logs/download/:fileName", h.HandleDownloadLog)
// Email test route
admin.POST("/test-email", h.HandleTestEmail)
}
// API routes
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
// "github.com/starfleetcptn/gomft/internal/api"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/config"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/scheduler"
@@ -27,7 +28,7 @@ func main() {
gin.SetMode(gin.ReleaseMode)
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Starting GoMFT server...")
log.Printf("Starting GoMFT server version %s...", components.AppVersion)
// Initialize configuration
cfg, err := config.Load()