diff --git a/README.md b/README.md
index 85c7114..4d9fa60 100644
--- a/README.md
+++ b/README.md
@@ -151,12 +151,12 @@ services:
- EMAIL_USERNAME=smtp_username
- EMAIL_PASSWORD=smtp_password
# Logging configuration
- - GOMFT_LOGS_DIR=/app/data/logs
- - GOMFT_LOG_MAX_SIZE=10
- - GOMFT_LOG_MAX_BACKUPS=5
- - GOMFT_LOG_MAX_AGE=30
- - GOMFT_LOG_COMPRESS=true
- - GOMFT_LOG_LEVEL=info
+ - LOGS_DIR=/app/data/logs
+ - LOG_MAX_SIZE=10
+ - LOG_MAX_BACKUPS=5
+ - LOG_MAX_AGE=30
+ - LOG_COMPRESS=true
+ - LOG_LEVEL=info
```
Alternatively, you can mount your own .env file to the container:
@@ -233,12 +233,12 @@ EMAIL_PASSWORD=smtp_password
GoMFT provides configurable logging with rotation support through the following environment variables:
-- `GOMFT_LOGS_DIR`: Directory where log files are stored (default: `./data/logs`)
-- `GOMFT_LOG_MAX_SIZE`: Maximum size in megabytes for each log file before rotation (default: `10`)
-- `GOMFT_LOG_MAX_BACKUPS`: Number of old log files to retain (default: `5`)
-- `GOMFT_LOG_MAX_AGE`: Maximum number of days to retain old log files (default: `30`)
-- `GOMFT_LOG_COMPRESS`: Whether to compress rotated log files (default: `true`)
-- `GOMFT_LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`)
+- `LOGS_DIR`: Directory where log files are stored (default: `./data/logs`)
+- `LOG_MAX_SIZE`: Maximum size in megabytes for each log file before rotation (default: `10`)
+- `LOG_MAX_BACKUPS`: Number of old log files to retain (default: `5`)
+- `LOG_MAX_AGE`: Maximum number of days to retain old log files (default: `30`)
+- `LOG_COMPRESS`: Whether to compress rotated log files (default: `true`)
+- `LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`)
- `error`: Only show errors and critical issues
- `info`: Show errors and general operational information (default)
- `debug`: Show all messages including detailed debugging information
@@ -442,4 +442,4 @@ volumes:
- /host/path/backups:/app/backups # For database backups
```
-These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `GOMFT_LOGS_DIR`.
+These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
diff --git a/components/admin_tools.templ b/components/admin_tools.templ
index 58623c5..81b931c 100644
--- a/components/admin_tools.templ
+++ b/components/admin_tools.templ
@@ -12,6 +12,13 @@ type BackupFile struct {
ModTime time.Time
}
+type LogFile struct {
+ Name string
+ Size string
+ ModTime time.Time
+ Path string
+}
+
type AdminToolsData struct {
JobHistoryCount int
DatabaseSize string
@@ -26,6 +33,9 @@ type AdminToolsData struct {
BackupPath string
MaintenanceMessage string
BackupFiles []BackupFile
+ LogFiles []LogFile
+ LogContent string
+ CurrentLogFile string
}
// Dialog component for confirmation dialogs
@@ -131,6 +141,64 @@ templ BackupActionDialog(id string, title string, message string, confirmClass s
templ AdminTools(ctx context.Context, data AdminToolsData) {
@LayoutWithContext("Admin Tools", ctx) {
+
@@ -448,6 +516,11 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
+
+
+
+ @AdminLogViewer(data)
+
}
@@ -576,3 +649,167 @@ templ BackupsList(data AdminToolsData) {
}
}
+
+// Add this new template after other admin tool templates
+templ AdminLogViewer(data AdminToolsData) {
+
+
+ Log Files
+
+
+
+
+
Available Logs
+
+
+
+
+
+
+ Refresh
+
+
+
+
+
+
+
+ if data.CurrentLogFile != "" {
+ Log: { data.CurrentLogFile }
+ } else {
+ Select a log file
+ }
+
+ if data.CurrentLogFile != "" {
+
+
+ Download
+
+
+ }
+
+
+ @AdminLogContent(data)
+
+
+
+}
+
+// AdminLogContent template for log view
+templ AdminLogContent(data AdminToolsData) {
+
+
+
+
+ if data.CurrentLogFile == "" {
+
+
+
+
Select a log file to view its contents
+
+
+ } else {
+
+
+
+
+ { data.LogContent }
+
+
+
+
+
+
+
+ }
+
+}
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 52d28d2..18d6784 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -18,8 +18,8 @@ services:
- TZ=UTC
- DATA_DIR=/app/data
- BACKUP_DIR=/app/backups
- - GOMFT_LOGS_DIR=/app/data/logs
- # - GOMFT_LOG_LEVEL=info
+ - LOGS_DIR=/app/data/logs
+ # - LOG_LEVEL=info
networks:
- gomft-network
diff --git a/internal/auth/password.go b/internal/auth/password.go
index 608557d..7e1e5b2 100644
--- a/internal/auth/password.go
+++ b/internal/auth/password.go
@@ -6,46 +6,46 @@ import (
"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
+ 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"`
+ 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,
+ MinLength: 8,
+ RequireUppercase: true,
+ RequireLowercase: true,
+ RequireNumbers: true,
+ RequireSpecial: true,
+ ExpirationDays: 90,
+ HistoryCount: 5,
+ DisallowCommon: true,
+ MaxLoginAttempts: 5,
+ LockoutDuration: 15 * time.Minute,
}
}
@@ -127,7 +127,7 @@ 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)
}
@@ -143,7 +143,7 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
UserID: userID,
PasswordHash: hashedPassword,
}
-
+
if err := db.Create(&passwordHistory).Error; err != nil {
return err
}
@@ -151,13 +151,13 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
// 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
@@ -168,15 +168,6 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
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))
diff --git a/internal/db/db.go b/internal/db/db.go
index aa060f4..acc0f35 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -333,16 +333,6 @@ func (db *DB) CreateFileMetadata(metadata *FileMetadata) error {
return db.Create(metadata).Error
}
-// GetFileMetadata retrieves file metadata by ID
-func (db *DB) GetFileMetadata(id uint) (*FileMetadata, error) {
- var metadata FileMetadata
- err := db.First(&metadata, id).Error
- if err != nil {
- return nil, err
- }
- return &metadata, nil
-}
-
// GetFileMetadataByJobAndName retrieves file metadata by job ID and filename
func (db *DB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*FileMetadata, error) {
var metadata FileMetadata
@@ -363,18 +353,6 @@ func (db *DB) GetFileMetadataByHash(fileHash string) (*FileMetadata, error) {
return &metadata, nil
}
-// UpdateFileMetadata updates an existing file metadata record
-func (db *DB) UpdateFileMetadata(metadata *FileMetadata) error {
- return db.Save(metadata).Error
-}
-
-// GetFileMetadataForJob retrieves all file metadata for a job
-func (db *DB) GetFileMetadataForJob(jobID uint) ([]FileMetadata, error) {
- var metadata []FileMetadata
- err := db.Where("job_id = ?", jobID).Find(&metadata).Error
- return metadata, err
-}
-
// DeleteFileMetadata deletes file metadata by ID
func (db *DB) DeleteFileMetadata(id uint) error {
return db.Delete(&FileMetadata{}, id).Error
@@ -392,16 +370,6 @@ func (db *DB) GetConfigRclonePath(config *TransferConfig) string {
return filepath.Join(dataDir, "configs", fmt.Sprintf("config_%d.conf", config.ID))
}
-// GetSkipProcessedFilesValue gets the current value of SkipProcessedFiles for a config
-func (db *DB) GetSkipProcessedFilesValue(configID uint) (bool, error) {
- var value bool
- err := db.Model(&TransferConfig{}).
- Where("id = ?", configID).
- Select("skip_processed_files").
- Scan(&value).Error
- return value, err
-}
-
func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
configPath := db.GetConfigRclonePath(config)
diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go
index b2af8c9..e0f6508 100644
--- a/internal/scheduler/scheduler.go
+++ b/internal/scheduler/scheduler.go
@@ -1,8 +1,6 @@
package scheduler
import (
- "crypto/md5"
- "encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -102,7 +100,7 @@ func NewLogger() *Logger {
// Ensure logs directory exists
logsDir := filepath.Join(dataDir, "logs")
- if envLogsDir := os.Getenv("GOMFT_LOGS_DIR"); envLogsDir != "" {
+ if envLogsDir := os.Getenv("LOGS_DIR"); envLogsDir != "" {
logsDir = envLogsDir
}
@@ -112,34 +110,34 @@ func NewLogger() *Logger {
// Get log rotation settings from environment or use defaults
maxSize := 10 // Default: 10MB
- if envSize := os.Getenv("GOMFT_LOG_MAX_SIZE"); envSize != "" {
+ if envSize := os.Getenv("LOG_MAX_SIZE"); envSize != "" {
if size, err := strconv.Atoi(envSize); err == nil && size > 0 {
maxSize = size
}
}
maxBackups := 5 // Default: keep 5 backups
- if envBackups := os.Getenv("GOMFT_LOG_MAX_BACKUPS"); envBackups != "" {
+ if envBackups := os.Getenv("LOG_MAX_BACKUPS"); envBackups != "" {
if backups, err := strconv.Atoi(envBackups); err == nil && backups >= 0 {
maxBackups = backups
}
}
maxAge := 30 // Default: 30 days
- if envAge := os.Getenv("GOMFT_LOG_MAX_AGE"); envAge != "" {
+ if envAge := os.Getenv("LOG_MAX_AGE"); envAge != "" {
if age, err := strconv.Atoi(envAge); err == nil && age >= 0 {
maxAge = age
}
}
compress := true // Default: compress logs
- if envCompress := os.Getenv("GOMFT_LOG_COMPRESS"); envCompress == "false" {
+ if envCompress := os.Getenv("LOG_COMPRESS"); envCompress == "false" {
compress = false
}
// Get log level from environment or use default
logLevel := LogLevelInfo // Default to info level
- if envLogLevel := os.Getenv("GOMFT_LOG_LEVEL"); envLogLevel != "" {
+ if envLogLevel := os.Getenv("LOG_LEVEL"); envLogLevel != "" {
logLevel = ParseLogLevel(envLogLevel)
}
@@ -201,7 +199,7 @@ func New(database *db.DB) *Scheduler {
logger := NewLogger()
logger.Info.Println("Initializing scheduler")
- c := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))
+ c := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
c.Start()
s := &Scheduler{
@@ -944,39 +942,6 @@ func (s *Scheduler) RunJobNow(jobID uint) error {
return nil
}
-// calculateFileHash computes an MD5 hash for the given file path
-func calculateFileHash(filePath string) (string, error) {
- file, err := os.Open(filePath)
- if err != nil {
- return "", fmt.Errorf("error opening file: %v", err)
- }
- defer file.Close()
-
- hash := md5.New()
- if _, err := io.Copy(hash, file); err != nil {
- return "", fmt.Errorf("error calculating hash: %v", err)
- }
-
- return hex.EncodeToString(hash.Sum(nil)), nil
-}
-
-// getFileInfo retrieves file stats like size, creation time, and modification time
-func getFileInfo(filePath string) (int64, time.Time, time.Time, error) {
- info, err := os.Stat(filePath)
- if err != nil {
- return 0, time.Time{}, time.Time{}, fmt.Errorf("error getting file info: %v", err)
- }
-
- size := info.Size()
- modTime := info.ModTime()
-
- // Get creation time (this is platform-specific)
- // For simplicity, we'll use modification time as a fallback
- createTime := modTime
-
- return size, createTime, modTime, nil
-}
-
// hasFileBeenProcessed checks if a file with the same hash has been processed before
func (s *Scheduler) hasFileBeenProcessed(jobID uint, fileHash string) (bool, *db.FileMetadata, error) {
if fileHash == "" {
@@ -1002,82 +967,3 @@ func (s *Scheduler) checkFileProcessingHistory(jobID uint, fileName string) (*db
return nil, fmt.Errorf("no history found for file %s in job %d", fileName, jobID)
}
-
-// getRemoteFileInfo gets metadata for a remote file using rclone lsjson
-func (s *Scheduler) getRemoteFileInfo(config *db.TransferConfig, file string) (int64, time.Time, time.Time, string, error) {
- // Get rclone config path
- configPath := s.db.GetConfigRclonePath(config)
-
- // Construct the appropriate source path
- var sourcePath string
- if config.SourceType == "s3" || config.SourceType == "minio" || config.SourceType == "b2" {
- sourcePath = fmt.Sprintf("source_%d:%s", config.ID, config.SourceBucket)
- if config.SourcePath != "" && config.SourcePath != "/" {
- sourcePath = fmt.Sprintf("source_%d:%s/%s", config.ID, config.SourceBucket, config.SourcePath)
- }
- } else {
- sourcePath = fmt.Sprintf("source_%d:%s", config.ID, config.SourcePath)
- }
-
- // Use rclone lsjson to get file details
- rclonePath := os.Getenv("RCLONE_PATH")
- if rclonePath == "" {
- rclonePath = "rclone"
- }
-
- // Construct the full path to the file
- fullPath := fmt.Sprintf("%s/%s", sourcePath, file)
-
- // Run rclone lsjson command
- args := []string{
- "--config", configPath,
- "lsjson",
- "--hash",
- fullPath,
- }
-
- cmd := exec.Command(rclonePath, args...)
- output, err := cmd.CombinedOutput()
- if err != nil {
- return 0, time.Time{}, time.Time{}, "", fmt.Errorf("error getting remote file info: %v", err)
- }
-
- // Parse the JSON output
- var files []map[string]interface{}
- if err := json.Unmarshal(output, &files); err != nil {
- return 0, time.Time{}, time.Time{}, "", fmt.Errorf("error parsing lsjson output: %v", err)
- }
-
- if len(files) == 0 {
- return 0, time.Time{}, time.Time{}, "", fmt.Errorf("file not found: %s", file)
- }
-
- fileInfo := files[0]
-
- // Extract file size
- var fileSize int64
- if size, ok := fileInfo["Size"].(float64); ok {
- fileSize = int64(size)
- }
-
- // Extract modification time
- modTime := time.Now()
- if modTimeStr, ok := fileInfo["ModTime"].(string); ok {
- if parsedTime, err := time.Parse(time.RFC3339, modTimeStr); err == nil {
- modTime = parsedTime
- }
- }
-
- // Create time is usually not available for remote files, so we'll use modTime
- createTime := modTime
-
- // Calculate hash if available
- var md5Hash string
- if hashes, ok := fileInfo["Hashes"].(map[string]interface{}); ok {
- if md5, ok := hashes["md5"].(string); ok {
- md5Hash = md5
- }
- }
-
- return fileSize, createTime, modTime, md5Hash, nil
-}
diff --git a/internal/web/handlers/admin_handlers.go b/internal/web/handlers/admin_handlers.go
deleted file mode 100644
index bc03959..0000000
--- a/internal/web/handlers/admin_handlers.go
+++ /dev/null
@@ -1,13 +0,0 @@
-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
index 409aad2..1e3fe71 100644
--- a/internal/web/handlers/admin_tools_handlers.go
+++ b/internal/web/handlers/admin_tools_handlers.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "io/ioutil"
"net/http"
"os"
"path/filepath"
@@ -23,6 +24,7 @@ func (h *Handlers) HandleAdminTools(c *gin.Context) {
SystemUptime: h.getSystemUptime(),
DatabasePath: h.DBPath,
BackupPath: h.BackupDir,
+ LogFiles: h.getLogFiles(),
}
// Get database size
@@ -295,19 +297,30 @@ func (h *Handlers) HandleRestoreDatabaseByFilename(c *gin.Context) {
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)
}
+// HandleRefreshLogs refreshes the log files list
+func (h *Handlers) HandleRefreshLogs(c *gin.Context) {
+ // Get system statistics
+ data := components.AdminToolsData{
+ LogFiles: h.getLogFiles(),
+ }
+
+ // Render only the log viewer component
+ components.AdminLogViewer(data).Render(c, c.Writer)
+}
+
// Helper functions
// getSystemUptime returns the system uptime as a formatted string
@@ -578,3 +591,167 @@ func (h *Handlers) HandleDownloadBackup(c *gin.Context) {
// Serve the file
c.File(filePath)
}
+
+// formatSize converts bytes to human-readable sizes
+func formatSize(bytes float64) string {
+ const (
+ KB = 1024
+ MB = KB * 1024
+ GB = MB * 1024
+ TB = GB * 1024
+ )
+
+ switch {
+ case bytes >= TB:
+ return fmt.Sprintf("%.2f TB", bytes/TB)
+ case bytes >= GB:
+ return fmt.Sprintf("%.2f GB", bytes/GB)
+ case bytes >= MB:
+ return fmt.Sprintf("%.2f MB", bytes/MB)
+ case bytes >= KB:
+ return fmt.Sprintf("%.2f KB", bytes/KB)
+ default:
+ return fmt.Sprintf("%.0f B", bytes)
+ }
+}
+
+// Helper function to get log files
+func (h *Handlers) getLogFiles() []components.LogFile {
+ // Determine logs directory
+ logsDir := os.Getenv("LOGS_DIR")
+ if logsDir == "" {
+ dataDir := os.Getenv("DATA_DIR")
+ if dataDir == "" {
+ dataDir = "./data"
+ }
+ logsDir = filepath.Join(dataDir, "logs")
+ }
+
+ // Try to read directory
+ files, err := ioutil.ReadDir(logsDir)
+ if err != nil {
+ return []components.LogFile{}
+ }
+
+ // Process files
+ var logFiles []components.LogFile
+ for _, file := range files {
+ if file.IsDir() {
+ continue
+ }
+
+ // Only include .log files
+ if !strings.HasSuffix(strings.ToLower(file.Name()), ".log") {
+ continue
+ }
+
+ size := formatSize(float64(file.Size()))
+ logFiles = append(logFiles, components.LogFile{
+ Name: file.Name(),
+ Size: size,
+ ModTime: file.ModTime(),
+ Path: filepath.Join(logsDir, file.Name()),
+ })
+ }
+
+ // Sort by modification time (newest first)
+ sort.Slice(logFiles, func(i, j int) bool {
+ return logFiles[i].ModTime.After(logFiles[j].ModTime)
+ })
+
+ return logFiles
+}
+
+// HandleViewLog displays the contents of a log file
+func (h *Handlers) HandleViewLog(c *gin.Context) {
+ fileName := c.Param("fileName")
+ if fileName == "" {
+ c.String(http.StatusBadRequest, "No file name provided")
+ return
+ }
+
+ // Sanitize the filename to prevent directory traversal
+ fileName = filepath.Base(fileName)
+
+ // Determine logs directory
+ logsDir := os.Getenv("LOGS_DIR")
+ if logsDir == "" {
+ dataDir := os.Getenv("DATA_DIR")
+ if dataDir == "" {
+ dataDir = "./data"
+ }
+ logsDir = filepath.Join(dataDir, "logs")
+ }
+
+ filePath := filepath.Join(logsDir, fileName)
+
+ // Check if file exists
+ if _, err := os.Stat(filePath); os.IsNotExist(err) {
+ c.String(http.StatusNotFound, "Log file not found")
+ return
+ }
+
+ // Read file contents
+ content, err := ioutil.ReadFile(filePath)
+ if err != nil {
+ c.String(http.StatusInternalServerError, "Error reading log file: "+err.Error())
+ return
+ }
+
+ // Ensure content is large enough to trigger scrollbar (add padding)
+ logContent := string(content)
+
+ // Add padding at the end to ensure scrollbar is visible even for small logs
+ if len(logContent) < 2000 {
+ paddingNeeded := 100 - strings.Count(logContent, "\n")
+ if paddingNeeded > 0 {
+ for i := 0; i < paddingNeeded; i++ {
+ logContent += "\n "
+ }
+ }
+ }
+
+ data := components.AdminToolsData{
+ CurrentLogFile: fileName,
+ LogContent: logContent,
+ }
+
+ // Render the template using the templ package
+ components.AdminLogContent(data).Render(c, c.Writer)
+}
+
+// HandleDownloadLog allows downloading a log file
+func (h *Handlers) HandleDownloadLog(c *gin.Context) {
+ fileName := c.Param("fileName")
+ if fileName == "" {
+ c.String(http.StatusBadRequest, "No file name provided")
+ return
+ }
+
+ // Sanitize the filename to prevent directory traversal
+ fileName = filepath.Base(fileName)
+
+ // Determine logs directory
+ logsDir := os.Getenv("LOGS_DIR")
+ if logsDir == "" {
+ dataDir := os.Getenv("DATA_DIR")
+ if dataDir == "" {
+ dataDir = "./data"
+ }
+ logsDir = filepath.Join(dataDir, "logs")
+ }
+
+ filePath := filepath.Join(logsDir, fileName)
+
+ // Check if file exists
+ if _, err := os.Stat(filePath); os.IsNotExist(err) {
+ c.String(http.StatusNotFound, "Log file not found")
+ return
+ }
+
+ // Set headers for file download
+ c.Header("Content-Description", "File Transfer")
+ c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
+ c.Header("Content-Type", "text/plain")
+ c.File(filePath)
+}
diff --git a/internal/web/handlers/api_handlers.go b/internal/web/handlers/api_handlers.go
index 29a270f..565a05e 100644
--- a/internal/web/handlers/api_handlers.go
+++ b/internal/web/handlers/api_handlers.go
@@ -4,7 +4,6 @@ import (
"fmt"
"net/http"
-
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/internal/db"
"golang.org/x/crypto/bcrypt"
@@ -55,7 +54,7 @@ func (h *Handlers) HandleAPILogin(c *gin.Context) {
// 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)
@@ -66,7 +65,7 @@ func (h *Handlers) HandleAPIConfigs(c *gin.Context) {
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"})
@@ -109,7 +108,7 @@ func (h *Handlers) HandleAPICreateConfig(c *gin.Context) {
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"})
@@ -150,7 +149,7 @@ func (h *Handlers) HandleAPIUpdateConfig(c *gin.Context) {
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"})
@@ -184,38 +183,6 @@ func (h *Handlers) HandleAPIDeleteConfig(c *gin.Context) {
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
@@ -250,7 +217,7 @@ func (h *Handlers) HandleAPIDeleteJob(c *gin.Context) {
func (h *Handlers) HandleAPIRunJob(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"})
@@ -284,7 +251,7 @@ func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
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,
diff --git a/internal/web/handlers/config_handlers.go b/internal/web/handlers/config_handlers.go
index ca1fd2f..4a42a5f 100644
--- a/internal/web/handlers/config_handlers.go
+++ b/internal/web/handlers/config_handlers.go
@@ -13,7 +13,7 @@ import (
// 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)
@@ -36,7 +36,7 @@ func (h *Handlers) HandleNewConfig(c *gin.Context) {
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")
@@ -93,7 +93,7 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
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)
@@ -145,7 +145,7 @@ func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
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"})
@@ -178,44 +178,3 @@ func (h *Handlers) HandleDeleteConfig(c *gin.Context) {
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
index 793cc0b..ffd99d5 100644
--- a/internal/web/handlers/dashboard_handlers.go
+++ b/internal/web/handlers/dashboard_handlers.go
@@ -1,8 +1,11 @@
package handlers
import (
+ "fmt"
+ "math"
"net/http"
- "time"
+ "net/url"
+ "strconv"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
@@ -11,96 +14,161 @@ import (
// 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) {
+// HandleHistory handles the GET /history route
+func (h *Handlers) HandleHistory(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"`
+ // Get pagination parameters
+ page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
+ if err != nil || page < 1 {
+ page = 1
}
- 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)
+ 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
+ }
- var completed int64
- var failed int64
+ // Get search term
+ searchTerm := c.Query("search")
- h.DB.Model(&db.Job{}).
- Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "completed", startOfDay, endOfDay).
- Count(&completed)
+ // 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)
- h.DB.Model(&db.Job{}).
- Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "failed", startOfDay, endOfDay).
- Count(&failed)
+ // Apply search if provided
+ if searchTerm != "" {
+ query = query.Where("transfer_configs.name LIKE ? OR job_histories.status LIKE ?",
+ "%"+searchTerm+"%", "%"+searchTerm+"%")
+ }
- 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,
- })
+ // 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{
- "activeJobs": activeJobCount,
- "completedJobs": completedJobCount,
- "failedJobs": failedJobCount,
- "dailyStats": dailyStats,
- "uptime": time.Since(h.StartTime).String(),
- "uptimeSeconds": int64(time.Since(h.StartTime).Seconds()),
+ "recent_runs": recentRuns,
})
}
-// 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)
+// 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{
- "recentJobs": recentJobs,
+ "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,
})
}
diff --git a/internal/web/handlers/profile_handlers.go b/internal/web/handlers/profile_handlers.go
index 8589b82..f82977a 100644
--- a/internal/web/handlers/profile_handlers.go
+++ b/internal/web/handlers/profile_handlers.go
@@ -1,7 +1,6 @@
package handlers
import (
- "fmt"
"net/http"
"github.com/gin-gonic/gin"
@@ -24,55 +23,34 @@ func (h *Handlers) HandleProfile(c *gin.Context) {
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
index 5c0d086..64bfc62 100644
--- a/internal/web/handlers/routes.go
+++ b/internal/web/handlers/routes.go
@@ -1,151 +1,9 @@
package handlers
import (
- "fmt"
- "math"
- "net/http"
- "net/url"
- "strconv"
-
"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
@@ -171,14 +29,14 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
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.POST("/configs/:id", h.HandleUpdateConfig)
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.POST("/jobs/:id", h.HandleUpdateJob)
authorized.DELETE("/jobs/:id", h.HandleDeleteJob)
authorized.POST("/jobs/:id/run", h.HandleRunJob)
authorized.GET("/history", h.HandleHistory)
@@ -196,10 +54,6 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
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
@@ -225,6 +79,11 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
admin.GET("/download-backup/:filename", h.HandleDownloadBackup)
admin.DELETE("/delete-backup/:filename", h.HandleDeleteBackup)
admin.GET("/refresh-backups", h.HandleRefreshBackups)
+
+ // Log viewer routes
+ admin.GET("/logs/refresh", h.HandleRefreshLogs)
+ admin.GET("/logs/view/:fileName", h.HandleViewLog)
+ admin.GET("/logs/download/:fileName", h.HandleDownloadLog)
}
// API routes
diff --git a/internal/web/handlers/user_handlers.go b/internal/web/handlers/user_handlers.go
index 521a39d..7c9efc5 100644
--- a/internal/web/handlers/user_handlers.go
+++ b/internal/web/handlers/user_handlers.go
@@ -1,8 +1,6 @@
package handlers
import (
- "fmt"
- "log"
"net/http"
"strconv"
"time"
@@ -40,34 +38,34 @@ 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,
+ 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")
}
@@ -78,20 +76,20 @@ func (h *Handlers) HandleDeleteUser(c *gin.Context) {
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")
}
@@ -100,13 +98,13 @@ 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)
}
@@ -115,23 +113,23 @@ 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,
@@ -139,178 +137,21 @@ func (h *Handlers) HandleRegister(c *gin.Context) {
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
deleted file mode 100644
index 87d2cec..0000000
--- a/internal/web/middleware/auth.go
+++ /dev/null
@@ -1,45 +0,0 @@
-
-// 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()
- }
-}