302 lines
8.3 KiB
Go
302 lines
8.3 KiB
Go
package api
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"net/http"
|
||
|
|
"strconv"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
loginBaseLimit int64 = 20
|
||
|
|
refreshRateLimit int64 = 60
|
||
|
|
// apiGlobalLimitDefault is generous for normal LMS polling/navigation (~4 req/s).
|
||
|
|
apiGlobalLimitDefault int64 = 240
|
||
|
|
uploadRateLimit int64 = 20 // per-user chat/avatar uploads per minute
|
||
|
|
wsHandshakeLimit int64 = 30 // per-IP WS upgrades per minute
|
||
|
|
chatExportRateLimit int64 = 10 // per-IP external chat-export downloads per minute
|
||
|
|
rateLimitWindow = time.Minute
|
||
|
|
authFailTTL = 15 * time.Minute
|
||
|
|
apiGlobalRateMsg = "تعداد درخواستها زیاد است؛ لطفاً کمی صبر کنید"
|
||
|
|
authRateMsg = "تعداد تلاشها بیش از حد مجاز است؛ کمی بعد دوباره تلاش کنید"
|
||
|
|
)
|
||
|
|
|
||
|
|
// apiGlobalLimit is the per-IP budget for all /api/* routes (except /api/health).
|
||
|
|
// Mutable so tests can lower it without hammering Redis.
|
||
|
|
var apiGlobalLimit = apiGlobalLimitDefault
|
||
|
|
|
||
|
|
// wsHandshakeLimitVar is mutable for tests.
|
||
|
|
var wsHandshakeLimitVar = wsHandshakeLimit
|
||
|
|
|
||
|
|
// SetAPIGlobalLimitForTest overrides the global /api budget and returns the previous value.
|
||
|
|
func SetAPIGlobalLimitForTest(limit int64) (previous int64) {
|
||
|
|
previous = apiGlobalLimit
|
||
|
|
apiGlobalLimit = limit
|
||
|
|
return previous
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetWSHandshakeLimitForTest overrides the per-IP WS upgrade budget.
|
||
|
|
func SetWSHandshakeLimitForTest(limit int64) (previous int64) {
|
||
|
|
previous = wsHandshakeLimitVar
|
||
|
|
wsHandshakeLimitVar = limit
|
||
|
|
return previous
|
||
|
|
}
|
||
|
|
|
||
|
|
// effectiveAuthLimit tightens the per-IP request budget after recent failures.
|
||
|
|
// Healthy clients (no failures) keep the full baseline.
|
||
|
|
func effectiveAuthLimit(base, failCount int64) int64 {
|
||
|
|
switch {
|
||
|
|
case failCount <= 0:
|
||
|
|
return base
|
||
|
|
case failCount <= 2:
|
||
|
|
if base > 15 {
|
||
|
|
return 15
|
||
|
|
}
|
||
|
|
return base
|
||
|
|
case failCount <= 5:
|
||
|
|
return 8
|
||
|
|
case failCount <= 9:
|
||
|
|
return 4
|
||
|
|
default:
|
||
|
|
return 2
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func authFailKey(scope, ip string) string {
|
||
|
|
return "pargar:auth-fail:" + scope + ":" + ip
|
||
|
|
}
|
||
|
|
|
||
|
|
func authRateKey(scope, ip string) string {
|
||
|
|
return "pargar:auth-rate:" + scope + ":" + ip
|
||
|
|
}
|
||
|
|
|
||
|
|
func apiRateKey(ip string) string {
|
||
|
|
return "pargar:api-rate:" + ip
|
||
|
|
}
|
||
|
|
|
||
|
|
func uploadRateKey(userID int64) string {
|
||
|
|
return "pargar:upload-rate:" + strconv.FormatInt(userID, 10)
|
||
|
|
}
|
||
|
|
|
||
|
|
func wsRateKey(ip string) string {
|
||
|
|
return "pargar:ws-rate:" + ip
|
||
|
|
}
|
||
|
|
|
||
|
|
func chatExportRateKey(ip string) string {
|
||
|
|
return "pargar:chat-export-rate:" + ip
|
||
|
|
}
|
||
|
|
|
||
|
|
// ipRateLimit caps a public surface per client IP (e.g. external chat export).
|
||
|
|
func (s *Server) ipRateLimit(limit int64, keyFn func(string) string, next http.HandlerFunc) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if s.redis == nil {
|
||
|
|
if !s.cfg.DevMode {
|
||
|
|
writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ip := clientAddress(r)
|
||
|
|
if ip == "" {
|
||
|
|
next(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
key := keyFn(ip)
|
||
|
|
count, err := s.redis.Incr(r.Context(), key).Result()
|
||
|
|
if err != nil {
|
||
|
|
if !s.cfg.DevMode {
|
||
|
|
writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if count == 1 {
|
||
|
|
_ = s.redis.Expire(r.Context(), key, rateLimitWindow).Err()
|
||
|
|
}
|
||
|
|
if count > limit {
|
||
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(rateLimitWindow/time.Second)))
|
||
|
|
writeErr(w, http.StatusTooManyRequests, apiGlobalRateMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next(w, r)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) authFailCount(ctx context.Context, scope, ip string) int64 {
|
||
|
|
if s.redis == nil {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
n, err := s.redis.Get(ctx, authFailKey(scope, ip)).Int64()
|
||
|
|
if err != nil {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
return n
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) recordAuthFailure(ctx context.Context, scope, ip string) {
|
||
|
|
if s.redis == nil || ip == "" {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
key := authFailKey(scope, ip)
|
||
|
|
n, err := s.redis.Incr(ctx, key).Result()
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if n == 1 {
|
||
|
|
_ = s.redis.Expire(ctx, key, authFailTTL).Err()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) clearAuthFailures(ctx context.Context, scope, ip string) {
|
||
|
|
if s.redis == nil || ip == "" {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
// Drop both the failure streak and the short request window so a healthy
|
||
|
|
// success restores full budget instead of leaving residual pressure.
|
||
|
|
_ = s.redis.Del(ctx, authFailKey(scope, ip), authRateKey(scope, ip)).Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) authRateLimit(limit int64, scope string, next http.HandlerFunc) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if s.redis == nil {
|
||
|
|
if !s.cfg.DevMode {
|
||
|
|
writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ip := clientAddress(r)
|
||
|
|
fails := s.authFailCount(r.Context(), scope, ip)
|
||
|
|
effective := effectiveAuthLimit(limit, fails)
|
||
|
|
key := authRateKey(scope, ip)
|
||
|
|
count, err := s.redis.Incr(r.Context(), key).Result()
|
||
|
|
if err != nil {
|
||
|
|
if !s.cfg.DevMode {
|
||
|
|
writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if count == 1 {
|
||
|
|
_ = s.redis.Expire(r.Context(), key, rateLimitWindow).Err()
|
||
|
|
}
|
||
|
|
if count > effective {
|
||
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(rateLimitWindow/time.Second)))
|
||
|
|
writeErr(w, http.StatusTooManyRequests, authRateMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next(w, r)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// globalAPIRateLimit applies a generous per-IP sliding-window budget to /api/*.
|
||
|
|
// It never revokes tokens or locks accounts — only returns 429 + Retry-After.
|
||
|
|
// /api/ws is excluded: upgrades must not share the HTTP polling budget, and
|
||
|
|
// reconnect storms should not burn the same counter as REST calls.
|
||
|
|
func (s *Server) globalAPIRateLimit(next http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.URL.Path == "/api/ws" {
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if s.redis == nil {
|
||
|
|
if !s.cfg.DevMode {
|
||
|
|
writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ip := clientAddress(r)
|
||
|
|
if ip == "" {
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
key := apiRateKey(ip)
|
||
|
|
count, err := s.redis.Incr(r.Context(), key).Result()
|
||
|
|
if err != nil {
|
||
|
|
if !s.cfg.DevMode {
|
||
|
|
writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if count == 1 {
|
||
|
|
_ = s.redis.Expire(r.Context(), key, rateLimitWindow).Err()
|
||
|
|
}
|
||
|
|
if count > apiGlobalLimit {
|
||
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(rateLimitWindow/time.Second)))
|
||
|
|
writeErr(w, http.StatusTooManyRequests, apiGlobalRateMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// perUserRateLimit caps authenticated abuse surfaces (uploads) per user id.
|
||
|
|
func (s *Server) perUserRateLimit(limit int64, scope string, next http.HandlerFunc) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
u := currentUser(r)
|
||
|
|
if u == nil {
|
||
|
|
writeErr(w, http.StatusUnauthorized, "نیاز به ورود دارید")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if s.redis == nil {
|
||
|
|
if !s.cfg.DevMode {
|
||
|
|
writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
key := uploadRateKey(u.ID)
|
||
|
|
if scope != "upload" {
|
||
|
|
key = "pargar:user-rate:" + scope + ":" + strconv.FormatInt(u.ID, 10)
|
||
|
|
}
|
||
|
|
count, err := s.redis.Incr(r.Context(), key).Result()
|
||
|
|
if err != nil {
|
||
|
|
if !s.cfg.DevMode {
|
||
|
|
writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if count == 1 {
|
||
|
|
_ = s.redis.Expire(r.Context(), key, rateLimitWindow).Err()
|
||
|
|
}
|
||
|
|
if count > limit {
|
||
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(rateLimitWindow/time.Second)))
|
||
|
|
writeErr(w, http.StatusTooManyRequests, apiGlobalRateMsg)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next(w, r)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// allowWSHandshake enforces a per-IP budget on WebSocket upgrades.
|
||
|
|
// Returns false when the caller should receive 429 (already written by caller).
|
||
|
|
func (s *Server) allowWSHandshake(r *http.Request) bool {
|
||
|
|
if s.redis == nil {
|
||
|
|
return s.cfg.DevMode
|
||
|
|
}
|
||
|
|
ip := clientAddress(r)
|
||
|
|
if ip == "" {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
key := wsRateKey(ip)
|
||
|
|
count, err := s.redis.Incr(r.Context(), key).Result()
|
||
|
|
if err != nil {
|
||
|
|
return s.cfg.DevMode
|
||
|
|
}
|
||
|
|
if count == 1 {
|
||
|
|
_ = s.redis.Expire(r.Context(), key, rateLimitWindow).Err()
|
||
|
|
}
|
||
|
|
return count <= wsHandshakeLimitVar
|
||
|
|
}
|