diff --git a/backend/server/errors.go b/backend/server/errors.go index 8b9d031..49987a7 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -43,6 +43,8 @@ type APIError struct { Message string `json:"message,omitempty"` Details string `json:"details,omitempty"` + RatelimitReset *int `json:"ratelimit_reset,omitempty"` + // Status is set as the HTTP status code. Status int `json:"-"` } @@ -67,6 +69,7 @@ const ( ErrForbidden = 403 ErrNotFound = 404 ErrMethodNotAllowed = 405 + ErrTooManyRequests = 429 ErrInternalServerError = 500 // catch-all code for unknown errors // Login/authorize error codes @@ -83,6 +86,7 @@ var errCodeMessages = map[int]string{ ErrForbidden: "Forbidden", ErrInternalServerError: "Internal server error", ErrNotFound: "Not found", + ErrTooManyRequests: "Rate limit reached", ErrMethodNotAllowed: "Method not allowed", ErrInvalidState: "Invalid OAuth state", @@ -97,6 +101,7 @@ var errCodeStatuses = map[int]int{ ErrForbidden: http.StatusForbidden, ErrInternalServerError: http.StatusInternalServerError, ErrNotFound: http.StatusNotFound, + ErrTooManyRequests: http.StatusTooManyRequests, ErrMethodNotAllowed: http.StatusMethodNotAllowed, ErrInvalidState: http.StatusBadRequest, diff --git a/backend/server/server.go b/backend/server/server.go index a1a993c..cfc6218 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -3,11 +3,14 @@ package server import ( "net/http" "os" + "strconv" + "time" "codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/u1f320/pronouns.cc/backend/server/auth" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httprate" "github.com/go-chi/render" ) @@ -41,6 +44,33 @@ func New() (*Server, error) { // enable authentication for all routes (but don't require it) s.Router.Use(s.maybeAuth) + // rate limit handling + // - 120 req/minute (2/s) + // - keyed by Authorization header if valid token is provided, otherwise by IP + // - returns rate limit reset info in error + s.Router.Use(httprate.Limit( + 120, time.Minute, + httprate.WithKeyFuncs(func(r *http.Request) (string, error) { + _, ok := ClaimsFromContext(r.Context()) + if token := r.Header.Get("Authorization"); ok && token != "" { + return token, nil + } + + ip, err := httprate.KeyByIP(r) + return ip, err + }), + httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { + reset, _ := strconv.Atoi(w.Header().Get("X-RateLimit-Reset")) + + render.Status(r, http.StatusTooManyRequests) + render.JSON(w, r, APIError{ + Code: ErrTooManyRequests, + Message: errCodeMessages[ErrTooManyRequests], + RatelimitReset: &reset, + }) + }), + )) + // return an API error for not found + method not allowed s.Router.NotFound(func(w http.ResponseWriter, r *http.Request) { render.Status(r, errCodeStatuses[ErrNotFound]) diff --git a/go.mod b/go.mod index c4a248d..d84be58 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/bwmarrin/discordgo v0.25.0 github.com/georgysavva/scany v0.3.0 github.com/go-chi/chi/v5 v5.0.7 + github.com/go-chi/httprate v0.5.3 github.com/go-chi/render v1.0.1 github.com/golang-jwt/jwt/v4 v4.4.1 github.com/jackc/pgconn v1.12.0 @@ -21,6 +22,7 @@ require ( ) require ( + github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/go-gorp/gorp/v3 v3.0.2 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/gorilla/websocket v1.4.2 // indirect diff --git a/go.sum b/go.sum index d0ae145..1045ceb 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,8 @@ github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqO github.com/bwmarrin/discordgo v0.25.0 h1:NXhdfHRNxtwso6FPdzW2i3uBvvU7UIQTghmV2T4nqAs= github.com/bwmarrin/discordgo v0.25.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -95,6 +97,8 @@ github.com/georgysavva/scany v0.3.0/go.mod h1:q8QyrfXjmBk9iJD00igd4lbkAKEXAH/zIY github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/httprate v0.5.3 h1:5HPWb0N6ymIiuotMtCfOGpQKiKeqXVzMexHh1W1yXPc= +github.com/go-chi/httprate v0.5.3/go.mod h1:kYR4lorHX3It9tTh4eTdHhcF2bzrYnCrRNlv5+IBm2M= github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8= github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=