Web Apps with Buffalo - Part II

Introduction

Welcome back! This is the second part of a two part series on building web applications with Buffalo. The first part of the series can be found at https://mmikael.com/post/buffalo-part1. You can find the source code of the final version of the app at https://github.com/mikaelm1/Blog-App-Buffalo. In this tutorial, we will implement the functionality for creating posts and for commenting on posts.

Table of Contents

Creating Posts

Last time we let Buffalo generate both the action and model for users. From now on we will only generate actions with the Buffalo CLI and create the models manually. Let’s generate the actions for our posts:

buffalo g a posts index create edit delete detail

This will generate actions, templates, and register the actions with the app. I like to group routes by functionality, so let’s first update the way we will register the post routes in actions/app.go by replacing all the auto generated registers with the following:

postGroup := app.Group("/posts")
postGroup.GET("/index", PostsIndex)

Now that we have the route group set up, let’s implement the actions that will handle creating posts. Replace this action in actions/posts.go:

// PostsCreate default implementation.
func PostsCreate(c buffalo.Context) error {
	return c.Render(200, r.HTML("posts/create.html"))
}

with these two actions:

func PostsCreateGet(c buffalo.Context) error {
	c.Set("post", &models.Post{})
	return c.Render(200, r.HTML("posts/create"))
}

func PostsCreatePost(c buffalo.Context) error {
	// Allocate an empty Post
	post := &models.Post{}
	user := c.Value("current_user").(*models.User)
	// Bind post to the html form elements
	if err := c.Bind(post); err != nil {
		return errors.WithStack(err)
	}
	// Get the DB connection from the context
	tx := c.Value("tx").(*pop.Connection)
	// Validate the data from the html form
	post.AuthorID = user.ID
	verrs, err := tx.ValidateAndCreate(post)
	if err != nil {
		return errors.WithStack(err)
	}
	if verrs.HasAny() {
		c.Set("post", post)
		c.Set("errors", verrs.Errors)
		return c.Render(422, r.HTML("posts/create"))
	}
	// If there are no errors set a success message
	c.Flash().Add("success", "New post added successfully.")
	// and redirect to the index page
	return c.Redirect(302, "/")
}

Now let’s add the form view that will be rendered by these actions. Replace everything in templates/posts/create.html with this:

<div class="row">
    <div class="col">
        <%= if (errors) { %>
            <%= for (key, val) in errors { %>
                <div class="alert alert-danger alert-dismissible fade show m-1" role="alert">
                    <%= val %>
                    <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                    <span aria-hidden="true">&times;</span>
                    </button>
                </div>
            <% } %>
        <% } %>
    </div>
</div>
<div class="row mt-3 justify-content-center">
    <div class="col-md-8 col-sm-10">
        <h2>Create a new post</h2>
        <form action="<%= postsCreatePath() %>" method="POST">
            <%= csrf() %>
            <div class="form-group">
                <label for="title">Title</label>
                <input type="text" name="Title" class="form-control" id="title" value="<%= post.Title %>">
            </div>
            <div class="form-group">
                <label for="content">Content</label>
                <textarea class="form-control" name="Content" id="content"  rows="20"><%= post.Content %></textarea>
            </div>
            <button type="submit" class="btn btn-primary">Publish</button>
        </form>
    </div>
</div>

This should remind you of how we were creating users in the first tutorial. We render a view with a form and then process that form in a POST handler, if the fields pass all validators, we create the object and redirect the user to another page. You’ll get an error at this point because we haven’t defined the post model yet and we’ll fix that soon.

In this app we only want users who are admins to be able to create posts, so before we create the models let’s add a new middleware function that will only allow admin users through to the action. The function signature is exactly the same as the one we created in the first tutorial when we were placing the user on the Context. Add this new middleware below the SetCurrentUser in actions/userss.go:

// AdminRequired requires a user to be logged in and to be an admin before accessing a route.
func AdminRequired(next buffalo.Handler) buffalo.Handler {
	return func(c buffalo.Context) error {
		user, ok := c.Value("current_user").(*models.User)
		if ok && user.Admin {
			return next(c)
		}
		c.Flash().Add("danger", "You are not authorized to view that page.")
		return c.Redirect(302, "/")
	}
}

Now let’s register the create routes with the app along with the admin middleware:

postGroup := app.Group("/posts")
postGroup.GET("/index", PostsIndex)
postGroup.GET("/create", AdminRequired(PostsCreateGet))
postGroup.POST("/create", AdminRequired(PostsCreatePost))

