Ruby on Rails: Upload file một cách an toàn với Shrine.rb và Dropzone.js
Lời mở đầu Một form cho cho việc upload file có thể là một kẽ hở bảo mật để tấn công. Nhận ra điều đó, Janko Marohnić đã viết ra thư viện Shrine nhằm cải thiện tình trạng hiện thời của upload file trong Rails. Bạn có thể xem thêm trong blog của anh ấy hoặc trong documentation của Shrine để hiểu ...
Lời mở đầu
Một form cho cho việc upload file có thể là một kẽ hở bảo mật để tấn công. Nhận ra điều đó, Janko Marohnić đã viết ra thư viện Shrine nhằm cải thiện tình trạng hiện thời của upload file trong Rails. Bạn có thể xem thêm trong blog của anh ấy hoặc trong documentation của Shrine để hiểu thêm về vấn đề này.
Shrine là một thư viện upload file viết bằng Ruby, hỗ trợ cho cả Ruby thuần, Rails, Hanami và các web framework khác dựa trên nền Ruby.
Shrine có thể được áp dụng vào ngay từ đầu dự án hoặc nâng cấp từ giải pháp hiện tại như carrierwave, paperclip hay refile. Shrine cung cấp các công cụ để xây dựng plugin trên nền tảng của nó. Chính vì vậy, gần như tất cả các config của Shrine đều được thực hiện qua plugin.
Shrine
Một module phục vụ upload ảnh thường có đủ các chức năng sau:
- Upload ảnh lên Amazon S3
- Image versioning
- Đảm bảo các file đều "sạch"
- Upload bằng background job
- Kiểm tra kiểu file và độ lớn
- Loại bỏ các file đính kèm
- Cache file trong trường hợp lỗi
- Upload bằng drag and drop
- Truy cập ảnh thông qua CloudFront CDN
Cài đặt
Với các yêu cầu trên, ta bắt đầu cài đặt các gem cần thiết:
# Upload ảnh lên Amazon S3 gem 'aws-sdk' # Image versioning gem 'image_processing' # Image versioning gem 'mini_magick' # Shrine gem 'shrine'
Thông số đầu vào
Sau đó, ta sẽ tạo một initializer mang tên config/initializers/shrine.rb. File này sẽ chịu trách nhiệm config các plugin và background job.
require 'shrine' require 'shrine/storage/s3' s3_options = { # Required region: ENV['aws_region'], bucket: ENV['aws_bucket'], access_key_id: ENV['aws_access_key_id'], secret_access_key: ENV['aws_secret_access_key'] } # URL options for CloudFront CDN url_options = { public: true, host: ENV['aws_host'] } # The S3 storage plugin handles uploads to Amazon S3 service, using the aws-sdk gem. Shrine.storages = { # With Shrine both temporary (:cache) and permanent (:store) storage are first-class citizens and fully configurable, so you can also have files cached on S3. cache: Shrine::Storage::S3.new(prefix: 'cache', upload_options: { acl: 'public-read' }, **s3_options), store: Shrine::Storage::S3.new(prefix: 'store', upload_options: { acl: 'public-read' }, **s3_options) } # Plugins # Provides ActiveRecord integration, adding callbacks and validations. Shrine.plugin :activerecord # Automatically logs processing, storing and deleting, with a configurable format. Shrine.plugin :logging, logger: Rails.logger # Allows you to specify default URL options for uploaded files. Shrine.plugin :default_url_options, cache: url_options, store: url_options # Backgrounding # Adds the ability to put storing and deleting into a background job. Shrine.plugin :backgrounding # Setup background jobs (sidekiq workers) for async uploads. Shrine::Attacher.promote { |data| ShrineBackgrounding::PromoteJob.perform_async(data) } Shrine::Attacher.delete { |data| ShrineBackgrounding::DeleteJob.perform_async(data) }
Cài đặt background job
Plugin backgrounding có hai phương thức là promote và delete mà ta có thể đưa vào hai sidekiq worker. Ta sẽ định nghĩa các worker này trong app/jobs.
Các file được đánh phiên bản sẽ được xử lý ở job promote, nếu sidekiq đang không chạy, các file sẽ không được xử lý. Mặc định file gốc sẽ được đánh dấu là không có phiên bản cho đến khi được xử lý.
Ta có thể sử dụng plugin :recache để làm cho một số phiên bản có thể sử dụng ngay lập tức và xử lý các phiên bản còn lại ở background.
# app/jobs/shrine_backgrounding/delete_job.rb module ShrineBackgrounding class DeleteJob include Sidekiq::Worker def perform(data) Shrine::Attacher.delete(data) end end end # app/jobs/shrine_backgrounding/promote_job.rb module ShrineBackgrounding class PromoteJob include Sidekiq::Worker def perform(data) Shrine::Attacher.promote(data) end end end
Cài đặt uploader
Với initializer trên ta đã sẵn sàng để tạo một class uploader kế thừa từ Shrine. Class uploader này sẽ có trách nhiệm xử lý các yêu cầu trong việc upload file như đã nói ở trên. Trong ví dụ này ta có một uploader cơ bản có thể áp dụng vào đa số các mô hình.
require 'image_processing/mini_magick' class PictureUploader < Shrine include ImageProcessing::MiniMagick # Plugin determine_mime_type cho phép xác định và lưu trữ kiểu file từ nội dung file đã phân tích plugin :determine_mime_type # Plugin remove_attachment cho phép xóa các file đính kèm bằng một checkbox trên form plugin :remove_attachment # Plugin store_dimensions lấy và lưu trữ kích thước các chiều của ảnh bằng gem fastimage, tránh việc gặp phải file ảnh quá lớn plugin :store_dimensions # Plugin validation_helpers cung cấp các hàm helper giúp validate file plugin :validation_helpers # Plugin pretty_location giúp tạo một cấu trúc thư mục đẹp để lưu các file tải lên plugin :pretty_location # Cho phép định nghĩa các processing cho một hành động cụ thể plugin :processing # Plugin versions cho phép xử lý theo version, bằng cách trả về một Hash chứa các file sau khi xử lý plugin :versions # Plugin delete_promoted thực hiện xóa các file sau khi sau khi được tải lên S3 thành công plugin :delete_promoted # Plugin delete_raw tự đông xóa các file thô sau khi được xử lý plugin :delete_raw # Plugin cached_attachment_data giữ lại cache file sau khi hiện lại form, giúp cho người dùng không phải upload lại nếu gặp lỗi validation plugin :cached_attachment_data # Plugin recache giúp sử dụng các version ngay lập tức plugin :recache # Define validations Attacher.validate do validate_max_size 15.megabytes, message: 'is too large (max is 15 MB)' validate_mime_type_inclusion ['image/jpeg', 'image/png', 'image/gif'] end # Sử dụng phiên bản :original và :thumbnail ngay lập tức process(:recache) do |io| { original: io, thumbnail: resize_to_fill!(io.download, 600, 600) } end # Thực hiện các phiên bản khác ở background process(:store) do |io| original = io[:original].download { # Original sm: resize_to_fit(original, 350, 350), md: resize_to_fit(original, 600, 600), lg: resize_to_fit(original, 1200, 1200), # Squares sm_square: resize_to_fill(original, 350, 350), md_square: resize_to_fill(original, 600, 600), lg_square: resize_to_fill(original, 1200, 1200), } end end
Upload cơ bản này trông có vẻ phải làm rất nhiền việc, nhưng thư viện Shrine thực hiện chủ yếu thông qua các plugin. Nhờ vậy ta có một uploader khá gọn nhẹ nhưng vẫn đáp ứng đầy đủ các yêu cầu đề ra bên trên.
Dropzone
Để thực hiện upload file bằng thao tác kéo thả ta sử dụng thư viện Dropzone.js For any javascript based library I prefer to use rails-assets to keep up with updates and keep one less package manager out of the stack (bower, npm).
Cài đặt
Nếu sử dụng rails-assets để cài đặt Dropzone, thêm các dòng sau vào Gemfile:
source 'https://rails-assets.org' do gem 'rails-assets-dropzone' end
Hoặc truy cập trang dropzone.com để tìm hiểu thêm các phương pháp khác.
Cài đặt JavaScript
Sử dụng Dropzone khá dễ dàng, ta sử dụng data attribute để thông báo cho Dropzone biết controller cần được gửi đến là gì.
Ta cùng cần truyền vào X-CSRF-Token cho Rails, ta có thể lấy được từ meta tag. Hoặc ta có thể sử dụng một skip action filter để vượt qua nó, nhưng cách này không được khuyến khích.
Dropzone.autoDiscover = false; $(function() { var pictureDropzone = new Dropzone('#picture_dropzone', { url: $('#picture_dropzone').data('url'), previewTemplate: $('#dropzone_preview_template').html(), previewsContainer: '#dropzone_previews_container', acceptedFiles: 'image/*', headers: { 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') }, maxFileSize: 15 }); pictureDropzone.on('success', function(file, response) { $('#pictures').append(response.picture); setTimeout(function() { pictureDropzone.removeFile(file) }, 3500); }); });
Sử dụng với Rails
Phần này sẽ nói về các cài đặt trong Rails để Shrine có thể làm việc và tái sử dụng tốt nhất.
Cài đặt trong model
Shrine tìm kiếm một cột <attribute>_data khi một uploader được sử dụng. Vì vậy ta cần chuẩn bị model để có thể sử dụng được uploader.
rails g model picture file_data rails db:migrate
Bên trong model, ta truyền tên của <attribute> vào cho uploader
class Picture < ApplicationRecord include PictureUploader[:file] end
Cài đặt trong routes
Trong ví dụ này, ta sẽ định nghĩa một số route phục vụ tạo và xem lại ảnh.
Action index sẽ đưa ra tất cả các file ảnh đã upload và một giao diện kéo thả để có thể tiếp tục upload ảnh.
Action create sẽ là điểm đến của Dropzone và thực hiện upload file.
Rails.application.routes.draw do resources :pictures, only: [:index, :create] root 'pictures#index' end
Cài đặt controller
Trung tâm của toàn bộ appication, nơi mà Rails, Shrine và Dropzone làm việc cùng nhau để đưa ra kết quả cuối cùng.
class PicturesController < ApplicationController # skip_before_action :verify_authenticity_token, only: [:create] def index @pictures = Picture.sorted end def create @picture = Picture.create(file: params[:file]) if @picture picture_partial = render_to_string( 'pictures/_picture', layout: false, formats: [:html], locals: { picture: @picture } ) render json: { picture: picture_partial }, status: 200 else render json: @picture.errors, status: 400 end end end
Tạo view
Dựa vào các mô tả phía trên, ta sẽ điểm lại những nét chính trong việc thiết kế view của ví dụ này:
- Hiện tất cả các ảnh đã upload
- Có một form/dropzone để upload ảnh mới
- Tự động thêm ảnh mới upload vào trang
Đây chỉ là một view đơn giản để hiện các ảnh đã được upload lên. Ta bắt đầu vạch ra các nét cơ bản trên file pictures/index.html.erb
<div class="container"> <div id="picture_dropzone" class="card p-5 my-5" data-url="<%= pictures_path %>"> <h4 class="text-center m-y-0"> Drop files here or click to upload. </h4> <div class="fallback"> <strong>Please enable javascript to upload images.</strong> </div> <div id="dropzone_previews_container"></div> </div> <div id="pictures" class="row"> <%= render @pictures %> </div> </div> <div id="dropzone_preview_template" style="display: none;"> <div class="dz-preview dz-file-preview"> <div class="media mt-3"> <img class="d-flex mr-3" data-dz-thumbnail height="75" awidth="75" /> <div class="media-body"> <h5 class="mt-0"><span data-dz-name></span></h5> <span class="text-muted"> <span class="dz-size" data-dz-size></span> </span> <p class="dz-error-message text-danger"> <span data-dz-errormessage></span> </p> <div class="progress"> <div class="progress-bar progress-bar-striped progress-bar-animated" data-dz-uploadprogress></div> </div> </div> </div> </div> </div>
Sau đó là partial pictures/_picture.erb để render từng ảnh một:
<%= content_tag :div, id: dom_id(picture), class: 'col-3' do %> <%= link_to image_tag(picture.file_url(:thumbnail), class: 'rounded img-fluid mb-4'), picture.file_url(:original) %> <% end %>
Vậy là ta đã hoàn thiện một application thực hiện upload ảnh.
Tổng hợp
Sau đây là tổng hợp lại những thao tác mà application ta vừa viết ra thực hiện:
- User thả một(hoặc nhiều) file vào dropzone trên form.
- Dropzone chuyển file đến controller.
- Rails tạo object Picture mới, truyền vào các tham số từ Dropzone.
- Shrine tự động xử lý dữ liệu.
- Nếu object Picture được tạo thành công, Rails thực hiện render ảnh mới lên một đoạn HTML và trả về trong một chuỗi JSON.
- Khi Dropzone nhận chuỗi JSON trả về từ Rails nó sẽ thực hiện thêm đoạn HTML nhận được vào trong DOM.
Nguồn tham khảo
- https://github.com/codyeatworld/example-shrine-dropzone
- https://codyeatworld.com/2017/04/18/rails-uploading-images-confidently-with-shrine-rb/