commit 953ad364abfcb64598b3ecbb927f6fd388b046eb Author: StarFleetCPTN Date: Fri Mar 7 23:21:12 2025 -0800 initial commit diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..66433ab --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml new file mode 100644 index 0000000..92c07e6 --- /dev/null +++ b/.github/workflows/go-release.yml @@ -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 }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f03090d --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..93bb2a9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c8b71df --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec284b1 --- /dev/null +++ b/README.md @@ -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 \ No newline at end of file diff --git a/components/admin_tools.templ b/components/admin_tools.templ new file mode 100644 index 0000000..58623c5 --- /dev/null +++ b/components/admin_tools.templ @@ -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) { + +} + +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) { + +} + +templ AdminTools(ctx context.Context, data AdminToolsData) { + @LayoutWithContext("Admin Tools", ctx) { +
+
+
+

+ + Admin Tools +

+
+ + +
+
+ + if data.MaintenanceMessage != "" { +
+
+
+ +
+
+

+ { data.MaintenanceMessage } +

+
+
+
+ } + + +
+

+ + System Overview +

+
+
+
+
+
+ +
+
+
+
+ Database Size +
+
+
+ { data.DatabaseSize } +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ Job History Records +
+
+
+ { fmt.Sprint(data.JobHistoryCount) } +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ System Uptime +
+
+
+ { data.SystemUptime } +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ Active Jobs +
+
+
+ { fmt.Sprint(data.ActiveJobs) } +
+
+
+
+
+
+
+
+
+ + +
+ +
+
+

+ + Backup & Restore +

+
+ +
+
+ if data.LastBackupTime != nil { + Last backup: { data.LastBackupTime.Format("Jan 02, 2006 15:04:05") } + } else { + Last backup: Never + } +
+ + + + + + + +
+ +
+ +
+ +
+ +
+

Restore Database

+
+
+
+ + +
+ +
+

+ + Warning: This will replace your current database. Make sure to backup first! +

+
+
+
+
+ + +
+
+

+ + Maintenance Tools +

+
+
+ + @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", "") +
+ +
+ +
+
+ +

+ Runs VACUUM to optimize the database and reclaim unused space. +

+
+
+ +
+
+
+ + +
+ @BackupsList(data) +
+ + +
+
+
+

+ + System Information +

+
+
+
+
+
Database Path
+
+ { data.DatabasePath } +
+
+ +
+
Backup Directory
+
+ { data.BackupPath } +
+
+ +
+
Total Users
+
+ { fmt.Sprint(data.TotalUsers) } +
+
+ +
+
Total Configurations
+
+ { fmt.Sprint(data.TotalConfigs) } +
+
+ +
+
Total Jobs
+
+ { fmt.Sprint(data.TotalJobs) } +
+
+
+
+
+
+
+
+ } +} + +// BackupsList is a separate component for the backups list that can be refreshed via HTMX +templ BackupsList(data AdminToolsData) { + if len(data.BackupFiles) > 0 { +
+
+

+ + Available Backups +

