Switch from routes to self-registering handlers to group related routes.

This commit is contained in:
mikestefanello 2024-06-09 12:31:30 -04:00
parent 30389de16f
commit 2c635b5c75
19 changed files with 719 additions and 717 deletions

View File

@ -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

View File

@ -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() {

451
pkg/handlers/auth.go Normal file
View File

@ -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, <strong>%s</strong>. 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)
}

View File

@ -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)
}

View File

@ -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

27
pkg/handlers/handlers.go Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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().

66
pkg/handlers/router.go Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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()
}

View File

@ -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]
}

View File

@ -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, <strong>%s</strong>. You are now logged in.", u.Name))
return c.Redirect(ctx, routeNameHome)
}

View File

@ -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)
}

View File

@ -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.")
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}