mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-11 17:10:44 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7de4f57fc3 | ||
|
|
9a49d87777 | ||
|
|
73362793ad | ||
|
|
d6bd471eb0 | ||
|
|
1a1df435de | ||
|
|
e031e67b3c | ||
|
|
402072540f | ||
|
|
be19a5efde | ||
|
|
aa04c76e1f | ||
|
|
1981764f80 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">{
|
||||
"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": {
|
||||
"type": "local",
|
||||
"path": "/path/to/source"
|
||||
},
|
||||
"destination": {
|
||||
"type": "s3",
|
||||
"path": "bucket/path"
|
||||
}
|
||||
}</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
@@ -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
@@ -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 © { 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")
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>© {{.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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user