Skip to content

Commit c100d10

Browse files
authored
Merge pull request #3859 from AlchemyCMS/backport/8.2-stable/pr-3855
[8.2-stable] Fix Attachment#deletable? false positives
2 parents 6f1ce8c + 0722860 commit c100d10

9 files changed

Lines changed: 201 additions & 8 deletions

File tree

app/controllers/alchemy/admin/attachments_controller.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ def index
3939
.page(params[:page] || 1)
4040
.per(items_per_page)
4141

42+
# Preload deletable ids for the current page in a single query so the
43+
# view can decide which delete buttons to enable without calling
44+
# +deletable?+ (a two-query check) per row. We pass the already-loaded
45+
# ids (via +map(&:id)+) rather than the relation itself, because passing
46+
# a paginated relation produces +IN (SELECT ... LIMIT n)+, which older
47+
# MariaDB versions reject.
48+
@deletable_attachment_ids =
49+
Attachment.where(id: @attachments.map(&:id)).deletable.pluck(:id).to_set
50+
4251
if in_overlay?
4352
archive_overlay
4453
end

app/controllers/alchemy/admin/pictures_controller.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ class PicturesController < Alchemy::Admin::ResourcesController
88
include CurrentLanguage
99
include PictureDescriptionsFormHelper
1010

11+
before_action :load_pictures, only: :index
12+
1113
before_action :load_resource,
1214
only: [:edit, :update, :url, :destroy]
1315

@@ -19,6 +21,12 @@ class PicturesController < Alchemy::Admin::ResourcesController
1921
@picture = Picture.find(params[:id])
2022
end
2123

24+
# Preload deletable ids for the current page (index) or the current
25+
# resource (update, which re-renders the +_picture+ partial via a
26+
# turbo stream). One query replaces the per-row +deletable?+ check
27+
# that would otherwise fire for every picture the view renders.
28+
before_action :load_deletable_picture_ids, only: [:index, :update]
29+
2230
add_alchemy_filter :by_file_format, type: :select, options: ->(query) do
2331
Alchemy::Picture.file_formats(query.result)
2432
end
@@ -30,8 +38,6 @@ class PicturesController < Alchemy::Admin::ResourcesController
3038
helper_method :picture_offset
3139

3240
def index
33-
@pictures = filtered_pictures(page: params[:page])
34-
3541
if in_overlay?
3642
archive_overlay
3743
end
@@ -159,6 +165,21 @@ def items_per_page_options
159165

160166
private
161167

168+
def load_pictures
169+
@pictures = filtered_pictures(page: params[:page])
170+
end
171+
172+
# Preload deletable ids in a single query so the view can decide which
173+
# delete buttons to enable without calling +deletable?+ (a two-query
174+
# check) per row. We pass already-loaded ids (via +map(&:id)+) rather
175+
# than the relation itself, because passing a paginated relation
176+
# produces +IN (SELECT ... LIMIT n)+, which older MariaDB versions
177+
# reject.
178+
def load_deletable_picture_ids
179+
ids = @pictures ? @pictures.map(&:id) : [@picture.id]
180+
@deletable_picture_ids = Picture.where(id: ids).deletable.pluck(:id).to_set
181+
end
182+
162183
def picture_offset
163184
((params[:page] || 1).to_i - 1) * items_per_page
164185
end

app/models/alchemy/attachment.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

app/models/concerns/alchemy/relatable_resource.rb

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@ module Alchemy
22
module RelatableResource
33
extend ActiveSupport::Concern
44

5+
# SQL subquery selecting +related_object_id+ values for ingredients that
6+
# reference a given polymorphic type. Intended to be composed into a
7+
# +NOT IN (...)+ clause by +deletable+ and any overrides in including
8+
# classes. Takes one named bind :type - the polymorphic type name,
9+
# typically the class name of the including model
10+
# (e.g. +"Alchemy::Attachment"+).
11+
RELATED_INGREDIENTS_SUBQUERY = <<~SQL.squish
12+
SELECT related_object_id
13+
FROM alchemy_ingredients
14+
WHERE related_object_id IS NOT NULL
15+
AND related_object_type = :type
16+
SQL
17+
518
class_methods do
619
# Preload associations for element editor display
720
#
@@ -18,10 +31,7 @@ def alchemy_element_preloads(records)
1831

1932
included do
2033
scope :deletable, -> do
21-
where(
22-
"#{table_name}.id NOT IN (SELECT related_object_id FROM alchemy_ingredients WHERE related_object_id IS NOT NULL AND related_object_type = ?)",
23-
name
24-
)
34+
where("#{table_name}.id NOT IN (#{RELATED_INGREDIENTS_SUBQUERY})", type: name)
2535
end
2636

2737
has_many :related_ingredients,

app/views/alchemy/admin/attachments/_files_list.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
file_attribute: 'file' %>
6262
<% end %>
6363
<% table.with_action(:destroy) do |attachment| %>
64-
<% if attachment.deletable? %>
64+
<% if @deletable_attachment_ids.include?(attachment.id) %>
6565
<sl-tooltip content="<%= Alchemy.t(:delete_file) %>">
6666
<%= link_to_confirm_dialog render_icon(:minus),
6767
Alchemy.t(:confirm_to_delete_file),

