From 6ef98ad444631b1d5ba67bb16aaaa2afdfa53ae0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 23 Jul 2026 04:12:39 +0200 Subject: Witness asset destruction, naming every node it strips --- app/controllers/assets_controller.rb | 2 +- app/helpers/node_actions_helper.rb | 11 +++++++ app/models/asset.rb | 30 ++++++++++++++++++ app/models/node_action.rb | 9 ++++++ config/locales/de.yml | 3 ++ config/locales/en.yml | 3 ++ test/controllers/assets_controller_test.rb | 10 ++++++ test/models/asset_destroy_test.rb | 51 ++++++++++++++++++++++++++++++ 8 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 test/models/asset_destroy_test.rb diff --git a/app/controllers/assets_controller.rb b/app/controllers/assets_controller.rb index fbede0a7..8df4c94d 100644 --- a/app/controllers/assets_controller.rb +++ b/app/controllers/assets_controller.rb @@ -81,7 +81,7 @@ class AssetsController < ApplicationController # DELETE /assets/1.xml def destroy @asset = Asset.find(params[:id]) - @asset.destroy + @asset.destroy_witnessed!(:user => current_user) respond_to do |format| format.html { redirect_to(assets_url) } diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb index fd8cc36e..02d1ba83 100644 --- a/app/helpers/node_actions_helper.rb +++ b/app/helpers/node_actions_helper.rb @@ -180,4 +180,15 @@ module NodeActionsHelper t("node_actions.destroy", :actor => actor_ref(action), :subject => subject_ref(action), :path => h(action.metadata["path"])).html_safe end + + def summarize_asset_destroy action + m = action.metadata + parts = [t("node_actions.asset_destroy", :actor => actor_ref(action), + :asset => h(m["asset_name"].presence || m["path"]))] + 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, " ") + end end diff --git a/app/models/asset.rb b/app/models/asset.rb index 73970e21..8cec4371 100644 --- a/app/models/asset.rb +++ b/app/models/asset.rb @@ -40,4 +40,34 @@ class Asset < ApplicationRecord Node.where("head_id IN (:ids) OR draft_id IN (:ids) OR autosave_id IN (:ids)", :ids => page_ids).distinct 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). + def destroy_witnessed! user: + 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) + destroy! + end + end end diff --git a/app/models/node_action.rb b/app/models/node_action.rb index 8a3dd8b7..9ed0b628 100644 --- a/app/models/node_action.rb +++ b/app/models/node_action.rb @@ -69,6 +69,15 @@ class NodeAction < ApplicationRecord # "path" -- final path, flat string (create-symmetric) # "destroyed_descendants" -- integer, only when positive; one entry # at the root, per the subtree rule. + # "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 # # Reserved: "demote" (via "trash" | "depublish") for an explicit # depublish workflow, if ever built. diff --git a/config/locales/de.yml b/config/locales/de.yml index dd22cef7..d6241d51 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -116,6 +116,9 @@ de: revision_created: "angelegt am %{date} von %{actor}" revision_published: "veröffentlicht am %{date} von %{actor}" revision_restored: "wiederhergestellt am %{date} von %{actor}" + asset_destroy: "%{actor} hat das Asset „%{asset}“ gelöscht" + asset_destroy_detached: "— entfernt von %{paths}" + asset_destroy_headlines: "(war Aufmacher von %{paths})" open_gallery: "Gallerie anzeigen" asset_licenses: diff --git a/config/locales/en.yml b/config/locales/en.yml index 9be01524..bd59915b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -68,6 +68,9 @@ en: revision_created: "created %{date} by %{actor}" revision_published: "published %{date} by %{actor}" revision_restored: "restored %{date} by %{actor}" + asset_destroy: "%{actor} destroyed asset “%{asset}”" + asset_destroy_detached: "— detached from %{paths}" + asset_destroy_headlines: "(was the headline of %{paths})" open_gallery: "Open gallery" asset_licenses: diff --git a/test/controllers/assets_controller_test.rb b/test/controllers/assets_controller_test.rb index 05fc6ded..0e251aad 100644 --- a/test/controllers/assets_controller_test.rb +++ b/test/controllers/assets_controller_test.rb @@ -182,6 +182,16 @@ class AssetsControllerTest < ActionController::TestCase assert !Dir.exist?(upload_dir), "Upload directory should be removed after destroy" end + test "destroy is witnessed in the action log with the current user" do + asset = Asset.create!(:name => 'Witness me', + :upload_file_name => 'w.png', + :upload_content_type => 'image/png') + assert_difference 'NodeAction.where(:action => "asset_destroy").count' do + delete :destroy, params: { id: asset.id } + end + assert_equal users(:quentin), NodeAction.last.user + end + # --- URL helpers --- test "upload url returns correct path for original" do diff --git a/test/models/asset_destroy_test.rb b/test/models/asset_destroy_test.rb new file mode 100644 index 00000000..5583f685 --- /dev/null +++ b/test/models/asset_destroy_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class AssetDestroyTest < ActiveSupport::TestCase + def setup + @user = users(:quentin) + @asset = Asset.create!(:name => "Doomed asset", + :upload_file_name => "doomed.png", + :upload_content_type => "image/png") + end + + test "destroying an attached asset logs nodes and asset as participants" do + node = Node.root.children.create!(:slug => "asset_destroy_attached") + node.attach_asset!(@asset, :user => @user) + + @asset.destroy_witnessed!(:user => @user) + + action = NodeAction.where(:action => "asset_destroy").last + subjects = action.action_participants.map { |p| [p.subject_type, p.subject_id] } + assert_includes subjects, ["Asset", @asset.id] + assert_includes subjects, ["Node", node.id] + assert_equal [node.unique_name], action.metadata["detached_from"] + end + + test "records which nodes lost their headline" do + node = Node.root.children.create!(:slug => "asset_destroy_headline") + node.attach_asset!(@asset, :user => @user, :headline => true) + + @asset.destroy_witnessed!(:user => @user) + + action = NodeAction.where(:action => "asset_destroy").last + assert_equal [node.unique_name], action.metadata["headline_removed_from"] + end + + test "an unattached asset is still witnessed" do + @asset.destroy_witnessed!(:user => @user) + + action = NodeAction.where(:action => "asset_destroy").last + assert_equal [["Asset", @asset.id]], + action.action_participants.map { |p| [p.subject_type, p.subject_id] } + assert_nil action.node_id + end + + test "the entry outlives the asset" do + @asset.destroy_witnessed!(:user => @user) + action = NodeAction.where(:action => "asset_destroy").last + + assert_not Asset.exists?(@asset.id) + assert_equal "Doomed asset", action.metadata["asset_name"] + assert_nil action.action_participants.first.subject + end +end -- cgit v1.3