From 4df88b601b1900c287051d827eaff46f498f60d0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 19 Aug 2026 16:05:40 +0200 Subject: Redirect a page to a node or an external URL Page#redirect_target resolves the precedence and returns nil for a destination that is restricted or has no head, so a page with a broken target renders itself rather than linking nowhere. The banner partial will call the same method, so the redirect and the link cannot drift. One hop, no exceptions, checked in publish_draft! rather than as a validation: two nodes publishing concurrently could each pass a save-time check and still produce a chain. "Live" means heads only, a draft redirect that has not published is not yet a link anyone can follow. Node.search excludes redirecting pages. editor_search does not: an editor looking for one searches by title, and only the body is worth hiding. --- app/controllers/nodes_controller.rb | 2 +- app/models/node.rb | 27 +++++++++++++++++++++++++++ app/models/page.rb | 36 ++++++++++++++++++++++++++++++++---- config/locales/de.yml | 4 ++++ config/locales/en.yml | 4 ++++ db/schema.rb | 4 +++- 6 files changed, 71 insertions(+), 6 deletions(-) diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index ef47f258..47b8573a 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -282,7 +282,7 @@ class NodesController < ApplicationController def page_params params.fetch(:page, {}).permit(:title, :abstract, :body, :template_name, :published_at, :user_id, :slug, :parent_node_id, - :external_url) + :external_url, :redirect, :redirect_node_id) end def find_node diff --git a/app/models/node.rb b/app/models/node.rb index 6fc6c3dc..52e06d2d 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -236,6 +236,32 @@ class Node < ApplicationRecord raise ActiveRecord::RecordInvalid.new(self) end + if self.draft.redirect.present? + if self.draft.redirect_node_id == self.id + errors.add(:base, :redirect_to_self) + raise ActiveRecord::RecordInvalid.new(self) + end + + if self.draft.redirect_node_id.present? + target = Node.find_by(:id => self.draft.redirect_node_id) + + unless target + errors.add(:base, :redirect_target_missing) + raise ActiveRecord::RecordInvalid.new(self) + end + + if target.head&.redirect.present? + errors.add(:base, :redirect_to_redirect) + raise ActiveRecord::RecordInvalid.new(self) + end + end + + if Page.redirecting_to(self.id).exists? + errors.add(:base, :redirect_would_chain) + raise ActiveRecord::RecordInvalid.new(self) + end + end + path_before = self.unique_name ActiveRecord::Base.transaction do @@ -602,6 +628,7 @@ class Node < ApplicationRecord def self.search(term, _ = {}) joins(head: :translations) .where("page_translations.search_vector @@ plainto_tsquery('simple', ?)", term) + .where(:pages => { :redirect => nil }) .distinct end diff --git a/app/models/page.rb b/app/models/page.rb index c2dc227b..9115ccb0 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -4,6 +4,7 @@ class Page < ApplicationRecord PUBLIC_TEMPLATE_PATH = File.join(%w(custom page_templates public)) FULL_PUBLIC_TEMPLATE_PATH = Rails.root.join('app', 'views', PUBLIC_TEMPLATE_PATH) + REDIRECT_MODES = %w[temporary permanent].freeze # Mixins and Plugins acts_as_taggable @@ -23,6 +24,7 @@ class Page < ApplicationRecord validates :external_url, :format => { :with => %r{\Ahttps?://}i, :allow_blank => true, :message => :must_be_http } + validates :redirect, :inclusion => { :in => REDIRECT_MODES }, :allow_nil => true validates_format_of :slug, :with => /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/, :unless => -> { slug.blank? } validate :page_slug_not_reserved @@ -181,7 +183,6 @@ class Page < ApplicationRecord end def valid_template - if template_name && template_exists? public_template_path else @@ -212,15 +213,16 @@ class Page < ApplicationRecord self.slug = page.slug self.parent_node_id = page.parent_node_id self.external_url = page.external_url + self.redirect = page.redirect + self.redirect_node_id = page.redirect_node_id self.tag_list = page.tag_list self.template_name ||= page.template_name self.published_at = page.published_at - # Clone translated attributes -- update each locale in place rather + # Clone translated attributes, update each locale in place rather # than delete-and-recreate, so a locale whose content is genuinely # unchanged keeps its real created_at/updated_at instead of looking - # freshly touched on every single save (which was silently defeating - # Page.find_with_outdated_translations' whole staleness comparison). + # freshly touched on every single save. # search_vector is excluded deliberately: it's DB-trigger-maintained # from title/abstract, not real content, and comparing a precomputed # tsvector risked a false "changed" from representation noise alone. @@ -309,6 +311,32 @@ class Page < ApplicationRecord published_at.nil? ? true : published_at < Time.now end + # The destination this page sends visitors to, or nil. An internal target + # wins over an external one. A target that is restricted or has no head is + # no destination at all, so the page renders itself rather than linking to + # nothing. The banner partial calls this too, so the precedence cannot + # drift between the redirect and the link. + def redirect_target + return nil if redirect.blank? + + if redirect_node_id.present? + node = Node.find_by(:id => redirect_node_id) + return nil unless node&.head && !node.restricted? + return node.unique_name + end + + external_url.presence + end + + def redirect_status + redirect == "permanent" ? :moved_permanently : :found + end + + # Nodes whose published page redirects here + def self.redirecting_to(node_id) + Node.where(:head_id => where(:redirect_node_id => node_id).select(:id)) + end + # The address this page will have once published. def prospective_unique_name return nil if parent_node_id.nil? diff --git a/config/locales/de.yml b/config/locales/de.yml index bb99ac30..e94dfb7e 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -140,6 +140,10 @@ de: attach_in_trash: "An einen Node im Papierkorb können keine Assets angehängt werden" not_permitted: "In diesem Bereich dürfen nur Mitglieder der Redaktion veröffentlichte Inhalte ändern" attach_with_autosave: "An einen Node mit ungespeicherten Änderungen im Editor können keine Assets angehängt werden" + redirect_to_self: "Eine Seite kann nicht auf sich selbst weiterleiten" + redirect_target_missing: "Die Seite, auf die weitergeleitet werden sollte, gibt es nicht mehr." + redirect_to_redirect: "Auf eine Seite, die selbst weiterleitet, kann nicht weitergeleitet werden" + redirect_would_chain: "Eine andere Seite leitet bereits hierher weiter, daher kann diese Seite nicht weiterleiten" page: attributes: slug: diff --git a/config/locales/en.yml b/config/locales/en.yml index cce5f12a..54d973e2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -91,6 +91,10 @@ en: attach_in_trash: "Cannot attach assets to a node in the Trash" not_permitted: "Only Redaktion members may change published content in this section" attach_with_autosave: "Cannot attach assets to a node with unsaved changes in the editor" + redirect_to_self: "A page cannot redirect to itself" + redirect_target_missing: "The page this was to redirect to no longer exists." + redirect_to_redirect: "Cannot redirect to a page that itself redirects" + redirect_would_chain: "Another page already redirects here, so this page cannot redirect onward" page: attributes: slug: diff --git a/db/schema.rb b/db/schema.rb index 05dc1ef1..888e96f0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_09_135746) do +ActiveRecord::Schema[8.1].define(version: 2026_08_19_124104) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -150,6 +150,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_09_135746) do t.integer "parent_node_id" t.string "preview_token" t.datetime "published_at", precision: nil + t.string "redirect" + t.integer "redirect_node_id" t.integer "revision" t.string "slug" t.string "template_name", limit: 255 -- cgit v1.3