app/views/alchemy/admin/pictures/_picture.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<span class="picture_tool select">
33
<%= check_box_tag "picture_ids[]", picture.id %>
44
</span>
5-
<% if picture.deletable? && can?(:destroy, picture) %>
5+
<% if @deletable_picture_ids.include?(picture.id) && can?(:destroy, picture) %>
66
<div class="picture_tool delete">
77
<sl-tooltip content="<%= Alchemy.t('Delete image') %>">
88
<%= link_to_confirm_dialog(

spec/controllers/alchemy/admin/attachments_controller_spec.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,32 @@ module Alchemy
7070
end
7171
end
7272

73+
describe "@deletable_attachment_ids" do
74+
let!(:deletable_attachment) { create(:alchemy_attachment) }
75+
let!(:referenced_attachment) { create(:alchemy_attachment) }
76+
77+
before do
78+
create(
79+
:alchemy_ingredient_richtext,
80+
value: %(<a href="/attachment/#{referenced_attachment.id}/download">x</a>)
81+
)
82+
end
83+
84+
it "is a Set of ids of attachments on the current page that are deletable" do
85+
get :index
86+
expect(assigns(:deletable_attachment_ids)).to be_a(Set)
87+
expect(assigns(:deletable_attachment_ids)).to include(deletable_attachment.id)
88+
expect(assigns(:deletable_attachment_ids)).not_to include(referenced_attachment.id)
89+
end
90+
91+
it "is scoped to the current page only" do
92+
get :index, params: {per_page: 1}
93+
# With per_page: 1 only one attachment is on the current page,
94+
# so the set must have at most one element.
95+
expect(assigns(:deletable_attachment_ids).size).to be <= 1
96+
end
97+
end
98+
7399
describe "by_file_type filter" do
74100
let!(:png) { create(:alchemy_attachment) }
75101

spec/controllers/alchemy/admin/pictures_controller_spec.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,27 @@ module Alchemy
136136
end
137137
end
138138
end
139+
140+
describe "@deletable_picture_ids" do
141+
let!(:deletable_picture) { create(:alchemy_picture) }
142+
let!(:referenced_picture) { create(:alchemy_picture) }
143+
144+
before do
145+
create(:alchemy_ingredient_picture, related_object: referenced_picture)
146+
end
147+
148+
it "is a Set of ids of pictures on the current page that are deletable" do
149+
get :index
150+
expect(assigns(:deletable_picture_ids)).to be_a(Set)
151+
expect(assigns(:deletable_picture_ids)).to include(deletable_picture.id)
152+
expect(assigns(:deletable_picture_ids)).not_to include(referenced_picture.id)
153+
end
154+
155+
it "is scoped to the current page only" do
156+
get :index, params: {per_page: 1}
157+
expect(assigns(:deletable_picture_ids).size).to be <= 1
158+
end
159+
end
139160
end
140161

141162
describe "#create" do

spec/models/alchemy/attachment_spec.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,72 @@ module Alchemy
2121
resource_name: :attachment,
2222
ingredient_type: :file
2323

24+
describe ".deletable" do
25+
let!(:richtext_linked_attachment) { create(:alchemy_attachment) }
26+
let!(:link_linked_attachment) { create(:alchemy_attachment) }
27+
let!(:unlinked_attachment) { create(:alchemy_attachment) }
28+
29+
before do
30+
create(
31+
:alchemy_ingredient_richtext,
32+
value: %(<p>See <a href="/attachment/#{richtext_linked_attachment.id}/download/foo.pdf">this</a></p>)
33+
)
34+
create(
35+
:alchemy_ingredient_link,
36+
value: "/attachment/#{link_linked_attachment.id}/download"
37+
)
38+
end
39+
40+
it "excludes attachments linked from any ingredient download URL" do
41+
expect(described_class.deletable).to include(unlinked_attachment)
42+
expect(described_class.deletable).not_to include(richtext_linked_attachment)
43+
expect(described_class.deletable).not_to include(link_linked_attachment)
44+
end
45+
end
46+
47+
describe "#deletable?" do
48+
let(:attachment) { create(:alchemy_attachment) }
49+
50+
subject { attachment.deletable? }
51+
52+
context "when referenced by a /attachment/:id/download URL in a Richtext ingredient" do
53+
before do
54+
create(
55+
:alchemy_ingredient_richtext,
56+
value: %(<a href="/attachment/#{attachment.id}/download">x</a>)
57+
)
58+
end
59+
60+
it { is_expected.to be(false) }
61+
end
62+
63+
context "when referenced by a /attachment/:id/download URL in a Link ingredient" do
64+
before do
65+
create(
66+
:alchemy_ingredient_link,
67+
value: "/attachment/#{attachment.id}/download"
68+
)
69+
end
70+
71+
it { is_expected.to be(false) }
72+
end
73+
74+
context "when another ingredient links to a different attachment whose id shares a digit prefix" do
75+
let(:other_id) { attachment.id * 10 }
76+
77+
before do
78+
create(
79+
:alchemy_ingredient_richtext,
80+
value: %(<a href="/attachment/#{other_id}/download">x</a>)
81+
)
82+
end
83+
84+
it "does not falsely report as referenced" do
85+
is_expected.to be(true)
86+
end
87+
end
88+
end
89+
2490
it "has file mime type accessor" do
2591
expect(attachment.file_mime_type).to eq("image/png")
2692
end

0 commit comments

Comments
 (0)