initial commit

This commit is contained in:
StarFleetCPTN committed 2025-03-07 23:21:12 -08:00
commit 953ad364ab
68 files changed
+11440

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
package web
import (
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/internal/config"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/email"
"github.com/starfleetcptn/gomft/internal/scheduler"
"github.com/starfleetcptn/gomft/internal/web/handlers"
)
// Handler is a wrapper around the handlers package
type Handler struct {
handlers *handlers.Handlers
}
// NewHandler creates a new Handler instance that delegates to the handlers package
func NewHandler(database *db.DB, scheduler *scheduler.Scheduler, jwtSecret string, dbPath string, backupDir string, cfg *config.Config) (*Handler, error) {
// Create email service instance
emailService := email.NewService(cfg)
// Create handlers instance
handlersInstance := handlers.NewHandlers(database, scheduler, jwtSecret, dbPath, backupDir, emailService)
return &Handler{
handlers: handlersInstance,
}, nil
}
// InitializeRoutes delegates route registration to the handlers package
func (h *Handler) InitializeRoutes(router *gin.Engine) {
// Register all routes through the handlers package
h.handlers.RegisterRoutes(router)
}
+13
View File
@@ -0,0 +1,13 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
)
// HandleBackupDB handles the POST /admin/backup route
func (h *Handlers) HandleBackupDB(c *gin.Context) {
// TODO: Implement database backup
c.JSON(http.StatusOK, gin.H{"message": "Database backup initiated"})
}
@@ -0,0 +1,580 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleAdminTools displays the admin tools page
func (h *Handlers) HandleAdminTools(c *gin.Context) {
// Get system statistics
data := components.AdminToolsData{
SystemUptime: h.getSystemUptime(),
DatabasePath: h.DBPath,
BackupPath: h.BackupDir,
}
// Get database size
if dbSize, err := h.getDatabaseSize(); err == nil {
data.DatabaseSize = dbSize
} else {
data.DatabaseSize = "Unknown"
}
// Get job history count
var jobHistoryCount int64
if err := h.DB.Model(&db.JobHistory{}).Count(&jobHistoryCount).Error; err == nil {
data.JobHistoryCount = int(jobHistoryCount)
}
// Get active jobs count
var activeJobs int64
if err := h.DB.Model(&db.Job{}).Where("enabled = ?", true).Count(&activeJobs).Error; err == nil {
data.ActiveJobs = int(activeJobs)
}
// Get total configs count
var totalConfigs int64
if err := h.DB.Model(&db.TransferConfig{}).Count(&totalConfigs).Error; err == nil {
data.TotalConfigs = int(totalConfigs)
}
// Get total jobs count
var totalJobs int64
if err := h.DB.Model(&db.Job{}).Count(&totalJobs).Error; err == nil {
data.TotalJobs = int(totalJobs)
}
// Get total users count
var totalUsers int64
if err := h.DB.Model(&db.User{}).Count(&totalUsers).Error; err == nil {
data.TotalUsers = int(totalUsers)
}
// Get last backup time and backup count
data.LastBackupTime, data.BackupCount = h.getBackupInfo()
// Get list of backup files
data.BackupFiles = h.getBackupFiles()
// Check for maintenance issues
data.MaintenanceMessage = h.checkMaintenanceIssues()
// Render the admin tools page
components.AdminTools(components.CreateTemplateContext(c), data).Render(c, c.Writer)
}
// HandleBackupDatabase handles the backup database request
func (h *Handlers) HandleBackupDatabase(c *gin.Context) {
fmt.Println("Backup database")
// Create backup directory if it doesn't exist
if err := os.MkdirAll(h.BackupDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create backup directory: %v", err)})
return
}
// Create backup filename with timestamp
timestamp := time.Now().Format("20060102_150405")
backupFilename := filepath.Join(h.BackupDir, fmt.Sprintf("gomft_backup_%s.db", timestamp))
// Copy the database file to the backup location
if err := h.copyDatabaseToBackup(backupFilename); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create backup: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Database backup created successfully", "filename": backupFilename})
}
// HandleRestoreDatabase handles the restore database request
func (h *Handlers) HandleRestoreDatabase(c *gin.Context) {
// Get the uploaded file
file, err := c.FormFile("backup_file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No backup file provided"})
return
}
// Create a temporary file to store the uploaded backup
tempFile, err := os.CreateTemp("", "gomft_restore_*.db")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create temporary file: %v", err)})
return
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
// Save the uploaded file to the temporary location
if err := c.SaveUploadedFile(file, tempFile.Name()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save uploaded file: %v", err)})
return
}
// Stop the scheduler to prevent jobs from running during restore
h.Scheduler.Stop()
// Close the current database connection
if err := h.DB.Close(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to close database: %v", err)})
return
}
// Create a backup of the current database before restoring
backupBeforeRestore := filepath.Join(h.BackupDir, fmt.Sprintf("pre_restore_backup_%s.db", time.Now().Format("20060102_150405")))
if err := h.copyDatabaseToBackup(backupBeforeRestore); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create pre-restore backup: %v", err)})
return
}
// Copy the temporary file to the database location
if err := copyFile(tempFile.Name(), h.DBPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to restore database: %v", err)})
return
}
// Redirect to home page to reinitialize the application
c.JSON(http.StatusOK, gin.H{"message": "Database restored successfully. The application will restart."})
}
// HandleExportConfigs handles the export all configurations request
func (h *Handlers) HandleExportConfigs(c *gin.Context) {
// Get all configurations
var configs []db.TransferConfig
if err := h.DB.Find(&configs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve configurations: %v", err)})
return
}
// Create a temporary file
tmpFile, err := os.CreateTemp("", "gomft_configs_*.json")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create temporary file: %v", err)})
return
}
defer os.Remove(tmpFile.Name()) // Clean up temp file when done
defer tmpFile.Close()
// Write the configurations to the file
encoder := json.NewEncoder(tmpFile)
encoder.SetIndent("", " ") // Pretty print the JSON
if err := encoder.Encode(configs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to write configurations to file: %v", err)})
return
}
// Set headers for file download
c.Header("Content-Description", "File Transfer")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="gomft_configs_%s.json"`, time.Now().Format("20060102_150405")))
c.Header("Content-Type", "application/json")
// Send the file
c.File(tmpFile.Name())
}
// HandleExportJobs handles the export all jobs request
func (h *Handlers) HandleExportJobs(c *gin.Context) {
// Get all jobs
var jobs []db.Job
if err := h.DB.Preload("Config").Find(&jobs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve jobs: %v", err)})
return
}
// Create a temporary file
tmpFile, err := os.CreateTemp("", "gomft_jobs_*.json")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create temporary file: %v", err)})
return
}
defer os.Remove(tmpFile.Name()) // Clean up temp file when done
defer tmpFile.Close()
// Write the jobs to the file
encoder := json.NewEncoder(tmpFile)
encoder.SetIndent("", " ") // Pretty print the JSON
if err := encoder.Encode(jobs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to write jobs to file: %v", err)})
return
}
// Set headers for file download
c.Header("Content-Description", "File Transfer")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="gomft_jobs_%s.json"`, time.Now().Format("20060102_150405")))
c.Header("Content-Type", "application/json")
// Send the file
c.File(tmpFile.Name())
}
// HandleClearJobHistory handles the clear job history request
func (h *Handlers) HandleClearJobHistory(c *gin.Context) {
// Delete all job history records
if err := h.DB.Exec("DELETE FROM job_histories").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to clear job history: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Job history cleared successfully"})
}
// HandleVacuumDatabase handles the vacuum database request
func (h *Handlers) HandleVacuumDatabase(c *gin.Context) {
// Execute VACUUM command to optimize the database
if err := h.DB.Exec("VACUUM").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to vacuum database: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Database vacuumed successfully"})
}
// HandleRestoreDatabaseByFilename handles restoring a backup file from the backup directory
func (h *Handlers) HandleRestoreDatabaseByFilename(c *gin.Context) {
filename := c.Param("filename")
// Validate filename format
if !strings.HasPrefix(filename, "gomft_backup_") || !strings.HasSuffix(filename, ".db") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup filename"})
return
}
// Construct full file path
backupPath := filepath.Join(h.BackupDir, filename)
// Check if file exists and is within backup directory
if !strings.HasPrefix(backupPath, h.BackupDir) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup path"})
return
}
// Check if file exists
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup file not found"})
return
}
// Stop the scheduler to prevent jobs from running during restore
h.Scheduler.Stop()
// Close the current database connection
if err := h.DB.Close(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to close database: %v", err)})
return
}
// Create a backup of the current database before restoring
backupBeforeRestore := filepath.Join(h.BackupDir, fmt.Sprintf("pre_restore_backup_%s.db", time.Now().Format("20060102_150405")))
if err := h.copyDatabaseToBackup(backupBeforeRestore); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create pre-restore backup: %v", err)})
return
}
// Copy the backup file to the database location
if err := copyFile(backupPath, h.DBPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to restore database: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Database restored successfully. The application will restart."})
}
// HandleRefreshBackups handles the HTMX request to refresh the backups list
func (h *Handlers) HandleRefreshBackups(c *gin.Context) {
// Get list of backup files
backupFiles := h.getBackupFiles()
// Create data structure for the template
data := components.AdminToolsData{
BackupFiles: backupFiles,
}
// Get last backup time and backup count
data.LastBackupTime, data.BackupCount = h.getBackupInfo()
// Render just the BackupsList component
components.BackupsList(data).Render(c, c.Writer)
}
// Helper functions
// getSystemUptime returns the system uptime as a formatted string
func (h *Handlers) getSystemUptime() string {
uptime := time.Since(h.StartTime)
days := int(uptime.Hours() / 24)
hours := int(uptime.Hours()) % 24
minutes := int(uptime.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%d days, %d hours, %d minutes", days, hours, minutes)
}
if hours > 0 {
return fmt.Sprintf("%d hours, %d minutes", hours, minutes)
}
return fmt.Sprintf("%d minutes", minutes)
}
// getDatabaseSize returns the size of the database file as a formatted string
func (h *Handlers) getDatabaseSize() (string, error) {
fileInfo, err := os.Stat(h.DBPath)
if err != nil {
return "", err
}
sizeBytes := fileInfo.Size()
// Format size
if sizeBytes < 1024 {
return fmt.Sprintf("%d B", sizeBytes), nil
} else if sizeBytes < 1024*1024 {
return fmt.Sprintf("%.2f KB", float64(sizeBytes)/1024), nil
} else if sizeBytes < 1024*1024*1024 {
return fmt.Sprintf("%.2f MB", float64(sizeBytes)/(1024*1024)), nil
}
return fmt.Sprintf("%.2f GB", float64(sizeBytes)/(1024*1024*1024)), nil
}
// getBackupInfo returns the last backup time and total backup count
func (h *Handlers) getBackupInfo() (*time.Time, int) {
// Check if backup directory exists
if _, err := os.Stat(h.BackupDir); os.IsNotExist(err) {
return nil, 0
}
// List all backup files
backupFiles, err := filepath.Glob(filepath.Join(h.BackupDir, "gomft_backup_*.db"))
if err != nil {
return nil, 0
}
if len(backupFiles) == 0 {
return nil, 0
}
// Find the most recent backup
var lastBackupTime time.Time
var lastBackupFile string
for _, file := range backupFiles {
filename := filepath.Base(file)
// Extract timestamp from filename (format: gomft_backup_20060102_150405.db)
if len(filename) < 28 {
continue
}
timestampStr := filename[13:28]
timestamp, err := time.Parse("20060102_150405", timestampStr)
if err != nil {
continue
}
if timestamp.After(lastBackupTime) {
lastBackupTime = timestamp
lastBackupFile = file
}
}
if lastBackupFile == "" {
return nil, len(backupFiles)
}
return &lastBackupTime, len(backupFiles)
}
// checkMaintenanceIssues checks for potential maintenance issues
func (h *Handlers) checkMaintenanceIssues() string {
var issues []string
// Check database size
fileInfo, err := os.Stat(h.DBPath)
if err == nil {
sizeBytes := fileInfo.Size()
// If database is larger than 100MB, suggest vacuum
if sizeBytes > 100*1024*1024 {
issues = append(issues, "Database size is large (>100MB). Consider running vacuum to optimize.")
}
}
// Check job history count
var jobHistoryCount int64
if err := h.DB.Model(&db.JobHistory{}).Count(&jobHistoryCount).Error; err == nil {
// If more than 1000 job history records, suggest clearing old records
if jobHistoryCount > 1000 {
issues = append(issues, fmt.Sprintf("Job history contains %d records. Consider clearing old records.", jobHistoryCount))
}
}
// Check backup age
lastBackupTime, _ := h.getBackupInfo()
if lastBackupTime == nil {
issues = append(issues, "No database backups found. Consider creating a backup.")
} else {
// If last backup is older than 7 days, suggest creating a new backup
if time.Since(*lastBackupTime) > 7*24*time.Hour {
issues = append(issues, fmt.Sprintf("Last backup is %d days old. Consider creating a new backup.", int(time.Since(*lastBackupTime).Hours()/24)))
}
}
// Join all issues with newlines
if len(issues) > 0 {
return fmt.Sprintf("Maintenance Recommendations:\n%s", strings.Join(issues, "\n"))
}
return ""
}
// copyDatabaseToBackup copies the database file to the specified backup location
func (h *Handlers) copyDatabaseToBackup(backupPath string) error {
return copyFile(h.DBPath, backupPath)
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
if err != nil {
return err
}
return destFile.Sync()
}
// getBackupFiles returns a list of backup files with their details
func (h *Handlers) getBackupFiles() []components.BackupFile {
var backupFiles []components.BackupFile
// Check if backup directory exists
if _, err := os.Stat(h.BackupDir); os.IsNotExist(err) {
return backupFiles
}
// List all backup files
files, err := filepath.Glob(filepath.Join(h.BackupDir, "gomft_backup_*.db"))
if err != nil {
return backupFiles
}
for _, file := range files {
fileInfo, err := os.Stat(file)
if err != nil {
continue
}
// Format file size
var sizeStr string
size := fileInfo.Size()
switch {
case size < 1024:
sizeStr = fmt.Sprintf("%d B", size)
case size < 1024*1024:
sizeStr = fmt.Sprintf("%.2f KB", float64(size)/1024)
case size < 1024*1024*1024:
sizeStr = fmt.Sprintf("%.2f MB", float64(size)/(1024*1024))
default:
sizeStr = fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024))
}
backupFiles = append(backupFiles, components.BackupFile{
Name: filepath.Base(file),
Size: sizeStr,
ModTime: fileInfo.ModTime(),
})
}
// Sort backups by modification time, newest first
sort.Slice(backupFiles, func(i, j int) bool {
return backupFiles[i].ModTime.After(backupFiles[j].ModTime)
})
return backupFiles
}
// HandleDeleteBackup handles the DELETE /admin/delete-backup/:filename route
func (h *Handlers) HandleDeleteBackup(c *gin.Context) {
filename := c.Param("filename")
// Validate filename format
if !strings.HasPrefix(filename, "gomft_backup_") || !strings.HasSuffix(filename, ".db") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup filename"})
return
}
// Construct full file path
filePath := filepath.Join(h.BackupDir, filename)
// Check if file exists and is within backup directory
if !strings.HasPrefix(filePath, h.BackupDir) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup path"})
return
}
// Delete the file
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup file not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete backup: %v", err)})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "Backup deleted successfully"})
}
// HandleDownloadBackup handles the GET /admin/download-backup/:filename route
func (h *Handlers) HandleDownloadBackup(c *gin.Context) {
filename := c.Param("filename")
// Validate filename format
if !strings.HasPrefix(filename, "gomft_backup_") || !strings.HasSuffix(filename, ".db") {
c.String(http.StatusBadRequest, "Invalid backup filename")
return
}
// Construct full file path
filePath := filepath.Join(h.BackupDir, filename)
// Check if file exists and is within backup directory
if !strings.HasPrefix(filePath, h.BackupDir) {
c.String(http.StatusBadRequest, "Invalid backup path")
return
}
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.String(http.StatusNotFound, "Backup file not found")
return
}
// Set headers for file download
c.Header("Content-Description", "File Transfer")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Header("Content-Type", "application/octet-stream")
// Serve the file
c.File(filePath)
}
+295
View File
@@ -0,0 +1,295 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/internal/db"
"golang.org/x/crypto/bcrypt"
)
// HandleAPILogin handles the POST /api/login route
func (h *Handlers) HandleAPILogin(c *gin.Context) {
var loginData struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&loginData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request data"})
return
}
// Get user by email
var user db.User
if err := h.DB.Where("email = ?", loginData.Email).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// Check password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(loginData.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// Generate JWT token
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": gin.H{
"id": user.ID,
"email": user.Email,
"is_admin": user.IsAdmin,
},
})
}
// HandleAPIConfigs handles the GET /api/configs route
func (h *Handlers) HandleAPIConfigs(c *gin.Context) {
userID := c.GetUint("userID")
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
c.JSON(http.StatusOK, gin.H{"configs": configs})
}
// HandleAPIConfig handles the GET /api/configs/:id route
func (h *Handlers) HandleAPIConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to view this config"})
return
}
}
c.JSON(http.StatusOK, gin.H{"config": config})
}
// HandleAPICreateConfig handles the POST /api/configs route
func (h *Handlers) HandleAPICreateConfig(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request data: %v", err)})
return
}
userID := c.GetUint("userID")
config.CreatedBy = userID
if err := h.DB.Create(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create config: %v", err)})
return
}
c.JSON(http.StatusCreated, gin.H{"config": config})
}
// HandleAPIUpdateConfig handles the PUT /api/configs/:id route
func (h *Handlers) HandleAPIUpdateConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to update this config"})
return
}
}
// Get the old config values for comparison
oldConfig := config
// Bind JSON data to config
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request data: %v", err)})
return
}
// Preserve fields that shouldn't be updated
config.CreatedBy = oldConfig.CreatedBy
if err := h.DB.Save(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update config: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"config": config})
}
// HandleAPIDeleteConfig handles the DELETE /api/configs/:id route
func (h *Handlers) HandleAPIDeleteConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to delete this config"})
return
}
}
// Check if config is in use by any jobs
var jobCount int64
h.DB.Model(&db.Job{}).Where("config_id = ?", config.ID).Count(&jobCount)
if jobCount > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Config is in use by jobs and cannot be deleted"})
return
}
// Delete config
if err := h.DB.Delete(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete config: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
}
// HandleAPITestConnection handles the POST /api/configs/test route
func (h *Handlers) HandleAPITestConnection(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request data: %v", err)})
return
}
// TODO: Implement connection testing based on protocol
// This is a placeholder for the actual connection testing logic
success := true
message := "Connection successful"
// Example of how connection testing might work
switch config.SourceType {
case "sftp":
// Test SFTP connection
// success, message = testSFTPConnection(config)
case "ftp":
// Test FTP connection
// success, message = testFTPConnection(config)
default:
success = false
message = "Unsupported source type"
}
c.JSON(http.StatusOK, gin.H{
"success": success,
"message": message,
})
}
// HandleAPIJobs handles the API jobs request
func (h *Handlers) HandleAPIJobs(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API jobs handler stub"})
}
// HandleAPIJob handles the API job request
func (h *Handlers) HandleAPIJob(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API job handler stub"})
}
// HandleAPICreateJob handles the API create job request
func (h *Handlers) HandleAPICreateJob(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API create job handler stub"})
}
// HandleAPIUpdateJob handles the API update job request
func (h *Handlers) HandleAPIUpdateJob(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API update job handler stub"})
}
// HandleAPIDeleteJob handles the API delete job request
func (h *Handlers) HandleAPIDeleteJob(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API delete job handler stub"})
}
// HandleAPIRunJob handles the API run job request
func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API run job handler stub"})
}
// HandleAPIHistory handles the API history request
func (h *Handlers) HandleAPIHistory(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API history handler stub"})
}
// HandleAPIJobRun handles the API job run request
func (h *Handlers) HandleAPIJobRun(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API job run handler stub"})
}
// HandleAPIUsers handles the API users request
func (h *Handlers) HandleAPIUsers(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API users handler stub"})
}
// HandleAPIUser handles the API user request
func (h *Handlers) HandleAPIUser(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API user handler stub"})
}
// HandleAPICreateUser handles the API create user request
func (h *Handlers) HandleAPICreateUser(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API create user handler stub"})
}
// HandleAPIUpdateUser handles the API update user request
func (h *Handlers) HandleAPIUpdateUser(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API update user handler stub"})
}
// HandleAPIDeleteUser handles the API delete user request
func (h *Handlers) HandleAPIDeleteUser(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API delete user handler stub"})
}
+554
View File
@@ -0,0 +1,554 @@
package handlers
import (
"context"
"crypto/rand"
"encoding/base64"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/auth"
"github.com/starfleetcptn/gomft/internal/db"
"golang.org/x/crypto/bcrypt"
)
// AuthMiddleware is a middleware function that checks if the user is authenticated
func (h *Handlers) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Get the JWT token from the cookie
tokenString, err := c.Cookie("jwt_token")
if err != nil {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Parse and validate the token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(h.JWTSecret), nil
})
if err != nil || !token.Valid {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Set user information in the context
c.Set("userID", uint(claims["user_id"].(float64)))
c.Set("email", claims["email"].(string))
c.Set("username", claims["username"].(string))
c.Set("isAdmin", claims["is_admin"].(bool))
c.Next()
}
}
// AdminMiddleware is a middleware function that checks if the user is an admin
func (h *Handlers) AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
isAdmin, exists := c.Get("isAdmin")
if !exists || !isAdmin.(bool) {
c.Redirect(http.StatusFound, "/dashboard")
c.Abort()
return
}
c.Next()
}
}
// APIAuthMiddleware is a middleware function that checks if the API request is authenticated
func (h *Handlers) APIAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Get the Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
c.Abort()
return
}
// Check if the header is in the correct format
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
c.Abort()
return
}
// Parse and validate the token
tokenString := parts[1]
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(h.JWTSecret), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
c.Abort()
return
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
return
}
// Set user information in the context
c.Set("userID", uint(claims["user_id"].(float64)))
c.Set("email", claims["email"].(string))
c.Set("username", claims["username"].(string))
c.Set("isAdmin", claims["is_admin"].(bool))
c.Next()
}
}
// APIAdminMiddleware is a middleware function that checks if the API request is from an admin
func (h *Handlers) APIAdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
isAdmin, exists := c.Get("isAdmin")
if !exists || !isAdmin.(bool) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin privileges required"})
c.Abort()
return
}
c.Next()
}
}
// GenerateJWT generates a JWT token for the given user
func (h *Handlers) GenerateJWT(userID uint, username string, isAdmin bool) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": userID,
"username": username,
"is_admin": isAdmin,
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
return token.SignedString([]byte(h.JWTSecret))
}
// HandleLoginPage handles the GET /login route
func (h *Handlers) HandleLoginPage(c *gin.Context) {
// Check if user is already logged in
if userID, exists := c.Get("userID"); exists && userID != nil {
// User is logged in, redirect to dashboard
c.Redirect(http.StatusFound, "/dashboard")
return
}
// Create template context and set email if available
ctx := components.CreateTemplateContext(c)
if email, exists := c.Get("email"); exists {
ctx = context.WithValue(ctx, "email", email)
}
// Check for message query param (used for password expired, etc.)
message := c.Query("message")
// User is not logged in, show login page
if message != "" {
components.Login(ctx, message).Render(c.Request.Context(), c.Writer)
} else {
components.Login(ctx, "").Render(c.Request.Context(), c.Writer)
}
}
// HandleLogin handles the POST /login route
func (h *Handlers) HandleLogin(c *gin.Context) {
email := c.PostForm("email")
password := c.PostForm("password")
// Get user by email
var user db.User
if err := h.DB.Where("email = ?", email).First(&user).Error; err != nil {
components.Login(components.CreateTemplateContext(c), "Invalid credentials").Render(c, c.Writer)
return
}
// Check if account is locked
if user.AccountLocked {
if user.LockoutUntil != nil && time.Now().After(*user.LockoutUntil) {
// Lockout period has expired, reset the lockout
user.AccountLocked = false
user.FailedLoginAttempts = 0
user.LockoutUntil = nil
h.DB.Save(&user)
} else {
// Account is still locked
components.Login(components.CreateTemplateContext(c), "Account is locked due to too many failed login attempts. Please try again later.").Render(c, c.Writer)
return
}
}
// Check password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
// Increment failed login attempts
user.FailedLoginAttempts++
// Check if we need to lock the account
policy := auth.DefaultPasswordPolicy()
if user.FailedLoginAttempts >= policy.MaxLoginAttempts {
user.AccountLocked = true
lockoutTime := time.Now().Add(policy.LockoutDuration)
user.LockoutUntil = &lockoutTime
h.DB.Save(&user)
components.Login(components.CreateTemplateContext(c), "Account is locked due to too many failed login attempts. Please try again later.").Render(c, c.Writer)
return
}
h.DB.Save(&user)
components.Login(components.CreateTemplateContext(c), "Invalid credentials").Render(c, c.Writer)
return
}
// Reset failed login attempts on successful login
user.FailedLoginAttempts = 0
user.AccountLocked = false
user.LockoutUntil = nil
h.DB.Save(&user)
// Check password expiration
policy := auth.DefaultPasswordPolicy()
if auth.IsPasswordExpired(user.LastPasswordChange, policy) {
// Add flash message about password expiration
// We're simplifying by just redirecting to login with a message
c.SetCookie("jwt_token", "", -1, "/", "", false, true) // Logout the user
c.Redirect(http.StatusFound, "/login?message=Your+password+has+expired.+Please+contact+an+administrator.")
return
}
// Generate JWT token with all necessary user information
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user.ID,
"email": user.Email,
"username": strings.Split(user.Email, "@")[0], // Use email prefix as username
"is_admin": user.IsAdmin,
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
// Sign the token
tokenString, err := token.SignedString([]byte(h.JWTSecret))
if err != nil {
components.Login(components.CreateTemplateContext(c), "Authentication error").Render(c, c.Writer)
return
}
// Set token in cookie
c.SetCookie("jwt_token", tokenString, 86400, "/", "", false, true)
c.Redirect(http.StatusFound, "/dashboard")
}
// HandleLogout handles the POST /logout route
func (h *Handlers) HandleLogout(c *gin.Context) {
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
}
// HandleChangePassword handles the POST /change-password route
// This is now only for use from the profile page
func (h *Handlers) HandleChangePassword(c *gin.Context) {
// Get user ID from token
tokenCookie, err := c.Cookie("jwt_token")
if err != nil || tokenCookie == "" {
if c.GetHeader("HX-Request") == "true" {
c.Data(http.StatusUnauthorized, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Authentication required</span>
</div>`))
return
}
c.Redirect(http.StatusFound, "/login")
return
}
claims, err := auth.ValidateToken(tokenCookie, h.JWTSecret)
if err != nil {
if c.GetHeader("HX-Request") == "true" {
c.Data(http.StatusUnauthorized, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Invalid authentication</span>
</div>`))
return
}
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
return
}
userID := claims.UserID
// Get form values
currentPassword := c.PostForm("current_password")
newPassword := c.PostForm("new_password")
confirmPassword := c.PostForm("confirm_password")
// Validate new password matches confirmation
if newPassword != confirmPassword {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">New password and confirmation do not match</span>
</div>`))
return
}
// Get user
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">User not found</span>
</div>`))
return
}
// Verify current password
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Current password is incorrect</span>
</div>`))
return
}
// Validate password against policy
policy := auth.DefaultPasswordPolicy()
if err := auth.ValidatePassword(newPassword, policy); err != nil {
errorMsg := `<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">` + err.Error() + `</span>
</div>`
c.Data(http.StatusOK, "text/html", []byte(errorMsg))
return
}
// Check password history
if err := auth.CheckPasswordHistory(user.ID, newPassword, user.PasswordHash, h.DB.DB, policy); err != nil {
errorMsg := `<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">` + err.Error() + `</span>
</div>`
c.Data(http.StatusOK, "text/html", []byte(errorMsg))
return
}
// Hash the new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Error processing password</span>
</div>`))
return
}
// Update password history
if err := auth.UpdatePasswordHistory(user.ID, string(hashedPassword), h.DB.DB, policy); err != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Error updating password history</span>
</div>`))
return
}
// Update user's password
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
if err := h.DB.Save(&user).Error; err != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Error updating password</span>
</div>`))
return
}
// Return success message
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Password updated successfully!</span>
</div>`))
}
// HandleForgotPasswordPage displays the forgot password form
func (h *Handlers) HandleForgotPasswordPage(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "", "").Render(c.Request.Context(), c.Writer)
}
// HandleForgotPassword processes the forgot password form submission
func (h *Handlers) HandleForgotPassword(c *gin.Context) {
email := c.PostForm("email")
if email == "" {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "Email is required", "").Render(c.Request.Context(), c.Writer)
return
}
// Check if user exists
user, err := h.DB.GetUserByEmail(email)
if err != nil {
// Don't reveal that the email doesn't exist for security reasons
// But we'll log it for debugging
log.Printf("Password reset requested for non-existent email: %s", email)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "", "If your email is registered, you will receive a password reset link.").Render(c.Request.Context(), c.Writer)
return
}
// Generate reset token
token, err := generateResetToken(32)
if err != nil {
log.Printf("Error generating reset token: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "An error occurred. Please try again later.", "").Render(c.Request.Context(), c.Writer)
return
}
// Save token in database with expiration time (15 minutes)
expiration := time.Now().Add(15 * time.Minute)
resetToken := &db.PasswordResetToken{
UserID: user.ID,
Token: token,
ExpiresAt: expiration,
}
if err := h.DB.CreatePasswordResetToken(resetToken); err != nil {
log.Printf("Error saving reset token: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "An error occurred. Please try again later.", "").Render(c.Request.Context(), c.Writer)
return
}
// Send password reset email
err = h.Email.SendPasswordResetEmail(user.Email, user.Email, token)
if err != nil {
// If email sending fails, log the error but don't expose this to the user
log.Printf("Error sending password reset email: %v", err)
// If email is disabled, log the reset link
if strings.Contains(err.Error(), "email service is disabled") {
log.Printf("Email service is disabled, reset link: %v", err)
}
}
// Show success message regardless of whether email was sent
// This prevents user enumeration attacks
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "", "If your email is registered, you will receive a password reset link.").Render(c.Request.Context(), c.Writer)
}
// HandleResetPasswordPage displays the reset password form
func (h *Handlers) HandleResetPasswordPage(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.Redirect(http.StatusFound, "/forgot-password")
return
}
// Validate token exists and hasn't expired
_, err := h.DB.GetPasswordResetToken(token)
if err != nil {
log.Printf("Invalid reset token: %s, error: %v", token, err)
c.Redirect(http.StatusFound, "/forgot-password")
return
}
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "").Render(c.Request.Context(), c.Writer)
}
// HandleResetPassword processes the reset password form submission
func (h *Handlers) HandleResetPassword(c *gin.Context) {
token := c.PostForm("token")
password := c.PostForm("password")
confirmPassword := c.PostForm("confirm-password")
if token == "" {
c.Redirect(http.StatusFound, "/forgot-password")
return
}
if password == "" || confirmPassword == "" {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "Both password fields are required.").Render(c.Request.Context(), c.Writer)
return
}
if password != confirmPassword {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "Passwords do not match.").Render(c.Request.Context(), c.Writer)
return
}
if len(password) < 8 {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "Password must be at least 8 characters long.").Render(c.Request.Context(), c.Writer)
return
}
// Validate token and get user
resetToken, err := h.DB.GetPasswordResetToken(token)
if err != nil {
log.Printf("Invalid reset token: %s, error: %v", token, err)
c.Redirect(http.StatusFound, "/forgot-password")
return
}
// Get the user
user, err := h.DB.GetUserByID(resetToken.UserID)
if err != nil {
log.Printf("User not found for token: %s, user ID: %d, error: %v", token, resetToken.UserID, err)
c.Redirect(http.StatusFound, "/forgot-password")
return
}
// Hash the new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "An error occurred. Please try again later.").Render(c.Request.Context(), c.Writer)
return
}
// Update user's password
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
if err := h.DB.UpdateUser(user); err != nil {
log.Printf("Error updating user password: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "An error occurred. Please try again later.").Render(c.Request.Context(), c.Writer)
return
}
// Record password history
passwordHistory := &auth.PasswordHistory{
UserID: user.ID,
PasswordHash: string(hashedPassword),
}
if err := h.DB.DB.Create(passwordHistory).Error; err != nil {
log.Printf("Error recording password history: %v", err)
}
// Mark token as used
if err := h.DB.MarkPasswordResetTokenAsUsed(resetToken.ID); err != nil {
log.Printf("Error marking token as used: %v", err)
}
// Redirect to login with success message
c.Redirect(http.StatusFound, "/login?message=Password+reset+successful.+Please+log+in+with+your+new+password.")
}
// Helper function to generate a random token
func generateResetToken(length int) (string, error) {
b := make([]byte, length)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
+27
View File
@@ -0,0 +1,27 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/auth"
)
// HandleHome handles the GET / route
func (h *Handlers) HandleHome(c *gin.Context) {
// Check for JWT token in cookie
tokenCookie, err := c.Cookie("jwt_token")
if err == nil && tokenCookie != "" {
// Token exists, validate it
claims, err := auth.ValidateToken(tokenCookie, h.JWTSecret)
if err == nil && claims != nil {
// Valid token, redirect to dashboard
c.Redirect(http.StatusFound, "/dashboard")
return
}
}
// User is not logged in, show home page
components.Home(c.Request.Context()).Render(c, c.Writer)
}
+221
View File
@@ -0,0 +1,221 @@
package handlers
import (
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleConfigs handles the GET /configs route
func (h *Handlers) HandleConfigs(c *gin.Context) {
userID := c.GetUint("userID")
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
data := components.ConfigsData{
Configs: configs,
}
components.Configs(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleNewConfig handles the GET /configs/new route
func (h *Handlers) HandleNewConfig(c *gin.Context) {
data := components.ConfigFormData{
Config: &db.TransferConfig{},
IsNew: true,
}
components.ConfigForm(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleEditConfig handles the GET /configs/:id/edit route
func (h *Handlers) HandleEditConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.Redirect(http.StatusFound, "/configs")
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.Redirect(http.StatusFound, "/configs")
return
}
}
data := components.ConfigFormData{
Config: &config,
IsNew: false,
}
components.ConfigForm(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleCreateConfig handles the POST /configs route
func (h *Handlers) HandleCreateConfig(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBind(&config); err != nil {
log.Printf("Error binding config form: %v", err)
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
return
}
userID := c.GetUint("userID")
config.CreatedBy = userID
if err := h.DB.Create(&config).Error; err != nil {
log.Printf("Error creating config: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to create config: %v", err))
return
}
// Generate rclone config file
if err := h.DB.GenerateRcloneConfig(&config); err != nil {
log.Printf("Warning: Failed to generate rclone config: %v", err)
// Continue anyway, as the config was created in the database
} else {
log.Printf("Generated rclone config for config ID %d", config.ID)
}
c.Redirect(http.StatusFound, "/configs")
}
// HandleUpdateConfig handles the PUT /configs/:id route
func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
log.Printf("Error finding config: %v", err)
c.String(http.StatusNotFound, "Config not found")
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.String(http.StatusForbidden, "You do not have permission to update this config")
return
}
}
// Get the old config values for comparison
oldConfig := config
// Bind form data to config
if err := c.ShouldBind(&config); err != nil {
log.Printf("Error binding config form: %v", err)
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
return
}
// Preserve fields that shouldn't be updated
config.CreatedBy = oldConfig.CreatedBy
if err := h.DB.Save(&config).Error; err != nil {
log.Printf("Error updating config: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update config: %v", err))
return
}
// Regenerate rclone config file
if err := h.DB.GenerateRcloneConfig(&config); err != nil {
log.Printf("Warning: Failed to regenerate rclone config: %v", err)
// Continue anyway, as the config was updated in the database
} else {
log.Printf("Regenerated rclone config for config ID %d", config.ID)
}
c.Redirect(http.StatusFound, "/configs")
}
// HandleDeleteConfig handles the DELETE /configs/:id route
func (h *Handlers) HandleDeleteConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to delete this config"})
return
}
}
// Check if config is in use by any jobs
var jobCount int64
h.DB.Model(&db.Job{}).Where("config_id = ?", config.ID).Count(&jobCount)
if jobCount > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Config is in use by jobs and cannot be deleted"})
return
}
// Delete config
if err := h.DB.Delete(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete config: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
}
// HandleTestConnection handles the POST /configs/test route
func (h *Handlers) HandleTestConnection(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBind(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid form data: %v", err)})
return
}
// TODO: Implement connection testing based on protocol
// This is a placeholder for the actual connection testing logic
success := true
message := "Connection successful"
// Example of how connection testing might work
switch config.SourceType {
case "sftp":
// Test SFTP connection
// success, message = testSFTPConnection(config)
default:
success = false
message = "Unsupported source type"
}
c.JSON(http.StatusOK, gin.H{
"success": success,
"message": message,
})
}
// HandleTestSFTPConnection handles the test SFTP connection request
func (h *Handlers) HandleTestSFTPConnection(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "Test SFTP connection handler stub"})
}
// HandleBrowseDirectory handles the browse directory request
func (h *Handlers) HandleBrowseDirectory(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "Browse directory handler stub"})
}
+106
View File
@@ -0,0 +1,106 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleDashboard handles the GET /dashboard route
func (h *Handlers) HandleDashboard(c *gin.Context) {
// Get recent job history
var recentHistory []db.JobHistory
h.DB.Order("start_time DESC").Limit(5).Find(&recentHistory)
// Get job statistics
var totalJobs int64
h.DB.Model(&db.JobHistory{}).Where("job_histories.status = 'running' AND job_histories.end_time IS NULL").Count(&totalJobs)
var completedJobs int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "completed").Count(&completedJobs)
var failedJobs int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failed").Count(&failedJobs)
data := components.DashboardData{
RecentJobs: recentHistory,
ActiveTransfers: int(totalJobs),
CompletedToday: int(completedJobs),
FailedTransfers: int(failedJobs),
}
components.Dashboard(components.CreateTemplateContext(c), data).Render(c, c.Writer)
}
// HandleDashboardStats handles the dashboard stats API request
func (h *Handlers) HandleDashboardStats(c *gin.Context) {
userID := c.GetUint("userID")
// Get job statistics
var activeJobCount int64
var completedJobCount int64
var failedJobCount int64
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "running").Count(&activeJobCount)
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "completed").Count(&completedJobCount)
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "failed").Count(&failedJobCount)
// Get transfer statistics for the last 7 days
var dailyStats []struct {
Date string `json:"date"`
Completed int64 `json:"completed"`
Failed int64 `json:"failed"`
}
for i := 6; i >= 0; i-- {
date := time.Now().AddDate(0, 0, -i)
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local)
endOfDay := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, time.Local)
var completed int64
var failed int64
h.DB.Model(&db.Job{}).
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "completed", startOfDay, endOfDay).
Count(&completed)
h.DB.Model(&db.Job{}).
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "failed", startOfDay, endOfDay).
Count(&failed)
dailyStats = append(dailyStats, struct {
Date string `json:"date"`
Completed int64 `json:"completed"`
Failed int64 `json:"failed"`
}{
Date: startOfDay.Format("2006-01-02"),
Completed: completed,
Failed: failed,
})
}
c.JSON(http.StatusOK, gin.H{
"activeJobs": activeJobCount,
"completedJobs": completedJobCount,
"failedJobs": failedJobCount,
"dailyStats": dailyStats,
"uptime": time.Since(h.StartTime).String(),
"uptimeSeconds": int64(time.Since(h.StartTime).Seconds()),
})
}
// HandleRecentJobs handles the recent jobs API request
func (h *Handlers) HandleRecentJobs(c *gin.Context) {
userID := c.GetUint("userID")
var recentJobs []db.Job
h.DB.Where("created_by = ?", userID).Order("created_at DESC").Limit(5).Find(&recentJobs)
c.JSON(http.StatusOK, gin.H{
"recentJobs": recentJobs,
})
}
+33
View File
@@ -0,0 +1,33 @@
package handlers
import (
"time"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/email"
"github.com/starfleetcptn/gomft/internal/scheduler"
)
// Handlers contains all the dependencies needed by the handlers
type Handlers struct {
DB *db.DB
Scheduler *scheduler.Scheduler
JWTSecret string
StartTime time.Time
DBPath string
BackupDir string
Email *email.Service
}
// NewHandlers creates a new Handlers instance
func NewHandlers(database *db.DB, scheduler *scheduler.Scheduler, jwtSecret string, dbPath string, backupDir string, emailService *email.Service) *Handlers {
return &Handlers{
DB: database,
Scheduler: scheduler,
JWTSecret: jwtSecret,
StartTime: time.Now(),
DBPath: dbPath,
BackupDir: backupDir,
Email: emailService,
}
}
+301
View File
@@ -0,0 +1,301 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleJobs handles the GET /jobs route
func (h *Handlers) HandleJobs(c *gin.Context) {
userID := c.GetUint("userID")
var jobs []db.Job
h.DB.Where("created_by = ?", userID).Preload("Config").Find(&jobs)
data := components.JobsData{
Jobs: jobs,
}
components.Jobs(c, data).Render(c, c.Writer)
}
// HandleJobRunDetails handles the GET /job/:id route
func (h *Handlers) HandleJobRunDetails(c *gin.Context) {
userID := c.GetUint("userID")
jobID := c.Param("id")
// Get job history
var jobHistory db.JobHistory
if err := h.DB.First(&jobHistory, jobID).Error; err != nil {
c.String(http.StatusNotFound, "Job not found")
return
}
// Get job
var job db.Job
if err := h.DB.First(&job, jobHistory.JobID).Error; err != nil {
c.String(http.StatusNotFound, "Job not found")
return
}
// Verify that the user owns this job
if job.CreatedBy != userID {
c.String(http.StatusForbidden, "You don't have permission to view this job run")
return
}
// Get the config
var config db.TransferConfig
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
c.String(http.StatusNotFound, "Configuration not found")
return
}
data := components.JobRunDetailsData{
JobHistory: jobHistory,
Job: job,
Config: config,
}
components.JobRunDetails(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleNewJob handles the GET /jobs/new route
func (h *Handlers) HandleNewJob(c *gin.Context) {
// Get available configs for the user
userID := c.GetUint("userID")
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
data := components.JobFormData{
Job: &db.Job{},
Configs: configs,
IsNew: true,
}
components.JobForm(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleEditJob handles the GET /jobs/:id/edit route
func (h *Handlers) HandleEditJob(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
c.Redirect(http.StatusFound, "/jobs")
return
}
// Check if user owns this job
if job.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.Redirect(http.StatusFound, "/jobs")
return
}
}
// Get available configs for the user
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
data := components.JobFormData{
Job: &job,
Configs: configs,
IsNew: false,
}
components.JobForm(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleCreateJob handles the POST /jobs route
func (h *Handlers) HandleCreateJob(c *gin.Context) {
var job db.Job
if err := c.ShouldBind(&job); err != nil {
c.String(http.StatusBadRequest, "Invalid form data")
return
}
userID := c.GetUint("userID")
job.CreatedBy = userID
// Verify that the config exists and belongs to the user
var config db.TransferConfig
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
c.String(http.StatusBadRequest, "Invalid configuration selected")
return
}
// Check if the config belongs to the user
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
return
}
}
// If job name is empty, use the config name
if job.Name == "" {
job.Name = config.Name
}
// Clear the Config field to prevent GORM from creating a new config
job.Config = db.TransferConfig{}
// Create the job
if err := h.DB.CreateJob(&job); err != nil {
c.String(http.StatusInternalServerError, "Failed to create job")
return
}
// Schedule the job with the scheduler
if err := h.Scheduler.ScheduleJob(&job); err != nil {
c.String(http.StatusInternalServerError, "Job created but scheduling failed: "+err.Error())
return
}
c.Redirect(http.StatusFound, "/jobs")
}
// HandleUpdateJob handles the PUT /jobs/:id route
func (h *Handlers) HandleUpdateJob(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
c.String(http.StatusNotFound, "Job not found")
return
}
// Check if user owns this job
if job.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.String(http.StatusForbidden, "You do not have permission to update this job")
return
}
}
// Get the old job values for comparison
oldJob := job
// Bind form data to job
if err := c.ShouldBind(&job); err != nil {
c.String(http.StatusBadRequest, "Invalid form data")
return
}
// Verify that the config exists and belongs to the user
var config db.TransferConfig
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
c.String(http.StatusBadRequest, "Invalid configuration selected")
return
}
// Check if the config belongs to the user
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
return
}
}
// If job name is empty, use the config name
if job.Name == "" {
job.Name = config.Name
}
// Preserve fields that shouldn't be updated
job.CreatedBy = oldJob.CreatedBy
job.ID = oldJob.ID
// Clear the Config field to prevent GORM from updating or creating a new config
job.Config = db.TransferConfig{}
if err := h.DB.UpdateJob(&job); err != nil {
c.String(http.StatusInternalServerError, "Failed to update job")
return
}
// Reschedule the job with the scheduler
if err := h.Scheduler.ScheduleJob(&job); err != nil {
c.String(http.StatusInternalServerError, "Job updated but scheduling failed: "+err.Error())
return
}
c.Redirect(http.StatusFound, "/jobs")
}
// HandleDeleteJob handles the DELETE /jobs/:id route
func (h *Handlers) HandleDeleteJob(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user owns this job
if job.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to delete this job"})
return
}
}
// Unschedule the job from the scheduler
h.Scheduler.UnscheduleJob(job.ID)
// Delete job
if err := h.DB.Delete(&job).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete job"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Job deleted successfully"})
}
// HandleRunJob handles the POST /jobs/:id/run route
func (h *Handlers) HandleRunJob(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user owns this job
if job.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to run this job"})
return
}
}
// Run the job immediately using the scheduler
if err := h.Scheduler.RunJobNow(job.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run job: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Job started successfully",
"jobId": job.ID,
})
}
+78
View File
@@ -0,0 +1,78 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleProfile handles the GET /profile route
func (h *Handlers) HandleProfile(c *gin.Context) {
userID := c.GetUint("userID")
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to retrieve user profile")
return
}
components.Profile(c.Request.Context(), user).Render(c, c.Writer)
}
// HandleUpdateTheme handles the POST /profile/theme route
func (h *Handlers) HandleUpdateTheme(c *gin.Context) {
userID := c.GetUint("userID")
theme := c.PostForm("theme")
// Validate theme value
validThemes := map[string]bool{
"light": true,
"dark": true,
"system": true,
}
if !validThemes[theme] {
c.Status(http.StatusBadRequest)
return
}
// Update user theme preference
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.Status(http.StatusInternalServerError)
return
}
user.Theme = theme
if err := h.DB.Save(&user).Error; err != nil {
c.Status(http.StatusInternalServerError)
return
}
// Set theme cookie for client-side theme switching
c.SetCookie("theme", theme, 60*60*24*365, "/", "", false, false)
c.Status(http.StatusOK)
}
// HandleUpdateProfile handles the POST /profile/update route
func (h *Handlers) HandleUpdateProfile(c *gin.Context) {
userID := c.GetUint("userID")
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.String(http.StatusNotFound, "User not found")
return
}
// Update user fields
user.Email = c.PostForm("email")
if err := h.DB.Save(&user).Error; err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update profile: %v", err))
return
}
c.Redirect(http.StatusFound, "/profile")
}
+267
View File
@@ -0,0 +1,267 @@
package handlers
import (
"fmt"
"math"
"net/http"
"strconv"
"net/url"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleHistory handles the GET /history route
func (h *Handlers) HandleHistory(c *gin.Context) {
userID := c.GetUint("userID")
// Get pagination parameters
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
if err != nil {
pageSize = 10
}
// Limit page size options
if pageSize != 10 && pageSize != 25 && pageSize != 50 && pageSize != 100 {
pageSize = 10
}
// Get search term
searchTerm := c.Query("search")
// Build the query
query := h.DB.Model(&db.JobHistory{}).
Joins("JOIN jobs ON jobs.id = job_histories.job_id").
Joins("JOIN transfer_configs ON transfer_configs.id = jobs.config_id").
Where("jobs.created_by = ?", userID)
// Apply search if provided
if searchTerm != "" {
query = query.Where("transfer_configs.name LIKE ? OR job_histories.status LIKE ?",
"%"+searchTerm+"%", "%"+searchTerm+"%")
}
// Count total matching records for pagination
var total int64
query.Count(&total)
// Calculate total pages
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
if totalPages == 0 {
totalPages = 1
}
// Ensure page is within bounds
if page > totalPages {
page = totalPages
}
// Get paginated results
var history []db.JobHistory
offset := (page - 1) * pageSize
query.Offset(offset).
Limit(pageSize).
Preload("Job.Config").
Order("start_time desc").
Find(&history)
// If we got no results and we're not on page 1, redirect to page 1
// Only do this for non-HTMX requests to avoid navigation issues
isHtmxRequest := c.GetHeader("HX-Request") == "true"
if len(history) == 0 && page > 1 && total > 0 && !isHtmxRequest {
redirectURL := fmt.Sprintf("/history?page=1&pageSize=%d", pageSize)
if searchTerm != "" {
redirectURL += fmt.Sprintf("&search=%s", url.QueryEscape(searchTerm))
}
c.Redirect(http.StatusFound, redirectURL)
return
}
data := components.HistoryData{
History: history,
CurrentPage: page,
TotalPages: totalPages,
SearchTerm: searchTerm,
PageSize: pageSize,
Total: int(total),
}
// If this is an HTMX request, only render the history content component
if isHtmxRequest {
components.HistoryContent(c, data).Render(c, c.Writer)
} else {
components.History(c, data).Render(c, c.Writer)
}
}
// HandleDashboardData handles the GET /dashboard/data route
func (h *Handlers) HandleDashboardData(c *gin.Context) {
// Get recent job runs
var recentRuns []db.JobHistory
if err := h.DB.Preload("Job").Order("start_time desc").Limit(5).Find(&recentRuns).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve recent runs"})
return
}
c.JSON(http.StatusOK, gin.H{
"recent_runs": recentRuns,
})
}
// HandleDashboardJobsData handles the GET /dashboard/jobs route
func (h *Handlers) HandleDashboardJobsData(c *gin.Context) {
// Get active jobs
var activeJobs []db.Job
if err := h.DB.Where("enabled = ?", true).Find(&activeJobs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve active jobs"})
return
}
c.JSON(http.StatusOK, gin.H{
"active_jobs": activeJobs,
})
}
// HandleDashboardHistoryData handles the GET /dashboard/history route
func (h *Handlers) HandleDashboardHistoryData(c *gin.Context) {
// Get job history stats
var successCount int64
var failureCount int64
var pendingCount int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "success").Count(&successCount)
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failure").Count(&failureCount)
h.DB.Model(&db.JobHistory{}).Where("status = ?", "pending").Count(&pendingCount)
c.JSON(http.StatusOK, gin.H{
"success_count": successCount,
"failure_count": failureCount,
"pending_count": pendingCount,
})
}
// RegisterRoutes registers all the routes for the web interface
func (h *Handlers) RegisterRoutes(router *gin.Engine) {
// Public routes
router.GET("/", h.HandleHome)
router.GET("/login", h.HandleLoginPage)
router.POST("/login", h.HandleLogin)
router.GET("/forgot-password", h.HandleForgotPasswordPage)
router.POST("/forgot-password", h.HandleForgotPassword)
router.GET("/reset-password", h.HandleResetPasswordPage)
router.POST("/reset-password", h.HandleResetPassword)
// Protected routes
authorized := router.Group("/")
authorized.Use(h.AuthMiddleware())
// Password change route - only accessed from profile page
authorized.POST("/change-password", h.HandleChangePassword)
{
authorized.GET("/dashboard", h.HandleDashboard)
authorized.GET("/configs", h.HandleConfigs)
authorized.GET("/configs/new", h.HandleNewConfig)
authorized.GET("/configs/:id", h.HandleEditConfig)
authorized.POST("/configs", h.HandleCreateConfig)
authorized.PUT("/configs/:id", h.HandleUpdateConfig)
authorized.POST("/configs/:id", h.HandleUpdateConfig) // Add POST route for form submission
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
authorized.GET("/jobs", h.HandleJobs)
authorized.GET("/jobs/new", h.HandleNewJob)
authorized.GET("/jobs/:id", h.HandleEditJob)
authorized.POST("/jobs", h.HandleCreateJob)
authorized.PUT("/jobs/:id", h.HandleUpdateJob)
authorized.POST("/jobs/:id", h.HandleUpdateJob) // Add POST route for form submission
authorized.DELETE("/jobs/:id", h.HandleDeleteJob)
authorized.POST("/jobs/:id/run", h.HandleRunJob)
authorized.GET("/history", h.HandleHistory)
authorized.GET("/job-runs/:id", h.HandleJobRunDetails)
authorized.GET("/profile", h.HandleProfile)
authorized.POST("/profile/theme", h.HandleUpdateTheme)
authorized.POST("/logout", h.HandleLogout)
// AJAX routes for dashboard
authorized.GET("/dashboard/data", h.HandleDashboardData)
authorized.GET("/dashboard/jobs", h.HandleDashboardJobsData)
authorized.GET("/dashboard/history", h.HandleDashboardHistoryData)
// Test connection routes
authorized.POST("/test-connection", h.HandleTestConnection)
authorized.POST("/test-sftp-connection", h.HandleTestSFTPConnection)
authorized.POST("/browse-directory", h.HandleBrowseDirectory)
}
// Admin-only routes
admin := router.Group("/admin")
admin.Use(h.AuthMiddleware(), h.AdminMiddleware())
{
admin.GET("/users", h.HandleUsers)
admin.GET("/users/new", h.HandleNewUser)
admin.POST("/users", h.HandleCreateUser)
admin.DELETE("/users/:id", h.HandleDeleteUser)
admin.GET("/register", h.HandleRegisterPage)
admin.POST("/register", h.HandleRegister)
// Admin tools routes
admin.GET("/tools", h.HandleAdminTools)
admin.POST("/backup-database", h.HandleBackupDatabase)
admin.POST("/restore-database", h.HandleRestoreDatabase)
admin.POST("/restore-database/:filename", h.HandleRestoreDatabaseByFilename)
admin.GET("/export-configs", h.HandleExportConfigs)
admin.GET("/export-jobs", h.HandleExportJobs)
admin.POST("/clear-job-history", h.HandleClearJobHistory)
admin.POST("/vacuum-database", h.HandleVacuumDatabase)
admin.GET("/download-backup/:filename", h.HandleDownloadBackup)
admin.DELETE("/delete-backup/:filename", h.HandleDeleteBackup)
admin.GET("/refresh-backups", h.HandleRefreshBackups)
}
// API routes
api := router.Group("/api")
{
api.POST("/login", h.HandleAPILogin)
// Protected API routes
apiAuthorized := api.Group("/")
apiAuthorized.Use(h.APIAuthMiddleware())
{
// Config endpoints
apiAuthorized.GET("/configs", h.HandleAPIConfigs)
apiAuthorized.GET("/configs/:id", h.HandleAPIConfig)
apiAuthorized.POST("/configs", h.HandleAPICreateConfig)
apiAuthorized.PUT("/configs/:id", h.HandleAPIUpdateConfig)
apiAuthorized.DELETE("/configs/:id", h.HandleAPIDeleteConfig)
// Job endpoints
apiAuthorized.GET("/jobs", h.HandleAPIJobs)
apiAuthorized.GET("/jobs/:id", h.HandleAPIJob)
apiAuthorized.POST("/jobs", h.HandleAPICreateJob)
apiAuthorized.PUT("/jobs/:id", h.HandleAPIUpdateJob)
apiAuthorized.DELETE("/jobs/:id", h.HandleAPIDeleteJob)
apiAuthorized.POST("/jobs/:id/run", h.HandleAPIRunJob)
// History endpoints
apiAuthorized.GET("/history", h.HandleAPIHistory)
apiAuthorized.GET("/job-runs/:id", h.HandleAPIJobRun)
// Admin-only API routes
apiAdmin := apiAuthorized.Group("/admin")
apiAdmin.Use(h.APIAdminMiddleware())
{
// User management
apiAdmin.GET("/users", h.HandleAPIUsers)
apiAdmin.GET("/users/:id", h.HandleAPIUser)
apiAdmin.POST("/users", h.HandleAPICreateUser)
apiAdmin.PUT("/users/:id", h.HandleAPIUpdateUser)
apiAdmin.DELETE("/users/:id", h.HandleAPIDeleteUser)
}
}
}
}
+316
View File
@@ -0,0 +1,316 @@
package handlers
import (
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
"golang.org/x/crypto/bcrypt"
)
// HandleUsers handles the GET /admin/users route
func (h *Handlers) HandleUsers(c *gin.Context) {
var users []db.User
if err := h.DB.Find(&users).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to retrieve users")
return
}
data := components.UsersData{
Users: users,
}
components.Users(components.CreateTemplateContext(c), data).Render(c, c.Writer)
}
// HandleNewUser handles the GET /admin/users/new route
func (h *Handlers) HandleNewUser(c *gin.Context) {
data := components.UserFormData{
IsNew: true,
ErrorMessage: "",
}
components.UserForm(components.CreateTemplateContext(c), data).Render(c, c.Writer)
}
// HandleCreateUser handles the POST /admin/users/new route
func (h *Handlers) HandleCreateUser(c *gin.Context) {
email := c.PostForm("email")
password := c.PostForm("password")
isAdmin := c.PostForm("is_admin") == "on"
// Check if email already exists
var existingUser db.User
if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil {
c.String(http.StatusBadRequest, "Email already exists")
return
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to hash password")
return
}
// Create the user
user := db.User{
Email: email,
PasswordHash: string(hashedPassword),
IsAdmin: isAdmin,
LastPasswordChange: time.Now(),
}
if err := h.DB.Create(&user).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to create user")
return
}
c.Redirect(http.StatusSeeOther, "/admin/users")
}
// HandleDeleteUser handles the POST /admin/users/delete route
func (h *Handlers) HandleDeleteUser(c *gin.Context) {
userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.String(http.StatusBadRequest, "Invalid user ID")
return
}
// Don't allow deleting the current user
currentUserID := c.GetUint("userID")
if uint(userID) == currentUserID {
c.String(http.StatusBadRequest, "Cannot delete your own account")
return
}
// Delete the user
if err := h.DB.Delete(&db.User{}, userID).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to delete user")
return
}
c.Redirect(http.StatusSeeOther, "/admin/users")
}
// HandleRegisterPage handles the GET /register route
func (h *Handlers) HandleRegisterPage(c *gin.Context) {
// Check if any users exist
var count int64
h.DB.Model(&db.User{}).Count(&count)
// If users exist, don't allow registration
if count > 0 {
c.Redirect(http.StatusSeeOther, "/")
return
}
components.Register(c.Request.Context(), "").Render(c, c.Writer)
}
// HandleRegister handles the POST /register route
func (h *Handlers) HandleRegister(c *gin.Context) {
// Check if any users exist
var count int64
h.DB.Model(&db.User{}).Count(&count)
// If users exist, don't allow registration
if count > 0 {
c.Redirect(http.StatusSeeOther, "/")
return
}
email := c.PostForm("email")
password := c.PostForm("password")
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to hash password")
return
}
// Create the admin user
user := db.User{
Email: email,
PasswordHash: string(hashedPassword),
IsAdmin: true,
LastPasswordChange: time.Now(),
}
if err := h.DB.Create(&user).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to create user")
return
}
// Generate JWT
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to generate token")
return
}
// Set cookie
c.SetCookie("jwt", token, 60*60*24, "/", "", false, true)
c.Redirect(http.StatusSeeOther, "/dashboard")
}
// HandleEditUser handles the edit user page request
func (h *Handlers) HandleEditUser(c *gin.Context) {
// Only admin users can access this page
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.Redirect(http.StatusFound, "/dashboard")
return
}
id := c.Param("id")
var user db.User
if err := h.DB.First(&user, id).Error; err != nil {
c.Redirect(http.StatusFound, "/users")
return
}
data := components.UserFormData{
IsNew: false,
ErrorMessage: "",
}
components.UserForm(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleUpdateUser handles the update user form submission
func (h *Handlers) HandleUpdateUser(c *gin.Context) {
// Only admin users can update users
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.String(http.StatusForbidden, "Only administrators can update users")
return
}
id := c.Param("id")
var user db.User
if err := h.DB.First(&user, id).Error; err != nil {
log.Printf("Error finding user: %v", err)
c.String(http.StatusNotFound, "User not found")
return
}
// Get the old user values for comparison
oldUser := user
// Bind form data to user
if err := c.ShouldBind(&user); err != nil {
log.Printf("Error binding user form: %v", err)
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
return
}
// Check if email already exists for a different user
var existingUser db.User
if user.Email != oldUser.Email {
if err := h.DB.Where("email = ? AND id != ?", user.Email, user.ID).First(&existingUser).Error; err == nil {
c.String(http.StatusBadRequest, "Email already in use")
return
}
}
// Get password from form
password := c.PostForm("password")
// Only update password if provided
if password != "" {
// Validate password complexity
if !h.validatePasswordComplexity(password) {
c.String(http.StatusBadRequest, "Password does not meet complexity requirements")
return
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
c.String(http.StatusInternalServerError, "Failed to hash password")
return
}
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
} else {
// Preserve the old password if not updating
user.PasswordHash = oldUser.PasswordHash
user.LastPasswordChange = oldUser.LastPasswordChange
}
// Preserve fields that shouldn't be updated
user.CreatedAt = oldUser.CreatedAt
user.FailedLoginAttempts = oldUser.FailedLoginAttempts
user.AccountLocked = oldUser.AccountLocked
user.LockoutUntil = oldUser.LockoutUntil
// Update admin status
user.IsAdmin = c.PostForm("is_admin") == "on"
if err := h.DB.Save(&user).Error; err != nil {
log.Printf("Error updating user: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update user: %v", err))
return
}
c.Redirect(http.StatusFound, "/users")
}
// HandleUnlockUser handles the unlock user request
func (h *Handlers) HandleUnlockUser(c *gin.Context) {
// Only admin users can unlock users
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "Only administrators can unlock users"})
return
}
id := c.Param("id")
var user db.User
if err := h.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Unlock user
user.AccountLocked = false
user.FailedLoginAttempts = 0
user.LockoutUntil = nil
if err := h.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to unlock user: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User unlocked successfully"})
}
// validatePasswordComplexity validates that a password meets complexity requirements
func (h *Handlers) validatePasswordComplexity(password string) bool {
// Password must be at least 8 characters long
if len(password) < 8 {
return false
}
// Check for at least one uppercase letter, one lowercase letter, and one number
hasUpper := false
hasLower := false
hasNumber := false
for _, char := range password {
if char >= 'A' && char <= 'Z' {
hasUpper = true
} else if char >= 'a' && char <= 'z' {
hasLower = true
} else if char >= '0' && char <= '9' {
hasNumber = true
}
}
return hasUpper && hasLower && hasNumber
}
+44
View File
@@ -0,0 +1,44 @@
// AuthMiddleware is a middleware function that checks if the request has a valid JWT token
func (m *Middleware) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Get token from cookie
tokenString, err := c.Cookie("jwt_token")
if err != nil {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Parse and validate token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(m.JWTSecret), nil
})
if err != nil || !token.Valid {
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Set user information in context
c.Set("userID", uint(claims["user_id"].(float64)))
c.Set("email", claims["email"].(string))
c.Set("username", claims["username"].(string))
c.Set("isAdmin", claims["is_admin"].(bool))
c.Next()
}
}