From 5644fffbc3c58e6dd7e3c4103c28bad8f4c0bbc6 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 6 Aug 2026 13:30:38 +0200 Subject: Let editors set a node external homepage, gated and witnessed --- app/controllers/nodes_controller.rb | 3 ++- app/helpers/node_actions_helper.rb | 12 +++++++++++- app/models/node.rb | 20 ++++++++++++++++++++ app/models/node_action.rb | 1 + app/views/nodes/edit.html.erb | 6 ++++++ config/locales/de.yml | 3 +++ config/locales/en.yml | 3 +++ test/models/node_test.rb | 29 +++++++++++++++++++++++++++++ 8 files changed, 75 insertions(+), 2 deletions(-) diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 5b919b1f..22606674 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -116,7 +116,8 @@ class NodesController < ApplicationController rescue LockedByAnotherUser => e flash[:error] = e.message redirect_to node_path(@node) - rescue ActiveRecord::RecordInvalid + rescue ActiveRecord::RecordInvalid => e + flash.now[:error] = e.record.errors.full_messages.to_sentence @page = @node.autosave || @node.draft || @node.head render :action => :edit end diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb index 4cf990b8..c4de46cc 100644 --- a/app/helpers/node_actions_helper.rb +++ b/app/helpers/node_actions_helper.rb @@ -28,7 +28,8 @@ module NodeActionsHelper "admin_revoke" => "shield-minus", "event_create" => "calendar-plus", "event_update" => "calendar-event", - "event_destroy" => "calendar-x" + "event_destroy" => "calendar-x", + "node_external_url" => "world" }.freeze def verb_icon action @@ -64,6 +65,7 @@ module NodeActionsHelper return true if m["translation_diff"].present? return true if m["changes"].present? || m["description_changed"] return true if m["title"].is_a?(Hash) && m.dig("title", "from") != m.dig("title", "to") + return true if m["external_url"].present? %w[author tags template_changed assets assets_changed assets_reordered abstract_changed body_changed].any? { |key| m[key].present? } end @@ -95,6 +97,9 @@ module NodeActionsHelper end items << t("node_actions.assets_reordered") if m["assets_reordered"] items << t("node_actions.assets_changed") if m["assets_changed"] + items << t("node_actions.detail_external_url", + :from => m.dig("external_url", "from").presence || t("node_actions.event_none"), + :to => m.dig("external_url", "to").presence || t("node_actions.event_none")) if m["external_url"] items end @@ -400,6 +405,11 @@ module NodeActionsHelper event_sentence(action, "event_destroy") end + def summarize_node_external_url action + t("node_actions.node_external_url", :actor => actor_ref(action), + :subject => subject_ref(action)).html_safe + end + def event_sentence action, key if action.node t("node_actions.#{key}_on", :actor => actor_ref(action), diff --git a/app/models/node.rb b/app/models/node.rb index f8c29b48..4b7c9772 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -42,6 +42,9 @@ class Node < ApplicationRecord :inclusion => { :in => ->(_) { Page.custom_templates } }, :allow_blank => true, :if => :default_template_name_changed? + validates :external_url, :format => { :with => %r{\Ahttps?://}i, + :allow_blank => true, + :message => :must_be_http } # Everything outside the Trash subtree, the Trash node included. # Relies on unique_name being authoritative for tree position -- @@ -605,6 +608,23 @@ class Node < ApplicationRecord self.created_at < new_id_format_date ? unique_path : id end + def update_external_url!(url, current_user = nil) + normalised = url.presence + return false if normalised == external_url + + guard_live_change!(current_user) + previous = external_url + + transaction do + update!(:external_url => normalised) + NodeAction.record!(:node => self, :user => current_user, + :action => "node_external_url", + :path => unique_name, + :external_url => { "from" => previous, "to" => normalised }) + end + true + end + # Full-text search across all locale translations using PostgreSQL tsvector. # Uses 'simple' dictionary (no stemming, no stopwords) so queries work # across German and English content without language detection. diff --git a/app/models/node_action.rb b/app/models/node_action.rb index bfa469b1..f1e4eaea 100644 --- a/app/models/node_action.rb +++ b/app/models/node_action.rb @@ -134,6 +134,7 @@ class NodeAction < ApplicationRecord # from the node verbs' "tags", which is a pair, # so one renderer cannot mistake the other. # "path" -- the node's unique_name, when it has a node + # "external_url" -- pair # # On "event_update" only, and only when something changed -- an # update that changes nothing records no entry at all: diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index b65416e1..c153c241 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -114,6 +114,12 @@ %> +
<%= t(".external_url") %>
+
+ <%= f.text_field :external_url %> +

<%= t(".external_url_hint") %>

+
+
<%= t(".tags") %>
<%= text_field_tag :tag_list, @page.tag_list.join(', ') %> diff --git a/config/locales/de.yml b/config/locales/de.yml index bfa58360..5197b00c 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -255,6 +255,7 @@ de: detail_event_allday: "ganztägig %{from} → %{to}" detail_event_end: "Ende %{from} → %{to}" event_none: "—" + node_external_url: "%{actor} hat die externe Homepage von %{subject} geändert" unknown_event: "ein Termin" open_gallery: "Gallerie anzeigen" @@ -456,6 +457,8 @@ de: set_headline_label: "Als Aufmacherbild setzen" set_headline_title: "Dieses Foto als Aufmacherbild der Seite verwenden" remove_image_label: "Bild entfernen" + external_url: "Externe Homepage" + external_url_hint: "Die eigene Website einer Dezentrale, wird auf dessen Seite angezeigt. Vorsicht, Änderung wird ohne Draft sofort veröffentlicht." show: status: "Status" head: "Head" diff --git a/config/locales/en.yml b/config/locales/en.yml index f786ca81..7e484c66 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -201,6 +201,7 @@ en: detail_event_coordinates: "coordinates changed" detail_event_allday: "all-day %{from} → %{to}" detail_event_end: "end %{from} → %{to}" + node_external_url: "%{actor} changed the external homepage of %{subject}" event_none: "—" unknown_event: "an event" @@ -403,6 +404,8 @@ en: set_headline_label: "Set as headline image" set_headline_title: "Use this photo as the page's headline image" remove_image_label: "Remove image" + external_url: "External homepage" + external_url_hint: "A chapter's own website, shown on its page. N.B.: Changing takes effect immediately." show: status: "Status" head: "Head" diff --git a/test/models/node_test.rb b/test/models/node_test.rb index a34094a0..c7fee58e 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -1072,4 +1072,33 @@ class NodeTest < ActiveSupport::TestCase assert_not node.reload.in_trash? assert_equal club.id, node.parent_id end + + test "setting an external url is gated and witnessed" do + node = Node.root.children.create!(:slug => "chapter_url_test") + + assert_difference -> { NodeAction.where(:action => "node_external_url").count }, 1 do + assert node.update_external_url!("https://example.org", users(:aaron)) + end + + assert_equal "https://example.org", node.reload.external_url + entry = NodeAction.where(:action => "node_external_url").last + assert_equal({ "from" => nil, "to" => "https://example.org" }, + entry.metadata["external_url"]) + end + + test "a javascript url is refused" do + node = Node.root.children.create!(:slug => "chapter_url_reject") + assert_raises(ActiveRecord::RecordInvalid) do + node.update_external_url!("javascript:alert(1)", users(:aaron)) + end + end + + test "setting an external url on a restricted node needs redaktion" do + node = Node.find_by(:unique_name => "updates") || + Node.root.children.create!(:slug => "updates") + + assert_raises(ActiveRecord::RecordInvalid) do + node.update_external_url!("https://example.org", users(:quentin)) + end + end end -- cgit v1.3