The main difference between the AdminRequired middleware and the SetCurrentUser middleware is that the AdminRequired middleware is applied to specific routes individually and so will only be run when the user attempts to go to /posts/create.

Now that we have the actions and middleware set up, let’s create the model. Add a new file in models called post.go and paste in the following:

package models

import (
	"time"

	"github.com/markbates/pop"
	"github.com/markbates/validate"
	"github.com/markbates/validate/validators"
	uuid "github.com/satori/go.uuid"
)

type Post struct {
	ID        uuid.UUID `json:"id" db:"id"`
	CreatedAt time.Time `json:"created_at" db:"created_at"`
	UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
	Title     string    `json:"title" db:"title"`
	Content   string    `json:"content" db:"content"`
	AuthorID  uuid.UUID `json:"author_id" db:"author_id"`
}

type Posts []Post

// Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method.
func (p *Post) Validate(tx *pop.Connection) (*validate.Errors, error) {
	return validate.Validate(
		&validators.StringIsPresent{Field: p.Title, Name: "Title"},
		&validators.StringIsPresent{Field: p.Content, Name: "Content"},
	), nil
}

Next, we’ll need to add migrations for this model:

buffalo db g fizz create_posts

This will generate migrations files for both up and down. Inside the down file, which will be called something like $TIMESTAMP_create_posts.down.fizz, add the following:

drop_table("comments")

And inside the up migration add:

create_table("posts", func(t) {
	t.Column("id", "uuid", {"primary": true})
	t.Column("title", "string", {})
	t.Column("content", "text", {})
	t.Column("author_id", "uuid", {})
})

in order to create the posts table with its fields. Now we can apply these migrations:

buffalo db migrate up

Now if you log in and try to go to /posts/create you will be redirected back to the home page with an error message saying that you’re not authorized to view that page. This is because none of the users are admins yet. We’ll make one of our users into admins. First, make sure you have registered at least one user. Then, log into the SQLite database:

sqlite3 blog_app2_development.sqlite

And update your user’s admin flag to be true:

sqlite> update users set admin = 1 where username = '$USERNAME';
sqlite> .quit

If you try visiting /posts/create now, you won’t be redirected and can create a post. Add whatever markdown you want inside the post content.

Viewing Posts

Now that we can create posts, we want to be able to view them. We want to be able to view a list of all the posts and to be able to view each of them individually. Let’s work on the list view first. Update the PostsIndex to this:

// PostsIndex default implementation.
func PostsIndex(c buffalo.Context) error {
	tx := c.Value("tx").(*pop.Connection)
	posts := &models.Posts{}
	// Paginate results. Params "page" and "per_page" control pagination.
	// Default values are "page=1" and "per_page=20".
	q := tx.PaginateFromParams(c.Params())
	// Retrieve all Posts from the DB
	if err := q.All(posts); err != nil {
		return errors.WithStack(err)
	}
	// Make posts available inside the html template
	c.Set("posts", posts)
	// Add the paginator to the context so it can be used in the template.
	c.Set("pagination", q.Paginator)
	return c.Render(200, r.HTML("posts/index.html"))
}

This handler retrieves all the posts from the database and displays them in a paginated list. Next, update the navigation bar’s Posts link to point to this handler’s route:

<a class="nav-link" href="<%= postsIndexPath() %>">Posts</a>

And finally, let’s update the posts index view:

<div class="row">
    <div class="col-md-3 offset-md-9">
        <%= if (current_user.Admin) { %>
            <a href="<%= postsCreatePath() %>" class="btn btn-primary">Add Post</a>
        <% } %>
    </div>
</div>
<div class="row">
    <div class="col-md-8">
        <%= for (p) in posts { %>
            <hr>
            <a href="#"><h1><%= p.Title %></h1></a>
            <p><%= markdown(truncate(p.Content, {"size": 200})) %></p>
        <% } %>
    </div>
</div>
<div class="row">
    <div class="col">
        <%= paginator(pagination) %>
    </div>
</div>

This view displays all the posts and if the user is admin, shows a button to create a new post. This line:

<p><%= markdown(truncate(p.Content, {"size": 200})) %></p>

uses two of Buffalo’s builtin template helpers. First, it truncates the size of the post’s content to at most display only the first 200 characters. It then translates the markdown post content to html.

Now let’s add the action that will handle displaying the full post. Inside actions/posts.go update the PostsDetail handler to this:

