diff --git a/README.md b/README.md index 639c9b5..35a9785 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ * [Email verification](#email-verification) * [Routes](#routes) * [Custom middleware](#custom-middleware) - * [Controller / Dependencies](#controller--dependencies) - * [Patterns](#patterns) + * [Controller](#controller) + * [Handlers](#handlers) * [Errors](#errors) * [Testing](#testing) * [HTTP server](#http-server) @@ -306,7 +306,7 @@ This executes a database query to return the _password token_ entity with a give ## Sessions -Sessions are provided and handled via [Gorilla sessions](https://github.com/gorilla/sessions) and configured as middleware in the router located at `pkg/routes/router.go`. Session data is currently stored in cookies but there are many [options](https://github.com/gorilla/sessions#store-implementations) available if you wish to use something else. +Sessions are provided and handled via [Gorilla sessions](https://github.com/gorilla/sessions) and configured as middleware in the router located at `pkg/handlers/router.go`. Session data is currently stored in cookies but there are many [options](https://github.com/gorilla/sessions#store-implementations) available if you wish to use something else. Here's a simple example of loading data from a session and saving new values: @@ -384,7 +384,7 @@ To generate a new verification token, the `AuthClient` has a method `GenerateEma ## Routes -The router functionality is provided by [Echo](https://echo.labstack.com/guide/routing/) and constructed within via the `BuildRouter()` function inside `pkg/routes/router.go`. Since the _Echo_ instance is a _Service_ on the `Container` which is passed in to `BuildRouter()`, middleware and routes can be added directly to it. +The router functionality is provided by [Echo](https://echo.labstack.com/guide/routing/) and constructed within via the `BuildRouter()` function inside `pkg/handlers/router.go`. Since the _Echo_ instance is a _Service_ on the `Container` which is passed in to `BuildRouter()`, middleware and routes can be added directly to it. ### Custom middleware @@ -392,56 +392,83 @@ By default, a middleware stack is included in the router that makes sense for mo A `middleware` package is included which you can easily add to along with the custom middleware provided. -### Controller / Dependencies +### Controller -The `Controller`, which is described in a section below, serves two purposes for routes: - -1) It provides base functionality which can be embedded in each route, most importantly `Page` rendering (described in the `Controller` section below) -2) It stores a pointer to the `Container`, making all _Services_ available within your route +The `Controller`, which is described in a section below, provides base functionality which can be embedded in each handler, most importantly `Page` rendering. While using the `Controller` is not required for your routes, it will certainly make development easier. -See the following section for the proposed pattern. +### Handlers -### Patterns +A `Handler` is a simple type that handles one or more of your routes and allows you to group related routes together (ie, authentication). All provided handlers are located in `pkg/handlers`. _Handlers_ also handle self-registering their routes with the router. -These patterns are not required, but were designed to make development as easy as possible. +#### Example -To declare a new route that will have methods to handle a GET and POST request, for example, start with a new _struct_ type, that embeds the `Controller`: +The provided patterns are not required, but were designed to make development as easy as possible. + +For this example, we'll create a new handler which includes a GET and POST route and uses the ORM. Start by creating a file at `pkg/handlers/example.go`. + +1) Define the handler type: ```go -type home struct { +type Example struct { + orm *ent.Client controller.Controller } - -func (c *home) Get(ctx echo.Context) error {} - -func (c *home) Post(ctx echo.Context) error {} ``` -Then create the route and add to the router: +2) Register the handler so the router automatically includes it ```go -home := home{Controller: controller.NewController(c)} -g.GET("/", home.Get).Name = "home" -g.POST("/", home.Post).Name = "home.post" +func init() { + Register(new(Example)) +} ``` -Your route will now have all methods available on the `Controller` as well as access to the `Container`. It's not required to name the route methods to match the HTTP method. +3) Initialize the handler (and inject any required dependencies from the _Container_) + +```go +func (e *Example) Init(c *services.Container) error { + e.Controller = controller.NewController(c) + e.orm = c.ORM + return nil +} +``` + +4) Declare the routes **It is highly recommended** that you provide a `Name` for your routes. Most methods on the back and frontend leverage the route name and parameters in order to generate URLs. +```go +func (e *Example) Routes(g *echo.Group) { + g.GET("/example", e.Page).Name = "example" + g.POST("/example", c.PageSubmit).Name = "example.submit" +} +``` + +5) Implement your routes + +```go +func (e *Example) Page(ctx echo.Context) error { + // add your code here +} + +func (e *Example) PageSubmit(ctx echo.Context) error { + // add your code here +} +``` + ### Errors Routes can return errors to indicate that something wrong happened. Ideally, the error is of type `*echo.HTTPError` to indicate the intended HTTP response code. You can use `return echo.NewHTTPError(http.StatusInternalServerError)`, for example. If an error of a different type is returned, an _Internal Server Error_ is assumed. -The [error handler](https://echo.labstack.com/guide/error-handling/) is set to a provided route `pkg/routes/error.go` in the `BuildRouter()` function. That means that if any middleware or route return an error, the request gets routed there. This route conveniently constructs and renders a `Page` which uses the template `templates/pages/error.go`. The status code is passed to the template so you can easily alter the markup depending on the error type. +The [error handler](https://echo.labstack.com/guide/error-handling/) is set to a provided route `pkg/handlers/error.go` in the `BuildRouter()` function. That means that if any middleware or route return an error, the request gets routed there. This route conveniently constructs and renders a `Page` which uses the template `templates/pages/error.go`. The status code is passed to the template so you can easily alter the markup depending on the error type. ### Testing Since most of your web application logic will live in your routes, being able to easily test them is important. The following aims to help facilitate that. -The test setup and helpers reside in `pkg/routes/router_test.go`. +The test setup and helpers reside in `pkg/handlers/router_test.go`. Only a brief example of route tests were provided in order to highlight what is available. Adding full tests did not seem logical since these routes will most likely be changed or removed in your project. @@ -451,7 +478,7 @@ When the route tests initialize, a new `Container` is created which provides ful #### Request / Response helpers -With the test HTTP server setup, test helpers for making HTTP requests and evaluating responses are made available to reduce the amount of code you need to write. See `httpRequest` and `httpResponse` within `pkg/routes/router_test.go`. +With the test HTTP server setup, test helpers for making HTTP requests and evaluating responses are made available to reduce the amount of code you need to write. See `httpRequest` and `httpResponse` within `pkg/handlers/router_test.go`. Here is an example how to easily make a request and evaluate the response: @@ -1126,7 +1153,7 @@ Finally, the service is started with `async.Server.Run(mux)`. ## Static files -Static files are currently configured in the router (`pkg/routes/router.go`) to be served from the `static` directory. If you wish to change the directory, alter the constant `config.StaticDir`. The URL prefix for static files is `/files` which is controlled via the `config.StaticPrefix` constant. +Static files are currently configured in the router (`pkg/handler/router.go`) to be served from the `static` directory. If you wish to change the directory, alter the constant `config.StaticDir`. The URL prefix for static files is `/files` which is controlled via the `config.StaticPrefix` constant. ### Cache control headers diff --git a/cmd/web/main.go b/cmd/web/main.go index d47aab9..23b3981 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -9,7 +9,7 @@ import ( "os/signal" "time" - "github.com/mikestefanello/pagoda/pkg/routes" + "github.com/mikestefanello/pagoda/pkg/handlers" "github.com/mikestefanello/pagoda/pkg/services" ) @@ -23,7 +23,9 @@ func main() { }() // Build the router - routes.BuildRouter(c) + if err := handlers.BuildRouter(c); err != nil { + c.Web.Logger.Fatalf("failed to build the router: %v", err) + } // Start the server go func() { diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go new file mode 100644 index 0000000..6be0819 --- /dev/null +++ b/pkg/handlers/auth.go @@ -0,0 +1,451 @@ +package handlers + +import ( + "fmt" + "strings" + + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/ent" + "github.com/mikestefanello/pagoda/ent/user" + "github.com/mikestefanello/pagoda/pkg/context" + "github.com/mikestefanello/pagoda/pkg/controller" + "github.com/mikestefanello/pagoda/pkg/middleware" + "github.com/mikestefanello/pagoda/pkg/msg" + "github.com/mikestefanello/pagoda/pkg/services" + "github.com/mikestefanello/pagoda/templates" +) + +const ( + routeNameForgotPassword = "forgot_password" + routeNameForgotPasswordSubmit = "forgot_password.submit" + routeNameLogin = "login" + routeNameLoginSubmit = "login.submit" + routeNameLogout = "logout" + routeNameRegister = "register" + routeNameRegisterSubmit = "register.submit" + routeNameResetPassword = "reset_password" + routeNameResetPasswordSubmit = "reset_password.submit" + routeNameVerifyEmail = "verify_email" +) + +type ( + Auth struct { + auth *services.AuthClient + mail *services.MailClient + orm *ent.Client + controller.Controller + } + + forgotPasswordForm struct { + Email string `form:"email" validate:"required,email"` + Submission controller.FormSubmission + } + + loginForm struct { + Email string `form:"email" validate:"required,email"` + Password string `form:"password" validate:"required"` + Submission controller.FormSubmission + } + + registerForm struct { + Name string `form:"name" validate:"required"` + Email string `form:"email" validate:"required,email"` + Password string `form:"password" validate:"required"` + ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` + Submission controller.FormSubmission + } + + resetPasswordForm struct { + Password string `form:"password" validate:"required"` + ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` + Submission controller.FormSubmission + } +) + +func init() { + Register(new(Auth)) +} + +func (c *Auth) Init(ct *services.Container) error { + c.Controller = controller.NewController(ct) + c.orm = ct.ORM + c.auth = ct.Auth + c.mail = ct.Mail + return nil +} + +func (c *Auth) Routes(g *echo.Group) { + g.GET("/logout", c.Logout, middleware.RequireAuthentication()).Name = routeNameLogout + g.GET("/email/verify/:token", c.VerifyEmail).Name = routeNameVerifyEmail + + noAuth := g.Group("/user", middleware.RequireNoAuthentication()) + noAuth.GET("/login", c.LoginPage).Name = routeNameLogin + noAuth.POST("/login", c.LoginSubmit).Name = routeNameLoginSubmit + noAuth.GET("/register", c.RegisterPage).Name = routeNameRegister + noAuth.POST("/register", c.RegisterSubmit).Name = routeNameRegisterSubmit + noAuth.GET("/password", c.ForgotPasswordPage).Name = routeNameForgotPassword + noAuth.POST("/password", c.ForgotPasswordSubmit).Name = routeNameForgotPasswordSubmit + + resetGroup := noAuth.Group("/password/reset", + middleware.LoadUser(c.orm), + middleware.LoadValidPasswordToken(c.auth), + ) + resetGroup.GET("/token/:user/:password_token/:token", c.ResetPasswordPage).Name = routeNameResetPassword + resetGroup.POST("/token/:user/:password_token/:token", c.ResetPasswordSubmit).Name = routeNameResetPasswordSubmit +} + +func (c *Auth) ForgotPasswordPage(ctx echo.Context) error { + page := controller.NewPage(ctx) + page.Layout = templates.LayoutAuth + page.Name = templates.PageForgotPassword + page.Title = "Forgot password" + page.Form = forgotPasswordForm{} + + if form := ctx.Get(context.FormKey); form != nil { + page.Form = form.(*forgotPasswordForm) + } + + return c.RenderPage(ctx, page) +} + +func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error { + var form forgotPasswordForm + ctx.Set(context.FormKey, &form) + + succeed := func() error { + ctx.Set(context.FormKey, nil) + msg.Success(ctx, "An email containing a link to reset your password will be sent to this address if it exists in our system.") + return c.ForgotPasswordPage(ctx) + } + + // Parse the form values + if err := ctx.Bind(&form); err != nil { + return c.Fail(err, "unable to parse forgot password form") + } + + if err := form.Submission.Process(ctx, form); err != nil { + return c.Fail(err, "unable to process form submission") + } + + if form.Submission.HasErrors() { + return c.ForgotPasswordPage(ctx) + } + + // Attempt to load the user + u, err := c.orm.User. + Query(). + Where(user.Email(strings.ToLower(form.Email))). + Only(ctx.Request().Context()) + + switch err.(type) { + case *ent.NotFoundError: + return succeed() + case nil: + default: + return c.Fail(err, "error querying user during forgot password") + } + + // Generate the token + token, pt, err := c.auth.GeneratePasswordResetToken(ctx, u.ID) + if err != nil { + return c.Fail(err, "error generating password reset token") + } + + ctx.Logger().Infof("generated password reset token for user %d", u.ID) + + // Email the user + url := ctx.Echo().Reverse(routeNameResetPassword, u.ID, pt.ID, token) + err = c.mail. + Compose(). + To(u.Email). + Subject("Reset your password"). + Body(fmt.Sprintf("Go here to reset your password: %s", url)). + Send(ctx) + + if err != nil { + return c.Fail(err, "error sending password reset email") + } + + return succeed() +} + +func (c *Auth) LoginPage(ctx echo.Context) error { + page := controller.NewPage(ctx) + page.Layout = templates.LayoutAuth + page.Name = templates.PageLogin + page.Title = "Log in" + page.Form = loginForm{} + + if form := ctx.Get(context.FormKey); form != nil { + page.Form = form.(*loginForm) + } + + return c.RenderPage(ctx, page) +} + +func (c *Auth) LoginSubmit(ctx echo.Context) error { + var form loginForm + ctx.Set(context.FormKey, &form) + + authFailed := func() error { + form.Submission.SetFieldError("Email", "") + form.Submission.SetFieldError("Password", "") + msg.Danger(ctx, "Invalid credentials. Please try again.") + return c.LoginPage(ctx) + } + + // Parse the form values + if err := ctx.Bind(&form); err != nil { + return c.Fail(err, "unable to parse login form") + } + + if err := form.Submission.Process(ctx, form); err != nil { + return c.Fail(err, "unable to process form submission") + } + + if form.Submission.HasErrors() { + return c.LoginPage(ctx) + } + + // Attempt to load the user + u, err := c.orm.User. + Query(). + Where(user.Email(strings.ToLower(form.Email))). + Only(ctx.Request().Context()) + + switch err.(type) { + case *ent.NotFoundError: + return authFailed() + case nil: + default: + return c.Fail(err, "error querying user during login") + } + + // Check if the password is correct + err = c.auth.CheckPassword(form.Password, u.Password) + if err != nil { + return authFailed() + } + + // Log the user in + err = c.auth.Login(ctx, u.ID) + if err != nil { + return c.Fail(err, "unable to log in user") + } + + msg.Success(ctx, fmt.Sprintf("Welcome back, %s. You are now logged in.", u.Name)) + return c.Redirect(ctx, routeNameHome) +} + +func (c *Auth) Logout(ctx echo.Context) error { + if err := c.auth.Logout(ctx); err == nil { + msg.Success(ctx, "You have been logged out successfully.") + } else { + msg.Danger(ctx, "An error occurred. Please try again.") + } + return c.Redirect(ctx, routeNameHome) +} + +func (c *Auth) RegisterPage(ctx echo.Context) error { + page := controller.NewPage(ctx) + page.Layout = templates.LayoutAuth + page.Name = templates.PageRegister + page.Title = "Register" + page.Form = registerForm{} + + if form := ctx.Get(context.FormKey); form != nil { + page.Form = form.(*registerForm) + } + + return c.RenderPage(ctx, page) +} + +func (c *Auth) RegisterSubmit(ctx echo.Context) error { + var form registerForm + ctx.Set(context.FormKey, &form) + + // Parse the form values + if err := ctx.Bind(&form); err != nil { + return c.Fail(err, "unable to parse register form") + } + + if err := form.Submission.Process(ctx, form); err != nil { + return c.Fail(err, "unable to process form submission") + } + + if form.Submission.HasErrors() { + return c.RegisterPage(ctx) + } + + // Hash the password + pwHash, err := c.auth.HashPassword(form.Password) + if err != nil { + return c.Fail(err, "unable to hash password") + } + + // Attempt creating the user + u, err := c.orm.User. + Create(). + SetName(form.Name). + SetEmail(form.Email). + SetPassword(pwHash). + Save(ctx.Request().Context()) + + switch err.(type) { + case nil: + ctx.Logger().Infof("user created: %s", u.Name) + case *ent.ConstraintError: + msg.Warning(ctx, "A user with this email address already exists. Please log in.") + return c.Redirect(ctx, routeNameLogin) + default: + return c.Fail(err, "unable to create user") + } + + // Log the user in + err = c.auth.Login(ctx, u.ID) + if err != nil { + ctx.Logger().Errorf("unable to log in: %v", err) + msg.Info(ctx, "Your account has been created.") + return c.Redirect(ctx, routeNameLogin) + } + + msg.Success(ctx, "Your account has been created. You are now logged in.") + + // Send the verification email + c.sendVerificationEmail(ctx, u) + + return c.Redirect(ctx, routeNameHome) +} + +func (c *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { + // Generate a token + token, err := c.auth.GenerateEmailVerificationToken(usr.Email) + if err != nil { + ctx.Logger().Errorf("unable to generate email verification token: %v", err) + return + } + + // Send the email + url := ctx.Echo().Reverse(routeNameVerifyEmail, token) + err = c.mail. + Compose(). + To(usr.Email). + Subject("Confirm your email address"). + Body(fmt.Sprintf("Click here to confirm your email address: %s", url)). + Send(ctx) + + if err != nil { + ctx.Logger().Errorf("unable to send email verification link: %v", err) + return + } + + msg.Info(ctx, "An email was sent to you to verify your email address.") +} + +func (c *Auth) ResetPasswordPage(ctx echo.Context) error { + page := controller.NewPage(ctx) + page.Layout = templates.LayoutAuth + page.Name = templates.PageResetPassword + page.Title = "Reset password" + page.Form = resetPasswordForm{} + + if form := ctx.Get(context.FormKey); form != nil { + page.Form = form.(*resetPasswordForm) + } + + return c.RenderPage(ctx, page) +} + +func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error { + var form resetPasswordForm + ctx.Set(context.FormKey, &form) + + // Parse the form values + if err := ctx.Bind(&form); err != nil { + return c.Fail(err, "unable to parse password reset form") + } + + if err := form.Submission.Process(ctx, form); err != nil { + return c.Fail(err, "unable to process form submission") + } + + if form.Submission.HasErrors() { + return c.ResetPasswordPage(ctx) + } + + // Hash the new password + hash, err := c.auth.HashPassword(form.Password) + if err != nil { + return c.Fail(err, "unable to hash password") + } + + // Get the requesting user + usr := ctx.Get(context.UserKey).(*ent.User) + + // Update the user + _, err = usr. + Update(). + SetPassword(hash). + Save(ctx.Request().Context()) + + if err != nil { + return c.Fail(err, "unable to update password") + } + + // Delete all password tokens for this user + err = c.auth.DeletePasswordTokens(ctx, usr.ID) + if err != nil { + return c.Fail(err, "unable to delete password tokens") + } + + msg.Success(ctx, "Your password has been updated.") + return c.Redirect(ctx, routeNameLogin) +} + +func (c *Auth) VerifyEmail(ctx echo.Context) error { + var usr *ent.User + + // Validate the token + token := ctx.Param("token") + email, err := c.auth.ValidateEmailVerificationToken(token) + if err != nil { + msg.Warning(ctx, "The link is either invalid or has expired.") + return c.Redirect(ctx, routeNameHome) + } + + // Check if it matches the authenticated user + if u := ctx.Get(context.AuthenticatedUserKey); u != nil { + authUser := u.(*ent.User) + + if authUser.Email == email { + usr = authUser + } + } + + // Query to find a matching user, if needed + if usr == nil { + usr, err = c.orm.User. + Query(). + Where(user.Email(email)). + Only(ctx.Request().Context()) + + if err != nil { + return c.Fail(err, "query failed loading email verification token user") + } + } + + // Verify the user, if needed + if !usr.Verified { + usr, err = usr. + Update(). + SetVerified(true). + Save(ctx.Request().Context()) + + if err != nil { + return c.Fail(err, "failed to set user as verified") + } + } + + msg.Success(ctx, "Your email has been successfully verified.") + return c.Redirect(ctx, routeNameHome) +} diff --git a/pkg/routes/contact.go b/pkg/handlers/contact.go similarity index 66% rename from pkg/routes/contact.go rename to pkg/handlers/contact.go index 93b7edb..6fa3f34 100644 --- a/pkg/routes/contact.go +++ b/pkg/handlers/contact.go @@ -1,17 +1,23 @@ -package routes +package handlers import ( "fmt" + "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/controller" + "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/templates" +) - "github.com/labstack/echo/v4" +const ( + routeNameContact = "contact" + routeNameContactSubmit = "contact.submit" ) type ( - contact struct { + Contact struct { + mail *services.MailClient controller.Controller } @@ -23,7 +29,22 @@ type ( } ) -func (c *contact) Get(ctx echo.Context) error { +func init() { + Register(new(Contact)) +} + +func (c *Contact) Init(ct *services.Container) error { + c.Controller = controller.NewController(ct) + c.mail = ct.Mail + return nil +} + +func (c *Contact) Routes(g *echo.Group) { + g.GET("/contact", c.Page).Name = routeNameContact + g.POST("/contact", c.Submit).Name = routeNameContactSubmit +} + +func (c *Contact) Page(ctx echo.Context) error { page := controller.NewPage(ctx) page.Layout = templates.LayoutMain page.Name = templates.PageContact @@ -37,7 +58,7 @@ func (c *contact) Get(ctx echo.Context) error { return c.RenderPage(ctx, page) } -func (c *contact) Post(ctx echo.Context) error { +func (c *Contact) Submit(ctx echo.Context) error { var form contactForm ctx.Set(context.FormKey, &form) @@ -51,7 +72,7 @@ func (c *contact) Post(ctx echo.Context) error { } if !form.Submission.HasErrors() { - err := c.Container.Mail. + err := c.mail. Compose(). To(form.Email). Subject("Contact form submitted"). @@ -63,5 +84,5 @@ func (c *contact) Post(ctx echo.Context) error { } } - return c.Get(ctx) + return c.Page(ctx) } diff --git a/pkg/routes/error.go b/pkg/handlers/error.go similarity index 88% rename from pkg/routes/error.go rename to pkg/handlers/error.go index 98c948c..443b07a 100644 --- a/pkg/routes/error.go +++ b/pkg/handlers/error.go @@ -1,20 +1,19 @@ -package routes +package handlers import ( "net/http" + "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/controller" "github.com/mikestefanello/pagoda/templates" - - "github.com/labstack/echo/v4" ) -type errorHandler struct { +type Error struct { controller.Controller } -func (e *errorHandler) Get(err error, ctx echo.Context) { +func (e *Error) Page(err error, ctx echo.Context) { if ctx.Response().Committed || context.IsCanceledError(err) { return } @@ -31,9 +30,9 @@ func (e *errorHandler) Get(err error, ctx echo.Context) { } page := controller.NewPage(ctx) - page.Title = http.StatusText(code) page.Layout = templates.LayoutMain page.Name = templates.PageError + page.Title = http.StatusText(code) page.StatusCode = code page.HTMX.Request.Enabled = false diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go new file mode 100644 index 0000000..8fc9ee3 --- /dev/null +++ b/pkg/handlers/handlers.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/services" +) + +var handlers []Handler + +// Handler handles one or more HTTP routes +type Handler interface { + // Routes allows for self-registration of HTTP routes on the router + Routes(g *echo.Group) + + // Init provides the service container to initialize + Init(*services.Container) error +} + +// Register registers a handler +func Register(h Handler) { + handlers = append(handlers, h) +} + +// GetHandlers returns all handlers +func GetHandlers() []Handler { + return handlers +} diff --git a/pkg/routes/about.go b/pkg/handlers/pages.go similarity index 57% rename from pkg/routes/about.go rename to pkg/handlers/pages.go index 504bebb..3cb5bc4 100644 --- a/pkg/routes/about.go +++ b/pkg/handlers/pages.go @@ -1,19 +1,30 @@ -package routes +package handlers import ( + "fmt" "html/template" - "github.com/mikestefanello/pagoda/pkg/controller" - "github.com/mikestefanello/pagoda/templates" - "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/controller" + "github.com/mikestefanello/pagoda/pkg/services" + "github.com/mikestefanello/pagoda/templates" +) + +const ( + routeNameAbout = "about" + routeNameHome = "home" ) type ( - about struct { + Pages struct { controller.Controller } + post struct { + Title string + Body string + } + aboutData struct { ShowCacheWarning bool FrontendTabs []aboutTab @@ -26,7 +37,47 @@ type ( } ) -func (c *about) Get(ctx echo.Context) error { +func init() { + Register(new(Pages)) +} + +func (c *Pages) Init(ct *services.Container) error { + c.Controller = controller.NewController(ct) + return nil +} + +func (c *Pages) Routes(g *echo.Group) { + g.GET("/", c.Home).Name = routeNameHome + g.GET("/about", c.About).Name = routeNameAbout +} + +func (c *Pages) Home(ctx echo.Context) error { + page := controller.NewPage(ctx) + page.Layout = templates.LayoutMain + page.Name = templates.PageHome + page.Metatags.Description = "Welcome to the homepage." + page.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"} + page.Pager = controller.NewPager(ctx, 4) + page.Data = c.fetchPosts(&page.Pager) + + return c.RenderPage(ctx, page) +} + +// fetchPosts is an mock example of fetching posts to illustrate how paging works +func (c *Pages) fetchPosts(pager *controller.Pager) []post { + pager.SetItems(20) + posts := make([]post, 20) + + for k := range posts { + posts[k] = post{ + Title: fmt.Sprintf("Post example #%d", k+1), + Body: fmt.Sprintf("Lorem ipsum example #%d ddolor sit amet, consectetur adipiscing elit. Nam elementum vulputate tristique.", k+1), + } + } + return posts[pager.GetOffset() : pager.GetOffset()+pager.ItemsPerPage] +} + +func (c *Pages) About(ctx echo.Context) error { page := controller.NewPage(ctx) page.Layout = templates.LayoutMain page.Name = templates.PageAbout diff --git a/pkg/routes/about_test.go b/pkg/handlers/pages_test.go similarity index 89% rename from pkg/routes/about_test.go rename to pkg/handlers/pages_test.go index 34574fa..85c591b 100644 --- a/pkg/routes/about_test.go +++ b/pkg/handlers/pages_test.go @@ -1,4 +1,4 @@ -package routes +package handlers import ( "net/http" @@ -9,7 +9,7 @@ import ( // Simple example of how to test routes and their markup using the test HTTP server spun up within // this test package -func TestAbout_Get(t *testing.T) { +func TestPages__About(t *testing.T) { doc := request(t). setRoute(routeNameAbout). get(). diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go new file mode 100644 index 0000000..cb3c4c5 --- /dev/null +++ b/pkg/handlers/router.go @@ -0,0 +1,66 @@ +package handlers + +import ( + "net/http" + + "github.com/gorilla/sessions" + "github.com/labstack/echo-contrib/session" + echomw "github.com/labstack/echo/v4/middleware" + "github.com/mikestefanello/pagoda/config" + "github.com/mikestefanello/pagoda/pkg/controller" + "github.com/mikestefanello/pagoda/pkg/middleware" + "github.com/mikestefanello/pagoda/pkg/services" +) + +// BuildRouter builds the router +func BuildRouter(c *services.Container) error { + // Static files with proper cache control + // funcmap.File() should be used in templates to append a cache key to the URL in order to break cache + // after each server restart + c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.StaticFile)). + Static(config.StaticPrefix, config.StaticDir) + + // Non-static file route group + g := c.Web.Group("") + + // Force HTTPS, if enabled + if c.Config.HTTP.TLS.Enabled { + g.Use(echomw.HTTPSRedirect()) + } + + g.Use( + echomw.RemoveTrailingSlashWithConfig(echomw.TrailingSlashConfig{ + RedirectCode: http.StatusMovedPermanently, + }), + echomw.Recover(), + echomw.Secure(), + echomw.RequestID(), + echomw.Gzip(), + echomw.Logger(), + middleware.LogRequestID(), + echomw.TimeoutWithConfig(echomw.TimeoutConfig{ + Timeout: c.Config.App.Timeout, + }), + session.Middleware(sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))), + middleware.LoadAuthenticatedUser(c.Auth), + middleware.ServeCachedPage(c.Cache), + echomw.CSRFWithConfig(echomw.CSRFConfig{ + TokenLookup: "form:csrf", + }), + ) + + // Error handler + err := Error{Controller: controller.NewController(c)} + c.Web.HTTPErrorHandler = err.Page + + // Initialize and register all handlers + for _, h := range GetHandlers() { + if err := h.Init(c); err != nil { + return err + } + + h.Routes(g) + } + + return nil +} diff --git a/pkg/routes/routes_test.go b/pkg/handlers/router_test.go similarity index 97% rename from pkg/routes/routes_test.go rename to pkg/handlers/router_test.go index 97c7d91..002d077 100644 --- a/pkg/routes/routes_test.go +++ b/pkg/handlers/router_test.go @@ -1,4 +1,4 @@ -package routes +package handlers import ( "net/http" @@ -30,7 +30,9 @@ func TestMain(m *testing.M) { c = services.NewContainer() // Start a test HTTP server - BuildRouter(c) + if err := BuildRouter(c); err != nil { + panic(err) + } srv = httptest.NewServer(c.Web) // Run tests diff --git a/pkg/routes/search.go b/pkg/handlers/search.go similarity index 66% rename from pkg/routes/search.go rename to pkg/handlers/search.go index 2c83195..fd0d739 100644 --- a/pkg/routes/search.go +++ b/pkg/handlers/search.go @@ -1,17 +1,19 @@ -package routes +package handlers import ( "fmt" "math/rand" - "github.com/mikestefanello/pagoda/pkg/controller" - "github.com/mikestefanello/pagoda/templates" - "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/controller" + "github.com/mikestefanello/pagoda/pkg/services" + "github.com/mikestefanello/pagoda/templates" ) +const routeNameSearch = "search" + type ( - search struct { + Search struct { controller.Controller } @@ -21,7 +23,20 @@ type ( } ) -func (c *search) Get(ctx echo.Context) error { +func init() { + Register(new(Search)) +} + +func (c *Search) Init(ct *services.Container) error { + c.Controller = controller.NewController(ct) + return nil +} + +func (c *Search) Routes(g *echo.Group) { + g.GET("/search", c.Page).Name = routeNameSearch +} + +func (c *Search) Page(ctx echo.Context) error { page := controller.NewPage(ctx) page.Layout = templates.LayoutMain page.Name = templates.PageSearch diff --git a/pkg/routes/forgot_password.go b/pkg/routes/forgot_password.go deleted file mode 100644 index b5c39d2..0000000 --- a/pkg/routes/forgot_password.go +++ /dev/null @@ -1,101 +0,0 @@ -package routes - -import ( - "fmt" - "strings" - - "github.com/mikestefanello/pagoda/ent" - "github.com/mikestefanello/pagoda/ent/user" - "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/controller" - "github.com/mikestefanello/pagoda/pkg/msg" - "github.com/mikestefanello/pagoda/templates" - - "github.com/labstack/echo/v4" -) - -type ( - forgotPassword struct { - controller.Controller - } - - forgotPasswordForm struct { - Email string `form:"email" validate:"required,email"` - Submission controller.FormSubmission - } -) - -func (c *forgotPassword) Get(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutAuth - page.Name = templates.PageForgotPassword - page.Title = "Forgot password" - page.Form = forgotPasswordForm{} - - if form := ctx.Get(context.FormKey); form != nil { - page.Form = form.(*forgotPasswordForm) - } - - return c.RenderPage(ctx, page) -} - -func (c *forgotPassword) Post(ctx echo.Context) error { - var form forgotPasswordForm - ctx.Set(context.FormKey, &form) - - succeed := func() error { - ctx.Set(context.FormKey, nil) - msg.Success(ctx, "An email containing a link to reset your password will be sent to this address if it exists in our system.") - return c.Get(ctx) - } - - // Parse the form values - if err := ctx.Bind(&form); err != nil { - return c.Fail(err, "unable to parse forgot password form") - } - - if err := form.Submission.Process(ctx, form); err != nil { - return c.Fail(err, "unable to process form submission") - } - - if form.Submission.HasErrors() { - return c.Get(ctx) - } - - // Attempt to load the user - u, err := c.Container.ORM.User. - Query(). - Where(user.Email(strings.ToLower(form.Email))). - Only(ctx.Request().Context()) - - switch err.(type) { - case *ent.NotFoundError: - return succeed() - case nil: - default: - return c.Fail(err, "error querying user during forgot password") - } - - // Generate the token - token, pt, err := c.Container.Auth.GeneratePasswordResetToken(ctx, u.ID) - if err != nil { - return c.Fail(err, "error generating password reset token") - } - - ctx.Logger().Infof("generated password reset token for user %d", u.ID) - - // Email the user - url := ctx.Echo().Reverse(routeNameResetPassword, u.ID, pt.ID, token) - err = c.Container.Mail. - Compose(). - To(u.Email). - Subject("Reset your password"). - Body(fmt.Sprintf("Go here to reset your password: %s", url)). - Send(ctx) - - if err != nil { - return c.Fail(err, "error sending password reset email") - } - - return succeed() -} diff --git a/pkg/routes/home.go b/pkg/routes/home.go deleted file mode 100644 index 3495d7e..0000000 --- a/pkg/routes/home.go +++ /dev/null @@ -1,47 +0,0 @@ -package routes - -import ( - "fmt" - - "github.com/mikestefanello/pagoda/pkg/controller" - "github.com/mikestefanello/pagoda/templates" - - "github.com/labstack/echo/v4" -) - -type ( - home struct { - controller.Controller - } - - post struct { - Title string - Body string - } -) - -func (c *home) Get(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutMain - page.Name = templates.PageHome - page.Metatags.Description = "Welcome to the homepage." - page.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"} - page.Pager = controller.NewPager(ctx, 4) - page.Data = c.fetchPosts(&page.Pager) - - return c.RenderPage(ctx, page) -} - -// fetchPosts is an mock example of fetching posts to illustrate how paging works -func (c *home) fetchPosts(pager *controller.Pager) []post { - pager.SetItems(20) - posts := make([]post, 20) - - for k := range posts { - posts[k] = post{ - Title: fmt.Sprintf("Post example #%d", k+1), - Body: fmt.Sprintf("Lorem ipsum example #%d ddolor sit amet, consectetur adipiscing elit. Nam elementum vulputate tristique.", k+1), - } - } - return posts[pager.GetOffset() : pager.GetOffset()+pager.ItemsPerPage] -} diff --git a/pkg/routes/login.go b/pkg/routes/login.go deleted file mode 100644 index aa03fa2..0000000 --- a/pkg/routes/login.go +++ /dev/null @@ -1,95 +0,0 @@ -package routes - -import ( - "fmt" - "strings" - - "github.com/mikestefanello/pagoda/ent" - "github.com/mikestefanello/pagoda/ent/user" - "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/controller" - "github.com/mikestefanello/pagoda/pkg/msg" - "github.com/mikestefanello/pagoda/templates" - - "github.com/labstack/echo/v4" -) - -type ( - login struct { - controller.Controller - } - - loginForm struct { - Email string `form:"email" validate:"required,email"` - Password string `form:"password" validate:"required"` - Submission controller.FormSubmission - } -) - -func (c *login) Get(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutAuth - page.Name = templates.PageLogin - page.Title = "Log in" - page.Form = loginForm{} - - if form := ctx.Get(context.FormKey); form != nil { - page.Form = form.(*loginForm) - } - - return c.RenderPage(ctx, page) -} - -func (c *login) Post(ctx echo.Context) error { - var form loginForm - ctx.Set(context.FormKey, &form) - - authFailed := func() error { - form.Submission.SetFieldError("Email", "") - form.Submission.SetFieldError("Password", "") - msg.Danger(ctx, "Invalid credentials. Please try again.") - return c.Get(ctx) - } - - // Parse the form values - if err := ctx.Bind(&form); err != nil { - return c.Fail(err, "unable to parse login form") - } - - if err := form.Submission.Process(ctx, form); err != nil { - return c.Fail(err, "unable to process form submission") - } - - if form.Submission.HasErrors() { - return c.Get(ctx) - } - - // Attempt to load the user - u, err := c.Container.ORM.User. - Query(). - Where(user.Email(strings.ToLower(form.Email))). - Only(ctx.Request().Context()) - - switch err.(type) { - case *ent.NotFoundError: - return authFailed() - case nil: - default: - return c.Fail(err, "error querying user during login") - } - - // Check if the password is correct - err = c.Container.Auth.CheckPassword(form.Password, u.Password) - if err != nil { - return authFailed() - } - - // Log the user in - err = c.Container.Auth.Login(ctx, u.ID) - if err != nil { - return c.Fail(err, "unable to log in user") - } - - msg.Success(ctx, fmt.Sprintf("Welcome back, %s. You are now logged in.", u.Name)) - return c.Redirect(ctx, routeNameHome) -} diff --git a/pkg/routes/logout.go b/pkg/routes/logout.go deleted file mode 100644 index 0c2888d..0000000 --- a/pkg/routes/logout.go +++ /dev/null @@ -1,21 +0,0 @@ -package routes - -import ( - "github.com/mikestefanello/pagoda/pkg/controller" - "github.com/mikestefanello/pagoda/pkg/msg" - - "github.com/labstack/echo/v4" -) - -type logout struct { - controller.Controller -} - -func (l *logout) Get(c echo.Context) error { - if err := l.Container.Auth.Logout(c); err == nil { - msg.Success(c, "You have been logged out successfully.") - } else { - msg.Danger(c, "An error occurred. Please try again.") - } - return l.Redirect(c, routeNameHome) -} diff --git a/pkg/routes/register.go b/pkg/routes/register.go deleted file mode 100644 index ee09dfa..0000000 --- a/pkg/routes/register.go +++ /dev/null @@ -1,123 +0,0 @@ -package routes - -import ( - "fmt" - - "github.com/mikestefanello/pagoda/ent" - "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/controller" - "github.com/mikestefanello/pagoda/pkg/msg" - "github.com/mikestefanello/pagoda/templates" - - "github.com/labstack/echo/v4" -) - -type ( - register struct { - controller.Controller - } - - registerForm struct { - Name string `form:"name" validate:"required"` - Email string `form:"email" validate:"required,email"` - Password string `form:"password" validate:"required"` - ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` - Submission controller.FormSubmission - } -) - -func (c *register) Get(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutAuth - page.Name = templates.PageRegister - page.Title = "Register" - page.Form = registerForm{} - - if form := ctx.Get(context.FormKey); form != nil { - page.Form = form.(*registerForm) - } - - return c.RenderPage(ctx, page) -} - -func (c *register) Post(ctx echo.Context) error { - var form registerForm - ctx.Set(context.FormKey, &form) - - // Parse the form values - if err := ctx.Bind(&form); err != nil { - return c.Fail(err, "unable to parse register form") - } - - if err := form.Submission.Process(ctx, form); err != nil { - return c.Fail(err, "unable to process form submission") - } - - if form.Submission.HasErrors() { - return c.Get(ctx) - } - - // Hash the password - pwHash, err := c.Container.Auth.HashPassword(form.Password) - if err != nil { - return c.Fail(err, "unable to hash password") - } - - // Attempt creating the user - u, err := c.Container.ORM.User. - Create(). - SetName(form.Name). - SetEmail(form.Email). - SetPassword(pwHash). - Save(ctx.Request().Context()) - - switch err.(type) { - case nil: - ctx.Logger().Infof("user created: %s", u.Name) - case *ent.ConstraintError: - msg.Warning(ctx, "A user with this email address already exists. Please log in.") - return c.Redirect(ctx, routeNameLogin) - default: - return c.Fail(err, "unable to create user") - } - - // Log the user in - err = c.Container.Auth.Login(ctx, u.ID) - if err != nil { - ctx.Logger().Errorf("unable to log in: %v", err) - msg.Info(ctx, "Your account has been created.") - return c.Redirect(ctx, routeNameLogin) - } - - msg.Success(ctx, "Your account has been created. You are now logged in.") - - // Send the verification email - c.sendVerificationEmail(ctx, u) - - return c.Redirect(ctx, routeNameHome) -} - -func (c *register) sendVerificationEmail(ctx echo.Context, usr *ent.User) { - // Generate a token - token, err := c.Container.Auth.GenerateEmailVerificationToken(usr.Email) - if err != nil { - ctx.Logger().Errorf("unable to generate email verification token: %v", err) - return - } - - // Send the email - url := ctx.Echo().Reverse(routeNameVerifyEmail, token) - err = c.Container.Mail. - Compose(). - To(usr.Email). - Subject("Confirm your email address"). - Body(fmt.Sprintf("Click here to confirm your email address: %s", url)). - Send(ctx) - - if err != nil { - ctx.Logger().Errorf("unable to send email verification link: %v", err) - return - } - - msg.Info(ctx, "An email was sent to you to verify your email address.") -} diff --git a/pkg/routes/reset_password.go b/pkg/routes/reset_password.go deleted file mode 100644 index 957796a..0000000 --- a/pkg/routes/reset_password.go +++ /dev/null @@ -1,83 +0,0 @@ -package routes - -import ( - "github.com/mikestefanello/pagoda/ent" - "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/controller" - "github.com/mikestefanello/pagoda/pkg/msg" - "github.com/mikestefanello/pagoda/templates" - - "github.com/labstack/echo/v4" -) - -type ( - resetPassword struct { - controller.Controller - } - - resetPasswordForm struct { - Password string `form:"password" validate:"required"` - ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` - Submission controller.FormSubmission - } -) - -func (c *resetPassword) Get(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutAuth - page.Name = templates.PageResetPassword - page.Title = "Reset password" - page.Form = resetPasswordForm{} - - if form := ctx.Get(context.FormKey); form != nil { - page.Form = form.(*resetPasswordForm) - } - - return c.RenderPage(ctx, page) -} - -func (c *resetPassword) Post(ctx echo.Context) error { - var form resetPasswordForm - ctx.Set(context.FormKey, &form) - - // Parse the form values - if err := ctx.Bind(&form); err != nil { - return c.Fail(err, "unable to parse password reset form") - } - - if err := form.Submission.Process(ctx, form); err != nil { - return c.Fail(err, "unable to process form submission") - } - - if form.Submission.HasErrors() { - return c.Get(ctx) - } - - // Hash the new password - hash, err := c.Container.Auth.HashPassword(form.Password) - if err != nil { - return c.Fail(err, "unable to hash password") - } - - // Get the requesting user - usr := ctx.Get(context.UserKey).(*ent.User) - - // Update the user - _, err = usr. - Update(). - SetPassword(hash). - Save(ctx.Request().Context()) - - if err != nil { - return c.Fail(err, "unable to update password") - } - - // Delete all password tokens for this user - err = c.Container.Auth.DeletePasswordTokens(ctx, usr.ID) - if err != nil { - return c.Fail(err, "unable to delete password tokens") - } - - msg.Success(ctx, "Your password has been updated.") - return c.Redirect(ctx, routeNameLogin) -} diff --git a/pkg/routes/router.go b/pkg/routes/router.go deleted file mode 100644 index f7c5825..0000000 --- a/pkg/routes/router.go +++ /dev/null @@ -1,127 +0,0 @@ -package routes - -import ( - "net/http" - - "github.com/mikestefanello/pagoda/config" - "github.com/mikestefanello/pagoda/pkg/controller" - "github.com/mikestefanello/pagoda/pkg/middleware" - "github.com/mikestefanello/pagoda/pkg/services" - - "github.com/gorilla/sessions" - "github.com/labstack/echo-contrib/session" - - "github.com/labstack/echo/v4" - echomw "github.com/labstack/echo/v4/middleware" -) - -const ( - routeNameForgotPassword = "forgot_password" - routeNameForgotPasswordSubmit = "forgot_password.submit" - routeNameLogin = "login" - routeNameLoginSubmit = "login.submit" - routeNameLogout = "logout" - routeNameRegister = "register" - routeNameRegisterSubmit = "register.submit" - routeNameResetPassword = "reset_password" - routeNameResetPasswordSubmit = "reset_password.submit" - routeNameVerifyEmail = "verify_email" - routeNameContact = "contact" - routeNameContactSubmit = "contact.submit" - routeNameAbout = "about" - routeNameHome = "home" - routeNameSearch = "search" -) - -// BuildRouter builds the router -func BuildRouter(c *services.Container) { - // Static files with proper cache control - // funcmap.File() should be used in templates to append a cache key to the URL in order to break cache - // after each server restart - c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.StaticFile)). - Static(config.StaticPrefix, config.StaticDir) - - // Non static file route group - g := c.Web.Group("") - - // Force HTTPS, if enabled - if c.Config.HTTP.TLS.Enabled { - g.Use(echomw.HTTPSRedirect()) - } - - g.Use( - echomw.RemoveTrailingSlashWithConfig(echomw.TrailingSlashConfig{ - RedirectCode: http.StatusMovedPermanently, - }), - echomw.Recover(), - echomw.Secure(), - echomw.RequestID(), - echomw.Gzip(), - echomw.Logger(), - middleware.LogRequestID(), - echomw.TimeoutWithConfig(echomw.TimeoutConfig{ - Timeout: c.Config.App.Timeout, - }), - session.Middleware(sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))), - middleware.LoadAuthenticatedUser(c.Auth), - middleware.ServeCachedPage(c.Cache), - echomw.CSRFWithConfig(echomw.CSRFConfig{ - TokenLookup: "form:csrf", - }), - ) - - // Base controller - ctr := controller.NewController(c) - - // Error handler - err := errorHandler{Controller: ctr} - c.Web.HTTPErrorHandler = err.Get - - // Example routes - navRoutes(c, g, ctr) - userRoutes(c, g, ctr) -} - -func navRoutes(c *services.Container, g *echo.Group, ctr controller.Controller) { - home := home{Controller: ctr} - g.GET("/", home.Get).Name = routeNameHome - - search := search{Controller: ctr} - g.GET("/search", search.Get).Name = routeNameSearch - - about := about{Controller: ctr} - g.GET("/about", about.Get).Name = routeNameAbout - - contact := contact{Controller: ctr} - g.GET("/contact", contact.Get).Name = routeNameContact - g.POST("/contact", contact.Post).Name = routeNameContactSubmit -} - -func userRoutes(c *services.Container, g *echo.Group, ctr controller.Controller) { - logout := logout{Controller: ctr} - g.GET("/logout", logout.Get, middleware.RequireAuthentication()).Name = routeNameLogout - - verifyEmail := verifyEmail{Controller: ctr} - g.GET("/email/verify/:token", verifyEmail.Get).Name = routeNameVerifyEmail - - noAuth := g.Group("/user", middleware.RequireNoAuthentication()) - login := login{Controller: ctr} - noAuth.GET("/login", login.Get).Name = routeNameLogin - noAuth.POST("/login", login.Post).Name = routeNameLoginSubmit - - register := register{Controller: ctr} - noAuth.GET("/register", register.Get).Name = routeNameRegister - noAuth.POST("/register", register.Post).Name = routeNameRegisterSubmit - - forgot := forgotPassword{Controller: ctr} - noAuth.GET("/password", forgot.Get).Name = routeNameForgotPassword - noAuth.POST("/password", forgot.Post).Name = routeNameForgotPasswordSubmit - - resetGroup := noAuth.Group("/password/reset", - middleware.LoadUser(c.ORM), - middleware.LoadValidPasswordToken(c.Auth), - ) - reset := resetPassword{Controller: ctr} - resetGroup.GET("/token/:user/:password_token/:token", reset.Get).Name = routeNameResetPassword - resetGroup.POST("/token/:user/:password_token/:token", reset.Post).Name = routeNameResetPasswordSubmit -} diff --git a/pkg/routes/verify_email.go b/pkg/routes/verify_email.go deleted file mode 100644 index 9bc6f40..0000000 --- a/pkg/routes/verify_email.go +++ /dev/null @@ -1,62 +0,0 @@ -package routes - -import ( - "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/ent" - "github.com/mikestefanello/pagoda/ent/user" - "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/controller" - "github.com/mikestefanello/pagoda/pkg/msg" -) - -type verifyEmail struct { - controller.Controller -} - -func (c *verifyEmail) Get(ctx echo.Context) error { - var usr *ent.User - - // Validate the token - token := ctx.Param("token") - email, err := c.Container.Auth.ValidateEmailVerificationToken(token) - if err != nil { - msg.Warning(ctx, "The link is either invalid or has expired.") - return c.Redirect(ctx, routeNameHome) - } - - // Check if it matches the authenticated user - if u := ctx.Get(context.AuthenticatedUserKey); u != nil { - authUser := u.(*ent.User) - - if authUser.Email == email { - usr = authUser - } - } - - // Query to find a matching user, if needed - if usr == nil { - usr, err = c.Container.ORM.User. - Query(). - Where(user.Email(email)). - Only(ctx.Request().Context()) - - if err != nil { - return c.Fail(err, "query failed loading email verification token user") - } - } - - // Verify the user, if needed - if !usr.Verified { - usr, err = usr. - Update(). - SetVerified(true). - Save(ctx.Request().Context()) - - if err != nil { - return c.Fail(err, "failed to set user as verified") - } - } - - msg.Success(ctx, "Your email has been successfully verified.") - return c.Redirect(ctx, routeNameHome) -}