@@ -42,6 +42,33 @@ class Attachment < BaseRecord
4242 scope :recent , -> { where ( "#{ table_name } .created_at > ?" , Time . current - 24 . hours ) . order ( :created_at ) }
4343 scope :without_tag , -> { left_outer_joins ( :taggings ) . where ( gutentag_taggings : { id : nil } ) }
4444
45+ # Override +Alchemy::RelatableResource#deletable+ to also exclude
46+ # attachments referenced from +/attachment/:id/download+ URLs inside
47+ # ingredient values (e.g. Richtext markup, Link ingredients, raw Html).
48+ # Those URLs are written by the file tab of the link dialog and are
49+ # not tracked via the polymorphic +related_object+ association, so the
50+ # base scope cannot see them.
51+ #
52+ # Uses a correlated +NOT EXISTS+ subquery that builds the per-row LIKE
53+ # pattern with +Arel::Nodes::Concat+, which compiles to +||+ on
54+ # SQLite/PostgreSQL and +CONCAT()+ on MySQL.
55+ scope :deletable , -> do
56+ ingredients = Alchemy ::Ingredient . arel_table
57+ pattern = Arel ::Nodes ::Concat . new (
58+ Arel ::Nodes ::Concat . new (
59+ Arel ::Nodes . build_quoted ( "%/attachment/" ) ,
60+ arel_table [ :id ]
61+ ) ,
62+ Arel ::Nodes . build_quoted ( "/download%" )
63+ )
64+ referenced = ingredients
65+ . project ( 1 )
66+ . where ( ingredients [ :value ] . matches ( pattern ) )
67+
68+ where ( "#{ table_name } .id NOT IN (#{ RelatableResource ::RELATED_INGREDIENTS_SUBQUERY } )" , type : name )
69+ . where . not ( referenced . exists )
70+ end
71+
4572 # We need to define this method here to have it available in the validations below.
4673 class << self
4774 # The class used to generate URLs for attachments
@@ -112,6 +139,13 @@ def slug
112139 CGI . escape ( file_name . gsub ( /\. #{ extension } $/ , "" ) . tr ( "." , " " ) )
113140 end
114141
142+ # Override +Alchemy::RelatableResource#deletable?+ to also consider
143+ # +/attachment/:id/download+ links inside ingredient values (e.g.
144+ # Richtext markup, Link ingredients, raw Html).
145+ def deletable?
146+ super && !referenced_in_ingredient_value?
147+ end
148+
115149 # Checks if the attachment is restricted, because it is attached on restricted pages only
116150 def restricted?
117151 related_pages . any? && related_pages . not_restricted . blank?
@@ -178,6 +212,12 @@ def file_type_allowed
178212 end
179213 end
180214
215+ def referenced_in_ingredient_value?
216+ Alchemy ::Ingredient
217+ . where ( "value LIKE ?" , "%/attachment/#{ id } /download%" )
218+ . exists?
219+ end
220+
181221 def set_name
182222 self . name ||= Alchemy . storage_adapter . file_basename ( self ) . humanize
183223 end
0 commit comments