// PostsDetail displays a single post.
func PostsDetail(c buffalo.Context) error {
	tx := c.Value("tx").(*pop.Connection)
	post := &models.Post{}
	if err := tx.Find(post, c.Param("pid")); err != nil {
		return c.Error(404, err)
	}
	author := &models.User{}
	if err := tx.Find(author, post.AuthorID); err != nil {
		return c.Error(404, err)
	}
	c.Set("post", post)
	c.Set("author", author)
	return c.Render(200, r.HTML("posts/detail"))
}

and register the handler:

postGroup.GET("/detail/{pid}", PostsDetail)

The pid in the URL is a named parameter that will be the ID of the post, which we will use to find the post in the database. Update templates/posts/detail.html:

<div class="row mt-5">
    <div class="col-md-8 offset-md-2">
        <br>
        <h2>Comments</h2>
        <%= if (current_user) { %>
            <form action="<%= commentsCreatePath({pid: post.ID}) %>" method="POST">
                <%= csrf() %>
                <div class="form-group">
                    <label for="comment">Add Comment</label>
                    <textarea class="form-control" name="Content" id="content"  rows="5"><%= comment.Content %></textarea>
                </div>
                <button type="submit" class="btn btn-primary">Submit</button>
            </form>
        <% } else { %>
            <a href="<%= usersLoginPath() %>" class="btn btn-primary">Login to Comment</a>
        <% } %>
    </div>
</div>
<div class="row">
    <div class="col-md-8 offset-md-2">
        <%= for (c) in comments { %>
            <hr>
            <p class="author"><%= c.Author.Username %></p>
            <p style="white-space: pre-wrap;"><%= c.Content %></p>
            <!-- Emails are unique, and '==' does not work with UUIDs -->
            <%= if (current_user.Email == c.Author.Email) { %>
                <a href="<%= commentsDeletePath({cid: c.ID}) %>" class="btn btn-danger btn-sm m-0">Delete comment</a>
                <a href="<%= editCommentsPath({cid: c.ID}) %>" class="btn btn-primary btn-sm m-0">Edit comment</a>
            <% } %>
        <% } %>
    </div>
</div>

The template shows the full post content and if the user is an admin, it shows buttons to edit and delete the post.

Editing Posts

Now that we can create and view posts, let’s implement the functionality for editing it. Replace the PostsEdit handler with the new handlers for editing a post in actions/posts.go:

// PostsEditGet displays a form to edit the post.
func PostsEditGet(c buffalo.Context) error {
	tx := c.Value("tx").(*pop.Connection)
	post := &models.Post{}
	if err := tx.Find(post, c.Param("pid")); err != nil {
		return c.Error(404, err)
	}
	c.Set("post", post)
	return c.Render(200, r.HTML("posts/edit.html"))
}

// PostsEditPost updates a post.
func PostsEditPost(c buffalo.Context) error {
	tx := c.Value("tx").(*pop.Connection)
	post := &models.Post{}
	if err := tx.Find(post, c.Param("pid")); err != nil {
		return c.Error(404, err)
	}
	if err := c.Bind(post); err != nil {
		return errors.WithStack(err)
	}
	verrs, err := tx.ValidateAndUpdate(post)
	if err != nil {
		return errors.WithStack(err)
	}
	if verrs.HasAny() {
		c.Set("post", post)
		c.Set("errors", verrs.Errors)
		return c.Render(422, r.HTML("posts/edit.html"))
	}
	c.Flash().Add("success", "Post was updated successfully.")
	return c.Redirect(302, "/posts/detail/%s", post.ID)
}

and register the handlers with the app:

postGroup.GET("/edit/{pid}", AdminRequired(PostsEditGet))
postGroup.POST("/edit/{pid}", AdminRequired(PostsEditPost))

The pid values in the URL are named parameters and we can call them whatever we want. These will be the IDs of the post being edited. The value of a named parameter can be retrieved from the Context as shown here:

c.Param("pid")

which returns the value of a parameter named pid. We’re also wrapping the handlers in the AdminRequired middleware because we only want to allow admins to edit posts. At this point, you should be used to how the handlers are implemented as they follow the same structure as previous ones we’ve done. Now replace templates/posts/edit.html with this:

<div class="row">
    <div class="col">
        <%= if (errors) { %>
            <%= for (key, val) in errors { %>
                <div class="alert alert-danger alert-dismissible fade show m-1" role="alert">
                    <%= val %>
                    <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                    <span aria-hidden="true">&times;</span>
                    </button>
                </div>
            <% } %>
        <% } %>
    </div>
