mirror of https://gitee.com/answerdev/answer.git
feat(plugin): add user center plugin
This commit is contained in:
parent
aa13657e8d
commit
e5df07ad7c
|
@ -220,7 +220,9 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
|
||||||
templateController := controller.NewTemplateController(templateRenderController, siteInfoCommonService)
|
templateController := controller.NewTemplateController(templateRenderController, siteInfoCommonService)
|
||||||
templateRouter := router.NewTemplateRouter(templateController, templateRenderController, siteInfoController)
|
templateRouter := router.NewTemplateRouter(templateController, templateRenderController, siteInfoController)
|
||||||
connectorController := controller.NewConnectorController(siteInfoCommonService, emailService, userExternalLoginService)
|
connectorController := controller.NewConnectorController(siteInfoCommonService, emailService, userExternalLoginService)
|
||||||
pluginAPIRouter := router.NewPluginAPIRouter(connectorController)
|
userCenterLoginService := user_external_login2.NewUserCenterLoginService(userRepo, userCommon, userExternalLoginRepo, userActiveActivityRepo)
|
||||||
|
userCenterController := controller.NewUserCenterController(userCenterLoginService, siteInfoCommonService)
|
||||||
|
pluginAPIRouter := router.NewPluginAPIRouter(connectorController, userCenterController)
|
||||||
ginEngine := server.NewHTTPServer(debug, staticRouter, answerAPIRouter, swaggerRouter, uiRouter, authUserMiddleware, avatarMiddleware, templateRouter, pluginAPIRouter)
|
ginEngine := server.NewHTTPServer(debug, staticRouter, answerAPIRouter, swaggerRouter, uiRouter, authUserMiddleware, avatarMiddleware, templateRouter, pluginAPIRouter)
|
||||||
scheduledTaskManager := cron.NewScheduledTaskManager(siteInfoCommonService, questionService)
|
scheduledTaskManager := cron.NewScheduledTaskManager(siteInfoCommonService, questionService)
|
||||||
application := newApplication(serverConf, ginEngine, scheduledTaskManager)
|
application := newApplication(serverConf, ginEngine, scheduledTaskManager)
|
||||||
|
|
|
@ -25,4 +25,5 @@ var ProviderSetController = wire.NewSet(
|
||||||
NewActivityController,
|
NewActivityController,
|
||||||
NewTemplateController,
|
NewTemplateController,
|
||||||
NewConnectorController,
|
NewConnectorController,
|
||||||
|
NewUserCenterController,
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,176 @@
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/answerdev/answer/internal/base/handler"
|
||||||
|
"github.com/answerdev/answer/internal/base/middleware"
|
||||||
|
"github.com/answerdev/answer/internal/schema"
|
||||||
|
"github.com/answerdev/answer/internal/service/siteinfo_common"
|
||||||
|
"github.com/answerdev/answer/internal/service/user_external_login"
|
||||||
|
"github.com/answerdev/answer/plugin"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/segmentfault/pacman/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserCenterLoginRouter = "/user-center/login/redirect"
|
||||||
|
UserCenterSignUpRedirectRouter = "/user-center/sign-up/redirect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserCenterController comment controller
|
||||||
|
type UserCenterController struct {
|
||||||
|
userCenterLoginService *user_external_login.UserCenterLoginService
|
||||||
|
siteInfoService *siteinfo_common.SiteInfoCommonService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserCenterController new controller
|
||||||
|
func NewUserCenterController(
|
||||||
|
userCenterLoginService *user_external_login.UserCenterLoginService,
|
||||||
|
siteInfoService *siteinfo_common.SiteInfoCommonService,
|
||||||
|
) *UserCenterController {
|
||||||
|
return &UserCenterController{
|
||||||
|
userCenterLoginService: userCenterLoginService,
|
||||||
|
siteInfoService: siteInfoService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserCenterAgent get user center agent info
|
||||||
|
func (uc *UserCenterController) UserCenterAgent(ctx *gin.Context) {
|
||||||
|
resp := &schema.UserCenterAgentResp{}
|
||||||
|
resp.Enabled = plugin.UserCenterEnabled()
|
||||||
|
if !resp.Enabled {
|
||||||
|
handler.HandleResponse(ctx, nil, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
siteGeneral, err := uc.siteInfoService.GetSiteGeneral(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("get site info failed: %v", err)
|
||||||
|
ctx.Redirect(http.StatusFound, "/50x")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.AgentInfo = &schema.AgentInfo{}
|
||||||
|
resp.AgentInfo.LoginRedirectURL = fmt.Sprintf("%s%s%s", siteGeneral.SiteUrl,
|
||||||
|
commonRouterPrefix, UserCenterLoginRouter)
|
||||||
|
resp.AgentInfo.SignUpRedirectURL = fmt.Sprintf("%s%s%s", siteGeneral.SiteUrl,
|
||||||
|
commonRouterPrefix, UserCenterSignUpRedirectRouter)
|
||||||
|
|
||||||
|
_ = plugin.CallUserCenter(func(uc plugin.UserCenter) error {
|
||||||
|
info := uc.Description()
|
||||||
|
resp.AgentInfo.Name = info.Name
|
||||||
|
resp.AgentInfo.Icon = info.Icon
|
||||||
|
resp.AgentInfo.Url = info.Url
|
||||||
|
resp.AgentInfo.ControlCenterItems = make([]*schema.ControlCenter, 0)
|
||||||
|
items := uc.ControlCenterItems()
|
||||||
|
for _, item := range items {
|
||||||
|
resp.AgentInfo.ControlCenterItems = append(resp.AgentInfo.ControlCenterItems, &schema.ControlCenter{
|
||||||
|
Name: item.Name,
|
||||||
|
Label: item.Label,
|
||||||
|
Url: item.Url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
handler.HandleResponse(ctx, nil, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserCenterPersonalBranding get user center personal user info
|
||||||
|
func (uc *UserCenterController) UserCenterPersonalBranding(ctx *gin.Context) {
|
||||||
|
req := &schema.GetOtherUserInfoByUsernameReq{}
|
||||||
|
if handler.BindAndCheck(ctx, req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := uc.userCenterLoginService.UserCenterPersonalBranding(ctx, req.Username)
|
||||||
|
handler.HandleResponse(ctx, err, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserCenterController) UserCenterLoginRedirect(ctx *gin.Context) {
|
||||||
|
var redirectURL string
|
||||||
|
_ = plugin.CallUserCenter(func(uc plugin.UserCenter) error {
|
||||||
|
info := uc.Description()
|
||||||
|
redirectURL = info.LoginRedirectURL
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
ctx.Redirect(http.StatusFound, redirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserCenterController) UserCenterSignUpRedirect(ctx *gin.Context) {
|
||||||
|
var redirectURL string
|
||||||
|
_ = plugin.CallUserCenter(func(uc plugin.UserCenter) error {
|
||||||
|
info := uc.Description()
|
||||||
|
redirectURL = info.SignUpRedirectURL
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
ctx.Redirect(http.StatusFound, redirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserCenterController) UserCenterLoginCallback(ctx *gin.Context) {
|
||||||
|
siteGeneral, err := uc.siteInfoService.GetSiteGeneral(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("get site info failed: %v", err)
|
||||||
|
ctx.Redirect(http.StatusFound, "/50x")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userCenter, ok := plugin.GetUserCenter()
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusFound, "/404")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userInfo, err := userCenter.LoginCallback(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
ctx.Redirect(http.StatusFound, "/50x")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := uc.userCenterLoginService.ExternalLogin(ctx, userCenter.Info().SlugName, userInfo)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("external login failed: %v", err)
|
||||||
|
ctx.Redirect(http.StatusFound, "/50x")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/oauth?access_token=%s",
|
||||||
|
siteGeneral.SiteUrl, resp.AccessToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserCenterController) UserCenterSignUpCallback(ctx *gin.Context) {
|
||||||
|
siteGeneral, err := uc.siteInfoService.GetSiteGeneral(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("get site info failed: %v", err)
|
||||||
|
ctx.Redirect(http.StatusFound, "/50x")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userCenter, ok := plugin.GetUserCenter()
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusFound, "/404")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userInfo, err := userCenter.SignUpCallback(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
ctx.Redirect(http.StatusFound, "/50x")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := uc.userCenterLoginService.ExternalLogin(ctx, userCenter.Info().SlugName, userInfo)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("external login failed: %v", err)
|
||||||
|
ctx.Redirect(http.StatusFound, "/50x")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/oauth?access_token=%s",
|
||||||
|
siteGeneral.SiteUrl, resp.AccessToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserCenterUserSettings user center user settings
|
||||||
|
func (uc *UserCenterController) UserCenterUserSettings(ctx *gin.Context) {
|
||||||
|
userID := middleware.GetLoginUserIDFromContext(ctx)
|
||||||
|
resp, err := uc.userCenterLoginService.UserCenterUserSettings(ctx, userID)
|
||||||
|
handler.HandleResponse(ctx, err, resp)
|
||||||
|
}
|
|
@ -7,9 +7,12 @@ import (
|
||||||
"github.com/answerdev/answer/internal/base/data"
|
"github.com/answerdev/answer/internal/base/data"
|
||||||
"github.com/answerdev/answer/internal/base/reason"
|
"github.com/answerdev/answer/internal/base/reason"
|
||||||
"github.com/answerdev/answer/internal/entity"
|
"github.com/answerdev/answer/internal/entity"
|
||||||
|
"github.com/answerdev/answer/internal/schema"
|
||||||
"github.com/answerdev/answer/internal/service/config"
|
"github.com/answerdev/answer/internal/service/config"
|
||||||
usercommon "github.com/answerdev/answer/internal/service/user_common"
|
usercommon "github.com/answerdev/answer/internal/service/user_common"
|
||||||
|
"github.com/answerdev/answer/plugin"
|
||||||
"github.com/segmentfault/pacman/errors"
|
"github.com/segmentfault/pacman/errors"
|
||||||
|
"github.com/segmentfault/pacman/log"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -137,7 +140,9 @@ func (ur *userRepo) GetByUserID(ctx context.Context, userID string) (userInfo *e
|
||||||
exist, err = ur.data.DB.Where("id = ?", userID).Get(userInfo)
|
exist, err = ur.data.DB.Where("id = ?", userID).Get(userInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
ur.tryToDecorateUserInfoFromUserCenter(ctx, userInfo)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,6 +152,7 @@ func (ur *userRepo) BatchGetByID(ctx context.Context, ids []string) ([]*entity.U
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
||||||
}
|
}
|
||||||
|
ur.tryToDecorateUserListFromUserCenter(ctx, list)
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,7 +162,9 @@ func (ur *userRepo) GetByUsername(ctx context.Context, username string) (userInf
|
||||||
exist, err = ur.data.DB.Where("username = ?", username).Get(userInfo)
|
exist, err = ur.data.DB.Where("username = ?", username).Get(userInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
ur.tryToDecorateUserInfoFromUserCenter(ctx, userInfo)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,11 +179,102 @@ func (ur *userRepo) GetByEmail(ctx context.Context, email string) (userInfo *ent
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vr *userRepo) GetUserCount(ctx context.Context) (count int64, err error) {
|
func (ur *userRepo) GetUserCount(ctx context.Context) (count int64, err error) {
|
||||||
list := make([]*entity.User, 0)
|
list := make([]*entity.User, 0)
|
||||||
count, err = vr.data.DB.Where("mail_status =?", entity.EmailStatusAvailable).And("status =?", entity.UserStatusAvailable).FindAndCount(&list)
|
count, err = ur.data.DB.Where("mail_status =?", entity.EmailStatusAvailable).And("status =?", entity.UserStatusAvailable).FindAndCount(&list)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return count, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
return count, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ur *userRepo) tryToDecorateUserInfoFromUserCenter(ctx context.Context, original *entity.User) {
|
||||||
|
uc, ok := plugin.GetUserCenter()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo := &entity.UserExternalLogin{}
|
||||||
|
session := ur.data.DB.Where("user_id = ?", original.ID)
|
||||||
|
session.Where("provider = ?", uc.Info().SlugName)
|
||||||
|
exist, err := session.Get(userInfo)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exist {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userCenterBasicUserInfo, err := uc.UserInfo(userInfo.ExternalID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// In general, usernames should be guaranteed unique by the User Center plugin, so there are no inconsistencies.
|
||||||
|
if original.Username != userCenterBasicUserInfo.Username {
|
||||||
|
log.Warnf("user %s username is inconsistent with user center", original.ID)
|
||||||
|
}
|
||||||
|
decorateByUserCenterUser(original, userCenterBasicUserInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ur *userRepo) tryToDecorateUserListFromUserCenter(ctx context.Context, original []*entity.User) {
|
||||||
|
log.Debugf("try to decorate user list from user center, original: %+v", original)
|
||||||
|
uc, ok := plugin.GetUserCenter()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]string, 0)
|
||||||
|
originalUserIDMapping := make(map[string]*entity.User, 0)
|
||||||
|
for _, user := range original {
|
||||||
|
originalUserIDMapping[user.ID] = user
|
||||||
|
ids = append(ids, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
userExternalLoginList := make([]*entity.UserExternalLogin, 0)
|
||||||
|
session := ur.data.DB.Where("provider = ?", uc.Info().SlugName)
|
||||||
|
session.In("user_id", ids)
|
||||||
|
err := session.Find(&userExternalLoginList)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userExternalIDs := make([]string, 0)
|
||||||
|
originalExternalIDMapping := make(map[string]*entity.User, 0)
|
||||||
|
for _, u := range userExternalLoginList {
|
||||||
|
originalExternalIDMapping[u.ExternalID] = originalUserIDMapping[u.UserID]
|
||||||
|
userExternalIDs = append(userExternalIDs, u.ExternalID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ucUsers, err := uc.UserList(userExternalIDs)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ucUser := range ucUsers {
|
||||||
|
decorateByUserCenterUser(originalExternalIDMapping[ucUser.ExternalID], ucUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decorateByUserCenterUser(original *entity.User, ucUser *plugin.UserCenterBasicUserInfo) {
|
||||||
|
if original == nil || ucUser == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// In general, usernames should be guaranteed unique by the User Center plugin, so there are no inconsistencies.
|
||||||
|
if original.Username != ucUser.Username {
|
||||||
|
log.Warnf("user %s username is inconsistent with user center", original.ID)
|
||||||
|
}
|
||||||
|
original.DisplayName = ucUser.DisplayName
|
||||||
|
original.EMail = ucUser.Email
|
||||||
|
original.Avatar = schema.CustomAvatar(ucUser.Avatar).ToJsonString()
|
||||||
|
original.Mobile = ucUser.Mobile
|
||||||
|
|
||||||
|
// If plugin enable rank agent, use rank from user center.
|
||||||
|
if plugin.RankAgentEnabled() {
|
||||||
|
original.Rank = ucUser.Rank
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -43,10 +43,10 @@ func (ur *userExternalLoginRepo) UpdateInfo(ctx context.Context, userInfo *entit
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByExternalID get by external ID
|
// GetByExternalID get by external ID
|
||||||
func (ur *userExternalLoginRepo) GetByExternalID(ctx context.Context, externalID string) (
|
func (ur *userExternalLoginRepo) GetByExternalID(ctx context.Context, provider, externalID string) (
|
||||||
userInfo *entity.UserExternalLogin, exist bool, err error) {
|
userInfo *entity.UserExternalLogin, exist bool, err error) {
|
||||||
userInfo = &entity.UserExternalLogin{}
|
userInfo = &entity.UserExternalLogin{}
|
||||||
exist, err = ur.data.DB.Where("external_id = ?", externalID).Get(userInfo)
|
exist, err = ur.data.DB.Where("external_id = ?", externalID).Where("provider = ?", provider).Get(userInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,27 +6,41 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type PluginAPIRouter struct {
|
type PluginAPIRouter struct {
|
||||||
connectorController *controller.ConnectorController
|
connectorController *controller.ConnectorController
|
||||||
|
userCenterController *controller.UserCenterController
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPluginAPIRouter(
|
func NewPluginAPIRouter(
|
||||||
connectorController *controller.ConnectorController,
|
connectorController *controller.ConnectorController,
|
||||||
|
userCenterController *controller.UserCenterController,
|
||||||
) *PluginAPIRouter {
|
) *PluginAPIRouter {
|
||||||
return &PluginAPIRouter{
|
return &PluginAPIRouter{
|
||||||
connectorController: connectorController,
|
connectorController: connectorController,
|
||||||
|
userCenterController: userCenterController,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pr *PluginAPIRouter) RegisterUnAuthConnectorRouter(r *gin.RouterGroup) {
|
func (pr *PluginAPIRouter) RegisterUnAuthConnectorRouter(r *gin.RouterGroup) {
|
||||||
|
// connector plugin
|
||||||
connectorController := pr.connectorController
|
connectorController := pr.connectorController
|
||||||
r.GET(controller.ConnectorLoginRouterPrefix+":name", connectorController.ConnectorLoginDispatcher)
|
r.GET(controller.ConnectorLoginRouterPrefix+":name", connectorController.ConnectorLoginDispatcher)
|
||||||
r.GET(controller.ConnectorRedirectRouterPrefix+":name", connectorController.ConnectorRedirectDispatcher)
|
r.GET(controller.ConnectorRedirectRouterPrefix+":name", connectorController.ConnectorRedirectDispatcher)
|
||||||
r.GET("/connector/info", connectorController.ConnectorsInfo)
|
r.GET("/connector/info", connectorController.ConnectorsInfo)
|
||||||
r.POST("/connector/binding/email", connectorController.ExternalLoginBindingUserSendEmail)
|
r.POST("/connector/binding/email", connectorController.ExternalLoginBindingUserSendEmail)
|
||||||
|
|
||||||
|
// user center plugin
|
||||||
|
r.GET("/user-center/agent", pr.userCenterController.UserCenterAgent)
|
||||||
|
r.GET("/user-center/personal/branding", pr.userCenterController.UserCenterPersonalBranding)
|
||||||
|
r.GET(controller.UserCenterLoginRouter, pr.userCenterController.UserCenterLoginRedirect)
|
||||||
|
r.GET(controller.UserCenterSignUpRedirectRouter, pr.userCenterController.UserCenterSignUpRedirect)
|
||||||
|
r.GET("/user-center/login/callback", pr.userCenterController.UserCenterLoginCallback)
|
||||||
|
r.GET("/user-center/sign-up/callback", pr.userCenterController.UserCenterSignUpCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pr *PluginAPIRouter) RegisterAuthConnectorRouter(r *gin.RouterGroup) {
|
func (pr *PluginAPIRouter) RegisterAuthConnectorRouter(r *gin.RouterGroup) {
|
||||||
connectorController := pr.connectorController
|
connectorController := pr.connectorController
|
||||||
r.GET("/connector/user/info", connectorController.ConnectorsUserInfo)
|
r.GET("/connector/user/info", connectorController.ConnectorsUserInfo)
|
||||||
r.DELETE("/connector/user/unbinding", connectorController.ExternalLoginUnbinding)
|
r.DELETE("/connector/user/unbinding", connectorController.ExternalLoginUnbinding)
|
||||||
|
|
||||||
|
r.GET("/user-center/user/settings", pr.userCenterController.UserCenterUserSettings)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
package schema
|
||||||
|
|
||||||
|
type UserCenterAgentResp struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
AgentInfo *AgentInfo `json:"agent_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
LoginRedirectURL string `json:"login_redirect_url"`
|
||||||
|
SignUpRedirectURL string `json:"sign_up_redirect_url"`
|
||||||
|
ControlCenterItems []*ControlCenter `json:"control_center"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlCenter struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserCenterPersonalBranding struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
PersonalBranding []*PersonalBranding `json:"personal_branding"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersonalBranding struct {
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
}
|
|
@ -56,3 +56,14 @@ type ExternalLoginUnbindingReq struct {
|
||||||
ExternalID string `validate:"required,gt=0,lte=128" json:"external_id"`
|
ExternalID string `validate:"required,gt=0,lte=128" json:"external_id"`
|
||||||
UserID string `json:"-"`
|
UserID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserCenterUserSettingsResp user center user info response
|
||||||
|
type UserCenterUserSettingsResp struct {
|
||||||
|
ProfileSettingAgent UserSettingAgent `json:"profile_setting_agent"`
|
||||||
|
AccountSettingAgent UserSettingAgent `json:"account_setting_agent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSettingAgent struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
}
|
||||||
|
|
|
@ -143,6 +143,13 @@ func FormatAvatarInfo(avatarJson, email string) (res string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CustomAvatar(url string) *AvatarInfo {
|
||||||
|
return &AvatarInfo{
|
||||||
|
Type: AvatarTypeCustom,
|
||||||
|
Custom: url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetUserStatusResp get user status info
|
// GetUserStatusResp get user status info
|
||||||
type GetUserStatusResp struct {
|
type GetUserStatusResp struct {
|
||||||
// user status
|
// user status
|
||||||
|
@ -316,6 +323,11 @@ type AvatarInfo struct {
|
||||||
Custom string `validate:"omitempty,gt=0,lte=200" json:"custom"`
|
Custom string `validate:"omitempty,gt=0,lte=200" json:"custom"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *AvatarInfo) ToJsonString() string {
|
||||||
|
data, _ := json.Marshal(a)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
func (req *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, err error) {
|
func (req *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, err error) {
|
||||||
if len(req.Username) > 0 {
|
if len(req.Username) > 0 {
|
||||||
if checker.IsInvalidUsername(req.Username) {
|
if checker.IsInvalidUsername(req.Username) {
|
||||||
|
|
|
@ -82,5 +82,6 @@ var ProviderSetService = wire.NewSet(
|
||||||
role.NewUserRoleRelService,
|
role.NewUserRoleRelService,
|
||||||
role.NewRolePowerRelService,
|
role.NewRolePowerRelService,
|
||||||
user_external_login.NewUserExternalLoginService,
|
user_external_login.NewUserExternalLoginService,
|
||||||
|
user_external_login.NewUserCenterLoginService,
|
||||||
plugin_common.NewPluginCommonService,
|
plugin_common.NewPluginCommonService,
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,211 @@
|
||||||
|
package user_external_login
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/answerdev/answer/internal/entity"
|
||||||
|
"github.com/answerdev/answer/internal/schema"
|
||||||
|
"github.com/answerdev/answer/internal/service/activity"
|
||||||
|
usercommon "github.com/answerdev/answer/internal/service/user_common"
|
||||||
|
"github.com/answerdev/answer/pkg/random"
|
||||||
|
"github.com/answerdev/answer/plugin"
|
||||||
|
"github.com/segmentfault/pacman/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserCenterLoginService user external login service
|
||||||
|
type UserCenterLoginService struct {
|
||||||
|
userRepo usercommon.UserRepo
|
||||||
|
userExternalLoginRepo UserExternalLoginRepo
|
||||||
|
userCommonService *usercommon.UserCommon
|
||||||
|
userActivity activity.UserActiveActivityRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserCenterLoginService new user external login service
|
||||||
|
func NewUserCenterLoginService(
|
||||||
|
userRepo usercommon.UserRepo,
|
||||||
|
userCommonService *usercommon.UserCommon,
|
||||||
|
userExternalLoginRepo UserExternalLoginRepo,
|
||||||
|
userActivity activity.UserActiveActivityRepo,
|
||||||
|
) *UserCenterLoginService {
|
||||||
|
return &UserCenterLoginService{
|
||||||
|
userRepo: userRepo,
|
||||||
|
userCommonService: userCommonService,
|
||||||
|
userExternalLoginRepo: userExternalLoginRepo,
|
||||||
|
userActivity: userActivity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (us *UserCenterLoginService) ExternalLogin(
|
||||||
|
ctx context.Context, provider string, basicUserInfo *plugin.UserCenterBasicUserInfo) (
|
||||||
|
resp *schema.UserExternalLoginResp, err error) {
|
||||||
|
|
||||||
|
oldExternalLoginUserInfo, exist, err := us.userExternalLoginRepo.GetByExternalID(ctx,
|
||||||
|
provider, basicUserInfo.ExternalID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if exist {
|
||||||
|
// if user is already a member, login directly
|
||||||
|
oldUserInfo, exist, err := us.userRepo.GetByUserID(ctx, oldExternalLoginUserInfo.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if exist {
|
||||||
|
accessToken, _, err := us.userCommonService.CacheLoginUserInfo(
|
||||||
|
ctx, oldUserInfo.ID, oldUserInfo.MailStatus, oldUserInfo.Status)
|
||||||
|
return &schema.UserExternalLoginResp{AccessToken: accessToken}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oldUserInfo, err := us.registerNewUser(ctx, provider, basicUserInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
us.activeUser(ctx, oldUserInfo)
|
||||||
|
|
||||||
|
accessToken, _, err := us.userCommonService.CacheLoginUserInfo(
|
||||||
|
ctx, oldUserInfo.ID, oldUserInfo.MailStatus, oldUserInfo.Status)
|
||||||
|
return &schema.UserExternalLoginResp{AccessToken: accessToken}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (us *UserCenterLoginService) registerNewUser(ctx context.Context, provider string,
|
||||||
|
basicUserInfo *plugin.UserCenterBasicUserInfo) (userInfo *entity.User, err error) {
|
||||||
|
userInfo = &entity.User{}
|
||||||
|
userInfo.EMail = basicUserInfo.Email
|
||||||
|
userInfo.DisplayName = basicUserInfo.DisplayName
|
||||||
|
|
||||||
|
userInfo.Username, err = us.userCommonService.MakeUsername(ctx, basicUserInfo.Username)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
userInfo.Username = random.Username()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(basicUserInfo.Avatar) > 0 {
|
||||||
|
avatarInfo := &schema.AvatarInfo{
|
||||||
|
Type: schema.AvatarTypeCustom,
|
||||||
|
Custom: basicUserInfo.Avatar,
|
||||||
|
}
|
||||||
|
avatar, _ := json.Marshal(avatarInfo)
|
||||||
|
userInfo.Avatar = string(avatar)
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo.MailStatus = entity.EmailStatusAvailable
|
||||||
|
userInfo.Status = entity.UserStatusAvailable
|
||||||
|
userInfo.LastLoginDate = time.Now()
|
||||||
|
err = us.userRepo.AddUser(ctx, userInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
metaInfo, _ := json.Marshal(basicUserInfo)
|
||||||
|
newExternalUserInfo := &entity.UserExternalLogin{
|
||||||
|
UserID: userInfo.ID,
|
||||||
|
Provider: provider,
|
||||||
|
ExternalID: basicUserInfo.ExternalID,
|
||||||
|
MetaInfo: string(metaInfo),
|
||||||
|
}
|
||||||
|
err = us.userExternalLoginRepo.AddUserExternalLogin(ctx, newExternalUserInfo)
|
||||||
|
|
||||||
|
return userInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (us *UserCenterLoginService) activeUser(ctx context.Context, oldUserInfo *entity.User) {
|
||||||
|
if err := us.userActivity.UserActive(ctx, oldUserInfo.ID); err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (us *UserCenterLoginService) UserCenterUserSettings(ctx context.Context, userID string) (
|
||||||
|
resp *schema.UserCenterUserSettingsResp, err error) {
|
||||||
|
resp = &schema.UserCenterUserSettingsResp{}
|
||||||
|
|
||||||
|
userCenter, ok := plugin.GetUserCenter()
|
||||||
|
if !ok {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get external login info
|
||||||
|
externalLoginList, err := us.userExternalLoginRepo.GetUserExternalLoginList(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var externalInfo *entity.UserExternalLogin
|
||||||
|
for _, t := range externalLoginList {
|
||||||
|
if t.Provider == userCenter.Info().SlugName {
|
||||||
|
externalInfo = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if externalInfo == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := userCenter.UserSettings(externalInfo.ExternalID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(settings.AccountSettingRedirectURL) > 0 {
|
||||||
|
resp.AccountSettingAgent = schema.UserSettingAgent{
|
||||||
|
Enabled: true,
|
||||||
|
RedirectURL: settings.AccountSettingRedirectURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(settings.ProfileSettingRedirectURL) > 0 {
|
||||||
|
resp.ProfileSettingAgent = schema.UserSettingAgent{
|
||||||
|
Enabled: true,
|
||||||
|
RedirectURL: settings.ProfileSettingRedirectURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (us *UserCenterLoginService) UserCenterPersonalBranding(ctx context.Context, username string) (
|
||||||
|
resp *schema.UserCenterPersonalBranding, err error) {
|
||||||
|
resp = &schema.UserCenterPersonalBranding{
|
||||||
|
PersonalBranding: make([]*schema.PersonalBranding, 0),
|
||||||
|
}
|
||||||
|
userCenter, ok := plugin.GetUserCenter()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo, exist, err := us.userRepo.GetByUsername(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !exist {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get external login info
|
||||||
|
externalLoginList, err := us.userExternalLoginRepo.GetUserExternalLoginList(ctx, userInfo.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var externalInfo *entity.UserExternalLogin
|
||||||
|
for _, t := range externalLoginList {
|
||||||
|
if t.Provider == userCenter.Info().SlugName {
|
||||||
|
externalInfo = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if externalInfo == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Enabled = true
|
||||||
|
branding := userCenter.PersonalBranding(externalInfo.ExternalID)
|
||||||
|
|
||||||
|
for _, t := range branding {
|
||||||
|
resp.PersonalBranding = append(resp.PersonalBranding, &schema.PersonalBranding{
|
||||||
|
Icon: t.Icon,
|
||||||
|
Name: t.Name,
|
||||||
|
Label: t.Label,
|
||||||
|
Url: t.Url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
|
@ -23,7 +23,7 @@ import (
|
||||||
type UserExternalLoginRepo interface {
|
type UserExternalLoginRepo interface {
|
||||||
AddUserExternalLogin(ctx context.Context, user *entity.UserExternalLogin) (err error)
|
AddUserExternalLogin(ctx context.Context, user *entity.UserExternalLogin) (err error)
|
||||||
UpdateInfo(ctx context.Context, userInfo *entity.UserExternalLogin) (err error)
|
UpdateInfo(ctx context.Context, userInfo *entity.UserExternalLogin) (err error)
|
||||||
GetByExternalID(ctx context.Context, externalID string) (userInfo *entity.UserExternalLogin, exist bool, err error)
|
GetByExternalID(ctx context.Context, provider, externalID string) (userInfo *entity.UserExternalLogin, exist bool, err error)
|
||||||
GetUserExternalLoginList(ctx context.Context, userID string) (
|
GetUserExternalLoginList(ctx context.Context, userID string) (
|
||||||
resp []*entity.UserExternalLogin, err error)
|
resp []*entity.UserExternalLogin, err error)
|
||||||
DeleteUserExternalLogin(ctx context.Context, userID, externalID string) (err error)
|
DeleteUserExternalLogin(ctx context.Context, userID, externalID string) (err error)
|
||||||
|
@ -64,7 +64,8 @@ func NewUserExternalLoginService(
|
||||||
func (us *UserExternalLoginService) ExternalLogin(
|
func (us *UserExternalLoginService) ExternalLogin(
|
||||||
ctx context.Context, externalUserInfo *schema.ExternalLoginUserInfoCache) (
|
ctx context.Context, externalUserInfo *schema.ExternalLoginUserInfoCache) (
|
||||||
resp *schema.UserExternalLoginResp, err error) {
|
resp *schema.UserExternalLoginResp, err error) {
|
||||||
oldExternalLoginUserInfo, exist, err := us.userExternalLoginRepo.GetByExternalID(ctx, externalUserInfo.ExternalID)
|
oldExternalLoginUserInfo, exist, err := us.userExternalLoginRepo.GetByExternalID(ctx,
|
||||||
|
externalUserInfo.Provider, externalUserInfo.ExternalID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -156,7 +157,9 @@ func (us *UserExternalLoginService) registerNewUser(ctx context.Context,
|
||||||
|
|
||||||
func (us *UserExternalLoginService) bindOldUser(ctx context.Context,
|
func (us *UserExternalLoginService) bindOldUser(ctx context.Context,
|
||||||
externalUserInfo *schema.ExternalLoginUserInfoCache, oldUserInfo *entity.User) (err error) {
|
externalUserInfo *schema.ExternalLoginUserInfoCache, oldUserInfo *entity.User) (err error) {
|
||||||
oldExternalUserInfo, exist, err := us.userExternalLoginRepo.GetByExternalID(ctx, externalUserInfo.ExternalID)
|
oldExternalUserInfo, exist, err := us.userExternalLoginRepo.GetByExternalID(ctx,
|
||||||
|
externalUserInfo.Provider,
|
||||||
|
externalUserInfo.ExternalID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,10 @@ func Register(p Base) {
|
||||||
if _, ok := p.(Cache); ok {
|
if _, ok := p.(Cache); ok {
|
||||||
registerCache(p.(Cache))
|
registerCache(p.(Cache))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, ok := p.(UserCenter); ok {
|
||||||
|
registerUserCenter(p.(UserCenter))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stack[T Base] struct {
|
type Stack[T Base] struct {
|
||||||
|
|
|
@ -0,0 +1,81 @@
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
type UserCenter interface {
|
||||||
|
Base
|
||||||
|
Description() UserCenterDesc
|
||||||
|
ControlCenterItems() []ControlCenter
|
||||||
|
LoginCallback(ctx *GinContext) (userInfo *UserCenterBasicUserInfo, err error)
|
||||||
|
SignUpCallback(ctx *GinContext) (userInfo *UserCenterBasicUserInfo, err error)
|
||||||
|
UserInfo(externalID string) (userInfo *UserCenterBasicUserInfo, err error)
|
||||||
|
UserList(externalIDs []string) (userInfo []*UserCenterBasicUserInfo, err error)
|
||||||
|
UserSettings(externalID string) (userSettings *SettingInfo, err error)
|
||||||
|
PersonalBranding(externalID string) (branding []*PersonalBranding)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserCenterDesc struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
LoginRedirectURL string `json:"login_redirect_url"`
|
||||||
|
SignUpRedirectURL string `json:"sign_up_redirect_url"`
|
||||||
|
RankAgentEnabled bool `json:"rank_agent_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserCenterBasicUserInfo struct {
|
||||||
|
ExternalID string `json:"external_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Rank int `json:"rank"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Mobile string `json:"mobile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlCenter struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingInfo struct {
|
||||||
|
ProfileSettingRedirectURL string `json:"profile_setting_redirect_url"`
|
||||||
|
AccountSettingRedirectURL string `json:"account_setting_redirect_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersonalBranding struct {
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// CallUserCenter is a function that calls all registered parsers
|
||||||
|
CallUserCenter,
|
||||||
|
registerUserCenter = MakePlugin[UserCenter](false)
|
||||||
|
)
|
||||||
|
|
||||||
|
func UserCenterEnabled() (enabled bool) {
|
||||||
|
_ = CallUserCenter(func(fn UserCenter) error {
|
||||||
|
enabled = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func RankAgentEnabled() (enabled bool) {
|
||||||
|
_ = CallUserCenter(func(fn UserCenter) error {
|
||||||
|
enabled = fn.Description().RankAgentEnabled
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserCenter() (uc UserCenter, ok bool) {
|
||||||
|
_ = CallUserCenter(func(fn UserCenter) error {
|
||||||
|
uc = fn
|
||||||
|
ok = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
Loading…
Reference in New Issue