Writing a Blog Engine in Phoenix and Elixir: Part 8, finishing comments

Latest Update: 08/02/2016

Timmy better be trapped in a well! All of your comments on my blog are just “bark bark woof woof”!

Previous Post in this series

Current Versions

  • Elixir: v1.3.1
  • Phoenix: v1.2.0
  • Ecto: v2.0.2

Where We Left Off

When the last tutorial completed, we had a nice little starting UI going; we could add new comments to a post and we could get a listing of every comment on our post, but we still have some features we need to develop.

  1. We need to remove the “Approve/Delete” buttons if the user is not logged in or the creator of the post/an admin
  2. We need the “Approve/Delete” buttons to actually do something
  3. We should not show unapproved post if we’re not the authorized person for that post

We’ll start off by making sure our authorization model is all set, since that will dictate features 1 and 3.

Adding An Authorization Flag to Show

Open up web/controllers/post_controller.ex and down at the bottom you’ll see a lot of logic regarding authorization that we already have. Instead of adding even more logic, let’s refactor our authorization code a little bit and reuse it.

We’ll start by moving our user authorization check into a private function:

defp is_authorized_user?(conn) do
user = get_session(conn, :current_user)
(user && (Integer.to_string(user.id) == conn.params["user_id"] || Pxblog.RoleChecker.is_admin?(user)))
end

And then we’ll modify our existing authorize_user function to use this new function instead:

defp authorize_user(conn, _opts) do
if is_authorized_user?(conn) do
conn
else
conn
|> put_flash(:error, "You are not authorized to modify that post!")
|> redirect(to: page_path(conn, :index))
|> halt
end
end

And we’ll quickly run our tests to make sure we didn’t break anything.

$ mix test
Compiled web/controllers/post_controller.ex
.......................................................................
Finished in 1.3 seconds (0.5s on load, 0.7s on tests)
71 tests, 0 failures
Randomized with seed 501973

Wonderful! Next, we’ll need to add a new plug to set the authorization flag that we’ll be using on our template, reusing that handy helper function we just created:

# Add to the top of the controller with the other plug declarations
plug :set_authorization_flag
# Add to the bottom with the other plug definitions
defp set_authorization_flag(conn, _opts) do
assign(conn, :author_or_admin, is_authorized_user?(conn))
end

We could verify this works in two ways:

  1. Start hooking all the code up in our templates and see what happens
  2. Start writing tests

Let’s write tests instead of testing things the manual way (hey, we need to write the tests anyways)!

Writing Tests For Our Authorization Check

We’ll get rid of our initial “Shows chosen resource” test and instead replace it with four other tests. We have four tests we want to write: when is_authorized_user? returns true for being an author (or for an admin), when it returns false for the wrong logged in user, and when it returns false for no logged in user. In test/controllers/post_controller_test.exs, we need to modify the setup block, add a logout function, and add some new tests. First, the setup block and helper functions:

setup do
role = insert(:role)
user = insert(:user, role: role)
other_user = insert(:user, role: role)
post = insert(:post, user: user)
admin_role = insert(:role, admin: true)
admin = insert(:user, role: admin_role)
conn = build_conn() |> login_user(user)
{:ok, conn: conn, user: user, other_user: other_user, role: role, post: post, admin: admin}
end

defp login_user(conn, user) do
post conn, session_path(conn, :create), user: %{username: user.username, password: user.password}
end

defp logout_user(conn, user) do
delete conn, session_path(conn, :delete, user)
end

And then add some tests for the new logic expectations:

test "when logged in as the author, shows chosen resource with author flag set to true", %{conn: conn, user: user, post: post} do
conn = login_user(conn, user) |> get(user_post_path(conn, :show, user, post))
assert html_response(conn, 200) =~ "Show post"
assert conn.assigns[:author_or_admin]
end

test "when logged in as an admin, shows chosen resource with author flag set to true", %{conn: conn, user: user, admin: admin, post: post} do
conn = login_user(conn, admin) |> get(user_post_path(conn, :show, user, post))
assert html_response(conn, 200) =~ "Show post"
assert conn.assigns[:author_or_admin]
end

