initial commit

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

No files matched your search

+735
View File
@@ -0,0 +1,735 @@
package api
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/internal/auth"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/scheduler"
"golang.org/x/crypto/bcrypt"
)
type RegisterRequest struct {
Email string `json:"email" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=8"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
type LoginResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
}
type UserResponse struct {
ID uint `json:"id"`
Email string `json:"email"`
}
func InitializeRoutes(router *gin.Engine, database *db.DB, scheduler *scheduler.Scheduler, jwtSecret string) {
api := router.Group("/api")
// Auth routes
auth := api.Group("/auth")
{
auth.POST("/register", handleRegister(database))
auth.POST("/login", handleLogin(database, jwtSecret))
auth.POST("/logout", handleLogout())
}
// Protected routes
protected := api.Group("")
protected.Use(authMiddleware(database, jwtSecret))
{
// Transfer config routes
protected.GET("/configs", handleListConfigs(database))
protected.POST("/configs", handleCreateConfig(database))
protected.GET("/configs/:id", handleGetConfig(database))
protected.PUT("/configs/:id", handleUpdateConfig(database))
protected.DELETE("/configs/:id", handleDeleteConfig(database))
// Job routes
protected.GET("/jobs", handleListJobs(database))
protected.POST("/jobs", handleCreateJob(database, scheduler))
protected.GET("/jobs/:id", handleGetJob(database))
protected.PUT("/jobs/:id", handleUpdateJob(database, scheduler))
protected.DELETE("/jobs/:id", handleDeleteJob(database, scheduler))
protected.POST("/jobs/:id/run", handleRunJob(database, scheduler))
protected.POST("/jobs/:id/enable", handleEnableJob(database, scheduler))
protected.POST("/jobs/:id/disable", handleDisableJob(database, scheduler))
// History routes
protected.GET("/jobs/:id/history", handleGetJobHistory(database))
protected.GET("/history", handleListHistory(database))
}
}
func handleRegister(database *db.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if email already exists
if _, err := database.GetUserByEmail(req.Email); err == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
return
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// Create user
user := &db.User{
Email: req.Email,
PasswordHash: string(hashedPassword),
}
if err := database.CreateUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "User registered successfully"})
}
}
func handleLogin(database *db.DB, jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := database.GetUserByEmail(req.Email)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// Generate JWT token
token, err := auth.GenerateToken(user.ID, user.Email, jwtSecret, 24*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, LoginResponse{
Token: token,
User: UserResponse{
ID: user.ID,
Email: user.Email,
},
})
}
}
func handleLogout() gin.HandlerFunc {
return func(c *gin.Context) {
// JWT tokens are stateless, so we don't need to do anything server-side
// The client should discard the token
c.JSON(http.StatusOK, gin.H{"message": "Logout successful"})
}
}
func authMiddleware(database *db.DB, jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
// Get Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
c.Abort()
return
}
// Check if the header has the Bearer prefix
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header must be in the format 'Bearer {token}'"})
c.Abort()
return
}
// Validate token
tokenString := parts[1]
claims, err := auth.ValidateToken(tokenString, jwtSecret)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
c.Abort()
return
}
// Set user ID in context
c.Set("userID", claims.UserID)
c.Set("email", claims.Email)
c.Next()
}
}
func handleListConfigs(database *db.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from context
userID := c.GetUint("userID")
configs, err := database.GetTransferConfigs(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch configs"})
return
}
c.JSON(http.StatusOK, configs)
}
}
func handleCreateConfig(database *db.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Set user ID
config.CreatedBy = c.GetUint("userID")
if err := database.CreateTransferConfig(&config); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create config"})
return
}
c.JSON(http.StatusCreated, config)
}
}
func handleGetConfig(database *db.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing config ID"})
return
}
var configID uint
if _, err := fmt.Sscanf(id, "%d", &configID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config ID"})
return
}
config, err := database.GetTransferConfig(configID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user has access to this config
if config.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
c.JSON(http.StatusOK, config)
}
}
func handleUpdateConfig(database *db.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing config ID"})
return
}
var configID uint
if _, err := fmt.Sscanf(id, "%d", &configID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config ID"})
return
}
// Get existing config
existingConfig, err := database.GetTransferConfig(configID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user has access to this config
if existingConfig.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
// Bind updated fields
var updatedConfig db.TransferConfig
if err := c.ShouldBindJSON(&updatedConfig); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update fields but preserve ID and CreatedBy
updatedConfig.ID = existingConfig.ID
updatedConfig.CreatedBy = existingConfig.CreatedBy
updatedConfig.CreatedAt = existingConfig.CreatedAt
if err := database.UpdateTransferConfig(&updatedConfig); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update config"})
return
}
c.JSON(http.StatusOK, updatedConfig)
}
}
func handleDeleteConfig(database *db.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing config ID"})
return
}
var configID uint
if _, err := fmt.Sscanf(id, "%d", &configID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config ID"})
return
}
// Get existing config to check ownership
config, err := database.GetTransferConfig(configID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user has access to this config
if config.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
if err := database.DeleteTransferConfig(configID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
}
}
func handleListJobs(database *db.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from context
userID := c.GetUint("userID")
jobs, err := database.GetJobs(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch jobs"})
return
}
c.JSON(http.StatusOK, jobs)
}
}
func handleCreateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
return func(c *gin.Context) {
var job db.Job
if err := c.ShouldBindJSON(&job); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Set user ID
job.CreatedBy = c.GetUint("userID")
// Validate config exists and user has access
_, err := database.GetTransferConfig(job.ConfigID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config ID"})
return
}
// Check if user has access to this config
config, err := database.GetTransferConfig(job.ConfigID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
if config.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
if err := database.CreateJob(&job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create job"})
return
}
// Schedule the job if enabled
if job.Enabled {
if err := scheduler.ScheduleJob(&job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to schedule job"})
return
}
}
c.JSON(http.StatusCreated, job)
}
}
func handleGetJob(database *db.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
return
}
var jobID uint
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
return
}
job, err := database.GetJob(jobID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user has access to this job
if job.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
c.JSON(http.StatusOK, job)
}
}
func handleUpdateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
return
}
var jobID uint
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
return
}
// Get existing job
existingJob, err := database.GetJob(jobID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user has access to this job
if existingJob.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
// Bind updated fields
var updatedJob db.Job
if err := c.ShouldBindJSON(&updatedJob); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update fields but preserve ID and CreatedBy
updatedJob.ID = existingJob.ID
updatedJob.CreatedBy = existingJob.CreatedBy
updatedJob.CreatedAt = existingJob.CreatedAt
// Validate config exists and user has access
if updatedJob.ConfigID != existingJob.ConfigID {
_, err := database.GetTransferConfig(updatedJob.ConfigID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config ID"})
return
}
// Check if user has access to this config
config, err := database.GetTransferConfig(updatedJob.ConfigID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
if config.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
}
// Check if schedule or enabled status changed
scheduleChanged := updatedJob.Schedule != existingJob.Schedule || updatedJob.Enabled != existingJob.Enabled
if err := database.UpdateJob(&updatedJob); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
return
}
// Update the scheduler if needed
if scheduleChanged {
if updatedJob.Enabled {
if err := scheduler.ScheduleJob(&updatedJob); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job schedule"})
return
}
} else {
scheduler.UnscheduleJob(updatedJob.ID)
}
}
c.JSON(http.StatusOK, updatedJob)
}
}
func handleDeleteJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
return
}
var jobID uint
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
return
}
// Get existing job to check ownership
job, err := database.GetJob(jobID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user has access to this job
if job.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
// Remove from scheduler first
scheduler.UnscheduleJob(jobID)
if err := database.DeleteJob(jobID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete job"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Job deleted successfully"})
}
}
func handleRunJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
return
}
var jobID uint
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
return
}
// Get existing job
job, err := database.GetJob(jobID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user has access to this job
if job.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
// Run the job immediately
if err := scheduler.RunJobNow(jobID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run job: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Job started successfully"})
}
}
func handleEnableJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
return
}
var jobID uint
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
return
}
// Get existing job
job, err := database.GetJob(jobID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user has access to this job
if job.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
// Update job status
job.Enabled = true
if err := database.UpdateJob(job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
return
}
// Add to scheduler
if err := scheduler.ScheduleJob(job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to schedule job"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Job enabled successfully"})
}
}
func handleDisableJob(database *db.DB, scheduler *scheduler.Scheduler) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
return
}
var jobID uint
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
return
}
// Get existing job
job, err := database.GetJob(jobID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user has access to this job
if job.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
// Update job status
job.Enabled = false
if err := database.UpdateJob(job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
return
}
// Remove from scheduler
scheduler.UnscheduleJob(jobID)
c.JSON(http.StatusOK, gin.H{"message": "Job disabled successfully"})
}
}
func handleGetJobHistory(database *db.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing job ID"})
return
}
var jobID uint
if _, err := fmt.Sscanf(id, "%d", &jobID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
return
}
// Get existing job to check ownership
_, err := database.GetJob(jobID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user has access to this job
job, err := database.GetJob(jobID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
if job.CreatedBy != c.GetUint("userID") {
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
return
}
history, err := database.GetJobHistory(jobID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch job history"})
return
}
c.JSON(http.StatusOK, history)
}
}
func handleListHistory(database *db.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from context
userID := c.GetUint("userID")
// TODO: Implement pagination
// For now, just return the most recent 100 history entries for the user's jobs
var history []db.JobHistory
err := database.DB.
Joins("JOIN jobs ON job_histories.job_id = jobs.id").
Where("jobs.created_by = ?", userID).
Order("start_time DESC").
Limit(100).
Find(&history).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"})
return
}
c.JSON(http.StatusOK, history)
}
}
+66
View File
@@ -0,0 +1,66 @@
package auth
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims represents the JWT claims
type Claims struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
// GenerateToken creates a new JWT token for a user
func GenerateToken(userID uint, email, secret string, expirationTime time.Duration) (string, error) {
// Create claims with user ID and expiration time
claims := &Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expirationTime)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "gomft",
Subject: fmt.Sprintf("%d", userID),
},
}
// Create token with claims
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign token with secret
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
return "", err
}
return tokenString, nil
}
// ValidateToken validates a JWT token and returns the claims
func ValidateToken(tokenString, secret string) (*Claims, error) {
// Parse token
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
// Extract claims
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
+204
View File
@@ -0,0 +1,204 @@
package auth
import (
"errors"
"fmt"
"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
}
// PasswordHistory represents a historical password entry
type PasswordHistory struct {
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,
}
}
// ValidatePassword checks if a password meets the policy requirements
func ValidatePassword(password string, policy PasswordPolicy) error {
// Check minimum length
if len(password) < policy.MinLength {
return fmt.Errorf("password must be at least %d characters long", policy.MinLength)
}
// Check for uppercase letters
if policy.RequireUppercase {
match, _ := regexp.MatchString("[A-Z]", password)
if !match {
return errors.New("password must contain at least one uppercase letter")
}
}
// Check for lowercase letters
if policy.RequireLowercase {
match, _ := regexp.MatchString("[a-z]", password)
if !match {
return errors.New("password must contain at least one lowercase letter")
}
}
// Check for numbers
if policy.RequireNumbers {
match, _ := regexp.MatchString("[0-9]", password)
if !match {
return errors.New("password must contain at least one number")
}
}
// Check for special characters
if policy.RequireSpecial {
match, _ := regexp.MatchString("[^a-zA-Z0-9]", password)
if !match {
return errors.New("password must contain at least one special character")
}
}
// Check for common passwords
if policy.DisallowCommon && isCommonPassword(password) {
return errors.New("password is too common or easily guessable")
}
return nil
}
// CheckPasswordHistory verifies the password against the user's password history
func CheckPasswordHistory(userID uint, newPassword string, hashedPassword string, db *gorm.DB, policy PasswordPolicy) error {
if policy.HistoryCount <= 0 {
return nil
}
var passwordHistories []PasswordHistory
if err := db.Where("user_id = ?", userID).Order("created_at desc").Limit(policy.HistoryCount).Find(&passwordHistories).Error; err != nil {
return err
}
// Check current password
if ComparePasswords(hashedPassword, newPassword) == nil {
return errors.New("new password cannot be the same as your current password")
}
// Check password history
for _, history := range passwordHistories {
if ComparePasswords(history.PasswordHash, newPassword) == nil {
return fmt.Errorf("password was used in the last %d passwords", policy.HistoryCount)
}
}
return nil
}
// IsPasswordExpired checks if the user's password has expired
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)
}
// UpdatePasswordHistory adds the new password to the user's password history
func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, policy PasswordPolicy) error {
if policy.HistoryCount <= 0 {
return nil
}
// Add new password to history
passwordHistory := PasswordHistory{
UserID: userID,
PasswordHash: hashedPassword,
}
if err := db.Create(&passwordHistory).Error; err != nil {
return err
}
// 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
}
}
}
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))
}
// isCommonPassword checks if a password is in the list of common passwords
func isCommonPassword(password string) bool {
commonPasswords := []string{
"password", "123456", "12345678", "qwerty", "abc123", "monkey",
"1234567", "letmein", "trustno1", "dragon", "baseball", "111111",
"iloveyou", "master", "sunshine", "ashley", "bailey", "passw0rd",
"shadow", "123123", "654321", "superman", "qazwsx", "michael",
"football", "welcome", "jesus", "ninja", "mustang", "password1",
"admin", "admin123", "root", "toor", "qwerty123", "123qwe",
}
lowercasePassword := strings.ToLower(password)
for _, common := range commonPasswords {
if lowercasePassword == common {
return true
}
}
return false
}
+87
View File
@@ -0,0 +1,87 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
)
type Config struct {
ServerAddress string `json:"server_address"`
DataDir string `json:"data_dir"`
BackupDir string `json:"backup_dir"`
JWTSecret string `json:"jwt_secret"`
Email EmailConfig `json:"email"`
BaseURL string `json:"base_url"` // Base URL for generating links in emails
}
type EmailConfig struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
FromEmail string `json:"from_email"`
FromName string `json:"from_name"`
ReplyTo string `json:"reply_to,omitempty"`
EnableTLS bool `json:"enable_tls"`
RequireAuth bool `json:"require_auth"`
}
func Load() (*Config, error) {
// Default configuration
cfg := &Config{
ServerAddress: ":8080",
DataDir: filepath.Join("./data", "gomft"),
BackupDir: filepath.Join("./data", "gomft", "backups"),
JWTSecret: "change_this_to_a_secure_random_string",
BaseURL: "http://localhost:8080",
Email: EmailConfig{
Enabled: false,
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "your-password",
FromEmail: "gomft@example.com",
FromName: "GoMFT",
EnableTLS: true,
RequireAuth: true,
},
}
// Check if config file exists
configPath := filepath.Join(cfg.DataDir, "config.json")
if _, err := os.Stat(configPath); err == nil {
// Read configuration file
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
// Parse configuration
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
// Ensure data directory exists
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
return nil, err
}
// Save configuration if it doesn't exist
if _, err := os.Stat(configPath); os.IsNotExist(err) {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return nil, err
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
return nil, err
}
}
return cfg, nil
}
+567
View File
@@ -0,0 +1,567 @@
package db
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/starfleetcptn/gomft/internal/auth"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primarykey"`
Email string `gorm:"unique;not null"`
PasswordHash string `gorm:"not null"`
IsAdmin bool `gorm:"default:false"`
LastPasswordChange time.Time
FailedLoginAttempts int `gorm:"default:0"`
AccountLocked bool `gorm:"default:false"`
LockoutUntil *time.Time
Theme string `gorm:"default:'light'"`
CreatedAt time.Time
UpdatedAt time.Time
}
type PasswordHistory struct {
ID uint `gorm:"primarykey"`
UserID uint `gorm:"not null"`
User User `gorm:"foreignkey:UserID"`
PasswordHash string `gorm:"not null"`
CreatedAt time.Time
}
type PasswordResetToken struct {
ID uint `gorm:"primarykey"`
UserID uint `gorm:"not null"`
User User `gorm:"foreignkey:UserID"`
Token string `gorm:"not null"`
ExpiresAt time.Time `gorm:"not null"`
Used bool `gorm:"default:false"`
CreatedAt time.Time
UpdatedAt time.Time
}
type TransferConfig struct {
ID uint `gorm:"primarykey"`
Name string `gorm:"not null" form:"name"`
SourceType string `gorm:"not null" form:"source_type"`
SourcePath string `gorm:"not null" form:"source_path"`
SourceHost string `form:"source_host"`
SourcePort int `gorm:"default:22" form:"source_port"`
SourceUser string `form:"source_user"`
SourcePassword string `form:"source_password" gorm:"-"` // Not stored in DB, only used for form
SourceKeyFile string `form:"source_key_file"`
// S3 source fields
SourceBucket string `form:"source_bucket"`
SourceRegion string `form:"source_region"`
SourceAccessKey string `form:"source_access_key"`
SourceSecretKey string `form:"source_secret_key" gorm:"-"` // Not stored in DB, only used for form
SourceEndpoint string `form:"source_endpoint"`
// SMB source fields
SourceShare string `form:"source_share"`
SourceDomain string `form:"source_domain"`
// FTP source fields
SourcePassiveMode bool `gorm:"default:true" form:"source_passive_mode"`
// General fields
FilePattern string `gorm:"default:'*'" form:"file_pattern"`
OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables
DestinationType string `gorm:"not null" form:"destination_type"`
DestinationPath string `gorm:"not null" form:"destination_path"`
DestHost string `form:"dest_host"`
DestPort int `gorm:"default:22" form:"dest_port"`
DestUser string `form:"dest_user"`
DestPassword string `form:"dest_password" gorm:"-"` // Not stored in DB, only used for form
DestKeyFile string `form:"dest_key_file"`
// S3 destination fields
DestBucket string `form:"dest_bucket"`
DestRegion string `form:"dest_region"`
DestAccessKey string `form:"dest_access_key"`
DestSecretKey string `form:"dest_secret_key" gorm:"-"` // Not stored in DB, only used for form
DestEndpoint string `form:"dest_endpoint"`
// SMB destination fields
DestShare string `form:"dest_share"`
DestDomain string `form:"dest_domain"`
// FTP destination fields
DestPassiveMode bool `gorm:"default:true" form:"dest_passive_mode"`
// General fields
ArchivePath string `form:"archive_path"`
ArchiveEnabled bool `gorm:"default:false" form:"archive_enabled"`
RcloneFlags string `form:"rclone_flags"`
CreatedBy uint
User User `gorm:"foreignkey:CreatedBy"`
CreatedAt time.Time
UpdatedAt time.Time
}
type Job struct {
ID uint `gorm:"primarykey"`
Name string `form:"name"`
ConfigID uint `gorm:"not null" form:"config_id"`
Config TransferConfig `gorm:"foreignkey:ConfigID"`
Schedule string `gorm:"not null" form:"schedule"`
Enabled bool `gorm:"default:true" form:"enabled"`
LastRun *time.Time
NextRun *time.Time
CreatedBy uint
User User `gorm:"foreignkey:CreatedBy"`
CreatedAt time.Time
UpdatedAt time.Time
}
type JobHistory struct {
ID uint `gorm:"primarykey"`
JobID uint `gorm:"not null"`
Job Job `gorm:"foreignkey:JobID"`
StartTime time.Time `gorm:"not null"`
EndTime *time.Time
Status string `gorm:"not null"`
BytesTransferred int64
FilesTransferred int
ErrorMessage string
}
type DB struct {
*gorm.DB
}
func Initialize(dbPath string) (*DB, error) {
// Create directory if it doesn't exist
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create database directory: %v", err)
}
// Open database connection
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %v", err)
}
// Auto migrate the schema
err = db.AutoMigrate(&User{}, &auth.PasswordHistory{}, &PasswordResetToken{}, &TransferConfig{}, &Job{}, &JobHistory{})
if err != nil {
return nil, fmt.Errorf("failed to migrate database: %v", err)
}
return &DB{DB: db}, nil
}
func (db *DB) Close() error {
sqlDB, err := db.DB.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
// User operations
func (db *DB) CreateUser(user *User) error {
return db.Create(user).Error
}
func (db *DB) GetUserByEmail(email string) (*User, error) {
var user User
err := db.Where("email = ?", email).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) GetUserByID(id uint) (*User, error) {
var user User
err := db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) UpdateUser(user *User) error {
return db.Save(user).Error
}
// PasswordResetToken operations
func (db *DB) CreatePasswordResetToken(token *PasswordResetToken) error {
return db.Create(token).Error
}
func (db *DB) GetPasswordResetToken(token string) (*PasswordResetToken, error) {
var resetToken PasswordResetToken
err := db.Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).First(&resetToken).Error
if err != nil {
return nil, err
}
return &resetToken, nil
}
func (db *DB) MarkPasswordResetTokenAsUsed(tokenID uint) error {
return db.Model(&PasswordResetToken{}).Where("id = ?", tokenID).Update("used", true).Error
}
// TransferConfig operations
func (db *DB) CreateTransferConfig(config *TransferConfig) error {
return db.Create(config).Error
}
func (db *DB) GetTransferConfigs(userID uint) ([]TransferConfig, error) {
var configs []TransferConfig
err := db.Where("created_by = ?", userID).Find(&configs).Error
return configs, err
}
func (db *DB) GetTransferConfig(id uint) (*TransferConfig, error) {
var config TransferConfig
err := db.First(&config, id).Error
if err != nil {
return nil, err
}
return &config, nil
}
func (db *DB) UpdateTransferConfig(config *TransferConfig) error {
return db.Save(config).Error
}
func (db *DB) DeleteTransferConfig(id uint) error {
// First check if any jobs are using this config
var count int64
if err := db.Model(&Job{}).Where("config_id = ?", id).Count(&count).Error; err != nil {
return fmt.Errorf("failed to check for dependent jobs: %v", err)
}
if count > 0 {
return fmt.Errorf("cannot delete config: %d jobs are using this configuration", count)
}
// Delete the config
return db.Delete(&TransferConfig{}, id).Error
}
// Job operations
func (db *DB) CreateJob(job *Job) error {
// Use Omit to prevent GORM from creating a new config
return db.Omit("Config").Create(job).Error
}
func (db *DB) GetJobs(userID uint) ([]Job, error) {
var jobs []Job
err := db.Preload("Config").Where("created_by = ?", userID).Find(&jobs).Error
return jobs, err
}
func (db *DB) GetJob(id uint) (*Job, error) {
var job Job
err := db.Preload("Config").First(&job, id).Error
if err != nil {
return nil, err
}
return &job, nil
}
func (db *DB) UpdateJob(job *Job) error {
// Use Omit to prevent GORM from updating or creating a new config
return db.Omit("Config").Save(job).Error
}
func (db *DB) DeleteJob(id uint) error {
// Delete associated job history records first
if err := db.Where("job_id = ?", id).Delete(&JobHistory{}).Error; err != nil {
return fmt.Errorf("failed to delete job history: %v", err)
}
// Delete the job
return db.Delete(&Job{}, id).Error
}
func (db *DB) UpdateJobStatus(job *Job) error {
return db.Save(job).Error
}
// JobHistory operations
func (db *DB) CreateJobHistory(history *JobHistory) error {
return db.Create(history).Error
}
func (db *DB) UpdateJobHistory(history *JobHistory) error {
return db.Save(history).Error
}
func (db *DB) GetJobHistory(jobID uint) ([]JobHistory, error) {
var history []JobHistory
err := db.Where("job_id = ?", jobID).Order("start_time desc").Find(&history).Error
return history, err
}
// Helper functions
func (db *DB) GetConfigRclonePath(config *TransferConfig) string {
return filepath.Join("configs", fmt.Sprintf("config_%d.conf", config.ID))
}
func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
configPath := db.GetConfigRclonePath(config)
// Ensure configs directory exists
if err := os.MkdirAll("configs", 0755); err != nil {
return fmt.Errorf("failed to create configs directory: %v", err)
}
// Get the rclone path from the environment variable or use the default path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
sourceName := fmt.Sprintf("source_%d", config.ID)
// Generate rclone config using rclone CLI
switch config.SourceType {
case "sftp":
args := []string{
"config", "create", sourceName, "sftp",
"host", config.SourceHost,
"user", config.SourceUser,
"port", fmt.Sprintf("%d", config.SourcePort),
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.SourcePassword != "" {
args = append(args, "pass", config.SourcePassword)
}
if config.SourceKeyFile != "" {
args = append(args, "key_file", config.SourceKeyFile)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
}
case "s3":
args := []string{
"config", "create", sourceName, "s3",
"provider", "AWS",
"env_auth", "false",
"access_key_id", config.SourceAccessKey,
"secret_access_key", config.SourceSecretKey,
"region", config.SourceRegion,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.SourceEndpoint != "" {
args = append(args, "endpoint", config.SourceEndpoint)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
}
case "minio":
args := []string{
"config", "create", sourceName, "s3",
"provider", "Minio",
"env_auth", "false",
"access_key_id", config.SourceAccessKey,
"secret_access_key", config.SourceSecretKey,
"endpoint", config.SourceEndpoint,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
}
case "b2":
args := []string{
"config", "create", sourceName, "b2",
"account", config.SourceAccessKey,
"key", config.SourceSecretKey,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
}
case "smb":
args := []string{
"config", "create", sourceName, "smb",
"host", config.SourceHost,
"user", config.SourceUser,
"pass", config.SourcePassword,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.SourceDomain != "" {
args = append(args, "domain", config.SourceDomain)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
}
case "ftp":
args := []string{
"config", "create", sourceName, "ftp",
"host", config.SourceHost,
"user", config.SourceUser,
"pass", config.SourcePassword,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.SourcePassiveMode {
args = append(args, "passive", "true")
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output)
}
default:
// Write local config
content := fmt.Sprintf("[source_%d]\ntype = local\n\n", config.ID)
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
return fmt.Errorf("failed to write source config: %v", err)
}
}
destName := fmt.Sprintf("dest_%d", config.ID)
switch config.DestinationType {
case "sftp":
args := []string{
"config", "create", destName, "sftp",
"host", config.DestHost,
"user", config.DestUser,
"port", fmt.Sprintf("%d", config.DestPort),
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.DestPassword != "" {
args = append(args, "pass", config.DestPassword)
}
if config.DestKeyFile != "" {
args = append(args, "key_file", config.DestKeyFile)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
case "s3":
args := []string{
"config", "create", destName, "s3",
"provider", "AWS",
"env_auth", "false",
"access_key_id", config.DestAccessKey,
"secret_access_key", config.DestSecretKey,
"region", config.DestRegion,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.DestEndpoint != "" {
args = append(args, "endpoint", config.DestEndpoint)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
case "minio":
args := []string{
"config", "create", destName, "s3",
"provider", "Minio",
"env_auth", "false",
"access_key_id", config.DestAccessKey,
"secret_access_key", config.DestSecretKey,
"endpoint", config.DestEndpoint,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
case "b2":
args := []string{
"config", "create", destName, "b2",
"account", config.DestAccessKey,
"key", config.DestSecretKey,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
case "smb":
args := []string{
"config", "create", destName, "smb",
"host", config.DestHost,
"user", config.DestUser,
"pass", config.DestPassword,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.DestDomain != "" {
args = append(args, "domain", config.DestDomain)
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
case "ftp":
args := []string{
"config", "create", destName, "ftp",
"host", config.DestHost,
"user", config.DestUser,
"pass", config.DestPassword,
"--non-interactive",
"--config", configPath,
"--log-level", "ERROR",
}
if config.DestPassiveMode {
args = append(args, "passive", "true")
}
cmd := exec.Command(rclonePath, args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output)
}
default:
// Append local config
content := fmt.Sprintf("[dest_%d]\ntype = local\n", config.ID)
f, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open config file: %v", err)
}
defer f.Close()
if _, err := f.WriteString(content); err != nil {
return fmt.Errorf("failed to write destination config: %v", err)
}
}
return nil
}
+272
View File
@@ -0,0 +1,272 @@
package email
import (
"bytes"
"fmt"
"html/template"
"net/smtp"
"time"
"github.com/starfleetcptn/gomft/internal/config"
)
// Service represents the email sending service
type Service struct {
Config *config.Config
}
// NewService creates a new email service
func NewService(cfg *config.Config) *Service {
return &Service{
Config: cfg,
}
}
// SendPasswordResetEmail sends a password reset email to the specified email address
func (s *Service) SendPasswordResetEmail(toEmail, username, resetToken string) error {
if !s.Config.Email.Enabled {
// If email is not enabled, just log it (you can redirect to the default logging logic)
return fmt.Errorf("email service is disabled, reset link would be: %s/reset-password?token=%s",
s.Config.BaseURL, resetToken)
}
resetLink := fmt.Sprintf("%s/reset-password?token=%s", s.Config.BaseURL, resetToken)
// Create email data for template
data := map[string]interface{}{
"Username": username,
"ResetLink": resetLink,
"AppName": "GoMFT",
"Year": time.Now().Year(),
"ExpiresHours": 0.25, // Token expiration time in hours (15 minutes = 0.25 hours)
}
// Generate email content
subject := "Password Reset Request - GoMFT"
htmlContent, err := s.generatePasswordResetEmailHTML(data)
if err != nil {
return err
}
// Send the email
return s.sendEmail(toEmail, subject, htmlContent)
}
// generatePasswordResetEmailHTML generates the HTML content for password reset emails
func (s *Service) generatePasswordResetEmailHTML(data map[string]interface{}) (string, error) {
// HTML template for password reset email
tmpl, err := template.New("passwordResetEmail").Parse(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Your Password</title>
<style>
/* Base styles */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background-color: #f9fafb;
color: #374151;
line-height: 1.5;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.header {
text-align: center;
padding: 20px 0;
border-bottom: 1px solid #e5e7eb;
}
.logo {
width: 60px;
height: 60px;
margin: 0 auto 15px;
background-color: #2563eb;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.logo-icon {
font-size: 24px;
color: white;
font-weight: bold;
}
h1 {
color: #111827;
font-size: 24px;
margin: 0;
}
.content {
padding: 30px 20px;
}
p {
margin: 0 0 15px;
color: #4b5563;
}
.btn {
display: inline-block;
background-color: #2563eb;
color: #ffffff;
text-decoration: none;
padding: 12px 30px;
border-radius: 6px;
font-weight: 600;
margin: 25px 0;
text-align: center;
}
.btn:hover {
background-color: #4338ca;
}
.reset-link {
margin: 20px 0;
padding: 15px;
background-color: #f3f4f6;
border-radius: 6px;
word-break: break-all;
font-family: monospace;
color: #4b5563;
}
.note {
font-size: 14px;
color: #6b7280;
margin-top: 30px;
padding-top: 15px;
border-top: 1px solid #e5e7eb;
}
.footer {
text-align: center;
font-size: 12px;
color: #9ca3af;
padding: 20px 0;
background-color: #f9fafb;
border-radius: 0 0 8px 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">
<div class="logo-icon">G</div>
</div>
<h1>Reset Your Password</h1>
</div>
<div class="content">
<p>Hello{{if .Username}} {{.Username}}{{end}},</p>
<p>We received a request to reset your password for your {{.AppName}} account. Click the button below to reset it:</p>
<div style="text-align: center;">
<a href="{{.ResetLink}}" class="btn">Reset Password</a>
</div>
<p>If the button doesn't work, you can copy and paste the following link into your browser:</p>
<div class="reset-link">{{.ResetLink}}</div>
<p>This link will expire in 15 minutes.</p>
<div class="note">
<p>If you didn't request a password reset, you can ignore this email. Your password will remain unchanged.</p>
</div>
</div>
<div class="footer">
<p>&copy; {{.Year}} {{.AppName}}. All rights reserved.</p>
</div>
</div>
</body>
</html>
`)
if err != nil {
return "", err
}
var result bytes.Buffer
if err := tmpl.Execute(&result, data); err != nil {
return "", err
}
return result.String(), nil
}
// sendEmail sends an email with the given subject and HTML content
func (s *Service) sendEmail(toEmail, subject, htmlContent string) error {
from := s.Config.Email.FromEmail
if s.Config.Email.FromName != "" {
from = fmt.Sprintf("%s <%s>", s.Config.Email.FromName, s.Config.Email.FromEmail)
}
// Construct email headers
headers := make(map[string]string)
headers["From"] = from
headers["To"] = toEmail
headers["Subject"] = subject
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=UTF-8"
if s.Config.Email.ReplyTo != "" {
headers["Reply-To"] = s.Config.Email.ReplyTo
}
// Construct email message
message := ""
for key, value := range headers {
message += fmt.Sprintf("%s: %s\r\n", key, value)
}
message += "\r\n" + htmlContent
// Set up the SMTP server address
addr := fmt.Sprintf("%s:%d", s.Config.Email.Host, s.Config.Email.Port)
// Check if authentication is required
if s.Config.Email.RequireAuth {
// Use authenticated SMTP
auth := smtp.PlainAuth("", s.Config.Email.Username, s.Config.Email.Password, s.Config.Email.Host)
return smtp.SendMail(addr, auth, s.Config.Email.FromEmail, []string{toEmail}, []byte(message))
} else {
// Use unauthenticated SMTP
client, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %v", err)
}
defer client.Close()
// Set up TLS if enabled
if s.Config.Email.EnableTLS {
if err := client.StartTLS(nil); err != nil {
return fmt.Errorf("failed to start TLS: %v", err)
}
}
// Set the sender and recipient
if err := client.Mail(s.Config.Email.FromEmail); err != nil {
return fmt.Errorf("failed to set sender: %v", err)
}
if err := client.Rcpt(toEmail); err != nil {
return fmt.Errorf("failed to set recipient: %v", err)
}
// Send the email body
w, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %v", err)
}
_, err = w.Write([]byte(message))
if err != nil {
return fmt.Errorf("failed to write email data: %v", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("failed to close data writer: %v", err)
}
return client.Quit()
}
}
+445
View File
@@ -0,0 +1,445 @@
package scheduler
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
"github.com/starfleetcptn/gomft/internal/db"
)
type Scheduler struct {
cron *cron.Cron
db *db.DB
jobMutex sync.Mutex
jobs map[uint]cron.EntryID
}
func New(database *db.DB) *Scheduler {
scheduler := &Scheduler{
cron: cron.New(cron.WithSeconds()),
db: database,
jobs: make(map[uint]cron.EntryID),
}
// Start the cron scheduler
scheduler.cron.Start()
// Load existing jobs from database
scheduler.loadJobs()
return scheduler
}
func (s *Scheduler) loadJobs() {
var jobs []db.Job
if err := s.db.Preload("Config").Find(&jobs).Error; err != nil {
fmt.Printf("Error loading jobs: %v\n", err)
return
}
fmt.Printf("Loading %d jobs from database\n", len(jobs))
for _, job := range jobs {
if job.Enabled {
if err := s.ScheduleJob(&job); err != nil {
fmt.Printf("Error scheduling job %d: %v\n", job.ID, err)
continue
}
fmt.Printf("Scheduled job %d with cron expression: %s\n", job.ID, job.Schedule)
}
}
}
func (s *Scheduler) ScheduleJob(job *db.Job) error {
s.jobMutex.Lock()
defer s.jobMutex.Unlock()
fmt.Printf("Scheduling job %d (enabled: %v, schedule: %s)\n", job.ID, job.Enabled, job.Schedule)
// Remove existing job if it exists
if entryID, exists := s.jobs[job.ID]; exists {
fmt.Printf("Removing existing schedule for job %d\n", job.ID)
s.cron.Remove(entryID)
delete(s.jobs, job.ID)
}
// Only schedule if job is enabled
if !job.Enabled {
fmt.Printf("Job %d is disabled, skipping scheduling\n", job.ID)
return nil
}
// Convert 5-field cron to 6-field by prepending '0' for seconds
schedule := job.Schedule
if len(strings.Fields(schedule)) == 5 {
schedule = "0 " + schedule
}
// Validate cron expression
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
_, err := parser.Parse(schedule)
if err != nil {
return fmt.Errorf("invalid cron expression '%s': %w", job.Schedule, err)
}
// Schedule new job
entryID, err := s.cron.AddFunc(schedule, func() {
fmt.Printf("Executing job %d at %s\n", job.ID, time.Now().Format(time.RFC3339))
s.executeJob(job.ID)
})
if err != nil {
return fmt.Errorf("failed to schedule job: %w", err)
}
s.jobs[job.ID] = entryID
fmt.Printf("Successfully scheduled job %d with entry ID %v\n", job.ID, entryID)
// Calculate and log next run time
if entry := s.cron.Entry(entryID); entry.ID != 0 {
fmt.Printf("Next run time for job %d: %s\n", job.ID, entry.Next.Format(time.RFC3339))
}
return nil
}
func (s *Scheduler) executeJob(jobID uint) {
fmt.Printf("Starting execution of job %d\n", jobID)
// Get job details
var job db.Job
if err := s.db.Preload("Config").First(&job, jobID).Error; err != nil {
fmt.Printf("Error loading job %d: %v\n", jobID, err)
return
}
if job.Config.ID == 0 {
fmt.Printf("Error: job %d has no associated config\n", jobID)
return
}
fmt.Printf("Loaded job %d with config: source=%s:%s, dest=%s:%s\n",
jobID,
job.Config.SourceType,
job.Config.SourcePath,
job.Config.DestinationType,
job.Config.DestinationPath,
)
// Create job history entry
startTime := time.Now()
history := &db.JobHistory{
JobID: jobID,
StartTime: startTime,
Status: "running",
FilesTransferred: 0,
BytesTransferred: 0,
ErrorMessage: "",
}
if err := s.db.CreateJobHistory(history); err != nil {
fmt.Printf("Error creating job history for job %d: %v\n", jobID, err)
return
}
// Update job last run time
job.LastRun = &history.StartTime
if err := s.db.UpdateJobStatus(&job); err != nil {
fmt.Printf("Error updating job last run time for job %d: %v\n", jobID, err)
}
// Get rclone config path
configPath := s.db.GetConfigRclonePath(&job.Config)
// Size of transfer using rclone size
sizeArgs := []string{
"--config", configPath,
"size",
"--include", job.Config.FilePattern,
job.Config.SourcePath,
}
// Get the rclone path from the environment variable or use the default path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
output, err := exec.Command(rclonePath, sizeArgs...).CombinedOutput()
fmt.Printf("Running rclone size: %v\nOutput: %s\n", sizeArgs, output)
if err != nil {
fmt.Printf("Error running rclone size: %v\nOutput: %s\n", err, output)
return
}
// Parse rclone size output "Total objects: 1 Total size: 10 B (10 Byte)"
outputStr := string(output)
totalObjects := strings.TrimSpace(strings.Split(outputStr, "\n")[0])
totalObjects = strings.TrimSpace(strings.Split(totalObjects, ":")[1])
// totalSize := strings.TrimSpace(strings.Split(outputStr, ":")[2])
if totalObjects == "0" {
fmt.Printf("No files to transfer for job %d\n", jobID)
history.Status = "completed"
history.ErrorMessage = ""
history.FilesTransferred = 0
}
if totalObjects != "0" {
// First, list all files that match the pattern
listArgs := []string{
"--config", configPath,
"lsf",
"--include", job.Config.FilePattern,
fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath),
}
fmt.Printf("Listing files for job %d: rclone %s\n", jobID, strings.Join(listArgs, " "))
// Get the rclone path from the environment variable or use the default path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
listCmd := exec.Command(rclonePath, listArgs...)
listOutput, listErr := listCmd.CombinedOutput()
if listErr != nil {
fmt.Printf("Error listing files for job %d: %v\n", jobID, listErr)
history.Status = "failed"
history.ErrorMessage = fmt.Sprintf("File Listing Error: %v\nOutput: %s", listErr, string(listOutput))
return
}
// Split the output by newlines to get individual files
files := strings.Split(strings.TrimSpace(string(listOutput)), "\n")
fmt.Printf("Found %d files to transfer for job %d\n", len(files), jobID)
var transferErrors []string
filesTransferred := 0
// Process each file individually
for _, file := range files {
if file == "" {
continue
}
fmt.Printf("Processing file: %s for job %d\n", file, jobID)
// Prepare moveto command for transfer
transferArgs := []string{
"--config", configPath,
"moveto",
"--progress",
"--stats-one-line",
"--verbose",
"--stats", "1s",
}
// Source and destination paths
sourcePath := fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourcePath, file)
destPath := fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, file)
// Add output filename pattern if specified
if job.Config.OutputPattern != "" {
// Process the output pattern for this specific file
newFilename := ProcessOutputPattern(job.Config.OutputPattern, file)
destPath = fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, newFilename)
fmt.Printf("Renaming file from %s to %s for job %d\n", file, newFilename, jobID)
}
// Add custom flags if specified
if job.Config.RcloneFlags != "" {
customFlags := strings.Split(job.Config.RcloneFlags, " ")
transferArgs = append(transferArgs, customFlags...)
fmt.Printf("Added custom flags for job %d: %v\n", jobID, customFlags)
}
// Add source and destination to the command
transferArgs = append(transferArgs, sourcePath, destPath)
// Execute transfer for this file
fmt.Printf("Executing rclone transfer command for job %d, file %s: rclone %s\n",
jobID, file, strings.Join(transferArgs, " "))
// Get the rclone path from the environment variable or use the default path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
cmd := exec.Command(rclonePath, transferArgs...)
fileOutput, fileErr := cmd.CombinedOutput()
// Print the output
fmt.Printf("Output for file %s: %s\n", file, string(fileOutput))
// Check if file was successfully transferred
if fileErr != nil {
fmt.Printf("Error transferring file %s for job %d: %v\n", file, jobID, fileErr)
transferErrors = append(transferErrors, fmt.Sprintf("File %s: %v", file, fileErr))
} else {
filesTransferred++
fmt.Printf("Successfully transferred file %s for job %d\n", file, jobID)
// If archiving is enabled and transfer was successful, move files to archive
if job.Config.ArchiveEnabled && job.Config.ArchivePath != "" {
fmt.Printf("Archiving file %s for job %d\n", file, jobID)
// We don't need to move the file since we used moveto, but we can copy it to archive
archiveArgs := []string{
"--config", configPath,
"copyto",
sourcePath,
fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.ArchivePath, file),
}
fmt.Printf("Executing rclone archive command for job %d, file %s: rclone %s\n",
jobID, file, strings.Join(archiveArgs, " "))
// Get the rclone path from the environment variable or use the default path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
archiveCmd := exec.Command(rclonePath, archiveArgs...)
archiveOutput, archiveErr := archiveCmd.CombinedOutput()
// Print the output
fmt.Printf("Output for file %s: %s\n", file, string(archiveOutput))
// Check if file was successfully transferred
if archiveErr != nil {
fmt.Printf("Warning: Error archiving file %s for job %d: %v\n", file, jobID, archiveErr)
transferErrors = append(transferErrors,
fmt.Sprintf("Archive error for file %s: %v", file, archiveErr))
transferErrors = append(transferErrors,
fmt.Sprintf("Archive error for file %s: %v", file, archiveErr))
}
}
}
}
// Update job history with transfer results
history.FilesTransferred = filesTransferred
if len(transferErrors) > 0 {
history.Status = "completed_with_errors"
history.ErrorMessage = fmt.Sprintf("Transfer completed with %d errors:\n%s",
len(transferErrors), strings.Join(transferErrors, "\n"))
}
}
// Update job history with completion status and end time
endTime := time.Now()
history.EndTime = &endTime
if job.Config.ArchiveEnabled && job.Config.ArchivePath != "" {
if history.ErrorMessage != "" {
history.Status = "completed_with_archive_error"
} else {
history.Status = "completed"
}
} else {
history.Status = "completed"
}
if err := s.db.UpdateJobHistory(history); err != nil {
fmt.Printf("Error updating job history for job %d: %v\n", jobID, err)
}
// Update next run time if job is still scheduled
if entry := s.cron.Entry(s.jobs[jobID]); entry.ID != 0 {
job.NextRun = &entry.Next
if err := s.db.UpdateJobStatus(&job); err != nil {
fmt.Printf("Error updating next run time for job %d: %v\n", jobID, err)
} else {
fmt.Printf("Next run time for job %d: %s\n", jobID, entry.Next.Format(time.RFC3339))
}
}
}
// ProcessOutputPattern processes an output pattern with variables and returns the result
// This function is useful for testing pattern processing in isolation
func ProcessOutputPattern(pattern string, originalFilename string) string {
// Process date variables
dateRegex := regexp.MustCompile(`\${date:([^}]+)}`)
processedPattern := dateRegex.ReplaceAllStringFunc(pattern, func(match string) string {
format := dateRegex.FindStringSubmatch(match)[1]
return time.Now().Format(format)
})
// Split the filename and extension
ext := filepath.Ext(originalFilename)
filename := strings.TrimSuffix(originalFilename, ext)
// Replace filename and extension variables
processedPattern = strings.ReplaceAll(processedPattern, "${filename}", filename)
processedPattern = strings.ReplaceAll(processedPattern, "${ext}", ext)
return processedPattern
}
// createRcloneFilterFile creates a temporary filter file for rclone with rename rules
func createRcloneFilterFile(pattern string) (string, error) {
// Create a temporary file
tmpFile, err := ioutil.TempFile("", "rclone-filter-*.txt")
if err != nil {
return "", fmt.Errorf("failed to create temporary filter file: %v", err)
}
defer tmpFile.Close()
// Process the pattern to create a rclone filter rule
// First, replace date variables with current date in the specified format
dateRegex := regexp.MustCompile(`\${date:([^}]+)}`)
processedPattern := dateRegex.ReplaceAllStringFunc(pattern, func(match string) string {
format := dateRegex.FindStringSubmatch(match)[1]
return time.Now().Format(format)
})
// Replace filename and extension variables with rclone's capture group references
// For rclone rename filters, we need to use {1} for the first capture group, not $1
// See: https://rclone.org/filtering/#rename
// Extract filename without extension
processedPattern = strings.ReplaceAll(processedPattern, "${filename}", "{1}")
// Extract extension (with the dot)
processedPattern = strings.ReplaceAll(processedPattern, "${ext}", "{2}")
// Create a rename rule for rclone using the correct syntax:
// - The format for rename filters is: "-- SourceRegexp ReplacementPattern"
// - For files with extension: capture the name and extension separately
rule := fmt.Sprintf("-- (.*)(\\..+)$ %s\n", processedPattern)
// Add a fallback rule for files without extension
fallbackRule := fmt.Sprintf("-- ([^.]+)$ %s\n",
strings.ReplaceAll(processedPattern, "{2}", ""))
// Write the rules to the file
if _, err := tmpFile.WriteString(rule + fallbackRule); err != nil {
return "", fmt.Errorf("failed to write to filter file: %v", err)
}
return tmpFile.Name(), nil
}
func (s *Scheduler) UnscheduleJob(jobID uint) {
s.jobMutex.Lock()
defer s.jobMutex.Unlock()
if entryID, exists := s.jobs[jobID]; exists {
s.cron.Remove(entryID)
delete(s.jobs, jobID)
}
}
func (s *Scheduler) Stop() {
if s.cron != nil {
s.cron.Stop()
}
}
func (s *Scheduler) RunJobNow(jobID uint) error {
go s.executeJob(jobID)
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package web
import (
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/internal/config"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/email"
"github.com/starfleetcptn/gomft/internal/scheduler"
"github.com/starfleetcptn/gomft/internal/web/handlers"
)
// Handler is a wrapper around the handlers package
type Handler struct {
handlers *handlers.Handlers
}
// NewHandler creates a new Handler instance that delegates to the handlers package
func NewHandler(database *db.DB, scheduler *scheduler.Scheduler, jwtSecret string, dbPath string, backupDir string, cfg *config.Config) (*Handler, error) {
// Create email service instance
emailService := email.NewService(cfg)
// Create handlers instance
handlersInstance := handlers.NewHandlers(database, scheduler, jwtSecret, dbPath, backupDir, emailService)
return &Handler{
handlers: handlersInstance,
}, nil
}
// InitializeRoutes delegates route registration to the handlers package
func (h *Handler) InitializeRoutes(router *gin.Engine) {
// Register all routes through the handlers package
h.handlers.RegisterRoutes(router)
}
+13
View File
@@ -0,0 +1,13 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
)
// HandleBackupDB handles the POST /admin/backup route
func (h *Handlers) HandleBackupDB(c *gin.Context) {
// TODO: Implement database backup
c.JSON(http.StatusOK, gin.H{"message": "Database backup initiated"})
}
@@ -0,0 +1,580 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleAdminTools displays the admin tools page
func (h *Handlers) HandleAdminTools(c *gin.Context) {
// Get system statistics
data := components.AdminToolsData{
SystemUptime: h.getSystemUptime(),
DatabasePath: h.DBPath,
BackupPath: h.BackupDir,
}
// Get database size
if dbSize, err := h.getDatabaseSize(); err == nil {
data.DatabaseSize = dbSize
} else {
data.DatabaseSize = "Unknown"
}
// Get job history count
var jobHistoryCount int64
if err := h.DB.Model(&db.JobHistory{}).Count(&jobHistoryCount).Error; err == nil {
data.JobHistoryCount = int(jobHistoryCount)
}
// Get active jobs count
var activeJobs int64
if err := h.DB.Model(&db.Job{}).Where("enabled = ?", true).Count(&activeJobs).Error; err == nil {
data.ActiveJobs = int(activeJobs)
}
// Get total configs count
var totalConfigs int64
if err := h.DB.Model(&db.TransferConfig{}).Count(&totalConfigs).Error; err == nil {
data.TotalConfigs = int(totalConfigs)
}
// Get total jobs count
var totalJobs int64
if err := h.DB.Model(&db.Job{}).Count(&totalJobs).Error; err == nil {
data.TotalJobs = int(totalJobs)
}
// Get total users count
var totalUsers int64
if err := h.DB.Model(&db.User{}).Count(&totalUsers).Error; err == nil {
data.TotalUsers = int(totalUsers)
}
// Get last backup time and backup count
data.LastBackupTime, data.BackupCount = h.getBackupInfo()
// Get list of backup files
data.BackupFiles = h.getBackupFiles()
// Check for maintenance issues
data.MaintenanceMessage = h.checkMaintenanceIssues()
// Render the admin tools page
components.AdminTools(components.CreateTemplateContext(c), data).Render(c, c.Writer)
}
// HandleBackupDatabase handles the backup database request
func (h *Handlers) HandleBackupDatabase(c *gin.Context) {
fmt.Println("Backup database")
// Create backup directory if it doesn't exist
if err := os.MkdirAll(h.BackupDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create backup directory: %v", err)})
return
}
// Create backup filename with timestamp
timestamp := time.Now().Format("20060102_150405")
backupFilename := filepath.Join(h.BackupDir, fmt.Sprintf("gomft_backup_%s.db", timestamp))
// Copy the database file to the backup location
if err := h.copyDatabaseToBackup(backupFilename); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create backup: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Database backup created successfully", "filename": backupFilename})
}
// HandleRestoreDatabase handles the restore database request
func (h *Handlers) HandleRestoreDatabase(c *gin.Context) {
// Get the uploaded file
file, err := c.FormFile("backup_file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No backup file provided"})
return
}
// Create a temporary file to store the uploaded backup
tempFile, err := os.CreateTemp("", "gomft_restore_*.db")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create temporary file: %v", err)})
return
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
// Save the uploaded file to the temporary location
if err := c.SaveUploadedFile(file, tempFile.Name()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save uploaded file: %v", err)})
return
}
// Stop the scheduler to prevent jobs from running during restore
h.Scheduler.Stop()
// Close the current database connection
if err := h.DB.Close(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to close database: %v", err)})
return
}
// Create a backup of the current database before restoring
backupBeforeRestore := filepath.Join(h.BackupDir, fmt.Sprintf("pre_restore_backup_%s.db", time.Now().Format("20060102_150405")))
if err := h.copyDatabaseToBackup(backupBeforeRestore); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create pre-restore backup: %v", err)})
return
}
// Copy the temporary file to the database location
if err := copyFile(tempFile.Name(), h.DBPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to restore database: %v", err)})
return
}
// Redirect to home page to reinitialize the application
c.JSON(http.StatusOK, gin.H{"message": "Database restored successfully. The application will restart."})
}
// HandleExportConfigs handles the export all configurations request
func (h *Handlers) HandleExportConfigs(c *gin.Context) {
// Get all configurations
var configs []db.TransferConfig
if err := h.DB.Find(&configs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve configurations: %v", err)})
return
}
// Create a temporary file
tmpFile, err := os.CreateTemp("", "gomft_configs_*.json")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create temporary file: %v", err)})
return
}
defer os.Remove(tmpFile.Name()) // Clean up temp file when done
defer tmpFile.Close()
// Write the configurations to the file
encoder := json.NewEncoder(tmpFile)
encoder.SetIndent("", " ") // Pretty print the JSON
if err := encoder.Encode(configs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to write configurations to file: %v", err)})
return
}
// Set headers for file download
c.Header("Content-Description", "File Transfer")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="gomft_configs_%s.json"`, time.Now().Format("20060102_150405")))
c.Header("Content-Type", "application/json")
// Send the file
c.File(tmpFile.Name())
}
// HandleExportJobs handles the export all jobs request
func (h *Handlers) HandleExportJobs(c *gin.Context) {
// Get all jobs
var jobs []db.Job
if err := h.DB.Preload("Config").Find(&jobs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to retrieve jobs: %v", err)})
return
}
// Create a temporary file
tmpFile, err := os.CreateTemp("", "gomft_jobs_*.json")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create temporary file: %v", err)})
return
}
defer os.Remove(tmpFile.Name()) // Clean up temp file when done
defer tmpFile.Close()
// Write the jobs to the file
encoder := json.NewEncoder(tmpFile)
encoder.SetIndent("", " ") // Pretty print the JSON
if err := encoder.Encode(jobs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to write jobs to file: %v", err)})
return
}
// Set headers for file download
c.Header("Content-Description", "File Transfer")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="gomft_jobs_%s.json"`, time.Now().Format("20060102_150405")))
c.Header("Content-Type", "application/json")
// Send the file
c.File(tmpFile.Name())
}
// HandleClearJobHistory handles the clear job history request
func (h *Handlers) HandleClearJobHistory(c *gin.Context) {
// Delete all job history records
if err := h.DB.Exec("DELETE FROM job_histories").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to clear job history: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Job history cleared successfully"})
}
// HandleVacuumDatabase handles the vacuum database request
func (h *Handlers) HandleVacuumDatabase(c *gin.Context) {
// Execute VACUUM command to optimize the database
if err := h.DB.Exec("VACUUM").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to vacuum database: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Database vacuumed successfully"})
}
// HandleRestoreDatabaseByFilename handles restoring a backup file from the backup directory
func (h *Handlers) HandleRestoreDatabaseByFilename(c *gin.Context) {
filename := c.Param("filename")
// Validate filename format
if !strings.HasPrefix(filename, "gomft_backup_") || !strings.HasSuffix(filename, ".db") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup filename"})
return
}
// Construct full file path
backupPath := filepath.Join(h.BackupDir, filename)
// Check if file exists and is within backup directory
if !strings.HasPrefix(backupPath, h.BackupDir) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup path"})
return
}
// Check if file exists
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup file not found"})
return
}
// Stop the scheduler to prevent jobs from running during restore
h.Scheduler.Stop()
// Close the current database connection
if err := h.DB.Close(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to close database: %v", err)})
return
}
// Create a backup of the current database before restoring
backupBeforeRestore := filepath.Join(h.BackupDir, fmt.Sprintf("pre_restore_backup_%s.db", time.Now().Format("20060102_150405")))
if err := h.copyDatabaseToBackup(backupBeforeRestore); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create pre-restore backup: %v", err)})
return
}
// Copy the backup file to the database location
if err := copyFile(backupPath, h.DBPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to restore database: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Database restored successfully. The application will restart."})
}
// HandleRefreshBackups handles the HTMX request to refresh the backups list
func (h *Handlers) HandleRefreshBackups(c *gin.Context) {
// Get list of backup files
backupFiles := h.getBackupFiles()
// Create data structure for the template
data := components.AdminToolsData{
BackupFiles: backupFiles,
}
// Get last backup time and backup count
data.LastBackupTime, data.BackupCount = h.getBackupInfo()
// Render just the BackupsList component
components.BackupsList(data).Render(c, c.Writer)
}
// Helper functions
// getSystemUptime returns the system uptime as a formatted string
func (h *Handlers) getSystemUptime() string {
uptime := time.Since(h.StartTime)
days := int(uptime.Hours() / 24)
hours := int(uptime.Hours()) % 24
minutes := int(uptime.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%d days, %d hours, %d minutes", days, hours, minutes)
}
if hours > 0 {
return fmt.Sprintf("%d hours, %d minutes", hours, minutes)
}
return fmt.Sprintf("%d minutes", minutes)
}
// getDatabaseSize returns the size of the database file as a formatted string
func (h *Handlers) getDatabaseSize() (string, error) {
fileInfo, err := os.Stat(h.DBPath)
if err != nil {
return "", err
}
sizeBytes := fileInfo.Size()
// Format size
if sizeBytes < 1024 {
return fmt.Sprintf("%d B", sizeBytes), nil
} else if sizeBytes < 1024*1024 {
return fmt.Sprintf("%.2f KB", float64(sizeBytes)/1024), nil
} else if sizeBytes < 1024*1024*1024 {
return fmt.Sprintf("%.2f MB", float64(sizeBytes)/(1024*1024)), nil
}
return fmt.Sprintf("%.2f GB", float64(sizeBytes)/(1024*1024*1024)), nil
}
// getBackupInfo returns the last backup time and total backup count
func (h *Handlers) getBackupInfo() (*time.Time, int) {
// Check if backup directory exists
if _, err := os.Stat(h.BackupDir); os.IsNotExist(err) {
return nil, 0
}
// List all backup files
backupFiles, err := filepath.Glob(filepath.Join(h.BackupDir, "gomft_backup_*.db"))
if err != nil {
return nil, 0
}
if len(backupFiles) == 0 {
return nil, 0
}
// Find the most recent backup
var lastBackupTime time.Time
var lastBackupFile string
for _, file := range backupFiles {
filename := filepath.Base(file)
// Extract timestamp from filename (format: gomft_backup_20060102_150405.db)
if len(filename) < 28 {
continue
}
timestampStr := filename[13:28]
timestamp, err := time.Parse("20060102_150405", timestampStr)
if err != nil {
continue
}
if timestamp.After(lastBackupTime) {
lastBackupTime = timestamp
lastBackupFile = file
}
}
if lastBackupFile == "" {
return nil, len(backupFiles)
}
return &lastBackupTime, len(backupFiles)
}
// checkMaintenanceIssues checks for potential maintenance issues
func (h *Handlers) checkMaintenanceIssues() string {
var issues []string
// Check database size
fileInfo, err := os.Stat(h.DBPath)
if err == nil {
sizeBytes := fileInfo.Size()
// If database is larger than 100MB, suggest vacuum
if sizeBytes > 100*1024*1024 {
issues = append(issues, "Database size is large (>100MB). Consider running vacuum to optimize.")
}
}
// Check job history count
var jobHistoryCount int64
if err := h.DB.Model(&db.JobHistory{}).Count(&jobHistoryCount).Error; err == nil {
// If more than 1000 job history records, suggest clearing old records
if jobHistoryCount > 1000 {
issues = append(issues, fmt.Sprintf("Job history contains %d records. Consider clearing old records.", jobHistoryCount))
}
}
// Check backup age
lastBackupTime, _ := h.getBackupInfo()
if lastBackupTime == nil {
issues = append(issues, "No database backups found. Consider creating a backup.")
} else {
// If last backup is older than 7 days, suggest creating a new backup
if time.Since(*lastBackupTime) > 7*24*time.Hour {
issues = append(issues, fmt.Sprintf("Last backup is %d days old. Consider creating a new backup.", int(time.Since(*lastBackupTime).Hours()/24)))
}
}
// Join all issues with newlines
if len(issues) > 0 {
return fmt.Sprintf("Maintenance Recommendations:\n%s", strings.Join(issues, "\n"))
}
return ""
}
// copyDatabaseToBackup copies the database file to the specified backup location
func (h *Handlers) copyDatabaseToBackup(backupPath string) error {
return copyFile(h.DBPath, backupPath)
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
if err != nil {
return err
}
return destFile.Sync()
}
// getBackupFiles returns a list of backup files with their details
func (h *Handlers) getBackupFiles() []components.BackupFile {
var backupFiles []components.BackupFile
// Check if backup directory exists
if _, err := os.Stat(h.BackupDir); os.IsNotExist(err) {
return backupFiles
}
// List all backup files
files, err := filepath.Glob(filepath.Join(h.BackupDir, "gomft_backup_*.db"))
if err != nil {
return backupFiles
}
for _, file := range files {
fileInfo, err := os.Stat(file)
if err != nil {
continue
}
// Format file size
var sizeStr string
size := fileInfo.Size()
switch {
case size < 1024:
sizeStr = fmt.Sprintf("%d B", size)
case size < 1024*1024:
sizeStr = fmt.Sprintf("%.2f KB", float64(size)/1024)
case size < 1024*1024*1024:
sizeStr = fmt.Sprintf("%.2f MB", float64(size)/(1024*1024))
default:
sizeStr = fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024))
}
backupFiles = append(backupFiles, components.BackupFile{
Name: filepath.Base(file),
Size: sizeStr,
ModTime: fileInfo.ModTime(),
})
}
// Sort backups by modification time, newest first
sort.Slice(backupFiles, func(i, j int) bool {
return backupFiles[i].ModTime.After(backupFiles[j].ModTime)
})
return backupFiles
}
// HandleDeleteBackup handles the DELETE /admin/delete-backup/:filename route
func (h *Handlers) HandleDeleteBackup(c *gin.Context) {
filename := c.Param("filename")
// Validate filename format
if !strings.HasPrefix(filename, "gomft_backup_") || !strings.HasSuffix(filename, ".db") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup filename"})
return
}
// Construct full file path
filePath := filepath.Join(h.BackupDir, filename)
// Check if file exists and is within backup directory
if !strings.HasPrefix(filePath, h.BackupDir) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid backup path"})
return
}
// Delete the file
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup file not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete backup: %v", err)})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "Backup deleted successfully"})
}
// HandleDownloadBackup handles the GET /admin/download-backup/:filename route
func (h *Handlers) HandleDownloadBackup(c *gin.Context) {
filename := c.Param("filename")
// Validate filename format
if !strings.HasPrefix(filename, "gomft_backup_") || !strings.HasSuffix(filename, ".db") {
c.String(http.StatusBadRequest, "Invalid backup filename")
return
}
// Construct full file path
filePath := filepath.Join(h.BackupDir, filename)
// Check if file exists and is within backup directory
if !strings.HasPrefix(filePath, h.BackupDir) {
c.String(http.StatusBadRequest, "Invalid backup path")
return
}
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.String(http.StatusNotFound, "Backup file not found")
return
}
// Set headers for file download
c.Header("Content-Description", "File Transfer")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Header("Content-Type", "application/octet-stream")
// Serve the file
c.File(filePath)
}
+295
View File
@@ -0,0 +1,295 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/internal/db"
"golang.org/x/crypto/bcrypt"
)
// HandleAPILogin handles the POST /api/login route
func (h *Handlers) HandleAPILogin(c *gin.Context) {
var loginData struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&loginData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request data"})
return
}
// Get user by email
var user db.User
if err := h.DB.Where("email = ?", loginData.Email).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// Check password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(loginData.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// Generate JWT token
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": gin.H{
"id": user.ID,
"email": user.Email,
"is_admin": user.IsAdmin,
},
})
}
// HandleAPIConfigs handles the GET /api/configs route
func (h *Handlers) HandleAPIConfigs(c *gin.Context) {
userID := c.GetUint("userID")
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
c.JSON(http.StatusOK, gin.H{"configs": configs})
}
// HandleAPIConfig handles the GET /api/configs/:id route
func (h *Handlers) HandleAPIConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to view this config"})
return
}
}
c.JSON(http.StatusOK, gin.H{"config": config})
}
// HandleAPICreateConfig handles the POST /api/configs route
func (h *Handlers) HandleAPICreateConfig(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request data: %v", err)})
return
}
userID := c.GetUint("userID")
config.CreatedBy = userID
if err := h.DB.Create(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create config: %v", err)})
return
}
c.JSON(http.StatusCreated, gin.H{"config": config})
}
// HandleAPIUpdateConfig handles the PUT /api/configs/:id route
func (h *Handlers) HandleAPIUpdateConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to update this config"})
return
}
}
// Get the old config values for comparison
oldConfig := config
// Bind JSON data to config
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request data: %v", err)})
return
}
// Preserve fields that shouldn't be updated
config.CreatedBy = oldConfig.CreatedBy
if err := h.DB.Save(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update config: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"config": config})
}
// HandleAPIDeleteConfig handles the DELETE /api/configs/:id route
func (h *Handlers) HandleAPIDeleteConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to delete this config"})
return
}
}
// Check if config is in use by any jobs
var jobCount int64
h.DB.Model(&db.Job{}).Where("config_id = ?", config.ID).Count(&jobCount)
if jobCount > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Config is in use by jobs and cannot be deleted"})
return
}
// Delete config
if err := h.DB.Delete(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete config: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
}
// HandleAPITestConnection handles the POST /api/configs/test route
func (h *Handlers) HandleAPITestConnection(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request data: %v", err)})
return
}
// TODO: Implement connection testing based on protocol
// This is a placeholder for the actual connection testing logic
success := true
message := "Connection successful"
// Example of how connection testing might work
switch config.SourceType {
case "sftp":
// Test SFTP connection
// success, message = testSFTPConnection(config)
case "ftp":
// Test FTP connection
// success, message = testFTPConnection(config)
default:
success = false
message = "Unsupported source type"
}
c.JSON(http.StatusOK, gin.H{
"success": success,
"message": message,
})
}
// HandleAPIJobs handles the API jobs request
func (h *Handlers) HandleAPIJobs(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API jobs handler stub"})
}
// HandleAPIJob handles the API job request
func (h *Handlers) HandleAPIJob(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API job handler stub"})
}
// HandleAPICreateJob handles the API create job request
func (h *Handlers) HandleAPICreateJob(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API create job handler stub"})
}
// HandleAPIUpdateJob handles the API update job request
func (h *Handlers) HandleAPIUpdateJob(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API update job handler stub"})
}
// HandleAPIDeleteJob handles the API delete job request
func (h *Handlers) HandleAPIDeleteJob(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API delete job handler stub"})
}
// HandleAPIRunJob handles the API run job request
func (h *Handlers) HandleAPIRunJob(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API run job handler stub"})
}
// HandleAPIHistory handles the API history request
func (h *Handlers) HandleAPIHistory(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API history handler stub"})
}
// HandleAPIJobRun handles the API job run request
func (h *Handlers) HandleAPIJobRun(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API job run handler stub"})
}
// HandleAPIUsers handles the API users request
func (h *Handlers) HandleAPIUsers(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API users handler stub"})
}
// HandleAPIUser handles the API user request
func (h *Handlers) HandleAPIUser(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API user handler stub"})
}
// HandleAPICreateUser handles the API create user request
func (h *Handlers) HandleAPICreateUser(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API create user handler stub"})
}
// HandleAPIUpdateUser handles the API update user request
func (h *Handlers) HandleAPIUpdateUser(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API update user handler stub"})
}
// HandleAPIDeleteUser handles the API delete user request
func (h *Handlers) HandleAPIDeleteUser(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "API delete user handler stub"})
}
+554
View File
@@ -0,0 +1,554 @@
package handlers
import (
"context"
"crypto/rand"
"encoding/base64"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/auth"
"github.com/starfleetcptn/gomft/internal/db"
"golang.org/x/crypto/bcrypt"
)
// AuthMiddleware is a middleware function that checks if the user is authenticated
func (h *Handlers) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Get the JWT token from the cookie
tokenString, err := c.Cookie("jwt_token")
if err != nil {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Parse and validate the token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(h.JWTSecret), nil
})
if err != nil || !token.Valid {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Set user information in the context
c.Set("userID", uint(claims["user_id"].(float64)))
c.Set("email", claims["email"].(string))
c.Set("username", claims["username"].(string))
c.Set("isAdmin", claims["is_admin"].(bool))
c.Next()
}
}
// AdminMiddleware is a middleware function that checks if the user is an admin
func (h *Handlers) AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
isAdmin, exists := c.Get("isAdmin")
if !exists || !isAdmin.(bool) {
c.Redirect(http.StatusFound, "/dashboard")
c.Abort()
return
}
c.Next()
}
}
// APIAuthMiddleware is a middleware function that checks if the API request is authenticated
func (h *Handlers) APIAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Get the Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
c.Abort()
return
}
// Check if the header is in the correct format
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
c.Abort()
return
}
// Parse and validate the token
tokenString := parts[1]
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(h.JWTSecret), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
c.Abort()
return
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
return
}
// Set user information in the context
c.Set("userID", uint(claims["user_id"].(float64)))
c.Set("email", claims["email"].(string))
c.Set("username", claims["username"].(string))
c.Set("isAdmin", claims["is_admin"].(bool))
c.Next()
}
}
// APIAdminMiddleware is a middleware function that checks if the API request is from an admin
func (h *Handlers) APIAdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
isAdmin, exists := c.Get("isAdmin")
if !exists || !isAdmin.(bool) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin privileges required"})
c.Abort()
return
}
c.Next()
}
}
// GenerateJWT generates a JWT token for the given user
func (h *Handlers) GenerateJWT(userID uint, username string, isAdmin bool) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": userID,
"username": username,
"is_admin": isAdmin,
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
return token.SignedString([]byte(h.JWTSecret))
}
// HandleLoginPage handles the GET /login route
func (h *Handlers) HandleLoginPage(c *gin.Context) {
// Check if user is already logged in
if userID, exists := c.Get("userID"); exists && userID != nil {
// User is logged in, redirect to dashboard
c.Redirect(http.StatusFound, "/dashboard")
return
}
// Create template context and set email if available
ctx := components.CreateTemplateContext(c)
if email, exists := c.Get("email"); exists {
ctx = context.WithValue(ctx, "email", email)
}
// Check for message query param (used for password expired, etc.)
message := c.Query("message")
// User is not logged in, show login page
if message != "" {
components.Login(ctx, message).Render(c.Request.Context(), c.Writer)
} else {
components.Login(ctx, "").Render(c.Request.Context(), c.Writer)
}
}
// HandleLogin handles the POST /login route
func (h *Handlers) HandleLogin(c *gin.Context) {
email := c.PostForm("email")
password := c.PostForm("password")
// Get user by email
var user db.User
if err := h.DB.Where("email = ?", email).First(&user).Error; err != nil {
components.Login(components.CreateTemplateContext(c), "Invalid credentials").Render(c, c.Writer)
return
}
// Check if account is locked
if user.AccountLocked {
if user.LockoutUntil != nil && time.Now().After(*user.LockoutUntil) {
// Lockout period has expired, reset the lockout
user.AccountLocked = false
user.FailedLoginAttempts = 0
user.LockoutUntil = nil
h.DB.Save(&user)
} else {
// Account is still locked
components.Login(components.CreateTemplateContext(c), "Account is locked due to too many failed login attempts. Please try again later.").Render(c, c.Writer)
return
}
}
// Check password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
// Increment failed login attempts
user.FailedLoginAttempts++
// Check if we need to lock the account
policy := auth.DefaultPasswordPolicy()
if user.FailedLoginAttempts >= policy.MaxLoginAttempts {
user.AccountLocked = true
lockoutTime := time.Now().Add(policy.LockoutDuration)
user.LockoutUntil = &lockoutTime
h.DB.Save(&user)
components.Login(components.CreateTemplateContext(c), "Account is locked due to too many failed login attempts. Please try again later.").Render(c, c.Writer)
return
}
h.DB.Save(&user)
components.Login(components.CreateTemplateContext(c), "Invalid credentials").Render(c, c.Writer)
return
}
// Reset failed login attempts on successful login
user.FailedLoginAttempts = 0
user.AccountLocked = false
user.LockoutUntil = nil
h.DB.Save(&user)
// Check password expiration
policy := auth.DefaultPasswordPolicy()
if auth.IsPasswordExpired(user.LastPasswordChange, policy) {
// Add flash message about password expiration
// We're simplifying by just redirecting to login with a message
c.SetCookie("jwt_token", "", -1, "/", "", false, true) // Logout the user
c.Redirect(http.StatusFound, "/login?message=Your+password+has+expired.+Please+contact+an+administrator.")
return
}
// Generate JWT token with all necessary user information
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user.ID,
"email": user.Email,
"username": strings.Split(user.Email, "@")[0], // Use email prefix as username
"is_admin": user.IsAdmin,
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
// Sign the token
tokenString, err := token.SignedString([]byte(h.JWTSecret))
if err != nil {
components.Login(components.CreateTemplateContext(c), "Authentication error").Render(c, c.Writer)
return
}
// Set token in cookie
c.SetCookie("jwt_token", tokenString, 86400, "/", "", false, true)
c.Redirect(http.StatusFound, "/dashboard")
}
// HandleLogout handles the POST /logout route
func (h *Handlers) HandleLogout(c *gin.Context) {
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
}
// HandleChangePassword handles the POST /change-password route
// This is now only for use from the profile page
func (h *Handlers) HandleChangePassword(c *gin.Context) {
// Get user ID from token
tokenCookie, err := c.Cookie("jwt_token")
if err != nil || tokenCookie == "" {
if c.GetHeader("HX-Request") == "true" {
c.Data(http.StatusUnauthorized, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Authentication required</span>
</div>`))
return
}
c.Redirect(http.StatusFound, "/login")
return
}
claims, err := auth.ValidateToken(tokenCookie, h.JWTSecret)
if err != nil {
if c.GetHeader("HX-Request") == "true" {
c.Data(http.StatusUnauthorized, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Invalid authentication</span>
</div>`))
return
}
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
return
}
userID := claims.UserID
// Get form values
currentPassword := c.PostForm("current_password")
newPassword := c.PostForm("new_password")
confirmPassword := c.PostForm("confirm_password")
// Validate new password matches confirmation
if newPassword != confirmPassword {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">New password and confirmation do not match</span>
</div>`))
return
}
// Get user
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">User not found</span>
</div>`))
return
}
// Verify current password
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Current password is incorrect</span>
</div>`))
return
}
// Validate password against policy
policy := auth.DefaultPasswordPolicy()
if err := auth.ValidatePassword(newPassword, policy); err != nil {
errorMsg := `<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">` + err.Error() + `</span>
</div>`
c.Data(http.StatusOK, "text/html", []byte(errorMsg))
return
}
// Check password history
if err := auth.CheckPasswordHistory(user.ID, newPassword, user.PasswordHash, h.DB.DB, policy); err != nil {
errorMsg := `<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">` + err.Error() + `</span>
</div>`
c.Data(http.StatusOK, "text/html", []byte(errorMsg))
return
}
// Hash the new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Error processing password</span>
</div>`))
return
}
// Update password history
if err := auth.UpdatePasswordHistory(user.ID, string(hashedPassword), h.DB.DB, policy); err != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Error updating password history</span>
</div>`))
return
}
// Update user's password
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
if err := h.DB.Save(&user).Error; err != nil {
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Error updating password</span>
</div>`))
return
}
// Return success message
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Password updated successfully!</span>
</div>`))
}
// HandleForgotPasswordPage displays the forgot password form
func (h *Handlers) HandleForgotPasswordPage(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "", "").Render(c.Request.Context(), c.Writer)
}
// HandleForgotPassword processes the forgot password form submission
func (h *Handlers) HandleForgotPassword(c *gin.Context) {
email := c.PostForm("email")
if email == "" {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "Email is required", "").Render(c.Request.Context(), c.Writer)
return
}
// Check if user exists
user, err := h.DB.GetUserByEmail(email)
if err != nil {
// Don't reveal that the email doesn't exist for security reasons
// But we'll log it for debugging
log.Printf("Password reset requested for non-existent email: %s", email)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "", "If your email is registered, you will receive a password reset link.").Render(c.Request.Context(), c.Writer)
return
}
// Generate reset token
token, err := generateResetToken(32)
if err != nil {
log.Printf("Error generating reset token: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "An error occurred. Please try again later.", "").Render(c.Request.Context(), c.Writer)
return
}
// Save token in database with expiration time (15 minutes)
expiration := time.Now().Add(15 * time.Minute)
resetToken := &db.PasswordResetToken{
UserID: user.ID,
Token: token,
ExpiresAt: expiration,
}
if err := h.DB.CreatePasswordResetToken(resetToken); err != nil {
log.Printf("Error saving reset token: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "An error occurred. Please try again later.", "").Render(c.Request.Context(), c.Writer)
return
}
// Send password reset email
err = h.Email.SendPasswordResetEmail(user.Email, user.Email, token)
if err != nil {
// If email sending fails, log the error but don't expose this to the user
log.Printf("Error sending password reset email: %v", err)
// If email is disabled, log the reset link
if strings.Contains(err.Error(), "email service is disabled") {
log.Printf("Email service is disabled, reset link: %v", err)
}
}
// Show success message regardless of whether email was sent
// This prevents user enumeration attacks
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ForgotPassword(ctx, "", "If your email is registered, you will receive a password reset link.").Render(c.Request.Context(), c.Writer)
}
// HandleResetPasswordPage displays the reset password form
func (h *Handlers) HandleResetPasswordPage(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.Redirect(http.StatusFound, "/forgot-password")
return
}
// Validate token exists and hasn't expired
_, err := h.DB.GetPasswordResetToken(token)
if err != nil {
log.Printf("Invalid reset token: %s, error: %v", token, err)
c.Redirect(http.StatusFound, "/forgot-password")
return
}
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "").Render(c.Request.Context(), c.Writer)
}
// HandleResetPassword processes the reset password form submission
func (h *Handlers) HandleResetPassword(c *gin.Context) {
token := c.PostForm("token")
password := c.PostForm("password")
confirmPassword := c.PostForm("confirm-password")
if token == "" {
c.Redirect(http.StatusFound, "/forgot-password")
return
}
if password == "" || confirmPassword == "" {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "Both password fields are required.").Render(c.Request.Context(), c.Writer)
return
}
if password != confirmPassword {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "Passwords do not match.").Render(c.Request.Context(), c.Writer)
return
}
if len(password) < 8 {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "Password must be at least 8 characters long.").Render(c.Request.Context(), c.Writer)
return
}
// Validate token and get user
resetToken, err := h.DB.GetPasswordResetToken(token)
if err != nil {
log.Printf("Invalid reset token: %s, error: %v", token, err)
c.Redirect(http.StatusFound, "/forgot-password")
return
}
// Get the user
user, err := h.DB.GetUserByID(resetToken.UserID)
if err != nil {
log.Printf("User not found for token: %s, user ID: %d, error: %v", token, resetToken.UserID, err)
c.Redirect(http.StatusFound, "/forgot-password")
return
}
// Hash the new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "An error occurred. Please try again later.").Render(c.Request.Context(), c.Writer)
return
}
// Update user's password
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
if err := h.DB.UpdateUser(user); err != nil {
log.Printf("Error updating user password: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
components.ResetPassword(ctx, token, "An error occurred. Please try again later.").Render(c.Request.Context(), c.Writer)
return
}
// Record password history
passwordHistory := &auth.PasswordHistory{
UserID: user.ID,
PasswordHash: string(hashedPassword),
}
if err := h.DB.DB.Create(passwordHistory).Error; err != nil {
log.Printf("Error recording password history: %v", err)
}
// Mark token as used
if err := h.DB.MarkPasswordResetTokenAsUsed(resetToken.ID); err != nil {
log.Printf("Error marking token as used: %v", err)
}
// Redirect to login with success message
c.Redirect(http.StatusFound, "/login?message=Password+reset+successful.+Please+log+in+with+your+new+password.")
}
// Helper function to generate a random token
func generateResetToken(length int) (string, error) {
b := make([]byte, length)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
+27
View File
@@ -0,0 +1,27 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/auth"
)
// HandleHome handles the GET / route
func (h *Handlers) HandleHome(c *gin.Context) {
// Check for JWT token in cookie
tokenCookie, err := c.Cookie("jwt_token")
if err == nil && tokenCookie != "" {
// Token exists, validate it
claims, err := auth.ValidateToken(tokenCookie, h.JWTSecret)
if err == nil && claims != nil {
// Valid token, redirect to dashboard
c.Redirect(http.StatusFound, "/dashboard")
return
}
}
// User is not logged in, show home page
components.Home(c.Request.Context()).Render(c, c.Writer)
}
+221
View File
@@ -0,0 +1,221 @@
package handlers
import (
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleConfigs handles the GET /configs route
func (h *Handlers) HandleConfigs(c *gin.Context) {
userID := c.GetUint("userID")
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
data := components.ConfigsData{
Configs: configs,
}
components.Configs(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleNewConfig handles the GET /configs/new route
func (h *Handlers) HandleNewConfig(c *gin.Context) {
data := components.ConfigFormData{
Config: &db.TransferConfig{},
IsNew: true,
}
components.ConfigForm(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleEditConfig handles the GET /configs/:id/edit route
func (h *Handlers) HandleEditConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.Redirect(http.StatusFound, "/configs")
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.Redirect(http.StatusFound, "/configs")
return
}
}
data := components.ConfigFormData{
Config: &config,
IsNew: false,
}
components.ConfigForm(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleCreateConfig handles the POST /configs route
func (h *Handlers) HandleCreateConfig(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBind(&config); err != nil {
log.Printf("Error binding config form: %v", err)
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
return
}
userID := c.GetUint("userID")
config.CreatedBy = userID
if err := h.DB.Create(&config).Error; err != nil {
log.Printf("Error creating config: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to create config: %v", err))
return
}
// Generate rclone config file
if err := h.DB.GenerateRcloneConfig(&config); err != nil {
log.Printf("Warning: Failed to generate rclone config: %v", err)
// Continue anyway, as the config was created in the database
} else {
log.Printf("Generated rclone config for config ID %d", config.ID)
}
c.Redirect(http.StatusFound, "/configs")
}
// HandleUpdateConfig handles the PUT /configs/:id route
func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
log.Printf("Error finding config: %v", err)
c.String(http.StatusNotFound, "Config not found")
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.String(http.StatusForbidden, "You do not have permission to update this config")
return
}
}
// Get the old config values for comparison
oldConfig := config
// Bind form data to config
if err := c.ShouldBind(&config); err != nil {
log.Printf("Error binding config form: %v", err)
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
return
}
// Preserve fields that shouldn't be updated
config.CreatedBy = oldConfig.CreatedBy
if err := h.DB.Save(&config).Error; err != nil {
log.Printf("Error updating config: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update config: %v", err))
return
}
// Regenerate rclone config file
if err := h.DB.GenerateRcloneConfig(&config); err != nil {
log.Printf("Warning: Failed to regenerate rclone config: %v", err)
// Continue anyway, as the config was updated in the database
} else {
log.Printf("Regenerated rclone config for config ID %d", config.ID)
}
c.Redirect(http.StatusFound, "/configs")
}
// HandleDeleteConfig handles the DELETE /configs/:id route
func (h *Handlers) HandleDeleteConfig(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var config db.TransferConfig
if err := h.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Config not found"})
return
}
// Check if user owns this config
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to delete this config"})
return
}
}
// Check if config is in use by any jobs
var jobCount int64
h.DB.Model(&db.Job{}).Where("config_id = ?", config.ID).Count(&jobCount)
if jobCount > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Config is in use by jobs and cannot be deleted"})
return
}
// Delete config
if err := h.DB.Delete(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete config: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
}
// HandleTestConnection handles the POST /configs/test route
func (h *Handlers) HandleTestConnection(c *gin.Context) {
var config db.TransferConfig
if err := c.ShouldBind(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid form data: %v", err)})
return
}
// TODO: Implement connection testing based on protocol
// This is a placeholder for the actual connection testing logic
success := true
message := "Connection successful"
// Example of how connection testing might work
switch config.SourceType {
case "sftp":
// Test SFTP connection
// success, message = testSFTPConnection(config)
default:
success = false
message = "Unsupported source type"
}
c.JSON(http.StatusOK, gin.H{
"success": success,
"message": message,
})
}
// HandleTestSFTPConnection handles the test SFTP connection request
func (h *Handlers) HandleTestSFTPConnection(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "Test SFTP connection handler stub"})
}
// HandleBrowseDirectory handles the browse directory request
func (h *Handlers) HandleBrowseDirectory(c *gin.Context) {
// Implementation will be moved from the old handlers.go
c.JSON(http.StatusOK, gin.H{"message": "Browse directory handler stub"})
}
+106
View File
@@ -0,0 +1,106 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleDashboard handles the GET /dashboard route
func (h *Handlers) HandleDashboard(c *gin.Context) {
// Get recent job history
var recentHistory []db.JobHistory
h.DB.Order("start_time DESC").Limit(5).Find(&recentHistory)
// Get job statistics
var totalJobs int64
h.DB.Model(&db.JobHistory{}).Where("job_histories.status = 'running' AND job_histories.end_time IS NULL").Count(&totalJobs)
var completedJobs int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "completed").Count(&completedJobs)
var failedJobs int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failed").Count(&failedJobs)
data := components.DashboardData{
RecentJobs: recentHistory,
ActiveTransfers: int(totalJobs),
CompletedToday: int(completedJobs),
FailedTransfers: int(failedJobs),
}
components.Dashboard(components.CreateTemplateContext(c), data).Render(c, c.Writer)
}
// HandleDashboardStats handles the dashboard stats API request
func (h *Handlers) HandleDashboardStats(c *gin.Context) {
userID := c.GetUint("userID")
// Get job statistics
var activeJobCount int64
var completedJobCount int64
var failedJobCount int64
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "running").Count(&activeJobCount)
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "completed").Count(&completedJobCount)
h.DB.Model(&db.Job{}).Where("created_by = ? AND status = ?", userID, "failed").Count(&failedJobCount)
// Get transfer statistics for the last 7 days
var dailyStats []struct {
Date string `json:"date"`
Completed int64 `json:"completed"`
Failed int64 `json:"failed"`
}
for i := 6; i >= 0; i-- {
date := time.Now().AddDate(0, 0, -i)
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local)
endOfDay := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, time.Local)
var completed int64
var failed int64
h.DB.Model(&db.Job{}).
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "completed", startOfDay, endOfDay).
Count(&completed)
h.DB.Model(&db.Job{}).
Where("created_by = ? AND status = ? AND last_run BETWEEN ? AND ?", userID, "failed", startOfDay, endOfDay).
Count(&failed)
dailyStats = append(dailyStats, struct {
Date string `json:"date"`
Completed int64 `json:"completed"`
Failed int64 `json:"failed"`
}{
Date: startOfDay.Format("2006-01-02"),
Completed: completed,
Failed: failed,
})
}
c.JSON(http.StatusOK, gin.H{
"activeJobs": activeJobCount,
"completedJobs": completedJobCount,
"failedJobs": failedJobCount,
"dailyStats": dailyStats,
"uptime": time.Since(h.StartTime).String(),
"uptimeSeconds": int64(time.Since(h.StartTime).Seconds()),
})
}
// HandleRecentJobs handles the recent jobs API request
func (h *Handlers) HandleRecentJobs(c *gin.Context) {
userID := c.GetUint("userID")
var recentJobs []db.Job
h.DB.Where("created_by = ?", userID).Order("created_at DESC").Limit(5).Find(&recentJobs)
c.JSON(http.StatusOK, gin.H{
"recentJobs": recentJobs,
})
}
+33
View File
@@ -0,0 +1,33 @@
package handlers
import (
"time"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/email"
"github.com/starfleetcptn/gomft/internal/scheduler"
)
// Handlers contains all the dependencies needed by the handlers
type Handlers struct {
DB *db.DB
Scheduler *scheduler.Scheduler
JWTSecret string
StartTime time.Time
DBPath string
BackupDir string
Email *email.Service
}
// NewHandlers creates a new Handlers instance
func NewHandlers(database *db.DB, scheduler *scheduler.Scheduler, jwtSecret string, dbPath string, backupDir string, emailService *email.Service) *Handlers {
return &Handlers{
DB: database,
Scheduler: scheduler,
JWTSecret: jwtSecret,
StartTime: time.Now(),
DBPath: dbPath,
BackupDir: backupDir,
Email: emailService,
}
}
+301
View File
@@ -0,0 +1,301 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleJobs handles the GET /jobs route
func (h *Handlers) HandleJobs(c *gin.Context) {
userID := c.GetUint("userID")
var jobs []db.Job
h.DB.Where("created_by = ?", userID).Preload("Config").Find(&jobs)
data := components.JobsData{
Jobs: jobs,
}
components.Jobs(c, data).Render(c, c.Writer)
}
// HandleJobRunDetails handles the GET /job/:id route
func (h *Handlers) HandleJobRunDetails(c *gin.Context) {
userID := c.GetUint("userID")
jobID := c.Param("id")
// Get job history
var jobHistory db.JobHistory
if err := h.DB.First(&jobHistory, jobID).Error; err != nil {
c.String(http.StatusNotFound, "Job not found")
return
}
// Get job
var job db.Job
if err := h.DB.First(&job, jobHistory.JobID).Error; err != nil {
c.String(http.StatusNotFound, "Job not found")
return
}
// Verify that the user owns this job
if job.CreatedBy != userID {
c.String(http.StatusForbidden, "You don't have permission to view this job run")
return
}
// Get the config
var config db.TransferConfig
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
c.String(http.StatusNotFound, "Configuration not found")
return
}
data := components.JobRunDetailsData{
JobHistory: jobHistory,
Job: job,
Config: config,
}
components.JobRunDetails(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleNewJob handles the GET /jobs/new route
func (h *Handlers) HandleNewJob(c *gin.Context) {
// Get available configs for the user
userID := c.GetUint("userID")
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
data := components.JobFormData{
Job: &db.Job{},
Configs: configs,
IsNew: true,
}
components.JobForm(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleEditJob handles the GET /jobs/:id/edit route
func (h *Handlers) HandleEditJob(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
c.Redirect(http.StatusFound, "/jobs")
return
}
// Check if user owns this job
if job.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.Redirect(http.StatusFound, "/jobs")
return
}
}
// Get available configs for the user
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
data := components.JobFormData{
Job: &job,
Configs: configs,
IsNew: false,
}
components.JobForm(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleCreateJob handles the POST /jobs route
func (h *Handlers) HandleCreateJob(c *gin.Context) {
var job db.Job
if err := c.ShouldBind(&job); err != nil {
c.String(http.StatusBadRequest, "Invalid form data")
return
}
userID := c.GetUint("userID")
job.CreatedBy = userID
// Verify that the config exists and belongs to the user
var config db.TransferConfig
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
c.String(http.StatusBadRequest, "Invalid configuration selected")
return
}
// Check if the config belongs to the user
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
return
}
}
// If job name is empty, use the config name
if job.Name == "" {
job.Name = config.Name
}
// Clear the Config field to prevent GORM from creating a new config
job.Config = db.TransferConfig{}
// Create the job
if err := h.DB.CreateJob(&job); err != nil {
c.String(http.StatusInternalServerError, "Failed to create job")
return
}
// Schedule the job with the scheduler
if err := h.Scheduler.ScheduleJob(&job); err != nil {
c.String(http.StatusInternalServerError, "Job created but scheduling failed: "+err.Error())
return
}
c.Redirect(http.StatusFound, "/jobs")
}
// HandleUpdateJob handles the PUT /jobs/:id route
func (h *Handlers) HandleUpdateJob(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
c.String(http.StatusNotFound, "Job not found")
return
}
// Check if user owns this job
if job.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.String(http.StatusForbidden, "You do not have permission to update this job")
return
}
}
// Get the old job values for comparison
oldJob := job
// Bind form data to job
if err := c.ShouldBind(&job); err != nil {
c.String(http.StatusBadRequest, "Invalid form data")
return
}
// Verify that the config exists and belongs to the user
var config db.TransferConfig
if err := h.DB.First(&config, job.ConfigID).Error; err != nil {
c.String(http.StatusBadRequest, "Invalid configuration selected")
return
}
// Check if the config belongs to the user
if config.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
return
}
}
// If job name is empty, use the config name
if job.Name == "" {
job.Name = config.Name
}
// Preserve fields that shouldn't be updated
job.CreatedBy = oldJob.CreatedBy
job.ID = oldJob.ID
// Clear the Config field to prevent GORM from updating or creating a new config
job.Config = db.TransferConfig{}
if err := h.DB.UpdateJob(&job); err != nil {
c.String(http.StatusInternalServerError, "Failed to update job")
return
}
// Reschedule the job with the scheduler
if err := h.Scheduler.ScheduleJob(&job); err != nil {
c.String(http.StatusInternalServerError, "Job updated but scheduling failed: "+err.Error())
return
}
c.Redirect(http.StatusFound, "/jobs")
}
// HandleDeleteJob handles the DELETE /jobs/:id route
func (h *Handlers) HandleDeleteJob(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user owns this job
if job.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to delete this job"})
return
}
}
// Unschedule the job from the scheduler
h.Scheduler.UnscheduleJob(job.ID)
// Delete job
if err := h.DB.Delete(&job).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete job"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Job deleted successfully"})
}
// HandleRunJob handles the POST /jobs/:id/run route
func (h *Handlers) HandleRunJob(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var job db.Job
if err := h.DB.First(&job, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
return
}
// Check if user owns this job
if job.CreatedBy != userID {
// Check if user is admin
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "You do not have permission to run this job"})
return
}
}
// Run the job immediately using the scheduler
if err := h.Scheduler.RunJobNow(job.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run job: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Job started successfully",
"jobId": job.ID,
})
}
+78
View File
@@ -0,0 +1,78 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleProfile handles the GET /profile route
func (h *Handlers) HandleProfile(c *gin.Context) {
userID := c.GetUint("userID")
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to retrieve user profile")
return
}
components.Profile(c.Request.Context(), user).Render(c, c.Writer)
}
// HandleUpdateTheme handles the POST /profile/theme route
func (h *Handlers) HandleUpdateTheme(c *gin.Context) {
userID := c.GetUint("userID")
theme := c.PostForm("theme")
// Validate theme value
validThemes := map[string]bool{
"light": true,
"dark": true,
"system": true,
}
if !validThemes[theme] {
c.Status(http.StatusBadRequest)
return
}
// Update user theme preference
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.Status(http.StatusInternalServerError)
return
}
user.Theme = theme
if err := h.DB.Save(&user).Error; err != nil {
c.Status(http.StatusInternalServerError)
return
}
// Set theme cookie for client-side theme switching
c.SetCookie("theme", theme, 60*60*24*365, "/", "", false, false)
c.Status(http.StatusOK)
}
// HandleUpdateProfile handles the POST /profile/update route
func (h *Handlers) HandleUpdateProfile(c *gin.Context) {
userID := c.GetUint("userID")
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.String(http.StatusNotFound, "User not found")
return
}
// Update user fields
user.Email = c.PostForm("email")
if err := h.DB.Save(&user).Error; err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update profile: %v", err))
return
}
c.Redirect(http.StatusFound, "/profile")
}
+267
View File
@@ -0,0 +1,267 @@
package handlers
import (
"fmt"
"math"
"net/http"
"strconv"
"net/url"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleHistory handles the GET /history route
func (h *Handlers) HandleHistory(c *gin.Context) {
userID := c.GetUint("userID")
// Get pagination parameters
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
if err != nil {
pageSize = 10
}
// Limit page size options
if pageSize != 10 && pageSize != 25 && pageSize != 50 && pageSize != 100 {
pageSize = 10
}
// Get search term
searchTerm := c.Query("search")
// Build the query
query := h.DB.Model(&db.JobHistory{}).
Joins("JOIN jobs ON jobs.id = job_histories.job_id").
Joins("JOIN transfer_configs ON transfer_configs.id = jobs.config_id").
Where("jobs.created_by = ?", userID)
// Apply search if provided
if searchTerm != "" {
query = query.Where("transfer_configs.name LIKE ? OR job_histories.status LIKE ?",
"%"+searchTerm+"%", "%"+searchTerm+"%")
}
// Count total matching records for pagination
var total int64
query.Count(&total)
// Calculate total pages
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
if totalPages == 0 {
totalPages = 1
}
// Ensure page is within bounds
if page > totalPages {
page = totalPages
}
// Get paginated results
var history []db.JobHistory
offset := (page - 1) * pageSize
query.Offset(offset).
Limit(pageSize).
Preload("Job.Config").
Order("start_time desc").
Find(&history)
// If we got no results and we're not on page 1, redirect to page 1
// Only do this for non-HTMX requests to avoid navigation issues
isHtmxRequest := c.GetHeader("HX-Request") == "true"
if len(history) == 0 && page > 1 && total > 0 && !isHtmxRequest {
redirectURL := fmt.Sprintf("/history?page=1&pageSize=%d", pageSize)
if searchTerm != "" {
redirectURL += fmt.Sprintf("&search=%s", url.QueryEscape(searchTerm))
}
c.Redirect(http.StatusFound, redirectURL)
return
}
data := components.HistoryData{
History: history,
CurrentPage: page,
TotalPages: totalPages,
SearchTerm: searchTerm,
PageSize: pageSize,
Total: int(total),
}
// If this is an HTMX request, only render the history content component
if isHtmxRequest {
components.HistoryContent(c, data).Render(c, c.Writer)
} else {
components.History(c, data).Render(c, c.Writer)
}
}
// HandleDashboardData handles the GET /dashboard/data route
func (h *Handlers) HandleDashboardData(c *gin.Context) {
// Get recent job runs
var recentRuns []db.JobHistory
if err := h.DB.Preload("Job").Order("start_time desc").Limit(5).Find(&recentRuns).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve recent runs"})
return
}
c.JSON(http.StatusOK, gin.H{
"recent_runs": recentRuns,
})
}
// HandleDashboardJobsData handles the GET /dashboard/jobs route
func (h *Handlers) HandleDashboardJobsData(c *gin.Context) {
// Get active jobs
var activeJobs []db.Job
if err := h.DB.Where("enabled = ?", true).Find(&activeJobs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve active jobs"})
return
}
c.JSON(http.StatusOK, gin.H{
"active_jobs": activeJobs,
})
}
// HandleDashboardHistoryData handles the GET /dashboard/history route
func (h *Handlers) HandleDashboardHistoryData(c *gin.Context) {
// Get job history stats
var successCount int64
var failureCount int64
var pendingCount int64
h.DB.Model(&db.JobHistory{}).Where("status = ?", "success").Count(&successCount)
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failure").Count(&failureCount)
h.DB.Model(&db.JobHistory{}).Where("status = ?", "pending").Count(&pendingCount)
c.JSON(http.StatusOK, gin.H{
"success_count": successCount,
"failure_count": failureCount,
"pending_count": pendingCount,
})
}
// RegisterRoutes registers all the routes for the web interface
func (h *Handlers) RegisterRoutes(router *gin.Engine) {
// Public routes
router.GET("/", h.HandleHome)
router.GET("/login", h.HandleLoginPage)
router.POST("/login", h.HandleLogin)
router.GET("/forgot-password", h.HandleForgotPasswordPage)
router.POST("/forgot-password", h.HandleForgotPassword)
router.GET("/reset-password", h.HandleResetPasswordPage)
router.POST("/reset-password", h.HandleResetPassword)
// Protected routes
authorized := router.Group("/")
authorized.Use(h.AuthMiddleware())
// Password change route - only accessed from profile page
authorized.POST("/change-password", h.HandleChangePassword)
{
authorized.GET("/dashboard", h.HandleDashboard)
authorized.GET("/configs", h.HandleConfigs)
authorized.GET("/configs/new", h.HandleNewConfig)
authorized.GET("/configs/:id", h.HandleEditConfig)
authorized.POST("/configs", h.HandleCreateConfig)
authorized.PUT("/configs/:id", h.HandleUpdateConfig)
authorized.POST("/configs/:id", h.HandleUpdateConfig) // Add POST route for form submission
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
authorized.GET("/jobs", h.HandleJobs)
authorized.GET("/jobs/new", h.HandleNewJob)
authorized.GET("/jobs/:id", h.HandleEditJob)
authorized.POST("/jobs", h.HandleCreateJob)
authorized.PUT("/jobs/:id", h.HandleUpdateJob)
authorized.POST("/jobs/:id", h.HandleUpdateJob) // Add POST route for form submission
authorized.DELETE("/jobs/:id", h.HandleDeleteJob)
authorized.POST("/jobs/:id/run", h.HandleRunJob)
authorized.GET("/history", h.HandleHistory)
authorized.GET("/job-runs/:id", h.HandleJobRunDetails)
authorized.GET("/profile", h.HandleProfile)
authorized.POST("/profile/theme", h.HandleUpdateTheme)
authorized.POST("/logout", h.HandleLogout)
// AJAX routes for dashboard
authorized.GET("/dashboard/data", h.HandleDashboardData)
authorized.GET("/dashboard/jobs", h.HandleDashboardJobsData)
authorized.GET("/dashboard/history", h.HandleDashboardHistoryData)
// Test connection routes
authorized.POST("/test-connection", h.HandleTestConnection)
authorized.POST("/test-sftp-connection", h.HandleTestSFTPConnection)
authorized.POST("/browse-directory", h.HandleBrowseDirectory)
}
// Admin-only routes
admin := router.Group("/admin")
admin.Use(h.AuthMiddleware(), h.AdminMiddleware())
{
admin.GET("/users", h.HandleUsers)
admin.GET("/users/new", h.HandleNewUser)
admin.POST("/users", h.HandleCreateUser)
admin.DELETE("/users/:id", h.HandleDeleteUser)
admin.GET("/register", h.HandleRegisterPage)
admin.POST("/register", h.HandleRegister)
// Admin tools routes
admin.GET("/tools", h.HandleAdminTools)
admin.POST("/backup-database", h.HandleBackupDatabase)
admin.POST("/restore-database", h.HandleRestoreDatabase)
admin.POST("/restore-database/:filename", h.HandleRestoreDatabaseByFilename)
admin.GET("/export-configs", h.HandleExportConfigs)
admin.GET("/export-jobs", h.HandleExportJobs)
admin.POST("/clear-job-history", h.HandleClearJobHistory)
admin.POST("/vacuum-database", h.HandleVacuumDatabase)
admin.GET("/download-backup/:filename", h.HandleDownloadBackup)
admin.DELETE("/delete-backup/:filename", h.HandleDeleteBackup)
admin.GET("/refresh-backups", h.HandleRefreshBackups)
}
// API routes
api := router.Group("/api")
{
api.POST("/login", h.HandleAPILogin)
// Protected API routes
apiAuthorized := api.Group("/")
apiAuthorized.Use(h.APIAuthMiddleware())
{
// Config endpoints
apiAuthorized.GET("/configs", h.HandleAPIConfigs)
apiAuthorized.GET("/configs/:id", h.HandleAPIConfig)
apiAuthorized.POST("/configs", h.HandleAPICreateConfig)
apiAuthorized.PUT("/configs/:id", h.HandleAPIUpdateConfig)
apiAuthorized.DELETE("/configs/:id", h.HandleAPIDeleteConfig)
// Job endpoints
apiAuthorized.GET("/jobs", h.HandleAPIJobs)
apiAuthorized.GET("/jobs/:id", h.HandleAPIJob)
apiAuthorized.POST("/jobs", h.HandleAPICreateJob)
apiAuthorized.PUT("/jobs/:id", h.HandleAPIUpdateJob)
apiAuthorized.DELETE("/jobs/:id", h.HandleAPIDeleteJob)
apiAuthorized.POST("/jobs/:id/run", h.HandleAPIRunJob)
// History endpoints
apiAuthorized.GET("/history", h.HandleAPIHistory)
apiAuthorized.GET("/job-runs/:id", h.HandleAPIJobRun)
// Admin-only API routes
apiAdmin := apiAuthorized.Group("/admin")
apiAdmin.Use(h.APIAdminMiddleware())
{
// User management
apiAdmin.GET("/users", h.HandleAPIUsers)
apiAdmin.GET("/users/:id", h.HandleAPIUser)
apiAdmin.POST("/users", h.HandleAPICreateUser)
apiAdmin.PUT("/users/:id", h.HandleAPIUpdateUser)
apiAdmin.DELETE("/users/:id", h.HandleAPIDeleteUser)
}
}
}
}
+316
View File
@@ -0,0 +1,316 @@
package handlers
import (
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
"golang.org/x/crypto/bcrypt"
)
// HandleUsers handles the GET /admin/users route
func (h *Handlers) HandleUsers(c *gin.Context) {
var users []db.User
if err := h.DB.Find(&users).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to retrieve users")
return
}
data := components.UsersData{
Users: users,
}
components.Users(components.CreateTemplateContext(c), data).Render(c, c.Writer)
}
// HandleNewUser handles the GET /admin/users/new route
func (h *Handlers) HandleNewUser(c *gin.Context) {
data := components.UserFormData{
IsNew: true,
ErrorMessage: "",
}
components.UserForm(components.CreateTemplateContext(c), data).Render(c, c.Writer)
}
// HandleCreateUser handles the POST /admin/users/new route
func (h *Handlers) HandleCreateUser(c *gin.Context) {
email := c.PostForm("email")
password := c.PostForm("password")
isAdmin := c.PostForm("is_admin") == "on"
// Check if email already exists
var existingUser db.User
if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil {
c.String(http.StatusBadRequest, "Email already exists")
return
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to hash password")
return
}
// Create the user
user := db.User{
Email: email,
PasswordHash: string(hashedPassword),
IsAdmin: isAdmin,
LastPasswordChange: time.Now(),
}
if err := h.DB.Create(&user).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to create user")
return
}
c.Redirect(http.StatusSeeOther, "/admin/users")
}
// HandleDeleteUser handles the POST /admin/users/delete route
func (h *Handlers) HandleDeleteUser(c *gin.Context) {
userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.String(http.StatusBadRequest, "Invalid user ID")
return
}
// Don't allow deleting the current user
currentUserID := c.GetUint("userID")
if uint(userID) == currentUserID {
c.String(http.StatusBadRequest, "Cannot delete your own account")
return
}
// Delete the user
if err := h.DB.Delete(&db.User{}, userID).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to delete user")
return
}
c.Redirect(http.StatusSeeOther, "/admin/users")
}
// HandleRegisterPage handles the GET /register route
func (h *Handlers) HandleRegisterPage(c *gin.Context) {
// Check if any users exist
var count int64
h.DB.Model(&db.User{}).Count(&count)
// If users exist, don't allow registration
if count > 0 {
c.Redirect(http.StatusSeeOther, "/")
return
}
components.Register(c.Request.Context(), "").Render(c, c.Writer)
}
// HandleRegister handles the POST /register route
func (h *Handlers) HandleRegister(c *gin.Context) {
// Check if any users exist
var count int64
h.DB.Model(&db.User{}).Count(&count)
// If users exist, don't allow registration
if count > 0 {
c.Redirect(http.StatusSeeOther, "/")
return
}
email := c.PostForm("email")
password := c.PostForm("password")
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to hash password")
return
}
// Create the admin user
user := db.User{
Email: email,
PasswordHash: string(hashedPassword),
IsAdmin: true,
LastPasswordChange: time.Now(),
}
if err := h.DB.Create(&user).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to create user")
return
}
// Generate JWT
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
if err != nil {
c.String(http.StatusInternalServerError, "Failed to generate token")
return
}
// Set cookie
c.SetCookie("jwt", token, 60*60*24, "/", "", false, true)
c.Redirect(http.StatusSeeOther, "/dashboard")
}
// HandleEditUser handles the edit user page request
func (h *Handlers) HandleEditUser(c *gin.Context) {
// Only admin users can access this page
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.Redirect(http.StatusFound, "/dashboard")
return
}
id := c.Param("id")
var user db.User
if err := h.DB.First(&user, id).Error; err != nil {
c.Redirect(http.StatusFound, "/users")
return
}
data := components.UserFormData{
IsNew: false,
ErrorMessage: "",
}
components.UserForm(c.Request.Context(), data).Render(c, c.Writer)
}
// HandleUpdateUser handles the update user form submission
func (h *Handlers) HandleUpdateUser(c *gin.Context) {
// Only admin users can update users
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.String(http.StatusForbidden, "Only administrators can update users")
return
}
id := c.Param("id")
var user db.User
if err := h.DB.First(&user, id).Error; err != nil {
log.Printf("Error finding user: %v", err)
c.String(http.StatusNotFound, "User not found")
return
}
// Get the old user values for comparison
oldUser := user
// Bind form data to user
if err := c.ShouldBind(&user); err != nil {
log.Printf("Error binding user form: %v", err)
c.String(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %v", err))
return
}
// Check if email already exists for a different user
var existingUser db.User
if user.Email != oldUser.Email {
if err := h.DB.Where("email = ? AND id != ?", user.Email, user.ID).First(&existingUser).Error; err == nil {
c.String(http.StatusBadRequest, "Email already in use")
return
}
}
// Get password from form
password := c.PostForm("password")
// Only update password if provided
if password != "" {
// Validate password complexity
if !h.validatePasswordComplexity(password) {
c.String(http.StatusBadRequest, "Password does not meet complexity requirements")
return
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
c.String(http.StatusInternalServerError, "Failed to hash password")
return
}
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
} else {
// Preserve the old password if not updating
user.PasswordHash = oldUser.PasswordHash
user.LastPasswordChange = oldUser.LastPasswordChange
}
// Preserve fields that shouldn't be updated
user.CreatedAt = oldUser.CreatedAt
user.FailedLoginAttempts = oldUser.FailedLoginAttempts
user.AccountLocked = oldUser.AccountLocked
user.LockoutUntil = oldUser.LockoutUntil
// Update admin status
user.IsAdmin = c.PostForm("is_admin") == "on"
if err := h.DB.Save(&user).Error; err != nil {
log.Printf("Error updating user: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update user: %v", err))
return
}
c.Redirect(http.StatusFound, "/users")
}
// HandleUnlockUser handles the unlock user request
func (h *Handlers) HandleUnlockUser(c *gin.Context) {
// Only admin users can unlock users
isAdmin, exists := c.Get("isAdmin")
if !exists || isAdmin != true {
c.JSON(http.StatusForbidden, gin.H{"error": "Only administrators can unlock users"})
return
}
id := c.Param("id")
var user db.User
if err := h.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Unlock user
user.AccountLocked = false
user.FailedLoginAttempts = 0
user.LockoutUntil = nil
if err := h.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to unlock user: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User unlocked successfully"})
}
// validatePasswordComplexity validates that a password meets complexity requirements
func (h *Handlers) validatePasswordComplexity(password string) bool {
// Password must be at least 8 characters long
if len(password) < 8 {
return false
}
// Check for at least one uppercase letter, one lowercase letter, and one number
hasUpper := false
hasLower := false
hasNumber := false
for _, char := range password {
if char >= 'A' && char <= 'Z' {
hasUpper = true
} else if char >= 'a' && char <= 'z' {
hasLower = true
} else if char >= '0' && char <= '9' {
hasNumber = true
}
}
return hasUpper && hasLower && hasNumber
}
+44
View File
@@ -0,0 +1,44 @@
// AuthMiddleware is a middleware function that checks if the request has a valid JWT token
func (m *Middleware) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Get token from cookie
tokenString, err := c.Cookie("jwt_token")
if err != nil {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Parse and validate token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(m.JWTSecret), nil
})
if err != nil || !token.Valid {
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.SetCookie("jwt_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
// Set user information in context
c.Set("userID", uint(claims["user_id"].(float64)))
c.Set("email", claims["email"].(string))
c.Set("username", claims["username"].(string))
c.Set("isAdmin", claims["is_admin"].(bool))
c.Next()
}
}