mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 15:41:20 +02:00
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
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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) {
|
||||
<style>
|
||||
/* Custom scrollbar styles - more aggressive */
|
||||
.log-scrollbar {
|
||||
scrollbar-width: thin !important; /* Firefox */
|
||||
scrollbar-color: rgba(0,0,0,0.3) rgba(0,0,0,0.1) !important; /* Firefox */
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
.dark .log-scrollbar {
|
||||
scrollbar-color: rgba(255,255,255,0.3) rgba(255,255,255,0.1) !important; /* Firefox */
|
||||
}
|
||||
|
||||
.log-scrollbar::-webkit-scrollbar {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.log-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(0,0,0,0.1) !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.log-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.3) !important;
|
||||
border-radius: 4px !important;
|
||||
border: 2px solid transparent !important;
|
||||
background-clip: content-box !important;
|
||||
}
|
||||
|
||||
.log-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.5) !important;
|
||||
border: 2px solid transparent !important;
|
||||
background-clip: content-box !important;
|
||||
}
|
||||
|
||||
.dark .log-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255,255,255,0.1) !important;
|
||||
}
|
||||
|
||||
.dark .log-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.3) !important;
|
||||
border: 2px solid transparent !important;
|
||||
background-clip: content-box !important;
|
||||
}
|
||||
|
||||
.dark .log-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.5) !important;
|
||||
border: 2px solid transparent !important;
|
||||
background-clip: content-box !important;
|
||||
}
|
||||
|
||||
/* Force scrollbar to appear */
|
||||
.force-scroll {
|
||||
overflow-y: scroll !important;
|
||||
min-height: 100px !important;
|
||||
}
|
||||
</style>
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
@@ -448,6 +516,11 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Viewer -->
|
||||
<div id="logs-container" class="mt-8">
|
||||
@AdminLogViewer(data)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -576,3 +649,167 @@ templ BackupsList(data AdminToolsData) {
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Add this new template after other admin tool templates
|
||||
templ AdminLogViewer(data AdminToolsData) {
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-secondary-900 dark:text-secondary-100 flex items-center">
|
||||
<i class="fas fa-file-alt mr-2"></i> Log Files
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div class="lg:col-span-1 border-r border-secondary-200 dark:border-secondary-700 pr-4">
|
||||
<h4 class="text-lg font-medium mb-2 text-secondary-900 dark:text-secondary-100">Available Logs</h4>
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto pr-2 log-scrollbar">
|
||||
if len(data.LogFiles) == 0 {
|
||||
<div class="text-secondary-600 dark:text-secondary-400 italic">
|
||||
No log files found
|
||||
</div>
|
||||
} else {
|
||||
<div class="flex flex-col space-y-1">
|
||||
for _, logFile := range data.LogFiles {
|
||||
<button
|
||||
class={
|
||||
"text-left px-3 py-2 rounded transition-colors flex justify-between items-center",
|
||||
templ.KV("bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400", logFile.Name == data.CurrentLogFile),
|
||||
templ.KV("hover:bg-secondary-50 dark:hover:bg-secondary-700/50 text-secondary-700 dark:text-secondary-300", logFile.Name != data.CurrentLogFile)
|
||||
}
|
||||
hx-get={ fmt.Sprintf("/admin/logs/view/%s", logFile.Name) }
|
||||
hx-target="#log-content"
|
||||
hx-indicator="#log-loading"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-file-alt mr-2"></i>
|
||||
{ logFile.Name }
|
||||
</span>
|
||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">{ logFile.Size }</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-4 flex justify-between">
|
||||
<button
|
||||
class="btn-secondary btn-sm"
|
||||
hx-get="/admin/logs/refresh"
|
||||
hx-target="#logs-container"
|
||||
hx-indicator="#refresh-logs-indicator"
|
||||
>
|
||||
<span id="refresh-logs-indicator" class="htmx-indicator">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-3 pl-0 lg:pl-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h4 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
if data.CurrentLogFile != "" {
|
||||
Log: { data.CurrentLogFile }
|
||||
} else {
|
||||
Select a log file
|
||||
}
|
||||
</h4>
|
||||
if data.CurrentLogFile != "" {
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
class="btn-secondary btn-sm"
|
||||
hx-get={ fmt.Sprintf("/admin/logs/download/%s", data.CurrentLogFile) }
|
||||
>
|
||||
<i class="fas fa-download mr-1"></i> Download
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@AdminLogContent(data)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// AdminLogContent template for log view
|
||||
templ AdminLogContent(data AdminToolsData) {
|
||||
<div id="log-content" class="relative">
|
||||
<div id="log-loading" class="htmx-indicator absolute inset-0 bg-white/75 dark:bg-secondary-800/75 flex items-center justify-center">
|
||||
<i class="fas fa-spinner fa-spin text-primary-600 text-2xl"></i>
|
||||
</div>
|
||||
if data.CurrentLogFile == "" {
|
||||
<div class="border border-secondary-200 dark:border-secondary-700 rounded p-4 text-secondary-600 dark:text-secondary-400 bg-secondary-50 dark:bg-secondary-900/30 text-center h-96 flex items-center justify-center">
|
||||
<div>
|
||||
<i class="fas fa-file-alt text-4xl mb-2"></i>
|
||||
<p>Select a log file to view its contents</p>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Fixed height log content container with guaranteed scrollbars -->
|
||||
<div class="log-content-container" style="height: 400px; border: 1px solid #ccc; border-radius: 0.375rem; position: relative;">
|
||||
<!-- Standard scrollable div -->
|
||||
<div id="log-content-text" class="p-4 h-full overflow-y-scroll bg-secondary-50 dark:bg-secondary-900/30 text-secondary-800 dark:text-secondary-200 text-sm font-mono whitespace-pre-wrap" style="scrollbar-width: thin;">
|
||||
{ data.LogContent }
|
||||
</div>
|
||||
|
||||
<!-- Custom scrollbar -->
|
||||
<div class="custom-scrollbar dark:bg-white dark:bg-opacity-10" style="position: absolute; right: 0; top: 0; width: 12px; height: 100%; background-color: rgba(0,0,0,0.05); border-radius: 0 0.375rem 0.375rem 0;">
|
||||
<div class="scrollbar-thumb dark:bg-opacity-30 dark:bg-white" style="position: absolute; right: 0; width: 12px; background-color: rgba(0,0,0,0.3); border-radius: 6px; cursor: pointer; min-height: 40px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Custom scrollbar implementation
|
||||
(function() {
|
||||
const content = document.getElementById('log-content-text');
|
||||
const scrollThumb = document.querySelector('.scrollbar-thumb');
|
||||
|
||||
// Initial position
|
||||
updateScrollThumb();
|
||||
|
||||
// Update scrollbar position when content is scrolled
|
||||
content.addEventListener('scroll', updateScrollThumb);
|
||||
|
||||
function updateScrollThumb() {
|
||||
const scrollPercentage = content.scrollTop / (content.scrollHeight - content.clientHeight);
|
||||
const thumbHeight = Math.max(40, (content.clientHeight / content.scrollHeight) * content.clientHeight);
|
||||
const thumbTop = scrollPercentage * (content.clientHeight - thumbHeight);
|
||||
|
||||
scrollThumb.style.height = thumbHeight + 'px';
|
||||
scrollThumb.style.top = thumbTop + 'px';
|
||||
}
|
||||
|
||||
// Dragging the scrollbar
|
||||
let isDragging = false;
|
||||
let startY, startTop;
|
||||
|
||||
scrollThumb.addEventListener('mousedown', function(e) {
|
||||
isDragging = true;
|
||||
startY = e.clientY;
|
||||
startTop = parseInt(scrollThumb.style.top) || 0;
|
||||
document.body.style.userSelect = 'none'; // Prevent text selection during drag
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!isDragging) return;
|
||||
|
||||
const deltaY = e.clientY - startY;
|
||||
const newTop = Math.max(0, Math.min(content.clientHeight - scrollThumb.offsetHeight, startTop + deltaY));
|
||||
scrollThumb.style.top = newTop + 'px';
|
||||
|
||||
// Update scroll position
|
||||
const scrollPercentage = newTop / (content.clientHeight - scrollThumb.offsetHeight);
|
||||
content.scrollTop = scrollPercentage * (content.scrollHeight - content.clientHeight);
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function() {
|
||||
isDragging = false;
|
||||
document.body.style.userSelect = '';
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom
|
||||
content.scrollTop = content.scrollHeight;
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+28
-37
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user