initial commit

This commit is contained in:
StarFleetCPTN
2025-03-07 23:21:12 -08:00
commit 953ad364ab
68 changed files with 11440 additions and 0 deletions
+66
View File
@@ -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
+98
View File
@@ -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 }}
+52
View File
@@ -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/
+60
View File
@@ -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"]
+21
View File
@@ -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.
+276
View File
@@ -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 Overview](screenshots/dashboard.gomft.png)
*Dashboard showing active transfers and system status*
![Dashboard Overview Dark](screenshots/dashboard.dark.gomft.png)
*Dashboard dark mode showing active transfers and system status*
### Configuration Interface
![Transfer Configuration](screenshots/new.configuration.gomft.png)
*Setting up transfer configurations with multiple storage options*
### Job Management
![Job Scheduling](screenshots/new.job.gomft.png)
*Scheduling transfers with flexible cron expressions*
### User Mangement
![User Mangement](screenshots/user.management.gomft.png)
*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
+578
View File
@@ -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>
}
}
File diff suppressed because it is too large Load Diff
+109
View File
@@ -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>
}
}
+192
View File
@@ -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>
}
}
+243
View File
@@ -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>
}
}
+353
View File
@@ -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
}
+135
View File
@@ -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>
}
}
+291
View File
@@ -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>
}
}
+251
View File
@@ -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)
}
+131
View File
@@ -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>
}
}
+398
View File
@@ -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 &copy; { 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"
}
+142
View File
@@ -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>
}
}
+235
View File
@@ -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>
}
}
+105
View File
@@ -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>
}
}
+286
View File
@@ -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>
}
}
+29
View File
@@ -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
+18
View File
@@ -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"
}
}
+43
View File
@@ -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
)
+100
View File
@@ -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=
Executable
BIN
View File
Binary file not shown.
+735
View File
@@ -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)
}
}
+66
View File
@@ -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")
}
+204
View File
@@ -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
}
+87
View File
@@ -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
}
+567
View File
@@ -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
}
+272
View File
@@ -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>&copy; {{.Year}} {{.AppName}}. All rights reserved.</p>
</div>
</div>
</body>
</html>
`)
if err != nil {
return "", err
}
var result bytes.Buffer
if err := tmpl.Execute(&result, data); err != nil {
return "", err
}
return result.String(), nil
}
// 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()
}
}
+445
View File
@@ -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
}
+34
View File
@@ -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)
}
+13
View File
@@ -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)
}
+295
View File
@@ -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"})
}
+554
View File
@@ -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
}
+27
View File
@@ -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)
}
+221
View File
@@ -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"})
}
+106
View File
@@ -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,
})
}
+33
View File
@@ -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,
}
}
+301
View File
@@ -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,
})
}
+78
View File
@@ -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")
}
+267
View File
@@ -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)
}
}
}
}
+316
View File
@@ -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
}
+44
View File
@@ -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()
}
}
+134
View File
@@ -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)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+237
View File
@@ -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;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+244
View File
@@ -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);
+1
View File
@@ -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"}