feat: implement rate limiting for public widget endpoints with Redis support

This commit is contained in:
Abhinav Raut
2025-08-19 01:58:13 +05:30
parent 081a5c615a
commit 1962abdc16
6 changed files with 132 additions and 6 deletions
+6 -6
View File
@@ -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
View File
@@ -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)
}
+4
View File
@@ -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)
+11
View File
@@ -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)
}
}
+5
View File
@@ -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
+96
View File
@@ -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)
}
}