test "when not logged in, shows chosen resource with author flag set to false", %{conn: conn, user: user, post: post} do
conn = logout_user(conn, user) |> get(user_post_path(conn, :show, user, post))
assert html_response(conn, 200) =~ "Show post"
refute conn.assigns[:author_or_admin]
end

test "when logged in as a different user, shows chosen resource with author flag set to false", %{conn: conn, user: user, other_user: other_user, post: post} do
conn = login_user(conn, other_user) |> get(user_post_path(conn, :show, user, post))
assert html_response(conn, 200) =~ "Show post"
refute conn.assigns[:author_or_admin]
end

And now we'll run our tests again to make sure everything is still good:

$ mix test
..........................................................................
Finished in 1.2 seconds (0.5s on load, 0.7s on tests)
74 tests, 0 failures
Randomized with seed 206903

Wonderful! Now to start hooking up our UI since we know our new flag works as expected!

Hooking Up The Delete Button

First, let’s open up our Post Show template (web/templates/post/show.html.eex) and modify our listing of comments to pass our new author_or_admin flag:

<div class="comments">
<h2>Comments</h2>
<%= for comment <- @post.comments do %>
<%= render Pxblog.CommentView,
"comment.html",
comment: comment,
author_or_admin: @conn.assigns[:author_or_admin],
conn: @conn,
post: @post
%>
<% end %>
</div>

Notice we had to do a lot in this render function. First, we don’t want to use @author_or_admin since we don’t necessarily have a guarantee those will be set. We’ll instead use @conn.assigns, which is safer. Next, since we’ll need to post to specific routes using the link helper, we’ll need to pass in both @conn and @post.

Now, each comment template will be able to read the new flag we created, so we’ll jump to that template next (web/templates/comment/comment.html.eex). We need to do two things here:

  1. If we’re the author or admin, display the comment. If we’re not, then don’t display anything. To do this, we’ll wrap our comment inside of an if check.
  2. If we’re the author or admin, then display the “Approve/Delete” buttons.

We’ll start with step 1. At the top of our template, add this line:

<%= if @conn.assigns[:author_or_admin] || @comment.approved do %>

(Again, note the use of conn.assigns instead of just @value). At the bottom of our template, add:

<% end %>

Next, find the approve/delete buttons, and inside of the div they’re located in, modify it to the following:

<div class="col-xs-4 text-right">
<%= if @conn.assigns[:author_or_admin] do %>
<%= unless @comment.approved do %>
<button class="btn btn-xs btn-primary approve">Approve</button>
<% end %>
<button class="btn btn-xs btn-danger delete">Delete</button>
<% end %>
</div>

So the final template should look like this:

<%= if @conn.assigns[:author_or_admin] || @comment.approved do %>
<div class="comment">
<div class="row">
<div class="col-xs-4">
<strong><%= @comment.author %></strong>
</div>
<div class="col-xs-4">
<em><%= @comment.inserted_at %></em>
</div>
<div class="col-xs-4 text-right">
<%= if @conn.assigns[:author_or_admin] do %>
<%= unless @comment.approved do %>
<button class="btn btn-xs btn-primary approve">Approve</button>
<% end %>
<button class="btn btn-xs btn-danger delete">Delete</button>
<% end %>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<%= @comment.body %>
</div>
</div>
</div>
<% end %>

Now try opening up a blog post display in two separate browsers; one logged in as a user, the other not logged in at all. You should see the unapproved posts and the buttons as an authorized user, but no unapproved posts nor buttons as an unauthorized user!

We’re very close to having complete functionality with our comments, so let’s hammer out the last piece of functionality: hooking up the approve/delete buttons!

Hooking Up Our Delete Button

To make our delete button work, we’ll need to make sure the Comments controller supports delete. Open up web/controllers/comment_controller.ex and scroll down to the delete function. Right now, it only returns conn, so we’ll need to change it to actually do something:

