Deadcode-verified removals (RTA reachability from main, cross-checked with -test and textual xrefs): Go server: - whole files: middleware/transaction.go, middleware/error_handler.go, middleware/password_validator.go (live registration machinery kept), handlers/sync.go, sync/offline.go - unrouted endpoints: auth UpdateUsername/UpdateEmail, timezone settings handler, OPDS RegisterOPDS, Kobo LibrarySync handler, ReaderHandler.ShowReader, JSON-data twins on collections/library - duplicate book-matching handlers (book_matching.go Handler twins, matching.go MatchingHandler twins), superseded helpers (router.parseUUID, handlers.ValidateDeviceToken, normalizePath, userExists, generateLockID) Templ/client: - never-routed pages: api_explorer.templ, queue.templ and their client modules api-explorer.ts, api-explorer-docs.ts, queue.ts - fully orphaned wrapper modules events.ts, dom.ts - broken clearSyncQueue (called a nonexistent endpoint) and the hidden, never-populated sync-queue section on the devices page - 166+ unused exports demoted to private (knip-verified); ~85 truly dead declarations removed via tsc noUnusedLocals fixpoint - stale 'interactive API Explorer' claims dropped from docs The docs drift-gate package (internal/docs), templ-bound modules (dashboard, stats, header, index, login, profile*, bookshelf, collections, toast-error), sqlc-generated code, and web/static are intentionally untouched.
167 lines
3.4 KiB
Go
167 lines
3.4 KiB
Go
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) 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()
|
|
}
|