DEV Community

Mikias Abera
Mikias Abera

Posted on

Uploading and Validating Images with Crystal and Lucky on Heroku

Today we're going to be uploading images with Lucky and crystal! To demo this I'm going to make an app that allows uploading images to a gallery that is tied to your ip address. Since we're going to host this app on heroku, we can use heroku's X-FORWARDED_FOR header to get the user's ip address.

Note that because of proxys and the potential for ip spoofing, this is not a secure method of restricting user access and I won't recommend using it for any important data.

Finished Code

To see the finished code and run it locally you can clone the repo and checkout the image-uploads branch.

git clone git@github.com:mikeeus/lucky_demo.git
git checkout image-uploads

cd image-uploads
bin/setup
lucky db.create && lucky db.migrate
Enter fullscreen mode Exit fullscreen mode

And you can run the specs to see the beautiful green result :).

crystal spec spec/flows/images_spec.cr
Enter fullscreen mode Exit fullscreen mode

Image Table

First lets create the Image table.

lucky gen.migration CreateImages
Enter fullscreen mode Exit fullscreen mode

We'll add the following columns to hold the filename, ip address of the owner and we'll even record the number of times the image is viewed by users.

class Image::V20180728000000 < LuckyMigrator::Migration::V1
  def migrate
    create :images do
      add filename : String
      add owner_ip : String
      add views : Int32
    end

    execute "CREATE INDEX owner_ip_index ON images (owner_ip);"
    execute "CREATE UNIQUE INDEX filename_index ON images (filename);"  
  end

  def rollback
    drop :images
  end
end
Enter fullscreen mode Exit fullscreen mode

We'll also add a unique index on the filename and a normal index on the owner_ip column so we can quickly get collections of images based on it.

Specs

When allowing uploads to our app we'll want to restrict the files by type and possible dimensions. We'll create specs to check this for us. Unfortunately Crystal doesn't give us information on an image's dimensions our of the box, so we'll later we'll use crymagic to get this info for us.

The limits we'll put on our uploads are the following:

  • formats: JPG, GIF, PNG
  • max dimensions: 1000x1000
  • max size: 250kb

I've added some images to our assets folder that break each of these rules as well as one image that is perfect.

ALSO: I got these images from this amazing site: africandigitalart.com, which I recommend checking out.

public/
  assets/
    images/
      test/
        perfect_960x981_56kb.jpg
        too_big_900x900_256kb.jpg
        too_tall_1001x1023_95kb.jpg
        wrong_format_240x245_235kb.bmp
Enter fullscreen mode Exit fullscreen mode

Next we'll create an ImageBox in case we need to instantiate Images in our tests.

# spec/support/boxes/image_box.cr
class ImageBox < LuckyRecord::Box
  def initialize
    filename "perfect_960x981_56kb.jpg"
    owner_ip "0.0.0.0"
    views 1
  end
end
Enter fullscreen mode Exit fullscreen mode

Lucky Flow

Lucky uses the concept of Flows which are classes that encapsulate the behavior of your browser tests. We'll create one now that uploads an image on our homepage and has two methods for checking if it succeeded or not.

We can simulate uploading a file by adding the file's full path to the file input of the form. Then theclick "@upload-image" method will look for an element with [flow_id=upload-image] tag on the page and click it.

# spec/support/flows/images_flow.cr
class ImagesFlow < BaseFlow
  def upload_image(filepath)
    visit Home::Index

    fill_form ImageForm,
      image: File.expand_path(filepath)
    click "@upload-image"
  end

  def image_should_be_created(filepath)
    image = find_image_by_filename?(File.basename(filepath))
    image.should_not be_nil
  end

  def image_should_not_be_created(filepath)
    image = find_image_by_filename?(File.basename(filepath))
    image.should be_nil
  end

  private def find_image_by_filename?(filename)
    ImageQuery.new.filename.ilike("%#{filename}%").first?
  end
end
Enter fullscreen mode Exit fullscreen mode

Now we can use this flow and our test images to write our specs. Crystal has first class support for specs and we can see that by how simple it is to write them. We use Spec.after_each to clear the images with a delete! method that will also delete the underlying file after every spec.

# specs/flows/images_spec.cr
require "../spec_helper"

