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

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 :: 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

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

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

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 the click "@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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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.