def delete(conn, %{"id" => id, "post_id" => post_id}) do
post = Repo.get!(Post, post_id) |> Repo.preload(:user)
Repo.get!(Comment, id) |> Repo.delete!
conn
|> put_flash(:info, "Deleted comment!")
|> redirect(to: user_post_path(conn, :show, post.user, post))
end

And let’s write a test to make sure we can delete comments. Open up test/controllers/comment_controller_test.exs. First, we’ll alias Pxblog.Comment if we haven’t already. Next, we’ll add a comment insert to our setup block:

setup do
user = insert(:user)
post = insert(:post, user: user)
comment = insert(:comment)

{:ok, conn: build_conn(), user: user, post: post, comment: comment}
end

And finally, we'll write the test itself:

test "deletes the comment", %{conn: conn, post: post, comment: comment} do
conn = delete(conn, post_comment_path(conn, :delete, post, comment))
assert redirected_to(conn) == user_post_path(conn, :show, post.user, post)
refute Repo.get(Comment, comment.id)
end

And running our specs, everything should be green! All that’s left to do is hook up the button itself! Return to web/templates/comment/comment.html.eex and the code for the delete button should be replaced with:

<%= link "Delete",
method: :delete,
to: post_comment_path(@conn, :delete, @post, @comment),
class: "btn btn-xs btn-danger delete",
data: [confirm: "Are you sure you want to delete this comment?"]
%>

There’s a lot here, so let’s step through it. First, we set up the text for our button (“Delete” in our case). Next, we tell Phoenix that we want this to use DELETE instead of GET for our link. We then tell it where to send the delete command (you can use mix phoenix.routes to get a list of all available route helpers, remember). We then specify our CSS classes, and set up a confirmation box when the button is clicked through the data dictionary list.

That’s it! Just go through and test the functionality yourself, and make sure your tests are still green, but we should be all done with our Delete button functionality!

Hooking Up The Approve Button

The approve button will be a bit more complicated to write. We need to provide a means of updating the “approved” flag. We’ll start off by writing the code for our update function in the Comment controller (web/controllers/comment_controller.ex):

def update(conn, %{"id" => id, "post_id" => post_id, "comment" => comment_params}) do
post = Repo.get!(Post, post_id) |> Repo.preload(:user)
comment = Repo.get!(Comment, id)
changeset = Comment.changeset(comment, comment_params)

case Repo.update(changeset) do
{:ok, _} ->
conn
|> put_flash(:info, "Comment updated successfully.")
|> redirect(to: user_post_path(conn, :show, post.user, post))
{:error, _} ->
conn
|> put_flash(:info, "Failed to update comment!")
|> redirect(to: user_post_path(conn, :show, post.user, post))
end
end

And we’ll write a new test to cover updating the “approved” status to true in test/controllers/comment_controller_test.exs:

defp login_user(conn, user) do
post conn, session_path(conn, :create), user: %{username: user.username, password: user.password}
end
test "updates chosen resource and redirects when data is valid and logged in as the author", %{conn: conn, user: user, post: post, comment: comment} do
conn = login_user(conn, user) |> put(post_comment_path(conn, :update, post, comment), comment: %{"approved" => true})
assert redirected_to(conn) == user_post_path(conn, :show, user, post)
assert Repo.get_by(Comment, %{id: comment.id, approved: true})
end

Rerun our tests and everything should be green! Now, we need to modify our original “Approve” button to send to the update function with approved set to true. Replace the button in web/templates/comment/comment.html.eex with the following lines:

<%= form_for @conn, post_comment_path(@conn, :update, @post, @comment), [method: :put, as: :comment, style: "display: inline;"], fn f -> %>
<%= hidden_input f, :approved, value: "true" %>
<%= submit "Approve", class: "btn btn-xs btn-primary approve" %>
<% end %>

This creates a new little form just for purposes of our update button. It sends via PUT, displays the form inline (instead of as a block), and sets the “approved” value to true via a hidden input. Mess around with it a bit and everything should be working just fine! Everything is near perfect, except technically ANYONE would be able to modify/delete/approve comments! We need to add authorizations into the comments controller to keep everything safe!