describe "Images flow" do
  Spec.after_each do
    ImageQuery.new.map(&.delete!)
  end

  describe "uploading" do
    it "works with valid image" do
      flow = ImagesFlow.new

      flow.upload_image(valid_image_path)
      flow.image_should_be_created(valid_image_path)
    end

    it "doesnt work with image above 250kb" do
      flow = ImagesFlow.new

      flow.upload_image(too_big_image_path)
      flow.image_should_not_be_created(too_big_image_path)
    end

    it "doesnt work with dimensions over 1000x1000" do
      flow = ImagesFlow.new

      flow.upload_image(too_tall_image_path)
      flow.image_should_not_be_created(too_tall_image_path)
    end

    it "doesnt work with image of the wrong format" do
      flow = ImagesFlow.new

      flow.upload_image(wrong_format_image_path)
      flow.image_should_not_be_created(wrong_format_image_path)
    end
  end
end

private def valid_image_path
  "public/assets/images/test/perfect_960x981_56kb.jpg"
end

private def too_tall_image_path
  "public/assets/images/test/too_tall_1001x1023_95kb.jpg"
end

private def too_big_image_path
  "public/assets/images/test/too_big_900x900_256kb.jpg"
end

private def wrong_format_image_path
  "public/assets/images/test/wrong_format_240x245_235kb.bmp"
end
Enter fullscreen mode Exit fullscreen mode

Running these specs will cause them to fail since we haven't implemented anything. Let's now build out our models, actions and pages to make them work.

Image Model

We'll need to persist references to our images, their owner's ip and number of views to the database. So let's generate a model to do that.

lucky gen.model Image
Enter fullscreen mode Exit fullscreen mode

And we can fill out the Image model with its columns and a some helper methods to build the path, url and handle deletion. The images will be saved at public/assets/images/..., and will be available publicly at at www.example.com/assets/images/.... We'll also add a case for test images that will be stored under the public/assets/images/test/ directory.

# src/models/image.cr
class Image < BaseModel
  table :images do
    column filename : String
    column owner_ip : String
    column views : Int32
  end

  def url
    "#{Lucky::RouteHelper.settings.base_uri}#{path}"
  end

  def path
    if Lucky::Env.test?
      "/assets/images/test/#{self.filename}"
    else
      "/assets/images/#{self.filename}"
    end
  end

  def full_path
    "public#{path}"
  end

  def delete!
    File.delete(full_path)
    delete
  end
end
Enter fullscreen mode Exit fullscreen mode

Next we can fill out our ImageForm. Forms in Lucky are responsible for creating and updating models. We use fillable to declare which columns we'll be updating, and we'll declare a virtual field image to hold our uploaded image until we can save it. We'll also add needs file and needs ip because we'll be passing these in when instantiating the form.

uuid is used to make sure we have unique filenames and make it almost impossible for someone to view the image without the filename.

We put all of this together in the prepare method which saves the image and sets the columns. It currently doesn't do any validations but we'll get to that later.

require "uuid"

class ImageForm < Image::BaseForm
  fillable filename
  fillable views
  fillable owner_ip

  virtual image : String

  needs file : Lucky::UploadedFile, on: :create
  needs ip : String, on: :create

  getter new_filename

  def prepare
    save_image
    views.value = 1
    filename.value = new_filename
    owner_ip.value = ip
  end

  private def uploaded
    file.not_nil!
  end

  private def save_image
    File.write(save_path, File.read(uploaded.tempfile.path))
  end

  private def new_filename
    @new_filename ||= "#{UUID.random}_#{uploaded.metadata.filename}"
  end

  private def image_path
    if Lucky::Env.test?
      "assets/images/test/" + new_filename
    else
      "assets/images/" + new_filename
    end
  end

  private def save_path
    "public/" + image_path
  end
end
Enter fullscreen mode Exit fullscreen mode

Now we need to create the UI to allow uploads and the actions to save the forms.

Home Page

Currently our app displays Lucky's default homepage. We'll create a new Home page that holds our form and allow us to upload files. Let's generate the page.

lucky gen.page Home::IndexPage
Enter fullscreen mode Exit fullscreen mode

Then we'll add a form that has enctype: "multipart/form-data" and posts to Images::Create which will handle creating our Image. We add needs form : ImageForm to tell the action that renders this page to pass in a new form. We'll also render any errors in a list below the input.

