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 #2968

Draft
wants to merge 29 commits into
base: main
Choose a base branch
from
Draft
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
720f3b7
Add activestorage
tvdeyen Apr 12, 2022
a678f2d
Add image_processing
tvdeyen Apr 12, 2022
62a7484
Add dragonfly to image processing converter
tvdeyen Apr 12, 2022
64b9eab
Use vips as image processor in dummy app
tvdeyen Apr 12, 2022
35fab9b
[ci] install libvips
tvdeyen Apr 12, 2022
eb2ab47
Ignore active storage files in dummy app
tvdeyen Apr 12, 2022
8344fba
Use custom validations for picture size and format
tvdeyen Apr 12, 2022
48a3240
Use activestorage for picture file handling
tvdeyen Apr 12, 2022
b6d6332
Do not sharpen images by default
tvdeyen Apr 12, 2022
db69936
Add image_file_extension method
tvdeyen Apr 12, 2022
2866585
Delegate convertible format check to activestorage
tvdeyen Apr 12, 2022
f8d108f
Eager load attachments in admin pictures controller
tvdeyen Apr 12, 2022
4df586e
Delegate image_file_* methods to attached file
tvdeyen Apr 12, 2022
c3f8529
Remove custom picture variant classes
tvdeyen Apr 12, 2022
62d0a85
Move can_be_cropped_to? into picture_thumbnails
tvdeyen Apr 12, 2022
ab5dd29
Use deletaged image_file methods for validations
tvdeyen Apr 12, 2022
a9d79b7
Use ActiveStorage for Attachments as well
tvdeyen Apr 12, 2022
eef8605
Use file mime type for picture by format select
tvdeyen Apr 12, 2022
95386fa
Remove Dragonfly
tvdeyen Apr 12, 2022
84fc14b
Add support for animated gifs
tvdeyen Aug 2, 2023
d471242
Always return image format
tvdeyen Aug 4, 2023
4988f49
Use ActiveStorage redirect path
tvdeyen Jan 23, 2024
3cd6750
Fix picture and attachment search
tvdeyen Jan 23, 2024
b56b787
Preprocess thumbnails after upload
tvdeyen Jan 29, 2024
f1f59bf
Use image_file_extension as original format
tvdeyen Jun 11, 2024
c44b1bb
Allow webp as image format for ActiveStorage
tvdeyen Jun 11, 2024
5e909e3
Add upgrader for active storage
tvdeyen Dec 3, 2024
88cc9f2
Remove the dedicated alchemy_cms active storage service
tvdeyen Dec 6, 2024
fbcc5cc
Add storage adapter
tvdeyen Dec 13, 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/build_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,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"
- uses: actions/download-artifact@v4
if: needs.check_bun_lock.outputs.bun_lock_changed == 'true'
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,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", github: "AlchemyCMS/alchemy_i18n", 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 @@ -30,6 +30,7 @@ Gem::Specification.new do |gem|
activejob
activemodel
activerecord
activestorage
activesupport
railties
].each do |rails_gem|
Expand All @@ -44,6 +45,7 @@ Gem::Specification.new do |gem|
gem.add_runtime_dependency "dragonfly", ["~> 1.4"]
gem.add_runtime_dependency "dragonfly_svg", ["~> 0.0.4"]
gem.add_runtime_dependency "gutentag", ["~> 2.2", ">= 2.2.1"]
gem.add_runtime_dependency "image_processing", ["~> 1.13"]
gem.add_runtime_dependency "importmap-rails", ["~> 2.0"]
gem.add_runtime_dependency "kaminari", ["~> 1.1"]
gem.add_runtime_dependency "originator", ["~> 3.1"]
Expand Down
4 changes: 2 additions & 2 deletions app/components/alchemy/ingredients/picture_view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,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
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 @@ -22,7 +22,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
93 changes: 76 additions & 17 deletions app/models/alchemy/attachment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,21 @@ 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) }
end
attr_readonly(
:legacy_image_file_name,
:legacy_image_file_size,
:legacy_image_file_uid
)

deprecate(
:legacy_image_file_name,
:legacy_image_file_size,
:legacy_image_file_uid,
deprecator: Alchemy::Deprecation
)

# Use ActiveStorage file attachments
has_one_attached :file

stampable stamper_class_name: Alchemy.user_class.name

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 @@ -78,32 +94,46 @@ def last_upload
where(id: last_id)
end

# Used by Alchemy::Resource#search_field_name to build the search query
def searchable_alchemy_resource_attributes
%w[name file_name]
%w[name file_blob_filename]
end

def ransackable_attributes(_auth_object = nil)
%w[name]
end

def ransackable_associations(_auth_object = nil)
%w[file_blob]
end

def allowed_filetypes
Config.get(:uploader).fetch("allowed_filetypes", {}).fetch("alchemy/attachments", [])
end

private

def file_types
Alchemy.storage_adapter.file_formats(name)
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?("*") }

before_save :set_name, if: :file_name_changed?
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.changed? }

scope :with_file_type, ->(file_type) { where(file_mime_type: file_type) }

# Instance methods

def url(options = {})
if file
if file.present?
self.class.url_class.new(self).call(options)
end
end
Expand All @@ -118,9 +148,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 @@ -156,8 +200,23 @@ def icon_css_class

private

def file_type_allowed
unless extension&.in?(self.class.allowed_filetypes)
errors.add(: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