</div>
<div class="row mt-3 justify-content-center">
    <div class="col-md-8 col-sm-10">
        <h2>Edit this post</h2>
        <form action="<%= editPostsPath({pid: post.ID}) %>" method="POST">
            <%= csrf() %>
            <div class="form-group">
                <label for="title">Title</label>
                <input type="text" name="Title" class="form-control" id="title" value="<%= post.Title %>">
            </div>
            <div class="form-group">
                <label for="content">Content</label>
                <textarea class="form-control" name="Content" id="content"  rows="20"><%= post.Content %></textarea>
            </div>
            <button type="submit" class="btn btn-primary">Update</button>
        </form>
    </div>
</div>

And update the URL of the edit button in templates/posts/detail.html:

<a href="<%= editPostsPath({pid: post.ID}) %>" class="btn btn-primary">Edit Post</a>

And you can now update your posts if you login with an admin user.

Deleting Posts

We also need to be able to delete posts. To do so, update the PostsDelete handler to this:

func PostsDelete(c buffalo.Context) error {
	tx := c.Value("tx").(*pop.Connection)
	post := &models.Post{}
	if err := tx.Find(post, c.Param("pid")); err != nil {
		return c.Error(404, err)
	}
	if err := tx.Destroy(post); err != nil {
		return errors.WithStack(err)
	}
	c.Flash().Add("success", "Post was successfully deleted.")
	return c.Redirect(302, "/posts/index")
}

This retrieves the Post object by the ID passed in as a parameter value and deletes it from the database. Next, register the handler with the app:

postGroup.GET("/delete/{pid}", AdminRequired(PostsDelete))

And update the url in templates/posts/detail.html:

<a href="<%= postsDeletePath({pid: post.ID}) %>" class="btn btn-danger">Delete Post</a>

Admins can now delete posts.

Creating Comments

Now that we have posts, we can implement the functionality for commenting on posts. We will create a Comment model that will have a relationship to both users and posts. The relationships will be a One-to-Many because a user can create many comments and a post can have many comments. Let’s generate the actions first:

buffalo g a comments create edit delete

Replace the CommentsCreate handler in actions/comments.go with this:

func CommentsCreatePost(c buffalo.Context) error {
	comment := &models.Comment{}
	user := c.Value("current_user").(*models.User)
	if err := c.Bind(comment); err != nil {
		return errors.WithStack(err)
	}
	tx := c.Value("tx").(*pop.Connection)
	comment.AuthorID = user.ID
	postID, err := uuid.FromString(c.Param("pid"))
	if err != nil {
		return errors.WithStack(err)
	}
	comment.PostID = postID
	verrs, err := tx.ValidateAndCreate(comment)
	if err != nil {
		return errors.WithStack(err)
	}
	if verrs.HasAny() {
		c.Flash().Add("danger", "There was an error adding your comment.")
		return c.Redirect(302, "/posts/detail/%s", c.Param("pid"))
	}
	c.Flash().Add("success", "Comment added successfully.")
	return c.Redirect(302, "/posts/detail/%s", c.Param("pid"))
}

And update the templates/posts/detail.html to add a form to create comments and to also display all the comments that belong to the post:

<%= if (current_user.Admin) { %>
    <div class="row">
        <div class="col-md-3 offset-md-9">
            <a href="<%= editPostsPath({pid: post.ID}) %>" class="btn btn-primary">Edit Post</a>
            <a href="<%= postsDeletePath({pid: post.ID}) %>" class="btn btn-danger">Delete Post</a>
        </div>
    </div>
<% } %>
<div class="row">
    <div class="col-md-8 offset-md-2">
        <h1 class="text-center"><%= post.Title %></h1>
        <p>by <span class="author"><%= author.Username %></span></p>
        <p><%= markdown(post.Content) %></p>
    </div>
</div>
<div class="row mt-5">
    <div class="col-md-8 offset-md-2">
        <br>
        <h2>Comments</h2>
        <%= if (current_user) { %>
            <form action="<%= commentsCreatePath({pid: post.ID}) %>" method="POST">
                <%= csrf() %>
                <div class="form-group">
                    <label for="comment">Add Comment</label>
                    <textarea class="form-control" name="Content" id="content"  rows="5"><%= comment.Content %></textarea>
                </div>
                <button type="submit" class="btn btn-primary">Submit</button>
            </form>
        <% } else { %>
            <a href="<%= usersLoginPath() %>" class="btn btn-primary">Login to Comment</a>
        <% } %>
    </div>