class Home::IndexPage < GuestLayout
  needs form : ImageForm

  def content
    render_form(@form)
  end

  private def render_form(f)
    form_for Images::Create, enctype: "multipart/form-data" do
      text_input f.image, type: "file", flow_id: "file-input"      

      ul do
        f.image.errors.each do |err|
          li "Image #{err}", class: "error"
        end
      end

      submit "Upload Image", flow_id: "upload-image"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

And let's change our Home::Index action to show our index page rather than Lucky's welcome page.

# src/actions/home/index.cr
class Home::Index < BrowserAction
  include Auth::SkipRequireSignIn
  unexpose current_user

  get "/" do
    if current_user?
      redirect Me::Show
    else
      render Home::IndexPage
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Get current_ip in Actions

We won't be using current_user for authentication, instead we need to get the ip address of the request. When our app is on heroku we can use the X-FORWARDED-FOR header which is set automatically. Locally we'll just set it to local.

We'll add these methods in the BrowserAction. Since our other actions inherit from it class Home::Index < BrowserAction, it will make these methods available for us.

# src/actions/browser_action.cr
abstract class BrowserAction < Lucky::Action
  ...
  def current_ip
    current_ip?.not_nil!
  end

  private def current_ip?
    if Lucky::Env.production?
      context.request.headers["X-FORWARDED-FOR"]?
    else
      "local"
    end
  end
  ...
end
Enter fullscreen mode Exit fullscreen mode

Images Create Action

Now we need an action to handle the image creation after we submit the form on the home page. Let's generate one with:

lucky gen.action.browser Images::Create
Enter fullscreen mode Exit fullscreen mode

For more information on how actions work, you can check out Lucky's guides.

This action will get the file from the params which will be in the form { "image": { "image": "file is here" }}. If it's not nil we'll pass the file as well as the current_ip to the ImageForm which will validate and save our new Image.

To check that our file exists we'll make sure its not nil and that the filename exists.

# src/actions/images/create.cr
class Images::Create < BrowserAction
  include Auth::SkipRequireSignIn
  unexpose current_user

  route do # lucky expands this to: post "/images"
    file = params.nested_file?(:image)["image"]?

    if is_invalid(file)
      flash.danger = "Please select a file to upload"
      redirect to: Home::Index
    else
      ImageForm.create(file: file.not_nil!, ip: current_ip) do |form, image|
        if image
          flash.success = "Image successfuly uploaded from #{current_ip}!"
          redirect to: Home::Index
        else
          flash.danger = "Image upload failed"
          render Home::IndexPage, form: form
        end
      end
    end
  end

  private def is_invalid(file)
    file.nil? || file.metadata.filename.nil? || file.not_nil!.metadata.filename.not_nil!.empty?    
  end
end
Enter fullscreen mode Exit fullscreen mode

And voila! Our app can now handle image uploads.

If we run the specs with lucky spec spec/flows/images_spec.cr we'll see that our first spec that checks valid images will pass, but since we haven't implemented image validations the rest will fail.

Validations

In order to check the images' file size, type and dimensions we're going to use a little gem of a shard called crymagick. It requires having ImageMagick installed which luckily for us is present on Heroku by default. If it's not installed on your local machine you can get it from the official site here.

Lets install the shard by adding it to the bottom of our dependencies in shard.yml and running shards.

# shard.yml
...
dependencies:
  ...
  crymagick:
    github: imdrasil/crymagick
Enter fullscreen mode Exit fullscreen mode

Now we can use it in our ImageForm to validate our images. We add three methods validate_is_correct_size, validate_is_correct_dimensions and validate_is_correct_type that will use CryMagick::Image to check the file's type, size and dimensions. If there are no errors, we move on to saving the file and setting the Image's columns.

require "uuid"
require "crymagick"

