feat: Integrate Google Drive support and enhance configuration handling

- Add Google Drive as a source and destination option in the configuration forms.
- Implement Google Drive authentication flow and token management.
- Update job and configuration handlers to support Google Drive-specific settings.
- Enhance UI components to include Google Drive configuration templates.
- Introduce new tests for Google Drive integration and ensure proper handling of authentication and configuration.
- Update database migrations to accommodate new fields related to Google Drive configurations.
This commit is contained in:
StarFleetCPTN
2025-03-15 17:36:36 -07:00
parent a954023800
commit 8607f2098a
52 changed files with 2795 additions and 355 deletions
+26 -17
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
@@ -331,7 +330,7 @@ func (h *Handlers) HandleImportConfigs(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -370,7 +369,7 @@ func (h *Handlers) HandleImportJobs(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -401,7 +400,7 @@ func (h *Handlers) HandleImportJobs(c *gin.Context) {
}
if enabled, ok := rawJob["enabled"].(bool); ok {
job.Enabled = enabled
job.SetEnabled(enabled)
}
// Handle config_id
@@ -443,7 +442,7 @@ func (h *Handlers) HandleListBackups(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -466,7 +465,7 @@ func (h *Handlers) HandleSystemInfo(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -494,7 +493,7 @@ func (h *Handlers) HandleImportJobsFromFile(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -547,7 +546,7 @@ func (h *Handlers) HandleImportJobsFromFile(c *gin.Context) {
}
if enabled, ok := rawJob["enabled"].(bool); ok {
job.Enabled = enabled
job.SetEnabled(enabled)
}
// Handle config_id
@@ -589,7 +588,7 @@ func (h *Handlers) HandleDeleteLogFile(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -641,7 +640,7 @@ func (h *Handlers) HandleSystemMaintenanceCheck(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -682,7 +681,7 @@ func (h *Handlers) HandleUpdateSystemSettings(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -736,7 +735,12 @@ func (h *Handlers) checkDatabaseSize() map[string]interface{} {
// Parse size for comparison
var size float64
var unit string
fmt.Sscanf(sizeStr, "%f %s", &size, &unit)
if _, err := fmt.Sscanf(sizeStr, "%f %s", &size, &unit); err != nil {
return map[string]interface{}{
"status": "unknown",
"message": "Unable to determine database size",
}
}
status := "healthy"
message := fmt.Sprintf("Database size is %s", sizeStr)
@@ -1139,7 +1143,7 @@ func (h *Handlers) getLogFiles() []components.LogFile {
}
// Try to read directory
files, err := ioutil.ReadDir(logsDir)
files, err := os.ReadDir(logsDir)
if err != nil {
return []components.LogFile{}
}
@@ -1156,11 +1160,16 @@ func (h *Handlers) getLogFiles() []components.LogFile {
continue
}
size := formatSize(float64(file.Size()))
fileInfo, err := file.Info()
if err != nil {
continue
}
size := formatSize(float64(fileInfo.Size()))
logFiles = append(logFiles, components.LogFile{
Name: file.Name(),
Size: size,
ModTime: file.ModTime(),
ModTime: fileInfo.ModTime(),
Path: filepath.Join(logsDir, file.Name()),
})
}
@@ -1203,7 +1212,7 @@ func (h *Handlers) HandleViewLog(c *gin.Context) {
}
// Read file contents
content, err := ioutil.ReadFile(filePath)
content, err := os.ReadFile(filePath)
if err != nil {
c.String(http.StatusInternalServerError, "Error reading log file: "+err.Error())
return
@@ -1316,7 +1325,7 @@ func (h *Handlers) HandleImportConfigsFromFile(c *gin.Context) {
}
userObj, ok := user.(*db.User)
if !ok || !userObj.IsAdmin {
if !ok || !userObj.GetIsAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
@@ -36,7 +36,7 @@ func TestHandleAdminTools(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Create a test request
@@ -81,7 +81,7 @@ func TestHandleBackupDatabase(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -131,7 +131,7 @@ func TestHandleVacuumDatabase(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -186,7 +186,7 @@ func TestHandleClearJobHistory(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -228,7 +228,7 @@ func TestHandleExportConfigs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
config := &db.TransferConfig{
@@ -267,8 +267,7 @@ func TestHandleExportConfigs(t *testing.T) {
// Parse the response as JSON
var configs []map[string]interface{}
var err error
err = json.Unmarshal(w.Body.Bytes(), &configs)
var err = json.Unmarshal(w.Body.Bytes(), &configs)
assert.NoError(t, err)
assert.Greater(t, len(configs), 0)
}
@@ -281,7 +280,7 @@ func TestHandleExportJobs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Create a test config
@@ -301,7 +300,7 @@ func TestHandleExportJobs(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *", // Every 5 minutes
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
handlers.DB.DB.Create(job)
@@ -332,8 +331,7 @@ func TestHandleExportJobs(t *testing.T) {
// Parse the response as JSON
var jobs []map[string]interface{}
var err error
err = json.Unmarshal(w.Body.Bytes(), &jobs)
var err = json.Unmarshal(w.Body.Bytes(), &jobs)
assert.NoError(t, err)
assert.Greater(t, len(jobs), 0)
}
@@ -346,7 +344,7 @@ func TestHandleImportConfigs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the route
@@ -407,7 +405,7 @@ func TestHandleImportJobs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -481,7 +479,7 @@ func TestHandleExportConfigsUnauthorized(t *testing.T) {
testUser := &db.User{
ID: 2,
Email: "user@example.com",
IsAdmin: false,
IsAdmin: BoolPtr(false),
}
// Set up the context with the non-admin user
@@ -519,7 +517,7 @@ func TestHandleBackupDatabaseError(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user
@@ -589,7 +587,7 @@ func TestHandleListBackups(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -636,7 +634,7 @@ func TestHandleSystemInfo(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -705,7 +703,7 @@ func TestHandleImportConfigsFromFile(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the route
@@ -768,7 +766,7 @@ func TestHandleImportJobsFromFile(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -868,7 +866,7 @@ func TestHandleImportConfigsInvalidJSON(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -937,7 +935,7 @@ func TestHandleDeleteLogFile(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -982,7 +980,7 @@ func TestHandleSystemMaintenanceCheck(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - this must be done BEFORE registering the routes
@@ -1024,7 +1022,7 @@ func TestHandleUpdateSystemSettings(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1088,7 +1086,7 @@ func TestHandleViewLog(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1134,7 +1132,7 @@ func TestHandleViewLogNotFound(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1187,7 +1185,7 @@ func TestHandleDownloadLog(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1241,7 +1239,7 @@ func TestHandleDeleteBackup(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1301,7 +1299,7 @@ func TestHandleDownloadBackup(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1353,7 +1351,7 @@ func TestHandleRefreshLogs(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
@@ -1414,7 +1412,7 @@ func TestHandleRefreshBackups(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up the context with the user - must be done BEFORE registering routes
+1 -1
View File
@@ -35,7 +35,7 @@ func (h *Handlers) HandleAPILogin(c *gin.Context) {
}
// Generate JWT token
token, err := h.GenerateJWT(user.ID, user.Email, user.IsAdmin)
token, err := h.GenerateJWT(user.ID, user.Email, user.GetIsAdmin())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
+10 -13
View File
@@ -26,7 +26,7 @@ func setupAPITest(t *testing.T) (*Handlers, *gin.Engine, *db.DB, *db.User) {
user := &db.User{
Email: "test@example.com",
PasswordHash: string(hashedPassword),
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(user)
@@ -52,10 +52,7 @@ func setupAuthenticatedAPITest(t *testing.T, isAdmin bool) (*Handlers, *gin.Engi
handlers, router, database, user := setupAPITest(t)
// Update user admin status if needed
if isAdmin != user.IsAdmin {
user.IsAdmin = isAdmin
database.Save(user)
}
user.SetIsAdmin(isAdmin)
// Set up authentication middleware
router.Use(func(c *gin.Context) {
@@ -162,7 +159,7 @@ func TestHandleAPIConfigs(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -242,7 +239,7 @@ func TestHandleAPIConfig(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -376,7 +373,7 @@ func TestHandleAPIUpdateConfig(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -483,7 +480,7 @@ func TestHandleAPIDeleteConfig(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -513,7 +510,7 @@ func TestHandleAPIDeleteConfig(t *testing.T) {
Name: "Test Job",
Schedule: "* * * * *",
ConfigID: configWithJob.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -591,7 +588,7 @@ func TestHandleAPIRunJob(t *testing.T) {
Name: "Test Job",
Schedule: "* * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -600,7 +597,7 @@ func TestHandleAPIRunJob(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -609,7 +606,7 @@ func TestHandleAPIRunJob(t *testing.T) {
Name: "Other User Job",
Schedule: "* * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
+49 -40
View File
@@ -17,6 +17,15 @@ import (
"golang.org/x/crypto/bcrypt"
)
// Define a custom type for context keys to avoid string collisions
type contextKey string
// Context keys
const (
themeKey contextKey = "theme"
emailKey contextKey = "email"
)
// AuthMiddleware is a middleware function that checks if the user is authenticated
func (h *Handlers) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -152,16 +161,16 @@ func (h *Handlers) HandleLoginPage(c *gin.Context) {
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)
ctx = context.WithValue(ctx, emailKey, 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)
@@ -183,10 +192,10 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
}
// Check if account is locked
if user.AccountLocked {
if user.GetAccountLocked() {
if user.LockoutUntil != nil && time.Now().After(*user.LockoutUntil) {
// Lockout period has expired, reset the lockout
user.AccountLocked = false
user.SetAccountLocked(false)
user.FailedLoginAttempts = 0
user.LockoutUntil = nil
h.DB.Save(&user)
@@ -201,18 +210,18 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
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
user.SetAccountLocked(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
@@ -220,7 +229,7 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
// Reset failed login attempts on successful login
user.FailedLoginAttempts = 0
user.AccountLocked = false
user.SetAccountLocked(false)
user.LockoutUntil = nil
h.DB.Save(&user)
@@ -276,7 +285,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
return
}
claims, err := auth.ValidateToken(tokenCookie, h.JWTSecret)
if err != nil {
if c.GetHeader("HX-Request") == "true" {
@@ -290,12 +299,12 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
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">
@@ -303,7 +312,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</div>`))
return
}
// Get user
var user db.User
if err := h.DB.First(&user, userID).Error; err != nil {
@@ -312,7 +321,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</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">
@@ -320,7 +329,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</div>`))
return
}
// Validate password against policy
policy := auth.DefaultPasswordPolicy()
if err := auth.ValidatePassword(newPassword, policy); err != nil {
@@ -330,7 +339,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
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">
@@ -339,7 +348,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
c.Data(http.StatusOK, "text/html", []byte(errorMsg))
return
}
// Hash the new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
@@ -348,7 +357,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</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">
@@ -356,7 +365,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</div>`))
return
}
// Update user's password
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
@@ -366,7 +375,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
</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>
@@ -375,7 +384,7 @@ func (h *Handlers) HandleChangePassword(c *gin.Context) {
// HandleForgotPasswordPage displays the forgot password form
func (h *Handlers) HandleForgotPasswordPage(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ForgotPassword(ctx, "", "").Render(c.Request.Context(), c.Writer)
}
@@ -383,7 +392,7 @@ func (h *Handlers) HandleForgotPasswordPage(c *gin.Context) {
func (h *Handlers) HandleForgotPassword(c *gin.Context) {
email := c.PostForm("email")
if email == "" {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ForgotPassword(ctx, "Email is required", "").Render(c.Request.Context(), c.Writer)
return
}
@@ -394,7 +403,7 @@ func (h *Handlers) HandleForgotPassword(c *gin.Context) {
// 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")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ForgotPassword(ctx, "", "If your email is registered, you will receive a password reset link.").Render(c.Request.Context(), c.Writer)
return
}
@@ -403,7 +412,7 @@ func (h *Handlers) HandleForgotPassword(c *gin.Context) {
token, err := generateResetToken(32)
if err != nil {
log.Printf("Error generating reset token: %v", err)
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ForgotPassword(ctx, "An error occurred. Please try again later.", "").Render(c.Request.Context(), c.Writer)
return
}
@@ -411,33 +420,33 @@ func (h *Handlers) HandleForgotPassword(c *gin.Context) {
// 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,
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")
ctx := context.WithValue(c.Request.Context(), themeKey, "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")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ForgotPassword(ctx, "", "If your email is registered, you will receive a password reset link.").Render(c.Request.Context(), c.Writer)
}
@@ -457,7 +466,7 @@ func (h *Handlers) HandleResetPasswordPage(c *gin.Context) {
return
}
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ResetPassword(ctx, token, "").Render(c.Request.Context(), c.Writer)
}
@@ -473,19 +482,19 @@ func (h *Handlers) HandleResetPassword(c *gin.Context) {
}
if password == "" || confirmPassword == "" {
ctx := context.WithValue(c.Request.Context(), "theme", "light")
ctx := context.WithValue(c.Request.Context(), themeKey, "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")
ctx := context.WithValue(c.Request.Context(), themeKey, "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")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ResetPassword(ctx, token, "Password must be at least 8 characters long.").Render(c.Request.Context(), c.Writer)
return
}
@@ -510,7 +519,7 @@ func (h *Handlers) HandleResetPassword(c *gin.Context) {
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")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ResetPassword(ctx, token, "An error occurred. Please try again later.").Render(c.Request.Context(), c.Writer)
return
}
@@ -520,7 +529,7 @@ func (h *Handlers) HandleResetPassword(c *gin.Context) {
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")
ctx := context.WithValue(c.Request.Context(), themeKey, "light")
components.ResetPassword(ctx, token, "An error occurred. Please try again later.").Render(c.Request.Context(), c.Writer)
return
}
+13 -13
View File
@@ -318,9 +318,9 @@ func TestHandleLogin(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: string(hashedPassword),
IsAdmin: false,
IsAdmin: BoolPtr(false),
FailedLoginAttempts: 0,
AccountLocked: false,
AccountLocked: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(user)
@@ -437,9 +437,9 @@ func TestHandleChangePassword(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: string(hashedPassword),
IsAdmin: false,
IsAdmin: BoolPtr(false),
FailedLoginAttempts: 0,
AccountLocked: false,
AccountLocked: BoolPtr(false),
LastPasswordChange: time.Now().Add(-24 * time.Hour), // 1 day ago
}
database.Create(user)
@@ -577,7 +577,7 @@ func TestHandleForgotPassword(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: string(hashedPassword),
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(user)
@@ -612,7 +612,7 @@ func TestHandleForgotPassword(t *testing.T) {
result := database.Where("user_id = ?", user.ID).First(&resetToken)
assert.NoError(t, result.Error, "Reset token should be created")
assert.NotEmpty(t, resetToken.Token, "Token should not be empty")
assert.False(t, resetToken.Used, "Token should not be marked as used")
assert.False(t, resetToken.GetUsed(), "Token should not be marked as used")
// Verify email would have been sent (if not mocked)
// Note: We can't check SendPasswordResetEmailCalls with our current mock
@@ -654,7 +654,7 @@ func TestHandleResetPasswordPage(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(user)
@@ -665,7 +665,7 @@ func TestHandleResetPasswordPage(t *testing.T) {
UserID: user.ID,
Token: token,
ExpiresAt: time.Now().Add(15 * time.Minute),
Used: false,
Used: BoolPtr(false),
}
database.Create(resetToken)
@@ -719,7 +719,7 @@ func TestHandleResetPassword(t *testing.T) {
user := &db.User{
Email: "test@example.com",
PasswordHash: string(hashedPassword),
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now().Add(-24 * time.Hour), // 1 day ago
}
database.Create(user)
@@ -730,7 +730,7 @@ func TestHandleResetPassword(t *testing.T) {
UserID: user.ID,
Token: token,
ExpiresAt: time.Now().Add(15 * time.Minute),
Used: false,
Used: BoolPtr(false),
}
database.Create(resetToken)
@@ -767,7 +767,7 @@ func TestHandleResetPassword(t *testing.T) {
// Verify token is marked as used
var updatedToken db.PasswordResetToken
database.First(&updatedToken, resetToken.ID)
assert.True(t, updatedToken.Used, "Token should be marked as used")
assert.True(t, updatedToken.GetUsed(), "Token should be marked as used")
// Test case 2: Passwords don't match
// Create another token first
@@ -776,7 +776,7 @@ func TestHandleResetPassword(t *testing.T) {
UserID: user.ID,
Token: token2,
ExpiresAt: time.Now().Add(15 * time.Minute),
Used: false,
Used: BoolPtr(false),
}
database.Create(resetToken2)
@@ -800,7 +800,7 @@ func TestHandleResetPassword(t *testing.T) {
UserID: user.ID,
Token: token3,
ExpiresAt: time.Now().Add(15 * time.Minute),
Used: false,
Used: BoolPtr(false),
}
database.Create(resetToken3)
+47 -5
View File
@@ -17,8 +17,16 @@ func (h *Handlers) HandleConfigs(c *gin.Context) {
var configs []db.TransferConfig
h.DB.Where("created_by = ?", userID).Find(&configs)
// Check for error or status parameters in the URL
error := c.Query("error")
errorDetails := c.Query("details")
status := c.Query("status")
data := components.ConfigsData{
Configs: configs,
Configs: configs,
Error: error,
ErrorDetails: errorDetails,
Status: status,
}
components.Configs(c.Request.Context(), data).Render(c, c.Writer)
}
@@ -72,10 +80,27 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
userID := c.GetUint("userID")
config.CreatedBy = userID
// Process skipProcessedFiles value (now using pointer)
skipProcessedValue := c.Request.FormValue("skip_processed_files") == "true"
// Process Boolean fields
skipProcessedVal := c.Request.FormValue("skip_processed_files")
skipProcessedValue := skipProcessedVal == "on" || skipProcessedVal == "true"
config.SkipProcessedFiles = &skipProcessedValue
archiveEnabledVal := c.Request.FormValue("archive_enabled")
archiveEnabledValue := archiveEnabledVal == "on" || archiveEnabledVal == "true"
config.ArchiveEnabled = &archiveEnabledValue
deleteAfterTransferVal := c.Request.FormValue("delete_after_transfer")
deleteAfterTransferValue := deleteAfterTransferVal == "on" || deleteAfterTransferVal == "true"
config.DeleteAfterTransfer = &deleteAfterTransferValue
sourcePassiveModeVal := c.Request.FormValue("source_passive_mode")
sourcePassiveModeValue := sourcePassiveModeVal == "on" || sourcePassiveModeVal == "true"
config.SourcePassiveMode = &sourcePassiveModeValue
destPassiveModeVal := c.Request.FormValue("dest_passive_mode")
destPassiveModeValue := destPassiveModeVal == "on" || destPassiveModeVal == "true"
config.DestPassiveMode = &destPassiveModeValue
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))
@@ -125,10 +150,27 @@ func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
return
}
// Process skipProcessedFiles value (now using pointer)
skipProcessedValue := c.Request.FormValue("skip_processed_files") == "true"
// Process Boolean fields
skipProcessedVal := c.Request.FormValue("skip_processed_files")
skipProcessedValue := skipProcessedVal == "on" || skipProcessedVal == "true"
config.SkipProcessedFiles = &skipProcessedValue
archiveEnabledVal := c.Request.FormValue("archive_enabled")
archiveEnabledValue := archiveEnabledVal == "on" || archiveEnabledVal == "true"
config.ArchiveEnabled = &archiveEnabledValue
deleteAfterTransferVal := c.Request.FormValue("delete_after_transfer")
deleteAfterTransferValue := deleteAfterTransferVal == "on" || deleteAfterTransferVal == "true"
config.DeleteAfterTransfer = &deleteAfterTransferValue
sourcePassiveModeVal := c.Request.FormValue("source_passive_mode")
sourcePassiveModeValue := sourcePassiveModeVal == "on" || sourcePassiveModeVal == "true"
config.SourcePassiveMode = &sourcePassiveModeValue
destPassiveModeVal := c.Request.FormValue("dest_passive_mode")
destPassiveModeValue := destPassiveModeVal == "on" || destPassiveModeVal == "true"
config.DestPassiveMode = &destPassiveModeValue
// Preserve fields that shouldn't be updated
config.CreatedBy = oldConfig.CreatedBy
@@ -341,7 +341,7 @@ func TestHandleDeleteConfig(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: configWithJob.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.Create(job).Error; err != nil {
@@ -37,7 +37,7 @@ func setupDashboardTest(t *testing.T) (*Handlers, *gin.Engine, *db.DB) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
if err := database.DB.Create(job).Error; err != nil {
@@ -16,6 +16,10 @@ type FileMetadataHandler struct {
DB *db.DB
}
type UserIDKey string
const userIDKey UserIDKey = "userID"
// ListFileMetadata displays a list of file metadata with pagination and filtering options
func (h *FileMetadataHandler) ListFileMetadata(c *gin.Context) {
userID := c.GetUint("userID")
@@ -322,9 +326,6 @@ func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
return
}
// Create context for template
ctx := components.CreateTemplateContext(c)
// Render the file metadata search template
data := components.FileMetadataSearchData{
Files: fileMetadata,
@@ -348,7 +349,7 @@ func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
}
// Add HTMX request checking and conditional rendering
ctx = context.WithValue(c.Request.Context(), "userID", userID)
ctx := context.WithValue(c.Request.Context(), userIDKey, userID)
// Check if this is an HTMX request
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
@@ -626,7 +627,7 @@ func (h *FileMetadataHandler) HandleFileMetadataSearchPartial(c *gin.Context) {
data.TotalPages++
}
ctx := context.WithValue(c.Request.Context(), "userID", userID)
ctx := context.WithValue(c.Request.Context(), userIDKey, userID)
c.Header("Content-Type", "text/html")
components.FileMetadataSearchContent(data).Render(ctx, c.Writer)
}
@@ -43,7 +43,7 @@ func setupFileMetadataHandlers(t *testing.T) (*FileMetadataHandler, *gin.Engine,
Name: "Test Job for File Metadata",
ConfigID: testConfig.ID,
Schedule: "0 * * * *", // Run hourly
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: testUser.ID,
}
err = handlers.DB.CreateJob(testJob)
+380
View File
@@ -0,0 +1,380 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// HandleGDriveAuth initiates the Google Drive authentication process
func (h *Handlers) HandleGDriveAuth(c *gin.Context) {
// Get the config ID from the query parameter
configIDStr := c.Param("id")
if configIDStr == "" {
RenderErrorPage(c, "Missing configuration ID", "")
return
}
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
RenderErrorPage(c, "Invalid configuration ID", err.Error())
return
}
// Get the configuration
config, err := h.DB.GetTransferConfig(uint(configID))
if err != nil {
RenderErrorPage(c, "Configuration not found", err.Error())
return
}
// Ensure it's a Google Drive configuration
if config.DestinationType != "gdrive" {
RenderErrorPage(c, "Not a Google Drive configuration", "The selected configuration is not set up for Google Drive")
return
}
// Prepare for OAuth
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
// Get Rclone Config Path
rcloneConfigPath := h.DB.GetConfigRclonePath(config)
if rcloneConfigPath == "" {
RenderErrorPage(c, "Rclone config not found", "The selected configuration does not have a valid rclone config")
return
}
// Create a temporary config file for authentication
tempConfigDir := filepath.Join(dataDir, "temp")
if err := os.MkdirAll(tempConfigDir, 0755); err != nil {
RenderErrorPage(c, "Failed to create temporary directory", err.Error())
return
}
tempConfigPath := filepath.Join(tempConfigDir, fmt.Sprintf("gdrive_auth_%d.conf", config.ID))
// Store the temporary config path in a cookie
c.SetCookie("gdrive_temp_config", tempConfigPath, 3600, "/", "", false, true)
// Get base URL for redirect URI
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
// Try to detect the base URL from the request
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
baseURL = fmt.Sprintf("%s://%s", scheme, c.Request.Host)
}
// Define the redirect URI for our callback
redirectURI := fmt.Sprintf("%s/configs/gdrive-callback", baseURL)
// Attempt to get GDRIVE_CLIENT_ID and GDRIVE_CLIENT_SECRET from ENV
clientID := os.Getenv("GDRIVE_CLIENT_ID")
clientSecret := os.Getenv("GDRIVE_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
// Check if we have client credentials in the existing config file
existingClientID, existingClientSecret := h.DB.GetGDriveCredentialsFromConfig(config)
if existingClientID != "" && existingClientSecret != "" {
// Use credentials from existing config
clientID = existingClientID
clientSecret = existingClientSecret
} else {
// fallback to rclone client ID and secret
clientID = "202264815644.apps.googleusercontent.com"
clientSecret = "X4Z3ca8xfWDb1Voo-F9a7ZxJ"
}
}
if config.DestClientID != "" && config.DestClientSecret == "" {
// If user provided just client ID but no secret, try to find the secret in the config
_, existingClientSecret := h.DB.GetGDriveCredentialsFromConfig(config)
if existingClientSecret != "" {
// Use the secret from the existing config with the provided client ID
clientSecret = existingClientSecret
} else {
// If we still can't find a matching secret, show an error
RenderErrorPage(c, "Missing client secret", "You provided a custom client ID but no client secret. Both are required for Google Drive authentication.")
return
}
}
// Generate state parameter for security (to prevent CSRF)
state := fmt.Sprintf("gomft_%d_%d", config.ID, time.Now().Unix())
// Store state in cookie for validation during callback
c.SetCookie("gdrive_auth_state", state, 3600, "/", "", false, true)
// Store config ID in cookie for use during callback
c.SetCookie("gdrive_config_id", configIDStr, 3600, "/", "", false, true)
// Create a config file with redirect URI-based auth
configContent := fmt.Sprintf(`[temp_gdrive]
type = drive
client_id = %s
client_secret = %s
redirect_url = %s
`, clientID, clientSecret, redirectURI)
// Write the config file
if err := os.WriteFile(tempConfigPath, []byte(configContent), 0644); err != nil {
RenderErrorPage(c, "Failed to create temporary config file", err.Error())
return
}
// Direct Google OAuth URL with our redirect
scope := url.QueryEscape("https://www.googleapis.com/auth/drive")
authURL := fmt.Sprintf("https://accounts.google.com/o/oauth2/auth?client_id=%s&redirect_uri=%s&scope=%s&response_type=code&access_type=offline&state=%s",
url.QueryEscape(clientID),
url.QueryEscape(redirectURI),
scope,
url.QueryEscape(state))
// Redirect the user to Google's auth page directly
c.Redirect(http.StatusFound, authURL)
}
// HandleGDriveAuthCallback handles the callback from Google OAuth
func (h *Handlers) HandleGDriveAuthCallback(c *gin.Context) {
// Get auth code from query parameters
authCode := c.Query("code")
if authCode == "" {
RenderErrorPage(c, "Authentication failed", "No authorization code received from Google")
return
}
// Verify state parameter to prevent CSRF
state := c.Query("state")
storedState, err := c.Cookie("gdrive_auth_state")
if err != nil || state != storedState {
RenderErrorPage(c, "Authentication failed", "Invalid state parameter")
return
}
// Get config ID from cookie
configIDStr, err := c.Cookie("gdrive_config_id")
if err != nil {
RenderErrorPage(c, "Authentication failed", "Unable to retrieve configuration ID")
return
}
configID, err := strconv.ParseUint(configIDStr, 10, 64)
if err != nil {
RenderErrorPage(c, "Invalid configuration ID", err.Error())
return
}
// Get the temp config path from cookie
tempConfigPath, err := c.Cookie("gdrive_temp_config")
if err != nil || tempConfigPath == "" {
RenderErrorPage(c, "Session expired", "The authentication session has expired")
return
}
// Get base URL for redirect URI
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
// Try to detect the base URL from the request
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
baseURL = fmt.Sprintf("%s://%s", scheme, c.Request.Host)
}
redirectURI := fmt.Sprintf("%s/configs/gdrive-callback", baseURL)
// Get the configuration to retrieve client ID and secret
config, err := h.DB.GetTransferConfig(uint(configID))
if err != nil {
RenderErrorPage(c, "Failed to get configuration", err.Error())
return
}
// Attempt to get GDRIVE_CLIENT_ID and GDRIVE_CLIENT_SECRET from ENV
clientID := os.Getenv("GDRIVE_CLIENT_ID")
clientSecret := os.Getenv("GDRIVE_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
// Check if we have client credentials in the existing config file
existingClientID, existingClientSecret := h.DB.GetGDriveCredentialsFromConfig(config)
if existingClientID != "" && existingClientSecret != "" {
// Use credentials from existing config
clientID = existingClientID
clientSecret = existingClientSecret
} else {
// fallback to rclone client ID and secret
clientID = "202264815644.apps.googleusercontent.com"
clientSecret = "X4Z3ca8xfWDb1Voo-F9a7ZxJ"
}
}
if config.DestClientID != "" && config.DestClientSecret == "" {
// If user provided just client ID but no secret, try to find the secret in the config
_, existingClientSecret := h.DB.GetGDriveCredentialsFromConfig(config)
if existingClientSecret != "" {
// Use the secret from the existing config with the provided client ID
clientSecret = existingClientSecret
} else {
// If we still can't find a matching secret, show an error
RenderErrorPage(c, "Missing client secret", "You provided a custom client ID but no client secret. Both are required for Google Drive authentication.")
return
}
}
// Exchange auth code for token using HTTP request
tokenURL := "https://oauth2.googleapis.com/token"
formData := url.Values{
"code": {authCode},
"client_id": {clientID},
"client_secret": {clientSecret},
"redirect_uri": {redirectURI},
"grant_type": {"authorization_code"},
}
resp, err := http.PostForm(tokenURL, formData)
if err != nil {
RenderErrorPage(c, "Failed to exchange authorization code for token", err.Error())
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
RenderErrorPage(c, "Failed to read token response", err.Error())
return
}
if resp.StatusCode != http.StatusOK {
RenderErrorPage(c, "Failed to exchange authorization code for token", string(body))
return
}
// Parse the token response
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
RenderErrorPage(c, "Failed to parse token response", err.Error())
return
}
// Create a token JSON in the format rclone expects
tokenJSON := fmt.Sprintf(`{
"access_token": "%s",
"token_type": "%s",
"refresh_token": "%s",
"expiry": "%s"
}`,
tokenResp.AccessToken,
tokenResp.TokenType,
tokenResp.RefreshToken,
time.Now().Add(time.Duration(tokenResp.ExpiresIn)*time.Second).Format(time.RFC3339))
// Mark the configuration as authenticated in the database
config.SetGoogleDriveAuthenticated(true)
if err := h.DB.UpdateTransferConfig(config); err != nil {
RenderErrorPage(c, "Failed to update configuration", err.Error())
return
}
// Generate the rclone config file with the token
if err := h.DB.GenerateRcloneConfigWithToken(config, tokenJSON); err != nil {
RenderErrorPage(c, "Failed to generate rclone configuration", err.Error())
return
}
// Clean up the temporary file
os.Remove(tempConfigPath)
// Clear cookies
c.SetCookie("gdrive_temp_config", "", -1, "/", "", false, true)
c.SetCookie("gdrive_auth_state", "", -1, "/", "", false, true)
c.SetCookie("gdrive_config_id", "", -1, "/", "", false, true)
// Redirect to the config list with a success message
c.Redirect(http.StatusFound, "/configs?status=gdrive_auth_success")
}
// HandleGDriveTokenProcess processes a Google Drive token directly from a URL parameter
func (h *Handlers) HandleGDriveTokenProcess(c *gin.Context) {
// Get the parameters
configID := c.Query("config_id")
if configID == "" {
RenderErrorPage(c, "Missing configuration ID", "")
return
}
token := c.Query("token")
if token == "" {
RenderErrorPage(c, "Missing token", "")
return
}
// Parse config ID
configIDUint, err := strconv.ParseUint(configID, 10, 64)
if err != nil {
RenderErrorPage(c, "Invalid configuration ID", err.Error())
return
}
// Get the configuration
config, err := h.DB.GetTransferConfig(uint(configIDUint))
if err != nil {
RenderErrorPage(c, "Configuration not found", err.Error())
return
}
// Ensure it's a Google Drive configuration
if config.DestinationType != "gdrive" {
RenderErrorPage(c, "Not a Google Drive configuration", "")
return
}
// Mark the configuration as authenticated
config.SetGoogleDriveAuthenticated(true)
if err := h.DB.UpdateTransferConfig(config); err != nil {
RenderErrorPage(c, "Failed to update configuration", err.Error())
return
}
// Generate the rclone config with the token
if err := h.DB.GenerateRcloneConfigWithToken(config, token); err != nil {
RenderErrorPage(c, "Failed to generate rclone configuration", err.Error())
return
}
// Redirect to the config list with success
c.Redirect(http.StatusFound, "/configs?status=gdrive_auth_success")
}
// RenderErrorPage renders an error page with the given message
func RenderErrorPage(c *gin.Context, title string, details string) {
// Here we'd typically use a component for error display
// For now, we'll just redirect to the configs page with an error in the query string
errorURL := "/configs?error=" + url.QueryEscape(title)
if details != "" {
errorURL += "&details=" + url.QueryEscape(details)
}
c.Redirect(http.StatusFound, errorURL)
}
+4 -4
View File
@@ -35,7 +35,7 @@ func TestHandleImportJobsFixed(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up middleware to add the user to the context
@@ -99,7 +99,7 @@ func TestHandleImportJobsFixed(t *testing.T) {
ConfigID: testJobs[0].ConfigID,
ConfigIDs: testJobs[0].ConfigIDs,
Schedule: testJobs[0].Schedule,
Enabled: testJobs[0].Enabled,
Enabled: BoolPtr(testJobs[0].Enabled),
CreatedBy: testJobs[0].CreatedBy,
}
@@ -138,7 +138,7 @@ func TestHandleImportJobsFromFileFixed(t *testing.T) {
testUser := &db.User{
ID: 1,
Email: "admin@example.com",
IsAdmin: true,
IsAdmin: BoolPtr(true),
}
// Set up middleware to add the user to the context - must be done BEFORE registering routes
@@ -218,7 +218,7 @@ func TestHandleImportJobsFromFileFixed(t *testing.T) {
ConfigID: testJobs[0].ConfigID,
ConfigIDs: testJobs[0].ConfigIDs,
Schedule: testJobs[0].Schedule,
Enabled: testJobs[0].Enabled,
Enabled: BoolPtr(testJobs[0].Enabled),
CreatedBy: testJobs[0].CreatedBy,
}
+34
View File
@@ -214,6 +214,23 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) {
// Set the config IDs list
job.SetConfigIDsList(configIDsList)
// Set the boolean fields - handle both "on" and "true" values for checkboxes
enabledVal := c.Request.FormValue("enabled")
jobEnabledValue := enabledVal == "on" || enabledVal == "true"
job.SetEnabled(jobEnabledValue)
webhookEnabledVal := c.Request.FormValue("webhook_enabled")
webhookEnabledValue := webhookEnabledVal == "on" || webhookEnabledVal == "true"
job.SetWebhookEnabled(webhookEnabledValue)
notifySuccessVal := c.Request.FormValue("notify_on_success")
notifyOnSuccessValue := notifySuccessVal == "on" || notifySuccessVal == "true"
job.SetNotifyOnSuccess(notifyOnSuccessValue)
notifyFailureVal := c.Request.FormValue("notify_on_failure")
notifyOnFailureValue := notifyFailureVal == "on" || notifyFailureVal == "true"
job.SetNotifyOnFailure(notifyOnFailureValue)
// Set created by user
job.CreatedBy = userID
@@ -315,6 +332,23 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
// Set the config IDs list
job.SetConfigIDsList(configIDsList)
// Set the boolean fields - handle both "on" and "true" values for checkboxes
enabledVal := c.Request.FormValue("enabled")
jobEnabledValue := enabledVal == "on" || enabledVal == "true"
job.SetEnabled(jobEnabledValue)
webhookEnabledVal := c.Request.FormValue("webhook_enabled")
webhookEnabledValue := webhookEnabledVal == "on" || webhookEnabledVal == "true"
job.SetWebhookEnabled(webhookEnabledValue)
notifySuccessVal := c.Request.FormValue("notify_on_success")
notifyOnSuccessValue := notifySuccessVal == "on" || notifySuccessVal == "true"
job.SetNotifyOnSuccess(notifyOnSuccessValue)
notifyFailureVal := c.Request.FormValue("notify_on_failure")
notifyOnFailureValue := notifyFailureVal == "on" || notifyFailureVal == "true"
job.SetNotifyOnFailure(notifyOnFailureValue)
// Preserve fields that shouldn't be updated
job.CreatedBy = oldJob.CreatedBy
job.ID = oldJob.ID
+34 -34
View File
@@ -25,7 +25,7 @@ func setupJobsTest(t *testing.T) (*Handlers, *gin.Engine, *db.DB, *db.User, *db.
user := &db.User{
Email: "jobtest@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(user)
@@ -34,7 +34,7 @@ func setupJobsTest(t *testing.T) (*Handlers, *gin.Engine, *db.DB, *db.User, *db.
adminUser := &db.User{
Email: "jobadmin@example.com",
PasswordHash: "hashedpassword",
IsAdmin: true,
IsAdmin: BoolPtr(true),
LastPasswordChange: time.Now(),
}
database.Create(adminUser)
@@ -99,7 +99,7 @@ func TestHandleJobs(t *testing.T) {
Name: "Test Job 1",
Schedule: "*/5 * * * *",
ConfigID: 1,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job1)
@@ -108,7 +108,7 @@ func TestHandleJobs(t *testing.T) {
Name: "Test Job 2",
Schedule: "*/10 * * * *",
ConfigID: 1,
Enabled: false,
Enabled: BoolPtr(false),
CreatedBy: user.ID,
}
database.Create(job2)
@@ -117,7 +117,7 @@ func TestHandleJobs(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -126,7 +126,7 @@ func TestHandleJobs(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: 1,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
@@ -178,7 +178,7 @@ func TestHandleEditJob(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -187,7 +187,7 @@ func TestHandleEditJob(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -196,7 +196,7 @@ func TestHandleEditJob(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
@@ -232,7 +232,7 @@ func TestHandleEditJob(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(adminOtherJob)
@@ -287,13 +287,13 @@ func TestHandleCreateJob(t *testing.T) {
assert.Equal(t, jobName, job.Name)
assert.Equal(t, "*/15 * * * *", job.Schedule)
assert.Equal(t, config.ID, job.ConfigID)
assert.True(t, job.Enabled)
assert.True(t, job.GetEnabled())
// Test case 2: Try to use another user's config
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -420,7 +420,7 @@ func TestHandleUpdateJob(t *testing.T) {
Name: jobName,
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
@@ -435,7 +435,7 @@ func TestHandleUpdateJob(t *testing.T) {
assert.NoError(t, err, "Should find the newly created job")
assert.Equal(t, jobName, createdJob.Name, "Created job should have the expected name")
assert.Equal(t, "*/5 * * * *", createdJob.Schedule, "Created job should have the expected schedule")
assert.True(t, createdJob.Enabled, "Created job should be enabled")
assert.True(t, createdJob.GetEnabled(), "Created job should be enabled")
// Add route
router.PUT("/jobs/:id", handlers.HandleUpdateJob)
@@ -482,7 +482,7 @@ func TestHandleUpdateJob(t *testing.T) {
// Verify individual fields one by one
assert.Equal(t, updatedName, updatedJob.Name, "Job name should be updated")
assert.Equal(t, "0 0 * * *", updatedJob.Schedule, "Job schedule should be updated")
assert.False(t, updatedJob.Enabled, "Enabled status should be false")
assert.False(t, updatedJob.GetEnabled(), "Enabled status should be false")
// Make sure the ConfigIDs are still correct
configIDs := updatedJob.GetConfigIDsList()
@@ -493,7 +493,7 @@ func TestHandleUpdateJob(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
result = database.Create(otherUser)
@@ -504,7 +504,7 @@ func TestHandleUpdateJob(t *testing.T) {
Name: "Other User Job " + time.Now().Format("20060102150405"),
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
// Make sure the other job also has a config list set
@@ -557,7 +557,7 @@ func TestHandleUpdateJobWithMultipleConfigs(t *testing.T) {
Name: "Test Job for Multi-config Update",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
// Set initial configs (just config1)
@@ -596,7 +596,7 @@ func TestHandleUpdateJobWithMultipleConfigs(t *testing.T) {
assert.Equal(t, "Updated Multi-Config Job", updatedJob.Name)
assert.Equal(t, "0 * * * *", updatedJob.Schedule)
assert.True(t, updatedJob.Enabled)
assert.True(t, updatedJob.GetEnabled())
// The primary ConfigID should be updated to the first config in the new list
assert.Equal(t, config2.ID, updatedJob.ConfigID)
@@ -623,7 +623,7 @@ func TestHandleDeleteJob(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -651,7 +651,7 @@ func TestHandleDeleteJob(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -660,7 +660,7 @@ func TestHandleDeleteJob(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
@@ -683,7 +683,7 @@ func TestHandleRunJob(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -711,7 +711,7 @@ func TestHandleRunJob(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -720,7 +720,7 @@ func TestHandleRunJob(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
@@ -744,7 +744,7 @@ func TestHandleJobRunDetails(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -791,7 +791,7 @@ func TestHandleJobsFilter(t *testing.T) {
Name: "Test Job 1",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job1)
@@ -800,7 +800,7 @@ func TestHandleJobsFilter(t *testing.T) {
Name: "Test Job 2",
Schedule: "*/10 * * * *",
ConfigID: config.ID,
Enabled: false,
Enabled: BoolPtr(false),
CreatedBy: user.ID,
}
database.Create(job2)
@@ -809,7 +809,7 @@ func TestHandleJobsFilter(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -818,7 +818,7 @@ func TestHandleJobsFilter(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
@@ -881,7 +881,7 @@ func TestHandleJobHistory(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -976,7 +976,7 @@ func TestHandleJobSchedule(t *testing.T) {
Name: "Test Job",
Schedule: "*/5 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: user.ID,
}
database.Create(job)
@@ -1060,7 +1060,7 @@ func TestHandleJobSchedule(t *testing.T) {
otherUser := &db.User{
Email: "other@example.com",
PasswordHash: "hashedpassword",
IsAdmin: false,
IsAdmin: BoolPtr(false),
LastPasswordChange: time.Now(),
}
database.Create(otherUser)
@@ -1069,7 +1069,7 @@ func TestHandleJobSchedule(t *testing.T) {
Name: "Other User Job",
Schedule: "*/15 * * * *",
ConfigID: config.ID,
Enabled: true,
Enabled: BoolPtr(true),
CreatedBy: otherUser.ID,
}
database.Create(otherJob)
+239
View File
@@ -0,0 +1,239 @@
package handlers
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// GoogleDriveAuthHandler initiates the Google Drive OAuth flow
func (h *Handlers) HandleGoogleDriveAuth(c *gin.Context) {
configID := c.Query("config_id")
if configID == "" {
RenderErrorPage(c, "Missing config_id parameter", "")
return
}
// Prepare for the OAuth flow
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
// Ensure oauth directory exists
oauthDir := filepath.Join(dataDir, "oauth")
if err := os.MkdirAll(oauthDir, 0755); err != nil {
RenderErrorPage(c, "Failed to create oauth directory", err.Error())
return
}
// Get rclone path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
// Set up a temporary rclone config
tempConfigPath := filepath.Join(oauthDir, fmt.Sprintf("temp_gdrive_%s.conf", configID))
// Build rclone command to get auth URL
cmd := exec.Command(
rclonePath,
"config",
"create",
"temp_gdrive",
"drive",
"--config",
tempConfigPath,
)
// Set a timeout context
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
// Run the command with proper context handling
// We can't use exec.CommandContext directly since we're creating the command differently
// So we'll use a goroutine with the context's Done() channel to handle cancellation
go func() {
<-ctx.Done() // Wait for context to be done (timeout or cancellation)
if cmd.Process != nil {
if err := cmd.Process.Kill(); err != nil {
RenderErrorPage(c, "Failed to kill rclone process", err.Error())
}
}
}()
// Run the command to get the browser URL (this will fail in a specific way)
output, err := cmd.CombinedOutput()
if err != nil {
outputStr := string(output)
// Look for the URL in the output
authURL := extractAuthURL(outputStr)
if authURL == "" {
RenderErrorPage(c, "Failed to get Google Drive authentication URL", outputStr)
return
}
// Store the config ID in the session
session := sessions.Default(c)
session.Set("gdrive_config_id", configID)
session.Set("gdrive_temp_config", tempConfigPath)
if err := session.Save(); err != nil {
RenderErrorPage(c, "Failed to save session", err.Error())
return
}
// Use component rendering instead of HTML template
// This would typically use a component like:
// components.GDriveAuth(c.Request.Context(), components.GDriveAuthData{
// AuthURL: authURL,
// ConfigID: configID,
// }).Render(c.Request.Context(), c.Writer)
// For now, we'll redirect to the configs page with the auth URL and config ID
c.Redirect(http.StatusFound, fmt.Sprintf("/configs/%s/gdrive-auth?auth_url=%s",
configID, url.QueryEscape(authURL)))
return
}
// If we get here, something unexpected happened
RenderErrorPage(c, "Unexpected result from rclone", string(output))
}
// HandleGoogleDriveCallback handles the manual entry of the OAuth code
func (h *Handlers) HandleGoogleDriveCallback(c *gin.Context) {
// Get the auth code from form submission
authCode := c.PostForm("auth_code")
if authCode == "" {
RenderErrorPage(c, "Missing authentication code", "")
return
}
// Get the config ID from the session
session := sessions.Default(c)
configID := session.Get("gdrive_config_id")
tempConfigPath := session.Get("gdrive_temp_config")
if configID == nil || tempConfigPath == nil {
RenderErrorPage(c, "Session expired or invalid. Please try again.", "")
return
}
// Get rclone path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
// Complete the OAuth flow with the provided code
cmd := exec.Command(
rclonePath,
"config",
"reconnect",
"temp_gdrive:",
"--config",
tempConfigPath.(string),
)
// Create a pipe for stdin
stdin, err := cmd.StdinPipe()
if err != nil {
RenderErrorPage(c, "Failed to create stdin pipe", err.Error())
return
}
// Start the command
if err := cmd.Start(); err != nil {
RenderErrorPage(c, "Failed to start rclone command", err.Error())
return
}
// Write the auth code to stdin
fmt.Fprintln(stdin, authCode)
stdin.Close()
// Wait for the command to complete
if err := cmd.Wait(); err != nil {
RenderErrorPage(c, "Failed to complete Google Drive authentication", err.Error())
return
}
// Read the token from the config file
configData, err := ioutil.ReadFile(tempConfigPath.(string))
if err != nil {
RenderErrorPage(c, "Failed to read token from config file", err.Error())
return
}
// Extract token from config
token := extractToken(string(configData))
if token == "" {
RenderErrorPage(c, "Failed to extract token from config", "")
return
}
// Store the token in the database
configIDStr := configID.(string)
if err := h.DB.StoreGoogleDriveToken(configIDStr, token); err != nil {
RenderErrorPage(c, "Failed to save token", err.Error())
return
}
// Clean up temporary config
os.Remove(tempConfigPath.(string))
// Clear session data
session.Delete("gdrive_config_id")
session.Delete("gdrive_temp_config")
if err := session.Save(); err != nil {
RenderErrorPage(c, "Failed to save session", err.Error())
return
}
// Redirect to the configs page
c.Redirect(http.StatusFound, "/configs?status=gdrive_auth_success")
}
// Helper function to extract the authentication URL from rclone output
func extractAuthURL(output string) string {
// This is a simplified version - you may need to improve the regex
// to handle different output formats from rclone
lines := strings.Split(output, "\n")
for _, line := range lines {
if strings.Contains(line, "http") && strings.Contains(line, "accounts.google.com") {
// Extract the URL - this is a simplified approach
words := strings.Fields(line)
for _, word := range words {
if strings.HasPrefix(word, "http") {
return word
}
}
}
}
return ""
}
// Helper function to extract token from rclone config
func extractToken(configData string) string {
// Look for the token JSON in the config
lines := strings.Split(configData, "\n")
for _, line := range lines {
if strings.Contains(line, "token") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
}
return ""
}
+6
View File
@@ -31,6 +31,12 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.PUT("/configs/:id", h.HandleUpdateConfig)
authorized.POST("/configs/:id", h.HandleUpdateConfig)
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
// Google Drive authentication routes
authorized.GET("/configs/:id/gdrive-auth", h.HandleGDriveAuth)
authorized.GET("/configs/gdrive-callback", h.HandleGDriveAuthCallback)
authorized.GET("/configs/gdrive-token", h.HandleGDriveTokenProcess)
authorized.GET("/jobs", h.HandleJobs)
authorized.GET("/jobs/new", h.HandleNewJob)
authorized.GET("/jobs/:id", h.HandleEditJob)
+1 -1
View File
@@ -81,8 +81,8 @@ func setupTestDB(t *testing.T) *db.DB {
admin := db.User{
Email: testEmail,
PasswordHash: string(hashedPassword),
IsAdmin: true,
}
admin.SetIsAdmin(true)
if err := gormDB.Create(&admin).Error; err != nil {
t.Fatalf("Failed to create test admin user: %v", err)
+6 -5
View File
@@ -57,9 +57,9 @@ func (h *Handlers) HandleCreateUser(c *gin.Context) {
user := db.User{
Email: email,
PasswordHash: string(hashedPassword),
IsAdmin: isAdmin,
LastPasswordChange: time.Now(),
}
user.SetIsAdmin(isAdmin)
if err := h.DB.Create(&user).Error; err != nil {
c.String(http.StatusInternalServerError, "Failed to create user")
@@ -130,21 +130,22 @@ func (h *Handlers) HandleRegister(c *gin.Context) {
return
}
// Create the admin user
// Create the user
user := db.User{
Email: email,
PasswordHash: string(hashedPassword),
IsAdmin: true,
LastPasswordChange: time.Now(),
}
// Set as regular user (not admin)
user.SetIsAdmin(false)
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)
// Generate JWT token
token, err := h.GenerateJWT(user.ID, user.Email, user.GetIsAdmin())
if err != nil {
c.String(http.StatusInternalServerError, "Failed to generate token")
return
+1 -1
View File
@@ -299,7 +299,7 @@ func TestHandleRegister(t *testing.T) {
err := database.Where("email = ?", formData.Get("email")).First(&user).Error
assert.NoError(t, err)
assert.Equal(t, formData.Get("email"), user.Email)
assert.True(t, user.IsAdmin)
assert.True(t, user.GetIsAdmin())
// Verify JWT cookie was set
cookies := resp.Result().Cookies()
+17 -13
View File
@@ -57,12 +57,12 @@ func TestWebhookConfiguration(t *testing.T) {
require.NoError(t, err)
// Verify webhook settings were saved correctly
assert.True(t, job.WebhookEnabled)
assert.True(t, job.GetWebhookEnabled())
assert.Equal(t, "https://example.com/webhook", job.WebhookURL)
assert.Equal(t, "test-secret", job.WebhookSecret)
assert.Equal(t, `{"X-Test-Header": "test-value"}`, job.WebhookHeaders)
assert.True(t, job.NotifyOnSuccess)
assert.True(t, job.NotifyOnFailure)
assert.True(t, job.GetNotifyOnSuccess())
assert.True(t, job.GetNotifyOnFailure())
}
// TestWebhookEditConfiguration tests editing webhook configuration
@@ -75,8 +75,8 @@ func TestWebhookEditConfiguration(t *testing.T) {
Name: "Initial Job",
ConfigID: config.ID,
Schedule: "*/30 * * * *",
Enabled: true,
WebhookEnabled: false, // Initially disabled
Enabled: BoolPtr(true),
WebhookEnabled: BoolPtr(false), // Initially disabled
CreatedBy: user.ID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -116,12 +116,12 @@ func TestWebhookEditConfiguration(t *testing.T) {
require.NoError(t, err)
// Verify webhook settings were updated correctly
assert.True(t, updatedJob.WebhookEnabled)
assert.True(t, updatedJob.GetWebhookEnabled())
assert.Equal(t, "https://example.com/webhook", updatedJob.WebhookURL)
assert.Equal(t, "new-secret", updatedJob.WebhookSecret)
assert.Equal(t, `{"X-Api-Key": "12345"}`, updatedJob.WebhookHeaders)
assert.True(t, updatedJob.NotifyOnSuccess)
assert.False(t, updatedJob.NotifyOnFailure)
assert.True(t, updatedJob.GetNotifyOnSuccess())
assert.False(t, updatedJob.GetNotifyOnFailure())
}
// TestDisablingWebhook tests disabling a previously enabled webhook
@@ -134,13 +134,13 @@ func TestDisablingWebhook(t *testing.T) {
Name: "Webhook Enabled Job",
ConfigID: config.ID,
Schedule: "*/30 * * * *",
Enabled: true,
WebhookEnabled: true,
Enabled: BoolPtr(true),
WebhookEnabled: BoolPtr(true),
WebhookURL: "https://example.com/webhook",
WebhookSecret: "secret",
WebhookHeaders: `{"X-Test": "test"}`,
NotifyOnSuccess: true,
NotifyOnFailure: true,
NotifyOnSuccess: BoolPtr(true),
NotifyOnFailure: BoolPtr(true),
CreatedBy: user.ID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -178,7 +178,7 @@ func TestDisablingWebhook(t *testing.T) {
require.NoError(t, err)
// Verify webhook was disabled
assert.False(t, updatedJob.WebhookEnabled)
assert.False(t, updatedJob.GetWebhookEnabled())
// Other fields should remain unchanged
assert.Equal(t, "https://example.com/webhook", updatedJob.WebhookURL)
@@ -241,3 +241,7 @@ func TestWebhookValidation(t *testing.T) {
assert.NotEqual(t, http.StatusFound, resp.Code)
assert.Contains(t, resp.Body.String(), "valid JSON")
}
func BoolPtr(b bool) *bool {
return &b
}