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.gitgit checkout image-uploadscd image-uploadsbin/setuplucky db.create && lucky db.migrate
And you can run the specs to see the beautiful green result :).
crystal spec spec/flows/images_spec.cr
Image Table
First lets create the Image table.
lucky gen.migration CreateImages
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::V1def migratecreate :images doadd filename : Stringadd owner_ip : Stringadd views : Int32endexecute "CREATE INDEX owner_ip_index ON images (owner_ip);"execute "CREATE UNIQUE INDEX filename_index ON images (filename);"enddef rollbackdrop :imagesendend
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.jpgtoo_big_900x900_256kb.jpgtoo_tall_1001x1023_95kb.jpgwrong_format_240x245_235kb.bmp
Next we'll create an ImageBox
in case we need to instantiate Images in our tests.
# spec/support/boxes/image_box.crclass ImageBox < LuckyRecord::Boxdef initializefilename "perfect_960x981_56kb.jpg"owner_ip "0.0.0.0"views 1endend
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.crclass ImagesFlow < BaseFlowdef upload_image(filepath)visit Home::Indexfill_form ImageForm,image: File.expand_path(filepath)click "@upload-image"enddef image_should_be_created(filepath)image = find_image_by_filename?(File.basename(filepath))image.should_not be_nilenddef image_should_not_be_created(filepath)image = find_image_by_filename?(File.basename(filepath))image.should be_nilendprivate def find_image_by_filename?(filename)ImageQuery.new.filename.ilike("%#{filename}%").first?endend
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.crrequire "../spec_helper"describe "Images flow" doSpec.after_each doImageQuery.new.map(&.delete!)enddescribe "uploading" doit "works with valid image" doflow = ImagesFlow.newflow.upload_image(valid_image_path)flow.image_should_be_created(valid_image_path)endit "doesnt work with image above 250kb" doflow = ImagesFlow.newflow.upload_image(too_big_image_path)flow.image_should_not_be_created(too_big_image_path)endit "doesnt work with dimensions over 1000x1000" doflow = ImagesFlow.newflow.upload_image(too_tall_image_path)flow.image_should_not_be_created(too_tall_image_path)endit "doesnt work with image of the wrong format" doflow = ImagesFlow.newflow.upload_image(wrong_format_image_path)flow.image_should_not_be_created(wrong_format_image_path)endendendprivate def valid_image_path"public/assets/images/test/perfect_960x981_56kb.jpg"endprivate def too_tall_image_path"public/assets/images/test/too_tall_1001x1023_95kb.jpg"endprivate def too_big_image_path"public/assets/images/test/too_big_900x900_256kb.jpg"endprivate def wrong_format_image_path"public/assets/images/test/wrong_format_240x245_235kb.bmp"end
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
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.crclass Image < BaseModeltable :images docolumn filename : Stringcolumn owner_ip : Stringcolumn views : Int32enddef url"#{Lucky::RouteHelper.settings.base_uri}#{path}"enddef pathif Lucky::Env.test?"/assets/images/test/#{self.filename}"else"/assets/images/#{self.filename}"endenddef full_path"public#{path}"enddef delete!File.delete(full_path)deleteendend
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::BaseFormfillable filenamefillable viewsfillable owner_ipvirtual image : Stringneeds file : Lucky::UploadedFile, on: :createneeds ip : String, on: :creategetter new_filenamedef preparesave_imageviews.value = 1filename.value = new_filenameowner_ip.value = ipendprivate def uploadedfile.not_nil!endprivate def save_imageFile.write(save_path, File.read(uploaded.tempfile.path))endprivate def new_filename@new_filename ||= "#{UUID.random}_#{uploaded.metadata.filename}"endprivate def image_pathif Lucky::Env.test?"assets/images/test/" + new_filenameelse"assets/images/" + new_filenameendendprivate def save_path"public/" + image_pathendend
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
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 < GuestLayoutneeds form : ImageFormdef contentrender_form(@form)endprivate def render_form(f)form_for Images::Create, enctype: "multipart/form-data" dotext_input f.image, type: "file", flow_id: "file-input"ul dof.image.errors.each do |err|li "Image #{err}", class: "error"endendsubmit "Upload Image", flow_id: "upload-image"endendend
And let's change our Home::Index
action to show our index page rather than Lucky's welcome page.
# src/actions/home/index.crclass Home::Index < BrowserActioninclude Auth::SkipRequireSignInunexpose current_userget "/" doif current_user?redirect Me::Showelserender Home::IndexPageendendend
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.crabstract class BrowserAction < Lucky::Action...def current_ipcurrent_ip?.not_nil!endprivate def current_ip?if Lucky::Env.production?context.request.headers["X-FORWARDED-FOR"]?else"local"endend...end
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
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.crclass Images::Create < BrowserActioninclude Auth::SkipRequireSignInunexpose current_userroute 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::IndexelseImageForm.create(file: file.not_nil!, ip: current_ip) do |form, image|if imageflash.success = "Image successfuly uploaded from #{current_ip}!"redirect to: Home::Indexelseflash.danger = "Image upload failed"render Home::IndexPage, form: formendendendendprivate def is_invalid(file)file.nil? || file.metadata.filename.nil? || file.not_nil!.metadata.filename.not_nil!.empty?endend
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
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 preparevalidate_is_correct_sizevalidate_is_correct_dimensionsvalidate_is_correct_typeif errors.empty? # save if validations passsave_imageviews.value = 1filename.value = new_filenameowner_ip.value = ipendendprivate def validate_is_correct_typeext = crymagick_image.typeunless Image::VALID_FORMATS.includes? "#{ext}".downcaseimage.add_error "type should be jpg, jpeg, gif or png but was #{ext}"endendprivate def validate_is_correct_sizesize = crymagick_image.size # returns size in bytesif size > 250_000 # 250kb limitimage.add_error "size should be less than 250kb but was #{size / 1000}kb"endendprivate def validate_is_correct_dimensionsdimensions = crymagick_image.dimensions # returns (width, height)if dimensions.first > 1000image.add_error "width should be less than 1000px, but was #{dimensions.first}px"endif dimensions.last > 1000image.add_error "height should be less than 1000px, but was #{dimensions.last}px"endendprivate def crymagick_image # To avoid opening the file multiple times@crymagick_image ||= CryMagick::Image.open(uploaded.tempfile.path)end...end
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.crrequire "../spec_helper"describe "Images flow" do...describe "displays" doit "own images" doflow = ImagesFlow.newowned = ImageBox.new.owner_ip("local").createnot_owned = ImageBox.new.owner_ip("not-owned").createflow.homepage_should_display_image(owned.id)flow.homepage_should_not_display_image(not_owned.id)endenddescribe "deleting" doit "is allowed for owner" doflow = ImagesFlow.newflow.upload_image(valid_image_path)image = ImageQuery.new.firstflow.delete_image_from_homepage(image.id)flow.image_should_not_exist(image.id)endit "is not allowed for other ip addresses" doflow = ImagesFlow.newnot_owned = ImageBox.new.owner_ip("not-local").createflow.delete_image_from_action(not_owned.id)flow.image_should_exist(not_owned.id)endendend...
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::Indeximage(id).should be_on_pageenddef homepage_should_not_display_image(id)visit Home::Indeximage(id).should_not be_on_pageenddef delete_image_from_homepage(id)visit Home::Indexclick "@delete-image-#{id}"enddef delete_image_from_action(id)visit Images::Delete.with(id: id)enddef image_should_exist(id)ImageQuery.find(id).should_not be_nilenddef image_should_not_exist(id)ImageQuery.new.id(id).first?.should be_nilend...private def image(id)el("@image-#{id}")end
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 < GuestLayoutneeds form : ImageFormneeds images : ImageQuery # ADD THIS!def contentdiv class: "homepage-container" dorender_form(@form)gallery # add gallery erhereendendprivate def gallery # define it hereh2 "Image Gallery"ul class: "image-gallery" do@images.map do |image|li class: "image", flow_id: "image-#{image.id}" dodiv class: "picture", style: "background-image: url(#{image.path});" dodiv "Views: #{image.views}", class: "views"endlink to: Images::Delete.with(image.id), flow_id: "delete-image-#{image.id}" doimg src: asset("images/x.png")enddiv image.url, class: "image-url", flow_id: "image-url-#{image.id}"endendendend...end
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.crclass Home::Index < BrowserAction...get "/" doif current_user?redirect Me::Showelseimages = ImageQuery.new.owner_ip(current_ip)render Home::IndexPage, form: ImageForm.new, images: images # pass it in hereendendend
And in our Images::Create
action.
# src/actions/images/create.crclass Images::Create < BrowserAction...route doif is_invalid(file)...elseImageForm.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 hereendendendend...end
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
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.crclass Images::Delete < BrowserActioninclude Auth::SkipRequireSignInunexpose current_userroute do # expands to: delete "/images/:id"image = ImageQuery.find(id)if image.owner_ip == current_ipimage.delete!flash.success = "Image succesfully deleted!"redirect to: Home::Indexelseflash.danger = "You are not authorized to delete this image"redirect to: Home::Indexendendend
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::Showlucky gen.page Images::ShowPagetouch src/forms/image_views_form.cr # no generater for forms atm
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.crclass ImageViewsForm < Image::BaseFormfillable viewsfillable filenamefillable owner_ipdef prepareviews.value = views.value.not_nil! + 1endend
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.crclass Images::Show < BrowserActioninclude Auth::SkipRequireSignInunexpose current_userget "/images/:filename" doimage = ImageQuery.new.filename(filename).first?if image.nil?flash.danger = "Image with filename: #{filename} not found"redirect to: Home::IndexelseImageViewsForm.update!(image)render Images::ShowPage, image: imageendendend
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.crclass Images::ShowPage < GuestLayoutneeds image : Imagedef contentdiv style: "text-align: center;" doh1 @image.filenameh2 "Views: #{@image.views}"img src: @image.path, style: "max-width: 100%; height: auto;"endendend
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}" dolink to: Images::Show.with(image.filename), # Changed this to link: ..class: "picture",style: "background-image: url(#{image.path});" dodiv "Views: #{image.views}", class: "views"end...endendend...end
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.