class ImageForm < Image::BaseForm
  ...
  getter crymagick_image : CryMagick::Image?

  def prepare
    validate_is_correct_size
    validate_is_correct_dimensions
    validate_is_correct_type

    if errors.empty? # save if validations pass
      save_image

      views.value = 1
      filename.value = new_filename
      owner_ip.value = ip
    end
  end

  private def validate_is_correct_type
    ext = crymagick_image.type

    unless Image::VALID_FORMATS.includes? "#{ext}".downcase
      image.add_error "type should be jpg, jpeg, gif or png but was #{ext}"
    end
  end

  private def validate_is_correct_size
    size = crymagick_image.size # returns size in bytes

    if size > 250_000 # 250kb limit
      image.add_error "size should be less than 250kb but was #{size / 1000}kb"
    end
  end

  private def validate_is_correct_dimensions
  dimensions = crymagick_image.dimensions # returns (width, height)

    if dimensions.first > 1000
      image.add_error "width should be less than 1000px, but was #{dimensions.first}px"
    end

    if dimensions.last > 1000
      image.add_error "height should be less than 1000px, but was #{dimensions.last}px"
    end
  end

  private def crymagick_image # To avoid opening the file multiple times
    @crymagick_image ||= CryMagick::Image.open(uploaded.tempfile.path)
  end
  ...
end
Enter fullscreen mode Exit fullscreen mode

Now if we run the specs we'll see that they all pass! Hurray!

All thats left now is to add support for deleting and viewing our images.

Displaying and Deleting Images

What we want is to display our images on the home page as a gallery. Each image should have a button to delete and should display it's url.

Let's begin with a spec that visits the homepage and checks for images on the screen, and another one that clicks the delete button and checks that the image is deleted.

# specs/flows/images_spec.cr
require "../spec_helper"

describe "Images flow" do
  ...
  describe "displays" do
    it "own images" do
      flow = ImagesFlow.new

      owned = ImageBox.new.owner_ip("local").create
      not_owned = ImageBox.new.owner_ip("not-owned").create

      flow.homepage_should_display_image(owned.id)
      flow.homepage_should_not_display_image(not_owned.id)
    end
  end

  describe "deleting" do
    it "is allowed for owner" do
      flow = ImagesFlow.new

      flow.upload_image(valid_image_path)

      image = ImageQuery.new.first

      flow.delete_image_from_homepage(image.id)
      flow.image_should_not_exist(image.id)
    end

    it "is not allowed for other ip addresses" do
      flow = ImagesFlow.new
      not_owned = ImageBox.new.owner_ip("not-local").create

      flow.delete_image_from_action(not_owned.id)
      flow.image_should_exist(not_owned.id)
    end
  end
end
...
Enter fullscreen mode Exit fullscreen mode

And lets add the flows that will visit the homepage, check for images, check for images in the database, and delete images by pressing buttons or visiting the actions directly.

class ImagesFlow < BaseFlow
  ...
  def homepage_should_display_image(id)
    visit Home::Index
    image(id).should be_on_page    
  end

  def homepage_should_not_display_image(id)
    visit Home::Index
    image(id).should_not be_on_page    
  end

  def delete_image_from_homepage(id)
    visit Home::Index
    click "@delete-image-#{id}"
  end

  def delete_image_from_action(id)
    visit Images::Delete.with(id: id)
  end

  def image_should_exist(id)
    ImageQuery.find(id).should_not be_nil
  end

  def image_should_not_exist(id)
    ImageQuery.new.id(id).first?.should be_nil    
  end

  ...

  private def image(id)
    el("@image-#{id}")
  end
Enter fullscreen mode Exit fullscreen mode

Our tests will be failing now, so lets add support for displaying images by updating our Home::IndexPage. We'll require that the page is rendered with an images prop that is an ImageQuery. Then we'll use the images in a new gallery method that renders each image including links to delete it and a url to display it.

class Home::IndexPage < GuestLayout
  needs form : ImageForm
  needs images : ImageQuery # ADD THIS!

  def content
    div class: "homepage-container" do
      render_form(@form)

      gallery # add gallery erhere
    end
  end

  private def gallery # define it here
    h2 "Image Gallery"

    ul class: "image-gallery" do
      @images.map do |image|
        li class: "image", flow_id: "image-#{image.id}" do
          div class: "picture", style: "background-image: url(#{image.path});" do
            div "Views: #{image.views}", class: "views"
          end

          link to: Images::Delete.with(image.id), flow_id: "delete-image-#{image.id}" do
            img src: asset("images/x.png")
          end

          div image.url, class: "image-url",  flow_id: "image-url-#{image.id}"
        end
      end
    end
  end
  ...
end
Enter fullscreen mode Exit fullscreen mode

