diff --git a/config/.env.sample b/config/.env.sample index b145e5c..10f6066 100644 --- a/config/.env.sample +++ b/config/.env.sample @@ -12,6 +12,20 @@ DB_PORT=5432 # For sqlite, this is the path to the database file DB_DATABASE=config/articpad.db +# Redis settings +# Configuring Redis is optional but highly recommended, if not configured, the application will use an in-memory store +# However, this will not work as expected in a multi-instance setup or even in a single instance setup if preforking is enabled (DEBUG=false) +# REDIS_HOST sets the Redis server host +REDIS_HOST=localhost +# REDIS_PORT sets the Redis server port +REDIS_PORT=6379 +# REDIS_USERNAME sets the Redis server username +REDIS_USERNAME= +# REDIS_PASSWORD sets the Redis server password +REDIS_PASSWORD= +# REDIS_DB sets the Redis database number +REDIS_DB=0 + # Mailer settings # MAIL_HOST sets the SMTP server host MAIL_HOST=localhost @@ -22,7 +36,7 @@ MAIL_USERNAME= # MAIL_PASSWORD sets the SMTP server password MAIL_PASSWORD= # MAIL_FROM sets the from address for emails -MAIL_FROM=ArticPad +MAIL_FROM=ArticPad # MAIL_FORCE_TLS sets whether to force TLS or not # By default (false), TLS is used if the server supports it but is not enforced MAIL_FORCE_TLS=false @@ -30,7 +44,7 @@ MAIL_FORCE_TLS=false # If set to false, mail verification and password reset will be disabled ENABLE_MAIL=false -# DEBUG sets isProduction to false, it enables sending error messages +# DEBUG sets whether the app is running in production or development mode, it enables sending internal error messages # for HTTP requests to the client and disables preforking DEBUG=false # LOG_LEVEL sets the log level for the application @@ -54,3 +68,8 @@ TRUSTED_PROXIES= TEMPLATES_DIR=templates # LOCALES_DIR sets the directory where the language files are located LOCALES_DIR=locales +# By default, the aplication will rate limit auth requests to 40 requests per minute per IP +# You can enable or disable this feature by setting the RATE_LIMIT_AUTH environment variable +# It is recommended to enable this feature or set a rate limit at the reverse proxy level +# (If enabled and Redis is not configured the rate limiting behavior may not work as expected) +RATE_LIMIT_AUTH=true \ No newline at end of file diff --git a/config/config.go b/config/config.go index 2654654..7e6444a 100644 --- a/config/config.go +++ b/config/config.go @@ -25,6 +25,11 @@ var defaults = map[string]string{ "DB_PASSWORD": "", "DB_PORT": "5432", "DB_DATABASE": "config/articpad.db", + "REDIS_HOST": "localhost", + "REDIS_PORT": "6379", + "REDIS_USERNAME": "", + "REDIS_PASSWORD": "", + "REDIS_DB": "0", "MAIL_HOST": "localhost", "MAIL_PORT": "25", "MAIL_USER": "", @@ -42,6 +47,7 @@ var defaults = map[string]string{ "TRUSTED_PROXIES": "", "TEMPLATES_DIR": "templates", "LOCALES_DIR": "locales", + "RATE_LIMIT_AUTH": "true", } // LoadEnv loads the .env file, this should be called before using GetString or GetInt functions diff --git a/go.mod b/go.mod index 53c5e82..d2e3499 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/glebarez/sqlite v1.10.0 github.com/gofiber/fiber/v2 v2.52.1 github.com/gofiber/jwt/v3 v3.3.10 + github.com/gofiber/storage/redis/v3 v3.1.0 github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 @@ -19,6 +20,8 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -33,6 +36,7 @@ require ( github.com/mattn/go-runewidth v0.0.15 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/philhofer/fwd v1.1.2 // indirect + github.com/redis/go-redis/v9 v9.3.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/tinylib/msgp v1.1.9 // indirect diff --git a/go.sum b/go.sum index 41d01a4..50c3dbc 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,15 @@ github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= @@ -16,6 +22,8 @@ github.com/gofiber/fiber/v2 v2.52.1 h1:1RoU2NS+b98o1L77sdl5mboGPiW+0Ypsi5oLmcYlg github.com/gofiber/fiber/v2 v2.52.1/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/gofiber/jwt/v3 v3.3.10 h1:0bpWtFKaGepjwYTU4efHfy0o+matSqZwTxGMo5a+uuc= github.com/gofiber/jwt/v3 v3.3.10/go.mod h1:GJorFVaDyfMPSK9RB8RG4NQ3s1oXKTmYaoL/ny08O1A= +github.com/gofiber/storage/redis/v3 v3.1.0 h1:URly7BB1TVz15vPy2R1Q6vBqI53cDiD6p5qKZX/hpog= +github.com/gofiber/storage/redis/v3 v3.1.0/go.mod h1:HQ2wqleiIwb0Fbssq2T3v5DBiNlDZsCihWOuVmguIbg= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= @@ -57,6 +65,8 @@ github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0= +github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -71,7 +81,7 @@ github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/tinylib/msgp v1.1.9 h1:SHf3yoO2sGA0veCJeCBYLHuttAVFHGm2RHgNodW7wQU= diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 1063f22..405aa4f 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -93,7 +93,7 @@ func (h *AuthHandler) signInUser(c *fiber.Ctx) error { } } - signedToken, err := h.newJWTToken(user.ID.String(), user.Username, c.IP()) + signedToken, err := newJWTToken(user.ID.String(), user.Username, c.IP()) if err != nil { return apierror.NewApiError(fiber.StatusInternalServerError, consts.ErrCodeUnknown, err.Error()) } @@ -166,7 +166,7 @@ func (h *AuthHandler) refreshToken(c *fiber.Ctx) error { jwtData := c.Locals("user").(*jwt.Token) claims := jwtData.Claims.(jwt.MapClaims) - signedToken, err := h.newJWTToken(claims["uid"].(string), claims["user"].(string), claims["user_ip"].(string)) + signedToken, err := newJWTToken(claims["uid"].(string), claims["user"].(string), claims["user_ip"].(string)) if err != nil { return apierror.NewApiError(fiber.StatusInternalServerError, consts.ErrCodeUnknown, err.Error()) } @@ -377,7 +377,7 @@ func (h *AuthHandler) getLangCode(c *fiber.Ctx) string { return h.i18n.ParseLanguage(c.Get("Accept-Language")) } -func (h *AuthHandler) newJWTToken(userId string, username string, userIP string) (string, error) { +func newJWTToken(userId string, username string, userIP string) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwtClaims{ userId, username, diff --git a/internal/infrastructure/fiber.go b/internal/infrastructure/fiber.go index f4bbe17..4ab969f 100644 --- a/internal/infrastructure/fiber.go +++ b/internal/infrastructure/fiber.go @@ -2,6 +2,7 @@ package infrastructure import ( "strings" + "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/compress" @@ -14,21 +15,17 @@ import ( "github.com/jramsgz/articpad/internal/logging" "github.com/jramsgz/articpad/internal/misc" "github.com/jramsgz/articpad/internal/user" - "github.com/jramsgz/articpad/pkg/i18n" - "github.com/jramsgz/articpad/pkg/mail" - "github.com/rs/zerolog" - "gorm.io/gorm" ) // startFiberServer starts the Fiber server. -func startFiberServer(logger zerolog.Logger, db *gorm.DB, mailClient *mail.Mailer, i18n *i18n.I18n) *fiber.App { +func (a *App) startFiberServer() *fiber.App { var trustedProxies []string if config.GetString("TRUSTED_PROXIES") != "" { trustedProxies = strings.Split(config.GetString("TRUSTED_PROXIES"), ",") } var enableProxy bool = len(trustedProxies) > 0 - var isProduction bool = config.GetString("DEBUG") == "false" + app := fiber.New(fiber.Config{ Prefork: isProduction, ServerHeader: "ArticPad Server " + config.Version, @@ -38,8 +35,8 @@ func startFiberServer(logger zerolog.Logger, db *gorm.DB, mailClient *mail.Maile TrustedProxies: trustedProxies, }) - app.Use(logging.Logger(logger, func(c *fiber.Ctx) bool { - return c.Path() == "/health" // skip logging for health check + app.Use(logging.Logger(a.logger, func(c *fiber.Ctx) bool { + return c.Path() == "/health" })) app.Use(cors.New(cors.Config{ MaxAge: 1800, @@ -49,30 +46,37 @@ func startFiberServer(logger zerolog.Logger, db *gorm.DB, mailClient *mail.Maile Level: compress.LevelBestSpeed, // 1 })) app.Use(etag.New()) - app.Use(limiter.New(limiter.Config{ - // TODO: Make this configurable and enable it for auth routes. Also fix that every fork has its own counter by using redis or something. - Max: 100, - LimitReached: func(c *fiber.Ctx) error { - return fiber.NewError(fiber.StatusTooManyRequests, "You have exceeded the maximum number of requests. Please try again later.") - }, - })) + if config.GetString("RATE_LIMIT_AUTH") == "true" { + app.Use(limiter.New(limiter.Config{ + Max: 40, + Expiration: 1 * time.Minute, + LimitReached: func(c *fiber.Ctx) error { + return fiber.NewError(fiber.StatusTooManyRequests, "You have exceeded the maximum number of requests. Please try again later.") + }, + Storage: func() fiber.Storage { + if a.redis != nil { + return a.redis + } + return limiter.ConfigDefault.Storage + }(), + Next: func(c *fiber.Ctx) bool { + return !strings.HasPrefix(c.Path(), "/api/v1/auth") + }, + })) + } - // Create repositories. - userRepository := user.NewUserRepository(db) + userRepository := user.NewUserRepository(a.db) - // Create all of our services. userService := user.NewUserService(userRepository) - // Prepare our endpoints for the API. api := app.Group("/api") apiv1 := api.Group("/v1") misc.NewMiscHandler(apiv1) health.NewHealthHandler(app.Group("/health")) - auth.NewAuthHandler(apiv1.Group("/auth"), userService, mailClient, i18n) + auth.NewAuthHandler(apiv1.Group("/auth"), userService, a.mail, a.i18n) //user.NewUserHandler(apiv1.Group("/users"), userService) - // Prepare an endpoint for 'Not Found'. api.All("*", func(c *fiber.Ctx) error { return c.Status(404).JSON(fiber.Map{ "success": false, @@ -80,14 +84,11 @@ func startFiberServer(logger zerolog.Logger, db *gorm.DB, mailClient *mail.Maile }) }) - // Serve Single Page application on "/" - // assume static file at static folder app.Static("/", config.GetString("STATIC_DIR"), fiber.Static{ Compress: true, MaxAge: 3600, }) - // Panic test route, this brings up an error // TODO: Remove this route in production app.Get("/panic", func(ctx *fiber.Ctx) error { panic("Hi, I'm a panic error!") diff --git a/internal/infrastructure/init.go b/internal/infrastructure/init.go index 3a19f1f..0d8327c 100644 --- a/internal/infrastructure/init.go +++ b/internal/infrastructure/init.go @@ -8,11 +8,24 @@ import ( "time" "github.com/gofiber/fiber/v2" + "github.com/gofiber/storage/redis/v3" "github.com/jramsgz/articpad/config" "github.com/jramsgz/articpad/internal/user" + "github.com/jramsgz/articpad/pkg/i18n" "github.com/jramsgz/articpad/pkg/mail" + "github.com/rs/zerolog" + "gorm.io/gorm" ) +type App struct { + fiber *fiber.App + logger zerolog.Logger + db *gorm.DB + mail *mail.Mailer + i18n *i18n.I18n + redis *redis.Storage +} + // Run ArticPad API & Static Server func Run() { if err := config.LoadEnv(); err != nil { @@ -55,14 +68,37 @@ func Run() { Port: config.GetInt("MAIL_PORT"), Username: config.GetString("MAIL_USERNAME"), Password: config.GetString("MAIL_PASSWORD"), - From: config.GetString("MAIL_FROM", "ArticPad <"+config.GetString("MAIL_USERNAME")+">"), + From: config.GetString("MAIL_FROM"), ForceTLS: config.GetString("MAIL_FORCE_TLS") == "true", }) if err != nil || mailClient == nil { logger.Fatal().Msgf("Mail server connection error: %s", err) } - app := startFiberServer(logger, db, mailClient, i18n) + var redisDB *redis.Storage + go func() { + defer func() { + if err := recover(); err != nil { + logger.Error().Msgf("Redis connection error: %s. Some features may not be available.", err) + } + }() + redisDB = redis.New(redis.Config{ + Host: config.GetString("REDIS_HOST"), + Port: config.GetInt("REDIS_PORT"), + Username: config.GetString("REDIS_USERNAME"), + Password: config.GetString("REDIS_PASSWORD"), + Database: config.GetInt("REDIS_DB"), + }) + }() + + app := &App{ + logger: logger, + db: db, + mail: mailClient, + i18n: i18n, + redis: redisDB, + } + app.fiber = app.startFiberServer() c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) @@ -72,15 +108,15 @@ func Run() { <-c serverShutdown.Add(1) defer serverShutdown.Done() - _ = app.ShutdownWithTimeout(60 * time.Second) + _ = app.fiber.ShutdownWithTimeout(60 * time.Second) }() if !fiber.IsChild() { - logger.Info().Msgf("Starting ArticPad %s with isProduction: %t", config.Version, true) + logger.Info().Msgf("Starting ArticPad %s with isProduction: %t", config.Version, config.GetString("DEBUG") == "false") logger.Info().Msgf("BuildTime: %s | Commit: %s", config.BuildTime, config.Commit) logger.Info().Msgf("Listening on %s", config.GetString("APP_ADDR")) } - if err := app.Listen(config.GetString("APP_ADDR")); err != nil { + if err := app.fiber.Listen(config.GetString("APP_ADDR")); err != nil { logger.Fatal().Err(err).Msg("Error starting server") } @@ -89,11 +125,9 @@ func Run() { serverShutdown.Wait() } - // TODO: Only for main process or every process? - if true { - sqlDB, _ := db.DB() - _ = sqlDB.Close() - _ = logFile.Close() - _ = mailClient.Close() - } + sqlDB, _ := db.DB() + _ = sqlDB.Close() + _ = logFile.Close() + _ = mailClient.Close() + _ = redisDB.Close() } diff --git a/internal/logging/middleware.go b/internal/logging/middleware.go index 9f7ace8..a4f5e2b 100644 --- a/internal/logging/middleware.go +++ b/internal/logging/middleware.go @@ -96,8 +96,7 @@ func createLogFields(c *fiber.Ctx, rid string) *logFields { func handleError(log zerolog.Logger, c *fiber.Ctx, fields *logFields, start time.Time, err error) { if err != nil { - isProduction := config.GetString("DEBUG") == "false" - status, message, code := getErrorMessage(err, isProduction) + status, message, code := getErrorMessage(err) fields.StatusCode = status fields.ErrorCode = code fields.Error = err @@ -121,8 +120,7 @@ func handlePanic(log zerolog.Logger, c *fiber.Ctx, fields *logFields, start time err = fmt.Errorf("%v", rvr) } - isProduction := config.GetString("DEBUG") == "false" - status, message, code := getErrorMessage(err, isProduction) + status, message, code := getErrorMessage(err) fields.StatusCode = status fields.ErrorCode = code fields.Error = err @@ -137,7 +135,8 @@ func handlePanic(log zerolog.Logger, c *fiber.Ctx, fields *logFields, start time } } -func getErrorMessage(err error, isProduction bool) (int, string, string) { +func getErrorMessage(err error) (int, string, string) { + isProduction := config.GetString("DEBUG") == "false" status := fiber.StatusInternalServerError message := "Internal Server Error" code := "unknown_error"