├── .DS_Store ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SETUP.md ├── assets ├── Edit Rule.png ├── architecture.png ├── image.png ├── main.png └── screenshot.png ├── dump.rdb ├── rate_shield ├── .DS_Store ├── .gitignore ├── .idea │ ├── .gitignore │ ├── modules.xml │ ├── rate_shield.iml │ └── vcs.xml ├── .vscode │ └── settings.json ├── Dockerfile ├── Makefile ├── api │ ├── index.html │ ├── limit.go │ ├── rules.go │ └── server.go ├── docker-compose-dev.yml ├── docker-compose.yml ├── documentation │ └── README.md ├── examples │ ├── express │ │ ├── app.js │ │ ├── package-lock.json │ │ └── package.json │ ├── flask │ │ └── app.py │ └── gofiber │ │ ├── .gitignore │ │ ├── cmd │ │ └── app │ │ │ └── main.go │ │ ├── go.mod │ │ └── middleware │ │ └── rate_limit.go ├── go.mod ├── go.sum ├── limiter │ ├── fixed_window_counter.go │ ├── fixed_window_counter_test.go │ ├── leaky-bucket.go │ ├── limiter.go │ ├── sliding_window_counter.go │ ├── token_bucket.go │ └── token_bucket_test.go ├── main.go ├── models │ ├── fixed_window_counter.go │ ├── limit.go │ ├── rules.go │ ├── slack.go │ └── token_bucket.go ├── redis │ ├── client.go │ ├── interfaces.go │ ├── rate_limit.go │ └── rules.go ├── service │ ├── error_notification.go │ ├── rules.go │ └── slack_messaging.go ├── tests │ └── locust.py └── utils │ ├── api_request.go │ ├── api_responses.go │ ├── env.go │ ├── errors.go │ ├── json.go │ ├── limit.go │ ├── limit_validator.go │ └── token_bucket_validations.go └── web ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── vite.svg ├── src ├── App.css ├── App.tsx ├── api │ └── rules.tsx ├── assets │ ├── API.svg │ ├── BackArrow.png │ ├── GitHub.svg │ ├── Info Squared.svg │ ├── LinkedIn.svg │ ├── Twitter.svg │ ├── logo.svg │ ├── modify_rule.png │ └── react.svg ├── components │ ├── APIConfigurationHeader.tsx │ ├── AddOrUpdateRule.tsx │ ├── Button.tsx │ ├── ContentArea.tsx │ ├── Loader.tsx │ ├── RulesTable.tsx │ └── SideBar.tsx ├── index.css ├── main.tsx ├── pages │ ├── APIConfiguration.tsx │ ├── About.tsx │ └── Dashboard.tsx ├── utils │ ├── toast_styles.tsx │ └── validators.tsx └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/e9f65459f787c54e72cf7031d1664918ad808a9b/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/e9f65459f787c54e72cf7031d1664918ad808a9b/.gitignore -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contribution 2 | 3 | ### Project Setup 4 | 5 | Before setting up the project, ensure you have the following installed: 6 | 7 | - **[Node.js](https://nodejs.org/)** (includes npm) 8 | - **[Golang](https://golang.org/dl/)** 9 | - **[Docker](https://docs.docker.com/get-docker/) & [Docker Compose](https://docs.docker.com/compose/install/)** 10 | 11 | ### Setup Instructions 12 | 13 | Follow these steps to set up and run the project locally: 14 | 15 | 1. **Install Node.js** 16 | 17 | Download and install Node.js from the [official website](https://nodejs.org/). This will also install npm, which is required for managing Node.js packages. 18 | 19 | 2. **Install Dependencies** 20 | 21 | Navigate to the project `/web` directory where the `package.json` file is located and run: 22 | 23 | ```bash 24 | npm install 25 | ``` 26 | 27 | 3. **Start Docker Containers** 28 | 29 | In the project root directory (where your `docker-compose.yml` file is located), run: 30 | 31 | ``` 32 | sudo docker-compose up 33 | ``` 34 | 4. **Setup `.env` file** 35 | 36 | In folder `rate_shield` create a file named `.env` and add following content to it. You do not need to change these values if you are not working with something related to slack notification. Just copy and paste these 2 lines and it will work fine. 37 | 38 | 39 | ``` 40 | SLACK_TOKEN= 41 | SLACK_CHANNEL= 42 | ``` 43 | 44 | 5. Run the Golang Application 45 | 46 | Open a new terminal window, navigate to the directory containing your `main.go` file, and run: 47 | 48 | ``` 49 | go run main.go 50 | ``` 51 | 52 | 6. Start the Frontend 53 | 54 | Open another terminal window, navigate to the frontend directory, and run: 55 | 56 | ``` 57 | npm run dev 58 | ``` 59 | 60 | 7. Access Application 61 | 62 | Open browser and go to `http://localhost:5173/` 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sushant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 🚀 **RateShield** 2 | 3 | A fully customizable rate limiter designed to apply rate limiting on individual APIs with specific rules. 4 | 5 | 6 | #### 📊 **Dashboard Overview** 7 | 8 | ![RateShield Dashboard](https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/main/assets/main.png) 9 | 10 | 11 | ![RateShield Edit Rule](https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/main/assets/Edit%20Rule.png) 12 | 13 | ___ 14 | 15 | #### 🎯 **Why RateShield?** 16 | 17 | Why not? With some free time on hand, RateShield was created to explore the potential of building a versatile rate-limiting solution. What started as a side project is evolving into a powerful tool for developers. 18 | 19 | --- 20 | 21 | #### 🌟 **Key Features** 22 | 23 | - **Customizable Limiting:**
24 | Tailor rate limiting rules to each API endpoint according to your needs. 25 | 26 | - **Intuitive Dashboard:**
27 | A user-friendly interface to monitor and manage all your rate limits effectively. 28 | 29 | - **Easy Integration:**
30 | Plug-and-play middleware that seamlessly integrates into your existing infrastructure. 31 | 32 | --- 33 | 34 | #### ⚙️ **Use Cases** 35 | 36 | - **Preventing Abuse:** 37 | Control the number of requests your APIs can handle to prevent misuse and malicious activities. 38 | 39 | - **Cost Management:** 40 | Manage third-party API calls efficiently to avoid unexpected overages. 41 | 42 | --- 43 | 44 | #### 🚀 **Supported Rate Limiting Algorithms** 45 | 46 | - **Token Bucket** 47 | - **Fixed Window Counter** 48 | - **Sliding Window** 49 | 50 | --- 51 | 52 | #### 🪧 Usage Guide 53 | Check out this [document](https://github.com/x-sushant-x/Rate-Shield/tree/main/rate_shield/documentation). 54 | 55 | --- 56 | 57 | #### How it works? 58 | 59 | 60 | --- 61 | 62 | #### ⚠️ **Important Information** 63 | 64 | - **Current Limitation:** 65 | At present, RateShield only supports the Token Bucket, Fixed Window Counter algorithm & Sliding Window Counter, which may not fit all use cases. 66 | 67 | --- 68 | 69 | #### v2 Roadmap 70 | - [x] Cache rules locally for better performance. 71 | - [x] Add Sliding Window rate limiting strategy. 72 | - [ ] Slack integration. 73 | - [ ] End-2-End Testing 74 | 75 | 76 | #### 🤝 **Contributing** 77 | 78 | Interested in contributing? We'd love your help! Check out our [Contribution Guidelines](https://github.com/x-sushant-x/Rate-Shield/blob/main/CONTRIBUTION.md) to get started. 79 | 80 | --- 81 | -------------------------------------------------------------------------------- /SETUP.md: -------------------------------------------------------------------------------- 1 | ### How to setup Rate Shield? 2 | 3 | #### Prerequisite 4 | * Docker 5 | * Redis Instance (for rules) 6 | * Redis Cluster (with ReJSON Module Enabled) 7 | 8 | * Environment variables for the application: 9 | * RATE_SHIELD_PORT: The port number the application will listen on. 10 | * REDIS_RULES_INSTANCE_URL: URL for the Redis instance storing rate limit rules. 11 | * REDIS_RULES_INSTANCE_USERNAME: Username for Redis authentication. 12 | * REDIS_RULES_INSTANCE_PASSWORD: Password for Redis authentication. 13 | * REDIS_CLUSTERS_URLS: Comma seperated urls. Ex - 127.0.0.1:6380,127.0.0.1:6381 14 | * REDIS_CLUSTER_USERNAME: Username for Redis Cluster authentication. 15 | * REDIS_CLUSTER_PASSWORD: Password for Redis Cluster authentication. 16 | 17 | 18 |
19 | 20 | 1. Go to `rate_shield` subfolder. 21 | 2. Build docker image using `docker build -t rate-shield-backend .` 22 | 23 | 3. Once docker image is built you can run it using below command. 24 | ``` 25 | docker run -d \ 26 | -p 8080:8080 \ 27 | -e RATE_SHIELD_PORT=8080 \ 28 | -e REDIS_RULES_INSTANCE_URL=redis://localhost:6379 \ 29 | -e REDIS_RULES_INSTANCE_USERNAME=user \ 30 | -e REDIS_RULES_INSTANCE_PASSWORD=password \ 31 | -e REDIS_CLUSTERS_URLS=localhost:6380,localhost:6381 32 | rate-shield-app 33 | ``` 34 | 35 | Important: - Value for -p and RATE_SHIELD_PORT must be same. 36 | 37 | Now you can access rate shield via localhost:8080 (value passed in RATE_SHIELD_PORT). 38 | 39 | Follow [this](https://github.com/x-sushant-x/Rate-Shield/tree/main/rate_shield/documentation) usage guide to know how to use Rate Shield. 40 | 41 |
42 | 43 | ### Setup Frontend Client 44 | 1. Go to `web` subfolder and build docker image using `docker build -t rate-shield-frontend .` 45 | 2. Once docker image is built you can run it using below command. 46 | 47 | ``` 48 | docker run -p 6012:6012 \ 49 | -e VITE_RATE_SHIELD_BACKEND_BASE_URL=http://localhost:9081 \ 50 | -e PORT=6012 \ 51 | rate_shield_frontend 52 | ``` 53 | 54 | Important: - VITE_RATE_SHIELD_BACKEND_BASE_URL should have the URL of your backend docker container and -p and -e PORT value should match. 55 | -------------------------------------------------------------------------------- /assets/Edit Rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/e9f65459f787c54e72cf7031d1664918ad808a9b/assets/Edit Rule.png -------------------------------------------------------------------------------- /assets/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/e9f65459f787c54e72cf7031d1664918ad808a9b/assets/architecture.png -------------------------------------------------------------------------------- /assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/e9f65459f787c54e72cf7031d1664918ad808a9b/assets/image.png -------------------------------------------------------------------------------- /assets/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/e9f65459f787c54e72cf7031d1664918ad808a9b/assets/main.png -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/e9f65459f787c54e72cf7031d1664918ad808a9b/assets/screenshot.png -------------------------------------------------------------------------------- /dump.rdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/e9f65459f787c54e72cf7031d1664918ad808a9b/dump.rdb -------------------------------------------------------------------------------- /rate_shield/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/e9f65459f787c54e72cf7031d1664918ad808a9b/rate_shield/.DS_Store -------------------------------------------------------------------------------- /rate_shield/.gitignore: -------------------------------------------------------------------------------- 1 | /output 2 | node_modules 3 | .env -------------------------------------------------------------------------------- /rate_shield/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /rate_shield/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /rate_shield/.idea/rate_shield.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /rate_shield/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /rate_shield/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "makefile.configureOnOpen": false 3 | } -------------------------------------------------------------------------------- /rate_shield/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod go.sum ./ 6 | 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | RUN go build -o main . 12 | 13 | ENV RATE_SHIELD_PORT=${RATE_SHIELD_PORT} 14 | 15 | ENV REDIS_RULES_INSTANCE_URL=${REDIS_RULES_INSTANCE_URL} 16 | ENV REDIS_RULES_INSTANCE_USERNAME=${REDIS_RULES_INSTANCE_USERNAME} 17 | ENV REDIS_RULES_INSTANCE_PASSWORD=${REDIS_RULES_INSTANCE_PASSWORD} 18 | 19 | ENV REDIS_CLUSTERS_URLS=${REDIS_RULES_INSTANCE_URL} 20 | ENV REDIS_CLUSTER_USERNAME=${REDIS_RULES_INSTANCE_USERNAME} 21 | ENV REDIS_CLUSTER_PASSWORD=${REDIS_RULES_INSTANCE_PASSWORD} 22 | 23 | EXPOSE $RATE_SHIELD_PORT 24 | CMD ["./main"] 25 | -------------------------------------------------------------------------------- /rate_shield/Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | @ go build -o output 3 | 4 | run: build 5 | @ ./output/RateShield -------------------------------------------------------------------------------- /rate_shield/api/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RateShield Status 7 | 52 | 53 | 54 | 55 |
56 |

RateShield is Running

57 |

Welcome to the RateShield rate limiter. The server is currently active on this port.

58 |

Use the Dockerfile present in the /web subfolder to run the frontend client. Make sure to configure ports correctly by reading documentation.

59 |
60 | Follow me on GitHub | Follow me on LinkedIn 61 |
62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /rate_shield/api/limit.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/x-sushant-x/RateShield/limiter" 8 | "github.com/x-sushant-x/RateShield/utils" 9 | ) 10 | 11 | type RateLimitHandler struct { 12 | limiterSvc *limiter.Limiter 13 | } 14 | 15 | func NewRateLimitHandler(limiterSvc *limiter.Limiter) RateLimitHandler { 16 | return RateLimitHandler{ 17 | limiterSvc: limiterSvc, 18 | } 19 | } 20 | 21 | func (h RateLimitHandler) CheckRateLimit(w http.ResponseWriter, r *http.Request) { 22 | ip := r.Header.Get("ip") 23 | endpoint := r.Header.Get("endpoint") 24 | 25 | badRequest := utils.ValidateLimitRequest(ip, endpoint) 26 | if badRequest != nil { 27 | w.WriteHeader(http.StatusBadRequest) 28 | } 29 | 30 | resp := h.limiterSvc.CheckLimit(ip, endpoint) 31 | 32 | switch resp.HTTPStatusCode { 33 | case 200: 34 | w.Header().Set("rate-limit", fmt.Sprint(resp.RateLimit_Limit)) 35 | w.Header().Set("rate-limit-remaining", fmt.Sprint(resp.RateLimit_Remaining)) 36 | w.WriteHeader(http.StatusOK) 37 | case 429: 38 | w.WriteHeader(http.StatusTooManyRequests) 39 | default: 40 | w.WriteHeader(http.StatusInternalServerError) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /rate_shield/api/rules.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/x-sushant-x/RateShield/models" 8 | "github.com/x-sushant-x/RateShield/service" 9 | "github.com/x-sushant-x/RateShield/utils" 10 | ) 11 | 12 | type RulesAPIHandler struct { 13 | rulesSvc service.RulesService 14 | } 15 | 16 | func NewRulesAPIHandler(svc service.RulesService) RulesAPIHandler { 17 | return RulesAPIHandler{ 18 | rulesSvc: svc, 19 | } 20 | } 21 | 22 | func (h RulesAPIHandler) ListAllRules(w http.ResponseWriter, r *http.Request) { 23 | page := r.URL.Query().Get("page") 24 | items := r.URL.Query().Get("items") 25 | 26 | if page != "" && items != "" { 27 | pageInt, pageIntErr := strconv.Atoi(page) 28 | itemsInt, itemsIntErr := strconv.Atoi(items) 29 | 30 | if pageIntErr != nil || itemsIntErr != nil { 31 | utils.BadRequestError(w) 32 | return 33 | } 34 | 35 | rules, err := h.rulesSvc.GetPaginatedRules(pageInt, itemsInt) 36 | if err != nil { 37 | utils.InternalError(w, err.Error()) 38 | return 39 | } 40 | utils.SuccessResponse(rules, w) 41 | } else { 42 | rules, err := h.rulesSvc.GetAllRules() 43 | if err != nil { 44 | utils.InternalError(w, err.Error()) 45 | return 46 | } 47 | utils.SuccessResponse(rules, w) 48 | } 49 | } 50 | 51 | func (h RulesAPIHandler) SearchRules(w http.ResponseWriter, r *http.Request) { 52 | q := r.URL.Query() 53 | 54 | searchText := q.Get("endpoint") 55 | if len(searchText) == 0 { 56 | utils.BadRequestError(w) 57 | return 58 | } 59 | 60 | rules, err := h.rulesSvc.SearchRule(searchText) 61 | if err != nil { 62 | utils.InternalError(w, err.Error()) 63 | return 64 | } 65 | 66 | utils.SuccessResponse(rules, w) 67 | } 68 | 69 | func (h RulesAPIHandler) CreateOrUpdateRule(w http.ResponseWriter, r *http.Request) { 70 | if r.Method == http.MethodOptions { 71 | // Preflight 72 | w.WriteHeader(http.StatusNoContent) 73 | return 74 | } 75 | 76 | if r.Method == http.MethodPost { 77 | updateReq, err := utils.ParseAPIBody[models.Rule](r) 78 | if err != nil { 79 | utils.BadRequestError(w) 80 | return 81 | } 82 | err = h.rulesSvc.CreateOrUpdateRule(updateReq) 83 | if err != nil { 84 | utils.InternalError(w, err.Error()) 85 | return 86 | } 87 | 88 | utils.SuccessResponse("Rule Created Successfully", w) 89 | } else { 90 | utils.MethodNotAllowedError(w) 91 | } 92 | } 93 | 94 | func (h RulesAPIHandler) DeleteRule(w http.ResponseWriter, r *http.Request) { 95 | if r.Method == http.MethodPost { 96 | deleteReq, err := utils.ParseAPIBody[models.DeleteRuleDTO](r) 97 | if err != nil { 98 | utils.BadRequestError(w) 99 | return 100 | } 101 | 102 | err = h.rulesSvc.DeleteRule(deleteReq.RuleKey) 103 | if err != nil { 104 | utils.InternalError(w, err.Error()) 105 | return 106 | } 107 | 108 | utils.SuccessResponse("Rule Deleted Successfully", w) 109 | } else { 110 | utils.MethodNotAllowedError(w) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /rate_shield/api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/rs/zerolog/log" 10 | "github.com/x-sushant-x/RateShield/limiter" 11 | redisClient "github.com/x-sushant-x/RateShield/redis" 12 | "github.com/x-sushant-x/RateShield/service" 13 | ) 14 | 15 | type Server struct { 16 | port int 17 | limiter *limiter.Limiter 18 | } 19 | 20 | func NewServer(limiter *limiter.Limiter) Server { 21 | return Server{ 22 | port: getPort(), 23 | limiter: limiter, 24 | } 25 | } 26 | 27 | func (s Server) StartServer() error { 28 | log.Info().Msgf("Setting Up API endpoints in port: %d ✅", s.port) 29 | mux := http.NewServeMux() 30 | 31 | s.rulesRoutes(mux) 32 | s.registerRateLimiterRoutes(mux) 33 | s.setupHome(mux) 34 | 35 | corsMux := s.setupCORS(mux) 36 | 37 | server := http.Server{ 38 | Addr: fmt.Sprintf(":%d", s.port), 39 | Handler: corsMux, 40 | } 41 | 42 | log.Info().Msg("Rate Shield running on port: " + fmt.Sprintf("%d", s.port) + " ✅") 43 | 44 | err := server.ListenAndServe() 45 | if err != nil { 46 | log.Err(err).Msg("unable to start server") 47 | return err 48 | } 49 | return nil 50 | } 51 | 52 | func (s Server) setupCORS(h http.Handler) http.Handler { 53 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 54 | w.Header().Set("Access-Control-Allow-Origin", "*") 55 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 56 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 57 | 58 | if r.Method == http.MethodOptions { 59 | w.WriteHeader(http.StatusNoContent) 60 | return 61 | } 62 | 63 | h.ServeHTTP(w, r) 64 | }) 65 | } 66 | 67 | func (s Server) rulesRoutes(mux *http.ServeMux) { 68 | redisRuleClient, err := redisClient.NewRulesClient() 69 | if err != nil { 70 | log.Err(err).Msg("unable to setup new redis rules client") 71 | log.Fatal() 72 | } 73 | 74 | rulesSvc := service.NewRedisRulesService(redisRuleClient) 75 | rulesHandler := NewRulesAPIHandler(rulesSvc) 76 | 77 | mux.HandleFunc("/rule/list", rulesHandler.ListAllRules) 78 | mux.HandleFunc("/rule/add", rulesHandler.CreateOrUpdateRule) 79 | mux.HandleFunc("/rule/delete", rulesHandler.DeleteRule) 80 | mux.HandleFunc("/rule/search", rulesHandler.SearchRules) 81 | } 82 | 83 | func (s Server) registerRateLimiterRoutes(mux *http.ServeMux) { 84 | rateLimiterHandler := NewRateLimitHandler(s.limiter) 85 | mux.HandleFunc("/check-limit", rateLimiterHandler.CheckRateLimit) 86 | } 87 | 88 | func (s Server) setupHome(mux *http.ServeMux) { 89 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 90 | w.Header().Set("Content-Type", "text/html") 91 | 92 | wd, wdError := os.Getwd() 93 | 94 | homepage, err := os.ReadFile(wd + "/api/" + "index.html") 95 | if err != nil || wdError != nil { 96 | fmt.Println(err) 97 | w.Write([]byte("Rate Shield is running. Open frontend client on port 5173. If it does not work make sure react application is running.")) 98 | } 99 | 100 | fmt.Fprint(w, string(homepage)) 101 | }) 102 | } 103 | 104 | func getPort() int { 105 | port := os.Getenv("RATE_SHIELD_PORT") 106 | if len(port) == 0 { 107 | log.Fatal().Msg("RATE_SHIELD_PORT environment variable not provided in docker run command.") 108 | } 109 | 110 | portInt, err := strconv.Atoi(port) 111 | if err != nil { 112 | log.Fatal().Msg("Invalid port number provided.") 113 | } 114 | 115 | return portInt 116 | } 117 | -------------------------------------------------------------------------------- /rate_shield/docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | redis: 5 | image: redis/redis-stack 6 | volumes: 7 | - redis_data:/data:rw 8 | ports: 9 | - 6379:6379 10 | restart: unless-stopped 11 | 12 | 13 | volumes: 14 | redis_data: 15 | driver: local -------------------------------------------------------------------------------- /rate_shield/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: . 6 | ports: 7 | - "8080:8080" 8 | depends_on: 9 | - redis 10 | redis: 11 | image: redis/redis-stack 12 | volumes: 13 | - redis_data:/data:rw 14 | ports: 15 | - 6379:6379 16 | restart: unless-stopped 17 | 18 | 19 | volumes: 20 | redis_data: 21 | driver: local -------------------------------------------------------------------------------- /rate_shield/documentation/README.md: -------------------------------------------------------------------------------- 1 | 42 | 43 | ## Rate Shield Documentation 44 | **Rate Shield** provides a /check-limit endpoint that enables you to implement rate limiting for your API endpoints. By sending specific headers to this endpoint, Rate Shield applies the defined rate limiting rules and returns an appropriate HTTP status code based on the result. 45 | 46 | ### How to Use 47 | #### Endpoint Details 48 | The /check-limit endpoint accepts the following headers: 49 | 50 | * `ip:` 51 | * `endpoint:` 52 |
53 | 54 | When you send a request with these headers to /check-limit, Rate Shield retrieves the rate limiting rules defined for the specified endpoint and applies them based on the provided IP address. After processing, it returns one of the following HTTP status codes: 55 | 56 | * `200 OK:` The request is within the rate limit or **no rules are defined for the endpoint.** 57 | * `429 Too Many Requests:` The rate limit has been exceeded. 58 | * `500 Internal Server Error:` An error occurred during processing. 59 | 60 | Based on the response status code, you can decide whether to proceed with the request to your target API. 61 | 62 | ### Example Request 63 | Below is an example of using cURL to send a request to the /check-limit endpoint: 64 | 65 | ``` 66 | curl -X GET \ 67 | 'http://localhost:8080/check-limit' \ 68 | --header 'Accept: */*' \ 69 | --header 'ip: 127.0.0.1' \ 70 | --header 'endpoint: /api/v1/resource' 71 | ``` 72 | 73 | ### Example Response 74 | ``` 75 | HTTP/1.1 200 OK 76 | Access-Control-Allow-Headers: Content-Type, Authorization 77 | Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS 78 | Access-Control-Allow-Origin: * 79 | Rate-Limit: 100 80 | Rate-Limit-Remaining: 97 81 | Date: Sun, 08 Sep 2024 18:39:18 GMT 82 | Content-Length: 0 83 | ``` 84 | 85 | The response headers include rate limit information: 86 | 87 | * `Rate-Limit:` The total number of allowed requests. 88 | * `Rate-Limit-Remaining:` The number of remaining requests in the current time window. 89 | 90 | ### Automating with Middleware 91 | To streamline the rate limiting process, you can create custom middleware or interceptors in your preferred programming language and framework. The middleware should: 92 | 93 | 1. Send the client's IP address and the requested endpoint to the Rate Shield /check-limit endpoint. 94 | 2. Receive the response and handle it accordingly, such as allowing the request to proceed or returning an error message to the client. 95 | 96 | While I'm not providing specific code examples for creating middleware in different languages and frameworks, we encourage you to implement it in your environment of choice. Your contributions are valuable; feel free to share your custom middleware implementations with the community to enhance the Rate Shield project. 97 | -------------------------------------------------------------------------------- /rate_shield/examples/express/app.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | 3 | const app = express() 4 | 5 | const rateLimitCheck = async (req, res, next) => { 6 | const apiPath = req.baseUrl + req.path 7 | 8 | const headers = { 9 | 'endpoint': apiPath, 10 | 'ip': req.ip.replace('::ffff:', '') 11 | } 12 | 13 | try { 14 | const response = await fetch('http://127.0.0.1:8080/check-limit', { 15 | method: 'GET', 16 | headers: headers 17 | }) 18 | 19 | if (response.status === 429) { 20 | res.status(429).json({ 21 | error : 'TOO MANY REQUESTS' 22 | }) 23 | return 24 | } 25 | 26 | if (response.status === 500) { 27 | res.status(500).json({ 28 | error: 'INTERNAL SERVER ERROR' 29 | }) 30 | return 31 | } 32 | 33 | } catch (e) { 34 | console.error('Error in rate limit check:', err) 35 | return res.status(500).json({ 36 | error: 'Rate limit service unavailable' 37 | }) 38 | } 39 | next() 40 | } 41 | 42 | app.use(rateLimitCheck) 43 | 44 | app.get('/api/v1/process', (req, res) => { 45 | res.status(200).json({ 46 | 'success': true 47 | }) 48 | }) 49 | 50 | 51 | app.listen(3001, () => { 52 | console.log('server running on port 3001') 53 | }) -------------------------------------------------------------------------------- /rate_shield/examples/express/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-example", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "express-example", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "express": "^4.21.0" 13 | } 14 | }, 15 | "node_modules/accepts": { 16 | "version": "1.3.8", 17 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", 18 | "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", 19 | "license": "MIT", 20 | "dependencies": { 21 | "mime-types": "~2.1.34", 22 | "negotiator": "0.6.3" 23 | }, 24 | "engines": { 25 | "node": ">= 0.6" 26 | } 27 | }, 28 | "node_modules/array-flatten": { 29 | "version": "1.1.1", 30 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 31 | "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", 32 | "license": "MIT" 33 | }, 34 | "node_modules/body-parser": { 35 | "version": "1.20.3", 36 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", 37 | "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", 38 | "license": "MIT", 39 | "dependencies": { 40 | "bytes": "3.1.2", 41 | "content-type": "~1.0.5", 42 | "debug": "2.6.9", 43 | "depd": "2.0.0", 44 | "destroy": "1.2.0", 45 | "http-errors": "2.0.0", 46 | "iconv-lite": "0.4.24", 47 | "on-finished": "2.4.1", 48 | "qs": "6.13.0", 49 | "raw-body": "2.5.2", 50 | "type-is": "~1.6.18", 51 | "unpipe": "1.0.0" 52 | }, 53 | "engines": { 54 | "node": ">= 0.8", 55 | "npm": "1.2.8000 || >= 1.4.16" 56 | } 57 | }, 58 | "node_modules/bytes": { 59 | "version": "3.1.2", 60 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", 61 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", 62 | "license": "MIT", 63 | "engines": { 64 | "node": ">= 0.8" 65 | } 66 | }, 67 | "node_modules/call-bind": { 68 | "version": "1.0.7", 69 | "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", 70 | "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", 71 | "license": "MIT", 72 | "dependencies": { 73 | "es-define-property": "^1.0.0", 74 | "es-errors": "^1.3.0", 75 | "function-bind": "^1.1.2", 76 | "get-intrinsic": "^1.2.4", 77 | "set-function-length": "^1.2.1" 78 | }, 79 | "engines": { 80 | "node": ">= 0.4" 81 | }, 82 | "funding": { 83 | "url": "https://github.com/sponsors/ljharb" 84 | } 85 | }, 86 | "node_modules/content-disposition": { 87 | "version": "0.5.4", 88 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", 89 | "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 90 | "license": "MIT", 91 | "dependencies": { 92 | "safe-buffer": "5.2.1" 93 | }, 94 | "engines": { 95 | "node": ">= 0.6" 96 | } 97 | }, 98 | "node_modules/content-type": { 99 | "version": "1.0.5", 100 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", 101 | "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", 102 | "license": "MIT", 103 | "engines": { 104 | "node": ">= 0.6" 105 | } 106 | }, 107 | "node_modules/cookie": { 108 | "version": "0.6.0", 109 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", 110 | "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", 111 | "license": "MIT", 112 | "engines": { 113 | "node": ">= 0.6" 114 | } 115 | }, 116 | "node_modules/cookie-signature": { 117 | "version": "1.0.6", 118 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 119 | "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", 120 | "license": "MIT" 121 | }, 122 | "node_modules/debug": { 123 | "version": "2.6.9", 124 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 125 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 126 | "license": "MIT", 127 | "dependencies": { 128 | "ms": "2.0.0" 129 | } 130 | }, 131 | "node_modules/define-data-property": { 132 | "version": "1.1.4", 133 | "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", 134 | "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", 135 | "license": "MIT", 136 | "dependencies": { 137 | "es-define-property": "^1.0.0", 138 | "es-errors": "^1.3.0", 139 | "gopd": "^1.0.1" 140 | }, 141 | "engines": { 142 | "node": ">= 0.4" 143 | }, 144 | "funding": { 145 | "url": "https://github.com/sponsors/ljharb" 146 | } 147 | }, 148 | "node_modules/depd": { 149 | "version": "2.0.0", 150 | "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", 151 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", 152 | "license": "MIT", 153 | "engines": { 154 | "node": ">= 0.8" 155 | } 156 | }, 157 | "node_modules/destroy": { 158 | "version": "1.2.0", 159 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", 160 | "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", 161 | "license": "MIT", 162 | "engines": { 163 | "node": ">= 0.8", 164 | "npm": "1.2.8000 || >= 1.4.16" 165 | } 166 | }, 167 | "node_modules/ee-first": { 168 | "version": "1.1.1", 169 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 170 | "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", 171 | "license": "MIT" 172 | }, 173 | "node_modules/encodeurl": { 174 | "version": "2.0.0", 175 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", 176 | "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", 177 | "license": "MIT", 178 | "engines": { 179 | "node": ">= 0.8" 180 | } 181 | }, 182 | "node_modules/es-define-property": { 183 | "version": "1.0.0", 184 | "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", 185 | "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", 186 | "license": "MIT", 187 | "dependencies": { 188 | "get-intrinsic": "^1.2.4" 189 | }, 190 | "engines": { 191 | "node": ">= 0.4" 192 | } 193 | }, 194 | "node_modules/es-errors": { 195 | "version": "1.3.0", 196 | "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 197 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", 198 | "license": "MIT", 199 | "engines": { 200 | "node": ">= 0.4" 201 | } 202 | }, 203 | "node_modules/escape-html": { 204 | "version": "1.0.3", 205 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 206 | "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", 207 | "license": "MIT" 208 | }, 209 | "node_modules/etag": { 210 | "version": "1.8.1", 211 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 212 | "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", 213 | "license": "MIT", 214 | "engines": { 215 | "node": ">= 0.6" 216 | } 217 | }, 218 | "node_modules/express": { 219 | "version": "4.21.0", 220 | "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", 221 | "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", 222 | "license": "MIT", 223 | "dependencies": { 224 | "accepts": "~1.3.8", 225 | "array-flatten": "1.1.1", 226 | "body-parser": "1.20.3", 227 | "content-disposition": "0.5.4", 228 | "content-type": "~1.0.4", 229 | "cookie": "0.6.0", 230 | "cookie-signature": "1.0.6", 231 | "debug": "2.6.9", 232 | "depd": "2.0.0", 233 | "encodeurl": "~2.0.0", 234 | "escape-html": "~1.0.3", 235 | "etag": "~1.8.1", 236 | "finalhandler": "1.3.1", 237 | "fresh": "0.5.2", 238 | "http-errors": "2.0.0", 239 | "merge-descriptors": "1.0.3", 240 | "methods": "~1.1.2", 241 | "on-finished": "2.4.1", 242 | "parseurl": "~1.3.3", 243 | "path-to-regexp": "0.1.10", 244 | "proxy-addr": "~2.0.7", 245 | "qs": "6.13.0", 246 | "range-parser": "~1.2.1", 247 | "safe-buffer": "5.2.1", 248 | "send": "0.19.0", 249 | "serve-static": "1.16.2", 250 | "setprototypeof": "1.2.0", 251 | "statuses": "2.0.1", 252 | "type-is": "~1.6.18", 253 | "utils-merge": "1.0.1", 254 | "vary": "~1.1.2" 255 | }, 256 | "engines": { 257 | "node": ">= 0.10.0" 258 | } 259 | }, 260 | "node_modules/finalhandler": { 261 | "version": "1.3.1", 262 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", 263 | "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", 264 | "license": "MIT", 265 | "dependencies": { 266 | "debug": "2.6.9", 267 | "encodeurl": "~2.0.0", 268 | "escape-html": "~1.0.3", 269 | "on-finished": "2.4.1", 270 | "parseurl": "~1.3.3", 271 | "statuses": "2.0.1", 272 | "unpipe": "~1.0.0" 273 | }, 274 | "engines": { 275 | "node": ">= 0.8" 276 | } 277 | }, 278 | "node_modules/forwarded": { 279 | "version": "0.2.0", 280 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", 281 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", 282 | "license": "MIT", 283 | "engines": { 284 | "node": ">= 0.6" 285 | } 286 | }, 287 | "node_modules/fresh": { 288 | "version": "0.5.2", 289 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 290 | "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", 291 | "license": "MIT", 292 | "engines": { 293 | "node": ">= 0.6" 294 | } 295 | }, 296 | "node_modules/function-bind": { 297 | "version": "1.1.2", 298 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 299 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 300 | "license": "MIT", 301 | "funding": { 302 | "url": "https://github.com/sponsors/ljharb" 303 | } 304 | }, 305 | "node_modules/get-intrinsic": { 306 | "version": "1.2.4", 307 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", 308 | "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", 309 | "license": "MIT", 310 | "dependencies": { 311 | "es-errors": "^1.3.0", 312 | "function-bind": "^1.1.2", 313 | "has-proto": "^1.0.1", 314 | "has-symbols": "^1.0.3", 315 | "hasown": "^2.0.0" 316 | }, 317 | "engines": { 318 | "node": ">= 0.4" 319 | }, 320 | "funding": { 321 | "url": "https://github.com/sponsors/ljharb" 322 | } 323 | }, 324 | "node_modules/gopd": { 325 | "version": "1.0.1", 326 | "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", 327 | "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", 328 | "license": "MIT", 329 | "dependencies": { 330 | "get-intrinsic": "^1.1.3" 331 | }, 332 | "funding": { 333 | "url": "https://github.com/sponsors/ljharb" 334 | } 335 | }, 336 | "node_modules/has-property-descriptors": { 337 | "version": "1.0.2", 338 | "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", 339 | "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", 340 | "license": "MIT", 341 | "dependencies": { 342 | "es-define-property": "^1.0.0" 343 | }, 344 | "funding": { 345 | "url": "https://github.com/sponsors/ljharb" 346 | } 347 | }, 348 | "node_modules/has-proto": { 349 | "version": "1.0.3", 350 | "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", 351 | "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", 352 | "license": "MIT", 353 | "engines": { 354 | "node": ">= 0.4" 355 | }, 356 | "funding": { 357 | "url": "https://github.com/sponsors/ljharb" 358 | } 359 | }, 360 | "node_modules/has-symbols": { 361 | "version": "1.0.3", 362 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", 363 | "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", 364 | "license": "MIT", 365 | "engines": { 366 | "node": ">= 0.4" 367 | }, 368 | "funding": { 369 | "url": "https://github.com/sponsors/ljharb" 370 | } 371 | }, 372 | "node_modules/hasown": { 373 | "version": "2.0.2", 374 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 375 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 376 | "license": "MIT", 377 | "dependencies": { 378 | "function-bind": "^1.1.2" 379 | }, 380 | "engines": { 381 | "node": ">= 0.4" 382 | } 383 | }, 384 | "node_modules/http-errors": { 385 | "version": "2.0.0", 386 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", 387 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 388 | "license": "MIT", 389 | "dependencies": { 390 | "depd": "2.0.0", 391 | "inherits": "2.0.4", 392 | "setprototypeof": "1.2.0", 393 | "statuses": "2.0.1", 394 | "toidentifier": "1.0.1" 395 | }, 396 | "engines": { 397 | "node": ">= 0.8" 398 | } 399 | }, 400 | "node_modules/iconv-lite": { 401 | "version": "0.4.24", 402 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 403 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 404 | "license": "MIT", 405 | "dependencies": { 406 | "safer-buffer": ">= 2.1.2 < 3" 407 | }, 408 | "engines": { 409 | "node": ">=0.10.0" 410 | } 411 | }, 412 | "node_modules/inherits": { 413 | "version": "2.0.4", 414 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 415 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 416 | "license": "ISC" 417 | }, 418 | "node_modules/ipaddr.js": { 419 | "version": "1.9.1", 420 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", 421 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", 422 | "license": "MIT", 423 | "engines": { 424 | "node": ">= 0.10" 425 | } 426 | }, 427 | "node_modules/media-typer": { 428 | "version": "0.3.0", 429 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 430 | "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", 431 | "license": "MIT", 432 | "engines": { 433 | "node": ">= 0.6" 434 | } 435 | }, 436 | "node_modules/merge-descriptors": { 437 | "version": "1.0.3", 438 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", 439 | "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", 440 | "license": "MIT", 441 | "funding": { 442 | "url": "https://github.com/sponsors/sindresorhus" 443 | } 444 | }, 445 | "node_modules/methods": { 446 | "version": "1.1.2", 447 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 448 | "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", 449 | "license": "MIT", 450 | "engines": { 451 | "node": ">= 0.6" 452 | } 453 | }, 454 | "node_modules/mime": { 455 | "version": "1.6.0", 456 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 457 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", 458 | "license": "MIT", 459 | "bin": { 460 | "mime": "cli.js" 461 | }, 462 | "engines": { 463 | "node": ">=4" 464 | } 465 | }, 466 | "node_modules/mime-db": { 467 | "version": "1.52.0", 468 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 469 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 470 | "license": "MIT", 471 | "engines": { 472 | "node": ">= 0.6" 473 | } 474 | }, 475 | "node_modules/mime-types": { 476 | "version": "2.1.35", 477 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 478 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 479 | "license": "MIT", 480 | "dependencies": { 481 | "mime-db": "1.52.0" 482 | }, 483 | "engines": { 484 | "node": ">= 0.6" 485 | } 486 | }, 487 | "node_modules/ms": { 488 | "version": "2.0.0", 489 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 490 | "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", 491 | "license": "MIT" 492 | }, 493 | "node_modules/negotiator": { 494 | "version": "0.6.3", 495 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", 496 | "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", 497 | "license": "MIT", 498 | "engines": { 499 | "node": ">= 0.6" 500 | } 501 | }, 502 | "node_modules/object-inspect": { 503 | "version": "1.13.2", 504 | "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", 505 | "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", 506 | "license": "MIT", 507 | "engines": { 508 | "node": ">= 0.4" 509 | }, 510 | "funding": { 511 | "url": "https://github.com/sponsors/ljharb" 512 | } 513 | }, 514 | "node_modules/on-finished": { 515 | "version": "2.4.1", 516 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", 517 | "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", 518 | "license": "MIT", 519 | "dependencies": { 520 | "ee-first": "1.1.1" 521 | }, 522 | "engines": { 523 | "node": ">= 0.8" 524 | } 525 | }, 526 | "node_modules/parseurl": { 527 | "version": "1.3.3", 528 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", 529 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", 530 | "license": "MIT", 531 | "engines": { 532 | "node": ">= 0.8" 533 | } 534 | }, 535 | "node_modules/path-to-regexp": { 536 | "version": "0.1.10", 537 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", 538 | "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", 539 | "license": "MIT" 540 | }, 541 | "node_modules/proxy-addr": { 542 | "version": "2.0.7", 543 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", 544 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 545 | "license": "MIT", 546 | "dependencies": { 547 | "forwarded": "0.2.0", 548 | "ipaddr.js": "1.9.1" 549 | }, 550 | "engines": { 551 | "node": ">= 0.10" 552 | } 553 | }, 554 | "node_modules/qs": { 555 | "version": "6.13.0", 556 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", 557 | "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", 558 | "license": "BSD-3-Clause", 559 | "dependencies": { 560 | "side-channel": "^1.0.6" 561 | }, 562 | "engines": { 563 | "node": ">=0.6" 564 | }, 565 | "funding": { 566 | "url": "https://github.com/sponsors/ljharb" 567 | } 568 | }, 569 | "node_modules/range-parser": { 570 | "version": "1.2.1", 571 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", 572 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", 573 | "license": "MIT", 574 | "engines": { 575 | "node": ">= 0.6" 576 | } 577 | }, 578 | "node_modules/raw-body": { 579 | "version": "2.5.2", 580 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", 581 | "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", 582 | "license": "MIT", 583 | "dependencies": { 584 | "bytes": "3.1.2", 585 | "http-errors": "2.0.0", 586 | "iconv-lite": "0.4.24", 587 | "unpipe": "1.0.0" 588 | }, 589 | "engines": { 590 | "node": ">= 0.8" 591 | } 592 | }, 593 | "node_modules/safe-buffer": { 594 | "version": "5.2.1", 595 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 596 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 597 | "funding": [ 598 | { 599 | "type": "github", 600 | "url": "https://github.com/sponsors/feross" 601 | }, 602 | { 603 | "type": "patreon", 604 | "url": "https://www.patreon.com/feross" 605 | }, 606 | { 607 | "type": "consulting", 608 | "url": "https://feross.org/support" 609 | } 610 | ], 611 | "license": "MIT" 612 | }, 613 | "node_modules/safer-buffer": { 614 | "version": "2.1.2", 615 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 616 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", 617 | "license": "MIT" 618 | }, 619 | "node_modules/send": { 620 | "version": "0.19.0", 621 | "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", 622 | "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", 623 | "license": "MIT", 624 | "dependencies": { 625 | "debug": "2.6.9", 626 | "depd": "2.0.0", 627 | "destroy": "1.2.0", 628 | "encodeurl": "~1.0.2", 629 | "escape-html": "~1.0.3", 630 | "etag": "~1.8.1", 631 | "fresh": "0.5.2", 632 | "http-errors": "2.0.0", 633 | "mime": "1.6.0", 634 | "ms": "2.1.3", 635 | "on-finished": "2.4.1", 636 | "range-parser": "~1.2.1", 637 | "statuses": "2.0.1" 638 | }, 639 | "engines": { 640 | "node": ">= 0.8.0" 641 | } 642 | }, 643 | "node_modules/send/node_modules/encodeurl": { 644 | "version": "1.0.2", 645 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 646 | "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", 647 | "license": "MIT", 648 | "engines": { 649 | "node": ">= 0.8" 650 | } 651 | }, 652 | "node_modules/send/node_modules/ms": { 653 | "version": "2.1.3", 654 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 655 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 656 | "license": "MIT" 657 | }, 658 | "node_modules/serve-static": { 659 | "version": "1.16.2", 660 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", 661 | "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", 662 | "license": "MIT", 663 | "dependencies": { 664 | "encodeurl": "~2.0.0", 665 | "escape-html": "~1.0.3", 666 | "parseurl": "~1.3.3", 667 | "send": "0.19.0" 668 | }, 669 | "engines": { 670 | "node": ">= 0.8.0" 671 | } 672 | }, 673 | "node_modules/set-function-length": { 674 | "version": "1.2.2", 675 | "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", 676 | "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", 677 | "license": "MIT", 678 | "dependencies": { 679 | "define-data-property": "^1.1.4", 680 | "es-errors": "^1.3.0", 681 | "function-bind": "^1.1.2", 682 | "get-intrinsic": "^1.2.4", 683 | "gopd": "^1.0.1", 684 | "has-property-descriptors": "^1.0.2" 685 | }, 686 | "engines": { 687 | "node": ">= 0.4" 688 | } 689 | }, 690 | "node_modules/setprototypeof": { 691 | "version": "1.2.0", 692 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", 693 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", 694 | "license": "ISC" 695 | }, 696 | "node_modules/side-channel": { 697 | "version": "1.0.6", 698 | "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", 699 | "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", 700 | "license": "MIT", 701 | "dependencies": { 702 | "call-bind": "^1.0.7", 703 | "es-errors": "^1.3.0", 704 | "get-intrinsic": "^1.2.4", 705 | "object-inspect": "^1.13.1" 706 | }, 707 | "engines": { 708 | "node": ">= 0.4" 709 | }, 710 | "funding": { 711 | "url": "https://github.com/sponsors/ljharb" 712 | } 713 | }, 714 | "node_modules/statuses": { 715 | "version": "2.0.1", 716 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", 717 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", 718 | "license": "MIT", 719 | "engines": { 720 | "node": ">= 0.8" 721 | } 722 | }, 723 | "node_modules/toidentifier": { 724 | "version": "1.0.1", 725 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", 726 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", 727 | "license": "MIT", 728 | "engines": { 729 | "node": ">=0.6" 730 | } 731 | }, 732 | "node_modules/type-is": { 733 | "version": "1.6.18", 734 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", 735 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 736 | "license": "MIT", 737 | "dependencies": { 738 | "media-typer": "0.3.0", 739 | "mime-types": "~2.1.24" 740 | }, 741 | "engines": { 742 | "node": ">= 0.6" 743 | } 744 | }, 745 | "node_modules/unpipe": { 746 | "version": "1.0.0", 747 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 748 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", 749 | "license": "MIT", 750 | "engines": { 751 | "node": ">= 0.8" 752 | } 753 | }, 754 | "node_modules/utils-merge": { 755 | "version": "1.0.1", 756 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 757 | "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", 758 | "license": "MIT", 759 | "engines": { 760 | "node": ">= 0.4.0" 761 | } 762 | }, 763 | "node_modules/vary": { 764 | "version": "1.1.2", 765 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 766 | "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", 767 | "license": "MIT", 768 | "engines": { 769 | "node": ">= 0.8" 770 | } 771 | } 772 | } 773 | } 774 | -------------------------------------------------------------------------------- /rate_shield/examples/express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-example", 3 | "version": "1.0.0", 4 | "description": "An example on how to use rate shield with express.", 5 | "main": "app.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "start" : "nodemon app.js" 10 | }, 11 | "author": "Sushant", 12 | "license": "ISC", 13 | "dependencies": { 14 | "express": "^4.21.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /rate_shield/examples/flask/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, jsonify 2 | import requests 3 | 4 | app = Flask(__name__) 5 | 6 | def rate_limit_check(): 7 | endpoint = request.path 8 | ip = request.remote_addr 9 | 10 | headers = { 11 | 'endpoint' : endpoint, 12 | 'ip' : ip 13 | } 14 | 15 | try: 16 | response = requests.get('http://127.0.0.1:8080/check-limit', headers=headers) 17 | 18 | if response.status_code == 429: 19 | return jsonify({ 20 | 'error' : 'TOO MANY REQUESTS' 21 | }), 429 22 | 23 | if response.status_code == 500: 24 | return jsonify({ 25 | 'error' : 'INTERNAL SERVER ERROR' 26 | }), 500 27 | except requests.exceptions.RequestException as e: 28 | return jsonify({ 29 | 'error' : 'Rate limit service unavailable' 30 | }), 500 31 | 32 | @app.before_request 33 | def before_request(): 34 | rate_limit_response = rate_limit_check() 35 | if rate_limit_response: 36 | return rate_limit_response 37 | 38 | 39 | 40 | @app.route("/api/v1/process", methods=['GET']) 41 | def process(): 42 | return jsonify({"success": True}) 43 | 44 | if __name__ == '__main__': 45 | app.run(port=3002) -------------------------------------------------------------------------------- /rate_shield/examples/gofiber/.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Output folders 8 | bin/ 9 | build/ 10 | 11 | # Dependency directories 12 | vendor/ 13 | 14 | # GoLand project files 15 | .idea/ 16 | 17 | # VSCode project files 18 | .vscode/ 19 | 20 | # Logs 21 | *.log 22 | 23 | # Go test binaries 24 | *.test 25 | 26 | # Ignore .env files if any 27 | .env 28 | 29 | # Generated Go files 30 | *.gen.go 31 | 32 | # Golang package files 33 | go.sum 34 | 35 | # Ignore temporary files 36 | *~ 37 | -------------------------------------------------------------------------------- /rate_shield/examples/gofiber/cmd/app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "gofiberapp/middleware" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | ) 8 | 9 | func main() { 10 | app := fiber.New() 11 | 12 | app.Use(middleware.RateLimiter()) 13 | 14 | app.Get("/api/v1/resource", func(c *fiber.Ctx) error { 15 | return c.SendString("This is a protected resource") 16 | }) 17 | 18 | app.Listen(":3000") 19 | } 20 | -------------------------------------------------------------------------------- /rate_shield/examples/gofiber/go.mod: -------------------------------------------------------------------------------- 1 | module gofiberapp 2 | 3 | go 1.22.5 4 | 5 | require ( 6 | github.com/andybalholm/brotli v1.0.5 // indirect 7 | github.com/gofiber/fiber/v2 v2.52.5 // indirect 8 | github.com/google/uuid v1.5.0 // indirect 9 | github.com/klauspost/compress v1.17.0 // indirect 10 | github.com/mattn/go-colorable v0.1.13 // indirect 11 | github.com/mattn/go-isatty v0.0.20 // indirect 12 | github.com/mattn/go-runewidth v0.0.15 // indirect 13 | github.com/rivo/uniseg v0.2.0 // indirect 14 | github.com/valyala/bytebufferpool v1.0.0 // indirect 15 | github.com/valyala/fasthttp v1.51.0 // indirect 16 | github.com/valyala/tcplisten v1.0.0 // indirect 17 | golang.org/x/sys v0.15.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /rate_shield/examples/gofiber/middleware/rate_limit.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | ) 9 | 10 | func RateLimiter() fiber.Handler { 11 | fmt.Println("Here") 12 | return func(c *fiber.Ctx) error { 13 | 14 | ip := c.IP() // Client IP 15 | endpoint := c.Path() // Requested endpoint 16 | 17 | req, err := http.NewRequest("GET", "http://127.0.0.1:8080/check-limit", nil) 18 | if err != nil { 19 | return fiber.ErrInternalServerError 20 | } 21 | 22 | // Set headers for Rate Shield 23 | req.Header.Set("ip", ip) 24 | req.Header.Set("endpoint", endpoint) 25 | 26 | client := &http.Client{} 27 | resp, err := client.Do(req) 28 | if err != nil { 29 | return fiber.ErrInternalServerError 30 | } 31 | defer resp.Body.Close() 32 | 33 | // Handle Rate Shield response 34 | switch resp.StatusCode { 35 | case http.StatusOK: 36 | return c.Next() // Allow request to proceed 37 | case http.StatusTooManyRequests: 38 | return c.Status(fiber.StatusTooManyRequests).SendString("Rate limit exceeded") 39 | default: 40 | return fiber.ErrInternalServerError 41 | } 42 | 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /rate_shield/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/x-sushant-x/RateShield 2 | 3 | go 1.22.5 4 | 5 | require ( 6 | github.com/joho/godotenv v1.5.1 7 | github.com/redis/go-redis/v9 v9.7.0 8 | github.com/stretchr/testify v1.9.0 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 13 | github.com/kr/pretty v0.3.1 // indirect 14 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 15 | github.com/stretchr/objx v0.5.2 // indirect 16 | gopkg.in/yaml.v3 v3.0.1 // indirect 17 | ) 18 | 19 | require ( 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 21 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 22 | github.com/go-co-op/gocron/v2 v2.11.0 23 | github.com/google/uuid v1.6.0 // indirect 24 | github.com/jonboulle/clockwork v0.4.0 // indirect 25 | github.com/mattn/go-colorable v0.1.13 // indirect 26 | github.com/mattn/go-isatty v0.0.20 // indirect 27 | github.com/robfig/cron/v3 v3.0.1 // indirect 28 | github.com/rs/zerolog v1.33.0 29 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect 30 | golang.org/x/sys v0.26.0 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /rate_shield/go.sum: -------------------------------------------------------------------------------- 1 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 2 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 3 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 4 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 8 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 9 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 10 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 12 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 13 | github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE= 14 | github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w= 15 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 16 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 17 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 18 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 19 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 20 | github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= 21 | github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= 22 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 23 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 24 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 25 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 26 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 27 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 28 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 29 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 30 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 31 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 32 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 33 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 34 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 35 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 36 | github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= 37 | github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= 38 | github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= 39 | github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= 40 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 41 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 42 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 43 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 44 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 45 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 46 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 47 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 48 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 49 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 50 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 51 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 52 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 53 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= 54 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 55 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 59 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 60 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 61 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 62 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 63 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 64 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 65 | -------------------------------------------------------------------------------- /rate_shield/limiter/fixed_window_counter.go: -------------------------------------------------------------------------------- 1 | package limiter 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rs/zerolog/log" 7 | "github.com/x-sushant-x/RateShield/models" 8 | redisClient "github.com/x-sushant-x/RateShield/redis" 9 | "github.com/x-sushant-x/RateShield/utils" 10 | ) 11 | 12 | type FixedWindowService struct { 13 | redisClient redisClient.RedisRateLimiterClient 14 | } 15 | 16 | func NewFixedWindowService(client redisClient.RedisRateLimiterClient) FixedWindowService { 17 | return FixedWindowService{ 18 | redisClient: client, 19 | } 20 | } 21 | 22 | func (fw *FixedWindowService) processRequest(ip, endpoint string, rule *models.Rule) *models.RateLimitResponse { 23 | key := fw.parseToKey(ip, endpoint) 24 | 25 | fixedWindow, response := fw.prepareFixedWindow(ip, endpoint, rule) 26 | if response != nil { 27 | return response 28 | } 29 | 30 | currTime := time.Now().Unix() 31 | return fw.handleRateLimit(fixedWindow, key, currTime) 32 | } 33 | 34 | func (fw *FixedWindowService) prepareFixedWindow(ip, endpoint string, rule *models.Rule) (*models.FixedWindowCounter, *models.RateLimitResponse) { 35 | key := fw.parseToKey(ip, endpoint) 36 | fixedWindow, found, err := fw.getFixedWindowFromRedis(key) 37 | if err != nil { 38 | return nil, fw.handleError(err, "error while getting fixed window") 39 | } 40 | 41 | if !found { 42 | fixedWindow, err := fw.spawnNewFixedWindow(ip, endpoint, rule) 43 | if err != nil { 44 | return nil, fw.handleError(err, "unable to get newly spawned fixed window from redis") 45 | } 46 | return fixedWindow, utils.BuildRateLimitSuccessResponse(fixedWindow.MaxRequests, fixedWindow.MaxRequests-1) 47 | } 48 | 49 | return fixedWindow, nil 50 | } 51 | 52 | func (fw *FixedWindowService) handleRateLimit(fixedWindow *models.FixedWindowCounter, key string, currTime int64) *models.RateLimitResponse { 53 | if currTime-fixedWindow.LastAccessTime < int64(fixedWindow.Window) { 54 | return fw.processWithinTimeWindow(fixedWindow, key, currTime) 55 | } 56 | return fw.ResetWindow(key, currTime, fixedWindow) 57 | } 58 | 59 | func (fw *FixedWindowService) processWithinTimeWindow(fixedWindow *models.FixedWindowCounter, key string, currTime int64) *models.RateLimitResponse { 60 | if fixedWindow.CurrRequests < fixedWindow.MaxRequests { 61 | fixedWindow.CurrRequests++ 62 | fixedWindow.LastAccessTime = currTime 63 | return fw.saveFixedWindow(key, fixedWindow) 64 | } 65 | return utils.BuildRateLimitErrorResponse(429) 66 | } 67 | 68 | func (fw *FixedWindowService) saveFixedWindow(key string, fixedWindow *models.FixedWindowCounter) *models.RateLimitResponse { 69 | err := fw.save(key, fixedWindow) 70 | if err != nil { 71 | return fw.handleError(err, "error while saving fixed window") 72 | } 73 | return utils.BuildRateLimitSuccessResponse(fixedWindow.MaxRequests, fixedWindow.MaxRequests-fixedWindow.CurrRequests) 74 | } 75 | 76 | func (fw *FixedWindowService) handleError(err error, msg string) *models.RateLimitResponse { 77 | log.Err(err).Msg(msg) 78 | return utils.BuildRateLimitErrorResponse(500) 79 | } 80 | 81 | func (fw *FixedWindowService) ResetWindow(key string, currTime int64, fixedWindow *models.FixedWindowCounter) *models.RateLimitResponse { 82 | fixedWindow.CurrRequests = 1 83 | fixedWindow.LastAccessTime = currTime 84 | return fw.saveFixedWindow(key, fixedWindow) 85 | } 86 | 87 | func (fw *FixedWindowService) getFixedWindowFromRedis(key string) (*models.FixedWindowCounter, bool, error) { 88 | fixedWindowFromRedis, found, err := fw.redisClient.JSONGet(key) 89 | 90 | if err != nil { 91 | log.Error().Err(err).Msg("Error fetching fixed window from Redis") 92 | return nil, false, err 93 | } 94 | 95 | if !found { 96 | return nil, false, nil 97 | } 98 | 99 | fixedWindow, err := utils.Unmarshal[models.FixedWindowCounter]([]byte(fixedWindowFromRedis)) 100 | if err != nil { 101 | log.Err(err).Msg(err.Error()) 102 | return nil, true, err 103 | } 104 | 105 | return &fixedWindow, true, nil 106 | } 107 | 108 | func (fw *FixedWindowService) makeFixedWindowCounter(ip, endpoint string, rule *models.Rule) models.FixedWindowCounter { 109 | return models.FixedWindowCounter{ 110 | Endpoint: endpoint, 111 | ClientIP: ip, 112 | CreatedAt: time.Now().Unix(), 113 | MaxRequests: rule.FixedWindowCounterRule.MaxRequests, 114 | CurrRequests: 1, 115 | Window: rule.FixedWindowCounterRule.Window, 116 | LastAccessTime: time.Now().Unix(), 117 | } 118 | } 119 | 120 | func (fw *FixedWindowService) spawnNewFixedWindow(ip, endpoint string, rule *models.Rule) (*models.FixedWindowCounter, error) { 121 | key := fw.parseToKey(ip, endpoint) 122 | fixedWindow := fw.makeFixedWindowCounter(ip, endpoint, rule) 123 | 124 | if err := fw.save(key, &fixedWindow); err != nil { 125 | log.Err(err).Msg("unable to save fixed window to redis") 126 | return nil, err 127 | } 128 | 129 | err := fw.redisClient.Expire(key, time.Duration(fixedWindow.Window)*time.Second) 130 | if err != nil { 131 | log.Err(err).Msg("unable to set expire time of fixed window in redis") 132 | return nil, err 133 | } 134 | return &fixedWindow, nil 135 | } 136 | 137 | func (fw *FixedWindowService) save(key string, fixedWindow *models.FixedWindowCounter) error { 138 | return fw.redisClient.JSONSet(key, fixedWindow) 139 | } 140 | 141 | func (fw *FixedWindowService) parseToKey(ip, endpoint string) string { 142 | return "fixed_window_" + ip + ":" + endpoint 143 | } 144 | -------------------------------------------------------------------------------- /rate_shield/limiter/fixed_window_counter_test.go: -------------------------------------------------------------------------------- 1 | package limiter 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/mock" 12 | "github.com/x-sushant-x/RateShield/models" 13 | ) 14 | 15 | type MockRedisFixedWindowClient struct { 16 | mock.Mock 17 | } 18 | 19 | func (m *MockRedisFixedWindowClient) JSONGet(key string) (string, bool, error) { 20 | args := m.Called(key) 21 | 22 | return fmt.Sprintf("%s", args.Get(0)), args.Bool(1), args.Error(2) 23 | } 24 | 25 | func (m *MockRedisFixedWindowClient) JSONSet(key string, value interface{}) error { 26 | args := m.Called(key, value) 27 | return args.Error(0) 28 | } 29 | 30 | func (m *MockRedisFixedWindowClient) Expire(key string, expiration time.Duration) error { 31 | args := m.Called(key, expiration) 32 | return args.Error(0) 33 | } 34 | 35 | func (m *MockRedisFixedWindowClient) Delete(key string) error { 36 | args := m.Called(key) 37 | return args.Error(0) 38 | } 39 | 40 | func TestProcessRequest(t *testing.T) { 41 | mockRedis := new(MockRedisFixedWindowClient) 42 | service := NewFixedWindowService(mockRedis) 43 | 44 | t.Run("New window creation", func(t *testing.T) { 45 | mockRedis.ExpectedCalls = nil 46 | mockRedis.Calls = nil 47 | 48 | mockRedis.On("JSONGet", mock.Anything).Return((*models.FixedWindowCounter)(nil), false, nil) 49 | mockRedis.On("JSONSet", mock.Anything, mock.Anything).Return(nil) 50 | mockRedis.On("Expire", mock.Anything, mock.Anything).Return(nil) 51 | 52 | rule := &models.Rule{ 53 | FixedWindowCounterRule: &models.FixedWindowCounterRule{ 54 | MaxRequests: 10, 55 | Window: 60, 56 | }, 57 | } 58 | 59 | response := service.processRequest("192.168.1.1", "/test", rule) 60 | 61 | assert.Equal(t, 200, response.HTTPStatusCode) 62 | mockRedis.AssertExpectations(t) 63 | }) 64 | 65 | t.Run("Existing window within limit", func(t *testing.T) { 66 | mockRedis.ExpectedCalls = nil 67 | mockRedis.Calls = nil 68 | 69 | fixedWindow := &models.FixedWindowCounter{ 70 | MaxRequests: 10, 71 | CurrRequests: 5, 72 | Window: 60, 73 | LastAccessTime: time.Now().Unix() - 30, 74 | } 75 | 76 | windowStr, err := json.Marshal(fixedWindow) 77 | assert.NoError(t, err) 78 | 79 | mockRedis.On("JSONGet", mock.Anything).Return(string(windowStr), true, nil) 80 | mockRedis.On("JSONSet", mock.Anything, mock.Anything).Return(nil) 81 | 82 | rule := &models.Rule{ 83 | FixedWindowCounterRule: &models.FixedWindowCounterRule{ 84 | MaxRequests: 10, 85 | Window: 60, 86 | }, 87 | } 88 | 89 | response := service.processRequest("192.168.1.2", "/test", rule) 90 | 91 | assert.Equal(t, 200, response.HTTPStatusCode) 92 | mockRedis.AssertExpectations(t) 93 | }) 94 | 95 | t.Run("Existing window at limit", func(t *testing.T) { 96 | mockRedis.ExpectedCalls = nil 97 | mockRedis.Calls = nil 98 | 99 | fixedWindow := &models.FixedWindowCounter{ 100 | MaxRequests: 10, 101 | CurrRequests: 10, 102 | Window: 60, 103 | LastAccessTime: time.Now().Unix() - 30, 104 | } 105 | 106 | windowStr, err := json.Marshal(fixedWindow) 107 | assert.NoError(t, err) 108 | 109 | mockRedis.On("JSONGet", mock.Anything).Return(string(windowStr), true, nil) 110 | 111 | rule := &models.Rule{ 112 | FixedWindowCounterRule: &models.FixedWindowCounterRule{ 113 | MaxRequests: 10, 114 | Window: 60, 115 | }, 116 | } 117 | 118 | response := service.processRequest("192.168.1.3", "/test", rule) 119 | 120 | assert.Equal(t, 429, response.HTTPStatusCode) 121 | mockRedis.AssertExpectations(t) 122 | }) 123 | 124 | t.Run("Existing window outside time limit", func(t *testing.T) { 125 | mockRedis.ExpectedCalls = nil 126 | mockRedis.Calls = nil 127 | 128 | fixedWindow := &models.FixedWindowCounter{ 129 | MaxRequests: 10, 130 | CurrRequests: 10, 131 | Window: 10, 132 | LastAccessTime: time.Now().Unix() - 30, 133 | } 134 | 135 | windowStr, err := json.Marshal(fixedWindow) 136 | assert.NoError(t, err) 137 | 138 | mockRedis.On("JSONGet", mock.Anything).Return(string(windowStr), true, nil) 139 | 140 | rule := &models.Rule{ 141 | FixedWindowCounterRule: &models.FixedWindowCounterRule{ 142 | MaxRequests: 10, 143 | Window: 60, 144 | }, 145 | } 146 | mockRedis.On("JSONSet", mock.Anything, mock.Anything).Return(nil) 147 | response := service.processRequest("192.168.1.4", "/test", rule) 148 | 149 | assert.Equal(t, 200, response.HTTPStatusCode) 150 | mockRedis.AssertExpectations(t) 151 | }) 152 | 153 | t.Run("Redis error", func(t *testing.T) { 154 | mockRedis.ExpectedCalls = nil 155 | mockRedis.Calls = nil 156 | 157 | mockRedis.On("JSONGet", mock.Anything).Return((*models.FixedWindowCounter)(nil), false, errors.New("Redis error")) 158 | 159 | rule := &models.Rule{ 160 | FixedWindowCounterRule: &models.FixedWindowCounterRule{ 161 | MaxRequests: 10, 162 | Window: 60, 163 | }, 164 | } 165 | 166 | response := service.processRequest("192.168.1.5", "/test", rule) 167 | 168 | assert.Equal(t, 500, response.HTTPStatusCode) 169 | mockRedis.AssertExpectations(t) 170 | }) 171 | } 172 | 173 | func TestSpawnNewFixedWindow(t *testing.T) { 174 | mockRedis := new(MockRedisFixedWindowClient) 175 | service := NewFixedWindowService(mockRedis) 176 | 177 | t.Run("Successful new window creation", func(t *testing.T) { 178 | mockRedis.ExpectedCalls = nil 179 | mockRedis.Calls = nil 180 | 181 | // Mock the JSONSet operation 182 | mockRedis.On("JSONSet", mock.Anything, mock.Anything).Return(nil) 183 | 184 | // Mock the Expire operation 185 | mockRedis.On("Expire", mock.Anything, mock.Anything).Return(nil) 186 | 187 | rule := &models.Rule{ 188 | FixedWindowCounterRule: &models.FixedWindowCounterRule{ 189 | MaxRequests: 10, 190 | Window: 60, 191 | }, 192 | } 193 | 194 | fixedWindow, err := service.spawnNewFixedWindow("192.168.1.6", "/test", rule) 195 | 196 | // Assert that no error occurred 197 | assert.NoError(t, err) 198 | 199 | // Assert that the fixedWindow is not nil 200 | assert.NotNil(t, fixedWindow) 201 | 202 | // Assert the properties of the created fixedWindow 203 | assert.Equal(t, "/test", fixedWindow.Endpoint) 204 | assert.Equal(t, "192.168.1.6", fixedWindow.ClientIP) 205 | assert.Equal(t, int64(10), fixedWindow.MaxRequests) 206 | assert.Equal(t, int64(1), fixedWindow.CurrRequests) 207 | assert.Equal(t, 60, fixedWindow.Window) 208 | 209 | // Assert that the mock expectations were met 210 | mockRedis.AssertExpectations(t) 211 | }) 212 | 213 | t.Run("Redis JSONSet error", func(t *testing.T) { 214 | mockRedis.ExpectedCalls = nil 215 | mockRedis.Calls = nil 216 | 217 | // Mock the JSONSet operation to return an error 218 | expectedError := errors.New("Redis JSONSet error") 219 | mockRedis.On("JSONSet", mock.Anything, mock.Anything).Return(expectedError) 220 | 221 | rule := &models.Rule{ 222 | FixedWindowCounterRule: &models.FixedWindowCounterRule{ 223 | MaxRequests: 10, 224 | Window: 60, 225 | }, 226 | } 227 | 228 | fixedWindow, err := service.spawnNewFixedWindow("192.168.1.7", "/test", rule) 229 | 230 | // Assert that an error occurred 231 | assert.Error(t, err) 232 | assert.Nil(t, fixedWindow) 233 | 234 | // Check if the error is exactly what we expect 235 | assert.Equal(t, expectedError, err) 236 | 237 | // Assert that the mock expectations were met 238 | mockRedis.AssertExpectations(t) 239 | }) 240 | 241 | t.Run("Redis Expire error", func(t *testing.T) { 242 | mockRedis.ExpectedCalls = nil 243 | mockRedis.Calls = nil 244 | 245 | // Mock the JSONSet operation to succeed 246 | mockRedis.On("JSONSet", mock.Anything, mock.Anything).Return(nil) 247 | 248 | // Mock the Expire operation to fail 249 | expectedError := errors.New("Redis Expire error") 250 | mockRedis.On("Expire", mock.Anything, mock.Anything).Return(expectedError) 251 | 252 | rule := &models.Rule{ 253 | FixedWindowCounterRule: &models.FixedWindowCounterRule{ 254 | MaxRequests: 10, 255 | Window: 60, 256 | }, 257 | } 258 | 259 | fixedWindow, err := service.spawnNewFixedWindow("192.168.1.8", "/test", rule) 260 | 261 | // Assert that an error occurred 262 | assert.Error(t, err) 263 | assert.Nil(t, fixedWindow) 264 | 265 | // Check if the error is exactly what we expect 266 | assert.Equal(t, expectedError, err) 267 | 268 | // Assert that the mock expectations were met 269 | mockRedis.AssertExpectations(t) 270 | }) 271 | } 272 | -------------------------------------------------------------------------------- /rate_shield/limiter/leaky-bucket.go: -------------------------------------------------------------------------------- 1 | /* 2 | Important - This strategy is not ready to be used by Rate Shield yet. Please ignore this if you are using Rate Shield as library. 3 | */ 4 | 5 | package limiter 6 | 7 | import ( 8 | "sync" 9 | "time" 10 | 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | type Request struct { 15 | ClientIP string 16 | Endpoint string 17 | } 18 | 19 | type LeakyBucket struct { 20 | Reqs []Request 21 | Capacity int 22 | EmptyRate time.Duration 23 | StopRefilling chan struct{} 24 | Mutex sync.Mutex 25 | } 26 | 27 | func NewLeakyBucket(capacity int, emptyRate time.Duration) *LeakyBucket { 28 | b := &LeakyBucket{ 29 | Capacity: capacity, 30 | EmptyRate: emptyRate, 31 | StopRefilling: make(chan struct{}), 32 | } 33 | 34 | go b.processRequests() 35 | return b 36 | } 37 | 38 | func (lb *LeakyBucket) processRequests() { 39 | ticker := time.NewTicker(lb.EmptyRate) 40 | defer ticker.Stop() 41 | 42 | for { 43 | select { 44 | case <-ticker.C: 45 | lb.Mutex.Lock() 46 | if len(lb.Reqs) > 0 { 47 | log.Info().Msgf("Request Served: %d", len(lb.Reqs)) 48 | lb.Reqs = nil 49 | } 50 | lb.Mutex.Unlock() 51 | case <-lb.StopRefilling: 52 | return 53 | } 54 | } 55 | } 56 | 57 | func (lb *LeakyBucket) AddRequestsToBucket(req Request) bool { 58 | lb.Mutex.Lock() 59 | defer lb.Mutex.Unlock() 60 | 61 | if len(lb.Reqs) < lb.Capacity { 62 | lb.Reqs = append(lb.Reqs, req) 63 | return true 64 | } 65 | 66 | return false 67 | } 68 | 69 | func (lb *LeakyBucket) StopRefillingBucket() { 70 | close(lb.StopRefilling) 71 | } 72 | -------------------------------------------------------------------------------- /rate_shield/limiter/limiter.go: -------------------------------------------------------------------------------- 1 | package limiter 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/rs/zerolog/log" 8 | "github.com/x-sushant-x/RateShield/models" 9 | "github.com/x-sushant-x/RateShield/service" 10 | "github.com/x-sushant-x/RateShield/utils" 11 | ) 12 | 13 | const ( 14 | TokenAddTime = time.Second * 10 15 | ) 16 | 17 | type Limiter struct { 18 | tokenBucket *TokenBucketService 19 | fixedWindow *FixedWindowService 20 | slidingWindow *SlidingWindowService 21 | redisRuleSvc service.RulesService 22 | cachedRules *map[string]*models.Rule 23 | rulesMutex sync.RWMutex 24 | } 25 | 26 | func NewRateLimiterService( 27 | tokenBucket *TokenBucketService, fixedWindow *FixedWindowService, slidingWindow *SlidingWindowService, redisRuleSvc service.RulesService) Limiter { 28 | 29 | return Limiter{ 30 | tokenBucket: tokenBucket, 31 | fixedWindow: fixedWindow, 32 | redisRuleSvc: redisRuleSvc, 33 | slidingWindow: slidingWindow, 34 | // This is initialized later in StartRateLimiter() function 35 | cachedRules: nil, 36 | rulesMutex: sync.RWMutex{}, 37 | } 38 | } 39 | 40 | func (l *Limiter) CheckLimit(ip, endpoint string) *models.RateLimitResponse { 41 | key := ip + ":" + endpoint 42 | 43 | l.rulesMutex.RLock() 44 | rulesMap := *l.cachedRules 45 | l.rulesMutex.RUnlock() 46 | 47 | rule, found := rulesMap[endpoint] 48 | 49 | if found { 50 | switch rule.Strategy { 51 | case "TOKEN BUCKET": 52 | return l.processTokenBucketReq(key, rule) 53 | case "FIXED WINDOW COUNTER": 54 | return l.processFixedWindowReq(ip, endpoint, rule) 55 | case "SLIDING WINDOW COUNTER": 56 | return l.processSlidingWindowReq(ip, endpoint, rule) 57 | } 58 | } 59 | 60 | if !found { 61 | return utils.BuildRateLimitSuccessResponse(0, 0) 62 | } 63 | 64 | return utils.BuildRateLimitSuccessResponse(0, 0) 65 | } 66 | 67 | func (l *Limiter) processTokenBucketReq(key string, rule *models.Rule) *models.RateLimitResponse { 68 | resp := l.tokenBucket.processRequest(key, rule) 69 | 70 | if resp.Success { 71 | return resp 72 | } 73 | 74 | if rule.AllowOnError { 75 | return utils.BuildRateLimitSuccessResponse(0, 0) 76 | } 77 | 78 | return resp 79 | } 80 | 81 | func (l *Limiter) processFixedWindowReq(ip, endpoint string, rule *models.Rule) *models.RateLimitResponse { 82 | resp := l.fixedWindow.processRequest(ip, endpoint, rule) 83 | 84 | if resp.Success { 85 | return resp 86 | } 87 | 88 | if rule.AllowOnError { 89 | return utils.BuildRateLimitSuccessResponse(0, 0) 90 | } 91 | 92 | return resp 93 | } 94 | 95 | func (l *Limiter) processSlidingWindowReq(ip, endpoint string, rule *models.Rule) *models.RateLimitResponse { 96 | resp := l.slidingWindow.processRequest(ip, endpoint, rule) 97 | 98 | if resp.Success { 99 | return resp 100 | } 101 | 102 | if rule.AllowOnError { 103 | return utils.BuildRateLimitSuccessResponse(0, 0) 104 | } 105 | 106 | return resp 107 | } 108 | 109 | func (l *Limiter) GetRule(key string) (*models.Rule, bool, error) { 110 | return l.redisRuleSvc.GetRule(key) 111 | } 112 | 113 | func (l *Limiter) StartRateLimiter() { 114 | log.Info().Msg("Starting limiter service ✅") 115 | l.cachedRules = l.redisRuleSvc.CacheRulesLocally() 116 | log.Info().Msgf("Total Rules: %d", len(*l.cachedRules)) 117 | 118 | // Not required for now. 119 | //l.tokenBucket.startAddTokenJob() 120 | go l.listenToRulesUpdate() 121 | } 122 | 123 | func (l *Limiter) listenToRulesUpdate() { 124 | updatesChannel := make(chan string) 125 | go l.redisRuleSvc.ListenToRulesUpdate(updatesChannel) 126 | 127 | for { 128 | data := <-updatesChannel 129 | 130 | if data == "UpdateRules" { 131 | l.rulesMutex.Lock() 132 | l.cachedRules = l.redisRuleSvc.CacheRulesLocally() 133 | l.rulesMutex.Unlock() 134 | 135 | log.Info().Msg("Rules Updated Successfully") 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /rate_shield/limiter/sliding_window_counter.go: -------------------------------------------------------------------------------- 1 | package limiter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/redis/go-redis/v9" 9 | "github.com/x-sushant-x/RateShield/models" 10 | "github.com/x-sushant-x/RateShield/utils" 11 | ) 12 | 13 | var ( 14 | ctx = context.Background() 15 | ) 16 | 17 | type SlidingWindowService struct { 18 | redisClient *redis.ClusterClient 19 | } 20 | 21 | func NewSlidingWindowService(redisClient *redis.ClusterClient) SlidingWindowService { 22 | return SlidingWindowService{ 23 | redisClient: redisClient, 24 | } 25 | } 26 | 27 | func (s *SlidingWindowService) processRequest(ip, endpoint string, rule *models.Rule) *models.RateLimitResponse { 28 | key := ip + ":" + endpoint 29 | 30 | now := time.Now().Unix() 31 | windowSize := time.Duration(rule.SlidingWindowCounterRule.WindowSize) * time.Second 32 | 33 | count, err := s.removeOldRequestsAndCountActiveRequests(key, now, windowSize) 34 | if err != nil { 35 | return utils.BuildRateLimitErrorResponse(500) 36 | } 37 | 38 | if count > rule.SlidingWindowCounterRule.MaxRequests { 39 | return utils.BuildRateLimitErrorResponse(429) 40 | } 41 | 42 | err = s.updateWindow(key, now, windowSize) 43 | if err != nil { 44 | return utils.BuildRateLimitErrorResponse(500) 45 | } 46 | 47 | return utils.BuildRateLimitSuccessResponse(rule.SlidingWindowCounterRule.MaxRequests, rule.SlidingWindowCounterRule.MaxRequests-count) 48 | } 49 | 50 | func (s *SlidingWindowService) removeOldRequestsAndCountActiveRequests(key string, now int64, windowSize time.Duration) (int64, error) { 51 | then := fmt.Sprintf("%d", now-int64(windowSize.Seconds())) 52 | 53 | pipe := s.redisClient.TxPipeline() 54 | pipe.ZRemRangeByScore(ctx, key, "0", then) 55 | 56 | countCmd := pipe.ZCount(ctx, key, then, fmt.Sprintf("%d", now)) 57 | 58 | _, err := pipe.Exec(ctx) 59 | if err != nil { 60 | return -1, err 61 | } 62 | 63 | count, err := countCmd.Result() 64 | if err != nil { 65 | return -1, err 66 | } 67 | return count, nil 68 | } 69 | 70 | func (s *SlidingWindowService) updateWindow(key string, now int64, windowSize time.Duration) error { 71 | pipe := s.redisClient.TxPipeline() 72 | 73 | pipe.ZAdd(ctx, key, redis.Z{ 74 | Member: now, 75 | Score: float64(now), 76 | }) 77 | 78 | pipe.Expire(ctx, key, windowSize) 79 | 80 | _, err := pipe.Exec(ctx) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /rate_shield/limiter/token_bucket.go: -------------------------------------------------------------------------------- 1 | package limiter 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "github.com/go-co-op/gocron/v2" 12 | "github.com/rs/zerolog/log" 13 | "github.com/x-sushant-x/RateShield/models" 14 | redisClient "github.com/x-sushant-x/RateShield/redis" 15 | "github.com/x-sushant-x/RateShield/service" 16 | "github.com/x-sushant-x/RateShield/utils" 17 | ) 18 | 19 | const ( 20 | BucketExpireTime = time.Second * 60 21 | ) 22 | 23 | type TokenBucketService struct { 24 | redisClient redisClient.RedisRateLimiterClient 25 | errorNotificationSVC service.ErrorNotificationSVC 26 | } 27 | 28 | func NewTokenBucketService(client redisClient.RedisRateLimiterClient, errorNotificationSVC service.ErrorNotificationSVC) TokenBucketService { 29 | return TokenBucketService{ 30 | redisClient: client, 31 | errorNotificationSVC: errorNotificationSVC, 32 | } 33 | } 34 | 35 | func (t *TokenBucketService) addTokensToBucket(key string) { 36 | bucket, found, err := t.getBucket(key) 37 | if err != nil { 38 | t.sendGetBucketErrorNotification(key, err) 39 | return 40 | } 41 | 42 | if !found { 43 | return 44 | } 45 | 46 | // How many seconds have passed since last refill 47 | refilledAgo := time.Since(bucket.LastRefill).Seconds() 48 | 49 | if refilledAgo >= float64(bucket.RetentionTime) { 50 | // Remove bucket from redis 51 | err := t.redisClient.Delete(key) 52 | if err != nil { 53 | t.sendDeleteBucketErrorNotification(key, bucket, err) 54 | } 55 | 56 | return 57 | } 58 | 59 | if bucket.AvailableTokens < bucket.Capacity { 60 | tokensToAdd := bucket.Capacity - bucket.AvailableTokens 61 | 62 | if tokensToAdd > 0 { 63 | bucket.AvailableTokens += min(bucket.TokenAddRate, tokensToAdd) 64 | bucket.LastRefill = time.Now() 65 | 66 | if err := t.redisClient.JSONSet(key, bucket); err != nil { 67 | t.sendSetBucketErrorNotification(key, bucket, err) 68 | } 69 | } 70 | } 71 | } 72 | 73 | func (t *TokenBucketService) createBucket(ip, endpoint string, capacity, tokenAddRate int, retentionTime int16) (*models.Bucket, error) { 74 | if err := utils.ValidateCreateBucketReq(ip, endpoint, capacity, tokenAddRate); err != nil { 75 | return nil, err 76 | } 77 | 78 | b := &models.Bucket{ 79 | ClientIP: ip, 80 | CreatedAt: time.Now().Unix(), 81 | Capacity: capacity, 82 | AvailableTokens: capacity, 83 | Endpoint: endpoint, 84 | TokenAddRate: tokenAddRate, 85 | LastRefill: time.Now(), 86 | RetentionTime: retentionTime, 87 | } 88 | 89 | err := t.saveBucket(b, true) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | return b, nil 95 | } 96 | 97 | func (t *TokenBucketService) createBucketFromRule(ip, endpoint string, rule *models.Rule) (*models.Bucket, error) { 98 | b, err := t.createBucket(ip, endpoint, int(rule.TokenBucketRule.BucketCapacity), int(rule.TokenBucketRule.TokenAddRate), rule.TokenBucketRule.RetentionTime) 99 | if err != nil { 100 | return nil, err 101 | } 102 | return b, nil 103 | } 104 | 105 | func parseKey(key string) (string, string, error) { 106 | parts := strings.Split(key, ":") 107 | if len(parts) != 2 { 108 | return "", "", errors.New("invalid token bucket key") 109 | } 110 | 111 | return parts[0], parts[1], nil 112 | } 113 | 114 | func (t *TokenBucketService) spawnNewBucket(key string, rule *models.Rule) (*models.Bucket, error) { 115 | ip, endpoint, err := parseKey(key) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | return t.createBucketFromRule(ip, endpoint, rule) 121 | } 122 | 123 | func (t *TokenBucketService) getBucket(key string) (*models.Bucket, bool, error) { 124 | key = "token_bucket_" + key 125 | data, found, err := t.redisClient.JSONGet(key) 126 | if err != nil { 127 | log.Error().Err(err).Msg("Error fetching bucket from Redis") 128 | return nil, false, err 129 | } 130 | 131 | if !found { 132 | return nil, false, nil 133 | } 134 | 135 | tokenBucket, err := utils.Unmarshal[models.Bucket]([]byte(data)) 136 | if err != nil { 137 | return nil, false, err 138 | } 139 | 140 | return &tokenBucket, true, nil 141 | } 142 | 143 | func (t *TokenBucketService) addTokens() { 144 | ctx := context.TODO() 145 | keys, err := redisClient.TokenBucketClient.Keys(ctx, "token_bucket_*").Result() 146 | if err != nil { 147 | log.Error().Err(err).Msg("Unable to get Redis keys") 148 | return 149 | } 150 | 151 | log.Info().Msgf("Total Keys: %d", len(keys)) 152 | 153 | for _, key := range keys { 154 | t.addTokensToBucket(key) 155 | } 156 | 157 | } 158 | 159 | func (t *TokenBucketService) processRequest(key string, rule *models.Rule) *models.RateLimitResponse { 160 | bucket, found, err := t.getBucket(key) 161 | if err != nil { 162 | log.Error().Msgf("error while getting bucket %s" + err.Error()) 163 | return utils.BuildRateLimitErrorResponse(500) 164 | } 165 | 166 | if !found { 167 | b, err := t.spawnNewBucket(key, rule) 168 | if err != nil { 169 | return utils.BuildRateLimitErrorResponse(500) 170 | } 171 | bucket = b 172 | } 173 | 174 | if bucket.AvailableTokens <= 0 { 175 | return utils.BuildRateLimitErrorResponse(429) 176 | } 177 | 178 | bucket.AvailableTokens-- 179 | 180 | if err := t.saveBucket(bucket, false); err != nil { 181 | return utils.BuildRateLimitErrorResponse(500) 182 | } 183 | 184 | return &models.RateLimitResponse{ 185 | RateLimit_Limit: int64(bucket.Capacity), 186 | RateLimit_Remaining: int64(bucket.AvailableTokens), 187 | Success: true, 188 | HTTPStatusCode: http.StatusOK, 189 | } 190 | } 191 | 192 | func (t *TokenBucketService) saveBucket(bucket *models.Bucket, isNewBucket bool) error { 193 | key := "token_bucket_" + bucket.ClientIP + ":" + bucket.Endpoint 194 | if err := t.redisClient.JSONSet(key, bucket); err != nil { 195 | log.Error().Err(err).Msg("Error saving new bucket to Redis") 196 | return err 197 | } 198 | 199 | if isNewBucket { 200 | expiration := time.Duration(bucket.RetentionTime) * time.Second 201 | if err := t.redisClient.Expire(key, expiration); err != nil { 202 | log.Error().Err(err).Msg("Failed to set TTL on bucket key") 203 | return err 204 | } 205 | } 206 | 207 | return nil 208 | } 209 | 210 | func (t *TokenBucketService) startAddTokenJob() { 211 | s, err := gocron.NewScheduler() 212 | if err != nil { 213 | panic(err) 214 | } 215 | 216 | _, err = s.NewJob(gocron.DurationJob(TokenAddTime), gocron.NewTask(func() { 217 | t.addTokens() 218 | })) 219 | 220 | if err != nil { 221 | panic(err) 222 | } 223 | 224 | s.Start() 225 | } 226 | 227 | func (t *TokenBucketService) sendGetBucketErrorNotification(key string, err error) { 228 | customError := fmt.Sprintf("Unable to get bucket with key: %s got error: %s", key, err.Error()) 229 | t.errorNotificationSVC.SendErrorNotification(customError, time.Now(), "Nil", "Nil", models.Rule{}) 230 | log.Error().Err(err).Msg("Error fetching bucket") 231 | } 232 | 233 | func (t *TokenBucketService) sendSetBucketErrorNotification(key string, bucket *models.Bucket, err error) { 234 | customError := fmt.Sprintf("Unable save bucket with key: %s and data: %+v got error: %s", key, bucket, err.Error()) 235 | t.errorNotificationSVC.SendErrorNotification(customError, time.Now(), "Nil", "Nil", models.Rule{}) 236 | log.Error().Err(err).Msg("Error saving updated bucket to Redis") 237 | } 238 | 239 | func (t *TokenBucketService) sendDeleteBucketErrorNotification(key string, bucket *models.Bucket, err error) { 240 | customError := fmt.Sprintf("Unable save bucket with key: %s and data: %+v got error: %s", key, bucket, err.Error()) 241 | t.errorNotificationSVC.SendErrorNotification(customError, time.Now(), "Nil", "Nil", models.Rule{}) 242 | log.Error().Err(err).Msg("Error saving updated bucket to Redis") 243 | } 244 | -------------------------------------------------------------------------------- /rate_shield/limiter/token_bucket_test.go: -------------------------------------------------------------------------------- 1 | package limiter 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/mock" 10 | "github.com/x-sushant-x/RateShield/models" 11 | "github.com/x-sushant-x/RateShield/service" 12 | ) 13 | 14 | type MockRedisRateLimiterClient struct { 15 | mock.Mock 16 | } 17 | 18 | func (m *MockRedisRateLimiterClient) JSONGet(key string) (string, bool, error) { 19 | args := m.Called(key) 20 | return args.String(0), args.Bool(1), args.Error(2) 21 | } 22 | 23 | func (m *MockRedisRateLimiterClient) JSONSet(key string, val interface{}) error { 24 | args := m.Called(key, val) 25 | return args.Error(0) 26 | } 27 | 28 | func (m *MockRedisRateLimiterClient) Expire(key string, expireTime time.Duration) error { 29 | args := m.Called(key, expireTime) 30 | return args.Error(0) 31 | } 32 | 33 | func (m *MockRedisRateLimiterClient) Delete(key string) error { 34 | args := m.Called(key) 35 | return args.Error(0) 36 | } 37 | 38 | func TestTokenBucketService(t *testing.T) { 39 | mockRedis := new(MockRedisRateLimiterClient) 40 | 41 | slackSVC := service.NewSlackService("", "") 42 | errorNotificationSVC := service.NewErrorNotificationSVC(*slackSVC) 43 | 44 | svc := NewTokenBucketService(mockRedis, errorNotificationSVC) 45 | 46 | t.Run("getBucket_success", func(t *testing.T) { 47 | bucketData := `{"available_tokens" : 10}` 48 | 49 | expectedBucket := &models.Bucket{ 50 | AvailableTokens: 10, 51 | } 52 | 53 | mockRedis.On("JSONGet", "token_bucket_test").Return(bucketData, true, nil) 54 | 55 | result, found, err := svc.getBucket("test") 56 | assert.NoError(t, err) 57 | assert.True(t, found) 58 | assert.Equal(t, expectedBucket, result) 59 | 60 | mockRedis.ExpectedCalls = nil 61 | mockRedis.Calls = nil 62 | }) 63 | 64 | t.Run("getBucket_error", func(t *testing.T) { 65 | 66 | mockRedis.On("JSONGet", "token_bucket_test_error").Return("", false, errors.New("redis error")) 67 | 68 | result, found, err := svc.getBucket("test_error") 69 | assert.Nil(t, result) 70 | assert.Error(t, err) 71 | assert.False(t, found) 72 | 73 | mockRedis.ExpectedCalls = nil 74 | mockRedis.Calls = nil 75 | }) 76 | 77 | t.Run("getBucket_not_found", func(t *testing.T) { 78 | 79 | mockRedis.On("JSONGet", "token_bucket_test_not_found").Return("", false, nil) 80 | 81 | result, found, err := svc.getBucket("test_not_found") 82 | assert.Nil(t, result) 83 | assert.NoError(t, err) 84 | assert.False(t, found) 85 | 86 | mockRedis.ExpectedCalls = nil 87 | mockRedis.Calls = nil 88 | }) 89 | 90 | t.Run("getBucket_unmarshal_error", func(t *testing.T) { 91 | bucketData := `{"available_tokens" : "10"}` 92 | 93 | mockRedis.On("JSONGet", "token_bucket_test_unmarshal_error").Return(bucketData, true, nil) 94 | 95 | result, found, err := svc.getBucket("test_unmarshal_error") 96 | assert.Nil(t, result) 97 | assert.Error(t, err) 98 | assert.False(t, found) 99 | 100 | mockRedis.ExpectedCalls = nil 101 | mockRedis.Calls = nil 102 | }) 103 | 104 | t.Run("saveBucket_success", func(t *testing.T) { 105 | bucket := &models.Bucket{ 106 | Endpoint: "/api/v1/get-data", 107 | Capacity: 100, 108 | TokenAddRate: 100, 109 | ClientIP: "192.168.1.23", 110 | CreatedAt: time.Now().Unix(), 111 | AvailableTokens: 100, 112 | } 113 | 114 | mockRedis.On("JSONSet", "token_bucket_192.168.1.23:/api/v1/get-data", bucket).Return(nil) 115 | mockRedis.On("Expire", "token_bucket_192.168.1.23:/api/v1/get-data", time.Second*60).Return(nil) 116 | 117 | err := svc.saveBucket(bucket, true) 118 | assert.NoError(t, err) 119 | 120 | mockRedis.ExpectedCalls = nil 121 | mockRedis.Calls = nil 122 | }) 123 | 124 | t.Run("saveBucket_success", func(t *testing.T) { 125 | bucket := &models.Bucket{ 126 | Endpoint: "/api/v1/get-data", 127 | Capacity: 100, 128 | TokenAddRate: 100, 129 | ClientIP: "192.168.1.23", 130 | CreatedAt: time.Now().Unix(), 131 | AvailableTokens: 100, 132 | } 133 | 134 | mockRedis.On("JSONSet", "token_bucket_192.168.1.23:/api/v1/get-data", bucket).Return(nil) 135 | mockRedis.On("Expire", "token_bucket_192.168.1.23:/api/v1/get-data", time.Second*60).Return(nil) 136 | 137 | err := svc.saveBucket(bucket, true) 138 | assert.NoError(t, err) 139 | 140 | mockRedis.ExpectedCalls = nil 141 | mockRedis.Calls = nil 142 | }) 143 | 144 | t.Run("saveBucket_error", func(t *testing.T) { 145 | bucket := &models.Bucket{ 146 | Endpoint: "/api/v1/get-data", 147 | Capacity: 100, 148 | TokenAddRate: 100, 149 | ClientIP: "192.168.1.23", 150 | CreatedAt: time.Now().Unix(), 151 | AvailableTokens: 100, 152 | } 153 | 154 | mockRedis.On("JSONSet", "token_bucket_192.168.1.23:/api/v1/get-data", bucket).Return(errors.New("redis-error")) 155 | mockRedis.On("Expire", "token_bucket_192.168.1.23:/api/v1/get-data", time.Second*60).Return(nil) 156 | 157 | err := svc.saveBucket(bucket, true) 158 | assert.Error(t, err) 159 | 160 | mockRedis.ExpectedCalls = nil 161 | mockRedis.Calls = nil 162 | }) 163 | 164 | t.Run("saveBucket_expire_error", func(t *testing.T) { 165 | bucket := &models.Bucket{ 166 | Endpoint: "/api/v1/get-data", 167 | Capacity: 100, 168 | TokenAddRate: 100, 169 | ClientIP: "192.168.1.23", 170 | CreatedAt: time.Now().Unix(), 171 | AvailableTokens: 100, 172 | } 173 | 174 | mockRedis.On("JSONSet", "token_bucket_192.168.1.23:/api/v1/get-data", bucket).Return(nil) 175 | mockRedis.On("Expire", "token_bucket_192.168.1.23:/api/v1/get-data", time.Second*60).Return(errors.New("redis-error")) 176 | 177 | err := svc.saveBucket(bucket, false) 178 | assert.Error(t, err) 179 | 180 | mockRedis.ExpectedCalls = nil 181 | mockRedis.Calls = nil 182 | }) 183 | 184 | t.Run("parseKey_success", func(t *testing.T) { 185 | _, _, err := parseKey("192.168.23.1:/api/v1/get-data") 186 | assert.NoError(t, err) 187 | }) 188 | 189 | t.Run("parseKey_error", func(t *testing.T) { 190 | _, _, err := parseKey("") 191 | assert.Error(t, err) 192 | }) 193 | 194 | t.Run("createBucketFromRule_success", func(t *testing.T) { 195 | rule := &models.Rule{ 196 | Strategy: "TOKEN BUCKET", 197 | APIEndpoint: "/api/v1/get-data", 198 | HTTPMethod: "GET", 199 | TokenBucketRule: &models.TokenBucketRule{ 200 | BucketCapacity: 10, 201 | TokenAddRate: 10, 202 | }, 203 | } 204 | 205 | ip := "192.168.12.1" 206 | endpoint := "/api/v1/get-data" 207 | key := "token_bucket_" + ip + ":" + endpoint 208 | 209 | bucket := &models.Bucket{ 210 | Endpoint: "/api/v1/get-data", 211 | Capacity: 10, 212 | TokenAddRate: 10, 213 | ClientIP: "192.168.12.1", 214 | CreatedAt: time.Now().Unix(), 215 | AvailableTokens: 10, 216 | } 217 | 218 | mockRedis.On("JSONSet", key, bucket).Return(nil) 219 | mockRedis.On("Expire", key, time.Second*60).Return(nil) 220 | 221 | _, err := svc.createBucketFromRule(ip, "/api/v1/get-data", rule) 222 | assert.NoError(t, err) 223 | 224 | mockRedis.ExpectedCalls = nil 225 | mockRedis.Calls = nil 226 | }) 227 | 228 | t.Run("createBucketFromRule_error", func(t *testing.T) { 229 | rule := &models.Rule{ 230 | Strategy: "TOKEN BUCKET", 231 | APIEndpoint: "/api/v1/get-data", 232 | HTTPMethod: "GET", 233 | TokenBucketRule: &models.TokenBucketRule{ 234 | BucketCapacity: 10, 235 | TokenAddRate: 10, 236 | }, 237 | } 238 | 239 | ip := "192.168.12.1" 240 | endpoint := "/api/v1/get-data" 241 | key := "token_bucket_" + ip + ":" + endpoint 242 | 243 | bucket := &models.Bucket{ 244 | Endpoint: "/api/v1/get-data", 245 | Capacity: 10, 246 | TokenAddRate: 10, 247 | ClientIP: "192.168.12.1", 248 | CreatedAt: time.Now().Unix(), 249 | AvailableTokens: 10, 250 | } 251 | 252 | mockRedis.On("JSONSet", key, bucket).Return(errors.New("redis-error")) 253 | mockRedis.On("Expire", key, time.Second*60).Return(nil) 254 | 255 | _, err := svc.createBucketFromRule(ip, "/api/v1/get-data", rule) 256 | assert.Error(t, err) 257 | 258 | mockRedis.ExpectedCalls = nil 259 | mockRedis.Calls = nil 260 | }) 261 | } 262 | -------------------------------------------------------------------------------- /rate_shield/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/joho/godotenv" 8 | "github.com/rs/zerolog" 9 | "github.com/rs/zerolog/log" 10 | "github.com/x-sushant-x/RateShield/api" 11 | "github.com/x-sushant-x/RateShield/limiter" 12 | redisClient "github.com/x-sushant-x/RateShield/redis" 13 | "github.com/x-sushant-x/RateShield/service" 14 | ) 15 | 16 | var ( 17 | slackToken string 18 | slackChannelID string 19 | ) 20 | 21 | func init() { 22 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) 23 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 24 | 25 | loadENVFile() 26 | setSlackCredentials() 27 | } 28 | 29 | func main() { 30 | 31 | redisRulesClient, err := redisClient.NewRulesClient() 32 | if err != nil { 33 | log.Fatal().Err(err) 34 | } 35 | 36 | slackSvc := service.NewSlackService(slackToken, slackChannelID) 37 | 38 | errorNotificationSvc := service.NewErrorNotificationSVC(*slackSvc) 39 | 40 | redisRateLimiter, clusterClient, err := redisClient.NewRedisRateLimitClient() 41 | if err != nil { 42 | log.Fatal().Err(err) 43 | } 44 | 45 | tokenBucketSvc := limiter.NewTokenBucketService(redisRateLimiter, errorNotificationSvc) 46 | 47 | fixedWindowSvc := limiter.NewFixedWindowService(redisRateLimiter) 48 | 49 | redisRulesSvc := service.NewRedisRulesService(redisRulesClient) 50 | 51 | slidingWindowSvc := limiter.NewSlidingWindowService(clusterClient) 52 | 53 | limiter := limiter.NewRateLimiterService(&tokenBucketSvc, &fixedWindowSvc, &slidingWindowSvc, redisRulesSvc) 54 | limiter.StartRateLimiter() 55 | 56 | server := api.NewServer(&limiter) 57 | log.Fatal().Err(server.StartServer()) 58 | 59 | } 60 | 61 | func loadENVFile() { 62 | err := godotenv.Load() 63 | if err != nil { 64 | log.Panic().Msgf("error while loading env file: %s", err) 65 | } 66 | } 67 | 68 | func setSlackCredentials() { 69 | sToken := os.Getenv("SLACK_TOKEN") 70 | if len(sToken) == 0 { 71 | log.Panic().Msg("SLACK_TOKEN not available in env file") 72 | } 73 | slackToken = sToken 74 | 75 | sChannel := os.Getenv("SLACK_CHANNEL") 76 | if len(sChannel) == 0 { 77 | log.Panic().Msg("SLACK_CHANNEL not available in env file") 78 | } 79 | slackChannelID = sChannel 80 | } 81 | -------------------------------------------------------------------------------- /rate_shield/models/fixed_window_counter.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type FixedWindowCounter struct { 4 | Endpoint string `json:"endpoint"` 5 | ClientIP string `json:"client_ip"` 6 | CreatedAt int64 `json:"created_at"` 7 | MaxRequests int64 `json:"max_requests"` 8 | CurrRequests int64 `json:"current_requests"` 9 | Window int `json:"window"` 10 | LastAccessTime int64 `json:"last_access_time"` 11 | } 12 | -------------------------------------------------------------------------------- /rate_shield/models/limit.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type RateLimitResponse struct { 4 | RateLimit_Limit int64 5 | RateLimit_Remaining int64 6 | Success bool 7 | HTTPStatusCode int 8 | } 9 | -------------------------------------------------------------------------------- /rate_shield/models/rules.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Rule struct { 4 | Strategy string `json:"strategy"` 5 | APIEndpoint string `json:"endpoint"` 6 | HTTPMethod string `json:"http_method"` 7 | AllowOnError bool `json:"allow_on_error"` 8 | TokenBucketRule *TokenBucketRule `json:"token_bucket_rule,omitempty"` 9 | FixedWindowCounterRule *FixedWindowCounterRule `json:"fixed_window_counter_rule,omitempty"` 10 | SlidingWindowCounterRule *SlidingWindowCounterRule `json:"sliding_window_counter_rule,omitempty"` 11 | } 12 | 13 | type TokenBucketRule struct { 14 | BucketCapacity int64 `json:"bucket_capacity"` 15 | TokenAddRate int64 `json:"token_add_rate"` 16 | RetentionTime int16 `json:"retention_time"` 17 | } 18 | 19 | type FixedWindowCounterRule struct { 20 | MaxRequests int64 `json:"max_requests"` 21 | Window int `json:"window"` 22 | } 23 | 24 | type DeleteRuleDTO struct { 25 | RuleKey string `json:"rule_key"` 26 | } 27 | 28 | type PaginatedRules struct { 29 | PageNumber int `json:"page_number"` 30 | TotalItems int `json:"total_items"` 31 | HasNextPage bool `json:"has_next_page"` 32 | Rules []Rule `json:"rules"` 33 | } 34 | 35 | type SlidingWindowCounterRule struct { 36 | MaxRequests int64 `json:"max_requests"` 37 | WindowSize int `json:"window"` 38 | } 39 | -------------------------------------------------------------------------------- /rate_shield/models/slack.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type SlackMessage struct { 4 | Channel string `json:"channel"` 5 | Text string `json:"text"` 6 | } 7 | -------------------------------------------------------------------------------- /rate_shield/models/token_bucket.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | type Bucket struct { 6 | Endpoint string `json:"endpoint"` 7 | Capacity int `json:"capacity"` 8 | TokenAddRate int `json:"token_add_rate"` 9 | ClientIP string `json:"client_ip"` 10 | CreatedAt int64 `json:"created_at"` 11 | AvailableTokens int `json:"available_tokens"` 12 | LastRefill time.Time `json:"last_refill"` 13 | RetentionTime int16 `json:"retention_time"` // Amount of time to keep inactive bucket in redis (in seconds) 14 | } 15 | 16 | type Buckets struct { 17 | Buckets []Buckets `json:"buckets"` 18 | } 19 | -------------------------------------------------------------------------------- /rate_shield/redis/client.go: -------------------------------------------------------------------------------- 1 | package redisClient 2 | 3 | import ( 4 | "context" 5 | "github.com/redis/go-redis/v9" 6 | "github.com/rs/zerolog/log" 7 | "github.com/x-sushant-x/RateShield/utils" 8 | ) 9 | 10 | var ( 11 | ctx = context.Background() 12 | TokenBucketClient *redis.ClusterClient 13 | ) 14 | 15 | func createNewRedisConnection(addr, password string) (*redis.Client, error) { 16 | conn := redis.NewClient(&redis.Options{ 17 | Addr: addr, 18 | Password: password, 19 | }) 20 | 21 | result, err := conn.Ping(ctx).Result() 22 | if err != nil || result == "" { 23 | log.Fatal().Err(err).Msg("unable to connect to redis rules instance: " + addr) 24 | } 25 | 26 | return conn, nil 27 | } 28 | 29 | func NewRedisRateLimitClient() (RedisRateLimiterClient, *redis.ClusterClient, error) { 30 | clusterURLs := utils.GetRedisClusterURLs() 31 | clusterPassword := utils.GetRedisClusterPassword() 32 | 33 | client := redis.NewClusterClient(&redis.ClusterOptions{ 34 | Password: clusterPassword, 35 | Addrs: clusterURLs, 36 | }) 37 | 38 | result, err := client.Ping(ctx).Result() 39 | if err != nil || result == "" { 40 | log.Fatal().Err(err).Msg("unable to connect to redis or ping result is nil for rate limit cluster") 41 | } 42 | 43 | TokenBucketClient = client 44 | 45 | return RedisRateLimit{ 46 | client: client, 47 | }, client, nil 48 | } 49 | 50 | func NewRulesClient() (RedisRuleClient, error) { 51 | url, password := utils.GetRedisRulesInstanceDetails() 52 | 53 | client, err := createNewRedisConnection(url, password) 54 | if err != nil { 55 | log.Fatal().Err(err).Msg("unable to connect to redis rules instance: " + url) 56 | } 57 | 58 | return RedisRules{ 59 | client: client, 60 | }, nil 61 | } 62 | -------------------------------------------------------------------------------- /rate_shield/redis/interfaces.go: -------------------------------------------------------------------------------- 1 | package redisClient 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/x-sushant-x/RateShield/models" 7 | ) 8 | 9 | type RedisRuleClient interface { 10 | GetRule(key string) (*models.Rule, bool, error) 11 | GetAllRuleKeys() ([]string, bool, error) 12 | SetRule(key string, val interface{}) error 13 | DeleteRule(key string) error 14 | PublishMessage(channel, msg string) error 15 | ListenToRulesUpdate(udpatesChannel chan string) 16 | } 17 | 18 | type RedisRateLimiterClient interface { 19 | JSONSet(key string, val interface{}) error 20 | JSONGet(key string) (string, bool, error) 21 | Expire(key string, expireTime time.Duration) error 22 | Delete(key string) error 23 | } 24 | -------------------------------------------------------------------------------- /rate_shield/redis/rate_limit.go: -------------------------------------------------------------------------------- 1 | package redisClient 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/redis/go-redis/v9" 7 | ) 8 | 9 | type RedisRateLimit struct { 10 | client *redis.ClusterClient 11 | } 12 | 13 | func (r RedisRateLimit) JSONSet(key string, val interface{}) error { 14 | return r.client.JSONSet(ctx, key, ".", val).Err() 15 | } 16 | 17 | func (r RedisRateLimit) JSONGet(key string) (string, bool, error) { 18 | res, err := r.client.JSONGet(ctx, key, ".").Result() 19 | if err == redis.Nil || len(res) == 0 { 20 | return "", false, nil 21 | } else if err != nil { 22 | return "", false, err 23 | } 24 | 25 | return res, true, nil 26 | } 27 | 28 | func (r RedisRateLimit) Expire(key string, expiration time.Duration) error { 29 | return r.client.Expire(ctx, key, expiration).Err() 30 | } 31 | 32 | func (r RedisRateLimit) Delete(key string) error { 33 | return r.client.Del(ctx, key).Err() 34 | } 35 | -------------------------------------------------------------------------------- /rate_shield/redis/rules.go: -------------------------------------------------------------------------------- 1 | package redisClient 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/redis/go-redis/v9" 7 | "github.com/rs/zerolog/log" 8 | "github.com/x-sushant-x/RateShield/models" 9 | ) 10 | 11 | const ( 12 | redisRuleUpdateChannel = "rules-update" 13 | ) 14 | 15 | type RedisRules struct { 16 | client *redis.Client 17 | } 18 | 19 | func (r RedisRules) DeleteRule(key string) error { 20 | err := r.client.Del(ctx, key).Err() 21 | if err != nil { 22 | return err 23 | } 24 | return nil 25 | } 26 | 27 | func (r RedisRules) GetAllRuleKeys() ([]string, bool, error) { 28 | res, err := r.client.Keys(ctx, "*").Result() 29 | if err != nil { 30 | return nil, false, nil 31 | } 32 | 33 | return res, true, nil 34 | } 35 | 36 | func (r RedisRules) GetRule(key string) (*models.Rule, bool, error) { 37 | res, err := r.client.JSONGet(ctx, key).Result() 38 | if err == redis.Nil { 39 | return nil, false, nil 40 | } else if err != nil { 41 | return nil, false, err 42 | } 43 | 44 | var rule models.Rule 45 | err = json.Unmarshal([]byte(res), &rule) 46 | if err != nil { 47 | return nil, false, err 48 | } 49 | 50 | return &rule, true, nil 51 | } 52 | 53 | func (r RedisRules) SetRule(key string, val interface{}) error { 54 | err := r.client.JSONSet(ctx, key, ".", val).Err() 55 | if err != nil { 56 | return err 57 | } 58 | return nil 59 | } 60 | 61 | func (r RedisRules) PublishMessage(channel, msg string) error { 62 | return r.client.Publish(ctx, channel, msg).Err() 63 | } 64 | 65 | func (r RedisRules) ListenToRulesUpdate(updatesChannel chan string) { 66 | pubsub := r.client.Subscribe(ctx, redisRuleUpdateChannel) 67 | defer pubsub.Close() 68 | 69 | for { 70 | msg, err := pubsub.ReceiveMessage(ctx) 71 | if err != nil { 72 | log.Err(err).Msg("Error while listening for rule updates") 73 | continue 74 | } 75 | 76 | if msg.Channel == redisRuleUpdateChannel { 77 | updatesChannel <- "UpdateRules" 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /rate_shield/service/error_notification.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/x-sushant-x/RateShield/models" 8 | "github.com/x-sushant-x/RateShield/utils" 9 | ) 10 | 11 | type ErrorNotificationSVC struct { 12 | slackSVC SlackService 13 | notificationHistory map[string]time.Time 14 | } 15 | 16 | func NewErrorNotificationSVC(slackService SlackService) ErrorNotificationSVC { 17 | return ErrorNotificationSVC{ 18 | slackSVC: slackService, 19 | notificationHistory: make(map[string]time.Time), 20 | } 21 | } 22 | 23 | func (e *ErrorNotificationSVC) SendErrorNotification(systemError string, timestamp time.Time, ip string, endpoint string, rule models.Rule) { 24 | if !e.canSendNotification(ip, endpoint) { 25 | return 26 | } 27 | 28 | ruleString, _ := utils.MarshalJSON(rule) 29 | 30 | notificationString := fmt.Sprintf("Error: %s,\n IP: %s,\n Endpoint: %s,\n Rule: %s,\n Timestamp: %s", systemError, ip, endpoint, ruleString, timestamp) 31 | 32 | e.sendNotification(notificationString) 33 | e.notificationHistory[ip+":"+endpoint] = time.Now() 34 | 35 | } 36 | 37 | func (e *ErrorNotificationSVC) canSendNotification(ip, endpoint string) bool { 38 | key := ip + ":" + endpoint 39 | 40 | lastNotifiedTime, ok := e.notificationHistory[key] 41 | if !ok { 42 | return true 43 | } 44 | 45 | sinceTime := time.Since(lastNotifiedTime) 46 | return sinceTime.Seconds() >= 30 47 | } 48 | 49 | func (e *ErrorNotificationSVC) sendNotification(notification string) { 50 | e.slackSVC.SendSlackMessage(notification) 51 | } 52 | -------------------------------------------------------------------------------- /rate_shield/service/rules.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/rs/zerolog/log" 8 | "github.com/x-sushant-x/RateShield/models" 9 | redisClient "github.com/x-sushant-x/RateShield/redis" 10 | ) 11 | 12 | const ( 13 | redisChannel = "rules-update" 14 | ) 15 | 16 | type RulesService interface { 17 | GetAllRules() ([]models.Rule, error) 18 | GetPaginatedRules(page, items int) (models.PaginatedRules, error) 19 | GetRule(key string) (*models.Rule, bool, error) 20 | SearchRule(searchText string) ([]models.Rule, error) 21 | CreateOrUpdateRule(models.Rule) error 22 | DeleteRule(endpoint string) error 23 | CacheRulesLocally() *map[string]*models.Rule 24 | ListenToRulesUpdate(updatesChannel chan string) 25 | } 26 | 27 | type RulesServiceRedis struct { 28 | redisClient redisClient.RedisRuleClient 29 | } 30 | 31 | func NewRedisRulesService(client redisClient.RedisRuleClient) RulesServiceRedis { 32 | return RulesServiceRedis{ 33 | redisClient: client, 34 | } 35 | } 36 | 37 | func (s RulesServiceRedis) GetRule(key string) (*models.Rule, bool, error) { 38 | return s.redisClient.GetRule(key) 39 | } 40 | 41 | func (s RulesServiceRedis) GetAllRules() ([]models.Rule, error) { 42 | keys, _, err := s.redisClient.GetAllRuleKeys() 43 | if err != nil { 44 | log.Err(err).Msg("unable to get all rule keys from redis") 45 | } 46 | 47 | rules := []models.Rule{} 48 | 49 | for _, key := range keys { 50 | rule, found, err := s.redisClient.GetRule(key) 51 | 52 | if !found { 53 | log.Error().Msgf("rule with key: %s not found", key) 54 | continue 55 | } 56 | 57 | if err != nil { 58 | log.Err(err).Msg("unable to get rule from redis") 59 | continue 60 | } 61 | 62 | rules = append(rules, *rule) 63 | } 64 | 65 | return rules, nil 66 | } 67 | 68 | func (s RulesServiceRedis) SearchRule(searchText string) ([]models.Rule, error) { 69 | rules, err := s.GetAllRules() 70 | if err != nil { 71 | return nil, err 72 | } 73 | searchedRules := []models.Rule{} 74 | 75 | for _, rule := range rules { 76 | if strings.Contains(rule.APIEndpoint, searchText) { 77 | searchedRules = append(searchedRules, rule) 78 | } 79 | } 80 | 81 | return searchedRules, nil 82 | } 83 | 84 | func (s RulesServiceRedis) CreateOrUpdateRule(rule models.Rule) error { 85 | err := s.redisClient.SetRule(rule.APIEndpoint, rule) 86 | if err != nil { 87 | log.Err(err).Msg("unable to create or update rule") 88 | return err 89 | } 90 | 91 | return s.redisClient.PublishMessage(redisChannel, "rule-updated") 92 | } 93 | 94 | func (s RulesServiceRedis) DeleteRule(endpoint string) error { 95 | err := s.redisClient.DeleteRule(endpoint) 96 | if err != nil { 97 | log.Err(err).Msg("unable to create or update rule") 98 | return err 99 | 100 | } 101 | return s.redisClient.PublishMessage(redisChannel, "rule-updated") 102 | } 103 | 104 | func (s RulesServiceRedis) CacheRulesLocally() *map[string]*models.Rule { 105 | rules, err := s.GetAllRules() 106 | if err != nil { 107 | log.Err(err).Msg("Unable to cache all rules locally") 108 | } 109 | 110 | cachedRules := make(map[string]*models.Rule) 111 | 112 | for _, rule := range rules { 113 | cachedRules[rule.APIEndpoint] = &rule 114 | } 115 | 116 | log.Info().Msg("Rules locally cached ✅") 117 | return &cachedRules 118 | } 119 | 120 | func (s RulesServiceRedis) ListenToRulesUpdate(updatesChannel chan string) { 121 | s.redisClient.ListenToRulesUpdate(updatesChannel) 122 | } 123 | 124 | func (s RulesServiceRedis) GetPaginatedRules(page, items int) (models.PaginatedRules, error) { 125 | allRules, err := s.GetAllRules() 126 | if err != nil { 127 | log.Err(err).Msgf("unable to get rules from redis") 128 | return models.PaginatedRules{}, err 129 | } 130 | 131 | if len(allRules) == 0 { 132 | return models.PaginatedRules{ 133 | PageNumber: 1, 134 | TotalItems: 0, 135 | HasNextPage: false, 136 | Rules: make([]models.Rule, 0), 137 | }, nil 138 | } 139 | 140 | start := (page - 1) * items 141 | stop := start + items 142 | 143 | if start >= len(allRules) { 144 | return models.PaginatedRules{}, errors.New("invalid page number") 145 | } 146 | 147 | hasNextPage := stop < len(allRules) 148 | 149 | if stop >= len(allRules) { 150 | stop = len(allRules) 151 | } 152 | 153 | paginatedSlice := allRules[start:stop] 154 | 155 | rules := models.PaginatedRules{ 156 | PageNumber: page, 157 | TotalItems: stop - start, 158 | HasNextPage: hasNextPage, 159 | Rules: paginatedSlice, 160 | } 161 | 162 | return rules, nil 163 | } 164 | -------------------------------------------------------------------------------- /rate_shield/service/slack_messaging.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | 7 | "github.com/rs/zerolog/log" 8 | "github.com/x-sushant-x/RateShield/models" 9 | "github.com/x-sushant-x/RateShield/utils" 10 | ) 11 | 12 | const ( 13 | SLACK_SEND_MESSAGE_ENDPOINT = "https://slack.com/api/chat.postMessage" 14 | ) 15 | 16 | type SlackService struct { 17 | Token string 18 | Channel string 19 | } 20 | 21 | func NewSlackService(token, channel string) *SlackService { 22 | return &SlackService{ 23 | Token: token, 24 | Channel: channel, 25 | } 26 | } 27 | 28 | func (s *SlackService) SendSlackMessage(msg string) error { 29 | message := buildSlackMessageObject(s.Channel, msg) 30 | 31 | messageBytes, err := utils.MarshalJSON(message) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | req, err := http.NewRequest(http.MethodPost, SLACK_SEND_MESSAGE_ENDPOINT, bytes.NewBuffer(messageBytes)) 37 | if err != nil { 38 | log.Err(err).Msgf("error creating request: %s", err) 39 | return err 40 | } 41 | s.setRequestHeaders(req) 42 | 43 | return s.sendRequestToSlackAPI(req) 44 | } 45 | 46 | func buildSlackMessageObject(channel, msg string) models.SlackMessage { 47 | message := models.SlackMessage{ 48 | Channel: channel, 49 | Text: msg, 50 | } 51 | 52 | return message 53 | } 54 | 55 | func (s *SlackService) setRequestHeaders(req *http.Request) { 56 | req.Header.Set("Content-Type", "application/json") 57 | req.Header.Set("Authorization", "Bearer "+s.Token) 58 | } 59 | 60 | func (s *SlackService) sendRequestToSlackAPI(req *http.Request) error { 61 | client := &http.Client{} 62 | resp, err := client.Do(req) 63 | if err != nil { 64 | log.Err(err).Msgf("error sending request: %s", err) 65 | return err 66 | } 67 | 68 | defer resp.Body.Close() 69 | 70 | if resp.StatusCode != http.StatusOK { 71 | log.Err(err).Msgf("received non-OK response from Slack: %s", resp.Status) 72 | return err 73 | } 74 | 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /rate_shield/tests/locust.py: -------------------------------------------------------------------------------- 1 | from locust import FastHttpUser, TaskSet, task, between 2 | import random 3 | 4 | class RateLimiterLoadTest(TaskSet): 5 | @task 6 | def checkLoad(self): 7 | ip = f"192.168.{random.randint(0, 255)}.{random.randint(0, 255)}" 8 | endpoint = "/api/v1/send-otp" 9 | headers = { 10 | "ip" : ip, 11 | "endpoint" : endpoint, 12 | "Accept": "*/*", 13 | "User-Agent": "LocustLoadTest/1.0" 14 | } 15 | self.client.get("/check-limit", headers = headers) 16 | 17 | class User(FastHttpUser): 18 | tasks = [RateLimiterLoadTest] 19 | wait_time = between(0.1, 0.5) -------------------------------------------------------------------------------- /rate_shield/utils/api_request.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | func ParseAPIBody[T any](r *http.Request) (T, error) { 10 | var req T 11 | body, err := io.ReadAll(r.Body) 12 | if err != nil { 13 | return req, err 14 | } 15 | defer r.Body.Close() 16 | 17 | if err := json.Unmarshal(body, &req); err != nil { 18 | return req, err 19 | } 20 | return req, nil 21 | } 22 | -------------------------------------------------------------------------------- /rate_shield/utils/api_responses.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | func InternalError(w http.ResponseWriter, message string) { 9 | msg := map[string]string{ 10 | "status": "fail", 11 | "error": "Internal Server Error", 12 | "message": message, 13 | } 14 | 15 | w.WriteHeader(http.StatusInternalServerError) 16 | bytes, _ := json.Marshal(msg) 17 | w.Write(bytes) 18 | } 19 | 20 | func BadRequestError(w http.ResponseWriter) { 21 | msg := map[string]string{ 22 | "status": "fail", 23 | "error": "Invalid Request Body", 24 | } 25 | 26 | w.WriteHeader(http.StatusInternalServerError) 27 | bytes, _ := json.Marshal(msg) 28 | w.Write(bytes) 29 | } 30 | 31 | func MethodNotAllowedError(w http.ResponseWriter) { 32 | msg := map[string]string{ 33 | "status": "fail", 34 | "error": "Method Not Allowed", 35 | } 36 | 37 | w.WriteHeader(http.StatusMethodNotAllowed) 38 | bytes, _ := json.Marshal(msg) 39 | w.Write(bytes) 40 | } 41 | 42 | func SuccessResponse(data interface{}, w http.ResponseWriter) { 43 | msg := map[string]interface{}{ 44 | "status": "success", 45 | "data": data, 46 | } 47 | 48 | w.WriteHeader(http.StatusOK) 49 | bytes, _ := json.Marshal(msg) 50 | w.Write(bytes) 51 | } 52 | -------------------------------------------------------------------------------- /rate_shield/utils/env.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func GetApplicationEnviroment() string { 11 | appEnv := os.Getenv("ENV") 12 | 13 | if len(appEnv) != 0 { 14 | return appEnv 15 | } 16 | 17 | return "dev" 18 | } 19 | 20 | // (string, string, string) -> URL, Username, Password 21 | func GetRedisRulesInstanceDetails() (string, string) { 22 | url := os.Getenv("REDIS_RULES_INSTANCE_URL") 23 | checkEmptyENV(url, "REDIS_RULES_INSTANCE_URL must be provided in docker run command") 24 | 25 | password := os.Getenv("REDIS_RULES_INSTANCE_PASSWORD") 26 | 27 | return url, password 28 | } 29 | 30 | func GetRedisClusterURLs() []string { 31 | clusterURLs := os.Getenv("REDIS_CLUSTERS_URLS") 32 | checkEmptyENV(clusterURLs, "REDIS_CLUSTERS_URLS not specified in enviroment variables") 33 | 34 | clusterURLsArray := strings.Split(clusterURLs, ",") 35 | checkEmptyENV(clusterURLs, "REDIS_CLUSTERS_URLS is empty in enviroment variables. Specify comma seperated urls.") 36 | 37 | return clusterURLsArray 38 | } 39 | func GetRedisClusterPassword() string { 40 | password := os.Getenv("REDIS_CLUSTER_PASSWORD") 41 | return password 42 | } 43 | 44 | func checkEmptyENV(Var string, message string) { 45 | if len(Var) == 0 { 46 | log.Fatal().Msg(message) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /rate_shield/utils/errors.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrorNegativeAddTokenRate = errors.New("invalid token add rate. Must be greater than 0") 7 | ErrorZeroCapacity = errors.New("invalid token capacity. Must be greater than 0") 8 | ) 9 | 10 | var ( 11 | ErrorInvalidIP = errors.New("invalid IP Address. Make sure it's not empty") 12 | ErrorInvalidEndpoint = errors.New("invalid API Endpoint. Make sure it's not empty") 13 | ) 14 | -------------------------------------------------------------------------------- /rate_shield/utils/json.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | func MarshalJSON(data any) ([]byte, error) { 10 | dataBytes, err := json.Marshal(data) 11 | if err != nil { 12 | log.Err(err).Msgf("unable to marshal %v", data) 13 | } 14 | 15 | return dataBytes, nil 16 | } 17 | 18 | func Unmarshal[T any](data []byte) (T, error) { 19 | var res T 20 | err := json.Unmarshal(data, &res) 21 | if err != nil { 22 | log.Err(err).Msgf("unable to unmarshal %s", string(data)) 23 | return res, err 24 | } 25 | 26 | return res, nil 27 | } 28 | -------------------------------------------------------------------------------- /rate_shield/utils/limit.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/x-sushant-x/RateShield/models" 7 | ) 8 | 9 | func BuildRateLimitErrorResponse(statusCode int) *models.RateLimitResponse { 10 | return &models.RateLimitResponse{ 11 | RateLimit_Limit: -1, 12 | RateLimit_Remaining: -1, 13 | Success: false, 14 | HTTPStatusCode: statusCode, 15 | } 16 | } 17 | 18 | func BuildRateLimitSuccessResponse(limit, remaining int64) *models.RateLimitResponse { 19 | return &models.RateLimitResponse{ 20 | RateLimit_Limit: limit, 21 | RateLimit_Remaining: remaining, 22 | Success: true, 23 | HTTPStatusCode: http.StatusOK, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /rate_shield/utils/limit_validator.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func ValidateLimitRequest(ip, endpoint string) error { 4 | if len(ip) == 0 { 5 | return ErrorInvalidIP 6 | } 7 | 8 | if len(endpoint) == 0 { 9 | return ErrorInvalidEndpoint 10 | } 11 | 12 | return nil 13 | } 14 | -------------------------------------------------------------------------------- /rate_shield/utils/token_bucket_validations.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func ValidateCreateBucketReq(ip, endpoint string, capacity, tokenAddRate int) error { 4 | if len(ip) == 0 { 5 | return ErrorInvalidIP 6 | } 7 | 8 | if len(endpoint) == 0 { 9 | return ErrorInvalidEndpoint 10 | } 11 | 12 | if capacity <= 0 { 13 | return ErrorZeroCapacity 14 | 15 | } 16 | 17 | if tokenAddRate <= 0 { 18 | return ErrorNegativeAddTokenRate 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /web/.env: -------------------------------------------------------------------------------- 1 | VITE_RATE_SHIELD_BACKEND_BASE_URL=http://localhost:8080 -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:23-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN npm install 8 | 9 | ENV VITE_RATE_SHIELD_BACKEND_BASE_URL=${VITE_RATE_SHIELD_BACKEND_BASE_URL} 10 | ENV PORT=${PORT} 11 | 12 | EXPOSE $PORT 13 | 14 | CMD [ "npm", "run", "dev" ] -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /web/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config({ 8 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 9 | files: ['**/*.{ts,tsx}'], 10 | ignores: ['dist'], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | globals: globals.browser, 14 | }, 15 | plugins: { 16 | 'react-hooks': reactHooks, 17 | 'react-refresh': reactRefresh, 18 | }, 19 | rules: { 20 | ...reactHooks.configs.recommended.rules, 21 | 'react-refresh/only-export-components': [ 22 | 'warn', 23 | { allowConstantExport: true }, 24 | ], 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Rate Shield 8 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web_app", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@fontsource/poppins": "^5.0.14", 14 | "@fortawesome/free-solid-svg-icons": "^6.6.0", 15 | "@fortawesome/react-fontawesome": "^0.2.2", 16 | "axios": "^1.8.4", 17 | "dotenv": "^16.4.5", 18 | "react": "^18.3.1", 19 | "react-dom": "^18.3.1", 20 | "react-hot-toast": "^2.4.1", 21 | "react-router-dom": "^6.26.0" 22 | }, 23 | "devDependencies": { 24 | "@eslint/js": "^9.8.0", 25 | "@types/node": "^22.9.1", 26 | "@types/react": "^18.3.3", 27 | "@types/react-dom": "^18.3.0", 28 | "@vitejs/plugin-react": "^4.3.1", 29 | "autoprefixer": "^10.4.20", 30 | "eslint": "^9.8.0", 31 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 32 | "eslint-plugin-react-refresh": "^0.4.9", 33 | "globals": "^15.9.0", 34 | "postcss": "^8.4.41", 35 | "tailwindcss": "^3.4.9", 36 | "typescript": "^5.5.3", 37 | "typescript-eslint": "^8.0.0", 38 | "vite": "^5.4.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /web/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import Dashboard from "./pages/Dashboard" 2 | 3 | function App() { 4 | return () 5 | } 6 | 7 | export default App -------------------------------------------------------------------------------- /web/src/api/rules.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const baseUrl = import.meta.env.VITE_RATE_SHIELD_BACKEND_BASE_URL; 4 | 5 | export interface rule { 6 | strategy: string; 7 | endpoint: string; 8 | http_method: string; 9 | fixed_window_counter_rule: fixedWindowCounterRule | null; 10 | sliding_window_counter_rule: slidingWindowCounterRule | null; 11 | token_bucket_rule: tokenBucketRule | null; 12 | allow_on_error: boolean; 13 | } 14 | 15 | export interface paginatedRules { 16 | page: number; 17 | total_items: number; 18 | has_next_page: boolean; 19 | rules: rule[]; 20 | status: string; 21 | } 22 | 23 | export interface paginatedRulesResponse { 24 | data: paginatedRules; 25 | status: string; 26 | } 27 | 28 | export interface fixedWindowCounterRule { 29 | max_requests: number; 30 | window: number; 31 | } 32 | 33 | export interface slidingWindowCounterRule { 34 | max_requests: number; 35 | window: number; 36 | } 37 | 38 | 39 | export interface tokenBucketRule { 40 | bucket_capacity: number; 41 | token_add_rate: number; 42 | retention_time: number; 43 | } 44 | 45 | interface getAllRuleResponse { 46 | data: rule[]; 47 | status: string; 48 | } 49 | 50 | export async function getAllRules(): Promise { 51 | const url = `${baseUrl}/rule/list`; 52 | 53 | try { 54 | const response = await fetch(url, { 55 | method: "GET", 56 | }); 57 | 58 | if (!response.ok) { 59 | throw new Error(`HTTP error! Status: ${response.status}`); 60 | } 61 | 62 | const data: getAllRuleResponse = await response.json(); 63 | console.log("Response", data); 64 | 65 | return data.data; 66 | } catch (error) { 67 | console.error("Failed to fetch rules:", error); 68 | throw error; 69 | } 70 | } 71 | 72 | export async function getPaginatedRules( 73 | pageNumber: number, 74 | ): Promise { 75 | const url = `${baseUrl}/rule/list?page=${pageNumber}&items=10`; 76 | 77 | try { 78 | const response = await fetch(url, { 79 | method: "GET", 80 | }); 81 | 82 | if (!response.ok) { 83 | throw new Error(`HTTP error! Status: ${response.status}`); 84 | } 85 | 86 | const data: paginatedRulesResponse = await response.json(); 87 | 88 | if(data.data.rules.length === 0) { 89 | throw new Error("No rules found. Start by creating one.") 90 | } 91 | 92 | return data; 93 | } catch (error) { 94 | console.error("Failed to fetch rules:", error); 95 | throw error; 96 | } 97 | } 98 | 99 | export async function searchRulesViaEndpoint( 100 | searchText: string, 101 | ): Promise { 102 | const url = `${baseUrl}/rule/search?endpoint=${searchText}`; 103 | 104 | try { 105 | const response = await fetch(url, { 106 | method: "GET", 107 | }); 108 | 109 | if (!response.ok) { 110 | throw new Error(`HTTP error! Status: ${response.status}`); 111 | } 112 | 113 | const data: getAllRuleResponse = await response.json(); 114 | console.log("Response", data); 115 | 116 | return data.data; 117 | } catch (error) { 118 | console.error("Failed to fetch rules:", error); 119 | throw error; 120 | } 121 | } 122 | 123 | export async function createNewRule(rule: rule) { 124 | const url = `${baseUrl}/rule/add`; 125 | 126 | try { 127 | console.log("URL: " + url) 128 | const response = await axios.post(url, JSON.stringify(rule), { 129 | headers: { 130 | "Content-Type" : "application/json" 131 | } 132 | }) 133 | 134 | 135 | // const response = await fetch(url, { 136 | // method: "POST", 137 | // headers: { 138 | // "Content-Type": "application/json", 139 | // }, 140 | // body: JSON.stringify(rule), 141 | // }); 142 | 143 | if (response.status != 200) { 144 | const errorText = await response.data; 145 | throw new Error(errorText); 146 | } 147 | } catch (error) { 148 | console.error("Failed to add rule: ", error); 149 | throw error; 150 | } 151 | } 152 | 153 | export async function deleteRule(ruleKey: string) { 154 | const url = `${baseUrl}/rule/delete`; 155 | 156 | try { 157 | const response = await fetch(url, { 158 | method: "POST", 159 | headers: { 160 | "Content-Type": "application/json", 161 | }, 162 | body: JSON.stringify({ 163 | rule_key: ruleKey, 164 | }), 165 | }); 166 | 167 | if (!response.ok) { 168 | const errorText = await response.text(); 169 | throw new Error(errorText); 170 | } 171 | } catch (error) { 172 | console.error("Failed to delete rule: ", error); 173 | throw error; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /web/src/assets/API.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /web/src/assets/BackArrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/e9f65459f787c54e72cf7031d1664918ad808a9b/web/src/assets/BackArrow.png -------------------------------------------------------------------------------- /web/src/assets/GitHub.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /web/src/assets/Info Squared.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /web/src/assets/LinkedIn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /web/src/assets/Twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /web/src/assets/modify_rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/x-sushant-x/Rate-Shield/e9f65459f787c54e72cf7031d1664918ad808a9b/web/src/assets/modify_rule.png -------------------------------------------------------------------------------- /web/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/components/APIConfigurationHeader.tsx: -------------------------------------------------------------------------------- 1 | import { faSearch } from "@fortawesome/free-solid-svg-icons/faSearch"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { rule } from "../api/rules"; 4 | import { useState } from "react"; 5 | 6 | interface Props { 7 | openAddOrUpdateRuleDialog: (rule: rule | null) => void 8 | setSearchRuleText: (searchText: string) => void 9 | } 10 | 11 | const APIConfigurationHeader: React.FC = ({ openAddOrUpdateRuleDialog, setSearchRuleText }) => { 12 | const [searchedText, setSearchedText] = useState('') 13 | 14 | return ( 15 |
16 |

APIs Configurations

17 | 18 |
19 |
20 | { 24 | setSearchedText(e.target.value) 25 | }} 26 | 27 | /> 28 |
29 | 35 | 36 | 42 |
43 |
44 | ) 45 | } 46 | 47 | export default APIConfigurationHeader -------------------------------------------------------------------------------- /web/src/components/AddOrUpdateRule.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import toast, { Toaster } from "react-hot-toast"; 3 | 4 | import BackArrow from "../assets/BackArrow.png"; 5 | import { 6 | createNewRule, 7 | deleteRule, 8 | fixedWindowCounterRule, 9 | rule, 10 | slidingWindowCounterRule, 11 | tokenBucketRule, 12 | } from "../api/rules"; 13 | import { customToastStyle } from "../utils/toast_styles"; 14 | import { validateNewFixedWindowCounterRule, validateNewRule, validateNewSlidingWindowCounterRule, validateNewTokenBucketRule } from "../utils/validators"; 15 | 16 | interface Props { 17 | closeAddNewRule: () => void; 18 | strategy: string; 19 | action: string; 20 | endpoint?: string; 21 | httpMethod?: string; 22 | fixed_window_counter_rule: fixedWindowCounterRule | null; 23 | token_bucket_rule: tokenBucketRule | null; 24 | sliding_window_counter_rule: slidingWindowCounterRule | null; 25 | allow_on_error: boolean; 26 | } 27 | 28 | const AddOrUpdateRule: React.FC = ({ 29 | closeAddNewRule, 30 | action, 31 | strategy, 32 | endpoint, 33 | httpMethod, 34 | token_bucket_rule, 35 | fixed_window_counter_rule, 36 | sliding_window_counter_rule, 37 | allow_on_error, 38 | }) => { 39 | const [apiEndpoint, setApiEndpoint] = useState(endpoint || ""); 40 | const [limitStrategy, setLimitStrategy] = useState(strategy); 41 | const [method, setHttpMethod] = useState(httpMethod || "GET"); 42 | const [tokenBucket, setTokenBucketRule] = useState(token_bucket_rule); 43 | const [fixedWindowCounter, setFixedWindowCounterRule] = useState(fixed_window_counter_rule); 44 | const [slidingWindowCounter, setSlidingWindowCounterRule] = useState(sliding_window_counter_rule) 45 | const [allowOnError, setAllowOnError] = useState(allow_on_error || false); 46 | 47 | const addOrUpdateRule = async () => { 48 | const newRule: rule = { 49 | endpoint: apiEndpoint, 50 | http_method: method, 51 | strategy: limitStrategy, 52 | fixed_window_counter_rule: fixedWindowCounter, 53 | token_bucket_rule: tokenBucket, 54 | sliding_window_counter_rule: slidingWindowCounter, 55 | allow_on_error: allowOnError, 56 | }; 57 | 58 | 59 | if(!validateNewRule(newRule)) { 60 | console.log("validateNewRule") 61 | return; 62 | } 63 | 64 | if(!validateNewTokenBucketRule(newRule)) { 65 | console.log("validateNewTokenBucketRule") 66 | return; 67 | } 68 | 69 | if(!validateNewFixedWindowCounterRule(newRule)) { 70 | console.log("validateNewFixedWindowCounterRule") 71 | return; 72 | } 73 | 74 | if(!validateNewSlidingWindowCounterRule(newRule)) { 75 | console.log("validateNewSlidingWindowCounterRule") 76 | return; 77 | } 78 | 79 | try { 80 | await createNewRule(newRule); 81 | closeAddNewRule(); 82 | } catch (error) { 83 | toast.error("Unable to save rule: " + error, { 84 | style: customToastStyle, 85 | }); 86 | } 87 | }; 88 | 89 | const handleAllowOnErrorCheckbox = () => { 90 | setAllowOnError(!allowOnError); 91 | }; 92 | 93 | async function deleteExistingRule() { 94 | try { 95 | await deleteRule(apiEndpoint); 96 | closeAddNewRule(); 97 | } catch (error) { 98 | toast.error("Unable to add rule: " + error); 99 | } 100 | } 101 | 102 | return ( 103 |
104 |
105 | { 110 | closeAddNewRule(); 111 | }} 112 | /> 113 |

114 | {action === "ADD" ? "Add Rule" : "Update Rule"} 115 |

116 |
117 | 118 |

API Endpoint

119 | { 126 | setApiEndpoint(e.target.value); 127 | }} 128 | /> 129 | 130 |

