Ruby on Rails: Upload file một cách an toàn với Shrine.rb và Dropzone.js
Bài đăng này đã không được cập nhật trong 7 năm
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" 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:
- 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
All rights reserved