From d12911d3c8dcbf8868610822155a9e10814860db Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:01:28 -0400 Subject: [PATCH] feat(api): make device rate limits, OPDS page size, and conversion cache configurable Move three more hardcoded values behind the settings registry. All apply immediately on the next request (no restart needed). device_auth.go: - DeviceAuthMiddleware reads per-route device rate limits (sync / progress / metadata per minute) from the registry on each authenticated request via a rateLimitConfig() helper, falling back to the Default* constants when no registry is wired. - The X-RateLimit-Limit response header previously hardcoded "60" for every request type; it now reflects the actual configured limit for the request type via rateLimitForRequestType(). opds.go: - Default (50) and maximum (200) OPDS page sizes come from the registry's OpdsDefaultPageSize()/OpdsMaxPageSize() instead of inline literals, so catalog pagination can be tuned without a redeploy. conversion_service.go: - The 24h kepub cache lifetime is read from the registry via a cacheTTL() helper (was a bare 24 * time.Hour literal in the constructor). The field default is retained for tests that construct the service directly. - conversion_service_test.go updated to assert both the field default and the cacheTTL() accessor return 24h. --- internal/handlers/opds.go | 25 ++++++++++- internal/middleware/device_auth.go | 47 +++++++++++++++++--- internal/services/conversion_service.go | 24 +++++++++- internal/services/conversion_service_test.go | 4 +- 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go index 69287d6..7b5d286 100644 --- a/internal/handlers/opds.go +++ b/internal/handlers/opds.go @@ -26,6 +26,26 @@ type OPDSHandler struct { conversionService interface { ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error) } + settings *database.SettingsRegistry +} + +// SetSettings wires the tunable settings registry (OPDS page size). +func (h *OPDSHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s } + +// opdsDefaultPageSize returns the configured default page size (50 if unset). +func (h *OPDSHandler) opdsDefaultPageSize() int { + if h.settings != nil { + return h.settings.OpdsDefaultPageSize() + } + return 50 +} + +// opdsMaxPageSize returns the configured maximum page size (200 if unset). +func (h *OPDSHandler) opdsMaxPageSize() int { + if h.settings != nil { + return h.settings.OpdsMaxPageSize() + } + return 200 } func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface { @@ -168,9 +188,10 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { } } - perPageNum := 50 + perPageNum := h.opdsDefaultPageSize() + maxPerPage := h.opdsMaxPageSize() if perPage != "" { - if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= 200 { + if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage { perPageNum = num } } diff --git a/internal/middleware/device_auth.go b/internal/middleware/device_auth.go index d06132c..19893d7 100644 --- a/internal/middleware/device_auth.go +++ b/internal/middleware/device_auth.go @@ -25,6 +25,7 @@ type DeviceContext struct { type DeviceAuthMiddleware struct { db *database.Queries rateLimiter *DeviceRateLimiter + settings *database.SettingsRegistry } func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware { @@ -34,6 +35,41 @@ func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware { } } +// SetSettings wires the tunable settings registry so device rate limits are +// read live on each authenticated request. +func (m *DeviceAuthMiddleware) SetSettings(s *database.SettingsRegistry) { m.settings = s } + +// rateLimitConfig returns the active device rate limits from the registry, or +// the historical defaults when no registry is wired. +func (m *DeviceAuthMiddleware) rateLimitConfig() DeviceRateLimitConfig { + if m.settings != nil { + dl := m.settings.DeviceRateLimits() + return DeviceRateLimitConfig{ + SyncRequestsPerMinute: dl.Sync, + ProgressUpdatesPerMinute: dl.Progress, + MetadataRequestsPerMinute: dl.Metadata, + } + } + return DeviceRateLimitConfig{ + SyncRequestsPerMinute: DefaultSyncRequestsPerMinute, + ProgressUpdatesPerMinute: DefaultProgressUpdatesPerMinute, + MetadataRequestsPerMinute: DefaultMetadataRequestsPerMinute, + } +} + +// rateLimitForRequestType returns the configured per-minute limit for a given +// request type, for use in X-RateLimit-* headers. +func (m *DeviceAuthMiddleware) rateLimitForRequestType(requestType string, config DeviceRateLimitConfig) int { + switch requestType { + case "progress": + return config.ProgressUpdatesPerMinute + case "metadata": + return config.MetadataRequestsPerMinute + default: // "sync" and any unknown type + return config.SyncRequestsPerMinute + } +} + func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { var device database.Devices @@ -115,15 +151,12 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF deviceUUID := uuid.UUID(device.ID.Bytes) deviceID := deviceUUID.String() - config := DeviceRateLimitConfig{ - SyncRequestsPerMinute: 60, - ProgressUpdatesPerMinute: 120, - MetadataRequestsPerMinute: 30, - } + config := m.rateLimitConfig() + limitForType := m.rateLimitForRequestType(requestType, config) 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-Limit", strconv.Itoa(limitForType)) c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) c.Response().Header().Set("X-RateLimit-Reset", "60") return c.JSON(http.StatusTooManyRequests, map[string]string{ @@ -134,7 +167,7 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF } remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config) - c.Response().Header().Set("X-RateLimit-Limit", "60") + c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType)) c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) ctx := DeviceContext{ diff --git a/internal/services/conversion_service.go b/internal/services/conversion_service.go index 1a9e2ff..e478795 100644 --- a/internal/services/conversion_service.go +++ b/internal/services/conversion_service.go @@ -16,6 +16,10 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +// defaultConversionCacheTTL is the fallback kepub cache lifetime when no +// settings registry is wired. Matches the historical hardcoded 24h. +const defaultConversionCacheTTL = 24 * time.Hour + type ConvertedKEPUB struct { Path string SHA256 string @@ -27,6 +31,7 @@ type ConversionService struct { cacheDir string conversionTool string conversionCacheTTL time.Duration + settings *database.SettingsRegistry } func NewConversionService(db *database.Queries, cacheDir string) *ConversionService { @@ -34,17 +39,32 @@ func NewConversionService(db *database.Queries, cacheDir string) *ConversionServ db: db, cacheDir: cacheDir, conversionTool: "/usr/bin/kepubify", - conversionCacheTTL: 24 * time.Hour, + conversionCacheTTL: defaultConversionCacheTTL, } } +// SetSettings wires the tunable settings registry. When wired, the cache TTL +// is read live on each conversion request. +func (s *ConversionService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg } + +// cacheTTL returns the active conversion cache TTL. +func (s *ConversionService) cacheTTL() time.Duration { + if s.settings != nil { + return s.settings.ConversionCacheTTL() + } + if s.conversionCacheTTL > 0 { + return s.conversionCacheTTL + } + return defaultConversionCacheTTL +} + func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) { existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{ MediaItemID: mediaItemID, FormatType: "kepub", }) if err == nil && existing.FilePath.Valid { - if time.Since(existing.CreatedAt.Time) < s.conversionCacheTTL { + if time.Since(existing.CreatedAt.Time) < s.cacheTTL() { return &ConvertedKEPUB{ Path: existing.FilePath.String, SHA256: existing.FileSha256.String, diff --git a/internal/services/conversion_service_test.go b/internal/services/conversion_service_test.go index f974c6d..cbcb625 100644 --- a/internal/services/conversion_service_test.go +++ b/internal/services/conversion_service_test.go @@ -65,5 +65,7 @@ func TestConversionServiceDefaults(t *testing.T) { assert.NotNil(t, service) assert.Equal(t, cacheDir, service.cacheDir) assert.Equal(t, "/usr/bin/kepubify", service.conversionTool) - assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL should be 24 hours") + assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL field should be 24 hours") + // cacheTTL() must reflect the same default when no registry is wired. + assert.Equal(t, int64(24*3600*1000000000), service.cacheTTL().Nanoseconds(), "Default TTL accessor should return 24 hours") }