diff --git a/internal/webserver/controller.go b/internal/webserver/controller.go index 22392702..5a404ef5 100644 --- a/internal/webserver/controller.go +++ b/internal/webserver/controller.go @@ -1,12 +1,6 @@ package webserver import ( - "errors" - "fmt" - "log" - - "github.com/gofiber/fiber/v2" - jwtware "github.com/gofiber/jwt/v3" "github.com/spf13/afero" "github.com/svera/coreander/v3/internal/index" "github.com/svera/coreander/v3/internal/metadata" @@ -14,21 +8,15 @@ import ( "github.com/svera/coreander/v3/internal/webserver/controller/document" "github.com/svera/coreander/v3/internal/webserver/controller/highlight" "github.com/svera/coreander/v3/internal/webserver/controller/user" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" "github.com/svera/coreander/v3/internal/webserver/model" "gorm.io/gorm" ) type Controllers struct { - Auth *auth.Controller - Users *user.Controller - Highlights *highlight.Controller - Documents *document.Controller - AllowIfNotLoggedInMiddleware func(c *fiber.Ctx) error - AlwaysRequireAuthenticationMiddleware func(c *fiber.Ctx) error - ConfigurableAuthenticationMiddleware func(c *fiber.Ctx) error - ErrorHandler func(c *fiber.Ctx, err error) error + Auth *auth.Controller + Users *user.Controller + Highlights *highlight.Controller + Documents *document.Controller } func SetupControllers(cfg Config, db *gorm.DB, metadataReaders map[string]metadata.Reader, idx *index.BleveIndexer, sender Sender, appFs afero.Fs) Controllers { @@ -58,99 +46,10 @@ func SetupControllers(cfg Config, db *gorm.DB, metadataReaders map[string]metada UploadDocumentMaxSize: cfg.UploadDocumentMaxSize, } - authController := auth.NewController(usersRepository, sender, authCfg, printers) - usersController := user.NewController(usersRepository, usersCfg) - highlightsController := highlight.NewController(highlightsRepository, usersRepository, sender, cfg.WordsPerMinute, idx) - documentsController := document.NewController(highlightsRepository, sender, idx, metadataReaders, appFs, documentsCfg) - - emailSendingConfigured := true - if _, ok := sender.(*infrastructure.NoEmail); ok { - emailSendingConfigured = false - } - - supportedLanguages := getSupportedLanguages() - - forbidden := func(c *fiber.Ctx) error { - return c.Status(fiber.StatusForbidden).Render("auth/login", fiber.Map{ - "Lang": chooseBestLanguage(c, supportedLanguages), - "Title": "Login", - "Version": c.App().Config().AppName, - "EmailSendingConfigured": emailSendingConfigured, - "SupportedLanguages": supportedLanguages, - }, "layout") - } - return Controllers{ - Auth: authController, - Users: usersController, - Highlights: highlightsController, - Documents: documentsController, - AllowIfNotLoggedInMiddleware: jwtware.New(jwtware.Config{ - SigningKey: cfg.JwtSecret, - SigningMethod: "HS256", - TokenLookup: "cookie:coreander", - SuccessHandler: func(c *fiber.Ctx) error { - return fiber.ErrForbidden - }, - ErrorHandler: func(c *fiber.Ctx, err error) error { - return c.Next() - }, - }), - AlwaysRequireAuthenticationMiddleware: jwtware.New(jwtware.Config{ - SigningKey: cfg.JwtSecret, - SigningMethod: "HS256", - TokenLookup: "cookie:coreander", - SuccessHandler: func(c *fiber.Ctx) error { - c.Locals("Session", jwtclaimsreader.SessionData(c)) - return c.Next() - }, - ErrorHandler: func(c *fiber.Ctx, err error) error { - return forbidden(c) - }, - }), - ConfigurableAuthenticationMiddleware: jwtware.New(jwtware.Config{ - SigningKey: cfg.JwtSecret, - SigningMethod: "HS256", - TokenLookup: "cookie:coreander", - SuccessHandler: func(c *fiber.Ctx) error { - c.Locals("Session", jwtclaimsreader.SessionData(c)) - return c.Next() - }, - ErrorHandler: func(c *fiber.Ctx, err error) error { - err = c.Next() - if cfg.RequireAuth { - return forbidden(c) - } - return err - }, - }), - ErrorHandler: func(c *fiber.Ctx, err error) error { - // Status code defaults to 500 - code := fiber.StatusInternalServerError - // Retrieve the custom status code if it's a *fiber.Error - var e *fiber.Error - if errors.As(err, &e) { - code = e.Code - } - - // Send custom error page - err = c.Status(code).Render( - fmt.Sprintf("errors/%d", code), - fiber.Map{ - "Lang": chooseBestLanguage(c, supportedLanguages), - "Title": "Coreander", - "Session": jwtclaimsreader.SessionData(c), - "Version": c.App().Config().AppName, - }, - "layout") - - if err != nil { - log.Println(err) - // In case the Render fails - return c.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") - } - - return nil - }, + Auth: auth.NewController(usersRepository, sender, authCfg, printers), + Users: user.NewController(usersRepository, usersCfg), + Highlights: highlight.NewController(highlightsRepository, usersRepository, sender, cfg.WordsPerMinute, idx), + Documents: document.NewController(highlightsRepository, sender, idx, metadataReaders, appFs, documentsCfg), } } diff --git a/internal/webserver/controller/auth/edit-password.go b/internal/webserver/controller/auth/edit-password.go index d7a68097..6913c396 100644 --- a/internal/webserver/controller/auth/edit-password.go +++ b/internal/webserver/controller/auth/edit-password.go @@ -3,7 +3,7 @@ package auth import "github.com/gofiber/fiber/v2" func (a *Controller) EditPassword(c *fiber.Ctx) error { - if _, err := a.validateRecoveryAccess(c, c.Query("id")); err != nil { + if _, err := a.validateRecoveryAccess(c.Query("id")); err != nil { return err } diff --git a/internal/webserver/controller/auth/login.go b/internal/webserver/controller/auth/login.go index ccbff48e..1eb1ad66 100644 --- a/internal/webserver/controller/auth/login.go +++ b/internal/webserver/controller/auth/login.go @@ -5,16 +5,13 @@ import ( "strings" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/controller" "github.com/svera/coreander/v3/internal/webserver/infrastructure" ) func (a *Controller) Login(c *fiber.Ctx) error { resetPassword := fmt.Sprintf( - "%s://%s%s/%s/reset-password", - c.Protocol(), - a.config.Hostname, - controller.UrlPort(c.Protocol(), a.config.Port), + "%s/%s/reset-password", + c.Locals("fqdn").(string), c.Params("lang"), ) diff --git a/internal/webserver/controller/auth/request.go b/internal/webserver/controller/auth/request.go index b8661a1b..b8dd9e96 100644 --- a/internal/webserver/controller/auth/request.go +++ b/internal/webserver/controller/auth/request.go @@ -7,7 +7,6 @@ import ( "github.com/gofiber/fiber/v2" "github.com/google/uuid" - "github.com/svera/coreander/v3/internal/webserver/controller" "github.com/svera/coreander/v3/internal/webserver/infrastructure" ) @@ -31,10 +30,8 @@ func (a *Controller) Request(c *fiber.Ctx) error { } recoveryLink := fmt.Sprintf( - "%s://%s%s/%s/reset-password?id=%s", - c.Protocol(), - a.config.Hostname, - controller.UrlPort(c.Protocol(), a.config.Port), + "%s/%s/reset-password?id=%s", + c.Locals("fqdn"), c.Params("lang"), user.RecoveryUUID, ) diff --git a/internal/webserver/controller/auth/update-password.go b/internal/webserver/controller/auth/update-password.go index c533d2b0..72c2040a 100644 --- a/internal/webserver/controller/auth/update-password.go +++ b/internal/webserver/controller/auth/update-password.go @@ -10,7 +10,7 @@ import ( ) func (a *Controller) UpdatePassword(c *fiber.Ctx) error { - user, err := a.validateRecoveryAccess(c, c.FormValue("id")) + user, err := a.validateRecoveryAccess(c.FormValue("id")) if err != nil { return err } @@ -36,7 +36,7 @@ func (a *Controller) UpdatePassword(c *fiber.Ctx) error { return c.Redirect(fmt.Sprintf("/%s/login", c.Params("lang"))) } -func (a *Controller) validateRecoveryAccess(c *fiber.Ctx, recoveryUuid string) (*model.User, error) { +func (a *Controller) validateRecoveryAccess(recoveryUuid string) (*model.User, error) { if _, ok := a.sender.(*infrastructure.NoEmail); ok { return &model.User{}, fiber.ErrNotFound } diff --git a/internal/webserver/controller/controller.go b/internal/webserver/controller/controller.go deleted file mode 100644 index 8338f241..00000000 --- a/internal/webserver/controller/controller.go +++ /dev/null @@ -1,17 +0,0 @@ -package controller - -import "fmt" - -const ( - defaultHttpPort = 80 - defaultHttpsPort = 443 -) - -func UrlPort(protocol string, port int) string { - urlPort := fmt.Sprintf(":%d", port) - if (port == defaultHttpPort && protocol == "http") || - (port == defaultHttpsPort && protocol == "https") { - urlPort = "" - } - return urlPort -} diff --git a/internal/webserver/controller/document/delete.go b/internal/webserver/controller/document/delete.go index 9a4be8b7..91cb3a48 100644 --- a/internal/webserver/controller/document/delete.go +++ b/internal/webserver/controller/document/delete.go @@ -6,17 +6,9 @@ import ( "path/filepath" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" - "github.com/svera/coreander/v3/internal/webserver/model" ) func (d *Controller) Delete(c *fiber.Ctx) error { - session := jwtclaimsreader.SessionData(c) - - if session.Role != model.RoleAdmin { - return fiber.ErrForbidden - } - if c.FormValue("slug") == "" { return fiber.ErrBadRequest } diff --git a/internal/webserver/controller/document/upload.go b/internal/webserver/controller/document/upload.go index f7c80a45..47355698 100644 --- a/internal/webserver/controller/document/upload.go +++ b/internal/webserver/controller/document/upload.go @@ -9,34 +9,14 @@ import ( "os" "path/filepath" "slices" - "strings" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/controller" - "github.com/svera/coreander/v3/internal/webserver/model" "github.com/valyala/fasthttp" ) func (d *Controller) UploadForm(c *fiber.Ctx) error { - var session model.User - if val, ok := c.Locals("Session").(model.User); ok { - session = val - } - - if session.Role != model.RoleAdmin { - return fiber.ErrForbidden - } - - upload := fmt.Sprintf( - "%s://%s%s/%s/upload", - c.Protocol(), - d.config.Hostname, - controller.UrlPort(c.Protocol(), d.config.Port), - c.Params("lang"), - ) - msg := "" - if ref := string(c.Request().Header.Referer()); strings.HasPrefix(ref, upload) { + if c.Params("success") != "" { msg = "Document uploaded successfully." } @@ -48,12 +28,6 @@ func (d *Controller) UploadForm(c *fiber.Ctx) error { } func (d *Controller) Upload(c *fiber.Ctx) error { - session := c.Locals("Session").(model.User) - - if session.Role != model.RoleAdmin { - return fiber.ErrForbidden - } - file, err := c.FormFile("filename") if err != nil { if errors.Is(err, fasthttp.ErrMissingFile) { @@ -105,7 +79,7 @@ func (d *Controller) Upload(c *fiber.Ctx) error { return internalServerErrorStatus } - return c.Redirect(fmt.Sprintf("/%s/upload", c.Params("lang"))) + return c.Redirect(fmt.Sprintf("/%s/upload?success=1", c.Params("lang"))) } func fileToBytes(fileHeader *multipart.FileHeader) ([]byte, error) { diff --git a/internal/webserver/controller/user/controller.go b/internal/webserver/controller/user/controller.go index 35cd13d5..6e4741a4 100644 --- a/internal/webserver/controller/user/controller.go +++ b/internal/webserver/controller/user/controller.go @@ -1,7 +1,6 @@ package user import ( - "github.com/gofiber/fiber/v2" "github.com/svera/coreander/v3/internal/result" "github.com/svera/coreander/v3/internal/webserver/model" ) @@ -34,25 +33,3 @@ func NewController(repository usersRepository, usersCfg Config) *Controller { config: usersCfg, } } - -// New renders the new user form -func (u *Controller) New(c *fiber.Ctx) error { - var session model.User - if val, ok := c.Locals("Session").(model.User); ok { - session = val - } - - if session.Role != model.RoleAdmin { - return fiber.ErrForbidden - } - - user := model.User{ - WordsPerMinute: u.config.WordsPerMinute, - } - return c.Render("users/new", fiber.Map{ - "Title": "Add user", - "MinPasswordLength": u.config.MinPasswordLength, - "User": user, - "Errors": map[string]string{}, - }, "layout") -} diff --git a/internal/webserver/controller/user/create.go b/internal/webserver/controller/user/create.go index 090b0957..5482c6b2 100644 --- a/internal/webserver/controller/user/create.go +++ b/internal/webserver/controller/user/create.go @@ -11,15 +11,6 @@ import ( // Create gathers information coming from the new user form and creates a new user func (u *Controller) Create(c *fiber.Ctx) error { - var session model.User - if val, ok := c.Locals("Session").(model.User); ok { - session = val - } - - if session.Role != model.RoleAdmin { - return fiber.ErrForbidden - } - role, _ := strconv.Atoi(c.FormValue("role")) user := model.User{ Name: c.FormValue("name"), diff --git a/internal/webserver/controller/user/delete.go b/internal/webserver/controller/user/delete.go index f4691106..27ec0bf7 100644 --- a/internal/webserver/controller/user/delete.go +++ b/internal/webserver/controller/user/delete.go @@ -4,18 +4,11 @@ import ( "fmt" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" "github.com/svera/coreander/v3/internal/webserver/model" ) // Delete removes a user from the database func (u *Controller) Delete(c *fiber.Ctx) error { - session := jwtclaimsreader.SessionData(c) - - if session.Role != model.RoleAdmin { - return fiber.ErrForbidden - } - user, err := u.repository.FindByUuid(c.FormValue("uuid")) if err != nil { return fiber.ErrNotFound diff --git a/internal/webserver/controller/user/list.go b/internal/webserver/controller/user/list.go index b76c103e..703c4f38 100644 --- a/internal/webserver/controller/user/list.go +++ b/internal/webserver/controller/user/list.go @@ -10,15 +10,6 @@ import ( // List list all users registered in the database func (u *Controller) List(c *fiber.Ctx) error { - var session model.User - if val, ok := c.Locals("Session").(model.User); ok { - session = val - } - - if session.Role != model.RoleAdmin { - return fiber.ErrForbidden - } - page, err := strconv.Atoi(c.Query("page")) if err != nil { page = 1 diff --git a/internal/webserver/controller/user/new.go b/internal/webserver/controller/user/new.go new file mode 100644 index 00000000..a0a022be --- /dev/null +++ b/internal/webserver/controller/user/new.go @@ -0,0 +1,19 @@ +package user + +import ( + "github.com/gofiber/fiber/v2" + "github.com/svera/coreander/v3/internal/webserver/model" +) + +// New renders the new user form +func (u *Controller) New(c *fiber.Ctx) error { + user := model.User{ + WordsPerMinute: u.config.WordsPerMinute, + } + return c.Render("users/new", fiber.Map{ + "Title": "Add user", + "MinPasswordLength": u.config.MinPasswordLength, + "User": user, + "Errors": map[string]string{}, + }, "layout") +} diff --git a/internal/webserver/embedded/translations/es.yml b/internal/webserver/embedded/translations/es.yml index ba1d781d..3ccc0ddc 100644 --- a/internal/webserver/embedded/translations/es.yml +++ b/internal/webserver/embedded/translations/es.yml @@ -76,6 +76,7 @@ "A reset password request for Coreander has been received. You can proceed by clicking in the following link or pasting it in your browser": "Hemos recibido una petición para actualizar tu contraseña en Coreander. Si has sido tu, puedes continuar haciendo click en el siguiente enlace o pegándolo en tu navegador" "If you didn't request this, you can safely disregard this email.": "Si no has sido tu, puedes ignorar este correo." "We've received your password recovery request. If the address you introduced is registered in our system, you'll receive an email with further instructions in your inbox.": "Hemos recibido tu petición para recuperar tu contraseña. Si la dirección que has indicado está registrada en el sistema, recibiras un correo con indicaciones en tu bandeja de entrada." +"Check your spam folder if you don't receive the recovery email after a while.": "Comprueba tu carpeta de correo no deseado si no recibes el correo de confirmación en unos momentos." "Password recovery request": "Petición de recuperación de contraseña" "Set new password": "Introduce tu nueva contraseña" "Password changed successfully. Please sign in.": "Contraseña modificada con éxito. Por favor, identifícate." @@ -114,3 +115,4 @@ "Request entity too large": "Petición demasiado grande" "Error uploading document": "Error al subir el documento" "Invalid file type": "Tipo de archivo no válido" +"Upload": "Subir" diff --git a/internal/webserver/embedded/translations/fr.yml b/internal/webserver/embedded/translations/fr.yml index 766b7d05..e105d23d 100644 --- a/internal/webserver/embedded/translations/fr.yml +++ b/internal/webserver/embedded/translations/fr.yml @@ -76,6 +76,7 @@ "A reset password request for Coreander has been received. You can proceed by clicking in the following link or pasting it in your browser": "Une demande de réinitialisation du mot de passe pour Coreander a été reçue. Vous pouvez continuer en cliquant sur le lien suivant ou en le collant dans votre navigateur" "If you didn't request this, you can safely disregard this email.": "Si vous ne l'avez pas demandé, vous pouvez ignorer cet e-mail en toute sécurité." "We've received your password recovery request. If the address you introduced is registered in our system, you'll receive an email with further instructions in your inbox.": "Nous avons reçu votre demande de récupération de mot de passe. Si l'adresse que vous avez introduite est enregistrée dans notre système, vous recevrez un e-mail contenant des instructions supplémentaires dans votre boîte de réception." +"Check your spam folder if you don't receive the recovery email after a while.": "Vérifiez votre dossier spam si vous ne recevez pas l'e-mail de récupération après un certain temps." "Password recovery request": "Demande de récupération de mot de passe" "Set new password": "Définir un nouveau mot de passe" "Password changed successfully. Please sign in.": "Mot de passe modifié avec succès. Veuillez vous connecter." @@ -114,3 +115,4 @@ "Request entity too large": "Entité de requête trop grande" "Error uploading document": "Erreur lors du téléchargement du document" "Invalid file type": "Type de fichier invalide" +"Upload": "Télécharger" diff --git a/internal/webserver/embedded/views/auth/request.html b/internal/webserver/embedded/views/auth/request.html index e10113a0..50e44180 100644 --- a/internal/webserver/embedded/views/auth/request.html +++ b/internal/webserver/embedded/views/auth/request.html @@ -2,5 +2,6 @@

{{t .Lang "Recover password"}}

{{t .Lang "We've received your password recovery request. If the address you introduced is registered in our system, you'll receive an email with further instructions in your inbox."}}

+

{{t .Lang "Check your spam folder if you don't receive the recovery email after a while."}}

diff --git a/internal/webserver/embedded/views/results.html b/internal/webserver/embedded/views/results.html index 7379e782..43814094 100644 --- a/internal/webserver/embedded/views/results.html +++ b/internal/webserver/embedded/views/results.html @@ -1,9 +1,12 @@
- {{template "partials/searchbox" .}} {{if gt .Results.TotalHits 0}} -

{{t .Lang "%d matches found" .Results.TotalHits }}

- {{else}} -

{{t .Lang "No matches found" }}

- {{end}} + {{template "partials/searchbox" .}} +

+ {{if gt .Results.TotalHits 0}} + {{t .Lang "%d matches found" .Results.TotalHits }} + {{else}} + {{t .Lang "No matches found" }} + {{end}} +

{{ template "partials/docs-list" . }} diff --git a/internal/webserver/embedded/views/upload.html b/internal/webserver/embedded/views/upload.html index 2fe560f2..af6124de 100644 --- a/internal/webserver/embedded/views/upload.html +++ b/internal/webserver/embedded/views/upload.html @@ -28,7 +28,7 @@

{{t .Lang "Upload document" }}

- {{t .Lang "Send"}} + {{t .Lang "Upload"}}
diff --git a/internal/webserver/middleware.go b/internal/webserver/middleware.go new file mode 100644 index 00000000..9aea2633 --- /dev/null +++ b/internal/webserver/middleware.go @@ -0,0 +1,82 @@ +package webserver + +import ( + "github.com/gofiber/fiber/v2" + jwtware "github.com/gofiber/jwt/v3" + "github.com/svera/coreander/v3/internal/webserver/infrastructure" + "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" + "github.com/svera/coreander/v3/internal/webserver/model" +) + +func requireAdminMiddleware(c *fiber.Ctx) error { + session := c.Locals("Session").(model.User) + + if session.Role != model.RoleAdmin { + return fiber.ErrForbidden + } + + return c.Next() +} + +func allowIfNotLoggedInMiddleware(jwtSecret []byte) func(*fiber.Ctx) error { + return jwtware.New(jwtware.Config{ + SigningKey: jwtSecret, + SigningMethod: "HS256", + TokenLookup: "cookie:coreander", + SuccessHandler: func(c *fiber.Ctx) error { + return fiber.ErrForbidden + }, + ErrorHandler: func(c *fiber.Ctx, err error) error { + return c.Next() + }, + }) +} + +func alwaysRequireAuthenticationMiddleware(jwtSecret []byte, sender Sender) func(*fiber.Ctx) error { + return jwtware.New(jwtware.Config{ + SigningKey: jwtSecret, + SigningMethod: "HS256", + TokenLookup: "cookie:coreander", + SuccessHandler: func(c *fiber.Ctx) error { + c.Locals("Session", jwtclaimsreader.SessionData(c)) + return c.Next() + }, + ErrorHandler: func(c *fiber.Ctx, err error) error { + return forbidden(c, sender) + }, + }) +} + +func configurableAuthenticationMiddleware(jwtSecret []byte, sender Sender, requireAuth bool) func(*fiber.Ctx) error { + return jwtware.New(jwtware.Config{ + SigningKey: jwtSecret, + SigningMethod: "HS256", + TokenLookup: "cookie:coreander", + SuccessHandler: func(c *fiber.Ctx) error { + c.Locals("Session", jwtclaimsreader.SessionData(c)) + return c.Next() + }, + ErrorHandler: func(c *fiber.Ctx, err error) error { + err = c.Next() + if requireAuth { + return forbidden(c, sender) + } + return err + }, + }) +} + +func forbidden(c *fiber.Ctx, sender Sender) error { + emailSendingConfigured := true + if _, ok := sender.(*infrastructure.NoEmail); ok { + emailSendingConfigured = false + } + + return c.Status(fiber.StatusForbidden).Render("auth/login", fiber.Map{ + "Lang": chooseBestLanguage(c, getSupportedLanguages()), + "Title": "Login", + "Version": c.App().Config().AppName, + "EmailSendingConfigured": emailSendingConfigured, + "SupportedLanguages": getSupportedLanguages(), + }, "layout") +} diff --git a/internal/webserver/routes.go b/internal/webserver/routes.go index 84cf1e13..5ccaa49a 100644 --- a/internal/webserver/routes.go +++ b/internal/webserver/routes.go @@ -10,7 +10,11 @@ import ( "github.com/svera/coreander/v3/internal/webserver/controller" ) -func routes(app *fiber.App, controllers Controllers, supportedLanguages []string) { +func routes(app *fiber.App, controllers Controllers, jwtSecret []byte, sender Sender, requireAuth bool) { + var allowIfNotLoggedInMiddleware = allowIfNotLoggedInMiddleware(jwtSecret) + var alwaysRequireAuthenticationMiddleware = alwaysRequireAuthenticationMiddleware(jwtSecret, sender) + var configurableAuthenticationMiddleware = configurableAuthenticationMiddleware(jwtSecret, sender, requireAuth) + app.Use("/css", filesystem.New(filesystem.Config{ Root: http.FS(cssFS), })) @@ -23,46 +27,46 @@ func routes(app *fiber.App, controllers Controllers, supportedLanguages []string Root: http.FS(imagesFS), })) - langGroup := app.Group(fmt.Sprintf("/:lang", strings.Join(supportedLanguages, "|")), func(c *fiber.Ctx) error { + langGroup := app.Group(fmt.Sprintf("/:lang", strings.Join(getSupportedLanguages(), "|")), func(c *fiber.Ctx) error { pathMinusLang := c.Path()[3:] query := string(c.Request().URI().QueryString()) if query != "" { pathMinusLang = pathMinusLang + "?" + query } c.Locals("Lang", c.Params("lang")) - c.Locals("SupportedLanguages", supportedLanguages) + c.Locals("SupportedLanguages", getSupportedLanguages()) c.Locals("PathMinusLang", pathMinusLang) c.Locals("Version", c.App().Config().AppName) return c.Next() }) - langGroup.Get("/login", controllers.AllowIfNotLoggedInMiddleware, controllers.Auth.Login) - langGroup.Post("login", controllers.AllowIfNotLoggedInMiddleware, controllers.Auth.SignIn) - langGroup.Get("/recover", controllers.AllowIfNotLoggedInMiddleware, controllers.Auth.Recover) - langGroup.Post("/recover", controllers.AllowIfNotLoggedInMiddleware, controllers.Auth.Request) - langGroup.Get("/reset-password", controllers.AllowIfNotLoggedInMiddleware, controllers.Auth.EditPassword) - langGroup.Post("/reset-password", controllers.AllowIfNotLoggedInMiddleware, controllers.Auth.UpdatePassword) + langGroup.Get("/login", allowIfNotLoggedInMiddleware, controllers.Auth.Login) + langGroup.Post("login", allowIfNotLoggedInMiddleware, controllers.Auth.SignIn) + langGroup.Get("/recover", allowIfNotLoggedInMiddleware, controllers.Auth.Recover) + langGroup.Post("/recover", allowIfNotLoggedInMiddleware, controllers.Auth.Request) + langGroup.Get("/reset-password", allowIfNotLoggedInMiddleware, controllers.Auth.EditPassword) + langGroup.Post("/reset-password", allowIfNotLoggedInMiddleware, controllers.Auth.UpdatePassword) - usersGroup := langGroup.Group("/users", controllers.AlwaysRequireAuthenticationMiddleware) + usersGroup := langGroup.Group("/users", alwaysRequireAuthenticationMiddleware) - usersGroup.Get("/", controllers.Users.List) - usersGroup.Get("/new", controllers.Users.New) - usersGroup.Post("/new", controllers.Users.Create) + usersGroup.Get("/", requireAdminMiddleware, controllers.Users.List) + usersGroup.Get("/new", requireAdminMiddleware, controllers.Users.New) + usersGroup.Post("/new", requireAdminMiddleware, controllers.Users.Create) usersGroup.Get("/:uuid/edit", controllers.Users.Edit) usersGroup.Post("/:uuid/edit", controllers.Users.Update) - usersGroup.Post("/delete", controllers.Users.Delete) + usersGroup.Post("/delete", requireAdminMiddleware, controllers.Users.Delete) - langGroup.Get("/highlights/:uuid", controllers.AlwaysRequireAuthenticationMiddleware, controllers.Highlights.Highlights) - app.Post("/highlights", controllers.AlwaysRequireAuthenticationMiddleware, controllers.Highlights.Highlight) - app.Delete("/highlights", controllers.AlwaysRequireAuthenticationMiddleware, controllers.Highlights.Remove) + langGroup.Get("/highlights/:uuid", alwaysRequireAuthenticationMiddleware, controllers.Highlights.Highlights) + app.Post("/highlights", alwaysRequireAuthenticationMiddleware, controllers.Highlights.Highlight) + app.Delete("/highlights", alwaysRequireAuthenticationMiddleware, controllers.Highlights.Remove) - app.Post("/delete", controllers.AlwaysRequireAuthenticationMiddleware, controllers.Documents.Delete) + app.Post("/delete", alwaysRequireAuthenticationMiddleware, requireAdminMiddleware, controllers.Documents.Delete) - langGroup.Get("/upload", controllers.AlwaysRequireAuthenticationMiddleware, controllers.Documents.UploadForm) - langGroup.Post("/upload", controllers.AlwaysRequireAuthenticationMiddleware, controllers.Documents.Upload) + langGroup.Get("/upload", alwaysRequireAuthenticationMiddleware, requireAdminMiddleware, controllers.Documents.UploadForm) + langGroup.Post("/upload", alwaysRequireAuthenticationMiddleware, requireAdminMiddleware, controllers.Documents.Upload) // Authentication requirement is configurable for all routes below this middleware - app.Use(controllers.ConfigurableAuthenticationMiddleware) + app.Use(configurableAuthenticationMiddleware) langGroup.Get("/logout", controllers.Auth.SignOut) @@ -79,6 +83,6 @@ func routes(app *fiber.App, controllers Controllers, supportedLanguages []string langGroup.Get("/read/:slug", controllers.Documents.Reader) app.Get("/", func(c *fiber.Ctx) error { - return controller.Root(c, supportedLanguages) + return controller.Root(c, getSupportedLanguages()) }) } diff --git a/internal/webserver/upload_test.go b/internal/webserver/upload_test.go index 3c5bd335..dd962686 100644 --- a/internal/webserver/upload_test.go +++ b/internal/webserver/upload_test.go @@ -36,6 +36,16 @@ func TestUpload(t *testing.T) { t.Fatalf("Unexpected error: %v", err.Error()) } + response, err := postRequest(data, adminCookie, app, "/en/users/new") + if response == nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + regularUserCookie, err := login(app, "test@example.com", "test") + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + t.Run("Try to access upload page without an active session", func(t *testing.T) { response, err := getRequest(&http.Cookie{}, app, "/en/upload") if response == nil { @@ -46,17 +56,28 @@ func TestUpload(t *testing.T) { }) t.Run("Try to access upload page with a regular user session", func(t *testing.T) { - response, err := postRequest(data, adminCookie, app, "/en/users/new") + response, err = getRequest(regularUserCookie, app, "/en/upload") if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } - cookie, err := login(app, "test@example.com", "test") + + mustReturnStatus(response, fiber.StatusForbidden, t) + }) + + t.Run("Try to upload a document with a regular user session", func(t *testing.T) { + var buf bytes.Buffer + multipartWriter := multipart.NewWriter(&buf) + multipartWriter.Close() + + req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + req.AddCookie(regularUserCookie) - response, err = getRequest(cookie, app, "/en/upload") - if response == nil { + response, err := app.Test(req) + if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -184,7 +205,7 @@ func TestUpload(t *testing.T) { }) // Due to a limitation in how pirmd/epub handles opening epub files, we need to use - // a real filesystem instead Afero's in-memory implementatio + // a real filesystem instead Afero's in-memory implementation t.Run("Returns 302 for correct document", func(t *testing.T) { app := bootstrapApp(db, &infrastructure.NoEmail{}, afero.NewOsFs()) var buf bytes.Buffer diff --git a/internal/webserver/user_management_test.go b/internal/webserver/user_management_test.go index df2803f1..bad9361e 100644 --- a/internal/webserver/user_management_test.go +++ b/internal/webserver/user_management_test.go @@ -71,6 +71,27 @@ func TestUserManagement(t *testing.T) { } }) + t.Run("Try to add a user with a regular user active session", func(t *testing.T) { + cookie, err := login(app, "test@example.com", "test") + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + response, err := getRequest(cookie, app, "/en/users/new") + if response == nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + mustReturnStatus(response, fiber.StatusForbidden, t) + + response, err = postRequest(data, cookie, app, "/en/users/new") + if response == nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + mustReturnStatus(response, fiber.StatusForbidden, t) + }) + t.Run("Try to add a user with errors in form using an admin active session", func(t *testing.T) { response, err := postRequest(url.Values{}, adminCookie, app, "/en/users/new") expectedErrorMessages := []string{ @@ -97,20 +118,6 @@ func TestUserManagement(t *testing.T) { } }) - t.Run("Try to add a user with a regular user active session", func(t *testing.T) { - cookie, err := login(app, "test@example.com", "test") - if err != nil { - t.Fatalf("Unexpected error: %v", err.Error()) - } - - response, err := postRequest(data, cookie, app, "/en/users/new") - if response == nil { - t.Fatalf("Unexpected error: %v", err.Error()) - } - - mustReturnStatus(response, fiber.StatusForbidden, t) - }) - testUser := model.User{} db.Where("email = ?", "test@example.com").First(&testUser) diff --git a/internal/webserver/webserver.go b/internal/webserver/webserver.go index b06572c3..4fe54def 100644 --- a/internal/webserver/webserver.go +++ b/internal/webserver/webserver.go @@ -2,6 +2,8 @@ package webserver import ( "embed" + "errors" + "fmt" "io/fs" "log" "sort" @@ -13,6 +15,7 @@ import ( "github.com/gofiber/fiber/v2/middleware/favicon" "github.com/svera/coreander/v3/internal/i18n" "github.com/svera/coreander/v3/internal/webserver/infrastructure" + "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" "golang.org/x/exp/slices" "golang.org/x/text/message" ) @@ -26,6 +29,11 @@ var ( printers map[string]*message.Printer ) +const ( + defaultHttpPort = 80 + defaultHttpsPort = 443 +) + type Config struct { Version string SessionTimeout time.Duration @@ -77,7 +85,7 @@ func init() { } // New builds a new Fiber application and set up the required routes -func New(cfg Config, controllers Controllers) *fiber.App { +func New(cfg Config, controllers Controllers, sender Sender) *fiber.App { viewsFS, err := fs.Sub(embedded, "embedded/views") if err != nil { log.Fatal(err) @@ -93,12 +101,21 @@ func New(cfg Config, controllers Controllers) *fiber.App { DisableStartupMessage: true, AppName: cfg.Version, PassLocalsToViews: true, - ErrorHandler: controllers.ErrorHandler, + ErrorHandler: errorHandler, BodyLimit: cfg.UploadDocumentMaxSize * 1024 * 1024, DisablePreParseMultipartForm: true, StreamRequestBody: true, }) + app.Use(func(c *fiber.Ctx) error { + c.Locals("fqdn", fmt.Sprintf("%s://%s%s", + c.Protocol(), + cfg.Hostname, + urlPort(c.Protocol(), cfg.Port), + )) + return c.Next() + }) + app.Use(favicon.New()) app.Use(cache.New(cache.Config{ @@ -109,7 +126,7 @@ func New(cfg Config, controllers Controllers) *fiber.App { }), ) - routes(app, controllers, getSupportedLanguages()) + routes(app, controllers, cfg.JwtSecret, sender, cfg.RequireAuth) return app } @@ -137,3 +154,41 @@ func chooseBestLanguage(c *fiber.Ctx, supportedLanguages []string) string { return lang } + +func urlPort(protocol string, port int) string { + urlPort := fmt.Sprintf(":%d", port) + if (port == defaultHttpPort && protocol == "http") || + (port == defaultHttpsPort && protocol == "https") { + urlPort = "" + } + return urlPort +} + +func errorHandler(c *fiber.Ctx, err error) error { + // Status code defaults to 500 + code := fiber.StatusInternalServerError + // Retrieve the custom status code if it's a *fiber.Error + var e *fiber.Error + if errors.As(err, &e) { + code = e.Code + } + + // Send custom error page + err = c.Status(code).Render( + fmt.Sprintf("errors/%d", code), + fiber.Map{ + "Lang": chooseBestLanguage(c, getSupportedLanguages()), + "Title": "Coreander", + "Session": jwtclaimsreader.SessionData(c), + "Version": c.App().Config().AppName, + }, + "layout") + + if err != nil { + log.Println(err) + // In case the Render fails + return c.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + + return nil +} diff --git a/internal/webserver/webserver_test.go b/internal/webserver/webserver_test.go index fdd29843..6a1cc847 100644 --- a/internal/webserver/webserver_test.go +++ b/internal/webserver/webserver_test.go @@ -81,7 +81,7 @@ func bootstrapApp(db *gorm.DB, sender webserver.Sender, appFs afero.Fs) *fiber.A } controllers := webserver.SetupControllers(webserverConfig, db, metadataReaders, idx, sender, appFs) - app := webserver.New(webserverConfig, controllers) + app := webserver.New(webserverConfig, controllers, sender) return app } diff --git a/main.go b/main.go index 125dedbc..56ad8dca 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "os" + "strings" "time" "github.com/blevesearch/bleve/v2" @@ -96,7 +97,10 @@ func main() { } controllers := webserver.SetupControllers(webserverConfig, db, metadataReaders, idx, sender, appFs) - app := webserver.New(webserverConfig, controllers) + app := webserver.New(webserverConfig, controllers, sender) + if strings.ToLower(cfg.Hostname) == "localhost" { + fmt.Printf("Warning: using \"localhost\" as host name. Links using this host name won't be accesible outside this system.\n") + } fmt.Printf("Coreander version %s started listening on port %d\n\n", version, cfg.Port) log.Fatal(app.Listen(fmt.Sprintf(":%d", cfg.Port))) } @@ -119,7 +123,7 @@ func getIndexFile() bleve.Index { if err == bleve.ErrorIndexPathDoesNotExist { log.Println("No index found, creating a new one.") cfg.SkipIndexing = false - indexFile = createIndex(homeDir, cfg.LibPath, metadataReaders) + indexFile = createIndex(homeDir) } version, err := indexFile.GetInternal([]byte("version")) if err != nil { @@ -131,12 +135,12 @@ func getIndexFile() bleve.Index { log.Fatal(err) } cfg.SkipIndexing = false - indexFile = createIndex(homeDir, cfg.LibPath, metadataReaders) + indexFile = createIndex(homeDir) } return indexFile } -func createIndex(homeDir, libPath string, metadataReaders map[string]metadata.Reader) bleve.Index { +func createIndex(homeDir string) bleve.Index { indexFile, err := bleve.New(homeDir+indexPath, index.Mapping()) if err != nil { log.Fatal(err)