+ +
+
+
+
+ + + + + + + + + + + for _, backup := range data.BackupFiles { + + + + + + + } + +
NameSizeDateAction
{ backup.Name }{ backup.Size }{ backup.ModTime.Format("Jan 02, 2006 15:04:05") } + + + + + + @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, + ) + + + + @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, + ) + +
+
+
+
+
+ } else { +
+
+

+ + Available Backups +

+ +
+
+
+
+ +
+

No Backups Available

+

+ Create a backup using the "Backup Database" button. +

+
+
+
+ } +} diff --git a/components/config_form.templ b/components/config_form.templ new file mode 100644 index 0000000..35dea43 --- /dev/null +++ b/components/config_form.templ @@ -0,0 +1,1367 @@ +package components + +import ( + "fmt" + "github.com/starfleetcptn/gomft/internal/db" + "context" +) + +type ConfigFormData struct { + Config *db.TransferConfig + IsNew bool +} + +func getConfigFormTitle(isNew bool) string { + if isNew { + return "New Configuration" + } + return "Edit Configuration" +} + +func getInitialData(config *db.TransferConfig) string { + name := "" + sourceType := "local" + sourcePath := "" + sourceHost := "" + sourcePort := 22 + sourceUser := "" + sourcePassword := "" + sourceKeyFile := "" + // S3 fields + sourceBucket := "" + sourceRegion := "" + sourceAccessKey := "" + sourceSecretKey := "" + sourceEndpoint := "" + // SMB fields + sourceShare := "" + sourceDomain := "" + // FTP fields + sourcePassiveMode := true + filePattern := "*" + outputPattern := "" + destinationType := "local" + destinationPath := "" + destHost := "" + destPort := 22 + destUser := "" + destPassword := "" + destKeyFile := "" + // S3 fields + destBucket := "" + destRegion := "" + destAccessKey := "" + destSecretKey := "" + destEndpoint := "" + // SMB fields + destShare := "" + destDomain := "" + // FTP fields + destPassiveMode := true + archivePath := "" + archiveEnabled := false + rcloneFlags := "" + + if config != nil { + name = config.Name + sourceType = config.SourceType + sourcePath = config.SourcePath + sourceHost = config.SourceHost + sourcePort = config.SourcePort + sourceUser = config.SourceUser + sourcePassword = config.SourcePassword + sourceKeyFile = config.SourceKeyFile + // S3 fields + sourceBucket = config.SourceBucket + sourceRegion = config.SourceRegion + sourceAccessKey = config.SourceAccessKey + sourceSecretKey = config.SourceSecretKey + sourceEndpoint = config.SourceEndpoint + // SMB fields + sourceShare = config.SourceShare + sourceDomain = config.SourceDomain + // FTP fields + sourcePassiveMode = config.SourcePassiveMode + filePattern = config.FilePattern + outputPattern = config.OutputPattern + destinationType = config.DestinationType + destinationPath = config.DestinationPath + destHost = config.DestHost + destPort = config.DestPort + destUser = config.DestUser + destPassword = config.DestPassword + destKeyFile = config.DestKeyFile + // S3 fields + destBucket = config.DestBucket + destRegion = config.DestRegion + destAccessKey = config.DestAccessKey + destSecretKey = config.DestSecretKey + destEndpoint = config.DestEndpoint + // SMB fields + destShare = config.DestShare + destDomain = config.DestDomain + // FTP fields + destPassiveMode = config.DestPassiveMode + archivePath = config.ArchivePath + archiveEnabled = config.ArchiveEnabled + rcloneFlags = config.RcloneFlags + } + + return fmt.Sprintf(`{ + name: '%s', + sourceType: '%s', + sourcePath: '%s', + sourceHost: '%s', + sourcePort: %d, + sourceUser: '%s', + sourcePassword: '%s', + sourceKeyFile: '%s', + sourceBucket: '%s', + sourceRegion: '%s', + sourceAccessKey: '%s', + sourceSecretKey: '%s', + sourceEndpoint: '%s', + sourceShare: '%s', + sourceDomain: '%s', + sourcePassiveMode: %v, + filePattern: '%s', + outputPattern: '%s', + destinationType: '%s', + destinationPath: '%s', + destHost: '%s', + destPort: %d, + destUser: '%s', + destPassword: '%s', + destKeyFile: '%s', + // S3 fields + destBucket: '%s', + destRegion: '%s', + destAccessKey: '%s', + destSecretKey: '%s', + destEndpoint: '%s', + // SMB fields + destShare: '%s', + destDomain: '%s', + // FTP fields + destPassiveMode: %v, + // Existing fields + archivePath: '%s', + archiveEnabled: %v, + rcloneFlags: '%s', + loading: false, + validate() { + if (this.sourceType === 'sftp') { + if (!this.sourceHost || !this.sourceUser || (!this.sourcePassword && !this.sourceKeyFile)) { + return false; + } + } else if (this.sourceType === 's3') { + if (!this.sourceBucket || !this.sourceRegion || !this.sourceAccessKey || !this.sourceSecretKey) { + return false; + } + } else if (this.sourceType === 'minio') { + if (!this.sourceBucket || !this.sourceEndpoint || !this.sourceAccessKey || !this.sourceSecretKey) { + return false; + } + } else if (this.sourceType === 'b2') { + if (!this.sourceBucket || !this.sourceAccessKey || !this.sourceSecretKey) { + return false; + } + } else if (this.sourceType === 'smb') { + if (!this.sourceHost || !this.sourceShare || !this.sourceUser || !this.sourcePassword) { + return false; + } + } else if (this.sourceType === 'ftp') { + if (!this.sourceHost || !this.sourceUser || !this.sourcePassword) { + return false; + } + } + + if (this.destinationType === 'sftp') { + if (!this.destHost || !this.destUser || (!this.destPassword && !this.destKeyFile)) { + return false; + } + } else if (this.destinationType === 's3') { + if (!this.destBucket || !this.destRegion || !this.destAccessKey || !this.destSecretKey) { + return false; + } + } else if (this.destinationType === 'minio') { + if (!this.destBucket || !this.destEndpoint || !this.destAccessKey || !this.destSecretKey) { + return false; + } + } else if (this.destinationType === 'b2') { + if (!this.destBucket || !this.destAccessKey || !this.destSecretKey) { + return false; + } + } else if (this.destinationType === 'smb') { + if (!this.destHost || !this.destShare || !this.destUser || !this.destPassword) { + return false; + } + } else if (this.destinationType === 'ftp') { + if (!this.destHost || !this.destUser || !this.destPassword) { + return false; + } + } + return this.name && this.sourcePath && this.filePattern && this.destinationPath && (!this.archiveEnabled || this.archivePath); + } + }`, name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, + sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint, + sourceShare, sourceDomain, sourcePassiveMode, + filePattern, outputPattern, destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile, + destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, + destShare, destDomain, destPassiveMode, + archivePath, archiveEnabled, rcloneFlags) +} + +templ ConfigForm(ctx context.Context, data ConfigFormData) { + @LayoutWithContext(getConfigFormTitle(data.IsNew), ctx) { +
+
+
+
+
+
+ +
+

+ if data.IsNew { + Create New Configuration + } else { + Edit Configuration: { data.Config.Name } + } +

+

Set up your file transfer configuration

+
+ +
+
+
+
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ + + + + + + + + + + + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+

Pattern to match files for transfer (e.g., *.txt, *.csv)

+
+ +
+ +
+
+ +
+ +
+

+ Pattern for output filenames. Available variables: +
+ {`${filename}`} - Original filename without extension (e.g., "report") +
+ {`${ext}`} - Original file extension (e.g., ".csv") +
+ {`${date:format}`} - Current date using Go's time format: +
+ 2006-01-02 → YYYY-MM-DD +
+ 20060102 → YYYYMMDD +
+ 2006-01-02_15:04:05 → YYYY-MM-DD_HH:MM:SS +
+ Example: {`${filename}_[date:2006-01-02]_${ext}`} → "report_2025-03-01.csv" +

+
+ +
+ +
+
+ +
+ +
+
+ + + + + + + + + + + + + +
+ +
+
+ +
+ +
+
+ +
+
+ + + +
+
+ +
+
+ +
+ +
+

Location to move files after successful transfer

+
+
+ +
+ +
+
+ +
+ +
+

Optional: Additional rclone flags for fine-tuning the transfer.

+
+
+
+
+ +
+
+ + + Cancel + + +
+
+
+
+ +
+

+ + Configure your file transfer settings carefully for optimal performance +

+
+
+
+
+ } +} \ No newline at end of file diff --git a/components/configs.templ b/components/configs.templ new file mode 100644 index 0000000..ed4111f --- /dev/null +++ b/components/configs.templ @@ -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) { +
+
+
+

+ + Transfer Configurations +

+ + + New Configuration + +
+ +
+ if len(data.Configs) == 0 { +
+
+ +
+

No configurations

+

Get started by creating a new transfer configuration.

+ +
+ } else { +
+
    + for _, config := range data.Configs { +
  • +
    +
    +
    +
    +

    + { config.Name } +

    +
    +
    + + + Edit + + +
    +
    +
    +
    +

    + + Source: { config.SourceType }: { config.SourcePath } +

    +

    + + Destination: { config.DestinationType }: { config.DestinationPath } +

    +
    +
    + +

    + Updated: { config.UpdatedAt.Format("2006-01-02 15:04:05") } +

    +
    +
    +
    +
    +
  • + } +
+
+ } +
+ + +
+

+ + Configurations define how files are transferred between systems +

+
+
+
+ } +} \ No newline at end of file diff --git a/components/dashboard.templ b/components/dashboard.templ new file mode 100644 index 0000000..3323111 --- /dev/null +++ b/components/dashboard.templ @@ -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) { +
+
+
+

+ + Dashboard +

+
+ + +
+
+ + +
+
+
+
+ +
+
+

Active Transfers

+

{ strconv.Itoa(data.ActiveTransfers) }

+
+
+
+ +
+
+
+ +
+
+

Completed Today

+

{ strconv.Itoa(data.CompletedToday) }

+
+
+
+ +
+
+
+ +
+
+

Failed Transfers

+

{ strconv.Itoa(data.FailedTransfers) }

+
+
+
+
+ +
+
+
+

+ + Recent Jobs +

+
+
+ if len(data.RecentJobs) == 0 { +
+
+ +
+

No recent jobs found

+ + Create your first job + + +
+ } else { +
+
    + for _, job := range data.RecentJobs { +
  • +
    +
    + if job.Status == "completed" { + + + + } else if job.Status == "failed" { + + + + } else { + + + + } +
    +
    +

    + { job.Job.Config.Name } +

    +
    + +

    + Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") } +

    +
    +
    + +
    +
  • + } +
+
+ + } +
+
+ +
+
+

+ + Quick Actions +

+
+
+ + + Create New Config + + + + Create New Job + + + + View Transfer History + + + +
+

System Status

+
+
+ Server + Online +
+
+ Scheduler + Running +
+
+ Database + Connected +
+
+
+
+
+
+
+
+ } +} \ No newline at end of file diff --git a/components/forgot_password.templ b/components/forgot_password.templ new file mode 100644 index 0000000..1de4528 --- /dev/null +++ b/components/forgot_password.templ @@ -0,0 +1,243 @@ +package components + +import ( + "context" +) + +templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) { + @LayoutWithContext("Forgot Password", ctx) { +
+
+
+
+
+
+ +
+

Password Reset

+

Enter your email to receive a reset link

+
+ + if errorMessage != "" { + + } + + if successMessage != "" { + + } + +
+
+ +
+
+ +
+ +
+

+ We'll send a password reset link to this email +

+
+ +
+ +
+
+
+ + +
+ + +
+
+ + Secure, encrypted connection +
+
+
+
+ } +} + +// 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) { +
+
+
+
+
+
+ +
+