</div>
<div class="row">
    <div class="col-md-8 offset-md-2">
        <%= for (c) in comments { %>
            <hr>
            <p class="author"><%= c.Author.Username %></p>
            <p style="white-space: pre-wrap;"><%= c.Content %></p>
        <% } %>
    </div>
</div>

Register the handler with the app and remove all the auto-generated registers:

commentsGroup := app.Group("/comments")
commentsGroup.POST("/create/{pid}", CommentsCreatePost)

And update the PostsDetail handler so that it will show all the comments for the post in the detail page:

// PostsDetail displays a single post.
func PostsDetail(c buffalo.Context) error {
	tx := c.Value("tx").(*pop.Connection)
	post := &models.Post{}
	if err := tx.Find(post, c.Param("pid")); err != nil {
		return c.Error(404, err)
	}
	author := &models.User{}
	if err := tx.Find(author, post.AuthorID); err != nil {
		return c.Error(404, err)
	}
	c.Set("post", post)
	c.Set("author", author)
	comment := &models.Comment{}
	c.Set("comment", comment)
	comments := models.Comments{}
	if err := tx.BelongsTo(post).All(&comments); err != nil {
		return errors.WithStack(err)
	}
	for i := 0; i < len(comments); i++ {
		u := models.User{}
		if err := tx.Find(&u, comments[i].AuthorID); err != nil {
			return c.Error(404, err)
		}
		comments[i].Author = u
	}
	c.Set("comments", comments)
	return c.Render(200, r.HTML("posts/detail"))
}

The updated handler first gets the post from the database. Then it gets all the comments for the post, and finally it finds the user object for each comment. At this point, you’ll get an error because we haven’t defined the Comment model yet, so let’s add it now. Add a comment.go file in the models directory and paste this in there:

package models

import (
	"time"

	"github.com/markbates/pop"
	"github.com/markbates/validate"
	"github.com/markbates/validate/validators"
	uuid "github.com/satori/go.uuid"
)

type Comment struct {
	ID        uuid.UUID `json:"id" db:"id"`
	CreatedAt time.Time `json:"created_at" db:"created_at"`
	UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
	Content   string    `json:"content" db:"content"`
	AuthorID  uuid.UUID `json:"author_id" db:"author_id"`
	PostID    uuid.UUID `json:"post_id" db:"post_id"`
	Author    User      `json:"-" db:"-"`
}

type Comments []Comment

// Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method.
func (c *Comment) Validate(tx *pop.Connection) (*validate.Errors, error) {
	return validate.Validate(
		&validators.StringIsPresent{Field: c.Content, Name: "Content"},
	), nil
}

We need to create migrations for this new model. Generate the migration files:

buffalo db g fizz create_comments

Add this in the down file:

drop_table("comments")

And add this in the up file:

create_table("comments", func(t) {
	t.Column("id", "uuid", {"primary": true})
	t.Column("content", "text", {})
	t.Column("author_id", "uuid", {})
    t.Column("post_id", "uuid", {})
})

Finally, let’s apply the migrations:

buffalo db migrate up 

You should now be able to create comments for a post. The problem is that there isn’t much stopping someone from sending a POST request to the handler to create a new comment without being logged in. So let’s add a new middleware that will only allow users who are logged in. Add it in actions/users.go:

func LoginRequired(next buffalo.Handler) buffalo.Handler {
	return func(c buffalo.Context) error {
		_, ok := c.Value("current_user").(*models.User)
		if ok {
			return next(c)
		}
		c.Flash().Add("danger", "You are not authorized to view that page.")
		return c.Redirect(302, "/")
	}
}

All the comment related handlers should require a logged in user, so we can add this to the comments group in actions/app.go:

commentsGroup := app.Group("/comments")
commentsGroup.Use(LoginRequired)
commentsGroup.POST("/create/{pid}", CommentsCreatePost)

The LoginRequired middleware will be applied to all the handlers in the commentsGroup.

Editing and Deleting Comments

Now that users can create comments, they should be able to edit and delete them as well. First, replace CommentsEditGet and CommentsDelete with these handlers:

func CommentsEditGet(c buffalo.Context) error {
	tx := c.Value("tx").(*pop.Connection)
	user := c.Value("current_user").(*models.User)
	comment := &models.Comment{}
	if err := tx.Find(comment, c.Param("cid")); err != nil {
		return c.Error(404, err)
	}
	// make sure the comment was made by the logged in user
	if user.ID != comment.AuthorID {
		c.Flash().Add("danger", "You are not authorized to view that page.")
		return c.Redirect(302, "/posts/detail/%s", comment.PostID)
	}
	c.Set("comment", comment)
	return c.Render(200, r.HTML("comments/edit.html"))
}

