diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index a9dff442..37c684e7 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -25,6 +25,7 @@ import ( "github.com/answerdev/answer/internal/repo/comment" "github.com/answerdev/answer/internal/repo/config" "github.com/answerdev/answer/internal/repo/export" + "github.com/answerdev/answer/internal/repo/limit" "github.com/answerdev/answer/internal/repo/meta" notification2 "github.com/answerdev/answer/internal/repo/notification" "github.com/answerdev/answer/internal/repo/plugin_config" @@ -154,7 +155,9 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, rolePowerRelRepo := role.NewRolePowerRelRepo(dataData) rolePowerRelService := role2.NewRolePowerRelService(rolePowerRelRepo, userRoleRelService) rankService := rank2.NewRankService(userCommon, userRankRepo, objService, userRoleRelService, rolePowerRelService, configService) - commentController := controller.NewCommentController(commentService, rankService, captchaService) + limitRepo := limit.NewRateLimitRepo(dataData) + rateLimitMiddleware := middleware.NewRateLimitMiddleware(limitRepo) + commentController := controller.NewCommentController(commentService, rankService, captchaService, rateLimitMiddleware) reportRepo := report.NewReportRepo(dataData, uniqueIDRepo) reportService := report2.NewReportService(reportRepo, objService) reportController := controller.NewReportController(reportService, rankService, captchaService) @@ -181,8 +184,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalNotificationQueueService) questionService := service.NewQuestionService(questionRepo, tagCommonService, questionCommon, userCommon, userRepo, revisionService, metaService, collectionCommon, answerActivityService, emailService, notificationQueueService, externalNotificationQueueService, activityQueueService, siteInfoCommonService, externalNotificationService) answerService := service.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, notificationQueueService, externalNotificationQueueService, activityQueueService) - questionController := controller.NewQuestionController(questionService, answerService, rankService, siteInfoCommonService, captchaService) - answerController := controller.NewAnswerController(answerService, rankService, captchaService) + questionController := controller.NewQuestionController(questionService, answerService, rankService, siteInfoCommonService, captchaService, rateLimitMiddleware) + answerController := controller.NewAnswerController(answerService, rankService, captchaService, rateLimitMiddleware) searchParser := search_parser.NewSearchParser(tagCommonService, userCommon) searchRepo := search_common.NewSearchRepo(dataData, uniqueIDRepo, userCommon, tagCommonService) searchService := service.NewSearchService(searchParser, searchRepo) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 62a92e56..7ebbf9ea 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -14,6 +14,8 @@ backend: other: Data server error. forbidden_error: other: Forbidden. + duplicate_request_error: + other: Duplicate submission. action: report: other: Flag diff --git a/internal/base/constant/cache_key.go b/internal/base/constant/cache_key.go index f8b80d09..51bc257e 100644 --- a/internal/base/constant/cache_key.go +++ b/internal/base/constant/cache_key.go @@ -26,4 +26,6 @@ const ( NewQuestionNotificationLimitCacheKeyPrefix = "answer:new-question-notification-limit:" NewQuestionNotificationLimitCacheTime = 7 * 24 * time.Hour NewQuestionNotificationLimitMax = 50 + RateLimitCacheKeyPrefix = "answer:rate-limit:" + RateLimitCacheTime = 5 * time.Minute ) diff --git a/internal/base/middleware/provider.go b/internal/base/middleware/provider.go index 7e699c91..ec0129aa 100644 --- a/internal/base/middleware/provider.go +++ b/internal/base/middleware/provider.go @@ -9,4 +9,5 @@ var ProviderSetMiddleware = wire.NewSet( NewAuthUserMiddleware, NewAvatarMiddleware, NewShortIDMiddleware, + NewRateLimitMiddleware, ) diff --git a/internal/base/middleware/rate_limit.go b/internal/base/middleware/rate_limit.go new file mode 100644 index 00000000..837ad98e --- /dev/null +++ b/internal/base/middleware/rate_limit.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "encoding/json" + "fmt" + "github.com/answerdev/answer/internal/base/handler" + "github.com/answerdev/answer/internal/base/reason" + "github.com/answerdev/answer/internal/repo/limit" + "github.com/answerdev/answer/pkg/encryption" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +type RateLimitMiddleware struct { + limitRepo *limit.LimitRepo +} + +// NewRateLimitMiddleware new rate limit middleware +func NewRateLimitMiddleware(limitRepo *limit.LimitRepo) *RateLimitMiddleware { + return &RateLimitMiddleware{ + limitRepo: limitRepo, + } +} + +// DuplicateRequestRejection detects and rejects duplicate requests +// It only works for the requests that post content. Such as add question, add answer, comment etc. +func (rm *RateLimitMiddleware) DuplicateRequestRejection(ctx *gin.Context, req any) bool { + userID := GetLoginUserIDFromContext(ctx) + fullPath := ctx.FullPath() + reqJson, _ := json.Marshal(req) + key := encryption.MD5(fmt.Sprintf("%s:%s:%s", userID, fullPath, string(reqJson))) + reject, err := rm.limitRepo.CheckAndRecord(ctx, key) + if err != nil { + log.Errorf("check and record rate limit error: %s", err.Error()) + return false + } + if !reject { + return false + } + log.Debugf("duplicate request: [%s] %s", fullPath, string(reqJson)) + handler.HandleResponse(ctx, errors.BadRequest(reason.DuplicateRequestError), nil) + return true +} diff --git a/internal/base/reason/reason.go b/internal/base/reason/reason.go index 9b7e4716..9f3dce46 100644 --- a/internal/base/reason/reason.go +++ b/internal/base/reason/reason.go @@ -13,6 +13,8 @@ const ( DatabaseError = "base.database_error" // ForbiddenError forbidden error ForbiddenError = "base.forbidden_error" + // DuplicateRequestError duplicate request error + DuplicateRequestError = "base.duplicate_request_error" ) const ( diff --git a/internal/controller/answer_controller.go b/internal/controller/answer_controller.go index aa735733..63347c9a 100644 --- a/internal/controller/answer_controller.go +++ b/internal/controller/answer_controller.go @@ -21,9 +21,10 @@ import ( // AnswerController answer controller type AnswerController struct { - answerService *service.AnswerService - rankService *rank.RankService - actionService *action.CaptchaService + answerService *service.AnswerService + rankService *rank.RankService + actionService *action.CaptchaService + rateLimitMiddleware *middleware.RateLimitMiddleware } // NewAnswerController new controller @@ -31,11 +32,13 @@ func NewAnswerController( answerService *service.AnswerService, rankService *rank.RankService, actionService *action.CaptchaService, + rateLimitMiddleware *middleware.RateLimitMiddleware, ) *AnswerController { return &AnswerController{ - answerService: answerService, - rankService: rankService, - actionService: actionService, + answerService: answerService, + rankService: rankService, + actionService: actionService, + rateLimitMiddleware: rateLimitMiddleware, } } @@ -168,6 +171,9 @@ func (ac *AnswerController) Add(ctx *gin.Context) { if handler.BindAndCheck(ctx, req) { return } + if ac.rateLimitMiddleware.DuplicateRequestRejection(ctx, req) { + return + } req.QuestionID = uid.DeShortID(req.QuestionID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go index 20f623bf..a1643426 100644 --- a/internal/controller/comment_controller.go +++ b/internal/controller/comment_controller.go @@ -19,9 +19,10 @@ import ( // CommentController comment controller type CommentController struct { - commentService *comment.CommentService - rankService *rank.RankService - actionService *action.CaptchaService + commentService *comment.CommentService + rankService *rank.RankService + actionService *action.CaptchaService + rateLimitMiddleware *middleware.RateLimitMiddleware } // NewCommentController new controller @@ -29,11 +30,13 @@ func NewCommentController( commentService *comment.CommentService, rankService *rank.RankService, actionService *action.CaptchaService, + rateLimitMiddleware *middleware.RateLimitMiddleware, ) *CommentController { return &CommentController{ - commentService: commentService, - rankService: rankService, - actionService: actionService, + commentService: commentService, + rankService: rankService, + actionService: actionService, + rateLimitMiddleware: rateLimitMiddleware, } } @@ -52,6 +55,9 @@ func (cc *CommentController) AddComment(ctx *gin.Context) { if handler.BindAndCheck(ctx, req) { return } + if cc.rateLimitMiddleware.DuplicateRequestRejection(ctx, req) { + return + } req.ObjectID = uid.DeShortID(req.ObjectID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index c2bd2865..19148cf2 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -22,11 +22,12 @@ import ( // QuestionController question controller type QuestionController struct { - questionService *service.QuestionService - answerService *service.AnswerService - rankService *rank.RankService - siteInfoService siteinfo_common.SiteInfoCommonService - actionService *action.CaptchaService + questionService *service.QuestionService + answerService *service.AnswerService + rankService *rank.RankService + siteInfoService siteinfo_common.SiteInfoCommonService + actionService *action.CaptchaService + rateLimitMiddleware *middleware.RateLimitMiddleware } // NewQuestionController new controller @@ -36,13 +37,15 @@ func NewQuestionController( rankService *rank.RankService, siteInfoService siteinfo_common.SiteInfoCommonService, actionService *action.CaptchaService, + rateLimitMiddleware *middleware.RateLimitMiddleware, ) *QuestionController { return &QuestionController{ - questionService: questionService, - answerService: answerService, - rankService: rankService, - siteInfoService: siteInfoService, - actionService: actionService, + questionService: questionService, + answerService: answerService, + rankService: rankService, + siteInfoService: siteInfoService, + actionService: actionService, + rateLimitMiddleware: rateLimitMiddleware, } } @@ -332,6 +335,9 @@ func (qc *QuestionController) AddQuestion(ctx *gin.Context) { if ctx.IsAborted() { return } + if qc.rateLimitMiddleware.DuplicateRequestRejection(ctx, req) { + return + } req.UserID = middleware.GetLoginUserIDFromContext(ctx) canList, requireRanks, err := qc.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{ diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 2c87b1ac..4f6e74ac 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -73,7 +73,7 @@ var migrations = []Migration{ NewMigration("v1.1.1", "update the length of revision content", updateTheLengthOfRevisionContent, false), NewMigration("v1.1.2", "add notification config", addNoticeConfig, true), NewMigration("v1.1.3", "set default user notification config", setDefaultUserNotificationConfig, false), - NewMigration("v1.2.0", "add recover answer permission", addRecoverPermission, false), + NewMigration("v1.2.0", "add recover answer permission", addRecoverPermission, true), } func GetMigrations() []Migration { diff --git a/internal/repo/limit/limit.go b/internal/repo/limit/limit.go new file mode 100644 index 00000000..0217d602 --- /dev/null +++ b/internal/repo/limit/limit.go @@ -0,0 +1,37 @@ +package limit + +import ( + "context" + "github.com/answerdev/answer/internal/base/constant" + "github.com/answerdev/answer/internal/base/data" + "github.com/answerdev/answer/internal/base/reason" + "github.com/segmentfault/pacman/errors" +) + +// LimitRepo auth repository +type LimitRepo struct { + data *data.Data +} + +// NewRateLimitRepo new repository +func NewRateLimitRepo(data *data.Data) *LimitRepo { + return &LimitRepo{ + data: data, + } +} + +// CheckAndRecord check +func (lr *LimitRepo) CheckAndRecord(ctx context.Context, key string) (limit bool, err error) { + _, exist, err := lr.data.Cache.GetString(ctx, constant.RateLimitCacheKeyPrefix+key) + if err != nil { + return false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if exist { + return true, nil + } + err = lr.data.Cache.SetString(ctx, constant.RateLimitCacheKeyPrefix+key, "1", constant.RateLimitCacheTime) + if err != nil { + return false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return false, nil +} diff --git a/internal/repo/provider.go b/internal/repo/provider.go index 100ceb76..8bed049e 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -11,6 +11,7 @@ import ( "github.com/answerdev/answer/internal/repo/comment" "github.com/answerdev/answer/internal/repo/config" "github.com/answerdev/answer/internal/repo/export" + "github.com/answerdev/answer/internal/repo/limit" "github.com/answerdev/answer/internal/repo/meta" "github.com/answerdev/answer/internal/repo/notification" "github.com/answerdev/answer/internal/repo/plugin_config" @@ -75,4 +76,5 @@ var ProviderSetRepo = wire.NewSet( user_external_login.NewUserExternalLoginRepo, plugin_config.NewPluginConfigRepo, user_notification_config.NewUserNotificationConfigRepo, + limit.NewRateLimitRepo, )