package users import ( "encoding/json" "errors" "fmt" "log" "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 ID. func (uc *UserController) GetUser(c fiber.Ctx) error { user, err := GetUserByID(c.Params("id")) if err != nil { return err } return c.JSON(responses.Success(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 } user, err := CreateUser(req) if err != nil { return err } return c.Status(fiber.StatusCreated).JSON(responses.Success(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 } user, err := UpdateUser(req) if err != nil { return err } return c.JSON(responses.Success(user)) } // UpdateUserDetails replaces user fields and synchronizes details/preferences. func (uc *UserController) UpdateUserDetails(c fiber.Ctx) error { var req UpdateUserDetailsRequest if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } if err := validation.ValidateStruct(&req); err != nil { return err } err := UpdateUserDetails(req) if err != nil { return err } user, err := GetUserByID(req.UserID) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid user id") } return c.JSON(responses.Success(user)) } // DeleteUser removes a user and linked details/preferences through cascading delete rules. func (uc *UserController) DeleteUser(c fiber.Ctx) error { if err := DeleteUser(c.Params("uuid")); err != nil { return err } 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.ID, 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 }(), Type: func() UserType { if len(req.Type) == 0 { return UserType("internal") } return req.Type }(), Avatar: req.Avatar, ID: 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"})) } // 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 fiber.NewError(fiber.StatusForbidden, "missing token header") } claims, err := tokenService.ValidateAccessToken(tokenString) if err != nil { return fiber.NewError(fiber.StatusUnauthorized, "bad token") } db, err := db.DBFromCtx(c) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to load db") } var user User if err := db.Preload("Details").Preload("Preferences").Where("id = ?", claims.ID).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "user not found") } return fiber.NewError(fiber.StatusInternalServerError, "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.ID, 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 claims := c.Locals("authClaims") if claims == nil { return fiber.NewError(fiber.StatusForbidden, "forbidden") } if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } if err := validation.ValidateStruct(&req); err != nil { return err } if err := UpdateUserPassword(req, claims.(tokens.Claims).ID); err != nil { return err } return c.JSON(responses.Success(responses.SimpleResponse{Message: "password updated"})) }