Blog comments in Ruby on Rails - a post mortem
March 27, 2015 in Ruby on RailsThis blog has used a custom-built comment system for several years. Since it had now moved to Disqus, I’d like to share some hurdles I had to overcome with the original implementation.
There are enough comment systems on Ruby Toolbox, and I’m not going to make another one. In fact, I suggest you try using Disqus if you at all can - it’s open for both import and export, and everyone seems to be using it these days.
But, if you must, here are some nuances you should know about.
Spam protection - Akismet
Akismet is a spam protection service that was created for Wordpress, the largest blogging platform. It’s free for personal use, and it had protected my blog from literally hundreds of thousands of spam comments.
Akismet works much like an email spam filter - by analyzing the comment text and the metadata. With Akismet, you don’t need to obfuscate the comment form or put in a CAPTCHA. In my experience, Akismet catched 99,9% of spam.
There is a Ruby gem called rakismet that connects Akismet into Rails. All you need to do is hook it up to your Comment model and set some request variables from the controller, or use the Rakismet middleware to set them for you. The more Akismet knows about the comment - the author IP and referrer, for instance - the better it can detect spam.
After that, comment.spam?
will call the Akismet API to check if the comment is spammy, and then you can either not save it to the database or flag it as spam. In the simplest form, you can just do
Nested comments, replies
I prefer comment systems where you can reply to a specific comment. The most effective representation of a comment tree in database is the nested set, so I used the awesome_nested_set plugin for Rails to organize that.
Just make sure to set the proper indices on your nested set fields, or creating a comment will become slow with time.
add_index "comments", ["lft"], name: "index_comments_on_lft", using: :btree
add_index "comments", ["rgt"], name: "index_comments_on_rgt", using: :btree
Smarter-than-regular author info
Comment form on my ex-blog
I put a lot of thought into making a comment form that would be a joy to use.
- The metadata fields are below the text area, to aid the natural “write a comment then sign it” flow.
- Only the author name is always required.
- Email is only required if you tick the “receive replies” checkbox.
- URL could be a full URL, or it can also be a @twitter_username.
- If the commenter has an email, I use the Gravtastic Ruby gem to get avatars from Gravatar.
- If the commenter has a Twitter username, I show the Twitter avatar, that I get from the simple and convenient avatars.io API.
- There’s a link to see Markdown formatting tips.
Comment formatting
I wanted to let my commenters use Markdown for comments, just as I use for my blog posts. Publically accessible anonymous Markdown formatting demands some extra security measures on top of RDiscount, the Markdown parser that I use.
Post-processing Markdown parser results usually means feeding them into Nokogiri and doing some HTML transformations.
RUSSIAN_QUOTE_MAP = {
'“' => '«',
'”' => '»'
}
MARKDOWN_HEADER_MAP = {
'#' => '#',
'-' => '-',
'=' => '='
}
def safe_format(text, locale = :ru)
# Escape Markdown header characters to forbid headers
MARKDOWN_HEADER_MAP.each{|from, to| text.gsub!(from, to) }
# Convert to Markdown
html = RDiscount.new(text, :smart, :filter_styles, :filter_html, :no_image, :no_tables, :autolink, :safelink, :no_pseudo_protocols).to_html
# Typographic Russian quotes
RUSSIAN_QUOTE_MAP.each{|from, to| html.gsub!(from, to) } if locale == :ru
# Do some HTML transformations
doc = Nokogiri::HTML::Document.parse('<html></html>', nil, 'UTF-8')
fragment = Nokogiri::HTML::DocumentFragment.new(doc, html)
# graceful links
fragment.css('a').each do |a|
a['rel'] = 'nofollow'
a['target'] = '_blank'
end
# unlike in standard Markdown, treat line breaks as <br>s
fragment.css('p').each do |p|
p.inner_html = p.inner_html.gsub("\n", "<br>\n")
end
fragment.to_html
end
Allowing anonymous authors to edit their comments
Registration on a personal blog is asking too much. But you’ve got to have just a little bit of a “visitor memory”, so that commenters can edit the comment they’ve just made, and don’t have to fill the same metadata for every comment they post.
I store the set of author attributes and the comment IDs for his comments into the session object. This creates a short-term memory with no involvement from the commenter.
def save_author_info_to_session
if !@comment.by_owner? && @comment.status==:approved
session[:comment_author] = {
email: @comment.email,
author: @comment.author,
url: @comment.url,
send_email_notifications: @comment.send_email_notifications
}
session[:comment_ids] ||= []
session[:comment_ids] << @comment.id
end
end
Last words
In the beginning of the article I said that I’m not going to make another comment system. Well, in fact, before I decided to go with Disqus, I planned to extract the comment system into a self-contained Sinatra app - but in my opinion it’s not worth the maintenance cost for small blogs, and larger apps would need other features such as custom authentication, moderation controls and so on. Disqus is pretty nice, go try it out.
Liked the post? Treat me to a coffee