🤖🚫 AI-free content. This post is 100% written by a human, as is everything on my blog. Enjoy!

Blog comments in Ruby on Rails - a post mortem

March 27, 2015 in Ruby on Rails

This 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.

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.

Buy me a coffee Liked the post? Treat me to a coffee