package users import ( "encoding/json" "errors" "fmt" "log" "strconv" "strings" "time" "github.com/gofiber/fiber/v3" "github.com/google/uuid" "gorm.io/gorm" "server/internal/auth" "server/internal/db" "server/internal/mail" "server/internal/responses" "server/internal/systemUtils" "server/internal/tokens" "server/internal/validation" ) type UserController struct { TockenService *tokens.TockenService } func NewUserController(tockenService *tokens.TockenService) *UserController { return &UserController{ TockenService: tockenService, } } // GetUser returns a single user by UUID. func (uc *UserController) GetUser(c fiber.Ctx) error { user, err := loadUserByUUID(c, c.Params("uuid")) if err != nil { return err } return c.JSON(responses.Success(ToUserProfile(user))) } // CreateUser creates a user together with optional details and preferences. func (uc *UserController) CreateUser(c fiber.Ctx) error { var req UserCreateRequest if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } if err := validation.ValidateStruct(&req); err != nil { return err } db, err := db.DBFromCtx(c) if err != nil { return err } var existing User if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil { return fiber.NewError(fiber.StatusConflict, "user already exists") } else if !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "failed to check user") } hashedPassword, err := systemUtils.HashPassword(req.Password) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password") } now := time.Now().UTC() user := User{ Email: req.Email, Name: req.Name, Password: hashedPassword, Permission: req.Permission, Status: func() UserStatus { if req.Status == "" { return UserStatusPending } return req.Status }(), Types: func() UserTypes { if len(req.Types) == 0 { return UserTypes{"internal"} } return req.Types }(), Avatar: req.Avatar, UUID: uuid.NewString(), Details: req.Details, Preferences: req.Preferences, CreatedAt: &now, UpdatedAt: &now, } if err := db.Create(&user).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to create user") } if err := db.Preload("Details").Preload("Preferences").First(&user, user.ID).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user") } return c.Status(fiber.StatusCreated).JSON(responses.Success(ToUserProfile(&user))) } // UpdateUser replaces user fields and synchronizes details/preferences. func (uc *UserController) UpdateUser(c fiber.Ctx) error { var req UpdateUserRequest if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } if err := validation.ValidateStruct(&req); err != nil { return err } db, err := db.DBFromCtx(c) if err != nil { return err } user, err := loadUserByUUID(c, req.UUID) if err != nil { return err } if req.Email != user.Email { var existing User if err := db.Select("id").Where("email = ?", req.Email).First(&existing).Error; err == nil && existing.ID != user.ID { return fiber.NewError(fiber.StatusConflict, "user already exists") } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "failed to check user") } } now := time.Now().UTC() user.Name = req.Name user.Email = req.Email user.Avatar = req.Avatar user.UpdatedAt = &now user.Permission = req.Permission if req.Status != "" { user.Status = req.Status } if len(req.Types) > 0 { user.Types = req.Types } if err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Save(user).Error; err != nil { return err } if err := syncUserDetails(tx, user.ID, req.Details); err != nil { return err } if err := syncUserPreferences(tx, user.ID, req.Preferences); err != nil { return err } return nil }); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to update user") } if err := db.Preload("Details").Preload("Preferences").First(user, user.ID).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user") } return c.JSON(responses.Success(ToUserProfile(user))) } // DeleteUser removes a user and linked details/preferences through cascading delete rules. func (uc *UserController) DeleteUser(c fiber.Ctx) error { db, err := db.DBFromCtx(c) if err != nil { return err } user, err := loadUserByID(c) if err != nil { return err } if err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("user_id = ?", user.ID).Delete(&UserDetails{}).Error; err != nil { return err } if err := tx.Where("user_id = ?", user.ID).Delete(&UserPreferences{}).Error; err != nil { return err } return tx.Delete(user).Error }); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to delete user") } return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"})) } // Login authenticates a user and issues an access/refresh token pair. func (uc *UserController) Login(c fiber.Ctx) error { var req LoginRequest if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } if err := validation.ValidateStruct(&req); err != nil { return err } db, err := db.DBFromCtx(c) if err != nil { return err } var user User if err := db.Where("email = ?", req.Username).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials") } return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch user") } match, err := systemUtils.VerifyPassword(user.Password, req.Password) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to verify credentials") } if !match { return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials") } permission := auth.RoleToPermission(user.Permission) if permission == 0 { return fiber.NewError(fiber.StatusInternalServerError, "invalid user role") } token, err := uc.TockenService.GenerateTokenPair(user.Email, auth.PermissionToString(permission)) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token") } userID := user.ID now := time.Now().UTC() if err := db.Where("expires_at < ?", now).Delete(&Session{}).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to purge expired sessions") } session := Session{ UserID: &userID, Username: user.Email, AccessTokenHash: tokens.HashToken(token.AccessToken), RefreshTokenHash: tokens.HashToken(token.RefreshToken), ExpiresAt: now.Add(uc.TockenService.RefreshExpiry()), IPAddress: c.IP(), UserAgent: c.Get("User-Agent"), } if err := db.Create(&session).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to record session") } c.Response().Header.Set("Auth-Token", token.AccessToken) return c.JSON(responses.Success(token)) } // Register creates a new user with optional roles/types/preferences. func (uc *UserController) Register(c fiber.Ctx) error { var req UserCreateRequest if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } if err := validation.ValidateStruct(&req); err != nil { return err } db, err := db.DBFromCtx(c) if err != nil { return err } var existing User if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil { return fiber.NewError(fiber.StatusConflict, "user already exists") } else if !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "failed to check user") } now := time.Now().UTC() hashedPassword, err := systemUtils.HashPassword(req.Password) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password") } user := User{ Email: req.Email, Name: req.Name, Password: hashedPassword, Permission: req.Permission, Status: func() UserStatus { if req.Status == "" { return UserStatusPending } return req.Status }(), Types: func() UserTypes { if len(req.Types) == 0 { return UserTypes{"internal"} } return req.Types }(), Avatar: req.Avatar, UUID: uuid.NewString(), Details: req.Details, Preferences: func() *UserPreferences { if req.Preferences == nil { return nil } return &UserPreferences{ UseIdle: req.Preferences.UseIdle, IdleTimeout: req.Preferences.IdleTimeout, UseIdlePassword: req.Preferences.UseIdlePassword, IdlePin: req.Preferences.IdlePin, UseDirectLogin: req.Preferences.UseDirectLogin, UseQuadcodeLogin: req.Preferences.UseQuadcodeLogin, SendNoticesMail: req.Preferences.SendNoticesMail, Language: req.Preferences.Language, } }(), CreatedAt: &now, UpdatedAt: &now, } if err := db.Create(&user).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to create user") } mailService, err := mail.New() if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to initialize mail service") } if err := mailService.Send(c, mail.Message{ To: user.Email, Subject: fmt.Sprintf("[%s] Registrazione completata", mailService.AppName()), Template: "registration", TemplateData: mail.TemplateData{ AppName: mailService.AppName(), UserName: user.Name, UserEmail: user.Email, }, }); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to send registration email") } return c.Status(fiber.StatusCreated).JSON(responses.Success(&user)) } func (uc *UserController) ForgotPassword(c fiber.Ctx) error { var req ForgotPasswordRequest if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } if err := validation.ValidateStruct(&req); err != nil { return err } db, err := db.DBFromCtx(c) if err != nil { return err } var user User if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return c.JSON(responses.Success(fiber.Map{"sent": true})) } return fiber.NewError(fiber.StatusInternalServerError, "failed to load user") } if user.Status == UserStatusDisabled { return c.JSON(responses.Success(fiber.Map{"sent": true})) } resetToken, err := uc.TockenService.GenerateSecureToken() if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to generate reset token") } now := time.Now().UTC() record := PasswordResetToken{ UserID: user.ID, TokenHash: tokens.HashToken(resetToken), ExpiresAt: now.Add(30 * time.Minute), CreatedAt: &now, UpdatedAt: &now, } if err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("user_id = ? OR expires_at < ? OR used_at IS NOT NULL", user.ID, now). Delete(&PasswordResetToken{}).Error; err != nil { return err } return tx.Create(&record).Error }); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to store reset token") } mailService, err := mail.New() if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to initialize mail service") } if err := mailService.Send(c, mail.Message{ To: user.Email, Subject: fmt.Sprintf("[%s] Recupero password", mailService.AppName()), Template: "password_reset", TemplateData: mail.TemplateData{ AppName: mailService.AppName(), UserName: user.Name, UserEmail: user.Email, ResetToken: resetToken, ResetURL: mailService.ResetLink(resetToken), }, }); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email") } return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset email sent"})) } func (uc *UserController) ResetPassword(c fiber.Ctx) error { var req ResetPasswordRequest if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } if err := validation.ValidateStruct(&req); err != nil { return err } db, err := db.DBFromCtx(c) if err != nil { return err } hashedPassword, err := systemUtils.HashPassword(req.Password) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password") } now := time.Now().UTC() tokenHash := tokens.HashToken(req.Token) if err := db.Transaction(func(tx *gorm.DB) error { var resetToken PasswordResetToken if err := tx.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token") } return err } if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) { return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token") } if err := tx.Model(&User{}).Where("id = ?", resetToken.UserID).Updates(map[string]any{ "password": hashedPassword, "updated_at": now, }).Error; err != nil { return err } if err := tx.Model(&resetToken).Updates(map[string]any{ "used_at": now, "updated_at": now, }).Error; err != nil { return err } if err := tx.Where("user_id = ?", resetToken.UserID).Delete(&Session{}).Error; err != nil { return err } if err := tx.Where("user_id = ? AND id <> ?", resetToken.UserID, resetToken.ID).Delete(&PasswordResetToken{}).Error; err != nil { return err } return nil }); err != nil { var fiberErr *fiber.Error if errors.As(err, &fiberErr) { return fiberErr } return fiber.NewError(fiber.StatusInternalServerError, "failed to reset password") } return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset successful"})) } func (uc *UserController) ValidToken(c fiber.Ctx) error { raw := strings.TrimSpace(string(c.Body())) if raw == "" { return fiber.NewError(fiber.StatusBadRequest, "token is required") } token := raw if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { if err := json.Unmarshal([]byte(raw), &token); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } } token = strings.TrimSpace(token) if token == "" { return fiber.NewError(fiber.StatusBadRequest, "token is required") } db, err := db.DBFromCtx(c) if err != nil { return err } now := time.Now().UTC() tokenHash := tokens.HashToken(token) var resetToken PasswordResetToken if err := db.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token") } return fiber.NewError(fiber.StatusInternalServerError, "failed to validate reset token") } if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) { return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token") } return c.JSON(responses.Success(responses.SimpleResponse{Message: "valid reset token"})) } func loadUserByID(c fiber.Ctx) (*User, error) { id, err := strconv.Atoi(c.Params("id")) if err != nil || id <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id") } db, err := db.DBFromCtx(c) if err != nil { return nil, err } var user User if err := db.Preload("Details").Preload("Preferences").First(&user, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "user not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load user") } return &user, nil } func loadUserByUUID(c fiber.Ctx, uuid string) (*User, error) { if uuid == "" { return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid") } db, err := db.DBFromCtx(c) if err != nil { return nil, err } var user User if err := db.Preload("Details").Preload("Preferences").First(&user, "uuid = ?", uuid).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "user not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load user") } return &user, nil } func syncUserDetails(tx *gorm.DB, userID int, input *UserDetails) error { if input == nil { return tx.Where("user_id = ?", userID).Delete(&UserDetails{}).Error } var details UserDetails if err := tx.Where("user_id = ?", userID).First(&details).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { details = UserDetails{UserID: userID} } else { return err } } details.Title = input.Title details.FirstName = input.FirstName details.LastName = input.LastName details.Address = input.Address details.City = input.City details.ZipCode = input.ZipCode details.Country = input.Country details.Phone = input.Phone if details.ID == 0 { return tx.Create(&details).Error } return tx.Save(&details).Error } func syncUserPreferences(tx *gorm.DB, userID int, input *UserPreferences) error { if input == nil { return tx.Where("user_id = ?", userID).Delete(&UserPreferences{}).Error } var preferences UserPreferences if err := tx.Where("user_id = ?", userID).First(&preferences).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { preferences = UserPreferences{UserID: userID} } else { return err } } preferences.UseIdle = input.UseIdle preferences.IdleTimeout = input.IdleTimeout preferences.UseIdlePassword = input.UseIdlePassword preferences.IdlePin = input.IdlePin preferences.UseDirectLogin = input.UseDirectLogin preferences.UseQuadcodeLogin = input.UseQuadcodeLogin preferences.SendNoticesMail = input.SendNoticesMail preferences.Language = input.Language if preferences.ID == 0 { return tx.Create(&preferences).Error } return tx.Save(&preferences).Error } // Me returns the authenticated user's profile (short format). func (uc *UserController) Me(c fiber.Ctx) error { tokenService, err := tokens.GetTockenService() if err != nil { log.Fatalf("init tokens: %v", err) } tokenString := c.Get("Auth-Token") if tokenString == "" { return c.JSON(responses.Success("missing token header")) } claims, err := tokenService.ValidateAccessToken(tokenString) if err != nil { return c.JSON(responses.Success("bad token")) } db, err := db.DBFromCtx(c) if err != nil { return c.JSON(responses.Success("failed to load db")) } var user User if err := db.Preload("Details").Preload("Preferences").Where("email = ?", claims.Username).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return c.JSON(responses.Success("user not found")) } return c.JSON(responses.Success("failed to load user")) } return c.JSON(responses.Success(&user)) } func (us *UserController) Refresh(c fiber.Ctx) error { var req RefreshRequest if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } if req.RefreshToken == "" { return fiber.NewError(fiber.StatusBadRequest, "refresh_token is required") } claims, err := us.TockenService.ParseToken(req.RefreshToken) if err != nil { return fiber.NewError(fiber.StatusUnauthorized, err.Error()) } if claims.TokenType != tokens.TokenTypeRefresh { return fiber.NewError(fiber.StatusUnauthorized, "refresh token required") } tokens, err := us.TockenService.GenerateTokenPair(claims.Username, auth.PermissionToString(claims.Permission)) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } return c.JSON(responses.Success(tokens)) } // update user password by Claims func (us *UserController) UpdatePassword(c fiber.Ctx) error { var req UpdatePasswordRequest if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } if err := validation.ValidateStruct(&req); err != nil { return err } db, err := db.DBFromCtx(c) if err != nil { return err } err = UpdateUserPassword(db, req, c.Params("uuid")) if err != nil { return err } return c.JSON(responses.Success(responses.SimpleResponse{Message: "password updated"})) }