mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-10-12 17:27:28 +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:
1 parent
ed81b2c9be
commit
a8b4588ecb
15 files changed
+634
-761
No files matched your search
@@ -151,12 +151,12 @@ services:
|
|||||||
- EMAIL_USERNAME=smtp_username
|
- EMAIL_USERNAME=smtp_username
|
||||||
- EMAIL_PASSWORD=smtp_password
|
- EMAIL_PASSWORD=smtp_password
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
- GOMFT_LOGS_DIR=/app/data/logs
|
- LOGS_DIR=/app/data/logs
|
||||||
- GOMFT_LOG_MAX_SIZE=10
|
- LOG_MAX_SIZE=10
|
||||||
- GOMFT_LOG_MAX_BACKUPS=5
|
- LOG_MAX_BACKUPS=5
|
||||||
- GOMFT_LOG_MAX_AGE=30
|
- LOG_MAX_AGE=30
|
||||||
- GOMFT_LOG_COMPRESS=true
|
- LOG_COMPRESS=true
|
||||||
- GOMFT_LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, you can mount your own .env file to the container:
|
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 provides configurable logging with rotation support through the following environment variables:
|
||||||
|
|
||||||
- `GOMFT_LOGS_DIR`: Directory where log files are stored (default: `./data/logs`)
|
- `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`)
|
- `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`)
|
- `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`)
|
- `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`)
|
- `LOG_COMPRESS`: Whether to compress rotated log files (default: `true`)
|
||||||
- `GOMFT_LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`)
|
- `LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`)
|
||||||
- `error`: Only show errors and critical issues
|
- `error`: Only show errors and critical issues
|
||||||
- `info`: Show errors and general operational information (default)
|
- `info`: Show errors and general operational information (default)
|
||||||
- `debug`: Show all messages including detailed debugging information
|
- `debug`: Show all messages including detailed debugging information
|
||||||
@@ -442,4 +442,4 @@ volumes:
|
|||||||
- /host/path/backups:/app/backups # For database backups
|
- /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
|
ModTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogFile struct {
|
||||||
|
Name string
|
||||||
|
Size string
|
||||||
|
ModTime time.Time
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
type AdminToolsData struct {
|
type AdminToolsData struct {
|
||||||
JobHistoryCount int
|
JobHistoryCount int
|
||||||
DatabaseSize string
|
DatabaseSize string
|
||||||
@@ -26,6 +33,9 @@ type AdminToolsData struct {
|
|||||||
BackupPath string
|
BackupPath string
|
||||||
MaintenanceMessage string
|
MaintenanceMessage string
|
||||||
BackupFiles []BackupFile
|
BackupFiles []BackupFile
|
||||||
|
LogFiles []LogFile
|
||||||
|
LogContent string
|
||||||
|
CurrentLogFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dialog component for confirmation dialogs
|
// 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) {
|
templ AdminTools(ctx context.Context, data AdminToolsData) {
|
||||||
@LayoutWithContext("Admin Tools", ctx) {
|
@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="py-6">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
@@ -448,6 +516,11 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Viewer -->
|
||||||
|
<div id="logs-container" class="mt-8">
|
||||||
|
@AdminLogViewer(data)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -576,3 +649,167 @@ templ BackupsList(data AdminToolsData) {
|
|||||||
</div>
|
</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
|
- TZ=UTC
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
- BACKUP_DIR=/app/backups
|
- BACKUP_DIR=/app/backups
|
||||||
- GOMFT_LOGS_DIR=/app/data/logs
|
- LOGS_DIR=/app/data/logs
|
||||||
# - GOMFT_LOG_LEVEL=info
|
# - LOG_LEVEL=info
|
||||||
networks:
|
networks:
|
||||||
- gomft-network
|
- gomft-network
|
||||||
|
|
||||||
|
|||||||
+28
-37
@@ -6,46 +6,46 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PasswordPolicy defines the requirements for password strength and management
|
// PasswordPolicy defines the requirements for password strength and management
|
||||||
type PasswordPolicy struct {
|
type PasswordPolicy struct {
|
||||||
MinLength int // Minimum password length
|
MinLength int // Minimum password length
|
||||||
RequireUppercase bool // Require at least one uppercase letter
|
RequireUppercase bool // Require at least one uppercase letter
|
||||||
RequireLowercase bool // Require at least one lowercase letter
|
RequireLowercase bool // Require at least one lowercase letter
|
||||||
RequireNumbers bool // Require at least one number
|
RequireNumbers bool // Require at least one number
|
||||||
RequireSpecial bool // Require at least one special character
|
RequireSpecial bool // Require at least one special character
|
||||||
ExpirationDays int // Number of days until password expires (0 = never)
|
ExpirationDays int // Number of days until password expires (0 = never)
|
||||||
HistoryCount int // Number of previous passwords to remember (0 = disabled)
|
HistoryCount int // Number of previous passwords to remember (0 = disabled)
|
||||||
DisallowCommon bool // Disallow common passwords
|
DisallowCommon bool // Disallow common passwords
|
||||||
MaxLoginAttempts int // Maximum failed login attempts before lockout
|
MaxLoginAttempts int // Maximum failed login attempts before lockout
|
||||||
LockoutDuration time.Duration // Duration of account lockout after max failed attempts
|
LockoutDuration time.Duration // Duration of account lockout after max failed attempts
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasswordHistory represents a historical password entry
|
// PasswordHistory represents a historical password entry
|
||||||
type PasswordHistory struct {
|
type PasswordHistory struct {
|
||||||
ID uint `gorm:"primarykey"`
|
ID uint `gorm:"primarykey"`
|
||||||
UserID uint `gorm:"not null"`
|
UserID uint `gorm:"not null"`
|
||||||
PasswordHash string `gorm:"not null"`
|
PasswordHash string `gorm:"not null"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPasswordPolicy returns the default password policy
|
// DefaultPasswordPolicy returns the default password policy
|
||||||
func DefaultPasswordPolicy() PasswordPolicy {
|
func DefaultPasswordPolicy() PasswordPolicy {
|
||||||
return PasswordPolicy{
|
return PasswordPolicy{
|
||||||
MinLength: 8,
|
MinLength: 8,
|
||||||
RequireUppercase: true,
|
RequireUppercase: true,
|
||||||
RequireLowercase: true,
|
RequireLowercase: true,
|
||||||
RequireNumbers: true,
|
RequireNumbers: true,
|
||||||
RequireSpecial: true,
|
RequireSpecial: true,
|
||||||
ExpirationDays: 90,
|
ExpirationDays: 90,
|
||||||
HistoryCount: 5,
|
HistoryCount: 5,
|
||||||
DisallowCommon: true,
|
DisallowCommon: true,
|
||||||
MaxLoginAttempts: 5,
|
MaxLoginAttempts: 5,
|
||||||
LockoutDuration: 15 * time.Minute,
|
LockoutDuration: 15 * time.Minute,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ func IsPasswordExpired(lastPasswordChange time.Time, policy PasswordPolicy) bool
|
|||||||
if policy.ExpirationDays <= 0 {
|
if policy.ExpirationDays <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
expirationTime := lastPasswordChange.Add(time.Duration(policy.ExpirationDays) * 24 * time.Hour)
|
expirationTime := lastPasswordChange.Add(time.Duration(policy.ExpirationDays) * 24 * time.Hour)
|
||||||
return time.Now().After(expirationTime)
|
return time.Now().After(expirationTime)
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
PasswordHash: hashedPassword,
|
PasswordHash: hashedPassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&passwordHistory).Error; err != nil {
|
if err := db.Create(&passwordHistory).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -151,13 +151,13 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
|
|||||||
// Trim history if needed
|
// Trim history if needed
|
||||||
var count int64
|
var count int64
|
||||||
db.Model(&PasswordHistory{}).Where("user_id = ?", userID).Count(&count)
|
db.Model(&PasswordHistory{}).Where("user_id = ?", userID).Count(&count)
|
||||||
|
|
||||||
if count > int64(policy.HistoryCount) {
|
if count > int64(policy.HistoryCount) {
|
||||||
var oldestHistories []PasswordHistory
|
var oldestHistories []PasswordHistory
|
||||||
if err := db.Where("user_id = ?", userID).Order("created_at asc").Limit(int(count) - policy.HistoryCount).Find(&oldestHistories).Error; err != nil {
|
if err := db.Where("user_id = ?", userID).Order("created_at asc").Limit(int(count) - policy.HistoryCount).Find(&oldestHistories).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, history := range oldestHistories {
|
for _, history := range oldestHistories {
|
||||||
if err := db.Delete(&history).Error; err != nil {
|
if err := db.Delete(&history).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -168,15 +168,6 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
|
|||||||
return nil
|
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
|
// ComparePasswords compares a hashed password with a plain text password
|
||||||
func ComparePasswords(hashedPassword, plainPassword string) error {
|
func ComparePasswords(hashedPassword, plainPassword string) error {
|
||||||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
|
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
|
||||||
|
|||||||
@@ -333,16 +333,6 @@ func (db *DB) CreateFileMetadata(metadata *FileMetadata) error {
|
|||||||
return db.Create(metadata).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
|
// GetFileMetadataByJobAndName retrieves file metadata by job ID and filename
|
||||||
func (db *DB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*FileMetadata, error) {
|
func (db *DB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*FileMetadata, error) {
|
||||||
var metadata FileMetadata
|
var metadata FileMetadata
|
||||||
@@ -363,18 +353,6 @@ func (db *DB) GetFileMetadataByHash(fileHash string) (*FileMetadata, error) {
|
|||||||
return &metadata, nil
|
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
|
// DeleteFileMetadata deletes file metadata by ID
|
||||||
func (db *DB) DeleteFileMetadata(id uint) error {
|
func (db *DB) DeleteFileMetadata(id uint) error {
|
||||||
return db.Delete(&FileMetadata{}, id).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))
|
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 {
|
func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
||||||
configPath := db.GetConfigRclonePath(config)
|
configPath := db.GetConfigRclonePath(config)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package scheduler
|
package scheduler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -102,7 +100,7 @@ func NewLogger() *Logger {
|
|||||||
|
|
||||||
// Ensure logs directory exists
|
// Ensure logs directory exists
|
||||||
logsDir := filepath.Join(dataDir, "logs")
|
logsDir := filepath.Join(dataDir, "logs")
|
||||||
if envLogsDir := os.Getenv("GOMFT_LOGS_DIR"); envLogsDir != "" {
|
if envLogsDir := os.Getenv("LOGS_DIR"); envLogsDir != "" {
|
||||||
logsDir = envLogsDir
|
logsDir = envLogsDir
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,34 +110,34 @@ func NewLogger() *Logger {
|
|||||||
|
|
||||||
// Get log rotation settings from environment or use defaults
|
// Get log rotation settings from environment or use defaults
|
||||||
maxSize := 10 // Default: 10MB
|
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 {
|
if size, err := strconv.Atoi(envSize); err == nil && size > 0 {
|
||||||
maxSize = size
|
maxSize = size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maxBackups := 5 // Default: keep 5 backups
|
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 {
|
if backups, err := strconv.Atoi(envBackups); err == nil && backups >= 0 {
|
||||||
maxBackups = backups
|
maxBackups = backups
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maxAge := 30 // Default: 30 days
|
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 {
|
if age, err := strconv.Atoi(envAge); err == nil && age >= 0 {
|
||||||
maxAge = age
|
maxAge = age
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compress := true // Default: compress logs
|
compress := true // Default: compress logs
|
||||||
if envCompress := os.Getenv("GOMFT_LOG_COMPRESS"); envCompress == "false" {
|
if envCompress := os.Getenv("LOG_COMPRESS"); envCompress == "false" {
|
||||||
compress = false
|
compress = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get log level from environment or use default
|
// Get log level from environment or use default
|
||||||
logLevel := LogLevelInfo // Default to info level
|
logLevel := LogLevelInfo // Default to info level
|
||||||
if envLogLevel := os.Getenv("GOMFT_LOG_LEVEL"); envLogLevel != "" {
|
if envLogLevel := os.Getenv("LOG_LEVEL"); envLogLevel != "" {
|
||||||
logLevel = ParseLogLevel(envLogLevel)
|
logLevel = ParseLogLevel(envLogLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +199,7 @@ func New(database *db.DB) *Scheduler {
|
|||||||
logger := NewLogger()
|
logger := NewLogger()
|
||||||
|
|
||||||
logger.Info.Println("Initializing scheduler")
|
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()
|
c.Start()
|
||||||
|
|
||||||
s := &Scheduler{
|
s := &Scheduler{
|
||||||
@@ -944,39 +942,6 @@ func (s *Scheduler) RunJobNow(jobID uint) error {
|
|||||||
return nil
|
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
|
// 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) {
|
func (s *Scheduler) hasFileBeenProcessed(jobID uint, fileHash string) (bool, *db.FileMetadata, error) {
|
||||||
if fileHash == "" {
|
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)
|
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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -23,6 +24,7 @@ func (h *Handlers) HandleAdminTools(c *gin.Context) {
|
|||||||
SystemUptime: h.getSystemUptime(),
|
SystemUptime: h.getSystemUptime(),
|
||||||
DatabasePath: h.DBPath,
|
DatabasePath: h.DBPath,
|
||||||
BackupPath: h.BackupDir,
|
BackupPath: h.BackupDir,
|
||||||
|
LogFiles: h.getLogFiles(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get database size
|
// Get database size
|
||||||
@@ -295,19 +297,30 @@ func (h *Handlers) HandleRestoreDatabaseByFilename(c *gin.Context) {
|
|||||||
func (h *Handlers) HandleRefreshBackups(c *gin.Context) {
|
func (h *Handlers) HandleRefreshBackups(c *gin.Context) {
|
||||||
// Get list of backup files
|
// Get list of backup files
|
||||||
backupFiles := h.getBackupFiles()
|
backupFiles := h.getBackupFiles()
|
||||||
|
|
||||||
// Create data structure for the template
|
// Create data structure for the template
|
||||||
data := components.AdminToolsData{
|
data := components.AdminToolsData{
|
||||||
BackupFiles: backupFiles,
|
BackupFiles: backupFiles,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get last backup time and backup count
|
// Get last backup time and backup count
|
||||||
data.LastBackupTime, data.BackupCount = h.getBackupInfo()
|
data.LastBackupTime, data.BackupCount = h.getBackupInfo()
|
||||||
|
|
||||||
// Render just the BackupsList component
|
// Render just the BackupsList component
|
||||||
components.BackupsList(data).Render(c, c.Writer)
|
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
|
// Helper functions
|
||||||
|
|
||||||
// getSystemUptime returns the system uptime as a formatted string
|
// getSystemUptime returns the system uptime as a formatted string
|
||||||
@@ -578,3 +591,167 @@ func (h *Handlers) HandleDownloadBackup(c *gin.Context) {
|
|||||||
// Serve the file
|
// Serve the file
|
||||||
c.File(filePath)
|
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"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -55,7 +54,7 @@ func (h *Handlers) HandleAPILogin(c *gin.Context) {
|
|||||||
// HandleAPIConfigs handles the GET /api/configs route
|
// HandleAPIConfigs handles the GET /api/configs route
|
||||||
func (h *Handlers) HandleAPIConfigs(c *gin.Context) {
|
func (h *Handlers) HandleAPIConfigs(c *gin.Context) {
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
var configs []db.TransferConfig
|
var configs []db.TransferConfig
|
||||||
h.DB.Where("created_by = ?", userID).Find(&configs)
|
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) {
|
func (h *Handlers) HandleAPIConfig(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
var config db.TransferConfig
|
var config db.TransferConfig
|
||||||
if err := h.DB.First(&config, id).Error; err != nil {
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
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) {
|
func (h *Handlers) HandleAPIUpdateConfig(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
var config db.TransferConfig
|
var config db.TransferConfig
|
||||||
if err := h.DB.First(&config, id).Error; err != nil {
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
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) {
|
func (h *Handlers) HandleAPIDeleteConfig(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
var config db.TransferConfig
|
var config db.TransferConfig
|
||||||
if err := h.DB.First(&config, id).Error; err != nil {
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
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"})
|
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
|
// HandleAPIJobs handles the API jobs request
|
||||||
func (h *Handlers) HandleAPIJobs(c *gin.Context) {
|
func (h *Handlers) HandleAPIJobs(c *gin.Context) {
|
||||||
// Implementation will be moved from the old handlers.go
|
// 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) {
|
func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
var job db.Job
|
var job db.Job
|
||||||
if err := h.DB.First(&job, id).Error; err != nil {
|
if err := h.DB.First(&job, id).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run job: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Job started successfully",
|
"message": "Job started successfully",
|
||||||
"jobId": job.ID,
|
"jobId": job.ID,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
// HandleConfigs handles the GET /configs route
|
// HandleConfigs handles the GET /configs route
|
||||||
func (h *Handlers) HandleConfigs(c *gin.Context) {
|
func (h *Handlers) HandleConfigs(c *gin.Context) {
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
var configs []db.TransferConfig
|
var configs []db.TransferConfig
|
||||||
h.DB.Where("created_by = ?", userID).Find(&configs)
|
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) {
|
func (h *Handlers) HandleEditConfig(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
var config db.TransferConfig
|
var config db.TransferConfig
|
||||||
if err := h.DB.First(&config, id).Error; err != nil {
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
c.Redirect(http.StatusFound, "/configs")
|
c.Redirect(http.StatusFound, "/configs")
|
||||||
@@ -93,7 +93,7 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
|
|||||||
func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
|
func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
var config db.TransferConfig
|
var config db.TransferConfig
|
||||||
if err := h.DB.First(&config, id).Error; err != nil {
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
log.Printf("Error finding config: %v", err)
|
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) {
|
func (h *Handlers) HandleDeleteConfig(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
var config db.TransferConfig
|
var config db.TransferConfig
|
||||||
if err := h.DB.First(&config, id).Error; err != nil {
|
if err := h.DB.First(&config, id).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
|
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"})
|
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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/starfleetcptn/gomft/components"
|
"github.com/starfleetcptn/gomft/components"
|
||||||
@@ -11,96 +14,161 @@ import (
|
|||||||
|
|
||||||
// HandleDashboard handles the GET /dashboard route
|
// HandleDashboard handles the GET /dashboard route
|
||||||
func (h *Handlers) HandleDashboard(c *gin.Context) {
|
func (h *Handlers) HandleDashboard(c *gin.Context) {
|
||||||
|
|
||||||
// Get recent job history
|
// Get recent job history
|
||||||
var recentHistory []db.JobHistory
|
var recentHistory []db.JobHistory
|
||||||
h.DB.Order("start_time DESC").Limit(5).Find(&recentHistory)
|
h.DB.Order("start_time DESC").Limit(5).Find(&recentHistory)
|
||||||
|
|
||||||
// Get job statistics
|
// Get job statistics
|
||||||
var totalJobs int64
|
var totalJobs int64
|
||||||
h.DB.Model(&db.JobHistory{}).Where("job_histories.status = 'running' AND job_histories.end_time IS NULL").Count(&totalJobs)
|
h.DB.Model(&db.JobHistory{}).Where("job_histories.status = 'running' AND job_histories.end_time IS NULL").Count(&totalJobs)
|
||||||
|
|
||||||
var completedJobs int64
|
var completedJobs int64
|
||||||
h.DB.Model(&db.JobHistory{}).Where("status = ?", "completed").Count(&completedJobs)
|
h.DB.Model(&db.JobHistory{}).Where("status = ?", "completed").Count(&completedJobs)
|
||||||
|
|
||||||
var failedJobs int64
|
var failedJobs int64
|
||||||
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failed").Count(&failedJobs)
|
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failed").Count(&failedJobs)
|
||||||
|
|
||||||
data := components.DashboardData{
|
data := components.DashboardData{
|
||||||
RecentJobs: recentHistory,
|
RecentJobs: recentHistory,
|
||||||
ActiveTransfers: int(totalJobs),
|
ActiveTransfers: int(totalJobs),
|
||||||
CompletedToday: int(completedJobs),
|
CompletedToday: int(completedJobs),
|
||||||
FailedTransfers: int(failedJobs),
|
FailedTransfers: int(failedJobs),
|
||||||
}
|
}
|
||||||
|
|
||||||
components.Dashboard(components.CreateTemplateContext(c), data).Render(c, c.Writer)
|
components.Dashboard(components.CreateTemplateContext(c), data).Render(c, c.Writer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleDashboardStats handles the dashboard stats API request
|
// HandleHistory handles the GET /history route
|
||||||
func (h *Handlers) HandleDashboardStats(c *gin.Context) {
|
func (h *Handlers) HandleHistory(c *gin.Context) {
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
// Get job statistics
|
// Get pagination parameters
|
||||||
var activeJobCount int64
|
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
var completedJobCount int64
|
if err != nil || page < 1 {
|
||||||
var failedJobCount int64
|
page = 1
|
||||||
|
|
||||||
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "running").Count(&activeJobCount)
|
|
||||||
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "completed").Count(&completedJobCount)
|
|
||||||
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "failed").Count(&failedJobCount)
|
|
||||||
|
|
||||||
// Get transfer statistics for the last 7 days
|
|
||||||
var dailyStats []struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Completed int64 `json:"completed"`
|
|
||||||
Failed int64 `json:"failed"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 6; i >= 0; i-- {
|
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||||
date := time.Now().AddDate(0, 0, -i)
|
if err != nil {
|
||||||
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local)
|
pageSize = 10
|
||||||
endOfDay := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, time.Local)
|
}
|
||||||
|
// Limit page size options
|
||||||
|
if pageSize != 10 && pageSize != 25 && pageSize != 50 && pageSize != 100 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
var completed int64
|
// Get search term
|
||||||
var failed int64
|
searchTerm := c.Query("search")
|
||||||
|
|
||||||
h.DB.Model(&db.Job{}).
|
// Build the query
|
||||||
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "completed", startOfDay, endOfDay).
|
query := h.DB.Model(&db.JobHistory{}).
|
||||||
Count(&completed)
|
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{}).
|
// Apply search if provided
|
||||||
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "failed", startOfDay, endOfDay).
|
if searchTerm != "" {
|
||||||
Count(&failed)
|
query = query.Where("transfer_configs.name LIKE ? OR job_histories.status LIKE ?",
|
||||||
|
"%"+searchTerm+"%", "%"+searchTerm+"%")
|
||||||
|
}
|
||||||
|
|
||||||
dailyStats = append(dailyStats, struct {
|
// Count total matching records for pagination
|
||||||
Date string `json:"date"`
|
var total int64
|
||||||
Completed int64 `json:"completed"`
|
query.Count(&total)
|
||||||
Failed int64 `json:"failed"`
|
|
||||||
}{
|
// Calculate total pages
|
||||||
Date: startOfDay.Format("2006-01-02"),
|
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
|
||||||
Completed: completed,
|
if totalPages == 0 {
|
||||||
Failed: failed,
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"activeJobs": activeJobCount,
|
"recent_runs": recentRuns,
|
||||||
"completedJobs": completedJobCount,
|
|
||||||
"failedJobs": failedJobCount,
|
|
||||||
"dailyStats": dailyStats,
|
|
||||||
"uptime": time.Since(h.StartTime).String(),
|
|
||||||
"uptimeSeconds": int64(time.Since(h.StartTime).Seconds()),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleRecentJobs handles the recent jobs API request
|
// HandleDashboardJobsData handles the GET /dashboard/jobs route
|
||||||
func (h *Handlers) HandleRecentJobs(c *gin.Context) {
|
func (h *Handlers) HandleDashboardJobsData(c *gin.Context) {
|
||||||
userID := c.GetUint("userID")
|
// Get active jobs
|
||||||
|
var activeJobs []db.Job
|
||||||
var recentJobs []db.Job
|
if err := h.DB.Where("enabled = ?", true).Find(&activeJobs).Error; err != nil {
|
||||||
h.DB.Where("created_by = ?", userID).Order("created_at DESC").Limit(5).Find(&recentJobs)
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve active jobs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -24,55 +23,34 @@ func (h *Handlers) HandleProfile(c *gin.Context) {
|
|||||||
func (h *Handlers) HandleUpdateTheme(c *gin.Context) {
|
func (h *Handlers) HandleUpdateTheme(c *gin.Context) {
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
theme := c.PostForm("theme")
|
theme := c.PostForm("theme")
|
||||||
|
|
||||||
// Validate theme value
|
// Validate theme value
|
||||||
validThemes := map[string]bool{
|
validThemes := map[string]bool{
|
||||||
"light": true,
|
"light": true,
|
||||||
"dark": true,
|
"dark": true,
|
||||||
"system": true,
|
"system": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !validThemes[theme] {
|
if !validThemes[theme] {
|
||||||
c.Status(http.StatusBadRequest)
|
c.Status(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user theme preference
|
// Update user theme preference
|
||||||
var user db.User
|
var user db.User
|
||||||
if err := h.DB.First(&user, userID).Error; err != nil {
|
if err := h.DB.First(&user, userID).Error; err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
c.Status(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user.Theme = theme
|
user.Theme = theme
|
||||||
if err := h.DB.Save(&user).Error; err != nil {
|
if err := h.DB.Save(&user).Error; err != nil {
|
||||||
c.Status(http.StatusInternalServerError)
|
c.Status(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set theme cookie for client-side theme switching
|
// Set theme cookie for client-side theme switching
|
||||||
c.SetCookie("theme", theme, 60*60*24*365, "/", "", false, false)
|
c.SetCookie("theme", theme, 60*60*24*365, "/", "", false, false)
|
||||||
|
|
||||||
c.Status(http.StatusOK)
|
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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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
|
// RegisterRoutes registers all the routes for the web interface
|
||||||
func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||||
// Public routes
|
// Public routes
|
||||||
@@ -171,14 +29,14 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
|||||||
authorized.GET("/configs/:id", h.HandleEditConfig)
|
authorized.GET("/configs/:id", h.HandleEditConfig)
|
||||||
authorized.POST("/configs", h.HandleCreateConfig)
|
authorized.POST("/configs", h.HandleCreateConfig)
|
||||||
authorized.PUT("/configs/:id", h.HandleUpdateConfig)
|
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.DELETE("/configs/:id", h.HandleDeleteConfig)
|
||||||
authorized.GET("/jobs", h.HandleJobs)
|
authorized.GET("/jobs", h.HandleJobs)
|
||||||
authorized.GET("/jobs/new", h.HandleNewJob)
|
authorized.GET("/jobs/new", h.HandleNewJob)
|
||||||
authorized.GET("/jobs/:id", h.HandleEditJob)
|
authorized.GET("/jobs/:id", h.HandleEditJob)
|
||||||
authorized.POST("/jobs", h.HandleCreateJob)
|
authorized.POST("/jobs", h.HandleCreateJob)
|
||||||
authorized.PUT("/jobs/:id", h.HandleUpdateJob)
|
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.DELETE("/jobs/:id", h.HandleDeleteJob)
|
||||||
authorized.POST("/jobs/:id/run", h.HandleRunJob)
|
authorized.POST("/jobs/:id/run", h.HandleRunJob)
|
||||||
authorized.GET("/history", h.HandleHistory)
|
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/jobs", h.HandleDashboardJobsData)
|
||||||
authorized.GET("/dashboard/history", h.HandleDashboardHistoryData)
|
authorized.GET("/dashboard/history", h.HandleDashboardHistoryData)
|
||||||
|
|
||||||
// Test connection routes
|
|
||||||
authorized.POST("/test-connection", h.HandleTestConnection)
|
|
||||||
authorized.POST("/test-sftp-connection", h.HandleTestSFTPConnection)
|
|
||||||
authorized.POST("/browse-directory", h.HandleBrowseDirectory)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin-only routes
|
// Admin-only routes
|
||||||
@@ -225,6 +79,11 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
|||||||
admin.GET("/download-backup/:filename", h.HandleDownloadBackup)
|
admin.GET("/download-backup/:filename", h.HandleDownloadBackup)
|
||||||
admin.DELETE("/delete-backup/:filename", h.HandleDeleteBackup)
|
admin.DELETE("/delete-backup/:filename", h.HandleDeleteBackup)
|
||||||
admin.GET("/refresh-backups", h.HandleRefreshBackups)
|
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
|
// API routes
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -40,34 +38,34 @@ func (h *Handlers) HandleCreateUser(c *gin.Context) {
|
|||||||
email := c.PostForm("email")
|
email := c.PostForm("email")
|
||||||
password := c.PostForm("password")
|
password := c.PostForm("password")
|
||||||
isAdmin := c.PostForm("is_admin") == "on"
|
isAdmin := c.PostForm("is_admin") == "on"
|
||||||
|
|
||||||
// Check if email already exists
|
// Check if email already exists
|
||||||
var existingUser db.User
|
var existingUser db.User
|
||||||
if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil {
|
if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil {
|
||||||
c.String(http.StatusBadRequest, "Email already exists")
|
c.String(http.StatusBadRequest, "Email already exists")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash the password
|
// Hash the password
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "Failed to hash password")
|
c.String(http.StatusInternalServerError, "Failed to hash password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the user
|
// Create the user
|
||||||
user := db.User{
|
user := db.User{
|
||||||
Email: email,
|
Email: email,
|
||||||
PasswordHash: string(hashedPassword),
|
PasswordHash: string(hashedPassword),
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
LastPasswordChange: time.Now(),
|
LastPasswordChange: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.DB.Create(&user).Error; err != nil {
|
if err := h.DB.Create(&user).Error; err != nil {
|
||||||
c.String(http.StatusInternalServerError, "Failed to create user")
|
c.String(http.StatusInternalServerError, "Failed to create user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Redirect(http.StatusSeeOther, "/admin/users")
|
c.Redirect(http.StatusSeeOther, "/admin/users")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,20 +76,20 @@ func (h *Handlers) HandleDeleteUser(c *gin.Context) {
|
|||||||
c.String(http.StatusBadRequest, "Invalid user ID")
|
c.String(http.StatusBadRequest, "Invalid user ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't allow deleting the current user
|
// Don't allow deleting the current user
|
||||||
currentUserID := c.GetUint("userID")
|
currentUserID := c.GetUint("userID")
|
||||||
if uint(userID) == currentUserID {
|
if uint(userID) == currentUserID {
|
||||||
c.String(http.StatusBadRequest, "Cannot delete your own account")
|
c.String(http.StatusBadRequest, "Cannot delete your own account")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the user
|
// Delete the user
|
||||||
if err := h.DB.Delete(&db.User{}, userID).Error; err != nil {
|
if err := h.DB.Delete(&db.User{}, userID).Error; err != nil {
|
||||||
c.String(http.StatusInternalServerError, "Failed to delete user")
|
c.String(http.StatusInternalServerError, "Failed to delete user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Redirect(http.StatusSeeOther, "/admin/users")
|
c.Redirect(http.StatusSeeOther, "/admin/users")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,13 +98,13 @@ func (h *Handlers) HandleRegisterPage(c *gin.Context) {
|
|||||||
// Check if any users exist
|
// Check if any users exist
|
||||||
var count int64
|
var count int64
|
||||||
h.DB.Model(&db.User{}).Count(&count)
|
h.DB.Model(&db.User{}).Count(&count)
|
||||||
|
|
||||||
// If users exist, don't allow registration
|
// If users exist, don't allow registration
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
c.Redirect(http.StatusSeeOther, "/")
|
c.Redirect(http.StatusSeeOther, "/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
components.Register(c.Request.Context(), "").Render(c, c.Writer)
|
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
|
// Check if any users exist
|
||||||
var count int64
|
var count int64
|
||||||
h.DB.Model(&db.User{}).Count(&count)
|
h.DB.Model(&db.User{}).Count(&count)
|
||||||
|
|
||||||
// If users exist, don't allow registration
|
// If users exist, don't allow registration
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
c.Redirect(http.StatusSeeOther, "/")
|
c.Redirect(http.StatusSeeOther, "/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email := c.PostForm("email")
|
email := c.PostForm("email")
|
||||||
password := c.PostForm("password")
|
password := c.PostForm("password")
|
||||||
|
|
||||||
// Hash the password
|
// Hash the password
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "Failed to hash password")
|
c.String(http.StatusInternalServerError, "Failed to hash password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the admin user
|
// Create the admin user
|
||||||
user := db.User{
|
user := db.User{
|
||||||
Email: email,
|
Email: email,
|
||||||
@@ -139,178 +137,21 @@ func (h *Handlers) HandleRegister(c *gin.Context) {
|
|||||||
IsAdmin: true,
|
IsAdmin: true,
|
||||||
LastPasswordChange: time.Now(),
|
LastPasswordChange: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.DB.Create(&user).Error; err != nil {
|
if err := h.DB.Create(&user).Error; err != nil {
|
||||||
c.String(http.StatusInternalServerError, "Failed to create user")
|
c.String(http.StatusInternalServerError, "Failed to create user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT
|
// Generate JWT
|
||||||
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
|
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "Failed to generate token")
|
c.String(http.StatusInternalServerError, "Failed to generate token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cookie
|
// Set cookie
|
||||||
c.SetCookie("jwt", token, 60*60*24, "/", "", false, true)
|
c.SetCookie("jwt", token, 60*60*24, "/", "", false, true)
|
||||||
|
|
||||||
c.Redirect(http.StatusSeeOther, "/dashboard")
|
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