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]ost.picture.url%>"/>
</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.
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.
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.