summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-08-08 02:46:06 +0200
committererdgeist <erdgeist@erdgeist.org>2026-08-08 02:46:06 +0200
commit1a037ac6fab34c5f65ed0f37ed603b88e7b02fd1 (patch)
tree48af0eab79ea64c9ca92833d415483773e665305
parent5699572861085bfb0b375f44e2f35ee8eee3f8c6 (diff)
Move the external homepage onto the draft
-rw-r--r--app/controllers/nodes_controller.rb3
-rw-r--r--app/helpers/node_actions_helper.rb15
-rw-r--r--app/models/node.rb22
-rw-r--r--app/models/node_action.rb6
-rw-r--r--app/models/page.rb4
-rw-r--r--app/views/custom/page_templates/public/chapter_detail.html.erb4
-rw-r--r--app/views/custom/partials/_chapter.html.erb4
-rw-r--r--app/views/nodes/edit.html.erb3
-rw-r--r--config/locales/de.yml11
-rw-r--r--config/locales/en.yml4
-rw-r--r--lib/tasks/pages.rake28
-rw-r--r--test/models/node_test.rb25
12 files changed, 69 insertions, 60 deletions
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 3009e25a..ef47f258 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -281,7 +281,8 @@ class NodesController < ApplicationController
281 281
282 def page_params 282 def page_params
283 params.fetch(:page, {}).permit(:title, :abstract, :body, :template_name, 283 params.fetch(:page, {}).permit(:title, :abstract, :body, :template_name,
284 :published_at, :user_id, :slug, :parent_node_id) 284 :published_at, :user_id, :slug, :parent_node_id,
285 :external_url)
285 end 286 end
286 287
287 def find_node 288 def find_node
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
index 19385543..f21fee61 100644
--- a/app/helpers/node_actions_helper.rb
+++ b/app/helpers/node_actions_helper.rb
@@ -28,8 +28,7 @@ module NodeActionsHelper
28 "admin_revoke" => "shield-minus", 28 "admin_revoke" => "shield-minus",
29 "event_create" => "calendar-plus", 29 "event_create" => "calendar-plus",
30 "event_update" => "calendar-event", 30 "event_update" => "calendar-event",
31 "event_destroy" => "calendar-x", 31 "event_destroy" => "calendar-x"
32 "node_external_url" => "world"
33 }.freeze 32 }.freeze
34 33
35 def verb_icon action 34 def verb_icon action
@@ -67,7 +66,7 @@ module NodeActionsHelper
67 return true if m["title"].is_a?(Hash) && m.dig("title", "from") != m.dig("title", "to") 66 return true if m["title"].is_a?(Hash) && m.dig("title", "from") != m.dig("title", "to")
68 return true if m["external_url"].present? 67 return true if m["external_url"].present?
69 %w[author tags template_changed assets assets_changed assets_reordered 68 %w[author tags template_changed assets assets_changed assets_reordered
70 abstract_changed body_changed].any? { |key| m[key].present? } 69 abstract_changed body_changed external_url].any? { |key| m[key].present? }
71 end 70 end
72 71
73 def default_locale_changes action 72 def default_locale_changes action
@@ -95,6 +94,11 @@ module NodeActionsHelper
95 :names => linked_asset_names(action, names)).html_safe 94 :names => linked_asset_names(action, names)).html_safe
96 end 95 end
97 end 96 end
97 if m["external_url"]
98 items << t("node_actions.detail_external_url",
99 :from => m.dig("external_url", "from").presence || t("node_actions.event_none"),
100 :to => m.dig("external_url", "to").presence || t("node_actions.event_none"))
101 end
98 items << t("node_actions.assets_reordered") if m["assets_reordered"] 102 items << t("node_actions.assets_reordered") if m["assets_reordered"]
99 items << t("node_actions.assets_changed") if m["assets_changed"] 103 items << t("node_actions.assets_changed") if m["assets_changed"]
100 items << t("node_actions.detail_external_url", 104 items << t("node_actions.detail_external_url",
@@ -405,11 +409,6 @@ module NodeActionsHelper
405 event_sentence(action, "event_destroy") 409 event_sentence(action, "event_destroy")
406 end 410 end
407 411
408 def summarize_node_external_url action
409 t("node_actions.node_external_url", :actor => actor_ref(action),
410 :subject => subject_ref(action)).html_safe
411 end
412
413 def event_sentence action, key 412 def event_sentence action, key
414 if action.node 413 if action.node
415 t("node_actions.#{key}_on", :actor => actor_ref(action), 414 t("node_actions.#{key}_on", :actor => actor_ref(action),
diff --git a/app/models/node.rb b/app/models/node.rb
index 5c28a786..f5b76b45 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -42,9 +42,6 @@ class Node < ApplicationRecord
42 :inclusion => { :in => ->(_) { Page.custom_templates } }, 42 :inclusion => { :in => ->(_) { Page.custom_templates } },
43 :allow_blank => true, 43 :allow_blank => true,
44 :if => :default_template_name_changed? 44 :if => :default_template_name_changed?
45 validates :external_url, :format => { :with => %r{\Ahttps?://}i,
46 :allow_blank => true,
47 :message => :must_be_http }
48 45
49 # Everything outside the Trash subtree, the Trash node included. 46 # Everything outside the Trash subtree, the Trash node included.
50 # Relies on unique_name being authoritative for tree position -- 47 # Relies on unique_name being authoritative for tree position --
@@ -260,6 +257,8 @@ class Node < ApplicationRecord
260 self.slug = self.head.slug 257 self.slug = self.head.slug
261 end 258 end
262 259
260 self.external_url = self.head.external_url
261
263 if self.head.parent_node_id && self.head.parent_node_id != parent_id 262 if self.head.parent_node_id && self.head.parent_node_id != parent_id
264 new_parent = Node.find_by(:id => self.head.parent_node_id) 263 new_parent = Node.find_by(:id => self.head.parent_node_id)
265 264
@@ -604,23 +603,6 @@ class Node < ApplicationRecord
604 self.created_at < new_id_format_date ? unique_path : id 603 self.created_at < new_id_format_date ? unique_path : id
605 end 604 end
606 605
607 def update_external_url!(url, current_user = nil)
608 normalised = url.presence
609 return false if normalised == external_url
610
611 guard_live_change!(current_user)
612 previous = external_url
613
614 transaction do
615 update!(:external_url => normalised)
616 NodeAction.record!(:node => self, :user => current_user,
617 :action => "node_external_url",
618 :path => unique_name,
619 :external_url => { "from" => previous, "to" => normalised })
620 end
621 true
622 end
623
624 # Full-text search across all locale translations using PostgreSQL tsvector. 606 # Full-text search across all locale translations using PostgreSQL tsvector.
625 # Uses 'simple' dictionary (no stemming, no stopwords) so queries work 607 # Uses 'simple' dictionary (no stemming, no stopwords) so queries work
626 # across German and English content without language detection. 608 # across German and English content without language detection.
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index f1e4eaea..1e64861e 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -35,6 +35,7 @@ class NodeAction < ApplicationRecord
35 # "title" -- pair, always; "from" null on first publish 35 # "title" -- pair, always; "from" null on first publish
36 # "author" -- pair, when the byline changed (incl. first publish) 36 # "author" -- pair, when the byline changed (incl. first publish)
37 # "tags" -- pair of arrays, when changed 37 # "tags" -- pair of arrays, when changed
38 # "external_url" -- pair, when a chapter's homepage changed
38 # "assets" -- {"added" => [asset names], "removed" => [asset names]}, 39 # "assets" -- {"added" => [asset names], "removed" => [asset names]},
39 # keys only when any; a delta, not a pair. The event IS 40 # keys only when any; a delta, not a pair. The event IS
40 # the delta, full sets would bloat every entry. Changed 41 # the delta, full sets would bloat every entry. Changed
@@ -134,7 +135,6 @@ class NodeAction < ApplicationRecord
134 # from the node verbs' "tags", which is a pair, 135 # from the node verbs' "tags", which is a pair,
135 # so one renderer cannot mistake the other. 136 # so one renderer cannot mistake the other.
136 # "path" -- the node's unique_name, when it has a node 137 # "path" -- the node's unique_name, when it has a node
137 # "external_url" -- pair
138 # 138 #
139 # On "event_update" only, and only when something changed -- an 139 # On "event_update" only, and only when something changed -- an
140 # update that changes nothing records no entry at all: 140 # update that changes nothing records no entry at all:
@@ -199,12 +199,16 @@ class NodeAction < ApplicationRecord
199 "to" => title_of.call(new_page) } } 199 "to" => title_of.call(new_page) } }
200 unless old_page 200 unless old_page
201 diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user 201 diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user
202 diff[:external_url] = { "from" => nil, "to" => new_page.external_url } if new_page.external_url.present?
202 return diff 203 return diff
203 end 204 end
204 205
205 old_author, new_author = old_page.user&.login, new_page.user&.login 206 old_author, new_author = old_page.user&.login, new_page.user&.login
206 diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author 207 diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author
207 208
209 old_url, new_url = old_page.external_url, new_page.external_url
210 diff[:external_url] = { "from" => old_url, "to" => new_url } if old_url != new_url
211
208 old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort 212 old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort
209 diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags 213 diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags
210 214
diff --git a/app/models/page.rb b/app/models/page.rb
index bba7e6bc..3240057f 100644
--- a/app/models/page.rb
+++ b/app/models/page.rb
@@ -20,6 +20,9 @@ class Page < ApplicationRecord
20 :inclusion => { :in => ->(_) { Page.custom_templates } }, 20 :inclusion => { :in => ->(_) { Page.custom_templates } },
21 :allow_blank => true, 21 :allow_blank => true,
22 :if => :template_name_changed? 22 :if => :template_name_changed?
23 validates :external_url, :format => { :with => %r{\Ahttps?://}i,
24 :allow_blank => true,
25 :message => :must_be_http }
23 validates_format_of :slug, :with => /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/, 26 validates_format_of :slug, :with => /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/,
24 :unless => -> { slug.blank? } 27 :unless => -> { slug.blank? }
25 validate :page_slug_not_reserved 28 validate :page_slug_not_reserved
@@ -208,6 +211,7 @@ class Page < ApplicationRecord
208 # Clone untranslated attributes 211 # Clone untranslated attributes
209 self.slug = page.slug 212 self.slug = page.slug
210 self.parent_node_id = page.parent_node_id 213 self.parent_node_id = page.parent_node_id
214 self.external_url = page.external_url
211 self.tag_list = page.tag_list 215 self.tag_list = page.tag_list
212 self.template_name ||= page.template_name 216 self.template_name ||= page.template_name
213 self.published_at = page.published_at 217 self.published_at = page.published_at
diff --git a/app/views/custom/page_templates/public/chapter_detail.html.erb b/app/views/custom/page_templates/public/chapter_detail.html.erb
index 794be823..105ec21a 100644
--- a/app/views/custom/page_templates/public/chapter_detail.html.erb
+++ b/app/views/custom/page_templates/public/chapter_detail.html.erb
@@ -1,8 +1,8 @@
1<div class="article" lang="<%= @page.effective_lang %>"> 1<div class="article" lang="<%= @page.effective_lang %>">
2 <h1 class="headline"><%= @page.title %></h1> 2 <h1 class="headline"><%= @page.title %></h1>
3 <%= headline_image %> 3 <%= headline_image %>
4 <% if @page.node.external_url.present? %> 4 <% if @page.external_url.present? %>
5 <div class="chapter_url"><%= link_to @page.node.external_url, @page.node.external_url, target: '_blank', rel: 'noopener' %></div> 5 <div class="chapter_url"><%= link_to @page.external_url, @page.external_url, target: '_blank', rel: 'noopener' %></div>
6 <% end %> 6 <% end %>
7 <p><%= sanitize(@page.abstract) %></p> 7 <p><%= sanitize(@page.abstract) %></p>
8 <%= aggregate?(@page.body) %> 8 <%= aggregate?(@page.body) %>
diff --git a/app/views/custom/partials/_chapter.html.erb b/app/views/custom/partials/_chapter.html.erb
index 8a92b46f..2065560a 100644
--- a/app/views/custom/partials/_chapter.html.erb
+++ b/app/views/custom/partials/_chapter.html.erb
@@ -7,8 +7,8 @@
7 <% if page.abstract.present? %> 7 <% if page.abstract.present? %>
8 <div class="chapter_location"><%= page.abstract %></div> 8 <div class="chapter_location"><%= page.abstract %></div>
9 <% end %> 9 <% end %>
10 <% if page.node.external_url.present? %> 10 <% if page.external_url.present? %>
11 <div class="chapter_url"><%= link_to page.node.external_url, page.node.external_url, target: '_blank', rel: 'noopener' %></div> 11 <div class="chapter_url"><%= link_to page.external_url, page.external_url, target: '_blank', rel: 'noopener' %></div>
12 <% end %> 12 <% end %>
13 <% open_days = page.node.events.tagged_with('open-day').order(:start_time) %> 13 <% open_days = page.node.events.tagged_with('open-day').order(:start_time) %>
14 <% if open_days.any? %> 14 <% if open_days.any? %>
diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb
index ae206578..6b5a1234 100644
--- a/app/views/nodes/edit.html.erb
+++ b/app/views/nodes/edit.html.erb
@@ -116,8 +116,7 @@
116 116
117 <div class="layout_row_label"><%= t(".external_url") %></div> 117 <div class="layout_row_label"><%= t(".external_url") %></div>
118 <div class="layout_row_content"> 118 <div class="layout_row_content">
119 <%= f.text_field :external_url %> 119 <%= d.text_field :external_url %>
120 <p class="field_hint"><%= t(".external_url_hint") %></p>
121 </div> 120 </div>
122 121
123 <div class="layout_row_label"><%= t(".tags") %></div> 122 <div class="layout_row_label"><%= t(".tags") %></div>
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 032d7647..c6dc17b7 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -120,17 +120,12 @@ de:
120 errors: 120 errors:
121 models: 121 models:
122 node: 122 node:
123 # Attribute messages: full_messages prefixes the human attribute
124 # name, so these read as fragments. Defined at model level rather
125 # than per attribute, because several attributes share a message.
126 reserved_for_trash: "ist für den Papierkorb reserviert" 123 reserved_for_trash: "ist für den Papierkorb reserviert"
127 trash_immutable: "des Papierkorb-Nodes kann nicht geändert werden" 124 trash_immutable: "des Papierkorb-Nodes kann nicht geändert werden"
128 trash_must_be_empty: "muss auf dem Papierkorb-Node leer bleiben" 125 trash_must_be_empty: "muss auf dem Papierkorb-Node leer bleiben"
129 inside_trash: "kann im Papierkorb nicht existieren" 126 inside_trash: "kann im Papierkorb nicht existieren"
130 attributes: 127 attributes:
131 base: 128 base:
132 # :base messages stand alone -- no attribute name is prefixed,
133 # so these are whole sentences.
134 has_children: "Ein Node mit Kindern kann nicht gelöscht werden" 129 has_children: "Ein Node mit Kindern kann nicht gelöscht werden"
135 trash_undeletable: "Der Papierkorb-Node kann nicht gelöscht werden" 130 trash_undeletable: "Der Papierkorb-Node kann nicht gelöscht werden"
136 publish_in_trash: "Ein Node im Papierkorb kann nicht veröffentlicht werden" 131 publish_in_trash: "Ein Node im Papierkorb kann nicht veröffentlicht werden"
@@ -145,6 +140,8 @@ de:
145 attributes: 140 attributes:
146 slug: 141 slug:
147 reserved_for_trash: "ist für den Papierkorb reserviert" 142 reserved_for_trash: "ist für den Papierkorb reserviert"
143 external_url:
144 must_be_http: "muss mit http:// oder https:// beginnen"
148 user: 145 user:
149 attributes: 146 attributes:
150 roles: 147 roles:
@@ -163,7 +160,7 @@ de:
163 event: 160 event:
164 attributes: 161 attributes:
165 url: 162 url:
166 must_be_http: "must start with http:// or https://" 163 must_be_http: "muss mit http:// oder https:// beginnen"
167 164
168 tags: 165 tags:
169 index: 166 index:
@@ -253,7 +250,6 @@ de:
253 detail_event_allday: "ganztägig %{from} → %{to}" 250 detail_event_allday: "ganztägig %{from} → %{to}"
254 detail_event_end: "Ende %{from} → %{to}" 251 detail_event_end: "Ende %{from} → %{to}"
255 event_none: "—" 252 event_none: "—"
256 node_external_url: "%{actor} hat die externe Homepage von %{subject} geändert"
257 unknown_event: "ein Termin" 253 unknown_event: "ein Termin"
258 254
259 open_gallery: "Gallerie anzeigen" 255 open_gallery: "Gallerie anzeigen"
@@ -456,7 +452,6 @@ de:
456 set_headline_title: "Dieses Foto als Aufmacherbild der Seite verwenden" 452 set_headline_title: "Dieses Foto als Aufmacherbild der Seite verwenden"
457 remove_image_label: "Bild entfernen" 453 remove_image_label: "Bild entfernen"
458 external_url: "Externe Homepage" 454 external_url: "Externe Homepage"
459 external_url_hint: "Die eigene Website einer Dezentrale, wird auf dessen Seite angezeigt. Vorsicht, Änderung wird ohne Draft sofort veröffentlicht."
460 show: 455 show:
461 status: "Status" 456 status: "Status"
462 head: "Head" 457 head: "Head"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e75000bc..bd339cc9 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -91,6 +91,8 @@ en:
91 attributes: 91 attributes:
92 slug: 92 slug:
93 reserved_for_trash: "is reserved for the Trash" 93 reserved_for_trash: "is reserved for the Trash"
94 external_url:
95 must_be_http: "must start with http:// or https://"
94 user: 96 user:
95 attributes: 97 attributes:
96 roles: 98 roles:
@@ -199,7 +201,6 @@ en:
199 detail_event_coordinates: "coordinates changed" 201 detail_event_coordinates: "coordinates changed"
200 detail_event_allday: "all-day %{from} → %{to}" 202 detail_event_allday: "all-day %{from} → %{to}"
201 detail_event_end: "end %{from} → %{to}" 203 detail_event_end: "end %{from} → %{to}"
202 node_external_url: "%{actor} changed the external homepage of %{subject}"
203 event_none: "—" 204 event_none: "—"
204 unknown_event: "an event" 205 unknown_event: "an event"
205 206
@@ -403,7 +404,6 @@ en:
403 set_headline_title: "Use this photo as the page's headline image" 404 set_headline_title: "Use this photo as the page's headline image"
404 remove_image_label: "Remove image" 405 remove_image_label: "Remove image"
405 external_url: "External homepage" 406 external_url: "External homepage"
406 external_url_hint: "A chapter's own website, shown on its page. N.B.: Changing takes effect immediately."
407 show: 407 show:
408 status: "Status" 408 status: "Status"
409 head: "Head" 409 head: "Head"
diff --git a/lib/tasks/pages.rake b/lib/tasks/pages.rake
index 0cfbc5dc..ea0776b7 100644
--- a/lib/tasks/pages.rake
+++ b/lib/tasks/pages.rake
@@ -30,4 +30,32 @@ namespace :pages do
30 30
31 puts "#{write ? "updated" : "would update"} #{touched} pages" 31 puts "#{write ? "updated" : "would update"} #{touched} pages"
32 end 32 end
33
34 desc "Backfill pages.external_url from each page's node."
35 task :backfill_external_url => :environment do
36 write = ENV["WRITE"] == "1"
37 puts "DRY RUN -- nothing written. Re-run with WRITE=1." unless write
38
39 touched = 0
40 Node.where.not(:external_url => [nil, ""]).find_each do |node|
41 scope = node.pages.where("external_url IS DISTINCT FROM :u", :u => node.external_url)
42 count = scope.count
43 next if count.zero?
44
45 scope.update_all(:external_url => node.external_url) if write
46 touched += count
47 end
48
49 # Autosaves carry no node_id, so has_many :pages does not cover them.
50 Node.where.not(:autosave_id => nil).where.not(:external_url => [nil, ""])
51 .includes(:autosave).find_each do |node|
52 a = node.autosave
53 next if a.nil? || a.external_url == node.external_url
54
55 a.update_columns(:external_url => node.external_url) if write
56 touched += 1
57 end
58
59 puts "#{write ? "updated" : "would update"} #{touched} pages"
60 end
33end 61end
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index df1e96cb..735d2abe 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -1072,32 +1072,29 @@ class NodeTest < ActiveSupport::TestCase
1072 assert_equal club.id, node.parent_id 1072 assert_equal club.id, node.parent_id
1073 end 1073 end
1074 1074
1075 test "setting an external url is gated and witnessed" do 1075 test "an external url is carried by the draft and applied on publish" do
1076 node = Node.root.children.create!(:slug => "chapter_url_test") 1076 node = Node.root.children.create!(:slug => "chapter_url_test")
1077 1077 node.draft.update!(:external_url => "https://example.org")
1078 assert_difference -> { NodeAction.where(:action => "node_external_url").count }, 1 do 1078 node.publish_draft!(users(:aaron))
1079 assert node.update_external_url!("https://example.org", users(:aaron))
1080 end
1081 1079
1082 assert_equal "https://example.org", node.reload.external_url 1080 assert_equal "https://example.org", node.reload.external_url
1083 entry = NodeAction.where(:action => "node_external_url").last 1081 assert_equal "https://example.org", node.head.external_url
1084 assert_equal({ "from" => nil, "to" => "https://example.org" },
1085 entry.metadata["external_url"])
1086 end 1082 end
1087 1083
1088 test "a javascript url is refused" do 1084 test "a javascript url is refused on the draft" do
1089 node = Node.root.children.create!(:slug => "chapter_url_reject") 1085 node = Node.root.children.create!(:slug => "chapter_url_reject")
1090 assert_raises(ActiveRecord::RecordInvalid) do 1086 assert_raises(ActiveRecord::RecordInvalid) do
1091 node.update_external_url!("javascript:alert(1)", users(:aaron)) 1087 node.draft.update!(:external_url => "javascript:alert(1)")
1092 end 1088 end
1093 end 1089 end
1094 1090
1095 test "setting an external url on a restricted node needs redaktion" do 1091 test "publishing an external url change on a restricted node needs redaktion" do
1096 node = Node.find_by(:unique_name => "updates") || 1092 updates = Node.root.children.create!(:slug => "updates")
1097 Node.root.children.create!(:slug => "updates") 1093 node = updates.children.create!(:slug => "chapter_url_gated")
1094 node.draft.update!(:external_url => "https://example.org")
1098 1095
1099 assert_raises(ActiveRecord::RecordInvalid) do 1096 assert_raises(ActiveRecord::RecordInvalid) do
1100 node.update_external_url!("https://example.org", users(:quentin)) 1097 node.publish_draft!(users(:quentin))
1101 end 1098 end
1102 end 1099 end
1103 1100