Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Active Storage for Picture and File attachments #2544

Closed
wants to merge 29 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7940439
Add activestorage
tvdeyen Apr 12, 2022
a9076d1
Add image_processing
tvdeyen Apr 12, 2022
7be7e63
Add dragonfly to image processing converter
tvdeyen Apr 12, 2022
568f221
Use vips as image processor in dummy app
tvdeyen Apr 12, 2022
2b83c03
[ci] install libvips
tvdeyen Apr 12, 2022
0f33ae6
Ignore active storage files in dummy app
tvdeyen Apr 12, 2022
9ac823c
Use custom validations for picture size and format
tvdeyen Apr 12, 2022
f4a1259
Use activestorage for picture file handling
tvdeyen Apr 12, 2022
a2c53b5
Do not sharpen images by default
tvdeyen Apr 12, 2022
ff756d3
Add image_file_extension method
tvdeyen Apr 12, 2022
5890956
Delegate convertible format check to activestorage
tvdeyen Apr 12, 2022
ea33ea1
Eager load attachments in admin pictures controller
tvdeyen Apr 12, 2022
6d94c04
Delegate image_file_* methods to attached file
tvdeyen Apr 12, 2022
2c21393
Remove custom picture variant classes
tvdeyen Apr 12, 2022
1755d5c
Move can_be_cropped_to? into picture_thumbnails
tvdeyen Apr 12, 2022
0e6a2bb
Use deletaged image_file methods for validations
tvdeyen Apr 12, 2022
9118b75
Use ActiveStorage for Attachments as well
tvdeyen Apr 12, 2022
de9e7f5
Use file mime type for picture by format select
tvdeyen Apr 12, 2022
dcf8ee1
Remove Dragonfly
tvdeyen Apr 12, 2022
4b322be
Add support for animated gifs
tvdeyen Aug 2, 2023
5e9f259
wip: always return image format
tvdeyen Aug 4, 2023
93ce5e3
amend Gemfile mini_magick
tvdeyen Jan 23, 2024
59ee6a2
Use AS redirect path
tvdeyen Jan 23, 2024
096a0ca
Fix picture search
tvdeyen Jan 23, 2024
776e33d
amend use activestorage
tvdeyen Jan 23, 2024
38adfd0
Keep webp as mime type
tvdeyen Jan 23, 2024
77b241c
amend use active stprage
tvdeyen Jan 23, 2024
c641755
Add upgrader for active storage
tvdeyen Jan 23, 2024
6b5b532
Preprocess thumbnails after upload
tvdeyen Jan 29, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ jobs:
sudo apt update -qq
sudo apt install -qq --fix-missing libmysqlclient-dev -o dir::cache::archives="/home/runner/apt/cache"
sudo chown -R runner /home/runner/apt/cache
- name: Install libvips
env:
DEBIAN_FRONTEND: noninteractive
run: sudo apt install --fix-missing libvips -o dir::cache::archives="/home/runner/apt/cache"
- name: Restore node modules cache
id: yarn-cache
uses: actions/cache@v3
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ yarn-debug.log*
/spec/dummy/public/pictures
.byebug_history
.vscode/
/spec/dummy/storage
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ gem "pg", "~> 1.0" if ENV["DB"] == "postgresql"

gem "alchemy_i18n", git: "https://github.com/AlchemyCMS/alchemy_i18n.git", branch: "main"

gem "ruby-vips"

