diff --git a/.gitignore b/.gitignore index d7071db..7ce37cd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ __debug_* # data /analytics.db* /data + +# jetbrains +/.idea/ \ No newline at end of file diff --git a/cache.go b/cache.go deleted file mode 100644 index 8e1864e..0000000 --- a/cache.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "sync" - "time" -) - -type cacheField struct { - value any - expire int64 -} - -type Cache struct { - cache map[string]cacheField - mutex sync.RWMutex -} - -func NewCache() *Cache { - cache := &Cache{ - cache: make(map[string]cacheField), - } - cache.cleanup() - return cache -} - -func (c *Cache) Set(key string, value any, ttl int64) { - c.mutex.Lock() - defer c.mutex.Unlock() - - expire := time.Now().Add(time.Duration(ttl) * time.Second).Unix() - - c.cache[key] = cacheField{ - value: value, - expire: expire, - } -} - -func (c *Cache) Get(key string) (any, bool) { - c.mutex.RLock() - - field, ok := c.cache[key] - - if !ok { - c.mutex.RUnlock() - return nil, false - } - - if time.Now().Unix() > field.expire { - c.mutex.RUnlock() - c.Delete(key) - return nil, false - } - - c.mutex.RUnlock() - return field.value, true -} - -func (c *Cache) Delete(key string) { - c.mutex.Lock() - defer c.mutex.Unlock() - delete(c.cache, key) -} - -func (c *Cache) Flush() { - c.mutex.Lock() - defer c.mutex.Unlock() - c.cache = make(map[string]cacheField, 0) -} - -func (c *Cache) cleanup() { - go func() { - ticker := time.NewTicker(24 * time.Hour) - defer ticker.Stop() - - for range ticker.C { - c.mutex.Lock() - for key, field := range c.cache { - if time.Now().Unix() > field.expire { - delete(c.cache, key) - } - } - c.mutex.Unlock() - } - }() -} diff --git a/go.mod b/go.mod index 7a5d941..528d222 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ module github.com/tinyauthapp/analytics -go 1.25.0 +go 1.26.4 require ( github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-chi/render v1.0.3 github.com/spf13/viper v1.21.0 - modernc.org/sqlite v1.49.1 + modernc.org/sqlite v1.53.0 ) require ( @@ -26,10 +26,11 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinyauthapp/tinyauth v1.0.1-0.20260716230434-a7eba59a4243 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.28.0 // indirect - modernc.org/libc v1.72.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index d8570ec..e67bff7 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -36,6 +37,7 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -56,19 +58,28 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinyauthapp/tinyauth v1.0.1-0.20260716230434-a7eba59a4243 h1:upiRms1+AoOtPfda8NUwzrgKNIqLdZyMCOswkiU/COs= +github.com/tinyauthapp/tinyauth v1.0.1-0.20260716230434-a7eba59a4243/go.mod h1:EZtN6Y9y0IqUF98QLRv0uDH2nz1zPiEUs3KuuGRjQks= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -76,28 +87,36 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/main.go b/main.go index edee0d4..04785eb 100644 --- a/main.go +++ b/main.go @@ -79,7 +79,6 @@ func main() { );`) queries := queries.New(sqlDb) - cache := NewCache() router := chi.NewRouter() router.Use(middleware.Logger) router.Use(middleware.Recoverer) @@ -87,7 +86,7 @@ func main() { rateLimiter := NewRateLimiter(RateLimitConfig{ RateLimitCount: config.RateLimitCount, TrustedProxies: config.TrustedProxies, - }, cache) + }) instancesHandler := NewInstancesHandler(queries) healthHandler := NewHealthHandler() diff --git a/rate_limiter.go b/rate_limiter.go index 68a3e00..b6b38ae 100644 --- a/rate_limiter.go +++ b/rate_limiter.go @@ -2,12 +2,13 @@ package main import ( "fmt" - "log/slog" "net" "net/http" "slices" - "sync" + "strings" "time" + + "github.com/tinyauthapp/tinyauth/pkg/cache" ) type RateLimitConfig struct { @@ -17,63 +18,65 @@ type RateLimitConfig struct { type RateLimiter struct { config RateLimitConfig - cache *Cache - mutex sync.RWMutex + caches struct { + ratelimit *cache.CacheStore[int] + } } -func NewRateLimiter(config RateLimitConfig, cache *Cache) *RateLimiter { - return &RateLimiter{ +func NewRateLimiter(config RateLimitConfig) *RateLimiter { + rl := &RateLimiter{ config: config, - cache: cache, } + + ratelimitCache := cache.NewCacheStore[int](0) + rl.caches.ratelimit = ratelimitCache + + go func() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + rl.caches.ratelimit.Sweep() + } + }() + + return rl } func (rl *RateLimiter) limit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rl.mutex.Lock() - defer rl.mutex.Unlock() - clientIP := rl.getClientIP(r) - if clientIP == "" { http.Error(w, "failed to determine client ip", http.StatusInternalServerError) return } - value, exists := rl.cache.Get(clientIP) + var used int + rl.caches.ratelimit.WithLock(func(actions cache.CacheStoreActions[int]) { + current, exists := actions.Get(clientIP) + if !exists { + actions.Set(clientIP, 1, 12*time.Hour) + used = 1 + return + } + current++ + used = current + actions.Update(clientIP, current, 0) + if current > rl.config.RateLimitCount { + return + } + }) w.Header().Set("x-ratelimit-limit", fmt.Sprint(rl.config.RateLimitCount)) - w.Header().Set("x-ratelimit-reset", fmt.Sprint(time.Now().Add(12*time.Hour).Unix())) - - if !exists { - rl.cache.Set(clientIP, 1, 43200) // 12 hours TTL - w.Header().Set("x-ratelimit-remaining", fmt.Sprint(rl.config.RateLimitCount-1)) - w.Header().Set("x-ratelimit-used", fmt.Sprint(1)) - next.ServeHTTP(w, r) - return - } - - used, ok := value.(int) - - if !ok { - slog.Error("failed to assert rate limit cache value type") - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - - used++ + w.Header().Set("x-ratelimit-used", fmt.Sprint(used)) if used > rl.config.RateLimitCount { - w.Header().Set("x-ratelimit-remaining", fmt.Sprint(0)) - w.Header().Set("x-ratelimit-used", fmt.Sprint(used)) + w.Header().Set("x-ratelimit-remaining", "0") http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) return } - rl.cache.Set(clientIP, used, 43200) // 12 hours TTL - w.Header().Set("x-ratelimit-remaining", fmt.Sprint(rl.config.RateLimitCount-used)) - w.Header().Set("x-ratelimit-used", fmt.Sprint(used)) next.ServeHTTP(w, r) }) } @@ -92,10 +95,14 @@ func (rl *RateLimiter) getClientIP(r *http.Request) string { } if slices.Contains(rl.config.TrustedProxies, ip) { - xForwardedFor := r.Header.Values("x-forwarded-for") - - if len(xForwardedFor) > 0 { - return xForwardedFor[0] + xForwardedFor := r.Header.Get("x-forwarded-for") + + if xForwardedFor != "" { + firstIp := strings.SplitN(xForwardedFor, ",", 2)[0] + firstIp = strings.TrimSpace(firstIp) + if firstIp != "" { + return firstIp + } } }