Visit The School

Guide: Recreating Is It Porn? (isitporn.com) In Rails

Alexander Paterson
|
Posted about 1 year ago
|
17 minutes
Guide: Recreating Is It Porn? (isitporn.com) In Rails
Is It Porn? is my most successful web application, by exposure. It's a simple application

Those new to developing web applications start here

Although Is It Porn? is now built with Flask and uses my own content analysis algorithm, when it peaked in popularity it was a simple Rails application that relied on an API from indico.io. I was hosting my app on Heroku at the time, and due to a memory leak in imagemagick combined with my then lack of understanding, I ended up wasting hundreds of dollars on resources. I have real beef with imagemagick after this, so I'm happy to be using PIL now instead.

Here, I'm going to take you through the process of creating Is It Porn? in rails. In a future article I'll give you a look inside the Flask version, which doesn't rely on Imagemagick.

Here's a repository with the finished product.

Setting Up

Begin with rails new isitporn, and move into the new project directory.

We'll first change our database settings so that we can deploy to heroku. In our Gemfile, move the sqlite3 gem into the development+test group and add the pg gem to a production group:

# Gemfile

...

group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug'
  # Use sqlite3 as the database for Active Record
  gem 'sqlite3'
end

...

group :production do
  gem 'pg'
end

We're going to be using the carrierwave gem to handle our image uploads, which we'll keep on Amazon S3. Add that and fog-aws to your gemfile. 

# Gemfile

...

gem "carrierwave"
gem "fog-aws"

Now run bundle. We need to configure carrierwave, so in config/initializers/carrierwave.rb add the following:

# config/initializers/carrierwave.rb

CarrierWave.configure do |config|
  config.fog_credentials = {
    :provider              => 'AWS',
    :aws_access_key_id     => ENV['S3_ACCESS_KEY'],
    :aws_secret_access_key => ENV['S3_SECRET_KEY']
  }
  config.fog_directory     =  ENV['S3_BUCKET']
end

Now create a file called source with the following contents (where xxx is replaced with your actual S3 credentials):

# source

export S3_ACCESS_KEY=xxx
export S3_SECRET_KEY=xxx
export S3_BUCKET=xxx

And add this file to .gitignore to keep your secrets secret:

# .gitignore

...

source

If you don't have AWS credentials, see this chapter of my Rails For Beginners course. Ignore all the stuff about Paperclip.

Now whenever we want to run our rails app, we enter the command source source before rails s so that these environment variables are present. Run source source now. If your application doesn't seem to be able to access the S3 environment variables, run bin/spring stop, then run source source again. Now we generate an uploader with the command rails g uploader picture. I'll also generate a scaffold with rails g scaffold post picture:string chance:string.

Now let's add two more gems to our gemfile: mini_magick and indico. Also run bundle.

# Gemfile

...

gem 'mini_magick'
gem 'indico'

First we'll get to a point where we're successfully uploading images, then we'll start using indico to analyse their content.

Uploading Images

Update our uploader to look like this:

# app/uploaders/picture_uploader.rb

class PictureUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick

  storage :fog

  process :convert => 'png'
  process resize_to_limit: [800, 800]

  def filename
    super.chomp(File.extname(super)) + '.png'
  end

  # Override the directory where uploaded files will be stored.
  # This is a sensible default for uploaders that are meant to be mounted:
  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end
  # Add a white list of extensions which are allowed to be uploaded.
  def extension_white_list
    %w(jpg jpeg gif png)
  end
  #Defines URL for if image is missing for some reason
  def default_url
      "http://i.stack.imgur.com/ILTQq.png"
  end
end

We need to update our post form so that the picture field is a file field:

<!-- app/views/posts/_form.html.erb -->

...
  <div class="field">
    <%= f.label :picture %><br>
    <%= f.file_field :picture %>
  </div>
...

And on our show view we'll actually render out the image:

<!-- app/views/posts/show.html.erb -->

...
<p>
  <strong>Picture:</strong>
  <img src="<[email protected]%>"/>
</p>
...

In our model, we mount our uploader:

# app/models/post.rb

class Post < ActiveRecord::Base
  mount_uploader :picture, PictureUploader
end

Now if we run bundle exec rake db:migrate and rails s and navigate to http://localhost:3000/posts/new, we can upload images to our application. We're nearly finished.

First image upload

We just need to make a query to the indico API and print the results on our image. After that I'll refactor our views and delete the useless ones, and we'll be done.

Content Analysis

First, register an account at indico.io, and get your API key ready.

Indico Dashboard

Add your API key to the source file:

# source

export INDICO_KEY='3xxxxxxxxxxxxxxxxxxe'

Register that environment variable with bin/spring stop and source source

Now the actual content analysis is easy to implement, and we do it in posts_controller.rb. Due to this fact about carrierwave, we can't simply pass post_params to Post.new, so update your controller's create method to contain the following:

# app/controllers/posts_controller.rb

...
  # POST /posts
  def create
    Indico.api_key = ENV["INDICO_KEY"]
    chance = Indico.content_filtering(Base64.encode64(post_params[:picture].read))

    @post = Post.new()
    @post.chance = chance
    @post.picture = post_params[:picture]

    respond_to do |format|
      if @post.save
        format.html { redirect_to @post, notice: 'Post was successfully created.' }
      else
        format.html { render :new }
      end
    end
  end
...

The chance property of our posts is now being updated with the decimal likelihood that each image contains nudity. Now we just need to print this value onto the image. This is done using mini_magick in our uploader. In your uploader, you can access the post attributes with the keyword model, so we refer to model.chance for the value to print.

I'm going to be using some images in the following code. These images are available from the repository that goes with this article. The files you need to copy into your own project are the following:

