initial commit
@@ -0,0 +1,66 @@
|
|||||||
|
name: Build and Publish Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
manual_version:
|
||||||
|
description: 'Manual version override (leave empty to use git tag)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Use github.repository as the default image name
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Set the permissions needed for the GitHub token to push to GHCR
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Set up Docker Buildx for efficient builds
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# Login to GitHub Container Registry
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Extract metadata for Docker image
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=sha,format=long
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
# Build and push Docker image
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
name: Go Multi-Architecture Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
manual_version:
|
||||||
|
description: 'Manual version override (leave empty to use git tag)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
# Add permissions at workflow level
|
||||||
|
permissions:
|
||||||
|
contents: write # This is required for creating releases
|
||||||
|
packages: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Go Binaries
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.24.x'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
go mod download
|
||||||
|
# Install templ compiler for template generation
|
||||||
|
go install github.com/a-h/templ/cmd/templ@latest
|
||||||
|
|
||||||
|
- name: Generate template files
|
||||||
|
run: templ generate
|
||||||
|
|
||||||
|
- name: Set Version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
|
||||||
|
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
|
||||||
|
echo "version=${{ github.event.inputs.manual_version }}" >> $GITHUB_OUTPUT
|
||||||
|
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
VERSION=$(git describe --tags --abbrev=0)-$(git rev-parse --short HEAD)
|
||||||
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build for multiple platforms
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
|
||||||
|
# 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 .
|
||||||
|
|
||||||
|
# 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 .
|
||||||
|
|
||||||
|
# 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 .
|
||||||
|
|
||||||
|
# Create checksums
|
||||||
|
cd dist
|
||||||
|
sha256sum * > SHA256SUMS.txt
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: binaries
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/') || github.event.inputs.manual_version != ''
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: Release ${{ steps.version.outputs.version }}
|
||||||
|
files: |
|
||||||
|
dist/*
|
||||||
|
generate_release_notes: true
|
||||||
|
draft: false
|
||||||
|
# The following line is not needed as we set permissions at workflow level
|
||||||
|
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, build output
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.cov
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Build directories
|
||||||
|
_build/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Vendor directory
|
||||||
|
/vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# IDE/editor specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Ignore all Go files in the components directory
|
||||||
|
components/*.go
|
||||||
|
|
||||||
|
# Ignore the data directory
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Ignore the tmp directory
|
||||||
|
tmp/
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache git build-base
|
||||||
|
|
||||||
|
# Install templ compiler
|
||||||
|
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||||
|
|
||||||
|
# Copy go module files first for better layer caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy the rest of the source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Generate template files from .templ files
|
||||||
|
RUN templ generate
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN CGO_ENABLED=1 GOOS=linux go build -o gomft
|
||||||
|
|
||||||
|
# Install rclone
|
||||||
|
RUN apk add --no-cache curl unzip && \
|
||||||
|
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip && \
|
||||||
|
unzip rclone-current-linux-amd64.zip && \
|
||||||
|
cd rclone-*-linux-amd64 && \
|
||||||
|
cp rclone /usr/local/bin/ && \
|
||||||
|
chmod 755 /usr/local/bin/rclone && \
|
||||||
|
cd .. && \
|
||||||
|
rm -rf rclone*
|
||||||
|
|
||||||
|
# Create a smaller runtime image
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata sqlite bash
|
||||||
|
|
||||||
|
# Copy the binary from the builder stage
|
||||||
|
COPY --from=builder /app/gomft /app/
|
||||||
|
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
|
||||||
|
|
||||||
|
# Copy static files and configurations
|
||||||
|
COPY static/ /app/static/
|
||||||
|
COPY components/ /app/components/
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data/gomft
|
||||||
|
|
||||||
|
# Set executable permissions
|
||||||
|
RUN chmod +x /app/gomft
|
||||||
|
|
||||||
|
# Expose the application port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["/app/gomft"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020 Ben Busby
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# GoMFT - Go Managed File Transfer
|
||||||
|
|
||||||
|
GoMFT is a web-based managed file transfer application built with Go, leveraging rclone for robust file transfer capabilities. It provides a user-friendly interface for configuring, scheduling, and monitoring file transfers across various storage providers.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|

|
||||||
|
*Dashboard showing active transfers and system status*
|
||||||
|
|
||||||
|

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

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

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

|
||||||
|
*Create user accounts and manage them*
|
||||||
|
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
|
||||||
|
- Amazon S3
|
||||||
|
- MinIO
|
||||||
|
- Backblaze B2
|
||||||
|
- Azure Blob Storage
|
||||||
|
- Google Cloud Storage
|
||||||
|
- SFTP
|
||||||
|
- FTP
|
||||||
|
- SMB/CIFS shares
|
||||||
|
- Local filesystem
|
||||||
|
- And more via rclone
|
||||||
|
- **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options
|
||||||
|
- **Transfer Monitoring**: Real-time status updates and detailed transfer logs with bytes and files transferred statistics
|
||||||
|
- **Web Interface**: User-friendly interface for managing transfers, built with Templ components
|
||||||
|
- **File Pattern Matching**: Support for file patterns to filter files during transfers
|
||||||
|
- **File Output Patterns**: Dynamic naming of destination files using patterns with date variables
|
||||||
|
- **Archive Function**: Option to archive transferred files for backup and compliance
|
||||||
|
- **Transfer Configurations**: Full control over source and destination connection parameters
|
||||||
|
- **Job Management**: Create, edit, and monitor transfer jobs with scheduling
|
||||||
|
- **Security**: Role-based access control with admin-managed user accounts and secure password management
|
||||||
|
- **Password Recovery**: Self-service password reset via email with secure token-based authentication
|
||||||
|
- **User Profile Management**: Personal settings including theme preferences
|
||||||
|
- **Modern UI**: Built with Templ, HTMX and Tailwind CSS for a responsive experience
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go 1.21 or later
|
||||||
|
- rclone installed and configured
|
||||||
|
- SQLite 3
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/starfleetcptn/gomft.git
|
||||||
|
cd gomft
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
go mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build the application:
|
||||||
|
```bash
|
||||||
|
go build -o gomft
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
GoMFT uses a configuration file located at `./data/gomft/config.json`. On first run, a default configuration will be created:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_address": ":8080",
|
||||||
|
"data_dir": "./data/gomft",
|
||||||
|
"backup_dir": "./data/gomft/backups",
|
||||||
|
"jwt_secret": "your-secret-key",
|
||||||
|
"base_url": "http://localhost:8080",
|
||||||
|
"email": {
|
||||||
|
"enabled": false,
|
||||||
|
"host": "smtp.example.com",
|
||||||
|
"port": 587,
|
||||||
|
"username": "user@example.com",
|
||||||
|
"password": "your-password",
|
||||||
|
"from_email": "gomft@example.com",
|
||||||
|
"from_name": "GoMFT",
|
||||||
|
"reply_to": "",
|
||||||
|
"enable_tls": true,
|
||||||
|
"require_auth": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
- `server_address`: The address and port to run the server on
|
||||||
|
- `data_dir`: Directory for storing application data
|
||||||
|
- `backup_dir`: Directory for storing database backups
|
||||||
|
- `jwt_secret`: Secret key for JWT token generation
|
||||||
|
- `base_url`: Base URL for generating links in emails (e.g., password reset links)
|
||||||
|
- `email`: Email configuration settings for system notifications and password resets
|
||||||
|
- `enabled`: Set to `true` to enable email functionality
|
||||||
|
- `host`: SMTP server hostname
|
||||||
|
- `port`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
|
||||||
|
- `username`: Username for SMTP authentication
|
||||||
|
- `password`: Password for SMTP authentication
|
||||||
|
- `from_email`: Email address used as sender
|
||||||
|
- `from_name`: Name displayed as the sender
|
||||||
|
- `reply_to`: Optional reply-to email address
|
||||||
|
- `enable_tls`: Set to `true` to use TLS for secure email transmission
|
||||||
|
- `require_auth`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Start the server:
|
||||||
|
```bash
|
||||||
|
./gomft
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Access the web interface at `http://localhost:8080`
|
||||||
|
|
||||||
|
3. Log in with the default admin account:
|
||||||
|
- Email: `admin@example.com`
|
||||||
|
- Password: `admin`
|
||||||
|
- **Important**: Change this password immediately after first login
|
||||||
|
|
||||||
|
4. Create transfer configurations:
|
||||||
|
- Navigate to "Transfer Configs" section
|
||||||
|
- Configure source and destination locations with connection details
|
||||||
|
- Set file patterns and archive options as needed
|
||||||
|
|
||||||
|
5. Create jobs using your configurations:
|
||||||
|
- Navigate to "Jobs" section
|
||||||
|
- Select an existing transfer config
|
||||||
|
- Set up a schedule using cron expressions or run manually
|
||||||
|
- Enable/disable as needed
|
||||||
|
|
||||||
|
6. Monitor transfers:
|
||||||
|
- View active and completed transfers on the Dashboard
|
||||||
|
- Check detailed transfer history with performance metrics
|
||||||
|
- View job run details including any error messages
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
|
||||||
|
GoMFT uses a role-based access control system:
|
||||||
|
|
||||||
|
- **Administrators**: Can create and manage users, access all features
|
||||||
|
- **Regular Users**: Can manage transfers and view history
|
||||||
|
|
||||||
|
User management features:
|
||||||
|
- Only administrators can create new user accounts
|
||||||
|
- User passwords are securely hashed with bcrypt
|
||||||
|
- Password history tracking prevents reuse of recent passwords
|
||||||
|
- Account lockout after multiple failed login attempts
|
||||||
|
- Self-service password reset via secure email links
|
||||||
|
- JWT-based authentication with tokens
|
||||||
|
- User theme preference settings (light/dark)
|
||||||
|
|
||||||
|
### Transfer Configuration Options
|
||||||
|
|
||||||
|
1. **Source/Destination Types**:
|
||||||
|
- Local filesystem
|
||||||
|
- Amazon S3
|
||||||
|
- MinIO (S3-compatible storage)
|
||||||
|
- Backblaze B2
|
||||||
|
- SFTP
|
||||||
|
- FTP
|
||||||
|
- SMB/CIFS shares
|
||||||
|
- And many more via rclone
|
||||||
|
|
||||||
|
2. **Connection Options**:
|
||||||
|
- Host/server addresses
|
||||||
|
- Authentication (username/password or key files)
|
||||||
|
- Port configurations
|
||||||
|
- Cloud credentials (access keys, secret keys)
|
||||||
|
- Bucket and region settings
|
||||||
|
- Custom endpoints
|
||||||
|
- Custom rclone flags
|
||||||
|
|
||||||
|
3. **File Options**:
|
||||||
|
- File patterns for filtering (e.g., `*.txt`, `data_*.csv`)
|
||||||
|
- Output patterns for dynamic naming
|
||||||
|
- Archive options for transferred files
|
||||||
|
|
||||||
|
4. **Schedule Options**:
|
||||||
|
- Cron expressions for flexible scheduling
|
||||||
|
- Manual execution
|
||||||
|
- Enable/disable schedules
|
||||||
|
|
||||||
|
### Email Notifications
|
||||||
|
|
||||||
|
GoMFT supports email notifications for various features:
|
||||||
|
|
||||||
|
- **Password Reset**: Users can request password reset links sent to their registered email
|
||||||
|
- **Styled Emails**: Professional HTML emails that match the application's design theme
|
||||||
|
- **Secure Tokens**: One-time use secure tokens with 15-minute expiration for enhanced security
|
||||||
|
- **Flexible Configuration**: Easily configure your SMTP server settings
|
||||||
|
- **Authentication Options**: Support for both authenticated and unauthenticated SMTP servers
|
||||||
|
- **TLS Support**: Secure communication with your SMTP server
|
||||||
|
- **Development Mode**: When emails are disabled, reset links are logged to the console
|
||||||
|
|
||||||
|
To configure email functionality:
|
||||||
|
|
||||||
|
1. Edit the `config.json` file and provide your SMTP server details
|
||||||
|
2. Set `"enabled": true` in the email configuration section
|
||||||
|
3. Ensure the `base_url` setting is configured correctly for your deployment
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── components/ # Templ components for UI
|
||||||
|
├── internal/
|
||||||
|
│ ├── api/ # REST API handlers
|
||||||
|
│ ├── auth/ # Authentication/authorization
|
||||||
|
│ ├── config/ # Configuration management
|
||||||
|
│ ├── db/ # Database models and operations
|
||||||
|
│ ├── email/ # Email service for notifications and password resets
|
||||||
|
│ ├── scheduler/ # Job scheduling and execution
|
||||||
|
│ └── web/ # Web interface handlers
|
||||||
|
├── static/ # Static assets
|
||||||
|
│ ├── css/
|
||||||
|
│ └── js/
|
||||||
|
└── main.go # Application entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
### Technology Stack
|
||||||
|
|
||||||
|
- **Backend**: Go with Gin web framework
|
||||||
|
- **Frontend**: Templ for Go HTML components
|
||||||
|
- **UI Enhancement**: HTMX for dynamic interactions
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **Authentication**: JWT (JSON Web Tokens)
|
||||||
|
- **Database**: GORM with SQLite
|
||||||
|
- **File Transfer**: rclone
|
||||||
|
|
||||||
|
### Building from Source
|
||||||
|
|
||||||
|
1. Install development dependencies:
|
||||||
|
```bash
|
||||||
|
go install github.com/cosmtrek/air@latest # Hot reload for development
|
||||||
|
go install github.com/a-h/templ/cmd/templ@latest # Templ template compiler
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Generate template code:
|
||||||
|
```bash
|
||||||
|
templ generate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run in development mode:
|
||||||
|
```bash
|
||||||
|
air
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Commit your changes
|
||||||
|
4. Push to the branch
|
||||||
|
5. Create a Pull Request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BackupFile struct {
|
||||||
|
Name string
|
||||||
|
Size string
|
||||||
|
ModTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminToolsData struct {
|
||||||
|
JobHistoryCount int
|
||||||
|
DatabaseSize string
|
||||||
|
LastBackupTime *time.Time
|
||||||
|
BackupCount int
|
||||||
|
SystemUptime string
|
||||||
|
ActiveJobs int
|
||||||
|
TotalConfigs int
|
||||||
|
TotalJobs int
|
||||||
|
TotalUsers int
|
||||||
|
DatabasePath string
|
||||||
|
BackupPath string
|
||||||
|
MaintenanceMessage string
|
||||||
|
BackupFiles []BackupFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog component for confirmation dialogs
|
||||||
|
templ Dialog(id string, title string, message string, confirmClass string, confirmText string, formId string, targetAction string) {
|
||||||
|
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||||
|
<div class="px-6 pt-5 pb-3 text-center">
|
||||||
|
<div class="flex justify-center mb-2">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ title }
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 text-center">
|
||||||
|
<p class="text-secondary-700 dark:text-secondary-300">
|
||||||
|
{ message }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 flex justify-end space-x-3">
|
||||||
|
<button type="button" class="btn-secondary" onclick={ hideDialog(id) }>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
if formId != "" {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={ confirmClass }
|
||||||
|
onclick={ submitFormAndHideDialog(formId, id) }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={ confirmClass }
|
||||||
|
hx-target={ targetAction }
|
||||||
|
onclick={ hideDialog(id) }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
script hideDialog(id string) {
|
||||||
|
document.getElementById(id).classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
script submitFormAndHideDialog(formId string, dialogId string) {
|
||||||
|
document.getElementById(formId).submit();
|
||||||
|
document.getElementById(dialogId).classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
script showDialog(id string) {
|
||||||
|
document.getElementById(id).classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup dialog component specifically for restore and delete actions
|
||||||
|
templ BackupActionDialog(id string, title string, message string, confirmClass string, confirmText string, action string, backupName string) {
|
||||||
|
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||||
|
<div class="px-6 pt-5 pb-3 text-center">
|
||||||
|
<div class="flex justify-center mb-2">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-400 text-5xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ title }
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 text-center">
|
||||||
|
<p class="text-secondary-700 dark:text-secondary-300">
|
||||||
|
{ message }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 flex justify-center space-x-3">
|
||||||
|
<button type="button" class="btn-secondary" onclick={ hideDialog(id) }>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
if action == "restore" {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-warning"
|
||||||
|
hx-post={ fmt.Sprintf("/admin/restore-database/%s", backupName) }
|
||||||
|
hx-swap="none"
|
||||||
|
onclick={ hideDialog(id) }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
} else if action == "delete" {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
hx-delete={ fmt.Sprintf("/admin/delete-backup/%s", backupName) }
|
||||||
|
hx-target="closest tr"
|
||||||
|
hx-swap="delete"
|
||||||
|
onclick={ hideDialog(id) }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AdminTools(ctx context.Context, data AdminToolsData) {
|
||||||
|
@LayoutWithContext("Admin Tools", ctx) {
|
||||||
|
<div class="py-6">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-tools mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
|
Admin Tools
|
||||||
|
</h1>
|
||||||
|
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<span id="current-date" class="font-medium"></span>
|
||||||
|
<script>
|
||||||
|
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if data.MaintenanceMessage != "" {
|
||||||
|
<div class="mb-6 bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 p-4 rounded">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-yellow-700 dark:text-yellow-300">
|
||||||
|
{ data.MaintenanceMessage }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- System Overview -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
|
||||||
|
<i class="fas fa-chart-line mr-2 text-primary-500"></i>
|
||||||
|
System Overview
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-database text-blue-500 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||||
|
Database Size
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ data.DatabaseSize }
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-history text-green-500 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||||
|
Job History Records
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ fmt.Sprint(data.JobHistoryCount) }
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-clock text-purple-500 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||||
|
System Uptime
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ data.SystemUptime }
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-tasks text-orange-500 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||||
|
Active Jobs
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ fmt.Sprint(data.ActiveJobs) }
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Tools Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Backup & Restore -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-save mr-2 text-primary-500"></i>
|
||||||
|
Backup & Restore
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex items-center justify-between text-sm text-secondary-500 dark:text-secondary-400 mb-2">
|
||||||
|
if data.LastBackupTime != nil {
|
||||||
|
<span>Last backup: { data.LastBackupTime.Format("Jan 02, 2006 15:04:05") }</span>
|
||||||
|
} else {
|
||||||
|
<span>Last backup: Never</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="backup-form"
|
||||||
|
hx-post="/admin/backup-database"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-indicator="#backup-indicator"
|
||||||
|
hx-on::after-request="htmx.trigger('#refresh-backups-trigger', 'click')"
|
||||||
|
class="btn-primary w-full flex items-center justify-center">
|
||||||
|
<i class="fas fa-database mr-2"></i>
|
||||||
|
<span>Backup Database</span>
|
||||||
|
<div id="backup-indicator" class="htmx-indicator ml-2">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Hidden refresh trigger -->
|
||||||
|
<button id="refresh-backups-trigger"
|
||||||
|
class="hidden"
|
||||||
|
hx-get="/admin/refresh-backups"
|
||||||
|
hx-target="#backups-container"></button>
|
||||||
|
|
||||||
|
<form id="export-configs-form" action="/admin/export-configs" method="GET">
|
||||||
|
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
|
||||||
|
<i class="fas fa-file-export mr-2"></i>
|
||||||
|
<span>Export All Configurations</span>
|
||||||
|
<div id="export-configs-indicator" class="htmx-indicator ml-2">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="export-jobs-form" action="/admin/export-jobs" method="GET">
|
||||||
|
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
|
||||||
|
<i class="fas fa-file-export mr-2"></i>
|
||||||
|
<span>Export All Jobs</span>
|
||||||
|
<div id="export-jobs-indicator" class="htmx-indicator ml-2">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
|
||||||
|
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">Restore Database</h4>
|
||||||
|
<form id="restore-form" hx-post="/admin/restore-database" hx-encoding="multipart/form-data" hx-swap="none" hx-indicator="#restore-indicator">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="flex-grow">
|
||||||
|
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||||
|
Select Backup File
|
||||||
|
</label>
|
||||||
|
<input type="file" name="backup_file" class="block w-full text-sm text-secondary-500 dark:text-secondary-400
|
||||||
|
file:mr-4 file:py-2 file:px-4
|
||||||
|
file:rounded-md file:border-0
|
||||||
|
file:text-sm file:font-medium
|
||||||
|
file:bg-primary-50 file:text-primary-700
|
||||||
|
dark:file:bg-primary-900 dark:file:text-primary-300
|
||||||
|
hover:file:bg-primary-100 dark:hover:file:bg-primary-800
|
||||||
|
focus:outline-none" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-warning flex-shrink-0 flex items-center justify-center h-10">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<i class="fas fa-upload mr-1"></i>
|
||||||
|
<span>Restore</span>
|
||||||
|
<div id="restore-indicator" class="htmx-indicator ml-2">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
Warning: This will replace your current database. Make sure to backup first!
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Maintenance Tools -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-wrench mr-2 text-primary-500"></i>
|
||||||
|
Maintenance Tools
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<!-- Clear Job History Button and Dialog -->
|
||||||
|
@Dialog("clear-job-dialog", "Clear Job History", "Are you sure you want to clear all job history? This cannot be undone.", "btn-danger", "Clear History", "purge-form", "")
|
||||||
|
<form id="purge-form" hx-post="/admin/clear-job-history" hx-swap="none" hx-indicator="#clear-history-indicator">
|
||||||
|
<button type="button" class="btn-danger w-full flex items-center justify-center" onclick={ showDialog("clear-job-dialog") }>
|
||||||
|
<i class="fas fa-trash-alt mr-2"></i>
|
||||||
|
<span>Clear Job History</span>
|
||||||
|
<div id="clear-history-indicator" class="htmx-indicator ml-2">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
|
||||||
|
<form id="vacuum-form" hx-post="/admin/vacuum-database" hx-swap="none" hx-indicator="#vacuum-indicator">
|
||||||
|
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
|
||||||
|
<i class="fas fa-compress-alt mr-2"></i>
|
||||||
|
<span>Optimize Database</span>
|
||||||
|
<div id="vacuum-indicator" class="htmx-indicator ml-2">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
|
||||||
|
Runs VACUUM to optimize the database and reclaim unused space.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available Backups -->
|
||||||
|
<div id="backups-container" class="mt-8">
|
||||||
|
@BackupsList(data)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Information -->
|
||||||
|
<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-info-circle mr-2 text-primary-500"></i>
|
||||||
|
System Information
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Database Path</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded overflow-auto">
|
||||||
|
{ data.DatabasePath }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Backup Directory</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded overflow-auto">
|
||||||
|
{ data.BackupPath }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Users</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ fmt.Sprint(data.TotalUsers) }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Configurations</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ fmt.Sprint(data.TotalConfigs) }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Jobs</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ fmt.Sprint(data.TotalJobs) }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupsList is a separate component for the backups list that can be refreshed via HTMX
|
||||||
|
templ BackupsList(data AdminToolsData) {
|
||||||
|
if len(data.BackupFiles) > 0 {
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-history mr-2 text-primary-500"></i>
|
||||||
|
Available Backups
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
hx-get="/admin/refresh-backups"
|
||||||
|
hx-target="#backups-container"
|
||||||
|
hx-indicator="#refresh-backups-indicator"
|
||||||
|
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
|
title="Refresh backups list">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="bg-secondary-50 dark:bg-secondary-900 rounded-lg overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-secondary-100 dark:bg-secondary-800">
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Name</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Size</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Date</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
|
for _, backup := range data.BackupFiles {
|
||||||
|
<tr class="hover:bg-secondary-100 dark:hover:bg-secondary-800 transition-colors">
|
||||||
|
<td class="px-4 py-2 text-sm text-secondary-900 dark:text-secondary-100">{ backup.Name }</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.Size }</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.ModTime.Format("Jan 02, 2006 15:04:05") }</td>
|
||||||
|
<td class="px-4 py-2 text-right">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/admin/download-backup/%s", backup.Name)) }
|
||||||
|
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 mr-2"
|
||||||
|
title="Download backup">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Restore Dialog -->
|
||||||
|
@BackupActionDialog(
|
||||||
|
fmt.Sprintf("restore-dialog-%s", backup.Name),
|
||||||
|
"RESTORE BACKUP",
|
||||||
|
fmt.Sprintf("Are you sure you want to restore the backup '%s'? This will replace your current database.", backup.Name),
|
||||||
|
"btn-warning",
|
||||||
|
"Restore",
|
||||||
|
"restore",
|
||||||
|
backup.Name,
|
||||||
|
)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-yellow-600 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300 mr-2"
|
||||||
|
title="Restore this backup"
|
||||||
|
onclick={ showDialog(fmt.Sprintf("restore-dialog-%s", backup.Name)) }>
|
||||||
|
<i class="fas fa-upload"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete Dialog -->
|
||||||
|
@BackupActionDialog(
|
||||||
|
fmt.Sprintf("delete-dialog-%s", backup.Name),
|
||||||
|
"DELETE BACKUP",
|
||||||
|
fmt.Sprintf("Are you sure you want to delete the backup '%s'? This cannot be undone.", backup.Name),
|
||||||
|
"btn-danger",
|
||||||
|
"Delete",
|
||||||
|
"delete",
|
||||||
|
backup.Name,
|
||||||
|
)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
title="Delete backup"
|
||||||
|
onclick={ showDialog(fmt.Sprintf("delete-dialog-%s", backup.Name)) }>
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-history mr-2 text-primary-500"></i>
|
||||||
|
Available Backups
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
hx-get="/admin/refresh-backups"
|
||||||
|
hx-target="#backups-container"
|
||||||
|
hx-indicator="#refresh-backups-indicator"
|
||||||
|
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
|
title="Refresh backups list">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-center py-6">
|
||||||
|
<div class="mx-auto w-12 h-12 rounded-full bg-secondary-100 dark:bg-secondary-800 flex items-center justify-center mb-4">
|
||||||
|
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-sm font-medium text-secondary-900 dark:text-secondary-100">No Backups Available</h3>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
Create a backup using the "Backup Database" button.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigsData struct {
|
||||||
|
Configs []db.TransferConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Configs(ctx context.Context, data ConfigsData) {
|
||||||
|
@LayoutWithContext("Transfer Configurations", ctx) {
|
||||||
|
<div class="py-6">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-cogs mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
|
Transfer Configurations
|
||||||
|
</h1>
|
||||||
|
<a href="/configs/new" class="btn-primary">
|
||||||
|
<i class="fas fa-plus mr-2"></i>
|
||||||
|
New Configuration
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
if len(data.Configs) == 0 {
|
||||||
|
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||||
|
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No configurations</h3>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer configuration.</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/configs/new" class="btn-primary">
|
||||||
|
<i class="fas fa-plus mr-2"></i>
|
||||||
|
Create First Configuration
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
|
for _, config := range data.Configs {
|
||||||
|
<li>
|
||||||
|
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
||||||
|
<div class="px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
|
||||||
|
{ config.Name }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="btn-secondary btn-sm">
|
||||||
|
<i class="fas fa-edit mr-1"></i>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
hx-delete={ fmt.Sprintf("/configs/%d", config.ID) }
|
||||||
|
hx-confirm="Are you sure you want to delete this configuration?"
|
||||||
|
hx-target="closest li"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="btn-danger btn-sm">
|
||||||
|
<i class="fas fa-trash-alt mr-1"></i>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 sm:flex sm:justify-between">
|
||||||
|
<div class="sm:flex">
|
||||||
|
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
Source: { config.SourceType }: { config.SourcePath }
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||||
|
<i class="fas fa-download flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
Destination: { config.DestinationType }: { config.DestinationPath }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
||||||
|
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
<p>
|
||||||
|
Updated: { config.UpdatedAt.Format("2006-01-02 15:04:05") }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Notice -->
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
|
||||||
|
Configurations define how files are transferred between systems
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DashboardData struct {
|
||||||
|
RecentJobs []db.JobHistory
|
||||||
|
ActiveTransfers int
|
||||||
|
CompletedToday int
|
||||||
|
FailedTransfers int
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Dashboard(ctx context.Context, data DashboardData) {
|
||||||
|
@LayoutWithContext("Dashboard", ctx) {
|
||||||
|
<div class="py-6">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-tachometer-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<span id="current-date" class="font-medium"></span>
|
||||||
|
<script>
|
||||||
|
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Overview Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="p-6 flex items-center">
|
||||||
|
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-3 mr-4">
|
||||||
|
<i class="fas fa-exchange-alt text-blue-600 dark:text-blue-300 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Active Transfers</p>
|
||||||
|
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.ActiveTransfers) }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="p-6 flex items-center">
|
||||||
|
<div class="rounded-full bg-green-100 dark:bg-green-900 p-3 mr-4">
|
||||||
|
<i class="fas fa-check-circle text-green-600 dark:text-green-300 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Completed Today</p>
|
||||||
|
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.CompletedToday) }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="p-6 flex items-center">
|
||||||
|
<div class="rounded-full bg-red-100 dark:bg-red-900 p-3 mr-4">
|
||||||
|
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-300 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Failed Transfers</p>
|
||||||
|
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.FailedTransfers) }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-history mr-2 text-primary-500"></i>
|
||||||
|
Recent Jobs
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
if len(data.RecentJobs) == 0 {
|
||||||
|
<div class="py-8 text-center">
|
||||||
|
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-800 mb-4">
|
||||||
|
<i class="fas fa-inbox text-secondary-400 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-secondary-500 dark:text-secondary-400">No recent jobs found</p>
|
||||||
|
<a href="/jobs/new" class="mt-4 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||||
|
<span>Create your first job</span>
|
||||||
|
<i class="fas fa-arrow-right ml-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="flow-root">
|
||||||
|
<ul role="list" class="-my-5 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
|
for _, job := range data.RecentJobs {
|
||||||
|
<li class="py-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 px-4 rounded-lg transition-colors">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
if job.Status == "completed" {
|
||||||
|
<span class="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center dark:bg-green-900">
|
||||||
|
<i class="fas fa-check text-green-600 dark:text-green-300"></i>
|
||||||
|
</span>
|
||||||
|
} else if job.Status == "failed" {
|
||||||
|
<span class="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center dark:bg-red-900">
|
||||||
|
<i class="fas fa-times text-red-600 dark:text-red-300"></i>
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center dark:bg-blue-900">
|
||||||
|
<i class="fas fa-sync-alt text-blue-600 dark:text-blue-300"></i>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-secondary-900 truncate dark:text-secondary-100">
|
||||||
|
{ job.Job.Config.Name }
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center mt-1">
|
||||||
|
<i class="fas fa-clock text-xs text-secondary-500 dark:text-secondary-400 mr-1"></i>
|
||||||
|
<p class="text-sm text-secondary-500 truncate dark:text-secondary-400">
|
||||||
|
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
|
||||||
|
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 text-sm leading-5 font-medium rounded-full text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
|
||||||
|
<i class="fas fa-eye mr-1"></i>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/jobs" class="w-full flex justify-center items-center px-4 py-2 border border-secondary-300 shadow-sm text-sm font-medium rounded-lg text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
|
||||||
|
<i class="fas fa-list-ul mr-2"></i>
|
||||||
|
View all jobs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-bolt mr-2 text-primary-500"></i>
|
||||||
|
Quick Actions
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<a href="/configs/new" class="btn-primary text-center flex items-center justify-center">
|
||||||
|
<i class="fas fa-plus-circle mr-2"></i>
|
||||||
|
Create New Config
|
||||||
|
</a>
|
||||||
|
<a href="/jobs/new" class="btn-primary text-center flex items-center justify-center">
|
||||||
|
<i class="fas fa-play-circle mr-2"></i>
|
||||||
|
Create New Job
|
||||||
|
</a>
|
||||||
|
<a href="/history" class="btn-secondary text-center flex items-center justify-center">
|
||||||
|
<i class="fas fa-history mr-2"></i>
|
||||||
|
View Transfer History
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- System Status Card -->
|
||||||
|
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-700">
|
||||||
|
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">System Status</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-secondary-500 dark:text-secondary-400">Server</span>
|
||||||
|
<span class="badge badge-success">Online</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-secondary-500 dark:text-secondary-400">Scheduler</span>
|
||||||
|
<span class="badge badge-success">Running</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-secondary-500 dark:text-secondary-400">Database</span>
|
||||||
|
<span class="badge badge-success">Connected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) {
|
||||||
|
@LayoutWithContext("Forgot Password", ctx) {
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
||||||
|
<div class="max-w-md w-full">
|
||||||
|
<div class="card overflow-hidden shadow-lg">
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||||
|
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Password Reset</h2>
|
||||||
|
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter your email to receive a reset link</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if errorMessage != "" {
|
||||||
|
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span class="block sm:inline">{ errorMessage }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if successMessage != "" {
|
||||||
|
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-check-circle mr-2"></i>
|
||||||
|
<span class="block sm:inline">{ successMessage }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-6"
|
||||||
|
method="POST"
|
||||||
|
action="/forgot-password"
|
||||||
|
x-data="{
|
||||||
|
email: '',
|
||||||
|
loading: false,
|
||||||
|
validate() {
|
||||||
|
return this.email && this.email.includes('@');
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
x-model="email"
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="you@example.com"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
We'll send a password reset link to this email
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary w-full flex justify-center py-3"
|
||||||
|
x-bind:disabled="!validate()"
|
||||||
|
@click="loading = true">
|
||||||
|
<span x-show="!loading" class="flex items-center">
|
||||||
|
<i class="fas fa-paper-plane mr-2"></i>
|
||||||
|
Send Reset Link
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i>
|
||||||
|
Back to login
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Notice -->
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
|
<span>Secure, encrypted connection</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset password page for when users click the link from their email
|
||||||
|
templ ResetPassword(ctx context.Context, token string, errorMessage string) {
|
||||||
|
@LayoutWithContext("Reset Password", ctx) {
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
||||||
|
<div class="max-w-md w-full">
|
||||||
|
<div class="card overflow-hidden shadow-lg">
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||||
|
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Reset Password</h2>
|
||||||
|
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Create a new password for your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if errorMessage != "" {
|
||||||
|
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span class="block sm:inline">{ errorMessage }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-6"
|
||||||
|
method="POST"
|
||||||
|
action="/reset-password"
|
||||||
|
x-data="{
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
loading: false,
|
||||||
|
validate() {
|
||||||
|
return this.password &&
|
||||||
|
this.password.length >= 8 &&
|
||||||
|
this.password === this.confirmPassword;
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<input type="hidden" name="token" value={token}/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">New Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
x-model="password"
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="••••••••"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
Minimum 8 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm New Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
name="confirm-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
x-model="confirmPassword"
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="••••••••"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm"
|
||||||
|
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-secondary-500 dark:text-secondary-400': !confirmPassword || password === confirmPassword}">
|
||||||
|
<span x-show="!confirmPassword || password === confirmPassword">
|
||||||
|
Passwords must match
|
||||||
|
</span>
|
||||||
|
<span x-show="confirmPassword && password !== confirmPassword">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
Passwords do not match
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary w-full flex justify-center py-3"
|
||||||
|
x-bind:disabled="!validate()"
|
||||||
|
@click="loading = true">
|
||||||
|
<span x-show="!loading" class="flex items-center">
|
||||||
|
<i class="fas fa-check-circle mr-2"></i>
|
||||||
|
Reset Password
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i>
|
||||||
|
Back to login
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Notice -->
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
|
<span>Secure, encrypted connection</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HistoryData struct {
|
||||||
|
History []db.JobHistory
|
||||||
|
CurrentPage int
|
||||||
|
TotalPages int
|
||||||
|
SearchTerm string
|
||||||
|
PageSize int
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
// min returns the smaller of x or y
|
||||||
|
func min(x, y int) int {
|
||||||
|
if x < y {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryContent renders only the content part of the history page for HTMX requests
|
||||||
|
templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||||
|
if len(data.History) == 0 {
|
||||||
|
<div class="text-center py-12 bg-white dark:bg-secondary-800 rounded-lg shadow">
|
||||||
|
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||||
|
<i class="fas fa-inbox text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No transfer history</h3>
|
||||||
|
if data.SearchTerm != "" {
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No results found for "{ data.SearchTerm }". Try a different search term or <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">clear search</button>.</p>
|
||||||
|
} else if data.CurrentPage > 1 {
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No more results on this page. <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"page": 1, "pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">Return to first page</button>.</p>
|
||||||
|
} else {
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Transfer history will appear here once jobs have run.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg">
|
||||||
|
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
|
for _, history := range data.History {
|
||||||
|
<li class="hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
||||||
|
<div class="px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">{ history.Job.Config.Name }</p>
|
||||||
|
if history.Status == "completed" {
|
||||||
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||||
|
<i class="fas fa-check mr-1"></i> Completed
|
||||||
|
</span>
|
||||||
|
} else if history.Status == "failed" {
|
||||||
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||||
|
<i class="fas fa-times mr-1"></i> Failed
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||||
|
<i class="fas fa-sync-alt mr-1"></i> { history.Status }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", history.ID)) }
|
||||||
|
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 dark:border-secondary-600 text-sm leading-5 font-medium rounded-full text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors">
|
||||||
|
<i class="fas fa-eye mr-1"></i>
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 sm:flex sm:justify-between">
|
||||||
|
<div class="sm:flex">
|
||||||
|
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
Started: { history.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
|
</p>
|
||||||
|
if history.EndTime != nil {
|
||||||
|
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||||
|
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
Duration: { history.EndTime.Sub(history.StartTime).String() }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
||||||
|
if history.BytesTransferred > 0 {
|
||||||
|
<i class="fas fa-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
<p>
|
||||||
|
{ formatBytes(history.BytesTransferred) } transferred
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
if history.ErrorMessage != "" {
|
||||||
|
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800">
|
||||||
|
<p class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
Error: { history.ErrorMessage }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
if data.TotalPages > 1 {
|
||||||
|
<div class="mt-6 flex items-center justify-between">
|
||||||
|
<div class="flex-1 flex justify-between sm:hidden">
|
||||||
|
if data.CurrentPage > 1 {
|
||||||
|
<button
|
||||||
|
hx-get="/history"
|
||||||
|
hx-target="#history-content"
|
||||||
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||||
|
hx-indicator="#mobile-prev-indicator"
|
||||||
|
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||||
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
|
<span id="mobile-prev-indicator" class="htmx-indicator ml-1">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<span class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
|
||||||
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.CurrentPage < data.TotalPages {
|
||||||
|
<button
|
||||||
|
hx-get="/history"
|
||||||
|
hx-target="#history-content"
|
||||||
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||||
|
hx-indicator="#mobile-next-indicator"
|
||||||
|
class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||||
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
|
<span id="mobile-next-indicator" class="htmx-indicator ml-1">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
|
||||||
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-secondary-700 dark:text-secondary-300">
|
||||||
|
Showing
|
||||||
|
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PageSize + 1) }</span>
|
||||||
|
to
|
||||||
|
<span class="font-medium">{ fmt.Sprint(min((data.CurrentPage)*data.PageSize, data.Total)) }</span>
|
||||||
|
of
|
||||||
|
<span class="font-medium">{ fmt.Sprint(data.Total) }</span>
|
||||||
|
results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||||
|
if data.CurrentPage > 1 {
|
||||||
|
<button
|
||||||
|
hx-get="/history"
|
||||||
|
hx-target="#history-content"
|
||||||
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||||
|
hx-indicator="#prev-indicator"
|
||||||
|
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||||
|
<span class="sr-only">Previous</span>
|
||||||
|
<i class="fas fa-chevron-left h-5 w-5"></i>
|
||||||
|
<span id="prev-indicator" class="htmx-indicator ml-1">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
|
||||||
|
<span class="sr-only">Previous</span>
|
||||||
|
<i class="fas fa-chevron-left h-5 w-5"></i>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Page numbers -->
|
||||||
|
@pageNumbers(data.CurrentPage, data.TotalPages, data.PageSize, data.SearchTerm)
|
||||||
|
|
||||||
|
if data.CurrentPage < data.TotalPages {
|
||||||
|
<button
|
||||||
|
hx-get="/history"
|
||||||
|
hx-target="#history-content"
|
||||||
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||||
|
hx-indicator="#next-indicator"
|
||||||
|
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||||
|
<span class="sr-only">Next</span>
|
||||||
|
<i class="fas fa-chevron-right h-5 w-5"></i>
|
||||||
|
<span id="next-indicator" class="htmx-indicator ml-1">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
|
||||||
|
<span class="sr-only">Next</span>
|
||||||
|
<i class="fas fa-chevron-right h-5 w-5"></i>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ History(ctx context.Context, data HistoryData) {
|
||||||
|
@LayoutWithContext("Transfer History", ctx) {
|
||||||
|
<div id="history-page" class="py-6">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-history mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
|
Transfer History
|
||||||
|
</h1>
|
||||||
|
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search and Pagination Controls -->
|
||||||
|
<div class="mt-4 flex flex-col sm:flex-row justify-between items-center bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg mb-6">
|
||||||
|
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="w-full sm:w-auto mb-4 sm:mb-0">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="relative flex-grow">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-search text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
value={ data.SearchTerm }
|
||||||
|
placeholder="Search by job name or status..."
|
||||||
|
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="page" value="1" />
|
||||||
|
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="ml-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-primary-500 dark:hover:bg-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<span>Search</span>
|
||||||
|
<span id="search-indicator" class="htmx-indicator ml-1">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
if data.SearchTerm != "" {
|
||||||
|
<button
|
||||||
|
hx-get="/history"
|
||||||
|
hx-target="#history-content"
|
||||||
|
hx-indicator="#clear-indicator"
|
||||||
|
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
|
||||||
|
class="ml-2 inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times mr-1"></i>
|
||||||
|
Clear
|
||||||
|
<span id="clear-indicator" class="htmx-indicator ml-1">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-sm text-secondary-700 dark:text-secondary-300">
|
||||||
|
Show
|
||||||
|
<select
|
||||||
|
name="pageSize"
|
||||||
|
hx-get="/history"
|
||||||
|
hx-target="#history-content"
|
||||||
|
hx-include="[name='search']"
|
||||||
|
hx-indicator="#size-indicator"
|
||||||
|
class="mx-1 rounded-md border-secondary-300 dark:border-secondary-600 py-1 text-base bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:border-primary-500 focus:outline-none focus:ring-primary-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="10" selected?={ data.PageSize == 10 }>10</option>
|
||||||
|
<option value="25" selected?={ data.PageSize == 25 }>25</option>
|
||||||
|
<option value="50" selected?={ data.PageSize == 50 }>50</option>
|
||||||
|
<option value="100" selected?={ data.PageSize == 100 }>100</option>
|
||||||
|
</select>
|
||||||
|
entries
|
||||||
|
<span id="size-indicator" class="htmx-indicator ml-1">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="history-content" class="mt-6">
|
||||||
|
@HistoryContent(ctx, data)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm string) {
|
||||||
|
// Show at most 5 page numbers with the current page in the middle when possible
|
||||||
|
{{startPage := max(1, currentPage-2)}}
|
||||||
|
{{endPage := min(totalPages, startPage+4)}}
|
||||||
|
|
||||||
|
// Adjust startPage if we're near the end
|
||||||
|
if endPage - startPage < 4 && startPage > 1 {
|
||||||
|
startPage = max(1, endPage-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := startPage; i <= endPage; i++ {
|
||||||
|
if i == currentPage {
|
||||||
|
<span aria-current="page" class="relative inline-flex items-center px-4 py-2 border border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||||
|
{ fmt.Sprint(i) }
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<button
|
||||||
|
hx-get="/history"
|
||||||
|
hx-target="#history-content"
|
||||||
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
||||||
|
hx-indicator="#page-indicator"
|
||||||
|
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-700 dark:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||||
|
{ fmt.Sprint(i) }
|
||||||
|
<span id="page-indicator" class="htmx-indicator ml-1">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytes(bytes int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if bytes < unit {
|
||||||
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := bytes / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(x, y int) int {
|
||||||
|
if x > y {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
return y
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Home(ctx context.Context) {
|
||||||
|
@LayoutWithContext("Home", ctx) {
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-4xl font-bold text-secondary-900 dark:text-secondary-100 mb-4">
|
||||||
|
<i class="fas fa-exchange-alt text-primary-600 dark:text-primary-400 mr-3"></i>
|
||||||
|
Welcome to GoMFT
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-secondary-600 dark:text-secondary-300 mb-12">A modern managed file transfer solution</p>
|
||||||
|
|
||||||
|
if isLoggedIn(ctx) {
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Feature Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="card hover:shadow-lg transition-shadow duration-300">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
||||||
|
<i class="fas fa-cogs text-primary-600 dark:text-primary-300 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Transfer Configs</h2>
|
||||||
|
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Set up and manage file transfer configurations with ease.</p>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<a href="/configs" class="btn-primary inline-flex items-center">
|
||||||
|
<span>View Configs</span>
|
||||||
|
<i class="fas fa-arrow-right ml-2"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card hover:shadow-lg transition-shadow duration-300">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
||||||
|
<i class="fas fa-calendar-alt text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Schedule Jobs</h2>
|
||||||
|
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Create and monitor automated file transfer jobs.</p>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<a href="/jobs" class="btn-primary inline-flex items-center">
|
||||||
|
<span>View Jobs</span>
|
||||||
|
<i class="fas fa-arrow-right ml-2"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card hover:shadow-lg transition-shadow duration-300">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="rounded-full bg-green-100 dark:bg-green-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
||||||
|
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Track History</h2>
|
||||||
|
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">View detailed history of all file transfer operations.</p>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<a href="/history" class="btn-primary inline-flex items-center">
|
||||||
|
<span>View History</span>
|
||||||
|
<i class="fas fa-arrow-right ml-2"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Access Section -->
|
||||||
|
<div class="mt-12">
|
||||||
|
<h2 class="text-2xl font-bold text-secondary-900 dark:text-secondary-100 mb-6">
|
||||||
|
<i class="fas fa-bolt text-primary-600 dark:text-primary-400 mr-2"></i>
|
||||||
|
Quick Access
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<a href="/dashboard" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||||
|
<i class="fas fa-tachometer-alt text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||||
|
<span class="text-secondary-900 dark:text-secondary-100">Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="/configs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||||
|
<i class="fas fa-plus-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||||
|
<span class="text-secondary-900 dark:text-secondary-100">New Config</span>
|
||||||
|
</a>
|
||||||
|
<a href="/jobs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||||
|
<i class="fas fa-play-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||||
|
<span class="text-secondary-900 dark:text-secondary-100">New Job</span>
|
||||||
|
</a>
|
||||||
|
<a href="/profile" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||||
|
<i class="fas fa-user-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||||
|
<span class="text-secondary-900 dark:text-secondary-100">Profile</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="max-w-md mx-auto card p-8 hover:shadow-lg transition-shadow duration-300">
|
||||||
|
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-20 h-20 flex items-center justify-center mb-6 mx-auto">
|
||||||
|
<i class="fas fa-sign-in-alt text-primary-600 dark:text-primary-300 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Get Started</h2>
|
||||||
|
<p class="text-secondary-600 dark:text-secondary-400 mb-6">Log in to access the file transfer management system.</p>
|
||||||
|
<a href="/login" class="btn-primary inline-flex items-center px-8 py-3">
|
||||||
|
<i class="fas fa-lock mr-2"></i>
|
||||||
|
<span>Log In</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Features Preview -->
|
||||||
|
<div class="mt-12 pt-8 border-t border-secondary-200 dark:border-secondary-700">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Key Features</h3>
|
||||||
|
<ul class="space-y-3 text-left">
|
||||||
|
<li class="flex items-start">
|
||||||
|
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||||
|
<span class="text-secondary-600 dark:text-secondary-400">Secure file transfers with encryption</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||||
|
<span class="text-secondary-600 dark:text-secondary-400">Automated scheduling and monitoring</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||||
|
<span class="text-secondary-600 dark:text-secondary-400">Comprehensive transfer history tracking</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||||
|
<span class="text-secondary-600 dark:text-secondary-400">User-friendly interface with dark mode support</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobFormData struct {
|
||||||
|
Job *db.Job
|
||||||
|
Configs []db.TransferConfig
|
||||||
|
IsNew bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func getJobFormTitle(isNew bool) string {
|
||||||
|
if isNew {
|
||||||
|
return "New Job"
|
||||||
|
}
|
||||||
|
return "Edit Job"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getJobTitle(isNew bool) string {
|
||||||
|
if isNew {
|
||||||
|
return "Create New Job"
|
||||||
|
}
|
||||||
|
return "Edit Job"
|
||||||
|
}
|
||||||
|
|
||||||
|
templ JobForm(ctx context.Context, data JobFormData) {
|
||||||
|
@LayoutWithContext(getJobFormTitle(data.IsNew), ctx) {
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
||||||
|
<div class="max-w-3xl w-full">
|
||||||
|
<div class="card overflow-hidden shadow-lg">
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||||
|
<i class="fas fa-calendar-alt text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ getJobTitle(data.IsNew) }
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Configure your scheduled transfer job</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if data.IsNew {
|
||||||
|
<form
|
||||||
|
class="space-y-6"
|
||||||
|
hx-post="/jobs"
|
||||||
|
hx-target="body"
|
||||||
|
hx-boost="true"
|
||||||
|
@htmx:before-request="loading = true"
|
||||||
|
@htmx:after-request="loading = false"
|
||||||
|
@htmx:response-error="$dispatch('notification', { message: 'Failed to create job: ' + event.detail.xhr.responseText, type: 'error' })"
|
||||||
|
x-data="{ name: '', configId: '', schedule: '', enabled: true, loading: false, validate() { return this.configId && this.schedule; } }">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="name"
|
||||||
|
x-model="name"
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Daily Production Backup"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Descriptive name for this job (optional). If not provided, the config name will be used.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="config_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configuration</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-cog text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="config_id"
|
||||||
|
name="config_id"
|
||||||
|
x-model="configId"
|
||||||
|
required
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
||||||
|
<option value="">Select a configuration</option>
|
||||||
|
for _, config := range data.Configs {
|
||||||
|
<option value={ fmt.Sprint(config.ID) }>{ config.Name }</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="schedule" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Schedule (Cron Expression)</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-clock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="schedule"
|
||||||
|
id="schedule"
|
||||||
|
x-model="schedule"
|
||||||
|
required
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="*/15 * * * *"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg border border-secondary-200 dark:border-secondary-700">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enabled"
|
||||||
|
x-model="enabled"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"
|
||||||
|
checked/>
|
||||||
|
<input type="hidden" name="enabled" :value="enabled.toString()"/>
|
||||||
|
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1 text-amber-500"></i>
|
||||||
|
Disabled jobs will not run automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-5 flex justify-end space-x-3">
|
||||||
|
<a href="/jobs" class="btn-secondary flex items-center justify-center px-4 py-2">
|
||||||
|
<i class="fas fa-times mr-2"></i>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary flex items-center justify-center px-4 py-2"
|
||||||
|
x-bind:disabled="!validate() || loading">
|
||||||
|
<span x-show="!loading" class="flex items-center">
|
||||||
|
<i class="fas fa-plus mr-2"></i>
|
||||||
|
Create Job
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
} else {
|
||||||
|
<form
|
||||||
|
class="space-y-6"
|
||||||
|
hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) }
|
||||||
|
hx-target="body"
|
||||||
|
hx-boost="true"
|
||||||
|
@htmx:before-request="loading = true"
|
||||||
|
@htmx:after-request="loading = false"
|
||||||
|
@htmx:response-error="$dispatch('notification', { message: 'Failed to update job: ' + event.detail.xhr.responseText, type: 'error' })"
|
||||||
|
x-data={ fmt.Sprintf("{ name: '%s', configId: '%d', schedule: '%s', enabled: %v, loading: false, validate() { return this.configId && this.schedule; } }", data.Job.Name, data.Job.ConfigID, data.Job.Schedule, data.Job.Enabled) }>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="name"
|
||||||
|
x-model="name"
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Daily Production Backup"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Descriptive name for this job (optional). If not provided, the config name will be used.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="config_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configuration</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-cog text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="config_id"
|
||||||
|
name="config_id"
|
||||||
|
x-model="configId"
|
||||||
|
required
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
||||||
|
<option value="">Select a configuration</option>
|
||||||
|
for _, config := range data.Configs {
|
||||||
|
<option value={ fmt.Sprint(config.ID) } if data.Job != nil && data.Job.ConfigID == config.ID { selected }>{ config.Name }</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="schedule" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Schedule (Cron Expression)</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-clock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="schedule"
|
||||||
|
id="schedule"
|
||||||
|
x-model="schedule"
|
||||||
|
required
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="*/15 * * * *"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg border border-secondary-200 dark:border-secondary-700">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enabled"
|
||||||
|
x-model="enabled"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
|
||||||
|
<input type="hidden" name="enabled" :value="enabled.toString()"/>
|
||||||
|
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1 text-amber-500"></i>
|
||||||
|
Disabled jobs will not run automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-5 flex justify-end space-x-3">
|
||||||
|
<a href="/jobs" class="btn-secondary flex items-center justify-center px-4 py-2">
|
||||||
|
<i class="fas fa-times mr-2"></i>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary flex items-center justify-center px-4 py-2"
|
||||||
|
x-bind:disabled="!validate() || loading">
|
||||||
|
<span x-show="!loading" class="flex items-center">
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
Save Changes
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Notice -->
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-question-circle mr-2 text-primary-500"></i>
|
||||||
|
<span>Need help with cron expressions? Try <a href="https://crontab.guru/" target="_blank" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">crontab.guru</a></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobRunDetailsData struct {
|
||||||
|
JobHistory db.JobHistory
|
||||||
|
Job db.Job
|
||||||
|
Config db.TransferConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
|
||||||
|
@LayoutWithContext("Job Run Details", ctx) {
|
||||||
|
<div class="py-6">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/dashboard" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||||
|
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-file-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
|
Job Run Details
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Job Run Information Card -->
|
||||||
|
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
if data.Job.Name != "" {
|
||||||
|
{ data.Job.Name }
|
||||||
|
} else {
|
||||||
|
{ data.Config.Name }
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
if data.JobHistory.Status == "completed" {
|
||||||
|
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||||
|
<i class="fas fa-check mr-1"></i> Completed
|
||||||
|
</span>
|
||||||
|
} else if data.JobHistory.Status == "failed" {
|
||||||
|
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||||
|
<i class="fas fa-times mr-1"></i> Failed
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||||
|
<i class="fas fa-sync-alt mr-1"></i> { data.JobHistory.Status }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if data.Job.Name != "" && data.Job.Name != data.Config.Name {
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
Config: { data.Config.Name }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-calendar-alt mr-1"></i> Start Time
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-calendar-check mr-1"></i> End Time
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
if data.JobHistory.EndTime != nil {
|
||||||
|
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
|
} else {
|
||||||
|
<span class="text-secondary-500 dark:text-secondary-400">Still running...</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-clock mr-1"></i> Duration
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
if data.JobHistory.EndTime != nil {
|
||||||
|
{ formatDuration(data.JobHistory.EndTime.Sub(data.JobHistory.StartTime)) }
|
||||||
|
} else {
|
||||||
|
{ formatDuration(time.Since(data.JobHistory.StartTime)) } (ongoing)
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-upload mr-1"></i> Data Transferred
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ formatBytes(data.JobHistory.BytesTransferred) }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-file mr-1"></i> Files Transferred
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ fmt.Sprint(data.JobHistory.FilesTransferred) }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-calendar-day mr-1"></i> Job Schedule
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
{ data.Job.Schedule }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transfer Configuration Details -->
|
||||||
|
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-cog mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
|
Transfer Configuration
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Type</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">
|
||||||
|
{ data.Config.SourceType }
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Type</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||||
|
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">
|
||||||
|
{ data.Config.DestinationType }
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Path</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
||||||
|
if data.Config.SourceType == "sftp" {
|
||||||
|
{ data.Config.SourceUser } `@` { data.Config.SourceHost }:{ data.Config.SourcePath }
|
||||||
|
} else {
|
||||||
|
{ data.Config.SourcePath }
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Path</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
||||||
|
if data.Config.DestinationType == "sftp" {
|
||||||
|
data.Config.DestUser@data.Config.DestHost:data.Config.DestinationPath
|
||||||
|
} else {
|
||||||
|
{ data.Config.DestinationPath }
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">File Pattern</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
|
||||||
|
{ data.Config.FilePattern }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if data.Config.ArchiveEnabled {
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Archive Path</dt>
|
||||||
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
||||||
|
{ data.Config.ArchivePath }
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Information (if any) -->
|
||||||
|
if data.JobHistory.ErrorMessage != "" {
|
||||||
|
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8 border-l-4 border-red-500">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-red-600 dark:text-red-400">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||||
|
Error Details
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-5 sm:p-6 bg-red-50 dark:bg-red-900/20">
|
||||||
|
<pre class="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap font-mono">{ data.JobHistory.ErrorMessage }</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 mt-8">
|
||||||
|
<a href="/jobs" class="btn-secondary text-center flex items-center justify-center">
|
||||||
|
<i class="fas fa-list-ul mr-2"></i>
|
||||||
|
View All Jobs
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="btn-primary text-center flex items-center justify-center">
|
||||||
|
<i class="fas fa-edit mr-2"></i>
|
||||||
|
Edit Job
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDuration formats a duration in a human-readable way
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
d = d.Round(time.Second)
|
||||||
|
h := d / time.Hour
|
||||||
|
d -= h * time.Hour
|
||||||
|
m := d / time.Minute
|
||||||
|
d -= m * time.Minute
|
||||||
|
s := d / time.Second
|
||||||
|
|
||||||
|
if h > 0 {
|
||||||
|
return fmt.Sprintf("%dh %dm %ds", h, m, s)
|
||||||
|
}
|
||||||
|
if m > 0 {
|
||||||
|
return fmt.Sprintf("%dm %ds", m, s)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%ds", s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobsData struct {
|
||||||
|
Jobs []db.Job
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Jobs(ctx context.Context, data JobsData) {
|
||||||
|
@LayoutWithContext("Transfer Jobs", ctx) {
|
||||||
|
<div class="py-6">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-exchange-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
|
Transfer Jobs
|
||||||
|
</h1>
|
||||||
|
<a href="/jobs/new" class="btn-primary">
|
||||||
|
<i class="fas fa-plus mr-2"></i>
|
||||||
|
New Job
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6">
|
||||||
|
if len(data.Jobs) == 0 {
|
||||||
|
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||||
|
<i class="fas fa-tasks text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No jobs</h3>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer job.</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/jobs/new" class="btn-primary">
|
||||||
|
<i class="fas fa-plus mr-2"></i>
|
||||||
|
New Job
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
|
for _, job := range data.Jobs {
|
||||||
|
<li>
|
||||||
|
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
||||||
|
<div class="px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
|
||||||
|
if job.Name != "" {
|
||||||
|
{ job.Name }
|
||||||
|
} else {
|
||||||
|
{ job.Config.Name }
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
if job.Enabled {
|
||||||
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||||
|
<i class="fas fa-check-circle mr-1"></i>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
||||||
|
<i class="fas fa-pause-circle mr-1"></i>
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="btn-secondary btn-sm">
|
||||||
|
<i class="fas fa-edit mr-1"></i>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
hx-delete={ fmt.Sprintf("/jobs/%d", job.ID) }
|
||||||
|
hx-confirm="Are you sure you want to delete this job?"
|
||||||
|
hx-target="closest li"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="btn-danger btn-sm">
|
||||||
|
<i class="fas fa-trash-alt mr-1"></i>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 sm:flex sm:justify-between">
|
||||||
|
<div class="sm:flex">
|
||||||
|
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-cog flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
Config: { job.Config.Name }
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||||
|
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
Schedule: { job.Schedule }
|
||||||
|
</p>
|
||||||
|
if job.LastRun != nil {
|
||||||
|
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||||
|
<i class="fas fa-history flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
Last Run: { job.LastRun.Format("2006-01-02 15:04:05") }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if job.NextRun != nil {
|
||||||
|
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
||||||
|
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||||
|
<p>
|
||||||
|
Next Run: { job.NextRun.Format("2006-01-02 15:04:05") }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Notice -->
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
|
||||||
|
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateTemplateContext creates a new context with user information from Gin's context
|
||||||
|
func CreateTemplateContext(c *gin.Context) context.Context {
|
||||||
|
ctx := context.Background()
|
||||||
|
if userID, exists := c.Get("userID"); exists {
|
||||||
|
ctx = context.WithValue(ctx, "userID", userID)
|
||||||
|
}
|
||||||
|
if username, exists := c.Get("username"); exists {
|
||||||
|
ctx = context.WithValue(ctx, "username", username)
|
||||||
|
}
|
||||||
|
if email, exists := c.Get("email"); exists {
|
||||||
|
ctx = context.WithValue(ctx, "email", email)
|
||||||
|
}
|
||||||
|
if isAdmin, exists := c.Get("isAdmin"); exists {
|
||||||
|
ctx = context.WithValue(ctx, "isAdmin", isAdmin)
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Layout(title string) {
|
||||||
|
@LayoutWithContext(title, context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
templ LayoutWithContext(title string, ctx context.Context) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="light h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||||
|
<link rel="manifest" href="/static/site.webmanifest">
|
||||||
|
<title>{ title } - GoMFT</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
<script src="https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
100: '#e0f2fe',
|
||||||
|
200: '#bae6fd',
|
||||||
|
300: '#7dd3fc',
|
||||||
|
400: '#38bdf8',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
600: '#0284c7',
|
||||||
|
700: '#0369a1',
|
||||||
|
800: '#075985',
|
||||||
|
900: '#0c4a6e',
|
||||||
|
950: '#082f49',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
950: '#020617',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter var', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'custom': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||||
|
'custom-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style type="text/tailwindcss">
|
||||||
|
@layer components {
|
||||||
|
.btn-primary {
|
||||||
|
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply px-4 py-2 bg-secondary-200 text-secondary-800 rounded-lg hover:bg-secondary-300 transition-all duration-200 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-secondary-300 focus:ring-offset-2 dark:bg-secondary-700 dark:text-secondary-100 dark:hover:bg-secondary-600;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
@apply px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
|
||||||
|
}
|
||||||
|
.btn-warning {
|
||||||
|
@apply px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2;
|
||||||
|
}
|
||||||
|
.form-input {
|
||||||
|
@apply w-full px-3 py-2 border border-secondary-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 shadow-sm transition-all duration-200 dark:bg-secondary-800 dark:border-secondary-700 dark:text-white;
|
||||||
|
}
|
||||||
|
.form-checkbox {
|
||||||
|
@apply rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50 dark:border-secondary-700 dark:bg-secondary-800;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply bg-white dark:bg-secondary-800 rounded-xl shadow-custom overflow-hidden border border-secondary-200 dark:border-secondary-700 transition-all duration-200 hover:shadow-custom-lg;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
@apply px-6 py-4 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-900;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
@apply p-6;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
.badge-success {
|
||||||
|
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
||||||
|
}
|
||||||
|
.badge-warning {
|
||||||
|
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100;
|
||||||
|
}
|
||||||
|
.badge-danger {
|
||||||
|
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
||||||
|
}
|
||||||
|
.badge-info {
|
||||||
|
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
||||||
|
}
|
||||||
|
.table-container {
|
||||||
|
@apply overflow-hidden rounded-lg shadow-sm border border-secondary-200 dark:border-secondary-700;
|
||||||
|
}
|
||||||
|
.table {
|
||||||
|
@apply min-w-full divide-y divide-secondary-200 dark:divide-secondary-700;
|
||||||
|
}
|
||||||
|
.table thead {
|
||||||
|
@apply bg-secondary-50 dark:bg-secondary-900;
|
||||||
|
}
|
||||||
|
.table th {
|
||||||
|
@apply px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase tracking-wider dark:text-secondary-400;
|
||||||
|
}
|
||||||
|
.table tbody {
|
||||||
|
@apply bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700;
|
||||||
|
}
|
||||||
|
.table td {
|
||||||
|
@apply px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-secondary-100;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
@apply inline-flex items-center px-1 pt-1 text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200 border-b-2 border-transparent hover:border-primary-500 transition-all duration-200;
|
||||||
|
}
|
||||||
|
.nav-link-active {
|
||||||
|
@apply inline-flex items-center px-1 pt-1 text-primary-600 dark:text-primary-400 border-b-2 border-primary-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles */
|
||||||
|
.dark body {
|
||||||
|
@apply bg-secondary-900 text-secondary-100;
|
||||||
|
}
|
||||||
|
.dark .bg-white {
|
||||||
|
@apply bg-secondary-800;
|
||||||
|
}
|
||||||
|
.dark .text-secondary-800 {
|
||||||
|
@apply text-secondary-100;
|
||||||
|
}
|
||||||
|
.dark .text-secondary-700 {
|
||||||
|
@apply text-secondary-200;
|
||||||
|
}
|
||||||
|
.dark .text-secondary-500 {
|
||||||
|
@apply text-secondary-300;
|
||||||
|
}
|
||||||
|
.dark .hover\:text-secondary-700:hover {
|
||||||
|
@apply text-secondary-100;
|
||||||
|
}
|
||||||
|
.dark .hover\:bg-secondary-100:hover {
|
||||||
|
@apply bg-secondary-700;
|
||||||
|
}
|
||||||
|
.dark .bg-secondary-50 {
|
||||||
|
@apply bg-secondary-900;
|
||||||
|
}
|
||||||
|
.dark .bg-secondary-200 {
|
||||||
|
@apply bg-secondary-700;
|
||||||
|
}
|
||||||
|
.dark .shadow-sm {
|
||||||
|
@apply shadow-secondary-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Additional dark mode improvements */
|
||||||
|
.dark .bg-secondary-50 {
|
||||||
|
@apply bg-secondary-900;
|
||||||
|
}
|
||||||
|
.dark .bg-secondary-100 {
|
||||||
|
@apply bg-secondary-800;
|
||||||
|
}
|
||||||
|
.dark .border-secondary-200 {
|
||||||
|
@apply border-secondary-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
.animate-fadeIn {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: theme('colors.secondary.100');
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
background: theme('colors.secondary.800');
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: theme('colors.secondary.300');
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: theme('colors.secondary.600');
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: theme('colors.secondary.400');
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: theme('colors.secondary.500');
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full">
|
||||||
|
if isLoggedIn(ctx) {
|
||||||
|
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex">
|
||||||
|
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
<i class="fas fa-exchange-alt mr-2"></i>
|
||||||
|
GoMFT
|
||||||
|
</a>
|
||||||
|
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||||
|
<a href="/dashboard" class="nav-link">
|
||||||
|
<i class="fas fa-tachometer-alt mr-1"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/configs" class="nav-link">
|
||||||
|
<i class="fas fa-cogs mr-1"></i> Configs
|
||||||
|
</a>
|
||||||
|
<a href="/jobs" class="nav-link">
|
||||||
|
<i class="fas fa-tasks mr-1"></i> Jobs
|
||||||
|
</a>
|
||||||
|
<a href="/history" class="nav-link">
|
||||||
|
<i class="fas fa-history mr-1"></i> History
|
||||||
|
</a>
|
||||||
|
if isAdmin(ctx) {
|
||||||
|
<a href="/admin/users" class="nav-link">
|
||||||
|
<i class="fas fa-users mr-1"></i> Users
|
||||||
|
</a>
|
||||||
|
<a href="/admin/tools" class="nav-link">
|
||||||
|
<i class="fas fa-tools mr-1"></i> Admin Tools
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Theme Toggle -->
|
||||||
|
<button
|
||||||
|
id="theme-toggle"
|
||||||
|
type="button"
|
||||||
|
class="text-secondary-500 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg text-sm p-2"
|
||||||
|
onclick="toggleTheme()"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sun hidden dark:block"></i>
|
||||||
|
<i class="fas fa-moon block dark:hidden"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- User Menu -->
|
||||||
|
<div x-data="{ open: false }" class="relative">
|
||||||
|
<button @click="open = !open" class="flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||||
|
<span class="sr-only">Open user menu</span>
|
||||||
|
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
|
||||||
|
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div x-show="open"
|
||||||
|
@click.away="open = false"
|
||||||
|
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-secondary-800 dark:ring-secondary-700 animate-fadeIn"
|
||||||
|
role="menu"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-labelledby="user-menu-button"
|
||||||
|
tabindex="-1">
|
||||||
|
<div class="px-4 py-2 text-xs text-secondary-500 dark:text-secondary-400 border-b border-secondary-200 dark:border-secondary-700">
|
||||||
|
Signed in as <span class="font-medium">{ getUserEmail(ctx) }</span>
|
||||||
|
</div>
|
||||||
|
<a href="/profile" class="block px-4 py-2 text-sm text-secondary-700 hover:bg-secondary-100 dark:text-secondary-200 dark:hover:bg-secondary-700" role="menuitem">
|
||||||
|
<i class="fas fa-user-circle mr-2"></i> Profile
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700" role="menuitem">
|
||||||
|
<i class="fas fa-sign-out-alt mr-2"></i> Sign out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
<main class="flex-grow w-full max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 animate-fadeIn">
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
|
||||||
|
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||||
|
<p class="text-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
GoMFT © { getCurrentYear() } | Secure File Transfer Solution
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if user is admin
|
||||||
|
func isAdmin(ctx context.Context) bool {
|
||||||
|
// Try as bool first
|
||||||
|
if admin, ok := ctx.Value("isAdmin").(bool); ok {
|
||||||
|
return admin
|
||||||
|
}
|
||||||
|
// Try as interface{} (from JWT claims)
|
||||||
|
if admin, ok := ctx.Value("isAdmin").(interface{}); ok {
|
||||||
|
if boolVal, ok := admin.(bool); ok {
|
||||||
|
return boolVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if user is logged in
|
||||||
|
func isLoggedIn(ctx context.Context) bool {
|
||||||
|
// First try as uint
|
||||||
|
if userID, ok := ctx.Value("userID").(uint); ok && userID > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Then try as float64 (from JWT claims)
|
||||||
|
if userID, ok := ctx.Value("userID").(float64); ok && userID > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get user initial for avatar
|
||||||
|
func getUserInitial(ctx context.Context) string {
|
||||||
|
// Try as string first
|
||||||
|
if username, ok := ctx.Value("username").(string); ok && username != "" {
|
||||||
|
return string(username[0])
|
||||||
|
}
|
||||||
|
// Try as interface{} (from JWT claims)
|
||||||
|
if username, ok := ctx.Value("username").(interface{}); ok {
|
||||||
|
if strVal, ok := username.(string); ok && strVal != "" {
|
||||||
|
return string(strVal[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try email as fallback
|
||||||
|
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
||||||
|
return string(email[0])
|
||||||
|
}
|
||||||
|
return "U"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get user email
|
||||||
|
func getUserEmail(ctx context.Context) string {
|
||||||
|
// Try as string first
|
||||||
|
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
// Try as interface{} (from JWT claims)
|
||||||
|
if email, ok := ctx.Value("email").(interface{}); ok {
|
||||||
|
if strVal, ok := email.(string); ok && strVal != "" {
|
||||||
|
return strVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "user@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get current year
|
||||||
|
func getCurrentYear() string {
|
||||||
|
return "2025"
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Login(ctx context.Context, errorMessage string) {
|
||||||
|
@LayoutWithContext("Login", ctx) {
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
||||||
|
<div class="max-w-md w-full">
|
||||||
|
<div class="card overflow-hidden shadow-lg">
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||||
|
<i class="fas fa-lock text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Sign In</h2>
|
||||||
|
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Access your GoMFT account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if errorMessage != "" {
|
||||||
|
if strings.HasPrefix(errorMessage, "Password reset") || strings.Contains(errorMessage, "success") {
|
||||||
|
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-check-circle mr-2"></i>
|
||||||
|
<span class="block sm:inline">{ errorMessage }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span class="block sm:inline">{ errorMessage }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-6"
|
||||||
|
method="POST"
|
||||||
|
action="/login"
|
||||||
|
x-data="{
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
loading: false,
|
||||||
|
validate() {
|
||||||
|
return this.email && this.password;
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
x-model="email"
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="you@example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
x-model="password"
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="••••••••"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
name="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
|
||||||
|
<label for="remember-me" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">Remember me</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm">
|
||||||
|
<a href="/forgot-password" class="font-medium text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary w-full flex justify-center py-3"
|
||||||
|
x-bind:disabled="!validate()"
|
||||||
|
@click="loading = true">
|
||||||
|
<span x-show="!loading" class="flex items-center">
|
||||||
|
<i class="fas fa-sign-in-alt mr-2"></i>
|
||||||
|
Sign in
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
Contact an administrator to create an account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Notice -->
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
|
<span>Secure, encrypted connection</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Profile(ctx context.Context, user db.User) {
|
||||||
|
@LayoutWithContext("Profile", ctx) {
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-user-circle mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
|
Profile
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<!-- Profile Information Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-id-card mr-2 text-primary-500"></i>
|
||||||
|
Profile Information
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Personal details and application settings.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="space-y-6">
|
||||||
|
<div class="flex flex-col sm:flex-row">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Email</dt>
|
||||||
|
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">{ user.Email }</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Role</dt>
|
||||||
|
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||||
|
if user.IsAdmin {
|
||||||
|
<span class="badge badge-success">
|
||||||
|
<i class="fas fa-user-shield mr-1"></i> Administrator
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="badge badge-info">
|
||||||
|
<i class="fas fa-user mr-1"></i> Regular User
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row">
|
||||||
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
|
||||||
|
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||||
|
<form
|
||||||
|
hx-post="/profile/theme"
|
||||||
|
hx-swap="none"
|
||||||
|
class="flex items-center space-x-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="theme-light"
|
||||||
|
name="theme"
|
||||||
|
value="light"
|
||||||
|
checked?={ user.Theme == "light" || user.Theme == "" }
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-post="/profile/theme"
|
||||||
|
class="form-checkbox" />
|
||||||
|
<label for="theme-light" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
||||||
|
<i class="fas fa-sun mr-1"></i> Light
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="theme-dark"
|
||||||
|
name="theme"
|
||||||
|
value="dark"
|
||||||
|
checked?={ user.Theme == "dark" }
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-post="/profile/theme"
|
||||||
|
class="form-checkbox" />
|
||||||
|
<label for="theme-dark" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
||||||
|
<i class="fas fa-moon mr-1"></i> Dark
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="theme-system"
|
||||||
|
name="theme"
|
||||||
|
value="system"
|
||||||
|
checked?={ user.Theme == "system" }
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-post="/profile/theme"
|
||||||
|
class="form-checkbox" />
|
||||||
|
<label for="theme-system" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
||||||
|
<i class="fas fa-desktop mr-1"></i> System
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-key mr-2 text-primary-500"></i>
|
||||||
|
Change Password
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Update your password to keep your account secure.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="/change-password"
|
||||||
|
hx-post="/change-password"
|
||||||
|
hx-target="#password-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-headers='{"X-Profile-Page": "true"}'
|
||||||
|
hx-indicator="#password-change-indicator"
|
||||||
|
class="space-y-4"
|
||||||
|
x-data="{
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
loading: false,
|
||||||
|
validate() {
|
||||||
|
return this.currentPassword &&
|
||||||
|
this.newPassword &&
|
||||||
|
this.confirmPassword &&
|
||||||
|
this.newPassword === this.confirmPassword;
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@htmx:before-request="loading = true"
|
||||||
|
@htmx:after-request="loading = false">
|
||||||
|
|
||||||
|
<div id="password-result"></div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="current-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||||
|
<i class="fas fa-lock mr-1"></i> Current Password
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="current-password"
|
||||||
|
name="current_password"
|
||||||
|
x-model="currentPassword"
|
||||||
|
class="form-input pl-10 w-full"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="new-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||||
|
<i class="fas fa-lock-open mr-1"></i> New Password
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="new-password"
|
||||||
|
name="new_password"
|
||||||
|
x-model="newPassword"
|
||||||
|
class="form-input pl-10 w-full"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||||
|
<i class="fas fa-check-double mr-1"></i> Confirm New Password
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirm-password"
|
||||||
|
name="confirm_password"
|
||||||
|
x-model="confirmPassword"
|
||||||
|
class="form-input pl-10 w-full"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary w-full flex justify-center py-3"
|
||||||
|
x-bind:disabled="!validate() || loading">
|
||||||
|
<span x-show="!loading" class="flex items-center">
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
Update Password
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
|
||||||
|
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
|
<span>Password must be at least 8 characters with letters, numbers, and special characters</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Notice -->
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
|
<span>All profile changes are securely logged for your protection</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Register(ctx context.Context, errorMessage string) {
|
||||||
|
@LayoutWithContext("Register", ctx) {
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Create a new user account
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Complete the form below to create a new user
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if errorMessage != "" {
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||||
|
<span class="block sm:inline">{ errorMessage }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form class="mt-8 space-y-6" hx-post="/register" hx-target="body"
|
||||||
|
x-data="{
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
loading: false,
|
||||||
|
validate() {
|
||||||
|
return this.email &&
|
||||||
|
this.password &&
|
||||||
|
this.confirmPassword &&
|
||||||
|
this.password === this.confirmPassword;
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@htmx:before-request="loading = true"
|
||||||
|
@htmx:after-request="loading = false">
|
||||||
|
<input type="hidden" name="remember" value="true" />
|
||||||
|
<div class="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="sr-only">Email address</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
x-model="email"
|
||||||
|
class="form-input rounded-t-md"
|
||||||
|
placeholder="Email address"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="sr-only">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
x-model="password"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Password"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="confirm-password" class="sr-only">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
name="confirm_password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
x-model="confirmPassword"
|
||||||
|
class="form-input rounded-b-md"
|
||||||
|
placeholder="Confirm Password"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary w-full flex justify-center"
|
||||||
|
x-bind:disabled="!validate() || loading">
|
||||||
|
<span x-show="!loading">Create User</span>
|
||||||
|
<span x-show="loading" class="flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Already have an account?
|
||||||
|
<a href="/login" class="font-medium text-blue-600 hover:text-blue-500">Sign in</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"strconv"
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UsersData struct {
|
||||||
|
Users []db.User
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Users(ctx context.Context, data UsersData) {
|
||||||
|
@LayoutWithContext("User Management", ctx) {
|
||||||
|
<div class="py-6">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-users mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
|
<a href="/admin/users/new" class="btn-primary">
|
||||||
|
<i class="fas fa-user-plus mr-2"></i>
|
||||||
|
Add User
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
if len(data.Users) == 0 {
|
||||||
|
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||||
|
<i class="fas fa-user-slash text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No users found</h3>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by adding a new user.</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/admin/users/new" class="btn-primary">
|
||||||
|
<i class="fas fa-user-plus mr-2"></i>
|
||||||
|
Add User
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
|
<thead class="bg-secondary-50 dark:bg-secondary-800">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||||
|
<i class="fas fa-envelope mr-1"></i> Email
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||||
|
<i class="fas fa-user-tag mr-1"></i> Role
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||||
|
<i class="fas fa-calendar-plus mr-1"></i> Created
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="relative px-6 py-3">
|
||||||
|
<span class="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-secondary-750 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
|
if len(data.Users) == 0 {
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
No users found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} else {
|
||||||
|
for _, user := range data.Users {
|
||||||
|
<tr class="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-secondary-900 dark:text-secondary-100">{ user.Email }</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
if user.IsAdmin {
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-300">
|
||||||
|
<i class="fas fa-user-shield mr-1"></i> Admin
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
||||||
|
<i class="fas fa-user mr-1"></i> User
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
{ user.CreatedAt.Format("Jan 02, 2006") }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<button
|
||||||
|
class="btn-danger btn-sm"
|
||||||
|
hx-delete={ "/admin/users/" + strconv.Itoa(int(user.ID)) }
|
||||||
|
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
||||||
|
hx-target="body"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash-alt mr-1"></i> Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Notice -->
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-shield-alt mr-1 text-primary-500"></i>
|
||||||
|
User accounts provide secure access to the GoMFT application with role-based permissions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserFormData struct {
|
||||||
|
IsNew bool
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
templ UserForm(ctx context.Context, data UserFormData) {
|
||||||
|
@LayoutWithContext("Add User", ctx) {
|
||||||
|
<div class="py-6">
|
||||||
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||||
|
<i class="fas fa-user-plus mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||||
|
Add New User
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="p-6">
|
||||||
|
if data.ErrorMessage != "" {
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-6"
|
||||||
|
hx-post="/admin/users"
|
||||||
|
hx-target="body"
|
||||||
|
x-data="{
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
isAdmin: false,
|
||||||
|
loading: false,
|
||||||
|
validate() {
|
||||||
|
return this.email &&
|
||||||
|
this.password &&
|
||||||
|
this.confirmPassword &&
|
||||||
|
this.password === this.confirmPassword;
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@htmx:before-request="loading = true"
|
||||||
|
@htmx:after-request="loading = false">
|
||||||
|
<div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">User Information</h3>
|
||||||
|
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Create a new user account with appropriate permissions.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
id="email"
|
||||||
|
x-model="email"
|
||||||
|
required
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="user@example.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
id="password"
|
||||||
|
x-model="password"
|
||||||
|
required
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="confirm_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="confirm_password"
|
||||||
|
id="confirm_password"
|
||||||
|
x-model="confirmPassword"
|
||||||
|
required
|
||||||
|
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="••••••••" />
|
||||||
|
<p class="mt-1 text-sm text-red-600 dark:text-red-400" x-show="confirmPassword && password !== confirmPassword">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
Passwords do not match
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2">
|
||||||
|
<div class="relative flex items-start">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
id="is_admin"
|
||||||
|
name="is_admin"
|
||||||
|
type="checkbox"
|
||||||
|
x-model="isAdmin"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm">
|
||||||
|
<label for="is_admin" class="font-medium text-secondary-700 dark:text-secondary-300">Administrator</label>
|
||||||
|
<p class="text-secondary-500 dark:text-secondary-400">Grant administrative privileges to this user</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-5">
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<a href="/admin/users" class="btn-secondary">
|
||||||
|
<i class="fas fa-times mr-2"></i>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
x-bind:disabled="!validate() || loading">
|
||||||
|
<span x-show="!loading" class="flex items-center">
|
||||||
|
<i class="fas fa-user-plus mr-2"></i>
|
||||||
|
Create User
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
|
||||||
|
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
|
<span>User accounts provide secure access to the GoMFT application</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
gomft:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: gomft
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
# Persist data directory for SQLite database and configurations
|
||||||
|
- gomft-data:/app/data
|
||||||
|
# For development, you can mount the source code
|
||||||
|
# - .:/app
|
||||||
|
environment:
|
||||||
|
- TZ=UTC
|
||||||
|
# Add any environment variables needed for configuration
|
||||||
|
# - GOMFT_DB_PATH=/app/data/gomft.db
|
||||||
|
# - GOMFT_LOG_LEVEL=info
|
||||||
|
networks:
|
||||||
|
- gomft-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gomft-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gomft-data:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"server_address": ":8080",
|
||||||
|
"data_dir": "/app/data/gomft",
|
||||||
|
"backup_dir": "/app/data/gomft/backups",
|
||||||
|
"jwt_secret": "change_this_to_a_secure_random_string",
|
||||||
|
"email": {
|
||||||
|
"enabled": true,
|
||||||
|
"host": "smtp.example.com",
|
||||||
|
"port": 587,
|
||||||
|
"from_email": "gomft@example.com",
|
||||||
|
"from_name": "GoMFT",
|
||||||
|
"reply_to": "",
|
||||||
|
"enable_tls": true,
|
||||||
|
"require_auth": true,
|
||||||
|
"username": "smtp_username",
|
||||||
|
"password": "smtp_password"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
module github.com/starfleetcptn/gomft
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/a-h/templ v0.3.833
|
||||||
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
golang.org/x/crypto v0.35.0
|
||||||
|
gorm.io/driver/sqlite v1.5.7
|
||||||
|
gorm.io/gorm v1.25.12
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.12.9 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.25.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
golang.org/x/arch v0.14.0 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
|
||||||
|
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
|
||||||
|
github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ=
|
||||||
|
github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
||||||
|
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
|
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||||
|
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||||
|
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||||
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||||
|
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
||||||
|
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||||
|
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||||
|
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||||
|
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/auth"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/scheduler"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Email string `json:"email" binding:"required,min=3,max=50"`
|
||||||
|
Password string `json:"password" binding:"required,min=8"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email string `json:"email" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User UserResponse `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitializeRoutes(router *gin.Engine, database *db.DB, scheduler *scheduler.Scheduler, jwtSecret string) {
|
||||||
|
api := router.Group("/api")
|
||||||
|
|
||||||
|
// Auth routes
|
||||||
|
auth := api.Group("/auth")
|
||||||
|
{
|
||||||
|
auth.POST("/register", handleRegister(database))
|
||||||
|
auth.POST("/login", handleLogin(database, jwtSecret))
|
||||||
|
auth.POST("/logout", handleLogout())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
protected := api.Group("")
|
||||||
|
protected.Use(authMiddleware(database, jwtSecret))
|
||||||
|
{
|
||||||
|
// Transfer config routes
|
||||||
|
protected.GET("/configs", handleListConfigs(database))
|
||||||
|
protected.POST("/configs", handleCreateConfig(database))
|
||||||
|
protected.GET("/configs/:id", handleGetConfig(database))
|
||||||
|
protected.PUT("/configs/:id", handleUpdateConfig(database))
|
||||||
|
protected.DELETE("/configs/:id", handleDeleteConfig(database))
|
||||||
|
|
||||||
|
// Job routes
|
||||||
|
protected.GET("/jobs", handleListJobs(database))
|
||||||
|
protected.POST("/jobs", handleCreateJob(database, scheduler))
|
||||||
|
protected.GET("/jobs/:id", handleGetJob(database))
|
||||||
|
protected.PUT("/jobs/:id", handleUpdateJob(database, scheduler))
|
||||||
|
protected.DELETE("/jobs/:id", handleDeleteJob(database, scheduler))
|
||||||
|
protected.POST("/jobs/:id/run", handleRunJob(database, scheduler))
|
||||||
|
protected.POST("/jobs/:id/enable", handleEnableJob(database, scheduler))
|
||||||
|
protected.POST("/jobs/:id/disable", handleDisableJob(database, scheduler))
|
||||||
|
|
||||||
|
// History routes
|
||||||
|
protected.GET("/jobs/:id/history", handleGetJobHistory(database))
|
||||||
|
protected.GET("/history", handleListHistory(database))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRegister(database *db.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req RegisterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
if _, err := database.GetUserByEmail(req.Email); err == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
user := &db.User{
|
||||||
|
Email: req.Email,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.CreateUser(user); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"message": "User registered successfully"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLogin(database *db.DB, jwtSecret string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := database.GetUserByEmail(req.Email)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
token, err := auth.GenerateToken(user.ID, user.Email, jwtSecret, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, LoginResponse{
|
||||||
|
Token: token,
|
||||||
|
User: UserResponse{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLogout() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// JWT tokens are stateless, so we don't need to do anything server-side
|
||||||
|
// The client should discard the token
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Logout successful"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authMiddleware(database *db.DB, jwtSecret string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Get Authorization header
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the header has the Bearer prefix
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header must be in the format 'Bearer {token}'"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
tokenString := parts[1]
|
||||||
|
claims, err := auth.ValidateToken(tokenString, jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user ID in context
|
||||||
|
c.Set("userID", claims.UserID)
|
||||||
|
c.Set("email", claims.Email)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListConfigs(database *db.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Get user ID from context
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
configs, err := database.GetTransferConfigs(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch configs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, configs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateConfig(database *db.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := c.ShouldBindJSON(&config); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user ID
|
||||||
|
config.CreatedBy = c.GetUint("userID")
|
||||||
|
|
||||||
|
if err := database.CreateTransferConfig(&config); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetConfig(database *db.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var configID uint
|
||||||
|
if _, err := fmt.Sscanf(id, "%d", &configID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := database.GetTransferConfig(configID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this config
|
||||||
|
if config.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateConfig(database *db.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var configID uint
|
||||||
|
if _, err := fmt.Sscanf(id, "%d", &configID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing config
|
||||||
|
existingConfig, err := database.GetTransferConfig(configID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this config
|
||||||
|
if existingConfig.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind updated fields
|
||||||
|
var updatedConfig db.TransferConfig
|
||||||
|
if err := c.ShouldBindJSON(&updatedConfig); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields but preserve ID and CreatedBy
|
||||||
|
updatedConfig.ID = existingConfig.ID
|
||||||
|
updatedConfig.CreatedBy = existingConfig.CreatedBy
|
||||||
|
updatedConfig.CreatedAt = existingConfig.CreatedAt
|
||||||
|
|
||||||
|
if err := database.UpdateTransferConfig(&updatedConfig); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, updatedConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteConfig(database *db.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var configID uint
|
||||||
|
if _, err := fmt.Sscanf(id, "%d", &configID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing config to check ownership
|
||||||
|
config, err := database.GetTransferConfig(configID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this config
|
||||||
|
if config.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DeleteTransferConfig(configID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListJobs(database *db.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Get user ID from context
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
jobs, err := database.GetJobs(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch jobs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, jobs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var job db.Job
|
||||||
|
if err := c.ShouldBindJSON(&job); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user ID
|
||||||
|
job.CreatedBy = c.GetUint("userID")
|
||||||
|
|
||||||
|
// Validate config exists and user has access
|
||||||
|
_, err := database.GetTransferConfig(job.ConfigID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this config
|
||||||
|
config, err := database.GetTransferConfig(job.ConfigID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if config.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.CreateJob(&job); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule the job if enabled
|
||||||
|
if job.Enabled {
|
||||||
|
if err := scheduler.ScheduleJob(&job); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to schedule job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetJob(database *db.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobID uint
|
||||||
|
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := database.GetJob(jobID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this job
|
||||||
|
if job.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobID uint
|
||||||
|
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing job
|
||||||
|
existingJob, err := database.GetJob(jobID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this job
|
||||||
|
if existingJob.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind updated fields
|
||||||
|
var updatedJob db.Job
|
||||||
|
if err := c.ShouldBindJSON(&updatedJob); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields but preserve ID and CreatedBy
|
||||||
|
updatedJob.ID = existingJob.ID
|
||||||
|
updatedJob.CreatedBy = existingJob.CreatedBy
|
||||||
|
updatedJob.CreatedAt = existingJob.CreatedAt
|
||||||
|
|
||||||
|
// Validate config exists and user has access
|
||||||
|
if updatedJob.ConfigID != existingJob.ConfigID {
|
||||||
|
_, err := database.GetTransferConfig(updatedJob.ConfigID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Check if user has access to this config
|
||||||
|
config, err := database.GetTransferConfig(updatedJob.ConfigID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if config.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if schedule or enabled status changed
|
||||||
|
scheduleChanged := updatedJob.Schedule != existingJob.Schedule || updatedJob.Enabled != existingJob.Enabled
|
||||||
|
|
||||||
|
if err := database.UpdateJob(&updatedJob); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the scheduler if needed
|
||||||
|
if scheduleChanged {
|
||||||
|
if updatedJob.Enabled {
|
||||||
|
if err := scheduler.ScheduleJob(&updatedJob); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job schedule"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scheduler.UnscheduleJob(updatedJob.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, updatedJob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobID uint
|
||||||
|
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing job to check ownership
|
||||||
|
job, err := database.GetJob(jobID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this job
|
||||||
|
if job.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from scheduler first
|
||||||
|
scheduler.UnscheduleJob(jobID)
|
||||||
|
|
||||||
|
if err := database.DeleteJob(jobID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Job deleted successfully"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRunJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobID uint
|
||||||
|
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing job
|
||||||
|
job, err := database.GetJob(jobID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this job
|
||||||
|
if job.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the job immediately
|
||||||
|
if err := scheduler.RunJobNow(jobID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run job: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Job started successfully"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleEnableJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobID uint
|
||||||
|
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing job
|
||||||
|
job, err := database.GetJob(jobID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this job
|
||||||
|
if job.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update job status
|
||||||
|
job.Enabled = true
|
||||||
|
if err := database.UpdateJob(job); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to scheduler
|
||||||
|
if err := scheduler.ScheduleJob(job); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to schedule job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Job enabled successfully"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDisableJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobID uint
|
||||||
|
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing job
|
||||||
|
job, err := database.GetJob(jobID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this job
|
||||||
|
if job.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update job status
|
||||||
|
job.Enabled = false
|
||||||
|
if err := database.UpdateJob(job); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from scheduler
|
||||||
|
scheduler.UnscheduleJob(jobID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Job disabled successfully"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetJobHistory(database *db.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobID uint
|
||||||
|
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing job to check ownership
|
||||||
|
_, err := database.GetJob(jobID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this job
|
||||||
|
job, err := database.GetJob(jobID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if job.CreatedBy != c.GetUint("userID") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := database.GetJobHistory(jobID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch job history"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListHistory(database *db.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Get user ID from context
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// TODO: Implement pagination
|
||||||
|
// For now, just return the most recent 100 history entries for the user's jobs
|
||||||
|
var history []db.JobHistory
|
||||||
|
err := database.DB.
|
||||||
|
Joins("JOIN jobs ON job_histories.job_id = jobs.id").
|
||||||
|
Where("jobs.created_by = ?", userID).
|
||||||
|
Order("start_time DESC").
|
||||||
|
Limit(100).
|
||||||
|
Find(&history).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, history)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claims represents the JWT claims
|
||||||
|
type Claims struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken creates a new JWT token for a user
|
||||||
|
func GenerateToken(userID uint, email, secret string, expirationTime time.Duration) (string, error) {
|
||||||
|
// Create claims with user ID and expiration time
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expirationTime)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: "gomft",
|
||||||
|
Subject: fmt.Sprintf("%d", userID),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token with claims
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|
||||||
|
// Sign token with secret
|
||||||
|
tokenString, err := token.SignedString([]byte(secret))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates a JWT token and returns the claims
|
||||||
|
func ValidateToken(tokenString, secret string) (*Claims, error) {
|
||||||
|
// Parse token
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
// Validate signing method
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract claims
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordPolicy defines the requirements for password strength and management
|
||||||
|
type PasswordPolicy struct {
|
||||||
|
MinLength int // Minimum password length
|
||||||
|
RequireUppercase bool // Require at least one uppercase letter
|
||||||
|
RequireLowercase bool // Require at least one lowercase letter
|
||||||
|
RequireNumbers bool // Require at least one number
|
||||||
|
RequireSpecial bool // Require at least one special character
|
||||||
|
ExpirationDays int // Number of days until password expires (0 = never)
|
||||||
|
HistoryCount int // Number of previous passwords to remember (0 = disabled)
|
||||||
|
DisallowCommon bool // Disallow common passwords
|
||||||
|
MaxLoginAttempts int // Maximum failed login attempts before lockout
|
||||||
|
LockoutDuration time.Duration // Duration of account lockout after max failed attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordHistory represents a historical password entry
|
||||||
|
type PasswordHistory struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
UserID uint `gorm:"not null"`
|
||||||
|
PasswordHash string `gorm:"not null"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPasswordPolicy returns the default password policy
|
||||||
|
func DefaultPasswordPolicy() PasswordPolicy {
|
||||||
|
return PasswordPolicy{
|
||||||
|
MinLength: 8,
|
||||||
|
RequireUppercase: true,
|
||||||
|
RequireLowercase: true,
|
||||||
|
RequireNumbers: true,
|
||||||
|
RequireSpecial: true,
|
||||||
|
ExpirationDays: 90,
|
||||||
|
HistoryCount: 5,
|
||||||
|
DisallowCommon: true,
|
||||||
|
MaxLoginAttempts: 5,
|
||||||
|
LockoutDuration: 15 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePassword checks if a password meets the policy requirements
|
||||||
|
func ValidatePassword(password string, policy PasswordPolicy) error {
|
||||||
|
// Check minimum length
|
||||||
|
if len(password) < policy.MinLength {
|
||||||
|
return fmt.Errorf("password must be at least %d characters long", policy.MinLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for uppercase letters
|
||||||
|
if policy.RequireUppercase {
|
||||||
|
match, _ := regexp.MatchString("[A-Z]", password)
|
||||||
|
if !match {
|
||||||
|
return errors.New("password must contain at least one uppercase letter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for lowercase letters
|
||||||
|
if policy.RequireLowercase {
|
||||||
|
match, _ := regexp.MatchString("[a-z]", password)
|
||||||
|
if !match {
|
||||||
|
return errors.New("password must contain at least one lowercase letter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for numbers
|
||||||
|
if policy.RequireNumbers {
|
||||||
|
match, _ := regexp.MatchString("[0-9]", password)
|
||||||
|
if !match {
|
||||||
|
return errors.New("password must contain at least one number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for special characters
|
||||||
|
if policy.RequireSpecial {
|
||||||
|
match, _ := regexp.MatchString("[^a-zA-Z0-9]", password)
|
||||||
|
if !match {
|
||||||
|
return errors.New("password must contain at least one special character")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for common passwords
|
||||||
|
if policy.DisallowCommon && isCommonPassword(password) {
|
||||||
|
return errors.New("password is too common or easily guessable")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPasswordHistory verifies the password against the user's password history
|
||||||
|
func CheckPasswordHistory(userID uint, newPassword string, hashedPassword string, db *gorm.DB, policy PasswordPolicy) error {
|
||||||
|
if policy.HistoryCount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordHistories []PasswordHistory
|
||||||
|
if err := db.Where("user_id = ?", userID).Order("created_at desc").Limit(policy.HistoryCount).Find(&passwordHistories).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check current password
|
||||||
|
if ComparePasswords(hashedPassword, newPassword) == nil {
|
||||||
|
return errors.New("new password cannot be the same as your current password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password history
|
||||||
|
for _, history := range passwordHistories {
|
||||||
|
if ComparePasswords(history.PasswordHash, newPassword) == nil {
|
||||||
|
return fmt.Errorf("password was used in the last %d passwords", policy.HistoryCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPasswordExpired checks if the user's password has expired
|
||||||
|
func IsPasswordExpired(lastPasswordChange time.Time, policy PasswordPolicy) bool {
|
||||||
|
if policy.ExpirationDays <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expirationTime := lastPasswordChange.Add(time.Duration(policy.ExpirationDays) * 24 * time.Hour)
|
||||||
|
return time.Now().After(expirationTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePasswordHistory adds the new password to the user's password history
|
||||||
|
func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, policy PasswordPolicy) error {
|
||||||
|
if policy.HistoryCount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new password to history
|
||||||
|
passwordHistory := PasswordHistory{
|
||||||
|
UserID: userID,
|
||||||
|
PasswordHash: hashedPassword,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&passwordHistory).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim history if needed
|
||||||
|
var count int64
|
||||||
|
db.Model(&PasswordHistory{}).Where("user_id = ?", userID).Count(&count)
|
||||||
|
|
||||||
|
if count > int64(policy.HistoryCount) {
|
||||||
|
var oldestHistories []PasswordHistory
|
||||||
|
if err := db.Where("user_id = ?", userID).Order("created_at asc").Limit(int(count) - policy.HistoryCount).Find(&oldestHistories).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, history := range oldestHistories {
|
||||||
|
if err := db.Delete(&history).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashPassword hashes a password using bcrypt
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashedBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComparePasswords compares a hashed password with a plain text password
|
||||||
|
func ComparePasswords(hashedPassword, plainPassword string) error {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCommonPassword checks if a password is in the list of common passwords
|
||||||
|
func isCommonPassword(password string) bool {
|
||||||
|
commonPasswords := []string{
|
||||||
|
"password", "123456", "12345678", "qwerty", "abc123", "monkey",
|
||||||
|
"1234567", "letmein", "trustno1", "dragon", "baseball", "111111",
|
||||||
|
"iloveyou", "master", "sunshine", "ashley", "bailey", "passw0rd",
|
||||||
|
"shadow", "123123", "654321", "superman", "qazwsx", "michael",
|
||||||
|
"football", "welcome", "jesus", "ninja", "mustang", "password1",
|
||||||
|
"admin", "admin123", "root", "toor", "qwerty123", "123qwe",
|
||||||
|
}
|
||||||
|
|
||||||
|
lowercasePassword := strings.ToLower(password)
|
||||||
|
for _, common := range commonPasswords {
|
||||||
|
if lowercasePassword == common {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServerAddress string `json:"server_address"`
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
BackupDir string `json:"backup_dir"`
|
||||||
|
JWTSecret string `json:"jwt_secret"`
|
||||||
|
Email EmailConfig `json:"email"`
|
||||||
|
BaseURL string `json:"base_url"` // Base URL for generating links in emails
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
FromEmail string `json:"from_email"`
|
||||||
|
FromName string `json:"from_name"`
|
||||||
|
ReplyTo string `json:"reply_to,omitempty"`
|
||||||
|
EnableTLS bool `json:"enable_tls"`
|
||||||
|
RequireAuth bool `json:"require_auth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
// Default configuration
|
||||||
|
cfg := &Config{
|
||||||
|
ServerAddress: ":8080",
|
||||||
|
DataDir: filepath.Join("./data", "gomft"),
|
||||||
|
BackupDir: filepath.Join("./data", "gomft", "backups"),
|
||||||
|
JWTSecret: "change_this_to_a_secure_random_string",
|
||||||
|
BaseURL: "http://localhost:8080",
|
||||||
|
Email: EmailConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Host: "smtp.example.com",
|
||||||
|
Port: 587,
|
||||||
|
Username: "user@example.com",
|
||||||
|
Password: "your-password",
|
||||||
|
FromEmail: "gomft@example.com",
|
||||||
|
FromName: "GoMFT",
|
||||||
|
EnableTLS: true,
|
||||||
|
RequireAuth: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if config file exists
|
||||||
|
configPath := filepath.Join(cfg.DataDir, "config.json")
|
||||||
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
|
// Read configuration file
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse configuration
|
||||||
|
if err := json.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save configuration if it doesn't exist
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/starfleetcptn/gomft/internal/auth"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
Email string `gorm:"unique;not null"`
|
||||||
|
PasswordHash string `gorm:"not null"`
|
||||||
|
IsAdmin bool `gorm:"default:false"`
|
||||||
|
LastPasswordChange time.Time
|
||||||
|
FailedLoginAttempts int `gorm:"default:0"`
|
||||||
|
AccountLocked bool `gorm:"default:false"`
|
||||||
|
LockoutUntil *time.Time
|
||||||
|
Theme string `gorm:"default:'light'"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordHistory struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
UserID uint `gorm:"not null"`
|
||||||
|
User User `gorm:"foreignkey:UserID"`
|
||||||
|
PasswordHash string `gorm:"not null"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordResetToken struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
UserID uint `gorm:"not null"`
|
||||||
|
User User `gorm:"foreignkey:UserID"`
|
||||||
|
Token string `gorm:"not null"`
|
||||||
|
ExpiresAt time.Time `gorm:"not null"`
|
||||||
|
Used bool `gorm:"default:false"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransferConfig struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
Name string `gorm:"not null" form:"name"`
|
||||||
|
SourceType string `gorm:"not null" form:"source_type"`
|
||||||
|
SourcePath string `gorm:"not null" form:"source_path"`
|
||||||
|
SourceHost string `form:"source_host"`
|
||||||
|
SourcePort int `gorm:"default:22" form:"source_port"`
|
||||||
|
SourceUser string `form:"source_user"`
|
||||||
|
SourcePassword string `form:"source_password" gorm:"-"` // Not stored in DB, only used for form
|
||||||
|
SourceKeyFile string `form:"source_key_file"`
|
||||||
|
// S3 source fields
|
||||||
|
SourceBucket string `form:"source_bucket"`
|
||||||
|
SourceRegion string `form:"source_region"`
|
||||||
|
SourceAccessKey string `form:"source_access_key"`
|
||||||
|
SourceSecretKey string `form:"source_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
||||||
|
SourceEndpoint string `form:"source_endpoint"`
|
||||||
|
// SMB source fields
|
||||||
|
SourceShare string `form:"source_share"`
|
||||||
|
SourceDomain string `form:"source_domain"`
|
||||||
|
// FTP source fields
|
||||||
|
SourcePassiveMode bool `gorm:"default:true" form:"source_passive_mode"`
|
||||||
|
// General fields
|
||||||
|
FilePattern string `gorm:"default:'*'" form:"file_pattern"`
|
||||||
|
OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables
|
||||||
|
DestinationType string `gorm:"not null" form:"destination_type"`
|
||||||
|
DestinationPath string `gorm:"not null" form:"destination_path"`
|
||||||
|
DestHost string `form:"dest_host"`
|
||||||
|
DestPort int `gorm:"default:22" form:"dest_port"`
|
||||||
|
DestUser string `form:"dest_user"`
|
||||||
|
DestPassword string `form:"dest_password" gorm:"-"` // Not stored in DB, only used for form
|
||||||
|
DestKeyFile string `form:"dest_key_file"`
|
||||||
|
// S3 destination fields
|
||||||
|
DestBucket string `form:"dest_bucket"`
|
||||||
|
DestRegion string `form:"dest_region"`
|
||||||
|
DestAccessKey string `form:"dest_access_key"`
|
||||||
|
DestSecretKey string `form:"dest_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
||||||
|
DestEndpoint string `form:"dest_endpoint"`
|
||||||
|
// SMB destination fields
|
||||||
|
DestShare string `form:"dest_share"`
|
||||||
|
DestDomain string `form:"dest_domain"`
|
||||||
|
// FTP destination fields
|
||||||
|
DestPassiveMode bool `gorm:"default:true" form:"dest_passive_mode"`
|
||||||
|
// General fields
|
||||||
|
ArchivePath string `form:"archive_path"`
|
||||||
|
ArchiveEnabled bool `gorm:"default:false" form:"archive_enabled"`
|
||||||
|
RcloneFlags string `form:"rclone_flags"`
|
||||||
|
CreatedBy uint
|
||||||
|
User User `gorm:"foreignkey:CreatedBy"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
Name string `form:"name"`
|
||||||
|
ConfigID uint `gorm:"not null" form:"config_id"`
|
||||||
|
Config TransferConfig `gorm:"foreignkey:ConfigID"`
|
||||||
|
Schedule string `gorm:"not null" form:"schedule"`
|
||||||
|
Enabled bool `gorm:"default:true" form:"enabled"`
|
||||||
|
LastRun *time.Time
|
||||||
|
NextRun *time.Time
|
||||||
|
CreatedBy uint
|
||||||
|
User User `gorm:"foreignkey:CreatedBy"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobHistory struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
JobID uint `gorm:"not null"`
|
||||||
|
Job Job `gorm:"foreignkey:JobID"`
|
||||||
|
StartTime time.Time `gorm:"not null"`
|
||||||
|
EndTime *time.Time
|
||||||
|
Status string `gorm:"not null"`
|
||||||
|
BytesTransferred int64
|
||||||
|
FilesTransferred int
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
*gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func Initialize(dbPath string) (*DB, error) {
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
dir := filepath.Dir(dbPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create database directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open database connection
|
||||||
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto migrate the schema
|
||||||
|
err = db.AutoMigrate(&User{}, &auth.PasswordHistory{}, &PasswordResetToken{}, &TransferConfig{}, &Job{}, &JobHistory{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to migrate database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DB{DB: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
sqlDB, err := db.DB.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// User operations
|
||||||
|
func (db *DB) CreateUser(user *User) error {
|
||||||
|
return db.Create(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetUserByEmail(email string) (*User, error) {
|
||||||
|
var user User
|
||||||
|
err := db.Where("email = ?", email).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetUserByID(id uint) (*User, error) {
|
||||||
|
var user User
|
||||||
|
err := db.First(&user, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateUser(user *User) error {
|
||||||
|
return db.Save(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordResetToken operations
|
||||||
|
func (db *DB) CreatePasswordResetToken(token *PasswordResetToken) error {
|
||||||
|
return db.Create(token).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetPasswordResetToken(token string) (*PasswordResetToken, error) {
|
||||||
|
var resetToken PasswordResetToken
|
||||||
|
err := db.Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).First(&resetToken).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resetToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) MarkPasswordResetTokenAsUsed(tokenID uint) error {
|
||||||
|
return db.Model(&PasswordResetToken{}).Where("id = ?", tokenID).Update("used", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferConfig operations
|
||||||
|
func (db *DB) CreateTransferConfig(config *TransferConfig) error {
|
||||||
|
return db.Create(config).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetTransferConfigs(userID uint) ([]TransferConfig, error) {
|
||||||
|
var configs []TransferConfig
|
||||||
|
err := db.Where("created_by = ?", userID).Find(&configs).Error
|
||||||
|
return configs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetTransferConfig(id uint) (*TransferConfig, error) {
|
||||||
|
var config TransferConfig
|
||||||
|
err := db.First(&config, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateTransferConfig(config *TransferConfig) error {
|
||||||
|
return db.Save(config).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteTransferConfig(id uint) error {
|
||||||
|
// First check if any jobs are using this config
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&Job{}).Where("config_id = ?", id).Count(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check for dependent jobs: %v", err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return fmt.Errorf("cannot delete config: %d jobs are using this configuration", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the config
|
||||||
|
return db.Delete(&TransferConfig{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job operations
|
||||||
|
func (db *DB) CreateJob(job *Job) error {
|
||||||
|
// Use Omit to prevent GORM from creating a new config
|
||||||
|
return db.Omit("Config").Create(job).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetJobs(userID uint) ([]Job, error) {
|
||||||
|
var jobs []Job
|
||||||
|
err := db.Preload("Config").Where("created_by = ?", userID).Find(&jobs).Error
|
||||||
|
return jobs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetJob(id uint) (*Job, error) {
|
||||||
|
var job Job
|
||||||
|
err := db.Preload("Config").First(&job, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateJob(job *Job) error {
|
||||||
|
// Use Omit to prevent GORM from updating or creating a new config
|
||||||
|
return db.Omit("Config").Save(job).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteJob(id uint) error {
|
||||||
|
// Delete associated job history records first
|
||||||
|
if err := db.Where("job_id = ?", id).Delete(&JobHistory{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to delete job history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the job
|
||||||
|
return db.Delete(&Job{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateJobStatus(job *Job) error {
|
||||||
|
return db.Save(job).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobHistory operations
|
||||||
|
func (db *DB) CreateJobHistory(history *JobHistory) error {
|
||||||
|
return db.Create(history).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateJobHistory(history *JobHistory) error {
|
||||||
|
return db.Save(history).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetJobHistory(jobID uint) ([]JobHistory, error) {
|
||||||
|
var history []JobHistory
|
||||||
|
err := db.Where("job_id = ?", jobID).Order("start_time desc").Find(&history).Error
|
||||||
|
return history, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
func (db *DB) GetConfigRclonePath(config *TransferConfig) string {
|
||||||
|
return filepath.Join("configs", fmt.Sprintf("config_%d.conf", config.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
||||||
|
configPath := db.GetConfigRclonePath(config)
|
||||||
|
|
||||||
|
// Ensure configs directory exists
|
||||||
|
if err := os.MkdirAll("configs", 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create configs directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the rclone path from the environment variable or use the default path
|
||||||
|
rclonePath := os.Getenv("RCLONE_PATH")
|
||||||
|
if rclonePath == "" {
|
||||||
|
rclonePath = "rclone"
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceName := fmt.Sprintf("source_%d", config.ID)
|
||||||
|
// Generate rclone config using rclone CLI
|
||||||
|
switch config.SourceType {
|
||||||
|
case "sftp":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", sourceName, "sftp",
|
||||||
|
"host", config.SourceHost,
|
||||||
|
"user", config.SourceUser,
|
||||||
|
"port", fmt.Sprintf("%d", config.SourcePort),
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
if config.SourcePassword != "" {
|
||||||
|
args = append(args, "pass", config.SourcePassword)
|
||||||
|
}
|
||||||
|
if config.SourceKeyFile != "" {
|
||||||
|
args = append(args, "key_file", config.SourceKeyFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
case "s3":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", sourceName, "s3",
|
||||||
|
"provider", "AWS",
|
||||||
|
"env_auth", "false",
|
||||||
|
"access_key_id", config.SourceAccessKey,
|
||||||
|
"secret_access_key", config.SourceSecretKey,
|
||||||
|
"region", config.SourceRegion,
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.SourceEndpoint != "" {
|
||||||
|
args = append(args, "endpoint", config.SourceEndpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
case "minio":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", sourceName, "s3",
|
||||||
|
"provider", "Minio",
|
||||||
|
"env_auth", "false",
|
||||||
|
"access_key_id", config.SourceAccessKey,
|
||||||
|
"secret_access_key", config.SourceSecretKey,
|
||||||
|
"endpoint", config.SourceEndpoint,
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
case "b2":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", sourceName, "b2",
|
||||||
|
"account", config.SourceAccessKey,
|
||||||
|
"key", config.SourceSecretKey,
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
case "smb":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", sourceName, "smb",
|
||||||
|
"host", config.SourceHost,
|
||||||
|
"user", config.SourceUser,
|
||||||
|
"pass", config.SourcePassword,
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.SourceDomain != "" {
|
||||||
|
args = append(args, "domain", config.SourceDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
case "ftp":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", sourceName, "ftp",
|
||||||
|
"host", config.SourceHost,
|
||||||
|
"user", config.SourceUser,
|
||||||
|
"pass", config.SourcePassword,
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.SourcePassiveMode {
|
||||||
|
args = append(args, "passive", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Write local config
|
||||||
|
content := fmt.Sprintf("[source_%d]\ntype = local\n\n", config.ID)
|
||||||
|
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write source config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destName := fmt.Sprintf("dest_%d", config.ID)
|
||||||
|
switch config.DestinationType {
|
||||||
|
case "sftp":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", destName, "sftp",
|
||||||
|
"host", config.DestHost,
|
||||||
|
"user", config.DestUser,
|
||||||
|
"port", fmt.Sprintf("%d", config.DestPort),
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
if config.DestPassword != "" {
|
||||||
|
args = append(args, "pass", config.DestPassword)
|
||||||
|
}
|
||||||
|
if config.DestKeyFile != "" {
|
||||||
|
args = append(args, "key_file", config.DestKeyFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
case "s3":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", destName, "s3",
|
||||||
|
"provider", "AWS",
|
||||||
|
"env_auth", "false",
|
||||||
|
"access_key_id", config.DestAccessKey,
|
||||||
|
"secret_access_key", config.DestSecretKey,
|
||||||
|
"region", config.DestRegion,
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.DestEndpoint != "" {
|
||||||
|
args = append(args, "endpoint", config.DestEndpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
case "minio":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", destName, "s3",
|
||||||
|
"provider", "Minio",
|
||||||
|
"env_auth", "false",
|
||||||
|
"access_key_id", config.DestAccessKey,
|
||||||
|
"secret_access_key", config.DestSecretKey,
|
||||||
|
"endpoint", config.DestEndpoint,
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
case "b2":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", destName, "b2",
|
||||||
|
"account", config.DestAccessKey,
|
||||||
|
"key", config.DestSecretKey,
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
case "smb":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", destName, "smb",
|
||||||
|
"host", config.DestHost,
|
||||||
|
"user", config.DestUser,
|
||||||
|
"pass", config.DestPassword,
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.DestDomain != "" {
|
||||||
|
args = append(args, "domain", config.DestDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
case "ftp":
|
||||||
|
args := []string{
|
||||||
|
"config", "create", destName, "ftp",
|
||||||
|
"host", config.DestHost,
|
||||||
|
"user", config.DestUser,
|
||||||
|
"pass", config.DestPassword,
|
||||||
|
"--non-interactive",
|
||||||
|
"--config", configPath,
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.DestPassiveMode {
|
||||||
|
args = append(args, "passive", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Append local config
|
||||||
|
content := fmt.Sprintf("[dest_%d]\ntype = local\n", config.ID)
|
||||||
|
f, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open config file: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.WriteString(content); err != nil {
|
||||||
|
return fmt.Errorf("failed to write destination config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/smtp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/starfleetcptn/gomft/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service represents the email sending service
|
||||||
|
type Service struct {
|
||||||
|
Config *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new email service
|
||||||
|
func NewService(cfg *config.Config) *Service {
|
||||||
|
return &Service{
|
||||||
|
Config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPasswordResetEmail sends a password reset email to the specified email address
|
||||||
|
func (s *Service) SendPasswordResetEmail(toEmail, username, resetToken string) error {
|
||||||
|
if !s.Config.Email.Enabled {
|
||||||
|
// If email is not enabled, just log it (you can redirect to the default logging logic)
|
||||||
|
return fmt.Errorf("email service is disabled, reset link would be: %s/reset-password?token=%s",
|
||||||
|
s.Config.BaseURL, resetToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetLink := fmt.Sprintf("%s/reset-password?token=%s", s.Config.BaseURL, resetToken)
|
||||||
|
|
||||||
|
// Create email data for template
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Username": username,
|
||||||
|
"ResetLink": resetLink,
|
||||||
|
"AppName": "GoMFT",
|
||||||
|
"Year": time.Now().Year(),
|
||||||
|
"ExpiresHours": 0.25, // Token expiration time in hours (15 minutes = 0.25 hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate email content
|
||||||
|
subject := "Password Reset Request - GoMFT"
|
||||||
|
htmlContent, err := s.generatePasswordResetEmailHTML(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the email
|
||||||
|
return s.sendEmail(toEmail, subject, htmlContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePasswordResetEmailHTML generates the HTML content for password reset emails
|
||||||
|
func (s *Service) generatePasswordResetEmailHTML(data map[string]interface{}) (string, error) {
|
||||||
|
// HTML template for password reset email
|
||||||
|
tmpl, err := template.New("passwordResetEmail").Parse(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Reset Your Password</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;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #2563eb;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 25px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background-color: #4338ca;
|
||||||
|
}
|
||||||
|
.reset-link {
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.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>Reset Your Password</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hello{{if .Username}} {{.Username}}{{end}},</p>
|
||||||
|
<p>We received a request to reset your password for your {{.AppName}} account. Click the button below to reset it:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{.ResetLink}}" class="btn">Reset Password</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If the button doesn't work, you can copy and paste the following link into your browser:</p>
|
||||||
|
<div class="reset-link">{{.ResetLink}}</div>
|
||||||
|
|
||||||
|
<p>This link will expire in 15 minutes.</p>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<p>If you didn't request a password reset, you can ignore this email. Your password will remain unchanged.</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
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendEmail sends an email with the given subject and HTML content
|
||||||
|
func (s *Service) sendEmail(toEmail, subject, htmlContent string) error {
|
||||||
|
from := s.Config.Email.FromEmail
|
||||||
|
if s.Config.Email.FromName != "" {
|
||||||
|
from = fmt.Sprintf("%s <%s>", s.Config.Email.FromName, s.Config.Email.FromEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct email headers
|
||||||
|
headers := make(map[string]string)
|
||||||
|
headers["From"] = from
|
||||||
|
headers["To"] = toEmail
|
||||||
|
headers["Subject"] = subject
|
||||||
|
headers["MIME-Version"] = "1.0"
|
||||||
|
headers["Content-Type"] = "text/html; charset=UTF-8"
|
||||||
|
|
||||||
|
if s.Config.Email.ReplyTo != "" {
|
||||||
|
headers["Reply-To"] = s.Config.Email.ReplyTo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct email message
|
||||||
|
message := ""
|
||||||
|
for key, value := range headers {
|
||||||
|
message += fmt.Sprintf("%s: %s\r\n", key, value)
|
||||||
|
}
|
||||||
|
message += "\r\n" + htmlContent
|
||||||
|
|
||||||
|
// Set up the SMTP server address
|
||||||
|
addr := fmt.Sprintf("%s:%d", s.Config.Email.Host, s.Config.Email.Port)
|
||||||
|
|
||||||
|
// Check if authentication is required
|
||||||
|
if s.Config.Email.RequireAuth {
|
||||||
|
// Use authenticated SMTP
|
||||||
|
auth := smtp.PlainAuth("", s.Config.Email.Username, s.Config.Email.Password, s.Config.Email.Host)
|
||||||
|
return smtp.SendMail(addr, auth, s.Config.Email.FromEmail, []string{toEmail}, []byte(message))
|
||||||
|
} else {
|
||||||
|
// Use unauthenticated SMTP
|
||||||
|
client, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to SMTP server: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Set up TLS if enabled
|
||||||
|
if s.Config.Email.EnableTLS {
|
||||||
|
if err := client.StartTLS(nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to start TLS: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the sender and recipient
|
||||||
|
if err := client.Mail(s.Config.Email.FromEmail); err != nil {
|
||||||
|
return fmt.Errorf("failed to set sender: %v", err)
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(toEmail); err != nil {
|
||||||
|
return fmt.Errorf("failed to set recipient: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the email body
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get data writer: %v", err)
|
||||||
|
}
|
||||||
|
_, err = w.Write([]byte(message))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write email data: %v", err)
|
||||||
|
}
|
||||||
|
err = w.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to close data writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scheduler struct {
|
||||||
|
cron *cron.Cron
|
||||||
|
db *db.DB
|
||||||
|
jobMutex sync.Mutex
|
||||||
|
jobs map[uint]cron.EntryID
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(database *db.DB) *Scheduler {
|
||||||
|
scheduler := &Scheduler{
|
||||||
|
cron: cron.New(cron.WithSeconds()),
|
||||||
|
db: database,
|
||||||
|
jobs: make(map[uint]cron.EntryID),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the cron scheduler
|
||||||
|
scheduler.cron.Start()
|
||||||
|
|
||||||
|
// Load existing jobs from database
|
||||||
|
scheduler.loadJobs()
|
||||||
|
|
||||||
|
return scheduler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) loadJobs() {
|
||||||
|
var jobs []db.Job
|
||||||
|
if err := s.db.Preload("Config").Find(&jobs).Error; err != nil {
|
||||||
|
fmt.Printf("Error loading jobs: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Loading %d jobs from database\n", len(jobs))
|
||||||
|
for _, job := range jobs {
|
||||||
|
if job.Enabled {
|
||||||
|
if err := s.ScheduleJob(&job); err != nil {
|
||||||
|
fmt.Printf("Error scheduling job %d: %v\n", job.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("Scheduled job %d with cron expression: %s\n", job.ID, job.Schedule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) ScheduleJob(job *db.Job) error {
|
||||||
|
s.jobMutex.Lock()
|
||||||
|
defer s.jobMutex.Unlock()
|
||||||
|
|
||||||
|
fmt.Printf("Scheduling job %d (enabled: %v, schedule: %s)\n", job.ID, job.Enabled, job.Schedule)
|
||||||
|
|
||||||
|
// Remove existing job if it exists
|
||||||
|
if entryID, exists := s.jobs[job.ID]; exists {
|
||||||
|
fmt.Printf("Removing existing schedule for job %d\n", job.ID)
|
||||||
|
s.cron.Remove(entryID)
|
||||||
|
delete(s.jobs, job.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only schedule if job is enabled
|
||||||
|
if !job.Enabled {
|
||||||
|
fmt.Printf("Job %d is disabled, skipping scheduling\n", job.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert 5-field cron to 6-field by prepending '0' for seconds
|
||||||
|
schedule := job.Schedule
|
||||||
|
if len(strings.Fields(schedule)) == 5 {
|
||||||
|
schedule = "0 " + schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cron expression
|
||||||
|
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||||
|
_, err := parser.Parse(schedule)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid cron expression '%s': %w", job.Schedule, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new job
|
||||||
|
entryID, err := s.cron.AddFunc(schedule, func() {
|
||||||
|
fmt.Printf("Executing job %d at %s\n", job.ID, time.Now().Format(time.RFC3339))
|
||||||
|
s.executeJob(job.ID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to schedule job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.jobs[job.ID] = entryID
|
||||||
|
fmt.Printf("Successfully scheduled job %d with entry ID %v\n", job.ID, entryID)
|
||||||
|
|
||||||
|
// Calculate and log next run time
|
||||||
|
if entry := s.cron.Entry(entryID); entry.ID != 0 {
|
||||||
|
fmt.Printf("Next run time for job %d: %s\n", job.ID, entry.Next.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) executeJob(jobID uint) {
|
||||||
|
fmt.Printf("Starting execution of job %d\n", jobID)
|
||||||
|
|
||||||
|
// Get job details
|
||||||
|
var job db.Job
|
||||||
|
if err := s.db.Preload("Config").First(&job, jobID).Error; err != nil {
|
||||||
|
fmt.Printf("Error loading job %d: %v\n", jobID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.Config.ID == 0 {
|
||||||
|
fmt.Printf("Error: job %d has no associated config\n", jobID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Loaded job %d with config: source=%s:%s, dest=%s:%s\n",
|
||||||
|
jobID,
|
||||||
|
job.Config.SourceType,
|
||||||
|
job.Config.SourcePath,
|
||||||
|
job.Config.DestinationType,
|
||||||
|
job.Config.DestinationPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create job history entry
|
||||||
|
startTime := time.Now()
|
||||||
|
history := &db.JobHistory{
|
||||||
|
JobID: jobID,
|
||||||
|
StartTime: startTime,
|
||||||
|
Status: "running",
|
||||||
|
FilesTransferred: 0,
|
||||||
|
BytesTransferred: 0,
|
||||||
|
ErrorMessage: "",
|
||||||
|
}
|
||||||
|
if err := s.db.CreateJobHistory(history); err != nil {
|
||||||
|
fmt.Printf("Error creating job history for job %d: %v\n", jobID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update job last run time
|
||||||
|
job.LastRun = &history.StartTime
|
||||||
|
if err := s.db.UpdateJobStatus(&job); err != nil {
|
||||||
|
fmt.Printf("Error updating job last run time for job %d: %v\n", jobID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get rclone config path
|
||||||
|
configPath := s.db.GetConfigRclonePath(&job.Config)
|
||||||
|
|
||||||
|
// Size of transfer using rclone size
|
||||||
|
sizeArgs := []string{
|
||||||
|
"--config", configPath,
|
||||||
|
"size",
|
||||||
|
"--include", job.Config.FilePattern,
|
||||||
|
job.Config.SourcePath,
|
||||||
|
}
|
||||||
|
// Get the rclone path from the environment variable or use the default path
|
||||||
|
rclonePath := os.Getenv("RCLONE_PATH")
|
||||||
|
if rclonePath == "" {
|
||||||
|
rclonePath = "rclone"
|
||||||
|
}
|
||||||
|
output, err := exec.Command(rclonePath, sizeArgs...).CombinedOutput()
|
||||||
|
fmt.Printf("Running rclone size: %v\nOutput: %s\n", sizeArgs, output)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error running rclone size: %v\nOutput: %s\n", err, output)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse rclone size output "Total objects: 1 Total size: 10 B (10 Byte)"
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
totalObjects := strings.TrimSpace(strings.Split(outputStr, "\n")[0])
|
||||||
|
totalObjects = strings.TrimSpace(strings.Split(totalObjects, ":")[1])
|
||||||
|
// totalSize := strings.TrimSpace(strings.Split(outputStr, ":")[2])
|
||||||
|
|
||||||
|
if totalObjects == "0" {
|
||||||
|
fmt.Printf("No files to transfer for job %d\n", jobID)
|
||||||
|
history.Status = "completed"
|
||||||
|
history.ErrorMessage = ""
|
||||||
|
history.FilesTransferred = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalObjects != "0" {
|
||||||
|
// First, list all files that match the pattern
|
||||||
|
listArgs := []string{
|
||||||
|
"--config", configPath,
|
||||||
|
"lsf",
|
||||||
|
"--include", job.Config.FilePattern,
|
||||||
|
fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath),
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Listing files for job %d: rclone %s\n", jobID, strings.Join(listArgs, " "))
|
||||||
|
// Get the rclone path from the environment variable or use the default path
|
||||||
|
rclonePath := os.Getenv("RCLONE_PATH")
|
||||||
|
if rclonePath == "" {
|
||||||
|
rclonePath = "rclone"
|
||||||
|
}
|
||||||
|
listCmd := exec.Command(rclonePath, listArgs...)
|
||||||
|
listOutput, listErr := listCmd.CombinedOutput()
|
||||||
|
|
||||||
|
if listErr != nil {
|
||||||
|
fmt.Printf("Error listing files for job %d: %v\n", jobID, listErr)
|
||||||
|
history.Status = "failed"
|
||||||
|
history.ErrorMessage = fmt.Sprintf("File Listing Error: %v\nOutput: %s", listErr, string(listOutput))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the output by newlines to get individual files
|
||||||
|
files := strings.Split(strings.TrimSpace(string(listOutput)), "\n")
|
||||||
|
fmt.Printf("Found %d files to transfer for job %d\n", len(files), jobID)
|
||||||
|
|
||||||
|
var transferErrors []string
|
||||||
|
filesTransferred := 0
|
||||||
|
|
||||||
|
// Process each file individually
|
||||||
|
for _, file := range files {
|
||||||
|
if file == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Processing file: %s for job %d\n", file, jobID)
|
||||||
|
|
||||||
|
// Prepare moveto command for transfer
|
||||||
|
transferArgs := []string{
|
||||||
|
"--config", configPath,
|
||||||
|
"moveto",
|
||||||
|
"--progress",
|
||||||
|
"--stats-one-line",
|
||||||
|
"--verbose",
|
||||||
|
"--stats", "1s",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source and destination paths
|
||||||
|
sourcePath := fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourcePath, file)
|
||||||
|
destPath := fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, file)
|
||||||
|
|
||||||
|
// Add output filename pattern if specified
|
||||||
|
if job.Config.OutputPattern != "" {
|
||||||
|
// Process the output pattern for this specific file
|
||||||
|
newFilename := ProcessOutputPattern(job.Config.OutputPattern, file)
|
||||||
|
destPath = fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, newFilename)
|
||||||
|
fmt.Printf("Renaming file from %s to %s for job %d\n", file, newFilename, jobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom flags if specified
|
||||||
|
if job.Config.RcloneFlags != "" {
|
||||||
|
customFlags := strings.Split(job.Config.RcloneFlags, " ")
|
||||||
|
transferArgs = append(transferArgs, customFlags...)
|
||||||
|
fmt.Printf("Added custom flags for job %d: %v\n", jobID, customFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add source and destination to the command
|
||||||
|
transferArgs = append(transferArgs, sourcePath, destPath)
|
||||||
|
|
||||||
|
// Execute transfer for this file
|
||||||
|
fmt.Printf("Executing rclone transfer command for job %d, file %s: rclone %s\n",
|
||||||
|
jobID, file, 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"
|
||||||
|
}
|
||||||
|
cmd := exec.Command(rclonePath, transferArgs...)
|
||||||
|
fileOutput, fileErr := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
// Print the output
|
||||||
|
fmt.Printf("Output for file %s: %s\n", file, string(fileOutput))
|
||||||
|
|
||||||
|
// Check if file was successfully transferred
|
||||||
|
if fileErr != nil {
|
||||||
|
fmt.Printf("Error transferring file %s for job %d: %v\n", file, jobID, fileErr)
|
||||||
|
transferErrors = append(transferErrors, fmt.Sprintf("File %s: %v", file, fileErr))
|
||||||
|
} else {
|
||||||
|
filesTransferred++
|
||||||
|
fmt.Printf("Successfully transferred file %s for job %d\n", file, jobID)
|
||||||
|
|
||||||
|
// If archiving is enabled and transfer was successful, move files to archive
|
||||||
|
if job.Config.ArchiveEnabled && job.Config.ArchivePath != "" {
|
||||||
|
fmt.Printf("Archiving file %s for job %d\n", file, jobID)
|
||||||
|
|
||||||
|
// We don't need to move the file since we used moveto, but we can copy it to archive
|
||||||
|
archiveArgs := []string{
|
||||||
|
"--config", configPath,
|
||||||
|
"copyto",
|
||||||
|
sourcePath,
|
||||||
|
fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.ArchivePath, file),
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Executing rclone archive command for job %d, file %s: rclone %s\n",
|
||||||
|
jobID, file, strings.Join(archiveArgs, " "))
|
||||||
|
// Get the rclone path from the environment variable or use the default path
|
||||||
|
rclonePath := os.Getenv("RCLONE_PATH")
|
||||||
|
if rclonePath == "" {
|
||||||
|
rclonePath = "rclone"
|
||||||
|
}
|
||||||
|
archiveCmd := exec.Command(rclonePath, archiveArgs...)
|
||||||
|
archiveOutput, archiveErr := archiveCmd.CombinedOutput()
|
||||||
|
|
||||||
|
// Print the output
|
||||||
|
fmt.Printf("Output for file %s: %s\n", file, string(archiveOutput))
|
||||||
|
|
||||||
|
// Check if file was successfully transferred
|
||||||
|
if archiveErr != nil {
|
||||||
|
fmt.Printf("Warning: Error archiving file %s for job %d: %v\n", file, jobID, archiveErr)
|
||||||
|
transferErrors = append(transferErrors,
|
||||||
|
fmt.Sprintf("Archive error for file %s: %v", file, archiveErr))
|
||||||
|
transferErrors = append(transferErrors,
|
||||||
|
fmt.Sprintf("Archive error for file %s: %v", file, archiveErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update job history with transfer results
|
||||||
|
history.FilesTransferred = filesTransferred
|
||||||
|
|
||||||
|
if len(transferErrors) > 0 {
|
||||||
|
history.Status = "completed_with_errors"
|
||||||
|
history.ErrorMessage = fmt.Sprintf("Transfer completed with %d errors:\n%s",
|
||||||
|
len(transferErrors), strings.Join(transferErrors, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update job history with completion status and end time
|
||||||
|
endTime := time.Now()
|
||||||
|
history.EndTime = &endTime
|
||||||
|
if job.Config.ArchiveEnabled && job.Config.ArchivePath != "" {
|
||||||
|
if history.ErrorMessage != "" {
|
||||||
|
history.Status = "completed_with_archive_error"
|
||||||
|
} else {
|
||||||
|
history.Status = "completed"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
history.Status = "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.UpdateJobHistory(history); err != nil {
|
||||||
|
fmt.Printf("Error updating job history for job %d: %v\n", jobID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update next run time if job is still scheduled
|
||||||
|
if entry := s.cron.Entry(s.jobs[jobID]); entry.ID != 0 {
|
||||||
|
job.NextRun = &entry.Next
|
||||||
|
if err := s.db.UpdateJobStatus(&job); err != nil {
|
||||||
|
fmt.Printf("Error updating next run time for job %d: %v\n", jobID, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Next run time for job %d: %s\n", jobID, entry.Next.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessOutputPattern processes an output pattern with variables and returns the result
|
||||||
|
// This function is useful for testing pattern processing in isolation
|
||||||
|
func ProcessOutputPattern(pattern string, originalFilename string) string {
|
||||||
|
// Process date variables
|
||||||
|
dateRegex := regexp.MustCompile(`\${date:([^}]+)}`)
|
||||||
|
processedPattern := dateRegex.ReplaceAllStringFunc(pattern, func(match string) string {
|
||||||
|
format := dateRegex.FindStringSubmatch(match)[1]
|
||||||
|
return time.Now().Format(format)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Split the filename and extension
|
||||||
|
ext := filepath.Ext(originalFilename)
|
||||||
|
filename := strings.TrimSuffix(originalFilename, ext)
|
||||||
|
|
||||||
|
// Replace filename and extension variables
|
||||||
|
processedPattern = strings.ReplaceAll(processedPattern, "${filename}", filename)
|
||||||
|
processedPattern = strings.ReplaceAll(processedPattern, "${ext}", ext)
|
||||||
|
|
||||||
|
return processedPattern
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRcloneFilterFile creates a temporary filter file for rclone with rename rules
|
||||||
|
func createRcloneFilterFile(pattern string) (string, error) {
|
||||||
|
// Create a temporary file
|
||||||
|
tmpFile, err := ioutil.TempFile("", "rclone-filter-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temporary filter file: %v", err)
|
||||||
|
}
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
// Process the pattern to create a rclone filter rule
|
||||||
|
// First, replace date variables with current date in the specified format
|
||||||
|
dateRegex := regexp.MustCompile(`\${date:([^}]+)}`)
|
||||||
|
processedPattern := dateRegex.ReplaceAllStringFunc(pattern, func(match string) string {
|
||||||
|
format := dateRegex.FindStringSubmatch(match)[1]
|
||||||
|
return time.Now().Format(format)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Replace filename and extension variables with rclone's capture group references
|
||||||
|
// For rclone rename filters, we need to use {1} for the first capture group, not $1
|
||||||
|
// See: https://rclone.org/filtering/#rename
|
||||||
|
|
||||||
|
// Extract filename without extension
|
||||||
|
processedPattern = strings.ReplaceAll(processedPattern, "${filename}", "{1}")
|
||||||
|
|
||||||
|
// Extract extension (with the dot)
|
||||||
|
processedPattern = strings.ReplaceAll(processedPattern, "${ext}", "{2}")
|
||||||
|
|
||||||
|
// Create a rename rule for rclone using the correct syntax:
|
||||||
|
// - The format for rename filters is: "-- SourceRegexp ReplacementPattern"
|
||||||
|
// - For files with extension: capture the name and extension separately
|
||||||
|
rule := fmt.Sprintf("-- (.*)(\\..+)$ %s\n", processedPattern)
|
||||||
|
|
||||||
|
// Add a fallback rule for files without extension
|
||||||
|
fallbackRule := fmt.Sprintf("-- ([^.]+)$ %s\n",
|
||||||
|
strings.ReplaceAll(processedPattern, "{2}", ""))
|
||||||
|
|
||||||
|
// Write the rules to the file
|
||||||
|
if _, err := tmpFile.WriteString(rule + fallbackRule); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write to filter file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) UnscheduleJob(jobID uint) {
|
||||||
|
s.jobMutex.Lock()
|
||||||
|
defer s.jobMutex.Unlock()
|
||||||
|
|
||||||
|
if entryID, exists := s.jobs[jobID]; exists {
|
||||||
|
s.cron.Remove(entryID)
|
||||||
|
delete(s.jobs, jobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Stop() {
|
||||||
|
if s.cron != nil {
|
||||||
|
s.cron.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) RunJobNow(jobID uint) error {
|
||||||
|
go s.executeJob(jobID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/config"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/email"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/scheduler"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/web/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler is a wrapper around the handlers package
|
||||||
|
type Handler struct {
|
||||||
|
handlers *handlers.Handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new Handler instance that delegates to the handlers package
|
||||||
|
func NewHandler(database *db.DB, scheduler *scheduler.Scheduler, jwtSecret string, dbPath string, backupDir string, cfg *config.Config) (*Handler, error) {
|
||||||
|
// Create email service instance
|
||||||
|
emailService := email.NewService(cfg)
|
||||||
|
|
||||||
|
// Create handlers instance
|
||||||
|
handlersInstance := handlers.NewHandlers(database, scheduler, jwtSecret, dbPath, backupDir, emailService)
|
||||||
|
|
||||||
|
return &Handler{
|
||||||
|
handlers: handlersInstance,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitializeRoutes delegates route registration to the handlers package
|
||||||
|
func (h *Handler) InitializeRoutes(router *gin.Engine) {
|
||||||
|
// Register all routes through the handlers package
|
||||||
|
h.handlers.RegisterRoutes(router)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleBackupDB handles the POST /admin/backup route
|
||||||
|
func (h *Handlers) HandleBackupDB(c *gin.Context) {
|
||||||
|
// TODO: Implement database backup
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Database backup initiated"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleAdminTools displays the admin tools page
|
||||||
|
func (h *Handlers) HandleAdminTools(c *gin.Context) {
|
||||||
|
// Get system statistics
|
||||||
|
data := components.AdminToolsData{
|
||||||
|
SystemUptime: h.getSystemUptime(),
|
||||||
|
DatabasePath: h.DBPath,
|
||||||
|
BackupPath: h.BackupDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get database size
|
||||||
|
if dbSize, err := h.getDatabaseSize(); err == nil {
|
||||||
|
data.DatabaseSize = dbSize
|
||||||
|
} else {
|
||||||
|
data.DatabaseSize = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get job history count
|
||||||
|
var jobHistoryCount int64
|
||||||
|
if err := h.DB.Model(&db.JobHistory{}).Count(&jobHistoryCount).Error; err == nil {
|
||||||
|
data.JobHistoryCount = int(jobHistoryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active jobs count
|
||||||
|
var activeJobs int64
|
||||||
|
if err := h.DB.Model(&db.Job{}).Where("enabled = ?", true).Count(&activeJobs).Error; err == nil {
|
||||||
|
data.ActiveJobs = int(activeJobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total configs count
|
||||||
|
var totalConfigs int64
|
||||||
|
if err := h.DB.Model(&db.TransferConfig{}).Count(&totalConfigs).Error; err == nil {
|
||||||
|
data.TotalConfigs = int(totalConfigs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total jobs count
|
||||||
|
var totalJobs int64
|
||||||
|
if err := h.DB.Model(&db.Job{}).Count(&totalJobs).Error; err == nil {
|
||||||
|
data.TotalJobs = int(totalJobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total users count
|
||||||
|
var totalUsers int64
|
||||||
|
if err := h.DB.Model(&db.User{}).Count(&totalUsers).Error; err == nil {
|
||||||
|
data.TotalUsers = int(totalUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last backup time and backup count
|
||||||
|
data.LastBackupTime, data.BackupCount = h.getBackupInfo()
|
||||||
|
|
||||||
|
// Get list of backup files
|
||||||
|
data.BackupFiles = h.getBackupFiles()
|
||||||
|
|
||||||
|
// Check for maintenance issues
|
||||||
|
data.MaintenanceMessage = h.checkMaintenanceIssues()
|
||||||
|
|
||||||
|
// Render the admin tools page
|
||||||
|
components.AdminTools(components.CreateTemplateContext(c), data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleBackupDatabase handles the backup database request
|
||||||
|
func (h *Handlers) HandleBackupDatabase(c *gin.Context) {
|
||||||
|
fmt.Println("Backup database")
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(h.BackupDir, 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create backup directory: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup filename with timestamp
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
backupFilename := filepath.Join(h.BackupDir, fmt.Sprintf("gomft_backup_%s.db", timestamp))
|
||||||
|
|
||||||
|
// Copy the database file to the backup location
|
||||||
|
if err := h.copyDatabaseToBackup(backupFilename); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create backup: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Database backup created successfully", "filename": backupFilename})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRestoreDatabase handles the restore database request
|
||||||
|
func (h *Handlers) HandleRestoreDatabase(c *gin.Context) {
|
||||||
|
// Get the uploaded file
|
||||||
|
file, err := c.FormFile("backup_file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No backup file provided"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary file to store the uploaded backup
|
||||||
|
tempFile, err := os.CreateTemp("", "gomft_restore_*.db")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create temporary file: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
defer tempFile.Close()
|
||||||
|
|
||||||
|
// Save the uploaded file to the temporary location
|
||||||
|
if err := c.SaveUploadedFile(file, tempFile.Name()); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save uploaded file: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the scheduler to prevent jobs from running during restore
|
||||||
|
h.Scheduler.Stop()
|
||||||
|
|
||||||
|
// Close the current database connection
|
||||||
|
if err := h.DB.Close(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to close database: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a backup of the current database before restoring
|
||||||
|
backupBeforeRestore := filepath.Join(h.BackupDir, fmt.Sprintf("pre_restore_backup_%s.db", time.Now().Format("20060102_150405")))
|
||||||
|
if err := h.copyDatabaseToBackup(backupBeforeRestore); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create pre-restore backup: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the temporary file to the database location
|
||||||
|
if err := copyFile(tempFile.Name(), h.DBPath); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to restore database: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to home page to reinitialize the application
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Database restored successfully. The application will restart."})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleExportConfigs handles the export all configurations request
|
||||||
|
func (h *Handlers) HandleExportConfigs(c *gin.Context) {
|
||||||
|
// Get all configurations
|
||||||
|
var configs []db.TransferConfig
|
||||||
|
if err := h.DB.Find(&configs).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve configurations: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary file
|
||||||
|
tmpFile, err := os.CreateTemp("", "gomft_configs_*.json")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create temporary file: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name()) // Clean up temp file when done
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
// Write the configurations to the file
|
||||||
|
encoder := json.NewEncoder(tmpFile)
|
||||||
|
encoder.SetIndent("", " ") // Pretty print the JSON
|
||||||
|
if err := encoder.Encode(configs); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to write configurations to file: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers for file download
|
||||||
|
c.Header("Content-Description", "File Transfer")
|
||||||
|
c.Header("Content-Transfer-Encoding", "binary")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="gomft_configs_%s.json"`, time.Now().Format("20060102_150405")))
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Send the file
|
||||||
|
c.File(tmpFile.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleExportJobs handles the export all jobs request
|
||||||
|
func (h *Handlers) HandleExportJobs(c *gin.Context) {
|
||||||
|
// Get all jobs
|
||||||
|
var jobs []db.Job
|
||||||
|
if err := h.DB.Preload("Config").Find(&jobs).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve jobs: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary file
|
||||||
|
tmpFile, err := os.CreateTemp("", "gomft_jobs_*.json")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create temporary file: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name()) // Clean up temp file when done
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
// Write the jobs to the file
|
||||||
|
encoder := json.NewEncoder(tmpFile)
|
||||||
|
encoder.SetIndent("", " ") // Pretty print the JSON
|
||||||
|
if err := encoder.Encode(jobs); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to write jobs to file: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers for file download
|
||||||
|
c.Header("Content-Description", "File Transfer")
|
||||||
|
c.Header("Content-Transfer-Encoding", "binary")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="gomft_jobs_%s.json"`, time.Now().Format("20060102_150405")))
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Send the file
|
||||||
|
c.File(tmpFile.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleClearJobHistory handles the clear job history request
|
||||||
|
func (h *Handlers) HandleClearJobHistory(c *gin.Context) {
|
||||||
|
// Delete all job history records
|
||||||
|
if err := h.DB.Exec("DELETE FROM job_histories").Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to clear job history: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Job history cleared successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleVacuumDatabase handles the vacuum database request
|
||||||
|
func (h *Handlers) HandleVacuumDatabase(c *gin.Context) {
|
||||||
|
// Execute VACUUM command to optimize the database
|
||||||
|
if err := h.DB.Exec("VACUUM").Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to vacuum database: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Database vacuumed successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRestoreDatabaseByFilename handles restoring a backup file from the backup directory
|
||||||
|
func (h *Handlers) HandleRestoreDatabaseByFilename(c *gin.Context) {
|
||||||
|
filename := c.Param("filename")
|
||||||
|
|
||||||
|
// Validate filename format
|
||||||
|
if !strings.HasPrefix(filename, "gomft_backup_") || !strings.HasSuffix(filename, ".db") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct full file path
|
||||||
|
backupPath := filepath.Join(h.BackupDir, filename)
|
||||||
|
|
||||||
|
// Check if file exists and is within backup directory
|
||||||
|
if !strings.HasPrefix(backupPath, h.BackupDir) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Backup file not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the scheduler to prevent jobs from running during restore
|
||||||
|
h.Scheduler.Stop()
|
||||||
|
|
||||||
|
// Close the current database connection
|
||||||
|
if err := h.DB.Close(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to close database: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a backup of the current database before restoring
|
||||||
|
backupBeforeRestore := filepath.Join(h.BackupDir, fmt.Sprintf("pre_restore_backup_%s.db", time.Now().Format("20060102_150405")))
|
||||||
|
if err := h.copyDatabaseToBackup(backupBeforeRestore); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create pre-restore backup: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the backup file to the database location
|
||||||
|
if err := copyFile(backupPath, h.DBPath); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to restore database: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Database restored successfully. The application will restart."})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRefreshBackups handles the HTMX request to refresh the backups list
|
||||||
|
func (h *Handlers) HandleRefreshBackups(c *gin.Context) {
|
||||||
|
// Get list of backup files
|
||||||
|
backupFiles := h.getBackupFiles()
|
||||||
|
|
||||||
|
// Create data structure for the template
|
||||||
|
data := components.AdminToolsData{
|
||||||
|
BackupFiles: backupFiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last backup time and backup count
|
||||||
|
data.LastBackupTime, data.BackupCount = h.getBackupInfo()
|
||||||
|
|
||||||
|
// Render just the BackupsList component
|
||||||
|
components.BackupsList(data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
// getSystemUptime returns the system uptime as a formatted string
|
||||||
|
func (h *Handlers) getSystemUptime() string {
|
||||||
|
uptime := time.Since(h.StartTime)
|
||||||
|
days := int(uptime.Hours() / 24)
|
||||||
|
hours := int(uptime.Hours()) % 24
|
||||||
|
minutes := int(uptime.Minutes()) % 60
|
||||||
|
|
||||||
|
if days > 0 {
|
||||||
|
return fmt.Sprintf("%d days, %d hours, %d minutes", days, hours, minutes)
|
||||||
|
}
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%d hours, %d minutes", hours, minutes)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d minutes", minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDatabaseSize returns the size of the database file as a formatted string
|
||||||
|
func (h *Handlers) getDatabaseSize() (string, error) {
|
||||||
|
fileInfo, err := os.Stat(h.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeBytes := fileInfo.Size()
|
||||||
|
|
||||||
|
// Format size
|
||||||
|
if sizeBytes < 1024 {
|
||||||
|
return fmt.Sprintf("%d B", sizeBytes), nil
|
||||||
|
} else if sizeBytes < 1024*1024 {
|
||||||
|
return fmt.Sprintf("%.2f KB", float64(sizeBytes)/1024), nil
|
||||||
|
} else if sizeBytes < 1024*1024*1024 {
|
||||||
|
return fmt.Sprintf("%.2f MB", float64(sizeBytes)/(1024*1024)), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f GB", float64(sizeBytes)/(1024*1024*1024)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBackupInfo returns the last backup time and total backup count
|
||||||
|
func (h *Handlers) getBackupInfo() (*time.Time, int) {
|
||||||
|
// Check if backup directory exists
|
||||||
|
if _, err := os.Stat(h.BackupDir); os.IsNotExist(err) {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all backup files
|
||||||
|
backupFiles, err := filepath.Glob(filepath.Join(h.BackupDir, "gomft_backup_*.db"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(backupFiles) == 0 {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most recent backup
|
||||||
|
var lastBackupTime time.Time
|
||||||
|
var lastBackupFile string
|
||||||
|
|
||||||
|
for _, file := range backupFiles {
|
||||||
|
filename := filepath.Base(file)
|
||||||
|
// Extract timestamp from filename (format: gomft_backup_20060102_150405.db)
|
||||||
|
if len(filename) < 28 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
timestampStr := filename[13:28]
|
||||||
|
timestamp, err := time.Parse("20060102_150405", timestampStr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if timestamp.After(lastBackupTime) {
|
||||||
|
lastBackupTime = timestamp
|
||||||
|
lastBackupFile = file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastBackupFile == "" {
|
||||||
|
return nil, len(backupFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &lastBackupTime, len(backupFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMaintenanceIssues checks for potential maintenance issues
|
||||||
|
func (h *Handlers) checkMaintenanceIssues() string {
|
||||||
|
var issues []string
|
||||||
|
|
||||||
|
// Check database size
|
||||||
|
fileInfo, err := os.Stat(h.DBPath)
|
||||||
|
if err == nil {
|
||||||
|
sizeBytes := fileInfo.Size()
|
||||||
|
// If database is larger than 100MB, suggest vacuum
|
||||||
|
if sizeBytes > 100*1024*1024 {
|
||||||
|
issues = append(issues, "Database size is large (>100MB). Consider running vacuum to optimize.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check job history count
|
||||||
|
var jobHistoryCount int64
|
||||||
|
if err := h.DB.Model(&db.JobHistory{}).Count(&jobHistoryCount).Error; err == nil {
|
||||||
|
// If more than 1000 job history records, suggest clearing old records
|
||||||
|
if jobHistoryCount > 1000 {
|
||||||
|
issues = append(issues, fmt.Sprintf("Job history contains %d records. Consider clearing old records.", jobHistoryCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check backup age
|
||||||
|
lastBackupTime, _ := h.getBackupInfo()
|
||||||
|
if lastBackupTime == nil {
|
||||||
|
issues = append(issues, "No database backups found. Consider creating a backup.")
|
||||||
|
} else {
|
||||||
|
// If last backup is older than 7 days, suggest creating a new backup
|
||||||
|
if time.Since(*lastBackupTime) > 7*24*time.Hour {
|
||||||
|
issues = append(issues, fmt.Sprintf("Last backup is %d days old. Consider creating a new backup.", int(time.Since(*lastBackupTime).Hours()/24)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join all issues with newlines
|
||||||
|
if len(issues) > 0 {
|
||||||
|
return fmt.Sprintf("Maintenance Recommendations:\n%s", strings.Join(issues, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyDatabaseToBackup copies the database file to the specified backup location
|
||||||
|
func (h *Handlers) copyDatabaseToBackup(backupPath string) error {
|
||||||
|
return copyFile(h.DBPath, backupPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile copies a file from src to dst
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
sourceFile, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sourceFile.Close()
|
||||||
|
|
||||||
|
destFile, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(destFile, sourceFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return destFile.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBackupFiles returns a list of backup files with their details
|
||||||
|
func (h *Handlers) getBackupFiles() []components.BackupFile {
|
||||||
|
var backupFiles []components.BackupFile
|
||||||
|
|
||||||
|
// Check if backup directory exists
|
||||||
|
if _, err := os.Stat(h.BackupDir); os.IsNotExist(err) {
|
||||||
|
return backupFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all backup files
|
||||||
|
files, err := filepath.Glob(filepath.Join(h.BackupDir, "gomft_backup_*.db"))
|
||||||
|
if err != nil {
|
||||||
|
return backupFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
fileInfo, err := os.Stat(file)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format file size
|
||||||
|
var sizeStr string
|
||||||
|
size := fileInfo.Size()
|
||||||
|
switch {
|
||||||
|
case size < 1024:
|
||||||
|
sizeStr = fmt.Sprintf("%d B", size)
|
||||||
|
case size < 1024*1024:
|
||||||
|
sizeStr = fmt.Sprintf("%.2f KB", float64(size)/1024)
|
||||||
|
case size < 1024*1024*1024:
|
||||||
|
sizeStr = fmt.Sprintf("%.2f MB", float64(size)/(1024*1024))
|
||||||
|
default:
|
||||||
|
sizeStr = fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
backupFiles = append(backupFiles, components.BackupFile{
|
||||||
|
Name: filepath.Base(file),
|
||||||
|
Size: sizeStr,
|
||||||
|
ModTime: fileInfo.ModTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort backups by modification time, newest first
|
||||||
|
sort.Slice(backupFiles, func(i, j int) bool {
|
||||||
|
return backupFiles[i].ModTime.After(backupFiles[j].ModTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
return backupFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteBackup handles the DELETE /admin/delete-backup/:filename route
|
||||||
|
func (h *Handlers) HandleDeleteBackup(c *gin.Context) {
|
||||||
|
filename := c.Param("filename")
|
||||||
|
|
||||||
|
// Validate filename format
|
||||||
|
if !strings.HasPrefix(filename, "gomft_backup_") || !strings.HasSuffix(filename, ".db") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct full file path
|
||||||
|
filePath := filepath.Join(h.BackupDir, filename)
|
||||||
|
|
||||||
|
// Check if file exists and is within backup directory
|
||||||
|
if !strings.HasPrefix(filePath, h.BackupDir) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the file
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Backup file not found"})
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete backup: %v", err)})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Backup deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDownloadBackup handles the GET /admin/download-backup/:filename route
|
||||||
|
func (h *Handlers) HandleDownloadBackup(c *gin.Context) {
|
||||||
|
filename := c.Param("filename")
|
||||||
|
|
||||||
|
// Validate filename format
|
||||||
|
if !strings.HasPrefix(filename, "gomft_backup_") || !strings.HasSuffix(filename, ".db") {
|
||||||
|
c.String(http.StatusBadRequest, "Invalid backup filename")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct full file path
|
||||||
|
filePath := filepath.Join(h.BackupDir, filename)
|
||||||
|
|
||||||
|
// Check if file exists and is within backup directory
|
||||||
|
if !strings.HasPrefix(filePath, h.BackupDir) {
|
||||||
|
c.String(http.StatusBadRequest, "Invalid backup path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
c.String(http.StatusNotFound, "Backup file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers for file download
|
||||||
|
c.Header("Content-Description", "File Transfer")
|
||||||
|
c.Header("Content-Transfer-Encoding", "binary")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
|
|
||||||
|
// Serve the file
|
||||||
|
c.File(filePath)
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleAPILogin handles the POST /api/login route
|
||||||
|
func (h *Handlers) HandleAPILogin(c *gin.Context) {
|
||||||
|
var loginData struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&loginData); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request data"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user by email
|
||||||
|
var user db.User
|
||||||
|
if err := h.DB.Where("email = ?", loginData.Email).First(&user).Error; err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(loginData.Password)); err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"token": token,
|
||||||
|
"user": gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"is_admin": user.IsAdmin,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIConfigs handles the GET /api/configs route
|
||||||
|
func (h *Handlers) HandleAPIConfigs(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var configs []db.TransferConfig
|
||||||
|
h.DB.Where("created_by = ?", userID).Find(&configs)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"configs": configs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIConfig handles the GET /api/configs/:id route
|
||||||
|
func (h *Handlers) HandleAPIConfig(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this config
|
||||||
|
if config.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to view this config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"config": config})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPICreateConfig handles the POST /api/configs route
|
||||||
|
func (h *Handlers) HandleAPICreateConfig(c *gin.Context) {
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := c.ShouldBindJSON(&config); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request data: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
config.CreatedBy = userID
|
||||||
|
|
||||||
|
if err := h.DB.Create(&config).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create config: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"config": config})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIUpdateConfig handles the PUT /api/configs/:id route
|
||||||
|
func (h *Handlers) HandleAPIUpdateConfig(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this config
|
||||||
|
if config.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to update this config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the old config values for comparison
|
||||||
|
oldConfig := config
|
||||||
|
|
||||||
|
// Bind JSON data to config
|
||||||
|
if err := c.ShouldBindJSON(&config); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request data: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve fields that shouldn't be updated
|
||||||
|
config.CreatedBy = oldConfig.CreatedBy
|
||||||
|
|
||||||
|
if err := h.DB.Save(&config).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update config: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"config": config})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIDeleteConfig handles the DELETE /api/configs/:id route
|
||||||
|
func (h *Handlers) HandleAPIDeleteConfig(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this config
|
||||||
|
if config.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to delete this config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if config is in use by any jobs
|
||||||
|
var jobCount int64
|
||||||
|
h.DB.Model(&db.Job{}).Where("config_id = ?", config.ID).Count(&jobCount)
|
||||||
|
if jobCount > 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Config is in use by jobs and cannot be deleted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete config
|
||||||
|
if err := h.DB.Delete(&config).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete config: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPITestConnection handles the POST /api/configs/test route
|
||||||
|
func (h *Handlers) HandleAPITestConnection(c *gin.Context) {
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := c.ShouldBindJSON(&config); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request data: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement connection testing based on protocol
|
||||||
|
// This is a placeholder for the actual connection testing logic
|
||||||
|
success := true
|
||||||
|
message := "Connection successful"
|
||||||
|
|
||||||
|
// Example of how connection testing might work
|
||||||
|
switch config.SourceType {
|
||||||
|
case "sftp":
|
||||||
|
// Test SFTP connection
|
||||||
|
// success, message = testSFTPConnection(config)
|
||||||
|
case "ftp":
|
||||||
|
// Test FTP connection
|
||||||
|
// success, message = testFTPConnection(config)
|
||||||
|
default:
|
||||||
|
success = false
|
||||||
|
message = "Unsupported source type"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIJobs handles the API jobs request
|
||||||
|
func (h *Handlers) HandleAPIJobs(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API jobs handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIJob handles the API job request
|
||||||
|
func (h *Handlers) HandleAPIJob(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API job handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPICreateJob handles the API create job request
|
||||||
|
func (h *Handlers) HandleAPICreateJob(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API create job handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIUpdateJob handles the API update job request
|
||||||
|
func (h *Handlers) HandleAPIUpdateJob(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API update job handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIDeleteJob handles the API delete job request
|
||||||
|
func (h *Handlers) HandleAPIDeleteJob(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API delete job handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIRunJob handles the API run job request
|
||||||
|
func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API run job handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIHistory handles the API history request
|
||||||
|
func (h *Handlers) HandleAPIHistory(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API history handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIJobRun handles the API job run request
|
||||||
|
func (h *Handlers) HandleAPIJobRun(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API job run handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIUsers handles the API users request
|
||||||
|
func (h *Handlers) HandleAPIUsers(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API users handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIUser handles the API user request
|
||||||
|
func (h *Handlers) HandleAPIUser(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API user handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPICreateUser handles the API create user request
|
||||||
|
func (h *Handlers) HandleAPICreateUser(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API create user handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIUpdateUser handles the API update user request
|
||||||
|
func (h *Handlers) HandleAPIUpdateUser(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API update user handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAPIDeleteUser handles the API delete user request
|
||||||
|
func (h *Handlers) HandleAPIDeleteUser(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "API delete user handler stub"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/auth"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware is a middleware function that checks if the user is authenticated
|
||||||
|
func (h *Handlers) AuthMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Get the JWT token from the cookie
|
||||||
|
tokenString, err := c.Cookie("jwt_token")
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate the token
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(h.JWTSecret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract claims
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user information in the context
|
||||||
|
c.Set("userID", uint(claims["user_id"].(float64)))
|
||||||
|
c.Set("email", claims["email"].(string))
|
||||||
|
c.Set("username", claims["username"].(string))
|
||||||
|
c.Set("isAdmin", claims["is_admin"].(bool))
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMiddleware is a middleware function that checks if the user is an admin
|
||||||
|
func (h *Handlers) AdminMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || !isAdmin.(bool) {
|
||||||
|
c.Redirect(http.StatusFound, "/dashboard")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAuthMiddleware is a middleware function that checks if the API request is authenticated
|
||||||
|
func (h *Handlers) APIAuthMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Get the Authorization header
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the header is in the correct format
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate the token
|
||||||
|
tokenString := parts[1]
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(h.JWTSecret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract claims
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user information in the context
|
||||||
|
c.Set("userID", uint(claims["user_id"].(float64)))
|
||||||
|
c.Set("email", claims["email"].(string))
|
||||||
|
c.Set("username", claims["username"].(string))
|
||||||
|
c.Set("isAdmin", claims["is_admin"].(bool))
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminMiddleware is a middleware function that checks if the API request is from an admin
|
||||||
|
func (h *Handlers) APIAdminMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || !isAdmin.(bool) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin privileges required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateJWT generates a JWT token for the given user
|
||||||
|
func (h *Handlers) GenerateJWT(userID uint, username string, isAdmin bool) (string, error) {
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": userID,
|
||||||
|
"username": username,
|
||||||
|
"is_admin": isAdmin,
|
||||||
|
"exp": time.Now().Add(time.Hour * 24).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return token.SignedString([]byte(h.JWTSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLoginPage handles the GET /login route
|
||||||
|
func (h *Handlers) HandleLoginPage(c *gin.Context) {
|
||||||
|
// Check if user is already logged in
|
||||||
|
if userID, exists := c.Get("userID"); exists && userID != nil {
|
||||||
|
// User is logged in, redirect to dashboard
|
||||||
|
c.Redirect(http.StatusFound, "/dashboard")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create template context and set email if available
|
||||||
|
ctx := components.CreateTemplateContext(c)
|
||||||
|
if email, exists := c.Get("email"); exists {
|
||||||
|
ctx = context.WithValue(ctx, "email", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for message query param (used for password expired, etc.)
|
||||||
|
message := c.Query("message")
|
||||||
|
|
||||||
|
// User is not logged in, show login page
|
||||||
|
if message != "" {
|
||||||
|
components.Login(ctx, message).Render(c.Request.Context(), c.Writer)
|
||||||
|
} else {
|
||||||
|
components.Login(ctx, "").Render(c.Request.Context(), c.Writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogin handles the POST /login route
|
||||||
|
func (h *Handlers) HandleLogin(c *gin.Context) {
|
||||||
|
email := c.PostForm("email")
|
||||||
|
password := c.PostForm("password")
|
||||||
|
|
||||||
|
// Get user by email
|
||||||
|
var user db.User
|
||||||
|
if err := h.DB.Where("email = ?", email).First(&user).Error; err != nil {
|
||||||
|
components.Login(components.CreateTemplateContext(c), "Invalid credentials").Render(c, c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account is locked
|
||||||
|
if user.AccountLocked {
|
||||||
|
if user.LockoutUntil != nil && time.Now().After(*user.LockoutUntil) {
|
||||||
|
// Lockout period has expired, reset the lockout
|
||||||
|
user.AccountLocked = false
|
||||||
|
user.FailedLoginAttempts = 0
|
||||||
|
user.LockoutUntil = nil
|
||||||
|
h.DB.Save(&user)
|
||||||
|
} else {
|
||||||
|
// Account is still locked
|
||||||
|
components.Login(components.CreateTemplateContext(c), "Account is locked due to too many failed login attempts. Please try again later.").Render(c, c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
// Increment failed login attempts
|
||||||
|
user.FailedLoginAttempts++
|
||||||
|
|
||||||
|
// Check if we need to lock the account
|
||||||
|
policy := auth.DefaultPasswordPolicy()
|
||||||
|
if user.FailedLoginAttempts >= policy.MaxLoginAttempts {
|
||||||
|
user.AccountLocked = true
|
||||||
|
lockoutTime := time.Now().Add(policy.LockoutDuration)
|
||||||
|
user.LockoutUntil = &lockoutTime
|
||||||
|
h.DB.Save(&user)
|
||||||
|
components.Login(components.CreateTemplateContext(c), "Account is locked due to too many failed login attempts. Please try again later.").Render(c, c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.DB.Save(&user)
|
||||||
|
components.Login(components.CreateTemplateContext(c), "Invalid credentials").Render(c, c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset failed login attempts on successful login
|
||||||
|
user.FailedLoginAttempts = 0
|
||||||
|
user.AccountLocked = false
|
||||||
|
user.LockoutUntil = nil
|
||||||
|
h.DB.Save(&user)
|
||||||
|
|
||||||
|
// Check password expiration
|
||||||
|
policy := auth.DefaultPasswordPolicy()
|
||||||
|
if auth.IsPasswordExpired(user.LastPasswordChange, policy) {
|
||||||
|
// Add flash message about password expiration
|
||||||
|
// We're simplifying by just redirecting to login with a message
|
||||||
|
c.SetCookie("jwt_token", "", -1, "/", "", false, true) // Logout the user
|
||||||
|
c.Redirect(http.StatusFound, "/login?message=Your+password+has+expired.+Please+contact+an+administrator.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token with all necessary user information
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"username": strings.Split(user.Email, "@")[0], // Use email prefix as username
|
||||||
|
"is_admin": user.IsAdmin,
|
||||||
|
"exp": time.Now().Add(time.Hour * 24).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sign the token
|
||||||
|
tokenString, err := token.SignedString([]byte(h.JWTSecret))
|
||||||
|
if err != nil {
|
||||||
|
components.Login(components.CreateTemplateContext(c), "Authentication error").Render(c, c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set token in cookie
|
||||||
|
c.SetCookie("jwt_token", tokenString, 86400, "/", "", false, true)
|
||||||
|
c.Redirect(http.StatusFound, "/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogout handles the POST /logout route
|
||||||
|
func (h *Handlers) HandleLogout(c *gin.Context) {
|
||||||
|
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleChangePassword handles the POST /change-password route
|
||||||
|
// This is now only for use from the profile page
|
||||||
|
func (h *Handlers) HandleChangePassword(c *gin.Context) {
|
||||||
|
// Get user ID from token
|
||||||
|
tokenCookie, err := c.Cookie("jwt_token")
|
||||||
|
if err != nil || tokenCookie == "" {
|
||||||
|
if c.GetHeader("HX-Request") == "true" {
|
||||||
|
c.Data(http.StatusUnauthorized, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">Authentication required</span>
|
||||||
|
</div>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := auth.ValidateToken(tokenCookie, h.JWTSecret)
|
||||||
|
if err != nil {
|
||||||
|
if c.GetHeader("HX-Request") == "true" {
|
||||||
|
c.Data(http.StatusUnauthorized, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">Invalid authentication</span>
|
||||||
|
</div>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := claims.UserID
|
||||||
|
|
||||||
|
// Get form values
|
||||||
|
currentPassword := c.PostForm("current_password")
|
||||||
|
newPassword := c.PostForm("new_password")
|
||||||
|
confirmPassword := c.PostForm("confirm_password")
|
||||||
|
|
||||||
|
// Validate new password matches confirmation
|
||||||
|
if newPassword != confirmPassword {
|
||||||
|
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">New password and confirmation do not match</span>
|
||||||
|
</div>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
var user db.User
|
||||||
|
if err := h.DB.First(&user, userID).Error; err != nil {
|
||||||
|
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">User not found</span>
|
||||||
|
</div>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
|
||||||
|
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">Current password is incorrect</span>
|
||||||
|
</div>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password against policy
|
||||||
|
policy := auth.DefaultPasswordPolicy()
|
||||||
|
if err := auth.ValidatePassword(newPassword, policy); err != nil {
|
||||||
|
errorMsg := `<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">` + err.Error() + `</span>
|
||||||
|
</div>`
|
||||||
|
c.Data(http.StatusOK, "text/html", []byte(errorMsg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password history
|
||||||
|
if err := auth.CheckPasswordHistory(user.ID, newPassword, user.PasswordHash, h.DB.DB, policy); err != nil {
|
||||||
|
errorMsg := `<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">` + err.Error() + `</span>
|
||||||
|
</div>`
|
||||||
|
c.Data(http.StatusOK, "text/html", []byte(errorMsg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the new password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">Error processing password</span>
|
||||||
|
</div>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password history
|
||||||
|
if err := auth.UpdatePasswordHistory(user.ID, string(hashedPassword), h.DB.DB, policy); err != nil {
|
||||||
|
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">Error updating password history</span>
|
||||||
|
</div>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user's password
|
||||||
|
user.PasswordHash = string(hashedPassword)
|
||||||
|
user.LastPasswordChange = time.Now()
|
||||||
|
if err := h.DB.Save(&user).Error; err != nil {
|
||||||
|
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">Error updating password</span>
|
||||||
|
</div>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success message
|
||||||
|
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
|
||||||
|
<span class="block sm:inline">Password updated successfully!</span>
|
||||||
|
</div>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleForgotPasswordPage displays the forgot password form
|
||||||
|
func (h *Handlers) HandleForgotPasswordPage(c *gin.Context) {
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ForgotPassword(ctx, "", "").Render(c.Request.Context(), c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleForgotPassword processes the forgot password form submission
|
||||||
|
func (h *Handlers) HandleForgotPassword(c *gin.Context) {
|
||||||
|
email := c.PostForm("email")
|
||||||
|
if email == "" {
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ForgotPassword(ctx, "Email is required", "").Render(c.Request.Context(), c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
user, err := h.DB.GetUserByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
// Don't reveal that the email doesn't exist for security reasons
|
||||||
|
// But we'll log it for debugging
|
||||||
|
log.Printf("Password reset requested for non-existent email: %s", email)
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ForgotPassword(ctx, "", "If your email is registered, you will receive a password reset link.").Render(c.Request.Context(), c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate reset token
|
||||||
|
token, err := generateResetToken(32)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error generating reset token: %v", err)
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ForgotPassword(ctx, "An error occurred. Please try again later.", "").Render(c.Request.Context(), c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save token in database with expiration time (15 minutes)
|
||||||
|
expiration := time.Now().Add(15 * time.Minute)
|
||||||
|
resetToken := &db.PasswordResetToken{
|
||||||
|
UserID: user.ID,
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: expiration,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.DB.CreatePasswordResetToken(resetToken); err != nil {
|
||||||
|
log.Printf("Error saving reset token: %v", err)
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ForgotPassword(ctx, "An error occurred. Please try again later.", "").Render(c.Request.Context(), c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send password reset email
|
||||||
|
err = h.Email.SendPasswordResetEmail(user.Email, user.Email, token)
|
||||||
|
if err != nil {
|
||||||
|
// If email sending fails, log the error but don't expose this to the user
|
||||||
|
log.Printf("Error sending password reset email: %v", err)
|
||||||
|
|
||||||
|
// If email is disabled, log the reset link
|
||||||
|
if strings.Contains(err.Error(), "email service is disabled") {
|
||||||
|
log.Printf("Email service is disabled, reset link: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message regardless of whether email was sent
|
||||||
|
// This prevents user enumeration attacks
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ForgotPassword(ctx, "", "If your email is registered, you will receive a password reset link.").Render(c.Request.Context(), c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleResetPasswordPage displays the reset password form
|
||||||
|
func (h *Handlers) HandleResetPasswordPage(c *gin.Context) {
|
||||||
|
token := c.Query("token")
|
||||||
|
if token == "" {
|
||||||
|
c.Redirect(http.StatusFound, "/forgot-password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token exists and hasn't expired
|
||||||
|
_, err := h.DB.GetPasswordResetToken(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Invalid reset token: %s, error: %v", token, err)
|
||||||
|
c.Redirect(http.StatusFound, "/forgot-password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ResetPassword(ctx, token, "").Render(c.Request.Context(), c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleResetPassword processes the reset password form submission
|
||||||
|
func (h *Handlers) HandleResetPassword(c *gin.Context) {
|
||||||
|
token := c.PostForm("token")
|
||||||
|
password := c.PostForm("password")
|
||||||
|
confirmPassword := c.PostForm("confirm-password")
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
c.Redirect(http.StatusFound, "/forgot-password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if password == "" || confirmPassword == "" {
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ResetPassword(ctx, token, "Both password fields are required.").Render(c.Request.Context(), c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if password != confirmPassword {
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ResetPassword(ctx, token, "Passwords do not match.").Render(c.Request.Context(), c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(password) < 8 {
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ResetPassword(ctx, token, "Password must be at least 8 characters long.").Render(c.Request.Context(), c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token and get user
|
||||||
|
resetToken, err := h.DB.GetPasswordResetToken(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Invalid reset token: %s, error: %v", token, err)
|
||||||
|
c.Redirect(http.StatusFound, "/forgot-password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user
|
||||||
|
user, err := h.DB.GetUserByID(resetToken.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("User not found for token: %s, user ID: %d, error: %v", token, resetToken.UserID, err)
|
||||||
|
c.Redirect(http.StatusFound, "/forgot-password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the new password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error hashing password: %v", err)
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ResetPassword(ctx, token, "An error occurred. Please try again later.").Render(c.Request.Context(), c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user's password
|
||||||
|
user.PasswordHash = string(hashedPassword)
|
||||||
|
user.LastPasswordChange = time.Now()
|
||||||
|
if err := h.DB.UpdateUser(user); err != nil {
|
||||||
|
log.Printf("Error updating user password: %v", err)
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "theme", "light")
|
||||||
|
components.ResetPassword(ctx, token, "An error occurred. Please try again later.").Render(c.Request.Context(), c.Writer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record password history
|
||||||
|
passwordHistory := &auth.PasswordHistory{
|
||||||
|
UserID: user.ID,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
}
|
||||||
|
if err := h.DB.DB.Create(passwordHistory).Error; err != nil {
|
||||||
|
log.Printf("Error recording password history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark token as used
|
||||||
|
if err := h.DB.MarkPasswordResetTokenAsUsed(resetToken.ID); err != nil {
|
||||||
|
log.Printf("Error marking token as used: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login with success message
|
||||||
|
c.Redirect(http.StatusFound, "/login?message=Password+reset+successful.+Please+log+in+with+your+new+password.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to generate a random token
|
||||||
|
func generateResetToken(length int) (string, error) {
|
||||||
|
b := make([]byte, length)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleHome handles the GET / route
|
||||||
|
func (h *Handlers) HandleHome(c *gin.Context) {
|
||||||
|
// Check for JWT token in cookie
|
||||||
|
tokenCookie, err := c.Cookie("jwt_token")
|
||||||
|
if err == nil && tokenCookie != "" {
|
||||||
|
// Token exists, validate it
|
||||||
|
claims, err := auth.ValidateToken(tokenCookie, h.JWTSecret)
|
||||||
|
if err == nil && claims != nil {
|
||||||
|
// Valid token, redirect to dashboard
|
||||||
|
c.Redirect(http.StatusFound, "/dashboard")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is not logged in, show home page
|
||||||
|
components.Home(c.Request.Context()).Render(c, c.Writer)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleConfigs handles the GET /configs route
|
||||||
|
func (h *Handlers) HandleConfigs(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var configs []db.TransferConfig
|
||||||
|
h.DB.Where("created_by = ?", userID).Find(&configs)
|
||||||
|
|
||||||
|
data := components.ConfigsData{
|
||||||
|
Configs: configs,
|
||||||
|
}
|
||||||
|
components.Configs(c.Request.Context(), data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleNewConfig handles the GET /configs/new route
|
||||||
|
func (h *Handlers) HandleNewConfig(c *gin.Context) {
|
||||||
|
data := components.ConfigFormData{
|
||||||
|
Config: &db.TransferConfig{},
|
||||||
|
IsNew: true,
|
||||||
|
}
|
||||||
|
components.ConfigForm(c.Request.Context(), data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEditConfig handles the GET /configs/:id/edit route
|
||||||
|
func (h *Handlers) HandleEditConfig(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/configs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this config
|
||||||
|
if config.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.Redirect(http.StatusFound, "/configs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := components.ConfigFormData{
|
||||||
|
Config: &config,
|
||||||
|
IsNew: false,
|
||||||
|
}
|
||||||
|
components.ConfigForm(c.Request.Context(), data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCreateConfig handles the POST /configs route
|
||||||
|
func (h *Handlers) HandleCreateConfig(c *gin.Context) {
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := c.ShouldBind(&config); err != nil {
|
||||||
|
log.Printf("Error binding config form: %v", err)
|
||||||
|
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
config.CreatedBy = userID
|
||||||
|
|
||||||
|
if err := h.DB.Create(&config).Error; err != nil {
|
||||||
|
log.Printf("Error creating config: %v", err)
|
||||||
|
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to create config: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate rclone config file
|
||||||
|
if err := h.DB.GenerateRcloneConfig(&config); err != nil {
|
||||||
|
log.Printf("Warning: Failed to generate rclone config: %v", err)
|
||||||
|
// Continue anyway, as the config was created in the database
|
||||||
|
} else {
|
||||||
|
log.Printf("Generated rclone config for config ID %d", config.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/configs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateConfig handles the PUT /configs/:id route
|
||||||
|
func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
|
log.Printf("Error finding config: %v", err)
|
||||||
|
c.String(http.StatusNotFound, "Config not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this config
|
||||||
|
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 update this config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the old config values for comparison
|
||||||
|
oldConfig := config
|
||||||
|
|
||||||
|
// Bind form data to config
|
||||||
|
if err := c.ShouldBind(&config); err != nil {
|
||||||
|
log.Printf("Error binding config form: %v", err)
|
||||||
|
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve fields that shouldn't be updated
|
||||||
|
config.CreatedBy = oldConfig.CreatedBy
|
||||||
|
|
||||||
|
if err := h.DB.Save(&config).Error; err != nil {
|
||||||
|
log.Printf("Error updating config: %v", err)
|
||||||
|
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update config: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate rclone config file
|
||||||
|
if err := h.DB.GenerateRcloneConfig(&config); err != nil {
|
||||||
|
log.Printf("Warning: Failed to regenerate rclone config: %v", err)
|
||||||
|
// Continue anyway, as the config was updated in the database
|
||||||
|
} else {
|
||||||
|
log.Printf("Regenerated rclone config for config ID %d", config.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/configs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteConfig handles the DELETE /configs/:id route
|
||||||
|
func (h *Handlers) HandleDeleteConfig(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this config
|
||||||
|
if config.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to delete this config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if config is in use by any jobs
|
||||||
|
var jobCount int64
|
||||||
|
h.DB.Model(&db.Job{}).Where("config_id = ?", config.ID).Count(&jobCount)
|
||||||
|
if jobCount > 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Config is in use by jobs and cannot be deleted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete config
|
||||||
|
if err := h.DB.Delete(&config).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete config: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTestConnection handles the POST /configs/test route
|
||||||
|
func (h *Handlers) HandleTestConnection(c *gin.Context) {
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := c.ShouldBind(&config); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid form data: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement connection testing based on protocol
|
||||||
|
// This is a placeholder for the actual connection testing logic
|
||||||
|
success := true
|
||||||
|
message := "Connection successful"
|
||||||
|
|
||||||
|
// Example of how connection testing might work
|
||||||
|
switch config.SourceType {
|
||||||
|
case "sftp":
|
||||||
|
// Test SFTP connection
|
||||||
|
// success, message = testSFTPConnection(config)
|
||||||
|
default:
|
||||||
|
success = false
|
||||||
|
message = "Unsupported source type"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTestSFTPConnection handles the test SFTP connection request
|
||||||
|
func (h *Handlers) HandleTestSFTPConnection(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Test SFTP connection handler stub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleBrowseDirectory handles the browse directory request
|
||||||
|
func (h *Handlers) HandleBrowseDirectory(c *gin.Context) {
|
||||||
|
// Implementation will be moved from the old handlers.go
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Browse directory handler stub"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleDashboard handles the GET /dashboard route
|
||||||
|
func (h *Handlers) HandleDashboard(c *gin.Context) {
|
||||||
|
|
||||||
|
// Get recent job history
|
||||||
|
var recentHistory []db.JobHistory
|
||||||
|
h.DB.Order("start_time DESC").Limit(5).Find(&recentHistory)
|
||||||
|
|
||||||
|
// Get job statistics
|
||||||
|
var totalJobs int64
|
||||||
|
h.DB.Model(&db.JobHistory{}).Where("job_histories.status = 'running' AND job_histories.end_time IS NULL").Count(&totalJobs)
|
||||||
|
|
||||||
|
var completedJobs int64
|
||||||
|
h.DB.Model(&db.JobHistory{}).Where("status = ?", "completed").Count(&completedJobs)
|
||||||
|
|
||||||
|
var failedJobs int64
|
||||||
|
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failed").Count(&failedJobs)
|
||||||
|
|
||||||
|
data := components.DashboardData{
|
||||||
|
RecentJobs: recentHistory,
|
||||||
|
ActiveTransfers: int(totalJobs),
|
||||||
|
CompletedToday: int(completedJobs),
|
||||||
|
FailedTransfers: int(failedJobs),
|
||||||
|
}
|
||||||
|
|
||||||
|
components.Dashboard(components.CreateTemplateContext(c), data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDashboardStats handles the dashboard stats API request
|
||||||
|
func (h *Handlers) HandleDashboardStats(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Get job statistics
|
||||||
|
var activeJobCount int64
|
||||||
|
var completedJobCount int64
|
||||||
|
var failedJobCount int64
|
||||||
|
|
||||||
|
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "running").Count(&activeJobCount)
|
||||||
|
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "completed").Count(&completedJobCount)
|
||||||
|
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "failed").Count(&failedJobCount)
|
||||||
|
|
||||||
|
// Get transfer statistics for the last 7 days
|
||||||
|
var dailyStats []struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Completed int64 `json:"completed"`
|
||||||
|
Failed int64 `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 6; i >= 0; i-- {
|
||||||
|
date := time.Now().AddDate(0, 0, -i)
|
||||||
|
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local)
|
||||||
|
endOfDay := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, time.Local)
|
||||||
|
|
||||||
|
var completed int64
|
||||||
|
var failed int64
|
||||||
|
|
||||||
|
h.DB.Model(&db.Job{}).
|
||||||
|
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "completed", startOfDay, endOfDay).
|
||||||
|
Count(&completed)
|
||||||
|
|
||||||
|
h.DB.Model(&db.Job{}).
|
||||||
|
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "failed", startOfDay, endOfDay).
|
||||||
|
Count(&failed)
|
||||||
|
|
||||||
|
dailyStats = append(dailyStats, struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Completed int64 `json:"completed"`
|
||||||
|
Failed int64 `json:"failed"`
|
||||||
|
}{
|
||||||
|
Date: startOfDay.Format("2006-01-02"),
|
||||||
|
Completed: completed,
|
||||||
|
Failed: failed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"activeJobs": activeJobCount,
|
||||||
|
"completedJobs": completedJobCount,
|
||||||
|
"failedJobs": failedJobCount,
|
||||||
|
"dailyStats": dailyStats,
|
||||||
|
"uptime": time.Since(h.StartTime).String(),
|
||||||
|
"uptimeSeconds": int64(time.Since(h.StartTime).Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRecentJobs handles the recent jobs API request
|
||||||
|
func (h *Handlers) HandleRecentJobs(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var recentJobs []db.Job
|
||||||
|
h.DB.Where("created_by = ?", userID).Order("created_at DESC").Limit(5).Find(&recentJobs)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"recentJobs": recentJobs,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/email"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/scheduler"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handlers contains all the dependencies needed by the handlers
|
||||||
|
type Handlers struct {
|
||||||
|
DB *db.DB
|
||||||
|
Scheduler *scheduler.Scheduler
|
||||||
|
JWTSecret string
|
||||||
|
StartTime time.Time
|
||||||
|
DBPath string
|
||||||
|
BackupDir string
|
||||||
|
Email *email.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandlers creates a new Handlers instance
|
||||||
|
func NewHandlers(database *db.DB, scheduler *scheduler.Scheduler, jwtSecret string, dbPath string, backupDir string, emailService *email.Service) *Handlers {
|
||||||
|
return &Handlers{
|
||||||
|
DB: database,
|
||||||
|
Scheduler: scheduler,
|
||||||
|
JWTSecret: jwtSecret,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
DBPath: dbPath,
|
||||||
|
BackupDir: backupDir,
|
||||||
|
Email: emailService,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleJobs handles the GET /jobs route
|
||||||
|
func (h *Handlers) HandleJobs(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var jobs []db.Job
|
||||||
|
h.DB.Where("created_by = ?", userID).Preload("Config").Find(&jobs)
|
||||||
|
|
||||||
|
data := components.JobsData{
|
||||||
|
Jobs: jobs,
|
||||||
|
}
|
||||||
|
components.Jobs(c, data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleJobRunDetails handles the GET /job/:id route
|
||||||
|
func (h *Handlers) HandleJobRunDetails(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
jobID := c.Param("id")
|
||||||
|
|
||||||
|
// Get job history
|
||||||
|
var jobHistory db.JobHistory
|
||||||
|
if err := h.DB.First(&jobHistory, jobID).Error; err != nil {
|
||||||
|
c.String(http.StatusNotFound, "Job not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get job
|
||||||
|
var job db.Job
|
||||||
|
if err := h.DB.First(&job, jobHistory.JobID).Error; err != nil {
|
||||||
|
c.String(http.StatusNotFound, "Job not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the user owns this job
|
||||||
|
if job.CreatedBy != userID {
|
||||||
|
c.String(http.StatusForbidden, "You don't have permission to view this job run")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the config
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
|
||||||
|
c.String(http.StatusNotFound, "Configuration not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := components.JobRunDetailsData{
|
||||||
|
JobHistory: jobHistory,
|
||||||
|
Job: job,
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
|
||||||
|
components.JobRunDetails(c.Request.Context(), data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleNewJob handles the GET /jobs/new route
|
||||||
|
func (h *Handlers) HandleNewJob(c *gin.Context) {
|
||||||
|
// Get available configs for the user
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
var configs []db.TransferConfig
|
||||||
|
h.DB.Where("created_by = ?", userID).Find(&configs)
|
||||||
|
|
||||||
|
data := components.JobFormData{
|
||||||
|
Job: &db.Job{},
|
||||||
|
Configs: configs,
|
||||||
|
IsNew: true,
|
||||||
|
}
|
||||||
|
components.JobForm(c.Request.Context(), data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEditJob handles the GET /jobs/:id/edit route
|
||||||
|
func (h *Handlers) HandleEditJob(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var job db.Job
|
||||||
|
if err := h.DB.First(&job, id).Error; err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/jobs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this job
|
||||||
|
if job.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.Redirect(http.StatusFound, "/jobs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available configs for the user
|
||||||
|
var configs []db.TransferConfig
|
||||||
|
h.DB.Where("created_by = ?", userID).Find(&configs)
|
||||||
|
|
||||||
|
data := components.JobFormData{
|
||||||
|
Job: &job,
|
||||||
|
Configs: configs,
|
||||||
|
IsNew: false,
|
||||||
|
}
|
||||||
|
components.JobForm(c.Request.Context(), data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCreateJob handles the POST /jobs route
|
||||||
|
func (h *Handlers) HandleCreateJob(c *gin.Context) {
|
||||||
|
var job db.Job
|
||||||
|
if err := c.ShouldBind(&job); err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "Invalid form data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
job.CreatedBy = userID
|
||||||
|
|
||||||
|
// Verify that the config exists and belongs to the user
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the config belongs to the user
|
||||||
|
if config.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If job name is empty, use the config name
|
||||||
|
if job.Name == "" {
|
||||||
|
job.Name = config.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the Config field to prevent GORM from creating a new config
|
||||||
|
job.Config = db.TransferConfig{}
|
||||||
|
|
||||||
|
// Create the job
|
||||||
|
if err := h.DB.CreateJob(&job); err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to create job")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/jobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateJob handles the PUT /jobs/:id route
|
||||||
|
func (h *Handlers) HandleUpdateJob(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var job db.Job
|
||||||
|
if err := h.DB.First(&job, id).Error; err != nil {
|
||||||
|
c.String(http.StatusNotFound, "Job not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this job
|
||||||
|
if job.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.String(http.StatusForbidden, "You do not have permission to update this job")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the old job values for comparison
|
||||||
|
oldJob := job
|
||||||
|
|
||||||
|
// Bind form data to job
|
||||||
|
if err := c.ShouldBind(&job); err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "Invalid form data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the config exists and belongs to the user
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the config belongs to the user
|
||||||
|
if config.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If job name is empty, use the config name
|
||||||
|
if job.Name == "" {
|
||||||
|
job.Name = config.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve fields that shouldn't be updated
|
||||||
|
job.CreatedBy = oldJob.CreatedBy
|
||||||
|
job.ID = oldJob.ID
|
||||||
|
|
||||||
|
// Clear the Config field to prevent GORM from updating or creating a new config
|
||||||
|
job.Config = db.TransferConfig{}
|
||||||
|
|
||||||
|
if err := h.DB.UpdateJob(&job); err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to update job")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/jobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteJob handles the DELETE /jobs/:id route
|
||||||
|
func (h *Handlers) HandleDeleteJob(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var job db.Job
|
||||||
|
if err := h.DB.First(&job, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this job
|
||||||
|
if job.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to delete this job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unschedule the job from the scheduler
|
||||||
|
h.Scheduler.UnscheduleJob(job.ID)
|
||||||
|
|
||||||
|
// Delete job
|
||||||
|
if err := h.DB.Delete(&job).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Job deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRunJob handles the POST /jobs/:id/run route
|
||||||
|
func (h *Handlers) HandleRunJob(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var job db.Job
|
||||||
|
if err := h.DB.First(&job, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns this job
|
||||||
|
if job.CreatedBy != userID {
|
||||||
|
// Check if user is admin
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to run this job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the job immediately using the scheduler
|
||||||
|
if err := h.Scheduler.RunJobNow(job.ID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run job: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Job started successfully",
|
||||||
|
"jobId": job.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleProfile handles the GET /profile route
|
||||||
|
func (h *Handlers) HandleProfile(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
var user db.User
|
||||||
|
if err := h.DB.First(&user, userID).Error; err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to retrieve user profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
components.Profile(c.Request.Context(), user).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateTheme handles the POST /profile/theme route
|
||||||
|
func (h *Handlers) HandleUpdateTheme(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
theme := c.PostForm("theme")
|
||||||
|
|
||||||
|
// Validate theme value
|
||||||
|
validThemes := map[string]bool{
|
||||||
|
"light": true,
|
||||||
|
"dark": true,
|
||||||
|
"system": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validThemes[theme] {
|
||||||
|
c.Status(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user theme preference
|
||||||
|
var user db.User
|
||||||
|
if err := h.DB.First(&user, userID).Error; err != nil {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Theme = theme
|
||||||
|
if err := h.DB.Save(&user).Error; err != nil {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set theme cookie for client-side theme switching
|
||||||
|
c.SetCookie("theme", theme, 60*60*24*365, "/", "", false, false)
|
||||||
|
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateProfile handles the POST /profile/update route
|
||||||
|
func (h *Handlers) HandleUpdateProfile(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
var user db.User
|
||||||
|
if err := h.DB.First(&user, userID).Error; err != nil {
|
||||||
|
c.String(http.StatusNotFound, "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user fields
|
||||||
|
user.Email = c.PostForm("email")
|
||||||
|
|
||||||
|
if err := h.DB.Save(&user).Error; err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update profile: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/profile")
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleHistory handles the GET /history route
|
||||||
|
func (h *Handlers) HandleHistory(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Get pagination parameters
|
||||||
|
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if err != nil || page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||||
|
if err != nil {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
// Limit page size options
|
||||||
|
if pageSize != 10 && pageSize != 25 && pageSize != 50 && pageSize != 100 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get search term
|
||||||
|
searchTerm := c.Query("search")
|
||||||
|
|
||||||
|
// Build the query
|
||||||
|
query := h.DB.Model(&db.JobHistory{}).
|
||||||
|
Joins("JOIN jobs ON jobs.id = job_histories.job_id").
|
||||||
|
Joins("JOIN transfer_configs ON transfer_configs.id = jobs.config_id").
|
||||||
|
Where("jobs.created_by = ?", userID)
|
||||||
|
|
||||||
|
// Apply search if provided
|
||||||
|
if searchTerm != "" {
|
||||||
|
query = query.Where("transfer_configs.name LIKE ? OR job_histories.status LIKE ?",
|
||||||
|
"%"+searchTerm+"%", "%"+searchTerm+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total matching records for pagination
|
||||||
|
var total int64
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
// Calculate total pages
|
||||||
|
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
|
||||||
|
if totalPages == 0 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure page is within bounds
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
var history []db.JobHistory
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
query.Offset(offset).
|
||||||
|
Limit(pageSize).
|
||||||
|
Preload("Job.Config").
|
||||||
|
Order("start_time desc").
|
||||||
|
Find(&history)
|
||||||
|
|
||||||
|
// If we got no results and we're not on page 1, redirect to page 1
|
||||||
|
// Only do this for non-HTMX requests to avoid navigation issues
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true"
|
||||||
|
if len(history) == 0 && page > 1 && total > 0 && !isHtmxRequest {
|
||||||
|
redirectURL := fmt.Sprintf("/history?page=1&pageSize=%d", pageSize)
|
||||||
|
if searchTerm != "" {
|
||||||
|
redirectURL += fmt.Sprintf("&search=%s", url.QueryEscape(searchTerm))
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, redirectURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := components.HistoryData{
|
||||||
|
History: history,
|
||||||
|
CurrentPage: page,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
SearchTerm: searchTerm,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Total: int(total),
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is an HTMX request, only render the history content component
|
||||||
|
if isHtmxRequest {
|
||||||
|
components.HistoryContent(c, data).Render(c, c.Writer)
|
||||||
|
} else {
|
||||||
|
components.History(c, data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDashboardData handles the GET /dashboard/data route
|
||||||
|
func (h *Handlers) HandleDashboardData(c *gin.Context) {
|
||||||
|
// Get recent job runs
|
||||||
|
var recentRuns []db.JobHistory
|
||||||
|
if err := h.DB.Preload("Job").Order("start_time desc").Limit(5).Find(&recentRuns).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve recent runs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"recent_runs": recentRuns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDashboardJobsData handles the GET /dashboard/jobs route
|
||||||
|
func (h *Handlers) HandleDashboardJobsData(c *gin.Context) {
|
||||||
|
// Get active jobs
|
||||||
|
var activeJobs []db.Job
|
||||||
|
if err := h.DB.Where("enabled = ?", true).Find(&activeJobs).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve active jobs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"active_jobs": activeJobs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDashboardHistoryData handles the GET /dashboard/history route
|
||||||
|
func (h *Handlers) HandleDashboardHistoryData(c *gin.Context) {
|
||||||
|
// Get job history stats
|
||||||
|
var successCount int64
|
||||||
|
var failureCount int64
|
||||||
|
var pendingCount int64
|
||||||
|
|
||||||
|
h.DB.Model(&db.JobHistory{}).Where("status = ?", "success").Count(&successCount)
|
||||||
|
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failure").Count(&failureCount)
|
||||||
|
h.DB.Model(&db.JobHistory{}).Where("status = ?", "pending").Count(&pendingCount)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success_count": successCount,
|
||||||
|
"failure_count": failureCount,
|
||||||
|
"pending_count": pendingCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes registers all the routes for the web interface
|
||||||
|
func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||||
|
// Public routes
|
||||||
|
router.GET("/", h.HandleHome)
|
||||||
|
router.GET("/login", h.HandleLoginPage)
|
||||||
|
router.POST("/login", h.HandleLogin)
|
||||||
|
router.GET("/forgot-password", h.HandleForgotPasswordPage)
|
||||||
|
router.POST("/forgot-password", h.HandleForgotPassword)
|
||||||
|
router.GET("/reset-password", h.HandleResetPasswordPage)
|
||||||
|
router.POST("/reset-password", h.HandleResetPassword)
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
authorized := router.Group("/")
|
||||||
|
authorized.Use(h.AuthMiddleware())
|
||||||
|
|
||||||
|
// Password change route - only accessed from profile page
|
||||||
|
authorized.POST("/change-password", h.HandleChangePassword)
|
||||||
|
|
||||||
|
{
|
||||||
|
authorized.GET("/dashboard", h.HandleDashboard)
|
||||||
|
authorized.GET("/configs", h.HandleConfigs)
|
||||||
|
authorized.GET("/configs/new", h.HandleNewConfig)
|
||||||
|
authorized.GET("/configs/:id", h.HandleEditConfig)
|
||||||
|
authorized.POST("/configs", h.HandleCreateConfig)
|
||||||
|
authorized.PUT("/configs/:id", h.HandleUpdateConfig)
|
||||||
|
authorized.POST("/configs/:id", h.HandleUpdateConfig) // Add POST route for form submission
|
||||||
|
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
|
||||||
|
authorized.GET("/jobs", h.HandleJobs)
|
||||||
|
authorized.GET("/jobs/new", h.HandleNewJob)
|
||||||
|
authorized.GET("/jobs/:id", h.HandleEditJob)
|
||||||
|
authorized.POST("/jobs", h.HandleCreateJob)
|
||||||
|
authorized.PUT("/jobs/:id", h.HandleUpdateJob)
|
||||||
|
authorized.POST("/jobs/:id", h.HandleUpdateJob) // Add POST route for form submission
|
||||||
|
authorized.DELETE("/jobs/:id", h.HandleDeleteJob)
|
||||||
|
authorized.POST("/jobs/:id/run", h.HandleRunJob)
|
||||||
|
authorized.GET("/history", h.HandleHistory)
|
||||||
|
authorized.GET("/job-runs/:id", h.HandleJobRunDetails)
|
||||||
|
authorized.GET("/profile", h.HandleProfile)
|
||||||
|
authorized.POST("/profile/theme", h.HandleUpdateTheme)
|
||||||
|
authorized.POST("/logout", h.HandleLogout)
|
||||||
|
|
||||||
|
// AJAX routes for dashboard
|
||||||
|
authorized.GET("/dashboard/data", h.HandleDashboardData)
|
||||||
|
authorized.GET("/dashboard/jobs", h.HandleDashboardJobsData)
|
||||||
|
authorized.GET("/dashboard/history", h.HandleDashboardHistoryData)
|
||||||
|
|
||||||
|
// Test connection routes
|
||||||
|
authorized.POST("/test-connection", h.HandleTestConnection)
|
||||||
|
authorized.POST("/test-sftp-connection", h.HandleTestSFTPConnection)
|
||||||
|
authorized.POST("/browse-directory", h.HandleBrowseDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-only routes
|
||||||
|
admin := router.Group("/admin")
|
||||||
|
admin.Use(h.AuthMiddleware(), h.AdminMiddleware())
|
||||||
|
{
|
||||||
|
admin.GET("/users", h.HandleUsers)
|
||||||
|
admin.GET("/users/new", h.HandleNewUser)
|
||||||
|
admin.POST("/users", h.HandleCreateUser)
|
||||||
|
admin.DELETE("/users/:id", h.HandleDeleteUser)
|
||||||
|
admin.GET("/register", h.HandleRegisterPage)
|
||||||
|
admin.POST("/register", h.HandleRegister)
|
||||||
|
|
||||||
|
// Admin tools routes
|
||||||
|
admin.GET("/tools", h.HandleAdminTools)
|
||||||
|
admin.POST("/backup-database", h.HandleBackupDatabase)
|
||||||
|
admin.POST("/restore-database", h.HandleRestoreDatabase)
|
||||||
|
admin.POST("/restore-database/:filename", h.HandleRestoreDatabaseByFilename)
|
||||||
|
admin.GET("/export-configs", h.HandleExportConfigs)
|
||||||
|
admin.GET("/export-jobs", h.HandleExportJobs)
|
||||||
|
admin.POST("/clear-job-history", h.HandleClearJobHistory)
|
||||||
|
admin.POST("/vacuum-database", h.HandleVacuumDatabase)
|
||||||
|
admin.GET("/download-backup/:filename", h.HandleDownloadBackup)
|
||||||
|
admin.DELETE("/delete-backup/:filename", h.HandleDeleteBackup)
|
||||||
|
admin.GET("/refresh-backups", h.HandleRefreshBackups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
api := router.Group("/api")
|
||||||
|
{
|
||||||
|
api.POST("/login", h.HandleAPILogin)
|
||||||
|
|
||||||
|
// Protected API routes
|
||||||
|
apiAuthorized := api.Group("/")
|
||||||
|
apiAuthorized.Use(h.APIAuthMiddleware())
|
||||||
|
{
|
||||||
|
// Config endpoints
|
||||||
|
apiAuthorized.GET("/configs", h.HandleAPIConfigs)
|
||||||
|
apiAuthorized.GET("/configs/:id", h.HandleAPIConfig)
|
||||||
|
apiAuthorized.POST("/configs", h.HandleAPICreateConfig)
|
||||||
|
apiAuthorized.PUT("/configs/:id", h.HandleAPIUpdateConfig)
|
||||||
|
apiAuthorized.DELETE("/configs/:id", h.HandleAPIDeleteConfig)
|
||||||
|
|
||||||
|
// Job endpoints
|
||||||
|
apiAuthorized.GET("/jobs", h.HandleAPIJobs)
|
||||||
|
apiAuthorized.GET("/jobs/:id", h.HandleAPIJob)
|
||||||
|
apiAuthorized.POST("/jobs", h.HandleAPICreateJob)
|
||||||
|
apiAuthorized.PUT("/jobs/:id", h.HandleAPIUpdateJob)
|
||||||
|
apiAuthorized.DELETE("/jobs/:id", h.HandleAPIDeleteJob)
|
||||||
|
apiAuthorized.POST("/jobs/:id/run", h.HandleAPIRunJob)
|
||||||
|
|
||||||
|
// History endpoints
|
||||||
|
apiAuthorized.GET("/history", h.HandleAPIHistory)
|
||||||
|
apiAuthorized.GET("/job-runs/:id", h.HandleAPIJobRun)
|
||||||
|
|
||||||
|
// Admin-only API routes
|
||||||
|
apiAdmin := apiAuthorized.Group("/admin")
|
||||||
|
apiAdmin.Use(h.APIAdminMiddleware())
|
||||||
|
{
|
||||||
|
// User management
|
||||||
|
apiAdmin.GET("/users", h.HandleAPIUsers)
|
||||||
|
apiAdmin.GET("/users/:id", h.HandleAPIUser)
|
||||||
|
apiAdmin.POST("/users", h.HandleAPICreateUser)
|
||||||
|
apiAdmin.PUT("/users/:id", h.HandleAPIUpdateUser)
|
||||||
|
apiAdmin.DELETE("/users/:id", h.HandleAPIDeleteUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleUsers handles the GET /admin/users route
|
||||||
|
func (h *Handlers) HandleUsers(c *gin.Context) {
|
||||||
|
var users []db.User
|
||||||
|
if err := h.DB.Find(&users).Error; err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to retrieve users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := components.UsersData{
|
||||||
|
Users: users,
|
||||||
|
}
|
||||||
|
components.Users(components.CreateTemplateContext(c), data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleNewUser handles the GET /admin/users/new route
|
||||||
|
func (h *Handlers) HandleNewUser(c *gin.Context) {
|
||||||
|
data := components.UserFormData{
|
||||||
|
IsNew: true,
|
||||||
|
ErrorMessage: "",
|
||||||
|
}
|
||||||
|
components.UserForm(components.CreateTemplateContext(c), data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCreateUser handles the POST /admin/users/new route
|
||||||
|
func (h *Handlers) HandleCreateUser(c *gin.Context) {
|
||||||
|
email := c.PostForm("email")
|
||||||
|
password := c.PostForm("password")
|
||||||
|
isAdmin := c.PostForm("is_admin") == "on"
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
var existingUser db.User
|
||||||
|
if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil {
|
||||||
|
c.String(http.StatusBadRequest, "Email already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the user
|
||||||
|
user := db.User{
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.DB.Create(&user).Error; err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusSeeOther, "/admin/users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteUser handles the POST /admin/users/delete route
|
||||||
|
func (h *Handlers) HandleDeleteUser(c *gin.Context) {
|
||||||
|
userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "Invalid user ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow deleting the current user
|
||||||
|
currentUserID := c.GetUint("userID")
|
||||||
|
if uint(userID) == currentUserID {
|
||||||
|
c.String(http.StatusBadRequest, "Cannot delete your own account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the user
|
||||||
|
if err := h.DB.Delete(&db.User{}, userID).Error; err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to delete user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusSeeOther, "/admin/users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRegisterPage handles the GET /register route
|
||||||
|
func (h *Handlers) HandleRegisterPage(c *gin.Context) {
|
||||||
|
// Check if any users exist
|
||||||
|
var count int64
|
||||||
|
h.DB.Model(&db.User{}).Count(&count)
|
||||||
|
|
||||||
|
// If users exist, don't allow registration
|
||||||
|
if count > 0 {
|
||||||
|
c.Redirect(http.StatusSeeOther, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
components.Register(c.Request.Context(), "").Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRegister handles the POST /register route
|
||||||
|
func (h *Handlers) HandleRegister(c *gin.Context) {
|
||||||
|
// Check if any users exist
|
||||||
|
var count int64
|
||||||
|
h.DB.Model(&db.User{}).Count(&count)
|
||||||
|
|
||||||
|
// If users exist, don't allow registration
|
||||||
|
if count > 0 {
|
||||||
|
c.Redirect(http.StatusSeeOther, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := c.PostForm("email")
|
||||||
|
password := c.PostForm("password")
|
||||||
|
|
||||||
|
// Hash the password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the admin user
|
||||||
|
user := db.User{
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
IsAdmin: true,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.DB.Create(&user).Error; err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to generate token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cookie
|
||||||
|
c.SetCookie("jwt", token, 60*60*24, "/", "", false, true)
|
||||||
|
|
||||||
|
c.Redirect(http.StatusSeeOther, "/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEditUser handles the edit user page request
|
||||||
|
func (h *Handlers) HandleEditUser(c *gin.Context) {
|
||||||
|
// Only admin users can access this page
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.Redirect(http.StatusFound, "/dashboard")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := c.Param("id")
|
||||||
|
var user db.User
|
||||||
|
if err := h.DB.First(&user, id).Error; err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := components.UserFormData{
|
||||||
|
IsNew: false,
|
||||||
|
ErrorMessage: "",
|
||||||
|
}
|
||||||
|
components.UserForm(c.Request.Context(), data).Render(c, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateUser handles the update user form submission
|
||||||
|
func (h *Handlers) HandleUpdateUser(c *gin.Context) {
|
||||||
|
// Only admin users can update users
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.String(http.StatusForbidden, "Only administrators can update users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := c.Param("id")
|
||||||
|
var user db.User
|
||||||
|
if err := h.DB.First(&user, id).Error; err != nil {
|
||||||
|
log.Printf("Error finding user: %v", err)
|
||||||
|
c.String(http.StatusNotFound, "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the old user values for comparison
|
||||||
|
oldUser := user
|
||||||
|
|
||||||
|
// Bind form data to user
|
||||||
|
if err := c.ShouldBind(&user); err != nil {
|
||||||
|
log.Printf("Error binding user form: %v", err)
|
||||||
|
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already exists for a different user
|
||||||
|
var existingUser db.User
|
||||||
|
if user.Email != oldUser.Email {
|
||||||
|
if err := h.DB.Where("email = ? AND id != ?", user.Email, user.ID).First(&existingUser).Error; err == nil {
|
||||||
|
c.String(http.StatusBadRequest, "Email already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get password from form
|
||||||
|
password := c.PostForm("password")
|
||||||
|
|
||||||
|
// Only update password if provided
|
||||||
|
if password != "" {
|
||||||
|
// Validate password complexity
|
||||||
|
if !h.validatePasswordComplexity(password) {
|
||||||
|
c.String(http.StatusBadRequest, "Password does not meet complexity requirements")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error hashing password: %v", err)
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user.PasswordHash = string(hashedPassword)
|
||||||
|
user.LastPasswordChange = time.Now()
|
||||||
|
} else {
|
||||||
|
// Preserve the old password if not updating
|
||||||
|
user.PasswordHash = oldUser.PasswordHash
|
||||||
|
user.LastPasswordChange = oldUser.LastPasswordChange
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve fields that shouldn't be updated
|
||||||
|
user.CreatedAt = oldUser.CreatedAt
|
||||||
|
user.FailedLoginAttempts = oldUser.FailedLoginAttempts
|
||||||
|
user.AccountLocked = oldUser.AccountLocked
|
||||||
|
user.LockoutUntil = oldUser.LockoutUntil
|
||||||
|
|
||||||
|
// Update admin status
|
||||||
|
user.IsAdmin = c.PostForm("is_admin") == "on"
|
||||||
|
|
||||||
|
if err := h.DB.Save(&user).Error; err != nil {
|
||||||
|
log.Printf("Error updating user: %v", err)
|
||||||
|
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update user: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUnlockUser handles the unlock user request
|
||||||
|
func (h *Handlers) HandleUnlockUser(c *gin.Context) {
|
||||||
|
// Only admin users can unlock users
|
||||||
|
isAdmin, exists := c.Get("isAdmin")
|
||||||
|
if !exists || isAdmin != true {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Only administrators can unlock users"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := c.Param("id")
|
||||||
|
var user db.User
|
||||||
|
if err := h.DB.First(&user, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock user
|
||||||
|
user.AccountLocked = false
|
||||||
|
user.FailedLoginAttempts = 0
|
||||||
|
user.LockoutUntil = nil
|
||||||
|
|
||||||
|
if err := h.DB.Save(&user).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to unlock user: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "User unlocked successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePasswordComplexity validates that a password meets complexity requirements
|
||||||
|
func (h *Handlers) validatePasswordComplexity(password string) bool {
|
||||||
|
// Password must be at least 8 characters long
|
||||||
|
if len(password) < 8 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for at least one uppercase letter, one lowercase letter, and one number
|
||||||
|
hasUpper := false
|
||||||
|
hasLower := false
|
||||||
|
hasNumber := false
|
||||||
|
|
||||||
|
for _, char := range password {
|
||||||
|
if char >= 'A' && char <= 'Z' {
|
||||||
|
hasUpper = true
|
||||||
|
} else if char >= 'a' && char <= 'z' {
|
||||||
|
hasLower = true
|
||||||
|
} else if char >= '0' && char <= '9' {
|
||||||
|
hasNumber = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasUpper && hasLower && hasNumber
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// AuthMiddleware is a middleware function that checks if the request has a valid JWT token
|
||||||
|
func (m *Middleware) AuthMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Get token from cookie
|
||||||
|
tokenString, err := c.Cookie("jwt_token")
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate token
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(m.JWTSecret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract claims
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user information in context
|
||||||
|
c.Set("userID", uint(claims["user_id"].(float64)))
|
||||||
|
c.Set("email", claims["email"].(string))
|
||||||
|
c.Set("username", claims["username"].(string))
|
||||||
|
c.Set("isAdmin", claims["is_admin"].(bool))
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
// "github.com/starfleetcptn/gomft/internal/api"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/config"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/scheduler"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/web"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Set Gin to release mode
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
log.Printf("Starting GoMFT server...")
|
||||||
|
|
||||||
|
// Initialize configuration
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Configuration loaded successfully")
|
||||||
|
|
||||||
|
// Ensure required directories exist
|
||||||
|
dirs := []string{
|
||||||
|
cfg.DataDir,
|
||||||
|
cfg.BackupDir,
|
||||||
|
// "templates",
|
||||||
|
"static",
|
||||||
|
"static/css",
|
||||||
|
"static/js",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
log.Fatalf("Failed to create directory %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("Required directories created")
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
|
||||||
|
database, err := db.Initialize(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
log.Printf("Database initialized successfully")
|
||||||
|
|
||||||
|
// Create default admin user if no users exist
|
||||||
|
var count int64
|
||||||
|
database.Model(&db.User{}).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
log.Printf("No users found, creating default admin user")
|
||||||
|
// Generate password hash
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to hash password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
adminUser := &db.User{
|
||||||
|
Email: "admin@example.com",
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
IsAdmin: true,
|
||||||
|
LastPasswordChange: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.CreateUser(adminUser); err != nil {
|
||||||
|
log.Fatalf("Failed to create admin user: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Default admin user created successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize scheduler
|
||||||
|
scheduler := scheduler.New(database)
|
||||||
|
defer scheduler.Stop()
|
||||||
|
log.Printf("Scheduler initialized successfully")
|
||||||
|
|
||||||
|
// Initialize Gin router with custom recovery middleware
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||||
|
return fmt.Sprintf("[GIN] %v | %3d | %13v | %15s | %-7s %s\n%s",
|
||||||
|
param.TimeStamp.Format("2006/01/02 - 15:04:05"),
|
||||||
|
param.StatusCode,
|
||||||
|
param.Latency,
|
||||||
|
param.ClientIP,
|
||||||
|
param.Method,
|
||||||
|
param.Path,
|
||||||
|
param.ErrorMessage,
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
|
// Serve static files
|
||||||
|
router.Static("/static", "./static")
|
||||||
|
log.Printf("Static file serving configured")
|
||||||
|
|
||||||
|
// Initialize web handlers
|
||||||
|
webHandler, err := web.NewHandler(database, scheduler, cfg.JWTSecret, dbPath, cfg.BackupDir, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize web handlers: %v", err)
|
||||||
|
}
|
||||||
|
webHandler.InitializeRoutes(router)
|
||||||
|
log.Printf("Web handlers initialized successfully")
|
||||||
|
|
||||||
|
// Initialize API routes
|
||||||
|
// Commenting out the API routes initialization to avoid route conflicts
|
||||||
|
// api.InitializeRoutes(router, database, scheduler, cfg.JWTSecret)
|
||||||
|
// log.Printf("API routes initialized successfully")
|
||||||
|
|
||||||
|
// Add middleware for security headers
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
c.Writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Writer.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
log.Printf("Starting server on %s", cfg.ServerAddress)
|
||||||
|
if err := router.Run(cfg.ServerAddress); err != nil {
|
||||||
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,237 @@
|
|||||||
|
/* Base styles */
|
||||||
|
:root {
|
||||||
|
--primary-color: #2563eb;
|
||||||
|
--secondary-color: #475569;
|
||||||
|
--success-color: #22c55e;
|
||||||
|
--danger-color: #ef4444;
|
||||||
|
--warning-color: #f59e0b;
|
||||||
|
--background-color: #f8fafc;
|
||||||
|
--text-color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.navbar {
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-item {
|
||||||
|
color: var(--secondary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-item:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover {
|
||||||
|
background-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-2 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-3 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background-color: white;
|
||||||
|
padding: 2rem 0;
|
||||||
|
margin-top: 2rem;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-cols-2,
|
||||||
|
.grid-cols-3 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 350 B |
|
After Width: | Height: | Size: 653 B |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,244 @@
|
|||||||
|
// Theme management for GoMFT application
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initializeTheme();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize theme based on user preference
|
||||||
|
function initializeTheme() {
|
||||||
|
const storedTheme = getCookie('theme');
|
||||||
|
|
||||||
|
if (storedTheme === 'dark') {
|
||||||
|
applyDarkTheme();
|
||||||
|
} else if (storedTheme === 'system') {
|
||||||
|
applySystemTheme();
|
||||||
|
} else {
|
||||||
|
// Default to light theme
|
||||||
|
applyLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for theme changes from system
|
||||||
|
if (window.matchMedia) {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
// Add change listener
|
||||||
|
try {
|
||||||
|
// Chrome & Firefox
|
||||||
|
mediaQuery.addEventListener('change', (e) => {
|
||||||
|
if (getCookie('theme') === 'system') {
|
||||||
|
e.matches ? applyDarkTheme(false) : applyLightTheme(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e1) {
|
||||||
|
try {
|
||||||
|
// Safari
|
||||||
|
mediaQuery.addListener((e) => {
|
||||||
|
if (getCookie('theme') === 'system') {
|
||||||
|
e.matches ? applyDarkTheme(false) : applyLightTheme(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e2) {
|
||||||
|
console.error('Could not add media query listener', e2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for theme changes via HTMX
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
if (event.detail.requestConfig && event.detail.requestConfig.path === '/profile/theme') {
|
||||||
|
// Refresh the theme after update
|
||||||
|
const updatedTheme = getCookie('theme');
|
||||||
|
applyTheme(updatedTheme);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle between light and dark theme
|
||||||
|
function toggleTheme() {
|
||||||
|
const currentTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||||
|
if (currentTheme === 'dark') {
|
||||||
|
applyLightTheme();
|
||||||
|
setCookie('theme', 'light', 365);
|
||||||
|
} else {
|
||||||
|
applyDarkTheme();
|
||||||
|
setCookie('theme', 'dark', 365);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a subtle animation effect
|
||||||
|
document.body.classList.add('theme-transition');
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.classList.remove('theme-transition');
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme based on theme name
|
||||||
|
function applyTheme(theme) {
|
||||||
|
if (theme === 'dark') {
|
||||||
|
applyDarkTheme();
|
||||||
|
} else if (theme === 'system') {
|
||||||
|
applySystemTheme();
|
||||||
|
} else {
|
||||||
|
applyLightTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply dark theme
|
||||||
|
function applyDarkTheme(setClass = true) {
|
||||||
|
if (setClass) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
document.documentElement.classList.remove('light');
|
||||||
|
}
|
||||||
|
document.documentElement.style.colorScheme = 'dark';
|
||||||
|
|
||||||
|
// Add transition for smooth theme switching
|
||||||
|
document.documentElement.style.transition = 'background-color 0.3s ease, color 0.3s ease';
|
||||||
|
|
||||||
|
updateThemeColors('dark');
|
||||||
|
|
||||||
|
// Update theme toggle icon
|
||||||
|
updateThemeToggleIcon('dark');
|
||||||
|
|
||||||
|
// Store user preference in localStorage as a backup
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply light theme
|
||||||
|
function applyLightTheme(setClass = true) {
|
||||||
|
if (setClass) {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.documentElement.classList.add('light');
|
||||||
|
}
|
||||||
|
document.documentElement.style.colorScheme = 'light';
|
||||||
|
|
||||||
|
// Add transition for smooth theme switching
|
||||||
|
document.documentElement.style.transition = 'background-color 0.3s ease, color 0.3s ease';
|
||||||
|
|
||||||
|
updateThemeColors('light');
|
||||||
|
|
||||||
|
// Update theme toggle icon
|
||||||
|
updateThemeToggleIcon('light');
|
||||||
|
|
||||||
|
// Store user preference in localStorage as a backup
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply system theme based on user's OS preference
|
||||||
|
function applySystemTheme() {
|
||||||
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
applyDarkTheme(true);
|
||||||
|
} else {
|
||||||
|
applyLightTheme(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user preference in localStorage as a backup
|
||||||
|
localStorage.setItem('theme', 'system');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update theme colors
|
||||||
|
function updateThemeColors(theme) {
|
||||||
|
// This function can be expanded to update specific UI elements
|
||||||
|
// that might need special handling beyond CSS classes
|
||||||
|
|
||||||
|
// For example, updating charts, custom components, etc.
|
||||||
|
if (theme === 'dark') {
|
||||||
|
// Apply dark theme specific changes
|
||||||
|
// Ensure better contrast for text elements
|
||||||
|
const textElements = document.querySelectorAll('.text-gray-700, .text-gray-800, .text-gray-900, .text-secondary-700, .text-secondary-800, .text-secondary-900');
|
||||||
|
textElements.forEach(el => {
|
||||||
|
if (!el.classList.contains('dark:text-white') &&
|
||||||
|
!el.classList.contains('dark:text-gray-100') &&
|
||||||
|
!el.classList.contains('dark:text-gray-200') &&
|
||||||
|
!el.classList.contains('dark:text-secondary-100') &&
|
||||||
|
!el.classList.contains('dark:text-secondary-200')) {
|
||||||
|
el.classList.add('dark:text-secondary-200');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure better contrast for background elements
|
||||||
|
const bgElements = document.querySelectorAll('.bg-gray-800, .bg-gray-900, .bg-secondary-800, .bg-secondary-900');
|
||||||
|
bgElements.forEach(el => {
|
||||||
|
if (!el.classList.contains('dark:bg-gray-700') &&
|
||||||
|
!el.classList.contains('dark:bg-secondary-700')) {
|
||||||
|
el.classList.add('dark:bg-secondary-700');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply custom animations for dark mode
|
||||||
|
document.body.classList.add('theme-dark-animation');
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.classList.remove('theme-dark-animation');
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
// Apply light theme specific changes
|
||||||
|
|
||||||
|
// Apply custom animations for light mode
|
||||||
|
document.body.classList.add('theme-light-animation');
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.classList.remove('theme-light-animation');
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update theme toggle icon
|
||||||
|
function updateThemeToggleIcon(theme) {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (!themeToggle) return;
|
||||||
|
|
||||||
|
const sunIcon = themeToggle.querySelector('.fa-sun');
|
||||||
|
const moonIcon = themeToggle.querySelector('.fa-moon');
|
||||||
|
|
||||||
|
if (theme === 'dark') {
|
||||||
|
if (sunIcon) sunIcon.classList.remove('hidden');
|
||||||
|
if (moonIcon) moonIcon.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
if (sunIcon) sunIcon.classList.add('hidden');
|
||||||
|
if (moonIcon) moonIcon.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get cookie value
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||||
|
|
||||||
|
// Fallback to localStorage if cookie is not available
|
||||||
|
return localStorage.getItem(name) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to set cookie
|
||||||
|
function setCookie(name, value, days) {
|
||||||
|
let expires = '';
|
||||||
|
if (days) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||||
|
expires = '; expires=' + date.toUTCString();
|
||||||
|
}
|
||||||
|
document.cookie = name + '=' + (value || '') + expires + '; path=/; SameSite=Strict';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CSS for theme transition animations
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.theme-transition {
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark-animation {
|
||||||
|
animation: darkModeIn 0.5s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light-animation {
|
||||||
|
animation: lightModeIn 0.5s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes darkModeIn {
|
||||||
|
0% { opacity: 0.8; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lightModeIn {
|
||||||
|
0% { opacity: 0.8; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||