func CommentsEditPost(c buffalo.Context) error {
	tx := c.Value("tx").(*pop.Connection)
	comment := &models.Comment{}
	if err := tx.Find(comment, c.Param("cid")); err != nil {
		return c.Error(404, err)
	}
	if err := c.Bind(comment); err != nil {
		return errors.WithStack(err)
	}
	user := c.Value("current_user").(*models.User)
	// make sure the comment was made by the logged in user
	if user.ID != comment.AuthorID {
		c.Flash().Add("danger", "You are not authorized to view that page.")
		return c.Redirect(302, "/posts/detail/%s", comment.PostID)
	}
	verrs, err := tx.ValidateAndUpdate(comment)
	if err != nil {
		return errors.WithStack(err)
	}
	if verrs.HasAny() {
		c.Set("comment", comment)
		c.Set("errors", verrs.Errors)
		return c.Render(422, r.HTML("comments/edit.html"))
	}
	c.Flash().Add("success", "Comment updated successfully.")
	return c.Redirect(302, "/posts/detail/%s", comment.PostID)
}

func CommentsDelete(c buffalo.Context) error {
	tx := c.Value("tx").(*pop.Connection)
	comment := &models.Comment{}
	if err := tx.Find(comment, c.Param("cid")); err != nil {
		return c.Error(404, err)
	}
	user := c.Value("current_user").(*models.User)
	// only admins and comment creators can delete the comment
	if user.ID != comment.AuthorID && user.Admin == false {
		c.Flash().Add("danger", "You are not authorized to view that page.")
		return c.Redirect(302, "/posts/detail/%s", comment.PostID)
	}
	if err := tx.Destroy(comment); err != nil {
		return errors.WithStack(err)
	}
	c.Flash().Add("success", "Comment deleted successfuly.")
	return c.Redirect(302, "/posts/detail/%s", comment.PostID)
}

And register the handlers with the app:

commentsGroup.GET("/edit/{cid}", CommentsEditGet)
commentsGroup.POST("/edit/{cid}", CommentsEditPost)
commentsGroup.GET("/delete/{cid}", CommentsDelete)

Update templates/posts/detail.html to show buttons to edit and delete comments:

<div class="row">
    <div class="col-md-8 offset-md-2">
        <%= for (c) in comments { %>
            <hr>
            <p class="author"><%= c.Author.Username %></p>
            <p style="white-space: pre-wrap;"><%= c.Content %></p>
            <%= if (current_user.Email == c.Author.Email) { %>
                <a href="<%= commentsDeletePath({cid: c.ID}) %>" class="btn btn-danger btn-sm m-0">Delete comment</a>
                <a href="<%= editCommentsPath({cid: c.ID}) %>" class="btn btn-primary btn-sm m-0">Edit comment</a>
            <% } %>
        <% } %>
    </div>
</div>

And replace everything in templates/comments/edit.html with the code below in order to display the page with a form to edit the comment:

<div class="row">
    <div class="col">
        <%= if (errors) { %>
            <%= for (key, val) in errors { %>
                <div class="alert alert-danger alert-dismissible fade show m-1" role="alert">
                    <%= val %>
                    <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                    <span aria-hidden="true">&times;</span>
                    </button>
                </div>
            <% } %>
        <% } %>
    </div>
</div>
<div class="row mt-3 justify-content-center">
    <div class="col-md-8 col-sm-10">
        <h2>Edit comment</h2>
        <form action="<%= editCommentsPath({cid: comment.ID}) %>" method="POST">
            <%= csrf() %>
            <div class="form-group">
                <label for="content">Content</label>
                <textarea class="form-control" name="Content" id="content"  rows="5"><%= comment.Content %></textarea>
            </div>
            <button type="submit" class="btn btn-primary">Update</button>
        </form>
    </div>
</div>

You can now edit and delete your own comments or all comments if you’re an admin. That wraps up the application. You now have a fully working blogging app.

Conclusion

In this tutorial we implemented the functionality for creating, editing and deleting posts, and we added a custom middleware to only allow admins to access specific routes. We then created handlers for commenting on posts and editing those comments, along with another custom middleware to only allow logged in users to comment. I hope you found this series useful and feel confident enough to build your own projects with Buffalo.