diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-08-09 03:35:48 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-08-09 03:35:48 +0200 |
| commit | d16161eab9029a509951146b80334c304c35fd34 (patch) | |
| tree | 87dd7426389b0e27b31925dc28c76a442eb721b8 | |
| parent | 20c735b0c5a2db9b8984848253ad99332d6211a8 (diff) | |
Refuse destroying an asset that is still attached
| -rw-r--r-- | app/helpers/node_actions_helper.rb | 17 | ||||
| -rw-r--r-- | app/models/asset.rb | 45 | ||||
| -rw-r--r-- | app/models/node_action.rb | 23 | ||||
| -rw-r--r-- | config/locales/de.yml | 7 | ||||
| -rw-r--r-- | config/locales/en.yml | 7 | ||||
| -rw-r--r-- | test/models/asset_destroy_test.rb | 49 |
6 files changed, 42 insertions, 106 deletions
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb index 960e28b3..09de4250 100644 --- a/app/helpers/node_actions_helper.rb +++ b/app/helpers/node_actions_helper.rb | |||
| @@ -330,22 +330,9 @@ module NodeActionsHelper | |||
| 330 | :asset => asset_ref(action)).html_safe | 330 | :asset => asset_ref(action)).html_safe |
| 331 | end | 331 | end |
| 332 | 332 | ||
| 333 | def summarize_asset_attach action | ||
| 334 | m = action.metadata | ||
| 335 | key = m["headline"] ? "node_actions.asset_attach_headline" : "node_actions.asset_attach" | ||
| 336 | t(key, :actor => actor_ref(action), :subject => subject_ref(action), | ||
| 337 | :asset => asset_ref(action)).html_safe | ||
| 338 | end | ||
| 339 | |||
| 340 | def summarize_asset_destroy action | 333 | def summarize_asset_destroy action |
| 341 | m = action.metadata | 334 | t("node_actions.asset_destroy", :actor => actor_ref(action), |
| 342 | parts = [t("node_actions.asset_destroy", :actor => actor_ref(action), | 335 | :asset => asset_ref(action)).html_safe |
| 343 | :asset => asset_ref(action))] | ||
| 344 | parts << t("node_actions.asset_destroy_detached", | ||
| 345 | :paths => h(Array(m["detached_from"]).join(", "))) if m["detached_from"].present? | ||
| 346 | parts << t("node_actions.asset_destroy_headlines", | ||
| 347 | :paths => h(Array(m["headline_removed_from"]).join(", "))) if m["headline_removed_from"].present? | ||
| 348 | safe_join(parts, " ") | ||
| 349 | end | 336 | end |
| 350 | 337 | ||
| 351 | def summarize_otp_enroll action | 338 | def summarize_otp_enroll action |
diff --git a/app/models/asset.rb b/app/models/asset.rb index 4d43b18f..08ef5a78 100644 --- a/app/models/asset.rb +++ b/app/models/asset.rb | |||
| @@ -41,44 +41,23 @@ class Asset < ApplicationRecord | |||
| 41 | :ids => page_ids).distinct | 41 | :ids => page_ids).distinct |
| 42 | end | 42 | end |
| 43 | 43 | ||
| 44 | # An asset's reach is the reach of the pages carrying it: destroying one | 44 | # Witnessed destruction, refused while the asset is attached to any |
| 45 | # removes it from every live page at once, so a single restricted | 45 | # current row. Detaching stays an in-editor act, so nothing |
| 46 | # attachment makes the destruction a restricted act. | 46 | # destroyed here is carried by a page. The entry is still warranted: |
| 47 | def restricted? | 47 | # the original and its variants are reachable under /system/uploads |
| 48 | attached_nodes.any?(&:restricted?) | 48 | # until the row and its files die. |
| 49 | end | ||
| 50 | |||
| 51 | # Witnessed destruction. Destroying an asset is a public-facing act | ||
| 52 | # even when unattached. The original and its variants are publicly | ||
| 53 | # reachable under /system/uploads, so an entry is always written, | ||
| 54 | # before the row and its files die. Every currently-attached node | ||
| 55 | # participates so its zoomed history shows the loss; the asset itself | ||
| 56 | # participates as the first non-Node subject (its participant row | ||
| 57 | # dangles after destroy, by design, the name lives on in metadata). | ||
| 58 | def destroy_witnessed! user: | 49 | def destroy_witnessed! user: |
| 59 | if user && !user.may_change_live?(self) | 50 | if attached_nodes.any? |
| 60 | errors.add(:base, :not_permitted) | 51 | errors.add(:base, :destroy_while_attached) |
| 61 | raise ActiveRecord::RecordInvalid.new(self) | 52 | raise ActiveRecord::RecordInvalid.new(self) |
| 62 | end | 53 | end |
| 63 | 54 | ||
| 64 | ActiveRecord::Base.transaction do | 55 | ActiveRecord::Base.transaction do |
| 65 | affected = attached_nodes.to_a | 56 | NodeAction.record!(:participants => [self], :user => user, |
| 66 | headline_losses = affected.select do |node| | 57 | :action => "asset_destroy", |
| 67 | [node.head, node.draft, node.autosave].compact.any? do |row| | 58 | :asset_name => name, |
| 68 | row.related_assets.exists?(:asset_id => id, :headline => true) | 59 | :content_type => upload_content_type, |
| 69 | end | 60 | :path => upload.url.sub(/\?\d+$/, "")) |
| 70 | end | ||
| 71 | |||
| 72 | metadata = { | ||
| 73 | :asset_name => name, | ||
| 74 | :content_type => upload_content_type, | ||
| 75 | :path => upload.url.sub(/\?\d+$/, ""), | ||
| 76 | } | ||
| 77 | metadata[:detached_from] = affected.map(&:unique_name) if affected.any? | ||
| 78 | metadata[:headline_removed_from] = headline_losses.map(&:unique_name) if headline_losses.any? | ||
| 79 | |||
| 80 | NodeAction.record!(:participants => [self] + affected, :user => user, | ||
| 81 | :action => "asset_destroy", **metadata) | ||
| 82 | destroy! | 61 | destroy! |
| 83 | end | 62 | end |
| 84 | end | 63 | end |
diff --git a/app/models/node_action.rb b/app/models/node_action.rb index 1e64861e..aab1d238 100644 --- a/app/models/node_action.rb +++ b/app/models/node_action.rb | |||
| @@ -82,22 +82,13 @@ class NodeAction < ApplicationRecord | |||
| 82 | # "asset_create" (witnessed upload; participants: the asset alone): | 82 | # "asset_create" (witnessed upload; participants: the asset alone): |
| 83 | # "asset_name", "content_type", "path" -- flat strings | 83 | # "asset_name", "content_type", "path" -- flat strings |
| 84 | # | 84 | # |
| 85 | # "asset_attach" (out-of-band attach via Node#attach_asset!; written | 85 | # "asset_destroy" (witnessed asset deletion; the files were publicly |
| 86 | # only when at least one new join was created, per the tandem rule -- | 86 | # reachable, so an entry is always written. Destruction is refused |
| 87 | # in-editor curation stays draft-scoped and surfaces at publish. | 87 | # while the asset is attached to any current row, so no node is ever |
| 88 | # participants: the node (primary) and the asset): | 88 | # affected: node column nil, the asset the sole participant): |
| 89 | # "asset_name", "path" -- flat strings | 89 | # "asset_name" -- flat string |
| 90 | # "headline" -- boolean, only when set by this attach | 90 | # "content_type" -- flat string |
| 91 | # | 91 | # "path" -- public original path, flat string |
| 92 | # "asset_destroy" (witnessed asset deletion; always written, even for | ||
| 93 | # unattached assets -- the files were publicly reachable; node column | ||
| 94 | # nil, subjects via participants: the asset plus every then-attached | ||
| 95 | # node): | ||
| 96 | # "asset_name" -- flat string | ||
| 97 | # "content_type" -- flat string | ||
| 98 | # "path" -- public original path, flat string | ||
| 99 | # "detached_from" -- array of unique_names, only when any | ||
| 100 | # "headline_removed_from" -- array of unique_names, only when any | ||
| 101 | # | 92 | # |
| 102 | # "otp_enroll" / "otp_disable" / "otp_reset" (second-factor | 93 | # "otp_enroll" / "otp_disable" / "otp_reset" (second-factor |
| 103 | # lifecycle) and "user_create" / "user_deactivate" / | 94 | # lifecycle) and "user_create" / "user_deactivate" / |
diff --git a/config/locales/de.yml b/config/locales/de.yml index 58367aaf..92a46768 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml | |||
| @@ -155,7 +155,7 @@ de: | |||
| 155 | asset: | 155 | asset: |
| 156 | attributes: | 156 | attributes: |
| 157 | base: | 157 | base: |
| 158 | not_permitted: "Dieses Asset ist an geschützte Seiten angehängt; nur die Redaktion darf es löschen" | 158 | destroy_while_attached: "Ein Asset, das noch an Seiten angehängt ist, kann nicht gelöscht werden. Bitte entferne es zuerst im Editor" |
| 159 | upload: | 159 | upload: |
| 160 | unreadable_image: "konnte nicht als Bild gelesen werden. Möglicherweise ist sie beschädigt oder hat ein anderes Format, als der Name vermuten lässt." | 160 | unreadable_image: "konnte nicht als Bild gelesen werden. Möglicherweise ist sie beschädigt oder hat ein anderes Format, als der Name vermuten lässt." |
| 161 | event: | 161 | event: |
| @@ -218,11 +218,7 @@ de: | |||
| 218 | revision_restored: "wiederhergestellt am %{date} von %{actor}" | 218 | revision_restored: "wiederhergestellt am %{date} von %{actor}" |
| 219 | asset_history: "Chronik" | 219 | asset_history: "Chronik" |
| 220 | asset_create: "%{actor} hat das Asset „%{asset}“ hochgeladen" | 220 | asset_create: "%{actor} hat das Asset „%{asset}“ hochgeladen" |
| 221 | asset_attach: "%{actor} hat „%{asset}“ an %{subject} angehängt" | ||
| 222 | asset_attach_headline: "%{actor} hat „%{asset}“ als Aufmacher an %{subject} angehängt" | ||
| 223 | asset_destroy: "%{actor} hat das Asset „%{asset}“ gelöscht" | 221 | asset_destroy: "%{actor} hat das Asset „%{asset}“ gelöscht" |
| 224 | asset_destroy_detached: "— entfernt von %{paths}" | ||
| 225 | asset_destroy_headlines: "(war Aufmacher von %{paths})" | ||
| 226 | detail_assets_added: "Anhänge hinzugefügt: %{names}" | 222 | detail_assets_added: "Anhänge hinzugefügt: %{names}" |
| 227 | detail_assets_removed: "Anhänge entfernt: %{names}" | 223 | detail_assets_removed: "Anhänge entfernt: %{names}" |
| 228 | assets_reordered: "Anhänge umsortiert" | 224 | assets_reordered: "Anhänge umsortiert" |
| @@ -814,6 +810,7 @@ de: | |||
| 814 | locked_prefix: "Die Seite ist von %{login} gesperrt —" | 810 | locked_prefix: "Die Seite ist von %{login} gesperrt —" |
| 815 | locked_link: "dort entsperren" | 811 | locked_link: "dort entsperren" |
| 816 | locked_suffix: ", dann von der Asset-Seite aus erneut anhängen." | 812 | locked_suffix: ", dann von der Asset-Seite aus erneut anhängen." |
| 813 | resolve_link: "Seite öffnen, um den Bearbeitungskonflikt zu lösen" | ||
| 817 | headline_prefix: "Der bestehende Aufmacher der Seite wurde behalten —" | 814 | headline_prefix: "Der bestehende Aufmacher der Seite wurde behalten —" |
| 818 | headline_link: "dort ändern" | 815 | headline_link: "dort ändern" |
| 819 | headline_suffix: ", falls gewünscht." | 816 | headline_suffix: ", falls gewünscht." |
diff --git a/config/locales/en.yml b/config/locales/en.yml index 4bdca31b..458a4069 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml | |||
| @@ -106,7 +106,7 @@ en: | |||
| 106 | asset: | 106 | asset: |
| 107 | attributes: | 107 | attributes: |
| 108 | base: | 108 | base: |
| 109 | not_permitted: "Only Redaktion members may change assets related to nodes in this section" | 109 | destroy_while_attached: "Cannot destroy an asset that is still attached to pages; remove it in each page's editor first" |
| 110 | upload: | 110 | upload: |
| 111 | unreadable_image: "could not be read as an image. It may be damaged, or not the format its name suggests." | 111 | unreadable_image: "could not be read as an image. It may be damaged, or not the format its name suggests." |
| 112 | event: | 112 | event: |
| @@ -170,11 +170,7 @@ en: | |||
| 170 | revision_restored: "restored %{date} by %{actor}" | 170 | revision_restored: "restored %{date} by %{actor}" |
| 171 | asset_history: "History" | 171 | asset_history: "History" |
| 172 | asset_destroy: "%{actor} destroyed asset “%{asset}”" | 172 | asset_destroy: "%{actor} destroyed asset “%{asset}”" |
| 173 | asset_destroy_detached: "— detached from %{paths}" | ||
| 174 | asset_destroy_headlines: "(was the headline of %{paths})" | ||
| 175 | asset_create: "%{actor} uploaded asset “%{asset}”" | 173 | asset_create: "%{actor} uploaded asset “%{asset}”" |
| 176 | asset_attach: "%{actor} attached “%{asset}” to %{subject}" | ||
| 177 | asset_attach_headline: "%{actor} attached “%{asset}” to %{subject} as its headline" | ||
| 178 | detail_assets_added: "attachments added: %{names}" | 174 | detail_assets_added: "attachments added: %{names}" |
| 179 | detail_assets_removed: "attachments removed: %{names}" | 175 | detail_assets_removed: "attachments removed: %{names}" |
| 180 | assets_reordered: "attachments reordered" | 176 | assets_reordered: "attachments reordered" |
| @@ -766,6 +762,7 @@ en: | |||
| 766 | locked_prefix: "The page is locked by %{login} —" | 762 | locked_prefix: "The page is locked by %{login} —" |
| 767 | locked_link: "unlock it there" | 763 | locked_link: "unlock it there" |
| 768 | locked_suffix: " first, then attach from this asset's page." | 764 | locked_suffix: " first, then attach from this asset's page." |
| 765 | resolve_link: "Open the page to resolve this" | ||
| 769 | headline_prefix: "The page's existing headline was kept —" | 766 | headline_prefix: "The page's existing headline was kept —" |
| 770 | headline_link: "change it there" | 767 | headline_link: "change it there" |
| 771 | headline_suffix: " if needed." | 768 | headline_suffix: " if needed." |
diff --git a/test/models/asset_destroy_test.rb b/test/models/asset_destroy_test.rb index 2f38d692..54512b16 100644 --- a/test/models/asset_destroy_test.rb +++ b/test/models/asset_destroy_test.rb | |||
| @@ -8,29 +8,6 @@ class AssetDestroyTest < ActiveSupport::TestCase | |||
| 8 | :upload_content_type => "image/png") | 8 | :upload_content_type => "image/png") |
| 9 | end | 9 | end |
| 10 | 10 | ||
| 11 | test "destroying an attached asset logs nodes and asset as participants" do | ||
| 12 | node = Node.root.children.create!(:slug => "asset_destroy_attached") | ||
| 13 | node.attach_asset!(@asset, :user => @user) | ||
| 14 | |||
| 15 | @asset.destroy_witnessed!(:user => @user) | ||
| 16 | |||
| 17 | action = NodeAction.where(:action => "asset_destroy").last | ||
| 18 | subjects = action.action_participants.map { |p| [p.subject_type, p.subject_id] } | ||
| 19 | assert_includes subjects, ["Asset", @asset.id] | ||
| 20 | assert_includes subjects, ["Node", node.id] | ||
| 21 | assert_equal [node.unique_name], action.metadata["detached_from"] | ||
| 22 | end | ||
| 23 | |||
| 24 | test "records which nodes lost their headline" do | ||
| 25 | node = Node.root.children.create!(:slug => "asset_destroy_headline") | ||
| 26 | node.attach_asset!(@asset, :user => @user, :headline => true) | ||
| 27 | |||
| 28 | @asset.destroy_witnessed!(:user => @user) | ||
| 29 | |||
| 30 | action = NodeAction.where(:action => "asset_destroy").last | ||
| 31 | assert_equal [node.unique_name], action.metadata["headline_removed_from"] | ||
| 32 | end | ||
| 33 | |||
| 34 | test "an unattached asset is still witnessed" do | 11 | test "an unattached asset is still witnessed" do |
| 35 | @asset.destroy_witnessed!(:user => @user) | 12 | @asset.destroy_witnessed!(:user => @user) |
| 36 | 13 | ||
| @@ -49,16 +26,24 @@ class AssetDestroyTest < ActiveSupport::TestCase | |||
| 49 | assert_nil action.action_participants.first.subject | 26 | assert_nil action.action_participants.first.subject |
| 50 | end | 27 | end |
| 51 | 28 | ||
| 52 | test "destroying an asset attached to a restricted node needs the redaktion role" do | 29 | test "destruction is refused while the asset is attached" do |
| 53 | editor = User.create!(:login => "asset_gate", :email => "ag@example.com", | 30 | node = Node.root.children.create!(:slug => "asset_destroy_attached") |
| 54 | :password => "secret", :password_confirmation => "secret") | 31 | node.attach_asset!(@asset, :user => @user) |
| 55 | updates = Node.root.children.create!(:slug => "updates") | 32 | |
| 56 | node = updates.children.create!(:slug => "gated-attachment") | 33 | assert_raises(ActiveRecord::RecordInvalid) { @asset.destroy_witnessed!(:user => @user) } |
| 57 | node.reload.attach_asset!(@asset, :user => nil) | 34 | assert Asset.exists?(@asset.id) |
| 35 | assert_equal 0, NodeAction.where(:action => "asset_destroy").count | ||
| 36 | end | ||
| 37 | |||
| 38 | test "an attachment on a draft alone is enough to refuse" do | ||
| 39 | node = Node.root.children.create!(:slug => "asset_destroy_draft_only") | ||
| 40 | node.attach_asset!(@asset, :user => @user) | ||
| 41 | node.publish_draft!(@user) | ||
| 42 | node.lock_for_editing!(@user) | ||
| 43 | node.create_new_draft(@user) | ||
| 44 | node.head.related_assets.destroy_all | ||
| 58 | 45 | ||
| 59 | error = assert_raises(ActiveRecord::RecordInvalid) { @asset.destroy_witnessed!(:user => editor) } | 46 | assert_raises(ActiveRecord::RecordInvalid) { @asset.destroy_witnessed!(:user => @user) } |
| 60 | assert_includes error.message, | ||
| 61 | I18n.t("activerecord.errors.models.asset.attributes.base.not_permitted") | ||
| 62 | assert Asset.exists?(@asset.id) | 47 | assert Asset.exists?(@asset.id) |
| 63 | end | 48 | end |
| 64 | end | 49 | end |