Adding Authorization To Comments Controller

We’ll want to copy the authorize_user and is_authorized_user? functions from the PostController into the CommentController (web/controllers/comment_controller.ex), but with some modifications. First, we want to make sure we’re actually working with the expected post, so we’ll change authorize_user to set_post_and_authorize_user:

defp set_post(conn) do
post = Repo.get!(Post, conn.params["post_id"]) |> Repo.preload(:user)
assign(conn, :post, post)
end
defp set_post_and_authorize_user(conn, _opts) do
conn = set_post(conn)
if is_authorized_user?(conn) do
conn
else
conn
|> put_flash(:error, "You are not authorized to modify that comment!")
|> redirect(to: page_path(conn, :index))
|> halt
end
end

defp is_authorized_user?(conn) do
user = get_session(conn, :current_user)
post = conn.assigns[:post]
user && (user.id == post.user_id) || Pxblog.RoleChecker.is_admin?(user))
end

And we’ll need to add a plug for statement for these at the top of our controller:

plug :set_post_and_authorize_user when action in [:update, :delete]

And run our tests. We’ll get some failures depending on whether we’re logging in before updating/deleting or not, so let’s modify our tests (test/controllers/comment_controller_test.exs):

  test "deletes the comment when logged in as an authorized user", %{conn: conn, user: user, post: post, comment: comment} do
conn = login_user(conn, user) |> delete(post_comment_path(conn, :delete, post, comment))
assert redirected_to(conn) == user_post_path(conn, :show, post.user, post)
refute Repo.get(Comment, comment.id)
end

test "does not delete the comment when not logged in as an authorized user", %{conn: conn, post: post, comment: comment} do
conn = delete(conn, post_comment_path(conn, :delete, post, comment))
assert redirected_to(conn) == page_path(conn, :index)
assert Repo.get(Comment, comment.id)
end

test "updates chosen resource and redirects when data is valid and logged in as the author", %{conn: conn, user: user, post: post, comment: comment} do
conn = login_user(conn, user) |> put(post_comment_path(conn, :update, post, comment), comment: %{"approved" => true})
assert redirected_to(conn) == user_post_path(conn, :show, user, post)
assert Repo.get_by(Comment, %{id: comment.id, approved: true})
end

test "does not update the comment when not logged in as an authorized user", %{conn: conn, post: post, comment: comment} do
conn = put(conn, post_comment_path(conn, :update, post, comment), comment: %{"approved" => true})
assert redirected_to(conn) == page_path(conn, :index)
refute Repo.get_by(Comment, %{id: comment.id, approved: true})
end

And now we'll run our tests:

$ mix test
Compiling 1 file (.ex)
...........................................................................
Finished in 1.1 seconds
75 tests, 0 failures
Randomized with seed 721237

And that’s it! All of our buttons are hooked up and working, and we’re properly showing/hiding unapproved posts and actions when authorized or not!

Conclusion

We now have a fully-working comments section complete with mini admin panel! It’s not amazing and there are lots of other features we could start adding here, but this is a great baseline to iterate on. We’ll want to add our live commenting system (which we’ll tackle in the next post in this series), update the design of our blog engine (which will go into adding other dependencies and working with Brunch.io), and generally run around and clean up our code/refactor as appropriate (there is a ton of duplication with user authentication, for example)! We’ll also want to add a way for users to authenticate with outside systems and add functionality for RSS feeds, importing from other platforms, etc!

Join us for the next section of this tutorial where we’ll turn the commenting system into a live commenting system via Phoenix’s channels!

Next Post In This Series

Check out my new book!

Hey everyone! If you liked what you read here and want to learn more with me, check out my new book on Elixir and Phoenix web development:

I’m really excited to finally be bringing this project to the world! It’s written in the same style as my other tutorials where we will be building the scaffold of a full project from start to finish, even covering some of the trickier topics like file uploads, Twitter/Google OAuth logins, and APIs!