package handlers import ( "fmt" "net/http" "strings" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "sproutgate-backend/internal/email" "sproutgate-backend/internal/models" ) func (h *Handler) Register(c *gin.Context) { var req registerRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } req.Account = strings.TrimSpace(req.Account) req.Email = strings.TrimSpace(req.Email) inviteTrim := strings.TrimSpace(req.InviteCode) if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"}) return } requireInv := h.store.RegistrationRequireInvite() if requireInv && inviteTrim == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invite code is required"}) return } if inviteTrim != "" { if err := h.store.ValidateInviteForRegister(inviteTrim); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } } if _, found, err := h.store.GetUser(req.Account); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) return } else if found { c.JSON(http.StatusBadRequest, gin.H{"error": "account already exists"}) return } code, err := generateVerificationCode() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"}) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) return } expiresAt := time.Now().Add(10 * time.Minute) pending := models.PendingUser{ Account: req.Account, PasswordHash: string(hash), Username: req.Username, Email: req.Email, CodeHash: hashCode(code), CreatedAt: models.NowISO(), ExpiresAt: expiresAt.Format(time.RFC3339), } if inviteTrim != "" { pending.InviteCode = strings.ToUpper(inviteTrim) } if err := h.store.SavePending(pending); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pending user"}) return } if err := email.SendVerificationEmail(h.store.EmailConfig(), req.Email, code, 10*time.Minute); err != nil { _ = h.store.DeletePending(req.Account) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)}) return } c.JSON(http.StatusOK, gin.H{ "sent": true, "expiresAt": expiresAt.Format(time.RFC3339), }) } func (h *Handler) VerifyEmail(c *gin.Context) { var req verifyEmailRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } req.Account = strings.TrimSpace(req.Account) req.Code = strings.TrimSpace(req.Code) if req.Account == "" || req.Code == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "account and code are required"}) return } pending, found, err := h.store.GetPending(req.Account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"}) return } if !found { c.JSON(http.StatusBadRequest, gin.H{"error": "pending registration not found"}) return } expiresAt, err := time.Parse(time.RFC3339, pending.ExpiresAt) if err != nil || time.Now().After(expiresAt) { _ = h.store.DeletePending(req.Account) c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"}) return } if !verifyCode(req.Code, pending.CodeHash) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"}) return } record := models.UserRecord{ Account: pending.Account, PasswordHash: pending.PasswordHash, Username: pending.Username, Email: pending.Email, Level: 0, SproutCoins: 0, SecondaryEmails: []string{}, CreatedAt: models.NowISO(), UpdatedAt: models.NowISO(), } if err := h.store.CreateUser(record); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if strings.TrimSpace(pending.InviteCode) != "" { if err := h.store.RedeemInvite(pending.InviteCode); err != nil { _ = h.store.DeleteUser(record.Account) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } } _ = h.store.DeletePending(req.Account) c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.OwnerPublic()}) } func (h *Handler) ForgotPassword(c *gin.Context) { var req forgotPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } req.Account = strings.TrimSpace(req.Account) req.Email = strings.TrimSpace(req.Email) if req.Account == "" || req.Email == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "account and email are required"}) return } user, found, err := h.store.GetUser(req.Account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) return } if !found || strings.TrimSpace(user.Email) == "" || user.Email != req.Email { c.JSON(http.StatusBadRequest, gin.H{"error": "account or email not matched"}) return } if user.Banned { writeBanJSON(c, user.BanReason) return } code, err := generateVerificationCode() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"}) return } expiresAt := time.Now().Add(10 * time.Minute) resetRecord := models.ResetPassword{ Account: user.Account, Email: user.Email, CodeHash: hashCode(code), CreatedAt: models.NowISO(), ExpiresAt: expiresAt.Format(time.RFC3339), } if err := h.store.SaveReset(resetRecord); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save reset token"}) return } if err := email.SendResetPasswordEmail(h.store.EmailConfig(), user.Email, code, 10*time.Minute); err != nil { _ = h.store.DeleteReset(user.Account) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)}) return } c.JSON(http.StatusOK, gin.H{ "sent": true, "expiresAt": expiresAt.Format(time.RFC3339), }) } func (h *Handler) ResetPassword(c *gin.Context) { var req resetPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } req.Account = strings.TrimSpace(req.Account) req.Code = strings.TrimSpace(req.Code) if req.Account == "" || req.Code == "" || strings.TrimSpace(req.NewPassword) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "account, code and newPassword are required"}) return } resetRecord, found, err := h.store.GetReset(req.Account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load reset token"}) return } if !found { c.JSON(http.StatusBadRequest, gin.H{"error": "reset request not found"}) return } expiresAt, err := time.Parse(time.RFC3339, resetRecord.ExpiresAt) if err != nil || time.Now().After(expiresAt) { _ = h.store.DeleteReset(req.Account) c.JSON(http.StatusBadRequest, gin.H{"error": "reset code expired"}) return } if !verifyCode(req.Code, resetRecord.CodeHash) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reset code"}) return } user, found, err := h.store.GetUser(req.Account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) return } if !found { c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"}) return } if user.Banned { writeBanJSON(c, user.BanReason) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) return } user.PasswordHash = string(hash) if err := h.store.SaveUser(user); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) return } _ = h.store.DeleteReset(req.Account) c.JSON(http.StatusOK, gin.H{"reset": true}) }