From a8b4588ecb344256bc9be22acd35b75b803cd9cb Mon Sep 17 00:00:00 2001 From: StarFleetCPTN Date: Tue, 11 Mar 2025 20:50:47 -0700 Subject: [PATCH] feat: Implement comprehensive log viewer with advanced features - Add new log viewer component in admin tools - Support dynamic log file browsing and content display - Implement custom scrollbar for log content - Add log file refresh, download, and view capabilities - Update environment variables for log configuration - Enhance logging system with more flexible configuration options --- README.md | 26 +- components/admin_tools.templ | 237 ++++++++++++++++++ docker-compose.yaml | 4 +- internal/auth/password.go | 65 +++-- internal/db/db.go | 32 --- internal/scheduler/scheduler.go | 128 +--------- internal/web/handlers/admin_handlers.go | 13 - internal/web/handlers/admin_tools_handlers.go | 183 +++++++++++++- internal/web/handlers/api_handlers.go | 45 +--- internal/web/handlers/config_handlers.go | 49 +--- internal/web/handlers/dashboard_handlers.go | 182 +++++++++----- internal/web/handlers/profile_handlers.go | 34 +-- internal/web/handlers/routes.go | 155 +----------- internal/web/handlers/user_handlers.go | 197 ++------------- internal/web/middleware/auth.go | 45 ---- 15 files changed, 634 insertions(+), 761 deletions(-) delete mode 100644 internal/web/handlers/admin_handlers.go delete mode 100644 internal/web/middleware/auth.go 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

+
+ if len(data.LogFiles) == 0 { +
+ No log files found +
+ } else { +
+ for _, logFile := range data.LogFiles { + + } +
+ } +
+
+ +
+
+ +
+
+

+ if data.CurrentLogFile != "" { + Log: { data.CurrentLogFile } + } else { + Select a log file + } +

+ if data.CurrentLogFile != "" { +
+ +
+ } +
+ + @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() - } -}