131 | 132 | {action === "UPDATE" ? ( 133 |
134 |

Strategy

135 | 138 |
139 | ) : ( 140 |
141 |
142 |

Strategy

143 | 156 |
157 |
158 | )} 159 | 160 |

HTTP Method

161 | 174 | 175 | {limitStrategy === "TOKEN BUCKET" ? ( 176 |
177 |

Bucket Capacity

178 | 183 | setTokenBucketRule({ 184 | bucket_capacity: 185 | Number.parseInt(e.target.value) || 0, 186 | token_add_rate: 187 | tokenBucket?.token_add_rate || 0, 188 | retention_time: 189 | tokenBucket?.retention_time || 0 190 | }) 191 | } 192 | /> 193 | 194 |

Token Add Rate (per minute)

195 | { 200 | setTokenBucketRule({ 201 | token_add_rate: 202 | Number.parseInt(e.target.value) || 0, 203 | bucket_capacity: 204 | tokenBucket?.bucket_capacity || 0, 205 | retention_time: 206 | tokenBucket?.retention_time || 0 207 | }); 208 | }} 209 | /> 210 | 211 |

Retention Time (in seconds)

212 | { 217 | setTokenBucketRule({ 218 | token_add_rate: 219 | tokenBucket?.bucket_capacity || 0, 220 | bucket_capacity: 221 | tokenBucket?.bucket_capacity || 0, 222 | retention_time: 223 | Number.parseInt(e.target.value) || 0 224 | }); 225 | }} 226 | /> 227 |
228 | ) : limitStrategy === "FIXED WINDOW COUNTER" || limitStrategy === "SLIDING WINDOW COUNTER" ? ( 229 |
230 |

Maximum Requests

231 | { 236 | if(limitStrategy === "FIXED WINDOW COUNTER") { 237 | setFixedWindowCounterRule({ 238 | max_requests: Number.parseInt(e.target.value), 239 | window: fixedWindowCounter?.window || 0, 240 | }); 241 | } else if(limitStrategy === "SLIDING WINDOW COUNTER") { 242 | setSlidingWindowCounterRule({ 243 | max_requests: Number.parseInt(e.target.value), 244 | window: fixedWindowCounter?.window || 0, 245 | }); 246 | } 247 | }} 248 | /> 249 | 250 |

