Skip to content

Commit 3ed90b4

Browse files
committed
fix(Attachment): Detect references in ingredient values
The deletable scope only considered polymorphic related_object references, which missed attachments linked from /attachment/:id/download URLs written into ingredient values by the file tab of the link dialog. On sites where attachments are linked from Richtext, Link, or Html ingredients, every attachment was reported as deletable, risking accidental data loss. The scope now adds a correlated NOT EXISTS against ingredient value columns. The per-row LIKE pattern is built with Arel::Nodes::Concat so it compiles to || on SQLite and PostgreSQL and to CONCAT() on MySQL without adapter-specific SQL. The /download suffix in the pattern delimits the id, so matching attachment 1 does not incorrectly pull in URLs pointing at attachment 10.
1 parent d33e09c commit 3ed90b4

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

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

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)