mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-10-23 22:40:25 +02:00
initial commit
This commit is contained in:
commit
953ad364ab
68 files changed
+11440
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>© {{.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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user