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") }