Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save omnilord/4f308d4a1d0b9df02293dcaa8ee4d605 to your computer and use it in GitHub Desktop.
Save omnilord/4f308d4a1d0b9df02293dcaa8ee4d605 to your computer and use it in GitHub Desktop.
ActiveStorage is a great new feature introduced in Rails 5.2. I had a need for uploading images for profiles, and rather than go the route of including a gem that duplicated functionality (in this case, multiple gems, paperclip, fog, etc. to upload, process, and transport to storage), I wanted to use the internal solution. It was a GREAT choice,…
#
# A similar file will be created in ${RAILS_ROOT}/db/migrate/ when you run
# > rails active_storage:install && rails db:migrate
#
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
def change
create_table :active_storage_blobs do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.bigint :byte_size, null: false
t.string :checksum, null: false
t.datetime :created_at, null: false
t.index [ :key ], unique: true
end
create_table :active_storage_attachments do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false
t.references :blob, null: false
t.datetime :created_at, null: false
t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
end
end
end
<!--
file location: ${RAILS_ROOT}/app/views/profiles/_form.html.erb
-->
<%# This view expects you are using the usual form partial render that comes default with Rails scaffold %>
<%# Since I am copying this from my real project, you will notice I have some bootstrap sprinkled in %>
<div class="form-group">
<%= form_with(model: profile, url: submit_path, id: 'profile-form', local: true) do |form| %>
<div class="row">
<div class="col-md-2 field">
<div id="profile-container" class="profile-avatar">
<%= form.file_field(:avatar, multiple: false, class: 'img-upload-input hidden', direct_upload: true) %>
<%= form.check_box(:delete_avatar, class: 'hidden', include_hidden: false) %>
<div class="thumbnail-container">
<% if profile.avatar.attached? %>
<%= image_tag(profile.thumbnail, id: 'avatar-preview', class: 'avatar present', alt: t(:avatar), size: '100x100') %>
<button type="button" id="remove-avatar" class="btn btn-xs btn-danger avatar-btn pull-right" title="<%= t(:remove) %>">
<i class="fa fa-trash"></i> Delete
</button>
<% else %>
<%= image_tag('click_to_add.png', id: 'avatar-preview', class: 'avatar', alt: t(:click_to_add), size: '100x100') %>
<% end %>
</div>
</div>
</div>
<div class="col-md-10 field">
<%= form.label :name %>
<%= form.text_field :name, id: :profile_name, class: 'form-control' %>
</div>
</div>
<div class="row">
<div class="col-md-12 field">
<%= form.label :description %>
<%= form.text_area :description, id: :profile_description, class: 'form-control markdown-cntl' %>
</div>
</div>
<%= end %>
</div>
<script>
/*
//
// this JavaScript probably doesn't work 100% out of the box since it is a snippet from inside a larger block
//
// uses jQuery and Jcrop
//
*/
$(function () {
var thumbnail_crop = {},
default_css = {
height: '100px',
width: '100px',
marginLeft: '0px',
marginTop: '0px'
},
avatar_url = '<%= profile.avatar_attached? ? url_for(profile.avatar) : '' %>',
thumbnail_url = '<%= profile.avatar_attached? ? url_for(profile.thumbnail) : '' %>',
add_avatar_image_path = '<%= image_path('click_to_add.png') %>',
$avatar_thumb = $('#avatar-preview'),
$avatar_input = $('#profile_avatar').on('change', function (ev) {
var vertical_ratio = 100 / thumbnail_crop.h,
horizonal_ratio = 100 / thumbnail_crop.w,
$captureImage, reader = new FileReader();
/*
Do whatever you need to to capture a crop of the image for the thumbnail
coordinates and dimensions. I popup a dialog containing Jcrop and capture
the crop values with a save button. Just assume that on clicking the save
button, thumbnail_crop is populated with [h]eight, [w]idth, x, y values.
For the sake of simplicity, $captureImage is the jQuery references to the
img tag containing the image in Jcrop. I omitted everything else.
*/
if (this.files && this.files[0]) {
reader.onload = function (ev) {
$captureImage.attr('src', ev.target.result);
};
reader.readAsDataURL(input.files[0]);
$avatar_delete_checkbox.prop('checked', false); // Saving a new image will delete the old one implicitly
$avatar_thumb.attr(src, $captureImage.attr('src')).css({
height: Math.round(vertical_ratio * $captureImage.prop('naturalHeight')) + 'px',
width: Math.round(horizonal_ratio * $captureImage.prop('naturalWidth')) + 'px',
marginTop: '-' + Math.round(vertical_ratio * thumb.y) + 'px',
marginLeft: '-' + Math.round(horizonal_ratio * thumb.x) + 'px'
});
}
});
$avatar_delete_checkbox = $('#profile_delete_avatar'),
$remove_btn = $('#remove-avatar'),on('click', function () {
$avatar_input.val(null);
if ($avatar_thumb.hasClass('present')) {
$avatar_delete_checkbox.prop('checked', true);
}
$avatar_thumb.attr(src, add_avatar_image_path).css(default_css);
});
$('#profile-form').on('submit', function (ev) {
if (thumbnail_crop && thumbnail_crop.h && thumbnail_crop.w) {
$(this).append($('<input />').attr({ type: 'hidden', name: 'thumbnail', value: JSON.stringify(thumbnail_crop) }));
}
return true;
});
});
</script>
#
# file location: ${RAILS_ROOT}/config/environments/development.rb
#
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
...
# Store uploaded files on the local file system (see config/storage.yml for options)
config.active_storage.service = :local # <= EXAMPLE CODE
...
end
#
# file location: ${RAILS_ROOT}/config/environments/production.rb
#
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
...
# Store uploaded files on the local file system (see config/storage.yml for options)
config.active_storage.service = :amazon # <= EXAMPLE CODE
...
end
#
# file location: ${RAILS_ROOT}/app/models/profile.rb
#
class Profile < ApplicationRecord
...
has_one_attached :avatar
attr_accessor :delete_avatar
before_save :destroy_avatar!, if: -> { delete_avatar == '1' }
...
def thumbnail(size = '100x100')
if avatar.attached?
# I store thumbnail as a json in PostgreSQL. YMMV depending on your persistence layer options.
if thumbnail.is_a? Hash
dimensions = "#{thumbnail['h']}x#{thumbnail['w']}"
coord = "#{thumbnail['x']}+#{thumbnail['y']}"
avatar.variant(crop: "#{dimensions}+#{coord}", resize: size).processed
else
avatar.variant(resize: size).processed
end
end
end
...
private
# My destroy avatar function has the option to tell it do it now, or later, depending on your expected system load
def destroy_avatar!(now = true)
if avatar.attached?
now ? avatar.purge : avatar.purge_later
end
end
def clean_texts!
name = sanitize(name)
description = sanitize(description, tags: %w[div span img i p], attributes: %w[style class src alt])
if avatar.attached?
fn = "#{avatar.filename.base.parameterize}.#{avatar.filename.extension}"
avatar.filename = ActiveStorage::Filename.new(fn)
end
end
end
#
# file location: ${RAILS_ROOT}/app/controllers/profiles_controller.rb
#
class ProfilesController < ApplicationController
# You're usual RESTful endpoint functions here: index, view, new, create, end, update, destroy
...
# Never trust parameters from the scary internet, only allow the white list through.
def profile_params
permitted = params.require(:profile).permit(:name, :description, # :whatever_fields_you_use,
:avatar, :delete_avatar) # <= EXAMPLE CODE
if params['thumbnail'].present?
thumb_data = JSON.parse(params['thumbnail'], symbolize_names: true)
# Make sure we only have integers for the expected 4 values.
if thumb_data[:h].present? && thumb_data[:w].present? && thumb_data[:x].present? && thumb_data[:y].present?
permitted[:thumbnail] = {
h: thumb_data[:h].to_i,
w: thumb_data[:w].to_i,
x: thumb_data[:x].to_i,
y: thumb_data[:y].to_i
}
end
end
permitted.permit!
end
<!--
file location: ${RAILS_ROOT}/assets/views/profiles/show.html.erb
-->
<!-- put something like this anywhere you want to show the full-size avatar image -->
<%= profile.avatar.attached? ?
image_tag(profile.avatar, class: 'media-object avatar thumbnail', alt: t(:avatar)) :
image_tag('coming_soon.png', class: 'media-object thumbnail', alt: t(:coming_soon))
%>
<!-- put this anywhere you want to show the thumbnail of the image -->
<%= profile.avatar.attached? ?
image_tag(profile.thumbnail, class: 'media-object avatar thumbnail', alt: t(:avatar), size: '100x100') :
image_tag('coming_soon.png', class: 'media-object thumbnail', alt: t(:coming_soon), size: '100x100')
%>
<!-- I also have this beautiful popup with the thumbnail in my show: -->
<script>
$(function () {
var body_overflow = $('body').css('overflow') || 'visible',
$avatar = $('img.avatar.thumbnail');
$avatar.on('click', function () {
var $closePreview = $('<span class="overlay-closer"><i class="fa fa-2x fa-window-close white"></i></span>'),
$previewImage = $('<div class="preview-image"></div>')
.css('background-image', 'url(<%= url_for(profile.avatar) %>)'),
$onionskin = $('<div class="avatar-preview-onionskin"></div>').append([
$previewImage,
$closePreview
]),
esc_keypress = function (ev) {
if (ev.keyCode == 27) {
$closePreview.trigger('click');
}
};
$closePreview.on('click', function () {
$(document).off('keyup', esc_keypress);
$onionskin.remove();
$onionskin = undefined;
$closePreview = undefined;
$previewImage = undefined;
$('body').css('overflow', body_overflow);
});
$(document).on('keyup', esc_keypress);
$('body').css('overflow', 'hidden').prepend($onionskin);
});
});
</script>
<% end %>
#
# file location: ${RAILS_ROOT}/config/storage.yml
#
test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# <= EXAMPLE CODE =>
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
region: <%= ENV.fetch('ACTIVESTORAGE_AWS_S3_REGION') { 'us-west-2' } %>
bucket: <%= ENV.fetch('ACTIVESTORAGE_AWS_S3_BUCKET') %>
# <= END EXAMPLE CODE =>
# Remember not to checkin your GCS keyfile to a repository
# google:
# service: GCS
# project: your_project
# keyfile: <%= Rails.root.join("path/to/gcs.keyfile") %>
# bucket: your_own_bucket
# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
# microsoft:
# service: AzureStorage
# path: your_azure_storage_path
# storage_account_name: your_account_name
# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
# container: your_container_name
# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment