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
Allow an ActiveStorage attachment to be removed via a form post #48339
Allow an ActiveStorage attachment to be removed via a form post #48339
Conversation
Attachments can already be removed by updating the attachment to be nil such as: ```ruby User.find(params[:id]).update!(avatar: nil) ``` However, a form cannot post a nil param, it can only post an empty string. But, posting an empty string would result in an `ActiveSupport::MessageVerifier::InvalidSignature: mismatched digest` error being raised, because it's being treated as a signed blob id. Now, nil and an empty string are treated as a delete, which allows attachments to be removed via: ```ruby User.find(params[:id]).update!(params.require(:user).permit(:avatar)) ```
One one hand I don't like that we have to deal with HTML form concerns on the model layer, but on the other I'm forced to admit we already do a lot of that stuff (typically how Active Record converts booleans). So I'd tend to approve this. I'll see if I can find a second opinion, if not I'll merge in a few days. |
@byroot Agreed, but I'm not sure how to handle it otherwise. The controller layer (where I'd prefer to handle just converting This does get into me really never being a fan of empty strings, and always wanting string columns to either be All that to say, if the |
I'm approving this since I can't imagine better solutions to the problem but still open for discussion if someone has a better proposal or other suggestions. |
@@ -61,7 +61,7 @@ def #{name} | |||
|
|||
def #{name}=(attachable) | |||
attachment_changes["#{name}"] = | |||
if attachable.nil? | |||
if attachable.nil? || attachable == "" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤔 Any reason to not use blank?
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I simply wanted to add support for empty strings, since that’s how HTML form fields work when they have no value
. The previous code checked for nil?
instead of being falsely, which I assumed was intentional.
Therefore, I feel like whitespace-only strings and false
should still raise an ActiveSupport::MessageVerifier::InvalidSignature: mismatched digest
.
Additionally, blank?
is about 2.17x slower than what I've got here, when given a signed id:
# frozen_string_literal: true
require 'benchmark/ips'
require 'active_support'
str = +'BAh7CEkiCGdpZAY6BkVUSSIbZ2lkOi8vZHdlbGwtd2ViL1VzZXIvMQY7AFRJIgxwdXJwb3NlBjsAVEkiDGRlZmF1bHQGOwBUSSIPZXhwaXJlc19hdAY7AFRJIh0yMDIzLTA3LTAyVDEzOjUwOjE2LjM4MVoGOwBU--4335d744fdc394d750210728675cb8ef9e7e0ef0'
Benchmark.ips do |x|
x.report('==') { str.nil? || str == '' }
x.report('blank?') { str.blank? }
x.compare!
end
Warming up --------------------------------------
== 1.750M i/100ms
blank? 802.528k i/100ms
Calculating -------------------------------------
== 17.498M (± 0.5%) i/s - 87.495M in 5.000509s
blank? 8.052M (± 0.9%) i/s - 40.929M in 5.083819s
Comparison:
==: 17497713.1 i/s
blank?: 8051557.4 i/s - 2.17x slower
blank?
would solve Jean's concerns of mixing HTML form concerns in the model layer, but it would come at the expense of allowing more values to not raise an error (which feels incorrect), and being slower.
@natematykiewicz that exception is raising for nested model form submit. Do you have any clue where it could be produced? parameters = params.require(:post).permit(:name, comments_attributes: [:text, :image]) applying your tip for skip (looking for each comment and set to |
This PR is a feature of Rails 7.1. If you're on Rails 7.0 or lower and submitting |
Ouch, I'm on 7.0.x 🤡 |
I'm still on 7.0, running running this backport in raise 'Remove this backport' if Rails.version.to_f != 7.0
# https://github.com/rails/rails/pull/48339
# Method copy/pasted from here, added ` || attachable == ""`, to match PR 48339's change.
# https://github.com/rails/rails/blob/v7.0.5/activestorage/lib/active_storage/attached/model.rb
ActiveStorage::Attached::Model::ClassMethods.class_eval do
def has_one_attached(name, dependent: :purge_later, service: nil, strict_loading: false)
validate_service_configuration(name, service)
generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
# frozen_string_literal: true
def #{name}
@active_storage_attached ||= {}
@active_storage_attached[:#{name}] ||= ActiveStorage::Attached::One.new("#{name}", self)
end
def #{name}=(attachable)
attachment_changes["#{name}"] =
if attachable.nil? || attachable == ""
ActiveStorage::Attached::Changes::DeleteOne.new("#{name}", self)
else
ActiveStorage::Attached::Changes::CreateOne.new("#{name}", self, attachable)
end
end
CODE
has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment", as: :record, inverse_of: :record, dependent: :destroy, strict_loading: strict_loading
has_one :"#{name}_blob", through: :"#{name}_attachment", class_name: "ActiveStorage::Blob", source: :blob, strict_loading: strict_loading
scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) }
after_save { attachment_changes[name.to_s]&.save }
after_commit(on: %i[ create update ]) { attachment_changes.delete(name.to_s).try(:upload) }
reflection = ActiveRecord::Reflection.create(
:has_one_attached,
name,
nil,
{ dependent: dependent, service_name: service },
self
)
yield reflection if block_given?
ActiveRecord::Reflection.add_attachment_reflection(self, name, reflection)
end
end Whenever I upgrade to 7.1, I'll just delete that file and things will continue to work the same. |
I don't think that method has changed between 7.0.5 and 7.0.8, but you'll want to double-check. But all it does is add |
Motivation / Background
We use ActiveStorage attachments via Direct Uploads, which set the attachment value to an ActiveStorage::Blob signed id. We also have the ability to remove an attachment (such as an avatar) in the UI via a "clear" button. This button simply sets the attachment to an empty string (via a hidden field tag), since a form cannot send a nil param. However, we found that this causes an
ActiveSupport::MessageVerifier::InvalidSignature: mismatched digest
error to be raised, because it's being treated as a signed blob id.Detail
This PR changes the
#{attachment}=
ActiveRecord method to treat an empty string just like it treats a nil, and useActiveStorage::Attached::Changes::DeleteOne
instead ofActiveStorage::Attached::Changes::CreateOne
.Now these two are treated the same:
Additional information
Without this change, you have to do things like this in all of your controllers that accept direct uploads, which is quite tedious:
When adding tests, I simply copy/pasted the tests that test assigning
nil
, and modified them to be""
tests.Checklist
Before submitting the PR make sure the following are checked:
[Fix #issue-number]