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 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à promotedelete 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" width="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:

  1. User thả một(hoặc nhiều) file vào dropzone trên form.
  2. Dropzone chuyển file đến controller.
  3. Rails tạo object Picture mới, truyền vào các tham số từ Dropzone.
  4. Shrine tự động xử lý dữ liệu.
  5. 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.
  6. 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