diff options
| -rw-r--r-- | app/models/node.rb | 23 | ||||
| -rw-r--r-- | app/models/user.rb | 6 | ||||
| -rw-r--r-- | app/views/nodes/show.html.erb | 26 | ||||
| -rw-r--r-- | config/locales/de.yml | 1 | ||||
| -rw-r--r-- | config/locales/en.yml | 1 | ||||
| -rw-r--r-- | public/stylesheets/admin.css | 9 | ||||
| -rw-r--r-- | test/models/node_test.rb | 45 |
7 files changed, 97 insertions, 14 deletions
diff --git a/app/models/node.rb b/app/models/node.rb index ac3a6160..188b6c17 100644 --- a/app/models/node.rb +++ b/app/models/node.rb | |||
| @@ -236,7 +236,7 @@ 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) | 239 | guard_live_change!(current_user, :target_path => prospective_unique_name) |
| 240 | 240 | ||
| 241 | if in_trash? || trash_node? | 241 | if in_trash? || trash_node? |
| 242 | errors.add(:base, :publish_in_trash) | 242 | errors.add(:base, :publish_in_trash) |
| @@ -574,10 +574,8 @@ class Node < ApplicationRecord | |||
| 574 | false | 574 | false |
| 575 | end | 575 | end |
| 576 | 576 | ||
| 577 | def restricted? | 577 | def self.restricted_path? name |
| 578 | return true if root? | 578 | name = name.to_s |
| 579 | |||
| 580 | name = unique_name.to_s | ||
| 581 | return false if name.empty? | 579 | return false if name.empty? |
| 582 | 580 | ||
| 583 | CccConventions::RESTRICTED_SUBTREES.any? do |prefix| | 581 | CccConventions::RESTRICTED_SUBTREES.any? do |prefix| |
| @@ -585,6 +583,17 @@ class Node < ApplicationRecord | |||
| 585 | end | 583 | end |
| 586 | end | 584 | end |
| 587 | 585 | ||
| 586 | def restricted? | ||
| 587 | root? || self.class.restricted_path?(unique_name) | ||
| 588 | end | ||
| 589 | |||
| 590 | def prospective_unique_name | ||
| 591 | target_parent = staged_parent_id ? Node.find_by(:id => staged_parent_id) : parent | ||
| 592 | return nil unless target_parent | ||
| 593 | |||
| 594 | [target_parent.unique_name.presence, staged_slug.presence || slug].compact.join("/") | ||
| 595 | end | ||
| 596 | |||
| 588 | # Returns immutable node id for all new nodes so that the atom feed entry ids | 597 | # Returns immutable node id for all new nodes so that the atom feed entry ids |
| 589 | # stay the same eventhough the slug or positions changes. | 598 | # stay the same eventhough the slug or positions changes. |
| 590 | # Can be removed after a year or so ;) | 599 | # Can be removed after a year or so ;) |
| @@ -697,9 +706,9 @@ class Node < ApplicationRecord | |||
| 697 | 706 | ||
| 698 | private | 707 | private |
| 699 | 708 | ||
| 700 | def guard_live_change! user | 709 | def guard_live_change! user, target_path: nil |
| 701 | return if user.nil? | 710 | return if user.nil? |
| 702 | return if user.may_change_live?(self) | 711 | return if user.may_change_live?(self) && user.may_change_live_at?(target_path) |
| 703 | 712 | ||
| 704 | errors.add(:base, :not_permitted) | 713 | errors.add(:base, :not_permitted) |
| 705 | raise ActiveRecord::RecordInvalid.new(self) | 714 | raise ActiveRecord::RecordInvalid.new(self) |
diff --git a/app/models/user.rb b/app/models/user.rb index e8c3b9bb..bf0f40ee 100644 --- a/app/models/user.rb +++ b/app/models/user.rb | |||
| @@ -110,6 +110,12 @@ class User < ApplicationRecord | |||
| 110 | redaktion? | 110 | redaktion? |
| 111 | end | 111 | end |
| 112 | 112 | ||
| 113 | def may_change_live_at?(path) | ||
| 114 | return true if path.nil? | ||
| 115 | return true unless Node.restricted_path?(path) | ||
| 116 | redaktion? | ||
| 117 | end | ||
| 118 | |||
| 113 | def deactivate!(actor:) | 119 | def deactivate!(actor:) |
| 114 | return false if alumni? | 120 | return false if alumni? |
| 115 | transaction do | 121 | transaction do |
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 8b12e2eb..a6167c9e 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb | |||
| @@ -1,4 +1,5 @@ | |||
| 1 | <% locked_by_other = @node.locked? && @node.lock_owner != current_user %> | 1 | <% locked_by_other = @node.locked? && @node.lock_owner != current_user %> |
| 2 | <% may_change_live = current_user.may_change_live?(@node) %> | ||
| 2 | <div id="admin_layout" class="show_node"> | 3 | <div id="admin_layout" class="show_node"> |
| 3 | <h1><%= title_for_node(@node) %> <small>(<%= I18n.default_locale.to_s.upcase %>)</small></h1> | 4 | <h1><%= title_for_node(@node) %> <small>(<%= I18n.default_locale.to_s.upcase %>)</small></h1> |
| 4 | <div id="content"> | 5 | <div id="content"> |
| @@ -48,8 +49,12 @@ | |||
| 48 | <% unless locked_by_other %> | 49 | <% unless locked_by_other %> |
| 49 | <% if @node.draft && !@node.autosave && !@node.in_trash? && !@node.trash_node? %> | 50 | <% if @node.draft && !@node.autosave && !@node.in_trash? && !@node.trash_node? %> |
| 50 | <div class="info_item"> | 51 | <div class="info_item"> |
| 51 | <%= button_to t(".publish"), publish_node_path(@node), method: :put, | 52 | <% if may_change_live %> |
| 52 | form: { data: { confirm: t(".confirm_publish") }, class: 'button_to state_changing' } %> | 53 | <%= button_to t(".publish"), publish_node_path(@node), method: :put, |
| 54 | form: { data: { confirm: t(".confirm_publish") }, class: 'button_to state_changing' } %> | ||
| 55 | <% else %> | ||
| 56 | <span class="disabled_action"><%= t(".publish") %></span> | ||
| 57 | <% end %> | ||
| 53 | </div> | 58 | </div> |
| 54 | <% end %> | 59 | <% end %> |
| 55 | <% if @node.autosave || (@node.draft && @node.head) %> | 60 | <% if @node.autosave || (@node.draft && @node.head) %> |
| @@ -64,10 +69,17 @@ | |||
| 64 | <% end %> | 69 | <% end %> |
| 65 | <% unless @node.trash_node? || @node.in_trash? || @node.root? %> | 70 | <% unless @node.trash_node? || @node.in_trash? || @node.root? %> |
| 66 | <div class="info_item"> | 71 | <div class="info_item"> |
| 67 | <%= button_to trash_node_path(@node), method: :put, | 72 | <% if may_change_live %> |
| 68 | form: { data: { confirm: t(".confirm_trash") }, class: 'button_to destructive' } do %> | 73 | <%= button_to trash_node_path(@node), method: :put, |
| 69 | <%= icon("trash", library: "tabler", "aria-hidden": true) %> | 74 | form: { data: { confirm: t(".confirm_trash") }, class: 'button_to destructive' } do %> |
| 70 | <%= t(".move_to_trash") %> | 75 | <%= icon("trash", library: "tabler", "aria-hidden": true) %> |
| 76 | <%= t(".move_to_trash") %> | ||
| 77 | <% end %> | ||
| 78 | <% else %> | ||
| 79 | <span class="disabled_action"> | ||
| 80 | <%= icon("trash", library: "tabler", "aria-hidden": true) %> | ||
| 81 | <%= t(".move_to_trash") %> | ||
| 82 | </span> | ||
| 71 | <% end %> | 83 | <% end %> |
| 72 | </div> | 84 | </div> |
| 73 | <% end %> | 85 | <% end %> |
| @@ -76,6 +88,8 @@ | |||
| 76 | 88 | ||
| 77 | <% if locked_by_other %> | 89 | <% if locked_by_other %> |
| 78 | <span class="field_hint"><%= t(".locked_hint") %></span> | 90 | <span class="field_hint"><%= t(".locked_hint") %></span> |
| 91 | <% elsif !may_change_live %> | ||
| 92 | <span class="field_hint"><%= t(".restricted_hint") %></span> | ||
| 79 | <% end %> | 93 | <% end %> |
| 80 | </div> | 94 | </div> |
| 81 | 95 | ||
diff --git a/config/locales/de.yml b/config/locales/de.yml index a7ad0f26..746aa803 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml | |||
| @@ -493,6 +493,7 @@ de: | |||
| 493 | add_child: "Kind vom Typ %{kind} anlegen" | 493 | add_child: "Kind vom Typ %{kind} anlegen" |
| 494 | abstract_locale: "Abstract (%{lang})" | 494 | abstract_locale: "Abstract (%{lang})" |
| 495 | body_locale: "Text (%{lang})" | 495 | body_locale: "Text (%{lang})" |
| 496 | restricted_hint: "Geschützter Bereich — Veröffentlichen und Papierkorb sind der Redaktion vorbehalten. Entwürfe kannst du frei bearbeiten." | ||
| 496 | drafts: | 497 | drafts: |
| 497 | title: "Nodes mit Entwürfen, Autosaves oder Sperren" | 498 | title: "Nodes mit Entwürfen, Autosaves oder Sperren" |
| 498 | mine: | 499 | mine: |
diff --git a/config/locales/en.yml b/config/locales/en.yml index 7e830325..22429c9e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml | |||
| @@ -436,6 +436,7 @@ en: | |||
| 436 | add_child: "Add child type %{kind}" | 436 | add_child: "Add child type %{kind}" |
| 437 | abstract_locale: "Abstract (%{lang})" | 437 | abstract_locale: "Abstract (%{lang})" |
| 438 | body_locale: "Body (%{lang})" | 438 | body_locale: "Body (%{lang})" |
| 439 | restricted_hint: "Protected section — publishing and trashing are reserved for Redaktion. You can edit drafts freely." | ||
| 439 | drafts: | 440 | drafts: |
| 440 | title: "Nodes with drafts, autosaves or locks" | 441 | title: "Nodes with drafts, autosaves or locks" |
| 441 | mine: | 442 | mine: |
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 9991fad0..55fdf15d 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css | |||
| @@ -880,7 +880,9 @@ div.layout_row_content { | |||
| 880 | } | 880 | } |
| 881 | 881 | ||
| 882 | .info_group .disabled_action { | 882 | .info_group .disabled_action { |
| 883 | display: inline-block; | 883 | display: inline-flex; |
| 884 | align-items: center; | ||
| 885 | gap: 0.35rem; | ||
| 884 | border: 1px solid var(--border); | 886 | border: 1px solid var(--border); |
| 885 | border-radius: 2px; | 887 | border-radius: 2px; |
| 886 | padding: 4px 12px; | 888 | padding: 4px 12px; |
| @@ -889,6 +891,11 @@ div.layout_row_content { | |||
| 889 | cursor: not-allowed; | 891 | cursor: not-allowed; |
| 890 | } | 892 | } |
| 891 | 893 | ||
| 894 | .info_group .disabled_action svg { | ||
| 895 | width: 1.25rem; | ||
| 896 | height: 1.25rem; | ||
| 897 | } | ||
| 898 | |||
| 892 | .info_group_items { | 899 | .info_group_items { |
| 893 | display: flex; | 900 | display: flex; |
| 894 | flex-wrap: wrap; | 901 | flex-wrap: wrap; |
diff --git a/test/models/node_test.rb b/test/models/node_test.rb index f57f83bf..aa714427 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb | |||
| @@ -1004,4 +1004,49 @@ class NodeTest < ActiveSupport::TestCase | |||
| 1004 | node.publish_draft! | 1004 | node.publish_draft! |
| 1005 | assert_not_nil node.reload.head | 1005 | assert_not_nil node.reload.head |
| 1006 | end | 1006 | end |
| 1007 | |||
| 1008 | test "publishing a staged move into a restricted subtree is refused" do | ||
| 1009 | editor = User.create!(:login => "move_editor", :email => "me@example.com", | ||
| 1010 | :password => "secret", :password_confirmation => "secret") | ||
| 1011 | updates = Node.root.children.create!(:slug => "updates") | ||
| 1012 | year = updates.children.create!(:slug => "2026") | ||
| 1013 | node = Node.root.children.create!(:slug => "outside-post") | ||
| 1014 | node.reload.draft.update!(:title => "Entwurf") | ||
| 1015 | node.update!(:staged_parent_id => year.id) | ||
| 1016 | |||
| 1017 | assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft!(editor) } | ||
| 1018 | assert_nil node.reload.head | ||
| 1019 | assert_equal Node.root.id, node.parent_id | ||
| 1020 | end | ||
| 1021 | |||
| 1022 | test "a staged move within unrestricted space needs no role" do | ||
| 1023 | editor = User.create!(:login => "move_free", :email => "mf@example.com", | ||
| 1024 | :password => "secret", :password_confirmation => "secret") | ||
| 1025 | club = Node.root.children.create!(:slug => "club") | ||
| 1026 | node = Node.root.children.create!(:slug => "movable-post") | ||
| 1027 | node.reload.draft.update!(:title => "Entwurf") | ||
| 1028 | node.update!(:staged_parent_id => club.id) | ||
| 1029 | |||
| 1030 | node.publish_draft!(editor) | ||
| 1031 | assert_equal club.id, node.reload.parent_id | ||
| 1032 | end | ||
| 1033 | |||
| 1034 | test "publishing a staged rename onto a restricted path is refused" do | ||
| 1035 | editor = User.create!(:login => "rename_editor", :email => "re@example.com", | ||
| 1036 | :password => "secret", :password_confirmation => "secret") | ||
| 1037 | node = Node.root.children.create!(:slug => "harmless") | ||
| 1038 | node.reload.draft.update!(:title => "Entwurf") | ||
| 1039 | node.update!(:staged_slug => "updates") | ||
| 1040 | |||
| 1041 | assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft!(editor) } | ||
| 1042 | assert_nil node.reload.head | ||
| 1043 | end | ||
| 1044 | |||
| 1045 | test "a restricted subtree root cannot be claimed once it exists" do | ||
| 1046 | Node.root.children.create!(:slug => "updates") | ||
| 1047 | |||
| 1048 | squatter = Node.root.children.build(:slug => "updates") | ||
| 1049 | assert_not squatter.valid? | ||
| 1050 | assert squatter.errors[:slug].any? | ||
| 1051 | end | ||
| 1007 | end | 1052 | end |
