diff --git a/internal/middleware/device_auth.go b/internal/middleware/device_auth.go index 899242a..ef72b9c 100644 --- a/internal/middleware/device_auth.go +++ b/internal/middleware/device_auth.go @@ -21,12 +21,14 @@ type DeviceContext struct { } type DeviceAuthMiddleware struct { - db *database.Queries + db *database.Queries + rateLimiter *DeviceRateLimiter } func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware { return &DeviceAuthMiddleware{ - db: db, + db: db, + rateLimiter: NewDeviceRateLimiter(), } } @@ -60,6 +62,32 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF }) } + requestType := m.getRequestType(c.Request().URL.Path) + deviceUUID := uuid.UUID(device.ID.Bytes) + deviceID := deviceUUID.String() + + config := DeviceRateLimitConfig{ + SyncRequestsPerMinute: 60, + ProgressUpdatesPerMinute: 120, + MetadataRequestsPerMinute: 30, + } + + if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) { + remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config) + c.Response().Header().Set("X-RateLimit-Limit", "60") + c.Response().Header().Set("X-RateLimit-Remaining", string(rune(remaining))) + c.Response().Header().Set("X-RateLimit-Reset", "60") + return c.JSON(http.StatusTooManyRequests, map[string]string{ + "error": "rate limit exceeded", + "message": "Too many requests", + "remaining": string(rune(remaining)), + }) + } + + remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config) + c.Response().Header().Set("X-RateLimit-Limit", "60") + c.Response().Header().Set("X-RateLimit-Remaining", string(rune(remaining))) + ctx := DeviceContext{ ID: device.ID.Bytes, UserID: device.UserID.Bytes, @@ -78,6 +106,16 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF } } +func (m *DeviceAuthMiddleware) getRequestType(path string) string { + if strings.Contains(path, "/progress") { + return "progress" + } + if strings.Contains(path, "/metadata") || strings.Contains(path, "/library") { + return "metadata" + } + return "sync" +} + func (m *DeviceAuthMiddleware) RequirePermission(permission string) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { diff --git a/internal/middleware/device_rate_limiter.go b/internal/middleware/device_rate_limiter.go new file mode 100644 index 0000000..88caf6f --- /dev/null +++ b/internal/middleware/device_rate_limiter.go @@ -0,0 +1,177 @@ +package middleware + +import ( + "sync" + "time" +) + +type DeviceRateLimiter struct { + requests map[string]*DeviceRequestInfo + mu sync.RWMutex + cleanup *time.Ticker +} + +type DeviceRequestInfo struct { + Requests []time.Time + LastSeen time.Time + DeviceID string +} + +type DeviceRateLimitConfig struct { + SyncRequestsPerMinute int + ProgressUpdatesPerMinute int + MetadataRequestsPerMinute int +} + +const ( + DefaultSyncRequestsPerMinute = 60 + DefaultProgressUpdatesPerMinute = 120 + DefaultMetadataRequestsPerMinute = 30 + CleanupInterval = 5 * time.Minute + RequestWindow = 1 * time.Minute +) + +func NewDeviceRateLimiter() *DeviceRateLimiter { + limiter := &DeviceRateLimiter{ + requests: make(map[string]*DeviceRequestInfo), + cleanup: time.NewTicker(CleanupInterval), + } + + go limiter.cleanupOldEntries() + + return limiter +} + +func (drl *DeviceRateLimiter) CheckRateLimit(deviceID string, requestType string, config DeviceRateLimitConfig) bool { + drl.mu.Lock() + defer drl.mu.Unlock() + + now := time.Now() + key := deviceID + ":" + requestType + + info, exists := drl.requests[key] + if !exists { + info = &DeviceRequestInfo{ + Requests: []time.Time{}, + DeviceID: deviceID, + LastSeen: now, + } + drl.requests[key] = info + } + + var limit int + switch requestType { + case "sync": + limit = config.SyncRequestsPerMinute + case "progress": + limit = config.ProgressUpdatesPerMinute + case "metadata": + limit = config.MetadataRequestsPerMinute + default: + limit = DefaultSyncRequestsPerMinute + } + + if limit == 0 { + limit = DefaultSyncRequestsPerMinute + } + + info.Requests = append(info.Requests, now) + info.LastSeen = now + + cutoff := now.Add(-RequestWindow) + validRequests := 0 + for _, reqTime := range info.Requests { + if reqTime.After(cutoff) { + validRequests++ + } + } + info.Requests = info.Requests[len(info.Requests)-validRequests:] + + return validRequests <= limit +} + +func (drl *DeviceRateLimiter) GetRemainingRequests(deviceID string, requestType string, config DeviceRateLimitConfig) int { + drl.mu.RLock() + defer drl.mu.RUnlock() + + key := deviceID + ":" + requestType + info, exists := drl.requests[key] + if !exists { + var limit int + switch requestType { + case "sync": + limit = config.SyncRequestsPerMinute + case "progress": + limit = config.ProgressUpdatesPerMinute + case "metadata": + limit = config.MetadataRequestsPerMinute + default: + limit = DefaultSyncRequestsPerMinute + } + if limit == 0 { + limit = DefaultSyncRequestsPerMinute + } + return limit + } + + now := time.Now() + cutoff := now.Add(-RequestWindow) + validRequests := 0 + for _, reqTime := range info.Requests { + if reqTime.After(cutoff) { + validRequests++ + } + } + + var limit int + switch requestType { + case "sync": + limit = config.SyncRequestsPerMinute + case "progress": + limit = config.ProgressUpdatesPerMinute + case "metadata": + limit = config.MetadataRequestsPerMinute + default: + limit = DefaultSyncRequestsPerMinute + } + if limit == 0 { + limit = DefaultSyncRequestsPerMinute + } + + remaining := limit - validRequests + if remaining < 0 { + remaining = 0 + } + + return remaining +} + +func (drl *DeviceRateLimiter) Reset(deviceID string) { + drl.mu.Lock() + defer drl.mu.Unlock() + + for key := range drl.requests { + if len(key) > len(deviceID) && key[:len(deviceID)] == deviceID { + delete(drl.requests, key) + } + } +} + +func (drl *DeviceRateLimiter) cleanupOldEntries() { + for range drl.cleanup.C { + drl.mu.Lock() + cutoff := time.Now().Add(-10 * time.Minute) + + for key, info := range drl.requests { + if info.LastSeen.Before(cutoff) { + delete(drl.requests, key) + } + } + + drl.mu.Unlock() + } +} + +func (drl *DeviceRateLimiter) Stop() { + drl.cleanup.Stop() +}