Reset Password

+

Create a new password for your account

+
+ + if errorMessage != "" { + + } + +
+ + +
+ +
+
+ +
+ +
+

+ Minimum 8 characters +

+
+ +
+ +
+
+ +
+ +
+

+ + Passwords must match + + + + Passwords do not match + +

+
+ +
+ +
+
+
+ + +
+ + +
+
+ + Secure, encrypted connection +
+
+
+
+ } +} \ No newline at end of file diff --git a/components/history.templ b/components/history.templ new file mode 100644 index 0000000..ddf63e7 --- /dev/null +++ b/components/history.templ @@ -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 { +
+
+ +
+

No transfer history

+ if data.SearchTerm != "" { +

No results found for "{ data.SearchTerm }". Try a different search term or .

+ } else if data.CurrentPage > 1 { +

No more results on this page. .

+ } else { +

Transfer history will appear here once jobs have run.

+ } +
+ } else { +
+ +
+ + + if data.TotalPages > 1 { +
+
+ if data.CurrentPage > 1 { + + } else { + + Previous + + } + + if data.CurrentPage < data.TotalPages { + + } else { + + Next + + } +
+ +
+ } + } +} + +templ History(ctx context.Context, data HistoryData) { + @LayoutWithContext("Transfer History", ctx) { +
+
+
+

+ + Transfer History +

+
+ Total: { fmt.Sprint(data.Total) } transfers +
+
+ + +
+
+
+
+
+ +
+ + + +
+ + if data.SearchTerm != "" { + + } +
+
+ +
+ + Show + + entries + + + + +
+
+ +
+ @HistoryContent(ctx, data) +
+
+
+ } +} + +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 { + + { fmt.Sprint(i) } + + } else { + + } + } +} + +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 +} \ No newline at end of file diff --git a/components/home.templ b/components/home.templ new file mode 100644 index 0000000..2ac8ac7 --- /dev/null +++ b/components/home.templ @@ -0,0 +1,135 @@ +package components + +import ( + "context" +) + +templ Home(ctx context.Context) { + @LayoutWithContext("Home", ctx) { +
+
+

+ + Welcome to GoMFT +

+

A modern managed file transfer solution

+ + if isLoggedIn(ctx) { +
+ +
+
+
+
+ +
+

Transfer Configs

+

Set up and manage file transfer configurations with ease.

+ +
+
+ +
+
+
+ +
+

Schedule Jobs

+

Create and monitor automated file transfer jobs.

+ +
+
+ +
+
+
+ +
+

Track History

+

View detailed history of all file transfer operations.

+ +
+
+
+ + + +
+ } else { +
+
+ +
+

Get Started

+

Log in to access the file transfer management system.

+ + + Log In + + + +
+

Key Features

+
    +
  • + + Secure file transfers with encryption +
  • +
  • + + Automated scheduling and monitoring +
  • +
  • + + Comprehensive transfer history tracking +
  • +
  • + + User-friendly interface with dark mode support +
  • +
+
+
+ } +
+
+ } +} \ No newline at end of file diff --git a/components/job_form.templ b/components/job_form.templ new file mode 100644 index 0000000..d2ff542 --- /dev/null +++ b/components/job_form.templ @@ -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) { +
+
+
+
+
+
+ +
+

+ { getJobTitle(data.IsNew) } +

+

Configure your scheduled transfer job

+
+ + if data.IsNew { +
+
+
+ +
+
+ +
+ +
+

+ + Descriptive name for this job (optional). If not provided, the config name will be used. +

+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+

+ + Use standard cron expression format. Example: */15 * * * * (every 15 minutes) +

+
+ +
+
+ + + +
+

+ + Disabled jobs will not run automatically. +

+
+
+ +
+ + + Cancel + + +
+
+ } else { +
+
+
+ +
+
+ +
+ +
+

+ + Descriptive name for this job (optional). If not provided, the config name will be used. +

+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+

+ + Use standard cron expression format. Example: */15 * * * * (every 15 minutes) +

+
+ +
+
+ + + +
+

+ + Disabled jobs will not run automatically. +

+
+
+ +
+ + + Cancel + + +
+
+ } +
+ +
+

+ + Jobs will run according to their schedule and execute the selected transfer configuration +

+
+
+ + +
+
+ + Need help with cron expressions? Try crontab.guru +
+
+
+
+ } +} \ No newline at end of file diff --git a/components/job_run_details.templ b/components/job_run_details.templ new file mode 100644 index 0000000..de67a77 --- /dev/null +++ b/components/job_run_details.templ @@ -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) { +
+
+ + +
+

+ + Job Run Details +

+
+ + +
+
+
+

+ if data.Job.Name != "" { + { data.Job.Name } + } else { + { data.Config.Name } + } +

+ if data.JobHistory.Status == "completed" { + + Completed + + } else if data.JobHistory.Status == "failed" { + + Failed + + } else { + + { data.JobHistory.Status } + + } +
+ if data.Job.Name != "" && data.Job.Name != data.Config.Name { +

+ Config: { data.Config.Name } +

+ } +
+ +
+
+
+
+ Start Time +
+
+ { data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") } +
+
+ +
+
+ End Time +
+
+ if data.JobHistory.EndTime != nil { + { data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") } + } else { + Still running... + } +
+
+ +
+
+ Duration +
+
+ if data.JobHistory.EndTime != nil { + { formatDuration(data.JobHistory.EndTime.Sub(data.JobHistory.StartTime)) } + } else { + { formatDuration(time.Since(data.JobHistory.StartTime)) } (ongoing) + } +
+
+ +
+
+ Data Transferred +
+
+ { formatBytes(data.JobHistory.BytesTransferred) } +
+
+ +
+
+ Files Transferred +
+
+ { fmt.Sprint(data.JobHistory.FilesTransferred) } +
+
+ +
+
+ Job Schedule +
+
+ { data.Job.Schedule } +
+
+
+
+
+ + +
+
+

+ + Transfer Configuration +

+
+ +
+
+
+
Source Type
+
+ + { data.Config.SourceType } + +
+
+ +
+
Destination Type
+
+ + { data.Config.DestinationType } + +
+
+ +
+
Source Path
+
+ if data.Config.SourceType == "sftp" { + { data.Config.SourceUser } `@` { data.Config.SourceHost }:{ data.Config.SourcePath } + } else { + { data.Config.SourcePath } + } +
+
+ +
+
Destination Path
+
+ if data.Config.DestinationType == "sftp" { + data.Config.DestUser@data.Config.DestHost:data.Config.DestinationPath + } else { + { data.Config.DestinationPath } + } +
+
+ +
+
File Pattern
+
+ { data.Config.FilePattern } +
+
+ + if data.Config.ArchiveEnabled { +
+
Archive Path
+
+ { data.Config.ArchivePath } +
+
+ } +
+
+
+ + + if data.JobHistory.ErrorMessage != "" { +
+
+

+ + Error Details +

+
+ +
+
{ data.JobHistory.ErrorMessage }
+
+
+ } + + + +
+
+ } +} + +// 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) +} diff --git a/components/jobs.templ b/components/jobs.templ new file mode 100644 index 0000000..aa9901f --- /dev/null +++ b/components/jobs.templ @@ -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) { +
+
+
+

+ + Transfer Jobs +

+ + + New Job + +
+
+ if len(data.Jobs) == 0 { +
+
+ +
+

No jobs

+

Get started by creating a new transfer job.

+ +
+ } else { +
+
    + for _, job := range data.Jobs { +
  • +
    +
    +
    +
    +

    + if job.Name != "" { + { job.Name } + } else { + { job.Config.Name } + } +

    + if job.Enabled { + + + Active + + } else { + + + Inactive + + } +
    +
    + + + Edit + + +
    +
    +
    +
    +

    + + Config: { job.Config.Name } +

    +

    + + Schedule: { job.Schedule } +

    + if job.LastRun != nil { +

    + + Last Run: { job.LastRun.Format("2006-01-02 15:04:05") } +

    + } +
    + if job.NextRun != nil { +
    + +

    + Next Run: { job.NextRun.Format("2006-01-02 15:04:05") } +

    +
    + } +
    +
    +
    +
  • + } +
+
+ } +
+ + +
+

+ + Transfer jobs run according to their schedule and transfer files between configured sources and destinations +

+
+
+
+ } +} \ No newline at end of file diff --git a/components/layout.templ b/components/layout.templ new file mode 100644 index 0000000..898b1e8 --- /dev/null +++ b/components/layout.templ @@ -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) { + + + + + + + + + + { title } - GoMFT + + + + + + + + + + + + if isLoggedIn(ctx) { + + } +
+ { children... } +
+ + + +} + +// 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" +} \ No newline at end of file diff --git a/components/login.templ b/components/login.templ new file mode 100644 index 0000000..6e617f2 --- /dev/null +++ b/components/login.templ @@ -0,0 +1,142 @@ +package components + +import ( + "context" + "strings" +) + +templ Login(ctx context.Context, errorMessage string) { + @LayoutWithContext("Login", ctx) { +
+
+
+
+
+
+ +
+