app/uploaders/fonts/impact.ttf
app/assets/images/stampporn.png
app/assets/images/stampcaution.png
app/assets/images/stampsafe.png
app/assets/images/pinkbackground.png

In picture_uploader.rb add the following lines of code after resize_to_limit:

# app/uploaders/picture_uploader.rb

...
  process resize_to_limit: [800, 800]

  process :composite
  process :watermark
  process :text

  def composite
    manipulate! do |img|
      #Image Resize
      rows = img.height.to_f
      cols = img.width.to_f
      ratio = rows/cols
      if img.width < 400
        img.resize "400x#{400*ratio}"
      end

      #Pink Background
      overlay_path = Rails.root.join("app/assets/images/pinkbackground.png")
      overlay = MiniMagick::Image.open(overlay_path)
      overlay.resize "#{img.width}x140!"

      img = img.composite(overlay) do |c|
        c.compose 'Over'
        c.gravity 'South'
        c.geometry '+0+0'
      end

      chance = model.chance.to_f

      if chance < 0.6
        stamp_path = Rails.root.join("app/assets/images/stampsafe.png")
      elsif chance < 0.8
        stamp_path = Rails.root.join("app/assets/images/stampcaution.png")
      else
        stamp_path = Rails.root.join("app/assets/images/stampporn.png")
      end
      overlay = MiniMagick::Image.open(stamp_path)
      img = img.composite(overlay) do |c|
        c.gravity 'SouthEast'
        c.geometry '-10-20'
        c.compose 'Over'
      end
      img
    end
  end

  def text
    manipulate! do |img|
      chance = model.chance.to_f
      text = '%.1f' % (chance * 100) + "%"

      pointsize = 56
      stroke_width = pointsize / 30.0

      img.combine_options do |c|
        c.gravity 'Southwest'
        c.font "#{Rails.root}/app/uploaders/fonts/impact.ttf"
        c.pointsize "#{pointsize}"
        c.fill "#FFFFFF"
        c.strokewidth "#{stroke_width}"
        c.stroke "#000000"
        c.draw "text 7,35 '#{text}'"
      end

      if chance < 0.3
        text2 = "CERTIFIED NOT PORN"
      elsif chance < 0.6
        text2 = "PROBABLY NOT PORN"
      elsif chance <0.8
        text2 = "VERY WELL COULD BE PORN"
      elsif chance <0.9
        text2 = "PRETTY SURE THIS IS PORN"
      else
        text2 = "PORN. THIS IS PORN."
      end

      img.combine_options do |c|
        c.gravity 'Southwest'
        c.font "#{Rails.root}/app/uploaders/fonts/impact.ttf"
        c.pointsize "#{pointsize * 0.6}"
        c.fill "#FFFFFF"
        c.strokewidth "#{stroke_width}"
        c.stroke "#000000"
        c.draw "text 7,5 '#{text2}'"
      end
      img
    end
  end

  def watermark
    manipulate! do |img|
      img.combine_options do |c|
        c.gravity 'Southeast'
        c.pointsize "15"
        c.fill "#000000"
        c.weight "900"
        c.draw "text 2,2 'isitporn.com'"
      end
      img.combine_options do |c|
        c.gravity 'Southeast'
        c.pointsize "15"
        c.fill "#000000"
        c.weight "900"
        c.draw "text 2,4 'isitporn.com'"
      end
      img.combine_options do |c|
        c.gravity 'Southeast'
        c.pointsize "15"
        c.fill "#000000"
        c.weight "900"
        c.draw "text 4,2 'isitporn.com'"
      end
      img.combine_options do |c|
        c.gravity 'Southeast'
        c.pointsize "15"
        c.fill "#000000"
        c.weight "900"
        c.draw "text 4,4 'isitporn.com'"
      end

      img.combine_options do |c|
        c.gravity 'Southeast'
        c.pointsize "15"
        c.fill "#FFFFFF"
        c.weight "900"
        c.draw "text 3,3 'isitporn.com'"
      end
      img
    end
  end
...

This is all just minimagick manipulation of our image. It took me a long time to figure out a lot of this stuff, I recall the minimagick documentation being unsatisfactory. Have a read through, it's not too complicated.

That's essentially it. Now I'll just polish the app to look almost presentable for you.

Finishing Touches

Let's update our routes:

# config/routes.rb

Rails.application.routes.draw do
  root 'posts#new'
  resources :posts, only: [:create, :show]
end

Update our new post view:

<!-- app/views/posts/new.html.erb -->

<h1>Is It Porn?</h1>

<%= form_for(@post) do |f| %>
  <% if @post.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
      <% @post.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.file_field :picture %>
  </div>
  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

And our show post view:

<!-- app/views/posts/show.html.erb -->

<img src="<[email protected]%>"/>

<%= link_to 'Do Another', root_url %>

I'll remove the useless actions from posts_controller: 

# app/controllers/posts_controller.rb

class PostsController < ApplicationController
  before_action :set_post, only: [:show]

  # GET /posts/1
  def show
  end

  # GET /posts/new
  def new
    @post = Post.new
  end

  # POST /posts
  def create
    Indico.api_key = ENV["INDICO_KEY"]
    chance = Indico.content_filtering(Base64.encode64(post_params[:picture].read))

    @post = Post.new()
    @post.chance = chance
    @post.picture = post_params[:picture]

    respond_to do |format|
      if @post.save
        format.html { redirect_to @post, notice: 'Post was successfully created.' }
      else
        format.html { render :new }
      end
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_post
      @post = Post.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def post_params
      params.require(:post).permit(:picture, :chance)
    end
end

Now you just need to delete the views we're not using any more. Delete all of the views in app/views/posts aside from show.html.erb and new.html.erb.

Conclusion

And we're done. Our app's completely unstyled but it works.



ALEXANDER
PATERSON