group :development, :test do
gem "execjs", "~> 2.9.1"
gem "rubocop", require: false
Expand Down
2 changes: 2 additions & 0 deletions alchemy_cms.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Gem::Specification.new do |gem|
activejob
activemodel
activerecord
activestorage
activesupport
railties
].each do |rails_gem|
Expand All @@ -41,6 +42,7 @@ Gem::Specification.new do |gem|
gem.add_runtime_dependency "dragonfly_svg", ["~> 0.0.4"]
gem.add_runtime_dependency "gutentag", ["~> 2.2", ">= 2.2.1"]
gem.add_runtime_dependency "handlebars_assets", ["~> 0.23"]
gem.add_runtime_dependency "image_processing", [">= 1.2"]
gem.add_runtime_dependency "importmap-rails", ["~> 1.2", ">= 1.2.1"]
gem.add_runtime_dependency "jquery-rails", ["~> 4.0", ">= 4.0.4"]
gem.add_runtime_dependency "kaminari", ["~> 1.1"]
Expand Down
6 changes: 3 additions & 3 deletions app/components/alchemy/ingredients/picture_view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ class PictureView < BaseView
# @param disable_link [Boolean] (false) Whether to disable the link even if the picture has a link.
# @param srcset [Array<String>] An array of srcset sizes that will generate variants of the picture.
# @param sizes [Array<String>] An array of sizes that will be passed to the img tag.
# @param picture_options [Hash] Options that will be passed to the picture url. See {Alchemy::PictureVariant} for options.
# @param picture_options [Hash] Options that will be passed to the picture url. See {Alchemy::Picture#url} for options.
# @param html_options [Hash] Options that will be passed to the img tag.
# @see Alchemy::PictureVariant
# @see Alchemy::Picture#url
def initialize(
ingredient,
show_caption: nil,
Expand Down Expand Up @@ -101,7 +101,7 @@ def srcset_options
end

def alt_text
ingredient.alt_tag.presence || html_options.delete(:alt) || ingredient.picture.name&.humanize
ingredient.alt_tag.presence || html_options.delete(:alt) || ingredient.picture.name&.humanize&.presence
end
end
end
Expand Down
8 changes: 0 additions & 8 deletions app/controllers/alchemy/admin/attachments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,6 @@ def destroy
flash[:notice] = Alchemy.t("File deleted successfully", name: name)
end

def download
@attachment = Attachment.find(params[:id])
send_file @attachment.file.path, {
filename: @attachment.file_name,
type: @attachment.file_mime_type
}
end

private

def search_filter_params
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/alchemy/admin/pictures_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class PicturesController < Alchemy::Admin::ResourcesController

def index
@query = Picture.ransack(search_filter_params[:q])
@pictures = filtered_pictures.includes(:thumbs)
@pictures = filtered_pictures.with_attached_image_file

if in_overlay?
archive_overlay
Expand Down
41 changes: 24 additions & 17 deletions app/controllers/alchemy/attachments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,44 @@

module Alchemy
class AttachmentsController < BaseController
include ActiveStorage::Streaming

before_action :load_attachment
authorize_resource class: Alchemy::Attachment

self.etag_with_template_digest = false

# sends file inline. i.e. for viewing pdfs/movies in browser
def show
response.headers["Content-Length"] = @attachment.file.size.to_s
send_file(
@attachment.file.path,
{
filename: @attachment.file_name,
type: @attachment.file_mime_type,
disposition: "inline"
}
)
authorize! :show, @attachment
send_blob disposition: :inline
end

# sends file as attachment. aka download
def download
response.headers["Content-Length"] = @attachment.file.size.to_s
send_file(
@attachment.file.path, {
filename: @attachment.file_name,
type: @attachment.file_mime_type
}
)
authorize! :download, @attachment
send_blob disposition: :attachment
end

private

def load_attachment
@attachment = Attachment.find(params[:id])
end

def send_blob(disposition: :inline)
@blob = @attachment.file.blob

if request.headers["Range"].present?
send_blob_byte_range_data @blob, request.headers["Range"], disposition: disposition
else
http_cache_forever public: true do
response.headers["Accept-Ranges"] = "bytes"
send_blob_stream @blob, disposition: disposition
# Rails ActionController::Live removes the Content-Length header,
# but browsers need that to be able to show a progress bar during download.
response.headers["Content-Length"] = @blob.byte_size.to_s
end
end
end
end
end
80 changes: 66 additions & 14 deletions app/models/alchemy/attachment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,22 @@ class Attachment < BaseRecord
include Alchemy::Taggable
include Alchemy::TouchElements

dragonfly_accessor :file, app: :alchemy_attachments do
after_assign { |f| write_attribute(:file_mime_type, f.mime_type) }
# Legacy Dragonfly file attachments
extend Dragonfly::Model
dragonfly_accessor :legacy_file, app: :alchemy_attachments
DEPRECATED_COLUMNS = %i[
legacy_file
legacy_file_name
legacy_file_size
legacy_file_uid
].each do |column|
deprecate column, deprecator: Alchemy::Deprecation
deprecate :"#{column}=", deprecator: Alchemy::Deprecation
end

# Use ActiveStorage file attachments
has_one_attached :file, service: :alchemy_cms

stampable stamper_class_name: Alchemy.user_class.name

has_many :file_ingredients,
Expand All @@ -38,7 +50,11 @@ class Attachment < BaseRecord
has_many :elements, through: :file_ingredients
has_many :pages, through: :elements

scope :by_file_type, ->(file_type) { where(file_mime_type: file_type) }
scope :by_file_type,
->(file_type) {
with_attached_file.joins(:file_blob).where(active_storage_blobs: {content_type: file_type})
}

scope :recent, -> { where("#{table_name}.created_at > ?", Time.current - 24.hours).order(:created_at) }
scope :without_tag, -> { left_outer_joins(:taggings).where(gutentag_taggings: {id: nil}) }

Expand All @@ -62,7 +78,7 @@ def alchemy_resource_filters
[
{
name: :by_file_type,
values: distinct.pluck(:file_mime_type).map { |type| [Alchemy.t(type, scope: "mime_types"), type] }.sort_by(&:first)
values: file_types
},
{
name: :misc,
Expand All @@ -85,16 +101,23 @@ def searchable_alchemy_resource_attributes
def allowed_filetypes
Config.get(:uploader).fetch("allowed_filetypes", {}).fetch("alchemy/attachments", [])
end

private

def file_types
ActiveStorage::Blob.joins(:attachments).merge(
ActiveStorage::Attachment.where(record_type: name)
).distinct.pluck(:content_type)
end
end

validates_presence_of :file
validates_size_of :file, maximum: Config.get(:uploader)["file_size_limit"].megabytes
validates_property :ext,
of: :file,
in: allowed_filetypes,
case_sensitive: false,
message: Alchemy.t("not a valid file"),
unless: -> { self.class.allowed_filetypes.include?("*") }

validate :file_not_too_big, if: -> { file.present? }

validate :file_type_allowed,
unless: -> { self.class.allowed_filetypes.include?("*") },
if: -> { file.present? }

before_save :set_name, if: :file_name_changed?

Expand All @@ -111,7 +134,7 @@ def to_jq_upload
end

def url(options = {})
if file
if file.present?
self.class.url_class.new(self).call(options)
end
end
Expand All @@ -126,9 +149,23 @@ def restricted?
pages.any? && pages.not_restricted.blank?
end

# File name
def file_name
file&.filename&.to_s
end

# File size
def file_size
file&.byte_size
end

def file_mime_type
super || file&.content_type
end

# File format suffix
def extension
file_name.split(".").last
file&.filename&.extension
end

alias_method :suffix, :extension
Expand Down Expand Up @@ -164,8 +201,23 @@ def icon_css_class

private

def file_type_allowed
unless extension&.in?(self.class.allowed_filetypes)
errors.add(:image_file, Alchemy.t("not a valid file"))
end
end

def file_not_too_big
maximum = Config.get(:uploader)["file_size_limit"]&.megabytes
return true unless maximum

if file_size > maximum
errors.add(:file, :too_big)
end
end

def set_name
self.name = convert_to_humanized_name(file_name, file.ext)
self.name = convert_to_humanized_name(file_name, extension)
end
end
end
Loading
Loading