package handler import ( "context" "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) // UserHandler handles user-related requests type UserHandler struct { userService *service.UserService authService *service.AuthService emailService *service.EmailService emailCache service.EmailCache } // NewUserHandler creates a new UserHandler func NewUserHandler( userService *service.UserService, authService *service.AuthService, emailService *service.EmailService, emailCache service.EmailCache, ) *UserHandler { return &UserHandler{ userService: userService, authService: authService, emailService: emailService, emailCache: emailCache, } } // ChangePasswordRequest represents the change password request payload type ChangePasswordRequest struct { OldPassword string `json:"old_password" binding:"required"` NewPassword string `json:"new_password" binding:"required,min=6"` } // UpdateProfileRequest represents the update profile request payload type UpdateProfileRequest struct { Username *string `json:"username"` AvatarURL *string `json:"avatar_url"` BalanceNotifyEnabled *bool `json:"balance_notify_enabled"` BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"` } type userProfileResponse struct { dto.User AvatarURL string `json:"avatar_url,omitempty"` AvatarSource *userProfileSourceContext `json:"avatar_source,omitempty"` UsernameSource *userProfileSourceContext `json:"username_source,omitempty"` DisplayNameSource *userProfileSourceContext `json:"display_name_source,omitempty"` NicknameSource *userProfileSourceContext `json:"nickname_source,omitempty"` ProfileSources map[string]*userProfileSourceContext `json:"profile_sources,omitempty"` Identities service.UserIdentitySummarySet `json:"identities"` AuthBindings map[string]service.UserIdentitySummary `json:"auth_bindings"` IdentityBindings map[string]service.UserIdentitySummary `json:"identity_bindings"` EmailBound bool `json:"email_bound"` LinuxDoBound bool `json:"linuxdo_bound"` OIDCBound bool `json:"oidc_bound"` WeChatBound bool `json:"wechat_bound"` } type userProfileSourceContext struct { Provider string `json:"provider,omitempty"` Source string `json:"source,omitempty"` } // GetProfile handles getting user profile // GET /api/v1/users/me func (h *UserHandler) GetProfile(c *gin.Context) { subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return } userData, err := h.userService.GetProfile(c.Request.Context(), subject.UserID) if err != nil { response.ErrorFrom(c, err) return } profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, userData) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, profileResp) } // ChangePassword handles changing user password // POST /api/v1/users/me/password func (h *UserHandler) ChangePassword(c *gin.Context) { subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return } var req ChangePasswordRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } svcReq := service.ChangePasswordRequest{ CurrentPassword: req.OldPassword, NewPassword: req.NewPassword, } err := h.userService.ChangePassword(c.Request.Context(), subject.UserID, svcReq) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"message": "Password changed successfully"}) } // UpdateProfile handles updating user profile // PUT /api/v1/users/me func (h *UserHandler) UpdateProfile(c *gin.Context) { subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return } var req UpdateProfileRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } svcReq := service.UpdateProfileRequest{ Username: req.Username, AvatarURL: req.AvatarURL, BalanceNotifyEnabled: req.BalanceNotifyEnabled, BalanceNotifyThreshold: req.BalanceNotifyThreshold, } updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), subject.UserID, svcReq) if err != nil { response.ErrorFrom(c, err) return } profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, profileResp) } type StartIdentityBindingRequest struct { Provider string `json:"provider" binding:"required"` RedirectTo string `json:"redirect_to"` } type BindEmailIdentityRequest struct { Email string `json:"email" binding:"required,email"` VerifyCode string `json:"verify_code" binding:"required"` Password string `json:"password" binding:"required"` } type SendEmailBindingCodeRequest struct { Email string `json:"email" binding:"required,email"` } // StartIdentityBinding returns the backend authorize URL for starting a third-party identity bind flow. // POST /api/v1/user/auth-identities/bind/start func (h *UserHandler) StartIdentityBinding(c *gin.Context) { if _, ok := middleware2.GetAuthSubjectFromContext(c); !ok { response.Unauthorized(c, "User not authenticated") return } var req StartIdentityBindingRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } result, err := h.userService.PrepareIdentityBindingStart(c.Request.Context(), service.StartUserIdentityBindingRequest{ Provider: req.Provider, RedirectTo: req.RedirectTo, }) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, result) } // BindEmailIdentity verifies and binds a local email identity for the current user. // POST /api/v1/user/account-bindings/email func (h *UserHandler) BindEmailIdentity(c *gin.Context) { subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return } if h.authService == nil { response.InternalError(c, "Auth service not configured") return } var req BindEmailIdentityRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } updatedUser, err := h.authService.BindEmailIdentity( c.Request.Context(), subject.UserID, req.Email, req.VerifyCode, req.Password, ) if err != nil { response.ErrorFrom(c, err) return } profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, profileResp) } // UnbindIdentity removes a third-party sign-in provider from the current user. // DELETE /api/v1/user/account-bindings/:provider func (h *UserHandler) UnbindIdentity(c *gin.Context) { subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return } updatedUser, err := h.userService.UnbindUserAuthProvider( c.Request.Context(), subject.UserID, c.Param("provider"), ) if err != nil { response.ErrorFrom(c, err) return } profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, profileResp) } // SendEmailBindingCode sends a verification code for the current user's email binding flow. // POST /api/v1/user/account-bindings/email/send-code func (h *UserHandler) SendEmailBindingCode(c *gin.Context) { subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return } if h.authService == nil { response.InternalError(c, "Auth service not configured") return } var req SendEmailBindingCodeRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } if err := h.authService.SendEmailIdentityBindCode(c.Request.Context(), subject.UserID, req.Email); err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"message": "Verification code sent successfully"}) } // SendNotifyEmailCodeRequest represents the request to send notify email verification code type SendNotifyEmailCodeRequest struct { Email string `json:"email" binding:"required,email"` } // SendNotifyEmailCode sends verification code to extra notification email // POST /api/v1/user/notify-email/send-code func (h *UserHandler) SendNotifyEmailCode(c *gin.Context) { subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return } var req SendNotifyEmailCodeRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } err := h.userService.SendNotifyEmailCode(c.Request.Context(), subject.UserID, req.Email, h.emailService, h.emailCache) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"message": "Verification code sent successfully"}) } // VerifyNotifyEmailRequest represents the request to verify and add notify email type VerifyNotifyEmailRequest struct { Email string `json:"email" binding:"required,email"` Code string `json:"code" binding:"required,len=6"` } // VerifyNotifyEmail verifies code and adds email to notification list // POST /api/v1/user/notify-email/verify func (h *UserHandler) VerifyNotifyEmail(c *gin.Context) { subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return } var req VerifyNotifyEmailRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } err := h.userService.VerifyAndAddNotifyEmail(c.Request.Context(), subject.UserID, req.Email, req.Code, h.emailCache) if err != nil { response.ErrorFrom(c, err) return } // Return updated user updatedUser, err := h.userService.GetByID(c.Request.Context(), subject.UserID) if err != nil { response.ErrorFrom(c, err) return } profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, profileResp) } // RemoveNotifyEmailRequest represents the request to remove a notify email type RemoveNotifyEmailRequest struct { Email string `json:"email" binding:"required,email"` } // RemoveNotifyEmail removes email from notification list // DELETE /api/v1/user/notify-email func (h *UserHandler) RemoveNotifyEmail(c *gin.Context) { subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return } var req RemoveNotifyEmailRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } err := h.userService.RemoveNotifyEmail(c.Request.Context(), subject.UserID, req.Email) if err != nil { response.ErrorFrom(c, err) return } // Return updated user updatedUser, err := h.userService.GetByID(c.Request.Context(), subject.UserID) if err != nil { response.ErrorFrom(c, err) return } profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, profileResp) } // ToggleNotifyEmailRequest represents the request to toggle a notify email's disabled state type ToggleNotifyEmailRequest struct { Email string `json:"email" binding:"required,email"` Disabled bool `json:"disabled"` } // ToggleNotifyEmail toggles the disabled state of a notification email // PUT /api/v1/user/notify-email/toggle func (h *UserHandler) ToggleNotifyEmail(c *gin.Context) { subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return } var req ToggleNotifyEmailRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } err := h.userService.ToggleNotifyEmail(c.Request.Context(), subject.UserID, req.Email, req.Disabled) if err != nil { response.ErrorFrom(c, err) return } updatedUser, err := h.userService.GetByID(c.Request.Context(), subject.UserID) if err != nil { response.ErrorFrom(c, err) return } profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, profileResp) } func (h *UserHandler) buildUserProfileResponse(ctx context.Context, userID int64, user *service.User) (userProfileResponse, error) { identities, err := h.userService.GetProfileIdentitySummaries(ctx, userID, user) if err != nil { return userProfileResponse{}, err } return userProfileResponseFromService(user, identities), nil } func userProfileResponseFromService(user *service.User, identities service.UserIdentitySummarySet) userProfileResponse { base := dto.UserFromService(user) if base == nil { return userProfileResponse{} } bindings := userProfileBindingMap(identities) profileSources, avatarSource, usernameSource := inferUserProfileSources(user, identities) return userProfileResponse{ User: *base, AvatarURL: user.AvatarURL, AvatarSource: avatarSource, UsernameSource: usernameSource, DisplayNameSource: usernameSource, NicknameSource: usernameSource, ProfileSources: profileSources, Identities: identities, AuthBindings: bindings, IdentityBindings: bindings, EmailBound: identities.Email.Bound, LinuxDoBound: identities.LinuxDo.Bound, OIDCBound: identities.OIDC.Bound, WeChatBound: identities.WeChat.Bound, } } func userProfileBindingMap(identities service.UserIdentitySummarySet) map[string]service.UserIdentitySummary { return map[string]service.UserIdentitySummary{ "email": identities.Email, "linuxdo": identities.LinuxDo, "oidc": identities.OIDC, "wechat": identities.WeChat, } } func inferUserProfileSources(user *service.User, identities service.UserIdentitySummarySet) ( map[string]*userProfileSourceContext, *userProfileSourceContext, *userProfileSourceContext, ) { if user == nil { return nil, nil, nil } thirdParty := thirdPartyIdentityProviders(identities) var avatarSource *userProfileSourceContext if strings.TrimSpace(user.AvatarURL) != "" && len(thirdParty) == 1 { avatarSource = buildUserProfileSourceContext(thirdParty[0].Provider) } usernameValue := strings.TrimSpace(user.Username) var usernameSource *userProfileSourceContext for _, summary := range thirdParty { if usernameValue != "" && usernameValue == strings.TrimSpace(summary.DisplayName) { usernameSource = buildUserProfileSourceContext(summary.Provider) break } } if usernameSource == nil && usernameValue != "" && len(thirdParty) == 1 { usernameSource = buildUserProfileSourceContext(thirdParty[0].Provider) } profileSources := map[string]*userProfileSourceContext{} if avatarSource != nil { profileSources["avatar"] = avatarSource } if usernameSource != nil { profileSources["username"] = usernameSource profileSources["display_name"] = usernameSource profileSources["nickname"] = usernameSource } if len(profileSources) == 0 { return nil, avatarSource, usernameSource } return profileSources, avatarSource, usernameSource } func thirdPartyIdentityProviders(identities service.UserIdentitySummarySet) []service.UserIdentitySummary { out := make([]service.UserIdentitySummary, 0, 3) for _, summary := range []service.UserIdentitySummary{identities.LinuxDo, identities.OIDC, identities.WeChat} { if summary.Bound { out = append(out, summary) } } return out } func buildUserProfileSourceContext(provider string) *userProfileSourceContext { provider = strings.TrimSpace(provider) if provider == "" { return nil } return &userProfileSourceContext{ Provider: provider, Source: provider, } }