diff --git a/TODO.md b/TODO.md index f15cd82..dd5c440 100644 --- a/TODO.md +++ b/TODO.md @@ -9,7 +9,7 @@ After the point where it reaches feature parity with Venice circa 2006. * ~~Support all customizations that were done with the EMinds instance of Venice.~~ * ~~Gitea-like status page showing Go-specific internals.~~ * Build static Tailwind CSS file rather than using remote-loaded version. (Gate on debug/prod flag) -* Rate limiter. +* ~~Rate limiter.~~ * ~~Better logging configuration.~~ ## Immediate Cleanups Required diff --git a/config/config.go b/config/config.go index 6fa2d96..48cadd6 100644 --- a/config/config.go +++ b/config/config.go @@ -15,6 +15,7 @@ import ( "fmt" "io" "io/fs" + "math" "os" "path/filepath" "reflect" @@ -35,6 +36,9 @@ const AMSTERDAM_COPYRIGHT = "2025-2026" // CONFIGFILE_NAME is the name of the standard configuration file. const CONFIGFILE_NAME = "amsterdam.yaml" +// epsilon is used in testing if a float value is 0. +const epsilon = 1e-9 + // AmCLI is the command-line interface arguments structure. type AmCLI struct { ConfigFile string `arg:"-C,--config,env:AMSTERDAM_CONFIG" help:"Location of the configuration file."` @@ -90,6 +94,11 @@ type AmConfig struct { WelcomeTitle string `yaml:"welcomeTitle"` WelcomeMessage string `yaml:"welcomeMessage"` TopPostsTitle string `yaml:"topPostsTitle"` + RateLimit struct { + Rate float64 `yaml:"rate"` + Burst int `yaml:"burst"` + ExpireMinutes int `yaml:"expireMinutes"` + } `yaml:"rateLimit"` } `yaml:"site"` Database struct { Driver string `yaml:"driver"` @@ -278,6 +287,14 @@ func overlayStructValue(dest, loaded, defaults reflect.Value) { } else { fldDest.Set(fldLoaded) } + } else if fldDest.CanFloat() { + // float field handling + n := fldLoaded.Float() + if math.Abs(n) <= epsilon { + fldDest.Set(fldDefaults) + } else { + fldDest.Set(fldLoaded) + } } else { // if we see this message, this function needs more work log.Errorf("*** unable to deal with field %s of type %s", structField.Name, typ.Name()) diff --git a/config/default.yaml b/config/default.yaml index ef72a43..7ff2112 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -30,6 +30,10 @@ site: Welcome to the Amsterdam Web Communities System. To get the most out of this site, you should log in or create an account, using one of the links above. topPostsTitle: "Latest from the Conferences" + rateLimit: + rate: 20.0 + burst: 0 + expireMinutes: 3 database: driver: "mysql" dsn: "amsdb:x00yes2k@tcp(localhost)/amsterdam?parseTime=true&loc=UTC" diff --git a/errors.go b/errors.go index a8f7b90..28b5492 100644 --- a/errors.go +++ b/errors.go @@ -13,10 +13,14 @@ import ( "errors" "fmt" "net/http" + "time" + "git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/ui" "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" log "github.com/sirupsen/logrus" + "golang.org/x/time/rate" ) // EBUTTON is the standard error for an unknown button. @@ -80,3 +84,33 @@ func AmErrorHandler(err error, c echo.Context) { log.Errorf("Error rendering error (%v): %v", err, cerr) } } + +// rateLimitErrorHandler is called if there's an error getting the identifier for a connection (unlikely). +func rateLimitErrorHandler(c echo.Context, err error) error { + return ui.AmWithTempContext(c, func(ctxt ui.AmContext) (string, any) { + return "error", err + }) +} + +// rateLimitDenyHandler is called if the rate limit is exceeded by a connection. +func rateLimitDenyHandler(c echo.Context, identifier string, err error) error { + return ui.AmWithTempContext(c, func(ctxt ui.AmContext) (string, any) { + ctxt.VarMap().Set("identifier", identifier) + return "ratelimit", err + }) +} + +// AmSetupRateLimiter sets up the rate-limiting middleware. +func AmSetupRateLimiter() echo.MiddlewareFunc { + rcfg := middleware.RateLimiterMemoryStoreConfig{ + Rate: rate.Limit(config.GlobalConfig.Site.RateLimit.Rate), + Burst: config.GlobalConfig.Site.RateLimit.Burst, + ExpiresIn: time.Duration(config.GlobalConfig.Site.RateLimit.ExpireMinutes) * time.Minute, + } + cfg := middleware.RateLimiterConfig{ + Store: middleware.NewRateLimiterMemoryStoreWithConfig(rcfg), + ErrorHandler: rateLimitErrorHandler, + DenyHandler: rateLimitDenyHandler, + } + return middleware.RateLimiterWithConfig(cfg) +} diff --git a/go.mod b/go.mod index eee051f..c79d72b 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/tkuchiki/go-timezone v0.2.3 golang.org/x/net v0.50.0 golang.org/x/text v0.34.0 + golang.org/x/time v0.11.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -34,5 +35,4 @@ require ( golang.org/x/crypto v0.48.0 // indirect golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/time v0.11.0 // indirect ) diff --git a/main.go b/main.go index 538f6cc..dfa11b5 100644 --- a/main.go +++ b/main.go @@ -49,8 +49,11 @@ func setupEcho() *echo.Echo { } e.Use(LogrusMiddleware) + // set up the rate limiter from the configuration + rateLimiter := AmSetupRateLimiter() + // This is the set of all middleware functions used by the UI, as opposed to other things. - uiset := []echo.MiddlewareFunc{ui.SessionStoreInjector, ui.ContextCreator, ui.IPBanTest, ui.CookieLoginTest} + uiset := []echo.MiddlewareFunc{ui.SessionStoreInjector, ui.ContextCreator, rateLimiter, ui.IPBanTest, ui.CookieLoginTest} e.RouteNotFound("/*", ui.AmWrap(AmNotFoundHandler), uiset...) fs, err := config.AmOpenExternalContentPath() diff --git a/ui/render_wrap.go b/ui/render_wrap.go index ab3ae18..e2a5360 100644 --- a/ui/render_wrap.go +++ b/ui/render_wrap.go @@ -88,6 +88,11 @@ func AmSendPageData(ctxt echo.Context, amctxt AmContext, command string, data an httprc = http.StatusForbidden command = "framed" data = "ipban.jet" + case "ratelimit": + amctxt.SetFrameTitle("Rate Limit Exceeded") + httprc = http.StatusTooManyRequests + command = "framed" + data = "ratelimit.jet" } // Process commands. diff --git a/ui/views/ratelimit.jet b/ui/views/ratelimit.jet new file mode 100644 index 0000000..3741be0 --- /dev/null +++ b/ui/views/ratelimit.jet @@ -0,0 +1,29 @@ +{* + * Amsterdam Web Communities System + * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *} +
+ +
+
+ +
+

+ Whoa There, Tiger! +

+
+ + +
+
+

You're hitting this site a little fast, aren't you? Calm down and try again in a bit.

+

Offending identifier is {{ identifier }}.

+
+
+
+
+