summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-31 18:14:30 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-31 18:14:30 +0200
commit464af1625349d557f688da9f845471ef8b80a5f9 (patch)
treeaab415abf115f20ccbcdfad49c27ce5d1bd61134 /app
parent8c6a6516e1dc5c1b4f12740a6f7b32765b530bb7 (diff)
Gate live-content changes on restricted surfaces
publish_draft!, trash!, destroy_from_trash!, attach_asset! and Asset#destroy_witnessed! now refuse unless the acting user holds redaktion, and only when the subject is on a restricted surface: the front page, the updates tree that feeds ~100k subscribers, or disclosure. Drafting, autosaving, tagging and creating stay free everywhere for everyone. Enforcement is in the models rather than the controllers, since attach_asset! and the rest are reachable from rake tasks and internal paths. It follows the errors.add-plus-bare-raise pattern the rest of Node already uses, so every existing RecordInvalid rescue reports it with a localised message; only assets_controller#destroy needed a rescue added. A nil user is treated as a system context and bypasses the gate. The default nil on three of those verbs is what makes that reachable, and removing those defaults once every call site passes a user is the next tightening.
Diffstat (limited to 'app')
-rw-r--r--app/controllers/assets_controller.rb6
-rw-r--r--app/controllers/nodes_controller.rb3
-rw-r--r--app/models/asset.rb12
-rw-r--r--app/models/node.rb28
-rw-r--r--app/models/user.rb5
5 files changed, 54 insertions, 0 deletions
diff --git a/app/controllers/assets_controller.rb b/app/controllers/assets_controller.rb
index 988d56d1..03780c69 100644
--- a/app/controllers/assets_controller.rb
+++ b/app/controllers/assets_controller.rb
@@ -93,6 +93,12 @@ class AssetsController < ApplicationController
93 format.html { redirect_to(assets_url) } 93 format.html { redirect_to(assets_url) }
94 format.xml { head :ok } 94 format.xml { head :ok }
95 end 95 end
96 rescue ActiveRecord::RecordInvalid => e
97 flash[:error] = e.message
98 respond_to do |format|
99 format.html { redirect_to(asset_path(@asset)) }
100 format.xml { head :forbidden }
101 end
96 end 102 end
97 103
98 private 104 private
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index c56fd945..383fb72c 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -194,6 +194,9 @@ class NodesController < ApplicationController
194 @node.publish_draft!(current_user) 194 @node.publish_draft!(current_user)
195 flash[:notice] = t("flash.nodes.published") 195 flash[:notice] = t("flash.nodes.published")
196 redirect_to node_path(@node) 196 redirect_to node_path(@node)
197 rescue ActiveRecord::RecordInvalid => e
198 flash[:error] = e.message
199 redirect_to node_path(@node)
197 end 200 end
198 201
199 def unlock 202 def unlock
diff --git a/app/models/asset.rb b/app/models/asset.rb
index 8cec4371..b256b929 100644
--- a/app/models/asset.rb
+++ b/app/models/asset.rb
@@ -41,6 +41,13 @@ 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
45 # removes it from every live page at once, so a single restricted
46 # attachment makes the destruction a restricted act.
47 def restricted?
48 attached_nodes.any?(&:restricted?)
49 end
50
44 # Witnessed destruction. Destroying an asset is a public-facing act 51 # Witnessed destruction. Destroying an asset is a public-facing act
45 # even when unattached. The original and its variants are publicly 52 # even when unattached. The original and its variants are publicly
46 # reachable under /system/uploads, so an entry is always written, 53 # reachable under /system/uploads, so an entry is always written,
@@ -49,6 +56,11 @@ class Asset < ApplicationRecord
49 # participates as the first non-Node subject (its participant row 56 # participates as the first non-Node subject (its participant row
50 # dangles after destroy, by design, the name lives on in metadata). 57 # dangles after destroy, by design, the name lives on in metadata).
51 def destroy_witnessed! user: 58 def destroy_witnessed! user:
59 if user && !user.may_change_live?(self)
60 errors.add(:base, :not_permitted)
61 raise ActiveRecord::RecordInvalid.new(self)
62 end
63
52 ActiveRecord::Base.transaction do 64 ActiveRecord::Base.transaction do
53 affected = attached_nodes.to_a 65 affected = attached_nodes.to_a
54 headline_losses = affected.select do |node| 66 headline_losses = affected.select do |node|
diff --git a/app/models/node.rb b/app/models/node.rb
index 1823daa4..ac3a6160 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -236,6 +236,8 @@ class Node < ApplicationRecord
236 # Return nil if nothing to publish and no staged changes 236 # Return nil if nothing to publish and no staged changes
237 return nil unless self.draft || staged_slug || staged_parent_id 237 return nil unless self.draft || staged_slug || staged_parent_id
238 238
239 guard_live_change!(current_user)
240
239 if in_trash? || trash_node? 241 if in_trash? || trash_node?
240 errors.add(:base, :publish_in_trash) 242 errors.add(:base, :publish_in_trash)
241 raise ActiveRecord::RecordInvalid.new(self) 243 raise ActiveRecord::RecordInvalid.new(self)
@@ -319,6 +321,9 @@ class Node < ApplicationRecord
319 # at the root, carrying the leaving-public-view snapshot. 321 # at the root, carrying the leaving-public-view snapshot.
320 def trash! current_user = nil 322 def trash! current_user = nil
321 return nil if in_trash? 323 return nil if in_trash?
324
325 guard_live_change!(current_user)
326
322 if trash_node? 327 if trash_node?
323 errors.add(:base, :trash_the_trash) 328 errors.add(:base, :trash_the_trash)
324 raise ActiveRecord::RecordInvalid.new(self) 329 raise ActiveRecord::RecordInvalid.new(self)
@@ -391,6 +396,8 @@ class Node < ApplicationRecord
391 # One log entry at the root, per the subtree rule, written before the 396 # One log entry at the root, per the subtree rule, written before the
392 # rows die. 397 # rows die.
393 def destroy_from_trash! current_user = nil 398 def destroy_from_trash! current_user = nil
399 guard_live_change!(current_user)
400
394 unless in_trash? 401 unless in_trash?
395 errors.add(:base, :destroy_outside_trash) 402 errors.add(:base, :destroy_outside_trash)
396 raise ActiveRecord::RecordInvalid.new(self) 403 raise ActiveRecord::RecordInvalid.new(self)
@@ -485,6 +492,8 @@ class Node < ApplicationRecord
485 # Returns { :attached => n, :already => n, 492 # Returns { :attached => n, :already => n,
486 # :headline => nil | :set | :kept_existing | :not_eligible } 493 # :headline => nil | :set | :kept_existing | :not_eligible }
487 def attach_asset! asset, user:, headline: false 494 def attach_asset! asset, user:, headline: false
495 guard_live_change!(user)
496
488 if in_trash? || trash_node? 497 if in_trash? || trash_node?
489 errors.add(:base, :attach_in_trash) 498 errors.add(:base, :attach_in_trash)
490 raise ActiveRecord::RecordInvalid.new(self) 499 raise ActiveRecord::RecordInvalid.new(self)
@@ -565,6 +574,17 @@ class Node < ApplicationRecord
565 false 574 false
566 end 575 end
567 576
577 def restricted?
578 return true if root?
579
580 name = unique_name.to_s
581 return false if name.empty?
582
583 CccConventions::RESTRICTED_SUBTREES.any? do |prefix|
584 name == prefix || name.start_with?("#{prefix}/")
585 end
586 end
587
568 # Returns immutable node id for all new nodes so that the atom feed entry ids 588 # Returns immutable node id for all new nodes so that the atom feed entry ids
569 # stay the same eventhough the slug or positions changes. 589 # stay the same eventhough the slug or positions changes.
570 # Can be removed after a year or so ;) 590 # Can be removed after a year or so ;)
@@ -677,6 +697,14 @@ class Node < ApplicationRecord
677 697
678 private 698 private
679 699
700 def guard_live_change! user
701 return if user.nil?
702 return if user.may_change_live?(self)
703
704 errors.add(:base, :not_permitted)
705 raise ActiveRecord::RecordInvalid.new(self)
706 end
707
680 def reserved_slug_stays_reserved 708 def reserved_slug_stays_reserved
681 if parent&.root? && !trash_node_already_me? 709 if parent&.root? && !trash_node_already_me?
682 errors.add(:slug, :reserved_for_trash) if slug == CccConventions::TRASH_SLUG 710 errors.add(:slug, :reserved_for_trash) if slug == CccConventions::TRASH_SLUG
diff --git a/app/models/user.rb b/app/models/user.rb
index 1728521a..e8c3b9bb 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -105,6 +105,11 @@ class User < ApplicationRecord
105 roles.map { |r| I18n.t("users.roles.#{r}", :default => r) } 105 roles.map { |r| I18n.t("users.roles.#{r}", :default => r) }
106 end 106 end
107 107
108 def may_change_live?(subject)
109 return true unless subject.restricted?
110 redaktion?
111 end
112
108 def deactivate!(actor:) 113 def deactivate!(actor:)
109 return false if alumni? 114 return false if alumni?
110 transaction do 115 transaction do