mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-11 21:39:00 +00:00
feat: implement rate limiting for public widget endpoints with Redis support
This commit is contained in:
+6
-6
@@ -227,12 +227,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
// Widget APIs.
|
||||
g.GET("/api/v1/widget/chat/settings/launcher", handleGetChatLauncherSettings)
|
||||
g.GET("/api/v1/widget/chat/settings", handleGetChatSettings)
|
||||
g.POST("/api/v1/widget/chat/conversations/init", widgetAuth(handleChatInit))
|
||||
g.POST("/api/v1/widget/chat/conversations", widgetAuth(handleGetConversations))
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", widgetAuth(handleChatUpdateLastSeen))
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}", widgetAuth(handleChatGetConversation))
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}/message", widgetAuth(handleChatSendMessage))
|
||||
g.POST("/api/v1/widget/media/upload", widgetAuth(handleWidgetMediaUpload))
|
||||
g.POST("/api/v1/widget/chat/conversations/init", rateLimitWidget(widgetAuth(handleChatInit)))
|
||||
g.POST("/api/v1/widget/chat/conversations", rateLimitWidget(widgetAuth(handleGetConversations)))
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", rateLimitWidget(widgetAuth(handleChatUpdateLastSeen)))
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}", rateLimitWidget(widgetAuth(handleChatGetConversation)))
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}/message", rateLimitWidget(widgetAuth(handleChatSendMessage)))
|
||||
g.POST("/api/v1/widget/media/upload", rateLimitWidget(widgetAuth(handleWidgetMediaUpload)))
|
||||
|
||||
// Frontend pages.
|
||||
g.GET("/", notAuthPage(serveIndexPage))
|
||||
|
||||
+10
@@ -36,6 +36,7 @@ import (
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
|
||||
"github.com/abhinavxd/libredesk/internal/oidc"
|
||||
"github.com/abhinavxd/libredesk/internal/ratelimit"
|
||||
"github.com/abhinavxd/libredesk/internal/report"
|
||||
"github.com/abhinavxd/libredesk/internal/role"
|
||||
"github.com/abhinavxd/libredesk/internal/search"
|
||||
@@ -927,3 +928,12 @@ func getLogLevel(lvl string) logf.Level {
|
||||
return logf.InfoLevel
|
||||
}
|
||||
}
|
||||
|
||||
// initRateLimit initializes the rate limiter.
|
||||
func initRateLimit(redisClient *redis.Client) *ratelimit.Limiter {
|
||||
var config ratelimit.Config
|
||||
if err := ko.UnmarshalWithConf("rate_limit", &config, koanf.UnmarshalConf{Tag: "toml"}); err != nil {
|
||||
log.Fatalf("error unmarshalling rate limit config: %v", err)
|
||||
}
|
||||
return ratelimit.New(redisClient, config)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/inbox"
|
||||
"github.com/abhinavxd/libredesk/internal/media"
|
||||
"github.com/abhinavxd/libredesk/internal/oidc"
|
||||
"github.com/abhinavxd/libredesk/internal/ratelimit"
|
||||
"github.com/abhinavxd/libredesk/internal/role"
|
||||
"github.com/abhinavxd/libredesk/internal/setting"
|
||||
"github.com/abhinavxd/libredesk/internal/tag"
|
||||
@@ -95,6 +96,7 @@ type App struct {
|
||||
customAttribute *customAttribute.Manager
|
||||
report *report.Manager
|
||||
webhook *webhook.Manager
|
||||
rateLimit *ratelimit.Limiter
|
||||
|
||||
// Global state that stores data on an available app update.
|
||||
update *AppUpdate
|
||||
@@ -202,6 +204,7 @@ func main() {
|
||||
sla = initSLA(db, team, settings, businessHours, notifier, template, user, i18n)
|
||||
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook)
|
||||
autoassigner = initAutoAssigner(team, user, conversation)
|
||||
rateLimiter = initRateLimit(rdb)
|
||||
)
|
||||
|
||||
wsHub.SetConversationStore(conversation)
|
||||
@@ -253,6 +256,7 @@ func main() {
|
||||
macro: initMacro(db, i18n),
|
||||
ai: initAI(db, i18n),
|
||||
webhook: webhook,
|
||||
rateLimit: rateLimiter,
|
||||
}
|
||||
app.consts.Store(constants)
|
||||
|
||||
|
||||
@@ -154,3 +154,14 @@ func getWidgetClaimsOptional(r *fastglue.Request) *Claims {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rateLimitWidget applies rate limiting to widget endpoints.
|
||||
func rateLimitWidget(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
if err := app.rateLimit.CheckWidgetLimit(r.RequestCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
return handler(r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,3 +122,8 @@ unsnooze_interval = "5m"
|
||||
[sla]
|
||||
# How often to evaluate SLA compliance for conversations
|
||||
evaluation_interval = "5m"
|
||||
|
||||
[rate_limit]
|
||||
[rate_limit.widget]
|
||||
enabled = true
|
||||
requests_per_minute = 100
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
realip "github.com/ferluci/fast-realip"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// Config holds rate limiting configuration
|
||||
type Config struct {
|
||||
Widget WidgetConfig `toml:"widget"`
|
||||
}
|
||||
|
||||
// WidgetConfig holds widget-specific rate limiting configuration
|
||||
type WidgetConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
RequestsPerMinute int `toml:"requests_per_minute"`
|
||||
}
|
||||
|
||||
// Limiter handles rate limiting using Redis
|
||||
type Limiter struct {
|
||||
redis *redis.Client
|
||||
config Config
|
||||
}
|
||||
|
||||
// New creates a new rate limiter
|
||||
func New(redisClient *redis.Client, config Config) *Limiter {
|
||||
return &Limiter{
|
||||
redis: redisClient,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckWidgetLimit checks if the widget request should be rate limited
|
||||
func (l *Limiter) CheckWidgetLimit(ctx *fasthttp.RequestCtx) error {
|
||||
if !l.config.Widget.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
clientIP := realip.FromRequest(ctx)
|
||||
key := fmt.Sprintf("rate_limit:widget:%s", clientIP)
|
||||
|
||||
// Use sliding window approach with Redis
|
||||
now := time.Now().Unix()
|
||||
windowStart := now - 60 // 60 seconds window
|
||||
|
||||
// Get current count in the last minute
|
||||
count, err := l.redis.ZCount(ctx, key, strconv.FormatInt(windowStart, 10), "+inf").Result()
|
||||
if err != nil {
|
||||
// Redis is down, allow request
|
||||
return nil
|
||||
}
|
||||
|
||||
if count >= int64(l.config.Widget.RequestsPerMinute) {
|
||||
// Set rate limit headers
|
||||
ctx.Response.Header.Set("X-RateLimit-Limit", strconv.Itoa(l.config.Widget.RequestsPerMinute))
|
||||
ctx.Response.Header.Set("X-RateLimit-Remaining", "0")
|
||||
ctx.Response.Header.Set("X-RateLimit-Reset", strconv.FormatInt(now+60, 10))
|
||||
ctx.Response.Header.Set("Retry-After", "60")
|
||||
|
||||
ctx.SetStatusCode(fasthttp.StatusTooManyRequests)
|
||||
ctx.SetBodyString(`{"status":"error","message":"Rate limit exceeded"}`)
|
||||
return fmt.Errorf("rate limit exceeded")
|
||||
}
|
||||
|
||||
// Add current request to the sliding window
|
||||
pipe := l.redis.Pipeline()
|
||||
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now})
|
||||
pipe.ZRemRangeByScore(ctx, key, "-inf", strconv.FormatInt(windowStart, 10))
|
||||
pipe.Expire(ctx, key, time.Minute*2) // Set expiry to cleanup old keys
|
||||
_, err = pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
// Redis is down, allow request
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set rate limit headers for successful requests
|
||||
remaining := max(l.config.Widget.RequestsPerMinute-int(count)-1, 0)
|
||||
ctx.Response.Header.Set("X-RateLimit-Limit", strconv.Itoa(l.config.Widget.RequestsPerMinute))
|
||||
ctx.Response.Header.Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||
ctx.Response.Header.Set("X-RateLimit-Reset", strconv.FormatInt(now+60, 10))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WidgetMiddleware returns a fastglue middleware for widget rate limiting
|
||||
func (l *Limiter) WidgetMiddleware() func(*fastglue.Request) error {
|
||||
return func(r *fastglue.Request) error {
|
||||
return l.CheckWidgetLimit(r.RequestCtx)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user