From efbf2ec94784c34bb8d221c1de95ccee554e6356 Mon Sep 17 00:00:00 2001 From: Aiden Fine Date: Tue, 3 Feb 2026 23:43:05 -0500 Subject: [PATCH 1/2] semi working ignorepaths code --- checkpoint.go | 14 ++++++------- cmd/main.go | 6 +++++- config.go | 2 +- token_limiter.go | 17 ++++++++++++++- token_limiter_test.go | 49 ++++++++++++++++++++++++++++++++++++++----- 5 files changed, 73 insertions(+), 15 deletions(-) diff --git a/checkpoint.go b/checkpoint.go index 4714ee7..a2f08dc 100644 --- a/checkpoint.go +++ b/checkpoint.go @@ -4,17 +4,17 @@ import ( "net/http" ) -func Limit(maxTokens, refillRate, tokensPerRefill int) func(next http.Handler) http.Handler { - return NewTokenBucket(maxTokens, refillRate, tokensPerRefill).Handler +func Limit(maxTokens, refillRate, tokensPerRefill int, config Config) func(next http.Handler) http.Handler { + return NewTokenBucket(maxTokens, refillRate, tokensPerRefill, config).Handler } -func LimitByIp(maxTokens, refillRate, tokensPerRefill int) func(next http.Handler) http.Handler { - return Limit(maxTokens, refillRate, tokensPerRefill) +func LimitByIp(maxTokens, refillRate, tokensPerRefill int, config Config) func(next http.Handler) http.Handler { + return Limit(maxTokens, refillRate, tokensPerRefill, config) } -func LimitIpByEndpoint(maxTokens, refillRate, tokensPerRefill int) func(next http.Handler) http.Handler { - return Limit(maxTokens, refillRate, tokensPerRefill) +func LimitIpByEndpoint(maxTokens, refillRate, tokensPerRefill int, config Config) func(next http.Handler) http.Handler { + return Limit(maxTokens, refillRate, tokensPerRefill, config) } func WithConfig(config Config) func(next http.Handler) http.Handler { - return config.LimitMethod(config.MaxTokens, config.RefillRate, config.TokensPerRefill) + return config.LimitMethod(config.MaxTokens, config.RefillRate, config.TokensPerRefill, config) } diff --git a/cmd/main.go b/cmd/main.go index f6c177b..83a46f6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -13,6 +13,10 @@ func main() { fmt.Fprintln(w, "Hello World") }) + http.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "logs page") + }) + // CONFIG METHOD == config := checkpoint.Config{ IgnorePaths: []string{"/logs"}, @@ -23,7 +27,7 @@ func main() { } // Quick method - // rlMiddleware := checkpoint.LimitByIp(25, 1, 1) + // rlMiddleware := checkpoint.LimitByIp(25, 1 , 1) rlMiddleware := checkpoint.WithConfig(config) rlHandler := rlMiddleware(http.DefaultServeMux) diff --git a/config.go b/config.go index f0bd393..2d84d9b 100644 --- a/config.go +++ b/config.go @@ -7,5 +7,5 @@ type Config struct { MaxTokens int `json:"maxTokens" yaml:"maxTokens"` RefillRate int `json:"refillRate" yaml:"refillRate"` TokensPerRefill int `json:"tokensPerRefill" yaml:"tokensPerRefill"` - LimitMethod func(maxTokens, refillRate, tokensPerRefill int) func(next http.Handler) http.Handler + LimitMethod func(maxTokens, refillRate, tokensPerRefill int, config Config) func(next http.Handler) http.Handler } diff --git a/token_limiter.go b/token_limiter.go index 068eb58..c6932a8 100644 --- a/token_limiter.go +++ b/token_limiter.go @@ -3,6 +3,7 @@ package checkpoint import ( "fmt" "net/http" + "slices" "strconv" "sync" "time" @@ -20,14 +21,16 @@ type TokenBucket struct { refillRate int // seconds per token maxTokens int onRateLimited http.HandlerFunc + ignorePaths []string } -func NewTokenBucket(maxTokens, refillRate int, tokensPerRefill int) *TokenBucket { +func NewTokenBucket(maxTokens, refillRate int, tokensPerRefill int, config Config) *TokenBucket { tb := &TokenBucket{ clients: make(map[string]ClientRequestData), refillRate: refillRate, maxTokens: maxTokens, tokensPerRefill: tokensPerRefill, + ignorePaths: config.IgnorePaths, } return tb } @@ -75,6 +78,18 @@ func (tb *TokenBucket) Allow(ip string) (bool, int) { func (tb *TokenBucket) Handler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + // is api path to be ignored? we may need to make this better because ex: logs/** w + // ill not match logs/log we also need to ignore static assets like /favicon + apiURL := r.URL.Path + + fmt.Printf("current URL: %s \n", apiURL) + if slices.Contains(tb.ignorePaths, apiURL) { + // directly serve http and do not limit + next.ServeHTTP(w, r) + return + } + ip, err := getClientIP(r) if err != nil { fmt.Println("error getting client ip:", err) diff --git a/token_limiter_test.go b/token_limiter_test.go index 25db611..018e428 100644 --- a/token_limiter_test.go +++ b/token_limiter_test.go @@ -20,7 +20,15 @@ func generateRandomIPv4() string { func TestTokenBucketLimiter_HasEnoughTokens(t *testing.T) { - limiter := checkpoint.NewTokenBucket(100, 1, 5) + config := checkpoint.Config{ + IgnorePaths: []string{}, + MaxTokens: 100, + RefillRate: 1, + TokensPerRefill: 5, + LimitMethod: checkpoint.LimitByIp, + } + + limiter := checkpoint.NewTokenBucket(100, 1, 5, config) ip := "1" @@ -35,7 +43,15 @@ func TestTokenBucketLimiter_HasEnoughTokens(t *testing.T) { } func TestTokenBucketLimiter_DoesNotHaveEnoughTokens(t *testing.T) { - limiter := checkpoint.NewTokenBucket(100, 1, 5) + config := checkpoint.Config{ + IgnorePaths: []string{}, + MaxTokens: 100, + RefillRate: 1, + TokensPerRefill: 5, + LimitMethod: checkpoint.LimitByIp, + } + + limiter := checkpoint.NewTokenBucket(100, 1, 5, config) ip := "1" @@ -53,7 +69,15 @@ func TestTokenBucketLimiter_DoesNotHaveEnoughTokens(t *testing.T) { func TestTokenBucketLimiter_HasZeroTokensButCanBeRefilled(t *testing.T) { - limiter := checkpoint.NewTokenBucket(100, 1, 5) + config := checkpoint.Config{ + IgnorePaths: []string{}, + MaxTokens: 100, + RefillRate: 1, + TokensPerRefill: 5, + LimitMethod: checkpoint.LimitByIp, + } + + limiter := checkpoint.NewTokenBucket(100, 1, 5, config) ip := "1" @@ -67,7 +91,15 @@ func TestTokenBucketLimiter_HasZeroTokensButCanBeRefilled(t *testing.T) { // ---------------- BENCHMARKS ----------------// func BenchmarkAllowUniqueIps(b *testing.B) { - tb := checkpoint.NewTokenBucket(100, 1, 1) + + config := checkpoint.Config{ + IgnorePaths: []string{}, + MaxTokens: 100, + RefillRate: 1, + TokensPerRefill: 1, + LimitMethod: checkpoint.LimitByIp, + } + tb := checkpoint.NewTokenBucket(100, 1, 1, config) ips := make([]string, 1000) for i := range ips { @@ -80,7 +112,14 @@ func BenchmarkAllowUniqueIps(b *testing.B) { } } func BenchmarkAllowNonUniqueIps(b *testing.B) { - tb := checkpoint.NewTokenBucket(100, 1, 1) + config := checkpoint.Config{ + IgnorePaths: []string{}, + MaxTokens: 100, + RefillRate: 1, + TokensPerRefill: 1, + LimitMethod: checkpoint.LimitByIp, + } + tb := checkpoint.NewTokenBucket(100, 1, 1, config) ips := make([]string, 5) for i := range ips { From 5078fc7e12d28ba6f2f7867350e2147559055f10 Mon Sep 17 00:00:00 2001 From: Aiden Fine Date: Fri, 6 Feb 2026 15:35:43 -0500 Subject: [PATCH 2/2] Add pattern matching for paths --- paths.go | 41 ++++++ paths_test.go | 344 +++++++++++++++++++++++++++++++++++++++++++++++ token_limiter.go | 24 +++- 3 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 paths.go create mode 100644 paths_test.go diff --git a/paths.go b/paths.go new file mode 100644 index 0000000..a889b05 --- /dev/null +++ b/paths.go @@ -0,0 +1,41 @@ +package checkpoint + +import "strings" + +func MatchPathPattern(pattern, path string) bool { + if pattern == path { + return true + } + + if strings.Contains(pattern, "*") { + parts := strings.Split(pattern, "*") + + // does path start with the prefix? + if !strings.HasPrefix(path, parts[0]) { + return false + } + + if len(parts) == 2 { + return strings.HasSuffix(path, parts[1]) + } + currentIndex := len(parts[0]) + for i := 1; i < len(parts)-1; i++ { + if parts[i] == "" { + continue + } + + index := strings.Index(path[currentIndex:], parts[i]) + if index == -1 { + return false + } + + currentIndex += index + len(parts[i]) + } + lastPart := parts[len(parts)-1] + if lastPart != "" { + return strings.HasSuffix(path, lastPart) + } + return true + } + return false +} diff --git a/paths_test.go b/paths_test.go new file mode 100644 index 0000000..40257cb --- /dev/null +++ b/paths_test.go @@ -0,0 +1,344 @@ +package checkpoint_test + +import ( + "testing" + + "github.com/aidenfine/checkpoint" +) + +func TestMatchPathPattern(t *testing.T) { + tests := []struct { + name string + pattern string + path string + shouldMatch bool + }{ + { + name: "exact match - simple path", + pattern: "/api/users", + path: "/api/users", + shouldMatch: true, + }, + { + name: "exact match - no match", + pattern: "/api/users", + path: "/api/posts", + shouldMatch: false, + }, + { + name: "exact match - case sensitive", + pattern: "/api/users", + path: "/api/Users", + shouldMatch: false, + }, + { + name: "trailing wildcard - matches subdirectory", + pattern: "logs/*", + path: "logs/error.log", + shouldMatch: true, + }, + { + name: "trailing wildcard - matches nested path", + pattern: "logs/*", + path: "logs/2024/01/error.log", + shouldMatch: true, + }, + { + name: "trailing wildcard - matches empty after slash", + pattern: "logs/*", + path: "logs/", + shouldMatch: true, + }, + { + name: "trailing wildcard - no match wrong prefix", + pattern: "logs/*", + path: "logs2/error.log", + shouldMatch: false, + }, + { + name: "trailing wildcard - no match partial prefix", + pattern: "logs/*", + path: "log/error.log", + shouldMatch: false, + }, + { + name: "trailing wildcard - no match just prefix", + pattern: "logs/*", + path: "logs", + shouldMatch: false, + }, + { + name: "trailing wildcard - with leading slash", + pattern: "/tmp/tmpA/*", + path: "/tmp/tmpA/session123", + shouldMatch: true, + }, + { + name: "trailing wildcard - deep nesting", + pattern: "/tmp/tmpA/*", + path: "/tmp/tmpA/deep/nested/path/file.txt", + shouldMatch: true, + }, + { + name: "leading wildcard - matches with suffix", + pattern: "*/admin", + path: "/api/v1/admin", + shouldMatch: true, + }, + { + name: "leading wildcard - matches simple", + pattern: "*/admin", + path: "users/admin", + shouldMatch: true, + }, + { + name: "leading wildcard - no match wrong suffix", + pattern: "*/admin", + path: "/api/v1/user", + shouldMatch: false, + }, + { + name: "leading wildcard - no match partial suffix", + pattern: "*/admin", + path: "/api/v1/administrator", + shouldMatch: false, + }, + { + name: "middle wildcard - single wildcard", + pattern: "/api/*/users", + path: "/api/v1/users", + shouldMatch: true, + }, + { + name: "middle wildcard - matches multiple segments", + pattern: "/api/*/users", + path: "/api/v1/public/users", + shouldMatch: true, + }, + { + name: "middle wildcard - no match wrong prefix", + pattern: "/api/*/users", + path: "/app/v1/users", + shouldMatch: false, + }, + { + name: "middle wildcard - no match wrong suffix", + pattern: "/api/*/users", + path: "/api/v1/posts", + shouldMatch: false, + }, + { + name: "middle wildcard - matches empty middle", + pattern: "/api/*/users", + path: "/api//users", + shouldMatch: true, + }, + { + name: "multiple wildcards - two wildcards", + pattern: "/api/*/users/*", + path: "/api/v1/users/123", + shouldMatch: true, + }, + { + name: "multiple wildcards - complex path", + pattern: "/api/*/users/*", + path: "/api/v1/public/users/123/profile", + shouldMatch: true, + }, + { + name: "multiple wildcards - three wildcards", + pattern: "*/logs/*/error/*", + path: "app/logs/2024/error/critical.log", + shouldMatch: true, + }, + { + name: "empty pattern and path", + pattern: "", + path: "", + shouldMatch: true, + }, + { + name: "empty pattern non-empty path", + pattern: "", + path: "/api/users", + shouldMatch: false, + }, + { + name: "wildcard only pattern", + pattern: "*", + path: "/anything/goes/here", + shouldMatch: true, + }, + { + name: "wildcard only pattern - empty path", + pattern: "*", + path: "", + shouldMatch: true, + }, + { + name: "path with special characters", + pattern: "/api/*/data", + path: "/api/v1.2-beta/data", + shouldMatch: true, + }, + { + name: "path with query-like string", + pattern: "/api/users/*", + path: "/api/users/search?name=john", + shouldMatch: true, + }, + { + name: "consecutive wildcards", + pattern: "logs/*/*", + path: "logs/2024/01/error.log", + shouldMatch: true, + }, + { + name: "pattern longer than path", + pattern: "/api/v1/users/profile", + path: "/api/v1", + shouldMatch: false, + }, + { + name: "path longer than pattern", + pattern: "/api/v1", + path: "/api/v1/users/profile", + shouldMatch: false, + }, + { + name: "similar but different paths", + pattern: "/api/user/*", + path: "/api/users/123", + shouldMatch: false, + }, + { + name: "wildcard at end without slash", + pattern: "/api/logs*", + path: "/api/logs-archive", + shouldMatch: true, + }, + { + name: "wildcard at start without slash", + pattern: "*logs", + path: "error-logs", + shouldMatch: true, + }, + { + name: "health check ignore", + pattern: "/health/*", + path: "/health/status", + shouldMatch: true, + }, + { + name: "versioned API", + pattern: "/api/v*/internal/*", + path: "/api/v2/internal/metrics", + shouldMatch: true, + }, + { + name: "static assets", + pattern: "/static/*", + path: "/static/css/main.css", + shouldMatch: true, + }, + { + name: "user-specific paths", + pattern: "/users/*/private/*", + path: "/users/john123/private/settings", + shouldMatch: true, + }, + { + name: "admin panel", + pattern: "*/admin/*", + path: "/dashboard/admin/users", + shouldMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := checkpoint.MatchPathPattern(tt.pattern, tt.path) + if result != tt.shouldMatch { + t.Errorf("checkpoint.MatchPathPattern(%q, %q) = %v; want %v", + tt.pattern, tt.path, result, tt.shouldMatch) + } + }) + } +} + +func BenchMarkMatchPathPattern(b *testing.B) { + patterns := []string{ + "logs/*", + "/api/*/users", + "/tmp/tmpA/*", + "*/admin/*", + } + paths := []string{ + "logs/error.log", + "/api/v1/users", + "/tmp/tmpA/session123", + "/app/admin/settings", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, pattern := range patterns { + for _, path := range paths { + checkpoint.MatchPathPattern(pattern, path) + } + } + } +} + +// func TestTokenBucket_MatchPath(t *testing.T) { +// tests := []struct { +// name string +// ignorePaths []string +// testPath string +// shouldMatch bool +// }{ +// { +// name: "matches first pattern", +// ignorePaths: []string{"logs/*", "/tmp/*"}, +// testPath: "logs/error.log", +// shouldMatch: true, +// }, +// { +// name: "matches second pattern", +// ignorePaths: []string{"logs/*", "/tmp/*"}, +// testPath: "/tmp/session", +// shouldMatch: true, +// }, +// { +// name: "no match", +// ignorePaths: []string{"logs/*", "/tmp/*"}, +// testPath: "/api/users", +// shouldMatch: false, +// }, +// { +// name: "empty ignore list", +// ignorePaths: []string{}, +// testPath: "/any/path", +// shouldMatch: false, +// }, +// { +// name: "multiple patterns with overlap", +// ignorePaths: []string{"/api/*", "/api/v1/*", "/api/v1/admin/*"}, +// testPath: "/api/v1/admin/users", +// shouldMatch: true, +// }, +// } + +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// tb := &TokenBucket{ +// ignorePaths: tt.ignorePaths, +// } +// result := tb.matchPath(tt.testPath) +// if result != tt.shouldMatch { +// t.Errorf("matchPath(%q) = %v; want %v", +// tt.testPath, result, tt.shouldMatch) +// } +// }) +// } +// } diff --git a/token_limiter.go b/token_limiter.go index c6932a8..0e160e7 100644 --- a/token_limiter.go +++ b/token_limiter.go @@ -3,7 +3,7 @@ package checkpoint import ( "fmt" "net/http" - "slices" + "path" "strconv" "sync" "time" @@ -81,11 +81,12 @@ func (tb *TokenBucket) Handler(next http.Handler) http.Handler { // is api path to be ignored? we may need to make this better because ex: logs/** w // ill not match logs/log we also need to ignore static assets like /favicon - apiURL := r.URL.Path + currentAPIPath := r.URL.Path - fmt.Printf("current URL: %s \n", apiURL) - if slices.Contains(tb.ignorePaths, apiURL) { - // directly serve http and do not limit + fmt.Printf("current URL: %s \n", currentAPIPath) + + if tb.matchesIgnorePath(currentAPIPath) { + fmt.Println("path ignored") next.ServeHTTP(w, r) return } @@ -113,3 +114,16 @@ func (tb *TokenBucket) Handler(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +// Return true if path matches ignore path +func (tb *TokenBucket) matchesIgnorePath(currentPath string) bool { + + for _, pattern := range tb.ignorePaths { + matched, err := path.Match(pattern, currentPath) + if err == nil && matched { + return true + } + } + return false + +}