I've also added styles to src/css/app.scss which I won't include in this article.

In order for this to work we need to update our actions that render the Home::IndexPage so that they pass in the images.

# src/actions/home/index.cr
class Home::Index < BrowserAction
  ...

  get "/" do
    if current_user?
      redirect Me::Show
    else
      images = ImageQuery.new.owner_ip(current_ip)
      render Home::IndexPage, form: ImageForm.new, images: images # pass it in here
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

And in our Images::Create action.

# src/actions/images/create.cr
class Images::Create < BrowserAction
  ...
  route do
    if is_invalid(file)
      ...
    else
      ImageForm.create(file: file.not_nil!, ip: current_ip) do |form, image|
        if image
          ...
        else
          ...
          images = ImageQuery.new.owner_ip(current_ip)
          render Home::IndexPage, form: form, images: images # pass it in here
        end
      end
    end
  end
  ...
end
Enter fullscreen mode Exit fullscreen mode

All set! The Home::IndexPage won't complain about not having images passed in. But it will complain about a link to Images::Delete which hasn't been implemented. So let's do that now.

lucky gen.action.browser Images::Delete
Enter fullscreen mode Exit fullscreen mode

The Images::Delete action should check if the current_ip matches the Image's owner_ip and if so call delete!.

# src/actions/images/delete.cr
class Images::Delete < BrowserAction
  include Auth::SkipRequireSignIn
  unexpose current_user

  route do # expands to: delete "/images/:id"
    image = ImageQuery.find(id)

    if image.owner_ip == current_ip
      image.delete!
      flash.success = "Image succesfully deleted!"
      redirect to: Home::Index
    else
      flash.danger = "You are not authorized to delete this image"
      redirect to: Home::Index
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now run the tests and... Boom! Green.

Show Single Image

The last thing we'll implement is a show page for each image that updates the number of views. Let's generate the action, page and a form to update images for us.

lucky gen.action.browser Images::Show
lucky gen.page Images::ShowPage

touch src/forms/image_views_form.cr # no generater for forms atm
Enter fullscreen mode Exit fullscreen mode

The form will be simple and only be used for incrementing the value. It can be used like this: ImageViewsForm.update!(image).

# src/forms/image_views_form.cr
class ImageViewsForm < Image::BaseForm
  fillable views
  fillable filename
  fillable owner_ip

  def prepare
    views.value = views.value.not_nil! + 1
  end
end 
Enter fullscreen mode Exit fullscreen mode

For our action we'll use a custom route so that our route parameter is available as filename instead of id. Then we check that it exists and increment the views and render the page, otherwise we redirect to the Home::Index action.

# src/actions/images/show.cr
class Images::Show < BrowserAction
  include Auth::SkipRequireSignIn
  unexpose current_user

  get "/images/:filename" do
    image = ImageQuery.new.filename(filename).first?
    if image.nil?
      flash.danger = "Image with filename: #{filename} not found"
      redirect to: Home::Index
    else
      ImageViewsForm.update!(image)
      render Images::ShowPage, image: image
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The show page will be very simple. We display the filename, the views and the image using minimal style to keep everything centered while allowing the image to stretch to its full size.

# src/pages/images/show_page.cr
class Images::ShowPage < GuestLayout
  needs image : Image

  def content
    div style: "text-align: center;" do
      h1 @image.filename
      h2 "Views: #{@image.views}"
      img src: @image.path, style: "max-width: 100%; height: auto;"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

To finish off we'll make the image displayed on the home page link to the Images::ShowPage.

class Home::IndexPage < GuestLayout
  ...
    ul class: "image-gallery" do
      @images.map do |image|
        li class: "image", flow_id: "image-#{image.id}" do
          link to: Images::Show.with(image.filename), # Changed this to link: ..
               class: "picture",
               style: "background-image: url(#{image.path});" do
            div "Views: #{image.views}", class: "views"
          end
          ...
        end
      end
    end
  ...
end
Enter fullscreen mode Exit fullscreen mode

And we're done! The tests should all be green and the app working as expected.

Join Us

I hope you enjoyed this tutorial and found it useful. Join us on the Lucky gitter channel to stay up to date on the framework or checkout the docs for more information on how to bring your app idea to life with Lucky.

Latest comments (0)