Allow ActionText embed reuse and safe purge (#2346)

This commit is contained in:
Jeremy Daer
2026-01-12 10:30:18 -08:00
committed by GitHub
parent 7c7b4eb621
commit 006e61ea58
4 changed files with 140 additions and 18 deletions
+19 -16
View File
@@ -16,6 +16,8 @@
# attached to an untracked type (avatar/export) could theoretically be reused in a tracked
# context. This is acceptable - user-accessible blob IDs from untracked contexts are
# basically nonexistent in practice.
#
# Exception: ActionText embeds are allowed to reuse blobs to support copy/paste.
ActiveSupport.on_load(:active_storage_attachment) do
validate :blob_account_matches_record, on: :create
@@ -29,30 +31,31 @@ ActiveSupport.on_load(:active_storage_attachment) do
# bypass tenancy checks via try(:account) returning nil - this is intentional as
# these classes don't participate in storage tracking.
def blob_account_matches_record
return unless record&.try(:account).present?
return if whitelisted_for_cross_account?
unless blob&.account_id == record.account.id
errors.add(:blob_id, "blob account must match record account")
if record&.try(:account).present? && !whitelisted_for_cross_account?
unless blob&.account_id == record.account.id
errors.add(:blob_id, "blob account must match record account")
end
end
end
# Ledger integrity: blob can only have one tracked attachment
def no_tracked_blob_reuse
tracked_record = record&.try(:storage_tracked_record)
return unless tracked_record.present?
return if whitelisted_for_cross_account?
if tracked_record.present? &&
!whitelisted_for_cross_account? &&
!(record_type == "ActionText::RichText" && name == "embeds")
# Check for existing attachment of this blob in tracked contexts
# Uses Storage::TRACKED_RECORD_TYPES constant to stay generic
existing = ActiveStorage::Attachment
.where(blob_id: blob_id)
.where(record_type: Storage::TRACKED_RECORD_TYPES)
.where.not(id: id)
.exists?
# Check for existing attachment of this blob in tracked contexts
# Uses Storage::TRACKED_RECORD_TYPES constant to stay generic
existing = ActiveStorage::Attachment
.where(blob_id: blob_id)
.where(record_type: Storage::TRACKED_RECORD_TYPES)
.where.not(id: id)
.exists?
if existing
errors.add(:blob_id, "cannot reuse blob in tracked storage context")
if existing
errors.add(:blob_id, "cannot reuse blob in tracked storage context")
end
end
end
@@ -0,0 +1,40 @@
# Fizzy-specific override: ActiveStorage's default purge path uses `delete`,
# which skips attachment callbacks. We need `destroy` so storage ledger detaches
# are recorded and reused blobs (ActionText embeds) aren't purged until the
# last attachment is gone. Keep this local to Fizzy; it's not a Rails default.
module ActiveStorage
module PurgeOnLastAttachment
def purge
@purge_mode = :purge
destroy
purge_blob_if_last(:purge) if destroyed?
ensure
@purge_mode = nil
end
def purge_later
@purge_mode = :purge_later
destroy
purge_blob_if_last(:purge_later) if destroyed?
ensure
@purge_mode = nil
end
private
def purge_dependent_blob_later
if dependent == :purge_later && !@purge_mode
purge_blob_if_last(:purge_later)
end
end
def purge_blob_if_last(mode)
if blob && !blob.attachments.exists?
mode == :purge ? blob.purge : blob.purge_later
end
end
end
end
ActiveSupport.on_load(:active_storage_attachment) do
prepend ActiveStorage::PurgeOnLastAttachment
end