From d16161eab9029a509951146b80334c304c35fd34 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 9 Aug 2026 03:35:48 +0200 Subject: Refuse destroying an asset that is still attached --- app/helpers/node_actions_helper.rb | 17 ++------------ app/models/asset.rb | 45 ++++++++++---------------------------- app/models/node_action.rb | 23 ++++++------------- 3 files changed, 21 insertions(+), 64 deletions(-) (limited to 'app') 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 :asset => asset_ref(action)).html_safe end - def summarize_asset_attach action - m = action.metadata - key = m["headline"] ? "node_actions.asset_attach_headline" : "node_actions.asset_attach" - t(key, :actor => actor_ref(action), :subject => subject_ref(action), - :asset => asset_ref(action)).html_safe - end - def summarize_asset_destroy action - m = action.metadata - parts = [t("node_actions.asset_destroy", :actor => actor_ref(action), - :asset => asset_ref(action))] - parts << t("node_actions.asset_destroy_detached", - :paths => h(Array(m["detached_from"]).join(", "))) if m["detached_from"].present? - parts << t("node_actions.asset_destroy_headlines", - :paths => h(Array(m["headline_removed_from"]).join(", "))) if m["headline_removed_from"].present? - safe_join(parts, " ") + t("node_actions.asset_destroy", :actor => actor_ref(action), + :asset => asset_ref(action)).html_safe end 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 :ids => page_ids).distinct end - # An asset's reach is the reach of the pages carrying it: destroying one - # removes it from every live page at once, so a single restricted - # attachment makes the destruction a restricted act. - def restricted? - attached_nodes.any?(&:restricted?) - end - - # Witnessed destruction. Destroying an asset is a public-facing act - # even when unattached. The original and its variants are publicly - # reachable under /system/uploads, so an entry is always written, - # before the row and its files die. Every currently-attached node - # participates so its zoomed history shows the loss; the asset itself - # participates as the first non-Node subject (its participant row - # dangles after destroy, by design, the name lives on in metadata). + # Witnessed destruction, refused while the asset is attached to any + # current row. Detaching stays an in-editor act, so nothing + # destroyed here is carried by a page. The entry is still warranted: + # the original and its variants are reachable under /system/uploads + # until the row and its files die. def destroy_witnessed! user: - if user && !user.may_change_live?(self) - errors.add(:base, :not_permitted) + if attached_nodes.any? + errors.add(:base, :destroy_while_attached) raise ActiveRecord::RecordInvalid.new(self) end ActiveRecord::Base.transaction do - affected = attached_nodes.to_a - headline_losses = affected.select do |node| - [node.head, node.draft, node.autosave].compact.any? do |row| - row.related_assets.exists?(:asset_id => id, :headline => true) - end - end - - metadata = { - :asset_name => name, - :content_type => upload_content_type, - :path => upload.url.sub(/\?\d+$/, ""), - } - metadata[:detached_from] = affected.map(&:unique_name) if affected.any? - metadata[:headline_removed_from] = headline_losses.map(&:unique_name) if headline_losses.any? - - NodeAction.record!(:participants => [self] + affected, :user => user, - :action => "asset_destroy", **metadata) + NodeAction.record!(:participants => [self], :user => user, + :action => "asset_destroy", + :asset_name => name, + :content_type => upload_content_type, + :path => upload.url.sub(/\?\d+$/, "")) destroy! end 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 # "asset_create" (witnessed upload; participants: the asset alone): # "asset_name", "content_type", "path" -- flat strings # - # "asset_attach" (out-of-band attach via Node#attach_asset!; written - # only when at least one new join was created, per the tandem rule -- - # in-editor curation stays draft-scoped and surfaces at publish. - # participants: the node (primary) and the asset): - # "asset_name", "path" -- flat strings - # "headline" -- boolean, only when set by this attach - # - # "asset_destroy" (witnessed asset deletion; always written, even for - # unattached assets -- the files were publicly reachable; node column - # nil, subjects via participants: the asset plus every then-attached - # node): - # "asset_name" -- flat string - # "content_type" -- flat string - # "path" -- public original path, flat string - # "detached_from" -- array of unique_names, only when any - # "headline_removed_from" -- array of unique_names, only when any + # "asset_destroy" (witnessed asset deletion; the files were publicly + # reachable, so an entry is always written. Destruction is refused + # while the asset is attached to any current row, so no node is ever + # affected: node column nil, the asset the sole participant): + # "asset_name" -- flat string + # "content_type" -- flat string + # "path" -- public original path, flat string # # "otp_enroll" / "otp_disable" / "otp_reset" (second-factor # lifecycle) and "user_create" / "user_deactivate" / -- cgit v1.3