Sign In

+

Access your GoMFT account

+
+ + if errorMessage != "" { + if strings.HasPrefix(errorMessage, "Password reset") || strings.Contains(errorMessage, "success") { + + } else { + + } + } + +
+
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + +
+ +
+ +
+
+
+ +
+

+ + Contact an administrator to create an account +

+
+
+ + +
+
+ + Secure, encrypted connection +
+
+
+
+ } +} \ No newline at end of file diff --git a/components/profile.templ b/components/profile.templ new file mode 100644 index 0000000..d1457c8 --- /dev/null +++ b/components/profile.templ @@ -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) { +
+
+

+ + Profile +

+
+ +
+ +
+
+

+ + Profile Information +

+

Personal details and application settings.

+
+
+
+
+
Email
+
{ user.Email }
+
+
+
Role
+
+ if user.IsAdmin { + + Administrator + + } else { + + Regular User + + } +
+
+
+
Theme
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + +
+
+

+ + Change Password +

+

Update your password to keep your account secure.

+
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+
+ + Password must be at least 8 characters with letters, numbers, and special characters +
+
+
+
+ + +
+
+ + All profile changes are securely logged for your protection +
+
+
+ } +} \ No newline at end of file diff --git a/components/register.templ b/components/register.templ new file mode 100644 index 0000000..766659f --- /dev/null +++ b/components/register.templ @@ -0,0 +1,105 @@ +package components + +import ( + "context" +) + +templ Register(ctx context.Context, errorMessage string) { + @LayoutWithContext("Register", ctx) { +
+
+
+

+ Create a new user account +

+

+ Complete the form below to create a new user +

+
+ + if errorMessage != "" { +
+ +
+ } + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+

+ Already have an account? + Sign in +

+
+
+
+ } +} \ No newline at end of file diff --git a/components/users.templ b/components/users.templ new file mode 100644 index 0000000..c95fe21 --- /dev/null +++ b/components/users.templ @@ -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) { +
+
+
+

+ + User Management +

+ + + Add User + +
+ +
+ if len(data.Users) == 0 { +
+
+ +
+

No users found

+

Get started by adding a new user.

+ +
+ } else { +
+ + + + + + + + + + + if len(data.Users) == 0 { + + + + } else { + for _, user := range data.Users { + + + + + + + } + } + +
+ Email + + Role + + Created + + Actions +
+ No users found +
+
{ user.Email }
+
+ if user.IsAdmin { + + Admin + + } else { + + User + + } + + { user.CreatedAt.Format("Jan 02, 2006") } + + +
+
+ } +
+ + +
+

+ + User accounts provide secure access to the GoMFT application with role-based permissions +

+
+
+
+ } +} + +type UserFormData struct { + IsNew bool + ErrorMessage string +} + +templ UserForm(ctx context.Context, data UserFormData) { + @LayoutWithContext("Add User", ctx) { +
+
+
+

+ + Add New User +

+
+ +
+
+ if data.ErrorMessage != "" { +
+ +
+ } + +
+
+
+

User Information

+

Create a new user account with appropriate permissions.

+
+ +
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +

+ + Passwords do not match +

+
+
+ +
+
+
+ +
+
+ +

Grant administrative privileges to this user

+
+
+
+
+
+ +
+
+ + + Cancel + + +
+
+
+
+ +
+
+ + User accounts provide secure access to the GoMFT application +
+
+
+
+
+ } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..e38db5c --- /dev/null +++ b/docker-compose.yaml @@ -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 \ No newline at end of file diff --git a/example.config.json b/example.config.json new file mode 100644 index 0000000..fa032f3 --- /dev/null +++ b/example.config.json @@ -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" + } +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d95a02a --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9f7564c --- /dev/null +++ b/go.sum @@ -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= diff --git a/gomft b/gomft new file mode 100755 index 0000000..110cbad Binary files /dev/null and b/gomft differ diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..8ff7e9b --- /dev/null +++ b/internal/api/api.go @@ -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) + } +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..50c6899 --- /dev/null +++ b/internal/auth/jwt.go @@ -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") +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..608557d --- /dev/null +++ b/internal/auth/password.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..245c9b9 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..5a6d5ce --- /dev/null +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/email/email.go b/internal/email/email.go new file mode 100644 index 0000000..bcc969d --- /dev/null +++ b/internal/email/email.go @@ -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(` + + + + + + Reset Your Password + + + +
+
+ +

Reset Your Password

+
+
+

Hello{{if .Username}} {{.Username}}{{end}},

+

We received a request to reset your password for your {{.AppName}} account. Click the button below to reset it:

+ +
+ Reset Password +
+ +

If the button doesn't work, you can copy and paste the following link into your browser:

+ + +

This link will expire in 15 minutes.

+ +
+

If you didn't request a password reset, you can ignore this email. Your password will remain unchanged.

+
+
+ +
+ + +`) + 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() + } +} \ No newline at end of file diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..a4339db --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -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 +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go new file mode 100644 index 0000000..7129016 --- /dev/null +++ b/internal/web/handlers.go @@ -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) +} diff --git a/internal/web/handlers/admin_handlers.go b/internal/web/handlers/admin_handlers.go new file mode 100644 index 0000000..bc03959 --- /dev/null +++ b/internal/web/handlers/admin_handlers.go @@ -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"}) +} \ No newline at end of file diff --git a/internal/web/handlers/admin_tools_handlers.go b/internal/web/handlers/admin_tools_handlers.go new file mode 100644 index 0000000..409aad2 --- /dev/null +++ b/internal/web/handlers/admin_tools_handlers.go @@ -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) +} diff --git a/internal/web/handlers/api_handlers.go b/internal/web/handlers/api_handlers.go new file mode 100644 index 0000000..20d78f4 --- /dev/null +++ b/internal/web/handlers/api_handlers.go @@ -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"}) +} diff --git a/internal/web/handlers/auth_handlers.go b/internal/web/handlers/auth_handlers.go new file mode 100644 index 0000000..ebc6acb --- /dev/null +++ b/internal/web/handlers/auth_handlers.go @@ -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(``)) + 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(``)) + 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(``)) + return + } + + // Get user + var user db.User + if err := h.DB.First(&user, userID).Error; err != nil { + c.Data(http.StatusOK, "text/html", []byte(``)) + return + } + + // Verify current password + if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil { + c.Data(http.StatusOK, "text/html", []byte(``)) + return + } + + // Validate password against policy + policy := auth.DefaultPasswordPolicy() + if err := auth.ValidatePassword(newPassword, policy); err != nil { + errorMsg := `` + 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 := `` + 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(``)) + 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(``)) + 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(``)) + return + } + + // Return success message + c.Data(http.StatusOK, "text/html", []byte(``)) +} + +// 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 +} diff --git a/internal/web/handlers/basic_handlers.go b/internal/web/handlers/basic_handlers.go new file mode 100644 index 0000000..64a7495 --- /dev/null +++ b/internal/web/handlers/basic_handlers.go @@ -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) +} \ No newline at end of file diff --git a/internal/web/handlers/config_handlers.go b/internal/web/handlers/config_handlers.go new file mode 100644 index 0000000..ca1fd2f --- /dev/null +++ b/internal/web/handlers/config_handlers.go @@ -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"}) +} diff --git a/internal/web/handlers/dashboard_handlers.go b/internal/web/handlers/dashboard_handlers.go new file mode 100644 index 0000000..793cc0b --- /dev/null +++ b/internal/web/handlers/dashboard_handlers.go @@ -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, + }) +} diff --git a/internal/web/handlers/handler.go b/internal/web/handlers/handler.go new file mode 100644 index 0000000..bfe25dd --- /dev/null +++ b/internal/web/handlers/handler.go @@ -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, + } +} diff --git a/internal/web/handlers/job_handlers.go b/internal/web/handlers/job_handlers.go new file mode 100644 index 0000000..9b5b055 --- /dev/null +++ b/internal/web/handlers/job_handlers.go @@ -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, + }) +} \ No newline at end of file diff --git a/internal/web/handlers/profile_handlers.go b/internal/web/handlers/profile_handlers.go new file mode 100644 index 0000000..8589b82 --- /dev/null +++ b/internal/web/handlers/profile_handlers.go @@ -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") +} \ No newline at end of file diff --git a/internal/web/handlers/routes.go b/internal/web/handlers/routes.go new file mode 100644 index 0000000..20a4244 --- /dev/null +++ b/internal/web/handlers/routes.go @@ -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) + } + } + } +} diff --git a/internal/web/handlers/user_handlers.go b/internal/web/handlers/user_handlers.go new file mode 100644 index 0000000..521a39d --- /dev/null +++ b/internal/web/handlers/user_handlers.go @@ -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 +} diff --git a/internal/web/middleware/auth.go b/internal/web/middleware/auth.go new file mode 100644 index 0000000..0d28fab --- /dev/null +++ b/internal/web/middleware/auth.go @@ -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() + } +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..371b0bc --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/screenshots/admin.tools.gomft.png b/screenshots/admin.tools.gomft.png new file mode 100644 index 0000000..65aaf6a Binary files /dev/null and b/screenshots/admin.tools.gomft.png differ diff --git a/screenshots/dashboard.dark.gomft.png b/screenshots/dashboard.dark.gomft.png new file mode 100644 index 0000000..85e5e6b Binary files /dev/null and b/screenshots/dashboard.dark.gomft.png differ diff --git a/screenshots/dashboard.gomft.png b/screenshots/dashboard.gomft.png new file mode 100644 index 0000000..8e01f91 Binary files /dev/null and b/screenshots/dashboard.gomft.png differ diff --git a/screenshots/login.gomft.png b/screenshots/login.gomft.png new file mode 100644 index 0000000..b1475ed Binary files /dev/null and b/screenshots/login.gomft.png differ diff --git a/screenshots/new.configuration.gomft.png b/screenshots/new.configuration.gomft.png new file mode 100644 index 0000000..ccf70c7 Binary files /dev/null and b/screenshots/new.configuration.gomft.png differ diff --git a/screenshots/new.job.gomft.png b/screenshots/new.job.gomft.png new file mode 100644 index 0000000..1b6b28c Binary files /dev/null and b/screenshots/new.job.gomft.png differ diff --git a/screenshots/profile.gomft.png b/screenshots/profile.gomft.png new file mode 100644 index 0000000..e354197 Binary files /dev/null and b/screenshots/profile.gomft.png differ diff --git a/screenshots/transfer.configurations.gomft.png b/screenshots/transfer.configurations.gomft.png new file mode 100644 index 0000000..b8e3353 Binary files /dev/null and b/screenshots/transfer.configurations.gomft.png differ diff --git a/screenshots/transfer.history.gomft.png b/screenshots/transfer.history.gomft.png new file mode 100644 index 0000000..c1499ac Binary files /dev/null and b/screenshots/transfer.history.gomft.png differ diff --git a/screenshots/transfer.jobs.gomft.png b/screenshots/transfer.jobs.gomft.png new file mode 100644 index 0000000..284e740 Binary files /dev/null and b/screenshots/transfer.jobs.gomft.png differ diff --git a/screenshots/user.management.gomft.png b/screenshots/user.management.gomft.png new file mode 100644 index 0000000..769d0b7 Binary files /dev/null and b/screenshots/user.management.gomft.png differ diff --git a/static/android-chrome-192x192.png b/static/android-chrome-192x192.png new file mode 100644 index 0000000..66d7759 Binary files /dev/null and b/static/android-chrome-192x192.png differ diff --git a/static/android-chrome-512x512.png b/static/android-chrome-512x512.png new file mode 100644 index 0000000..20d3f6d Binary files /dev/null and b/static/android-chrome-512x512.png differ diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000..aaabcc4 Binary files /dev/null and b/static/apple-touch-icon.png differ diff --git a/static/css/app.css b/static/css/app.css new file mode 100644 index 0000000..86c003c --- /dev/null +++ b/static/css/app.css @@ -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; + } +} \ No newline at end of file diff --git a/static/favicon-16x16.png b/static/favicon-16x16.png new file mode 100644 index 0000000..8d53d24 Binary files /dev/null and b/static/favicon-16x16.png differ diff --git a/static/favicon-32x32.png b/static/favicon-32x32.png new file mode 100644 index 0000000..2d06505 Binary files /dev/null and b/static/favicon-32x32.png differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..65325fb Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..3560829 --- /dev/null +++ b/static/js/app.js @@ -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); diff --git a/static/site.webmanifest b/static/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/static/site.webmanifest @@ -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"} \ No newline at end of file