Window Time (in seconds)

251 | { 256 | if(limitStrategy === "FIXED WINDOW COUNTER") { 257 | setFixedWindowCounterRule({ 258 | max_requests: 259 | fixedWindowCounter?.max_requests || 0, 260 | window: Number.parseInt(e.target.value) || 0, 261 | }); 262 | } else if(limitStrategy === "SLIDING WINDOW COUNTER") { 263 | setSlidingWindowCounterRule({ 264 | max_requests: 265 | fixedWindowCounter?.max_requests || 0, 266 | window: Number.parseInt(e.target.value) || 0, 267 | }); 268 | } 269 | }} 270 | /> 271 |
272 | ) : ( 273 |
274 | )} 275 | 276 |

277 | 278 | 287 | 288 |
289 | 297 | 298 | {action === "UPDATE" ? ( 299 | 307 | ) : ( 308 |
309 | )} 310 |
311 | 312 | 313 |
314 | ); 315 | }; 316 | 317 | export default AddOrUpdateRule; 318 | -------------------------------------------------------------------------------- /web/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | export function Button({ onClick, text }) { 2 | return ( 3 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /web/src/components/ContentArea.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import About from "../pages/About"; 3 | import APIConfiguration from "../pages/APIConfiguration"; 4 | 5 | interface ContentAreaProps { 6 | selectedPage: string 7 | } 8 | 9 | const openInNewTab = (url: string) => { 10 | window.open(url, "_blank", "noreferrer") 11 | } 12 | 13 | const ContentArea: React.FC = ({ selectedPage }) => { 14 | useEffect(() => { 15 | if (selectedPage === "TWITTER") { 16 | openInNewTab('https://x.com/SushantDhiman17') 17 | } 18 | 19 | if (selectedPage === "LINKEDIN") { 20 | openInNewTab('https://linkedin.com/in/sushant102004') 21 | } 22 | 23 | if (selectedPage === "GITHUB") { 24 | openInNewTab('https://github.com/x-sushant-x') 25 | } 26 | }, [selectedPage]) 27 | 28 | 29 | return ( 30 |
31 | {selectedPage === 'API_CONFIGURATION' && } 32 | {selectedPage === 'ABOUT' && } 33 | {selectedPage === 'TWITTER' && } 34 | {selectedPage === 'LINKEDIN' && } 35 | {selectedPage === 'GITHUB' && } 36 |
37 | ); 38 | }; 39 | 40 | export default ContentArea; 41 | -------------------------------------------------------------------------------- /web/src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import ClipLoader from "react-spinners/ClipLoader"; 2 | 3 | function BasicExample() { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | export default BasicExample; -------------------------------------------------------------------------------- /web/src/components/RulesTable.tsx: -------------------------------------------------------------------------------- 1 | import modifyRule from "../assets/modify_rule.png"; 2 | import { rule } from "../api/rules"; 3 | import { Toaster } from "react-hot-toast"; 4 | import { Button } from "./Button"; 5 | 6 | interface Props { 7 | openAddOrUpdateRuleDialog: (rule: rule | null) => void; 8 | rulesData: rule[] | undefined; 9 | currentPageNumber: number; 10 | hasNextPage: boolean; 11 | setPageNumber: (pageNumber: number) => void; 12 | } 13 | 14 | const RulesTable: React.FC = ({ 15 | openAddOrUpdateRuleDialog, 16 | rulesData, 17 | currentPageNumber, 18 | hasNextPage, 19 | setPageNumber, 20 | }) => { 21 | return ( 22 |
23 | 24 | 25 | 26 | {" "} 29 | {/* Left aligned */} 30 | {" "} 33 | {/* Center aligned */} 34 | {" "} 37 | {/* Center aligned */} 38 | {" "} 41 | {/* Center aligned */} 42 | 43 | 44 | 45 | {rulesData === undefined ? ( 46 |
Unable to fetch rules.
47 | ) : ( 48 | rulesData.map((item, index) => ( 49 | <> 50 | 51 | 57 | {" "} 63 | {/* Center aligned */} 64 | {" "} 70 | {/* Center aligned */} 71 | 87 | 88 | 89 | 90 | 93 | 94 | 95 | )) 96 | )} 97 | 98 |
27 | Endpoint 28 | 31 | Method 32 | 35 | Strategy 36 | 39 | Modify 40 |
55 | {item.endpoint} 56 | 61 | {item.http_method} 62 | 68 | {item.strategy} 69 | 75 |
76 | { 80 | openAddOrUpdateRuleDialog( 81 | item, 82 | ); 83 | }} 84 | /> 85 |
86 |
91 |
92 |
99 | 100 |
101 | {currentPageNumber > 1 && hasNextPage ? ( 102 |
103 |
117 | ) : currentPageNumber > 1 && !hasNextPage ? ( 118 |
119 |
126 | ) : currentPageNumber == 1 && hasNextPage ? ( 127 |
128 |
135 | ) : ( 136 |
137 | )} 138 |
139 | 140 | 141 |
142 | ); 143 | }; 144 | 145 | export default RulesTable; 146 | -------------------------------------------------------------------------------- /web/src/components/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import logo from './../assets/logo.svg' 2 | import apiIcon from './../assets/API.svg' 3 | import infoIcon from './../assets/Info Squared.svg' 4 | import githubIcon from './../assets/GitHub.svg' 5 | import twitterIcon from './../assets/Twitter.svg' 6 | import linkedinIcon from './../assets/LinkedIn.svg' 7 | 8 | interface SidebarProps { 9 | onSelectPage: (page: string) => void 10 | } 11 | 12 | const Sidebar: React.FC = ({ onSelectPage }) => { 13 | return ( 14 |
15 |
16 |
17 | 18 | 19 | 20 |
21 |
    22 | {[ 23 | { label: 'API Configuration', icon: apiIcon, page: 'API_CONFIGURATION' }, 24 | { label: 'About', icon: infoIcon, page: 'ABOUT' }, 25 | { label: 'Follow on X', icon: twitterIcon, page: 'TWITTER' }, 26 | { label: 'Follow on LinkedIn', icon: linkedinIcon, page: 'LINKEDIN' }, 27 | { label: 'Follow on GitHub', icon: githubIcon, page: 'GITHUB' } 28 | ].map((item, index) => ( 29 |
  • onSelectPage(item.page)} 33 | > 34 | {`${item.label} 35 | {item.label} 36 |
  • 37 | ))} 38 |
39 |
40 |
41 | ); 42 | }; 43 | 44 | export default Sidebar; 45 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /web/src/pages/APIConfiguration.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import APIConfigurationHeader from "../components/APIConfigurationHeader"; 3 | import RulesTable from "../components/RulesTable"; 4 | import AddOrUpdateRule from "../components/AddOrUpdateRule"; 5 | import { getPaginatedRules, rule, searchRulesViaEndpoint } from "../api/rules"; 6 | import toast from "react-hot-toast"; 7 | import { customToastStyle } from "../utils/toast_styles"; 8 | 9 | export default function APIConfiguration() { 10 | const [rulesData, setRulesData] = useState(); 11 | const [searchRuleText, setSearchRuleText] = useState(""); 12 | const errorShown = useRef(false); 13 | const [pageNumber, setPageNumber] = useState(1); 14 | const [hasNextPage, setHasNextPage] = useState(false); 15 | 16 | const fetchRules = async () => { 17 | try { 18 | const rules = await getPaginatedRules(pageNumber); 19 | setRulesData(rules.data.rules); 20 | setHasNextPage(rules.data.has_next_page); 21 | 22 | errorShown.current = false; 23 | } catch (error) { 24 | console.error("Failed to fetch rules:", error); 25 | if (errorShown.current === false) { 26 | toast.error(`${error}`, { 27 | style: customToastStyle, 28 | }); 29 | errorShown.current = true; 30 | } 31 | } 32 | }; 33 | 34 | useEffect(() => { 35 | fetchRules(); 36 | }, [pageNumber]); 37 | 38 | useEffect(() => { 39 | const searchRules = async () => { 40 | if (searchRuleText) { 41 | try { 42 | const rules = await searchRulesViaEndpoint(searchRuleText); 43 | setRulesData(rules); 44 | errorShown.current = false; 45 | } catch (error) { 46 | console.error("Failed to fetch rules:", error); 47 | if (errorShown.current === false) { 48 | toast.error("Error: " + error, { 49 | style: customToastStyle, 50 | }); 51 | errorShown.current = true; 52 | } 53 | } 54 | } 55 | }; 56 | searchRules(); 57 | }, [searchRuleText]); 58 | 59 | const [isAddNewRuleDialogOpen, setIsAddRuleDialogOpen] = useState(false); 60 | const [selectedRule, setSelectedRule] = useState(null); 61 | 62 | const openAddOrUpdateRuleDialog = (rule: rule | null) => { 63 | setSelectedRule(rule); 64 | setIsAddRuleDialogOpen(true); 65 | }; 66 | 67 | const closeAddNewRuleDialog = () => { 68 | setIsAddRuleDialogOpen(false); 69 | setSelectedRule(null); 70 | window.location.reload(); 71 | }; 72 | 73 | return ( 74 |
75 | 79 | 80 | {isAddNewRuleDialogOpen ? ( 81 | 94 | ) : ( 95 | 102 | )} 103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /web/src/pages/About.tsx: -------------------------------------------------------------------------------- 1 | const About = () => { 2 | return ( 3 |
4 |
5 |

About

6 | 7 |
8 |

9 | A completely configurable rate limiter that can apply rate limiting on individual APIs with individual rules. 10 |

11 | 12 | 13 |

🎯 Why?

14 |

15 | Why not? I've got some free time, so I decided to build something. 16 |

17 | 18 |

🌟 Features

19 |
    20 |
  • 21 | 🛠 Customizable Limiting: Apply rate limiting to individual APIs with tailored rules. 22 |
  • 23 |
  • 24 | 🖥️ Dashboard: Manage all your API rules in one place, with a user-friendly dashboard. 25 |
  • 26 |
  • 27 | ⚙️ Plug-and-Play Middleware: Seamless integration with various frameworks, just plug it in and go. 28 |
  • 29 |
30 | 31 |

⚙️ Use Cases

32 |
    33 |
  • 34 | Preventing Abuse: Limit the number of requests an API can handle to prevent abuse or malicious activities. 35 |
  • 36 |
  • 37 | Cost Management: Avoid overages on third-party API calls by rate limiting outgoing requests to those services. 38 |
  • 39 |
40 | 41 |

⚠️ Important

42 |
    43 |
  • 44 | Current Limitation: Only supports Token Bucket Rate Limiting, which may not suit all scenarios. 45 |
  • 46 |
  • 47 | Under Development: This is a hobby project and still in progress. Not recommended for production use—yet! Stay tuned for v1.0. 48 |
  • 49 |
50 |
51 | 52 |
53 |
54 | ) 55 | } 56 | 57 | export default About -------------------------------------------------------------------------------- /web/src/pages/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import ContentArea from "../components/ContentArea" 3 | import Sidebar from "../components/SideBar" 4 | 5 | const Dashboard: React.FC = () => { 6 | const [selectedPage, setSelectedPage] = useState('API_CONFIGURATION') 7 | 8 | return ( 9 |
10 | 11 | 12 |
13 | ) 14 | } 15 | 16 | export default Dashboard -------------------------------------------------------------------------------- /web/src/utils/toast_styles.tsx: -------------------------------------------------------------------------------- 1 | export const customToastStyle = { 2 | background: "#333", 3 | color: "#fff", 4 | padding: "10px 20px", 5 | borderRadius: "8px", 6 | fontSize: "14px", 7 | }; 8 | -------------------------------------------------------------------------------- /web/src/utils/validators.tsx: -------------------------------------------------------------------------------- 1 | import toast from "react-hot-toast"; 2 | import { rule } from "../api/rules"; 3 | import { customToastStyle } from "./toast_styles"; 4 | 5 | export function validateNewRule(newRule: rule) { 6 | if (newRule.endpoint === "" || newRule.endpoint === undefined) { 7 | toast.error("API Endpoint can't be null.", { 8 | style: customToastStyle, 9 | }); 10 | return false; 11 | } 12 | 13 | if ( 14 | newRule.strategy === "" || 15 | newRule.strategy === undefined || 16 | newRule.strategy === "UNDEFINED" 17 | ) { 18 | toast.error("API limit strategy can't be null.", { 19 | style: customToastStyle, 20 | }); 21 | return false; 22 | } 23 | 24 | if (newRule.http_method === "" || newRule.http_method === undefined) { 25 | toast.error("API HTTP Method can't be null.", { 26 | style: customToastStyle, 27 | }); 28 | return false; 29 | } 30 | return true 31 | } 32 | 33 | export function validateNewTokenBucketRule(newRule: rule) { 34 | if (newRule.strategy === "TOKEN BUCKET") { 35 | if ( 36 | newRule.token_bucket_rule?.bucket_capacity === 0 || 37 | !newRule.token_bucket_rule?.bucket_capacity || 38 | newRule.token_bucket_rule.bucket_capacity <= 0 39 | ) { 40 | toast.error("Invalid value for bucket capacity.", { 41 | style: customToastStyle, 42 | }); 43 | return false; 44 | } 45 | 46 | if ( 47 | newRule.token_bucket_rule?.token_add_rate === 0 || 48 | !newRule.token_bucket_rule?.token_add_rate || 49 | newRule.token_bucket_rule.token_add_rate <= 0 50 | ) { 51 | toast.error("Invalid value for bucket capacity.", { 52 | style: customToastStyle, 53 | }); 54 | return false; 55 | } 56 | 57 | if ( 58 | newRule.token_bucket_rule?.token_add_rate > 59 | newRule.token_bucket_rule.bucket_capacity 60 | ) { 61 | toast.error( 62 | "Token add rate should not be more than bucket capacity.", 63 | { 64 | style: customToastStyle, 65 | }, 66 | ); 67 | return false; 68 | } 69 | } 70 | return true 71 | } 72 | 73 | export function validateNewFixedWindowCounterRule(newRule: rule) { 74 | if (newRule.strategy === "FIXED WINDOW COUNTER") { 75 | if ( 76 | newRule.fixed_window_counter_rule?.max_requests === 0 || 77 | !newRule.fixed_window_counter_rule?.max_requests || 78 | newRule.fixed_window_counter_rule?.max_requests <= 0 79 | ) { 80 | toast.error(`Invalid value for maximum requests: ${newRule.fixed_window_counter_rule?.max_requests}`, { 81 | style: customToastStyle, 82 | }); 83 | return false; 84 | } 85 | 86 | if ( 87 | newRule.fixed_window_counter_rule?.window === 0 || 88 | !newRule.fixed_window_counter_rule?.window || 89 | newRule.fixed_window_counter_rule?.window <= 0 90 | ) { 91 | toast.error("Invalid value for window time.", { 92 | style: customToastStyle, 93 | }); 94 | return false; 95 | } 96 | } 97 | return true 98 | } 99 | 100 | export function validateNewSlidingWindowCounterRule(newRule: rule) { 101 | if (newRule.strategy === "SLIDING WINDOW COUNTER") { 102 | if ( 103 | newRule.sliding_window_counter_rule?.max_requests === 0 || 104 | !newRule.sliding_window_counter_rule?.max_requests || 105 | newRule.sliding_window_counter_rule?.max_requests <= 0 106 | ) { 107 | toast.error("Invalid value for maximum requests.", { 108 | style: customToastStyle, 109 | }); 110 | return false; 111 | } 112 | 113 | if ( 114 | newRule.sliding_window_counter_rule?.window === 0 || 115 | !newRule.sliding_window_counter_rule?.window || 116 | newRule.sliding_window_counter_rule?.window <= 0 117 | ) { 118 | toast.error("Invalid value for window time.", { 119 | style: customToastStyle, 120 | }); 121 | return false; 122 | } 123 | } 124 | return true 125 | } -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: { 9 | colors: { 10 | 'global-bg': '#D7D6DF', 11 | 'sidebar-bg': '#131719' 12 | }, 13 | fontFamily: { 14 | poppins: ["Poppins", "sans-serif"], 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | } 20 | 21 | -------------------------------------------------------------------------------- /web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import dotenv from "dotenv" 4 | 5 | 6 | dotenv.config() 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [react()], 11 | server: { 12 | port: parseInt(process.env.PORT || '5173'), 13 | host: true 14 | } 15 | }) 16 | --------------------------------------------------------------------------------