package handlers import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "fmt" "net/http" "strings" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "sproutgate-backend/internal/auth" "sproutgate-backend/internal/email" "sproutgate-backend/internal/models" "sproutgate-backend/internal/storage" ) type Handler struct { store *storage.Store } func NewHandler(store *storage.Store) *Handler { return &Handler{store: store} } type loginRequest struct { Account string `json:"account"` Password string `json:"password"` } type verifyRequest struct { Token string `json:"token"` } type registerRequest struct { Account string `json:"account"` Password string `json:"password"` Username string `json:"username"` Email string `json:"email"` } type verifyEmailRequest struct { Account string `json:"account"` Code string `json:"code"` } type updateProfileRequest struct { Password *string `json:"password"` Username *string `json:"username"` Phone *string `json:"phone"` AvatarURL *string `json:"avatarUrl"` Bio *string `json:"bio"` } type forgotPasswordRequest struct { Account string `json:"account"` Email string `json:"email"` } type resetPasswordRequest struct { Account string `json:"account"` Code string `json:"code"` NewPassword string `json:"newPassword"` } type secondaryEmailRequest struct { Email string `json:"email"` } type verifySecondaryEmailRequest struct { Email string `json:"email"` Code string `json:"code"` } type createUserRequest struct { Account string `json:"account"` Password string `json:"password"` Username string `json:"username"` Email string `json:"email"` Level int `json:"level"` SproutCoins int `json:"sproutCoins"` SecondaryEmails []string `json:"secondaryEmails"` Phone string `json:"phone"` AvatarURL string `json:"avatarUrl"` Bio string `json:"bio"` } type updateUserRequest struct { Password *string `json:"password"` Username *string `json:"username"` Email *string `json:"email"` Level *int `json:"level"` SproutCoins *int `json:"sproutCoins"` SecondaryEmails *[]string `json:"secondaryEmails"` Phone *string `json:"phone"` AvatarURL *string `json:"avatarUrl"` Bio *string `json:"bio"` } func (h *Handler) Login(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } req.Account = strings.TrimSpace(req.Account) if req.Account == "" || req.Password == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "account and password 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 { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) return } token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"}) return } c.JSON(http.StatusOK, gin.H{ "token": token, "expiresAt": expiresAt.Format(time.RFC3339), "user": user.Public(), }) } func (h *Handler) Verify(c *gin.Context) { var req verifyRequest if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}) return } claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "invalid token"}) return } user, found, err := h.store.GetUser(claims.Account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) return } if !found { c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "user not found"}) return } c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()}) } 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) if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"}) 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 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 } _ = h.store.DeletePending(req.Account) c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.Public()}) } 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 } 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 } 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}) } func (h *Handler) RequestSecondaryEmail(c *gin.Context) { token := bearerToken(c.GetHeader("Authorization")) if token == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) return } claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } var req secondaryEmailRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } emailAddr := strings.TrimSpace(req.Email) if emailAddr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"}) return } user, found, err := h.store.GetUser(claims.Account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) return } if !found { c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) return } if strings.TrimSpace(user.Email) == emailAddr { c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"}) return } for _, e := range user.SecondaryEmails { if e == emailAddr { c.JSON(http.StatusBadRequest, gin.H{"error": "email already verified"}) 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) record := models.SecondaryEmailVerification{ Account: user.Account, Email: emailAddr, CodeHash: hashCode(code), CreatedAt: models.NowISO(), ExpiresAt: expiresAt.Format(time.RFC3339), } if err := h.store.SaveSecondaryVerification(record); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save verification"}) return } if err := email.SendVerificationEmail(h.store.EmailConfig(), emailAddr, code, 10*time.Minute); err != nil { _ = h.store.DeleteSecondaryVerification(user.Account, emailAddr) 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) VerifySecondaryEmail(c *gin.Context) { token := bearerToken(c.GetHeader("Authorization")) if token == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) return } claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } var req verifySecondaryEmailRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } emailAddr := strings.TrimSpace(req.Email) code := strings.TrimSpace(req.Code) if emailAddr == "" || code == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "email and code are required"}) return } record, found, err := h.store.GetSecondaryVerification(claims.Account, emailAddr) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"}) return } if !found { c.JSON(http.StatusBadRequest, gin.H{"error": "verification not found"}) return } expiresAt, err := time.Parse(time.RFC3339, record.ExpiresAt) if err != nil || time.Now().After(expiresAt) { _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"}) return } if !verifyCode(code, record.CodeHash) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"}) return } user, found, err := h.store.GetUser(claims.Account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) return } if !found { c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) return } for _, e := range user.SecondaryEmails { if e == emailAddr { _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()}) return } } user.SecondaryEmails = append(user.SecondaryEmails, emailAddr) if err := h.store.SaveUser(user); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) return } _ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr) c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()}) } func (h *Handler) Me(c *gin.Context) { token := bearerToken(c.GetHeader("Authorization")) if token == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) return } claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } user, found, err := h.store.GetUser(claims.Account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) return } if !found { c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) return } c.JSON(http.StatusOK, gin.H{"user": user.Public()}) } func (h *Handler) UpdateProfile(c *gin.Context) { token := bearerToken(c.GetHeader("Authorization")) if token == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) return } claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } var req updateProfileRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } user, found, err := h.store.GetUser(claims.Account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) return } if !found { c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) return } if req.Password != nil && strings.TrimSpace(*req.Password) != "" { hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) return } user.PasswordHash = string(hash) } if req.Username != nil { user.Username = *req.Username } if req.Phone != nil { user.Phone = *req.Phone } if req.AvatarURL != nil { user.AvatarURL = *req.AvatarURL } if req.Bio != nil { user.Bio = *req.Bio } if err := h.store.SaveUser(user); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) return } c.JSON(http.StatusOK, gin.H{"user": user.Public()}) } func (h *Handler) ListUsers(c *gin.Context) { users, err := h.store.ListUsers() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"}) return } publicUsers := make([]models.UserPublic, 0, len(users)) for _, u := range users { publicUsers = append(publicUsers, u.Public()) } c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers}) } func (h *Handler) CreateUser(c *gin.Context) { var req createUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } req.Account = strings.TrimSpace(req.Account) if req.Account == "" || strings.TrimSpace(req.Password) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"}) 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 } record := models.UserRecord{ Account: req.Account, PasswordHash: string(hash), Username: req.Username, Email: req.Email, Level: req.Level, SproutCoins: req.SproutCoins, SecondaryEmails: req.SecondaryEmails, Phone: req.Phone, AvatarURL: req.AvatarURL, Bio: req.Bio, CreatedAt: models.NowISO(), UpdatedAt: models.NowISO(), } if err := h.store.CreateUser(record); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"user": record.Public()}) } func (h *Handler) UpdateUser(c *gin.Context) { account := strings.TrimSpace(c.Param("account")) if account == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"}) return } var req updateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } user, found, err := h.store.GetUser(account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) return } if !found { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } if req.Password != nil && strings.TrimSpace(*req.Password) != "" { hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) return } user.PasswordHash = string(hash) } if req.Username != nil { user.Username = *req.Username } if req.Email != nil { user.Email = *req.Email } if req.Level != nil { user.Level = *req.Level } if req.SproutCoins != nil { user.SproutCoins = *req.SproutCoins } if req.SecondaryEmails != nil { user.SecondaryEmails = *req.SecondaryEmails } if req.Phone != nil { user.Phone = *req.Phone } if req.AvatarURL != nil { user.AvatarURL = *req.AvatarURL } if req.Bio != nil { user.Bio = *req.Bio } if err := h.store.SaveUser(user); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"}) return } c.JSON(http.StatusOK, gin.H{"user": user.Public()}) } func (h *Handler) DeleteUser(c *gin.Context) { account := strings.TrimSpace(c.Param("account")) if account == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"}) return } if err := h.store.DeleteUser(account); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"}) return } c.JSON(http.StatusOK, gin.H{"deleted": true}) } func (h *Handler) AdminMiddleware() gin.HandlerFunc { return func(c *gin.Context) { token := adminTokenFromRequest(c) if token == "" || token != h.store.AdminToken() { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"}) c.Abort() return } c.Next() } } func adminTokenFromRequest(c *gin.Context) string { if token := strings.TrimSpace(c.Query("token")); token != "" { return token } if token := strings.TrimSpace(c.GetHeader("X-Admin-Token")); token != "" { return token } authHeader := strings.TrimSpace(c.GetHeader("Authorization")) return bearerToken(authHeader) } func bearerToken(header string) string { if header == "" { return "" } if strings.HasPrefix(strings.ToLower(header), "bearer ") { return strings.TrimSpace(header[7:]) } return "" } func generateVerificationCode() (string, error) { randomBytes := make([]byte, 3) if _, err := rand.Read(randomBytes); err != nil { return "", err } number := int(randomBytes[0])<<16 | int(randomBytes[1])<<8 | int(randomBytes[2]) return fmt.Sprintf("%06d", number%1000000), nil } func hashCode(code string) string { sum := sha256.Sum256([]byte(code)) return hex.EncodeToString(sum[:]) } func verifyCode(code string, hash string) bool { return subtle.ConstantTimeCompare([]byte(hashCode(code)), []byte(hash)) == 1 }