diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-08-19 16:05:40 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-08-19 16:05:40 +0200 |
| commit | 4df88b601b1900c287051d827eaff46f498f60d0 (patch) | |
| tree | b6db47fbb256dc68601f2aa31430d8636018f115 /app | |
| parent | edf0d64aa2c837b4bb28787483f98cb4815601c2 (diff) | |
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.
Diffstat (limited to 'app')
| -rw-r--r-- | app/controllers/nodes_controller.rb | 2 | ||||
| -rw-r--r-- | app/models/node.rb | 27 | ||||
| -rw-r--r-- | app/models/page.rb | 36 |
3 files changed, 60 insertions, 5 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 | |||
| 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 | :external_url, :redirect, :redirect_node_id) |
| 286 | end | 286 | end |
| 287 | 287 | ||
| 288 | def find_node | 288 | 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 | |||
| 236 | raise ActiveRecord::RecordInvalid.new(self) | 236 | raise ActiveRecord::RecordInvalid.new(self) |
| 237 | end | 237 | end |
| 238 | 238 | ||
| 239 | if self.draft.redirect.present? | ||
| 240 | if self.draft.redirect_node_id == self.id | ||
| 241 | errors.add(:base, :redirect_to_self) | ||
| 242 | raise ActiveRecord::RecordInvalid.new(self) | ||
| 243 | end | ||
| 244 | |||
| 245 | if self.draft.redirect_node_id.present? | ||
| 246 | target = Node.find_by(:id => self.draft.redirect_node_id) | ||
| 247 | |||
| 248 | unless target | ||
| 249 | errors.add(:base, :redirect_target_missing) | ||
| 250 | raise ActiveRecord::RecordInvalid.new(self) | ||
| 251 | end | ||
| 252 | |||
| 253 | if target.head&.redirect.present? | ||
| 254 | errors.add(:base, :redirect_to_redirect) | ||
| 255 | raise ActiveRecord::RecordInvalid.new(self) | ||
| 256 | end | ||
| 257 | end | ||
| 258 | |||
| 259 | if Page.redirecting_to(self.id).exists? | ||
| 260 | errors.add(:base, :redirect_would_chain) | ||
| 261 | raise ActiveRecord::RecordInvalid.new(self) | ||
| 262 | end | ||
| 263 | end | ||
| 264 | |||
| 239 | path_before = self.unique_name | 265 | path_before = self.unique_name |
| 240 | 266 | ||
| 241 | ActiveRecord::Base.transaction do | 267 | ActiveRecord::Base.transaction do |
| @@ -602,6 +628,7 @@ class Node < ApplicationRecord | |||
| 602 | def self.search(term, _ = {}) | 628 | def self.search(term, _ = {}) |
| 603 | joins(head: :translations) | 629 | joins(head: :translations) |
| 604 | .where("page_translations.search_vector @@ plainto_tsquery('simple', ?)", term) | 630 | .where("page_translations.search_vector @@ plainto_tsquery('simple', ?)", term) |
| 631 | .where(:pages => { :redirect => nil }) | ||
| 605 | .distinct | 632 | .distinct |
| 606 | end | 633 | end |
| 607 | 634 | ||
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 | |||
| 4 | 4 | ||
| 5 | PUBLIC_TEMPLATE_PATH = File.join(%w(custom page_templates public)) | 5 | PUBLIC_TEMPLATE_PATH = File.join(%w(custom page_templates public)) |
| 6 | FULL_PUBLIC_TEMPLATE_PATH = Rails.root.join('app', 'views', PUBLIC_TEMPLATE_PATH) | 6 | FULL_PUBLIC_TEMPLATE_PATH = Rails.root.join('app', 'views', PUBLIC_TEMPLATE_PATH) |
| 7 | REDIRECT_MODES = %w[temporary permanent].freeze | ||
| 7 | 8 | ||
| 8 | # Mixins and Plugins | 9 | # Mixins and Plugins |
| 9 | acts_as_taggable | 10 | acts_as_taggable |
| @@ -23,6 +24,7 @@ class Page < ApplicationRecord | |||
| 23 | validates :external_url, :format => { :with => %r{\Ahttps?://}i, | 24 | validates :external_url, :format => { :with => %r{\Ahttps?://}i, |
| 24 | :allow_blank => true, | 25 | :allow_blank => true, |
| 25 | :message => :must_be_http } | 26 | :message => :must_be_http } |
| 27 | validates :redirect, :inclusion => { :in => REDIRECT_MODES }, :allow_nil => true | ||
| 26 | validates_format_of :slug, :with => /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/, | 28 | validates_format_of :slug, :with => /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/, |
| 27 | :unless => -> { slug.blank? } | 29 | :unless => -> { slug.blank? } |
| 28 | validate :page_slug_not_reserved | 30 | validate :page_slug_not_reserved |
| @@ -181,7 +183,6 @@ class Page < ApplicationRecord | |||
| 181 | end | 183 | end |
| 182 | 184 | ||
| 183 | def valid_template | 185 | def valid_template |
| 184 | |||
| 185 | if template_name && template_exists? | 186 | if template_name && template_exists? |
| 186 | public_template_path | 187 | public_template_path |
| 187 | else | 188 | else |
| @@ -212,15 +213,16 @@ class Page < ApplicationRecord | |||
| 212 | self.slug = page.slug | 213 | self.slug = page.slug |
| 213 | self.parent_node_id = page.parent_node_id | 214 | self.parent_node_id = page.parent_node_id |
| 214 | self.external_url = page.external_url | 215 | self.external_url = page.external_url |
| 216 | self.redirect = page.redirect | ||
| 217 | self.redirect_node_id = page.redirect_node_id | ||
| 215 | self.tag_list = page.tag_list | 218 | self.tag_list = page.tag_list |
| 216 | self.template_name ||= page.template_name | 219 | self.template_name ||= page.template_name |
| 217 | self.published_at = page.published_at | 220 | self.published_at = page.published_at |
| 218 | 221 | ||
| 219 | # Clone translated attributes -- update each locale in place rather | 222 | # Clone translated attributes, update each locale in place rather |
| 220 | # than delete-and-recreate, so a locale whose content is genuinely | 223 | # than delete-and-recreate, so a locale whose content is genuinely |
| 221 | # unchanged keeps its real created_at/updated_at instead of looking | 224 | # unchanged keeps its real created_at/updated_at instead of looking |
| 222 | # freshly touched on every single save (which was silently defeating | 225 | # freshly touched on every single save. |
| 223 | # Page.find_with_outdated_translations' whole staleness comparison). | ||
| 224 | # search_vector is excluded deliberately: it's DB-trigger-maintained | 226 | # search_vector is excluded deliberately: it's DB-trigger-maintained |
| 225 | # from title/abstract, not real content, and comparing a precomputed | 227 | # from title/abstract, not real content, and comparing a precomputed |
| 226 | # tsvector risked a false "changed" from representation noise alone. | 228 | # tsvector risked a false "changed" from representation noise alone. |
| @@ -309,6 +311,32 @@ class Page < ApplicationRecord | |||
| 309 | published_at.nil? ? true : published_at < Time.now | 311 | published_at.nil? ? true : published_at < Time.now |
| 310 | end | 312 | end |
| 311 | 313 | ||
| 314 | # The destination this page sends visitors to, or nil. An internal target | ||
| 315 | # wins over an external one. A target that is restricted or has no head is | ||
| 316 | # no destination at all, so the page renders itself rather than linking to | ||
| 317 | # nothing. The banner partial calls this too, so the precedence cannot | ||
| 318 | # drift between the redirect and the link. | ||
| 319 | def redirect_target | ||
| 320 | return nil if redirect.blank? | ||
| 321 | |||
| 322 | if redirect_node_id.present? | ||
| 323 | node = Node.find_by(:id => redirect_node_id) | ||
| 324 | return nil unless node&.head && !node.restricted? | ||
| 325 | return node.unique_name | ||
| 326 | end | ||
| 327 | |||
| 328 | external_url.presence | ||
| 329 | end | ||
| 330 | |||
| 331 | def redirect_status | ||
| 332 | redirect == "permanent" ? :moved_permanently : :found | ||
| 333 | end | ||
| 334 | |||
| 335 | # Nodes whose published page redirects here | ||
| 336 | def self.redirecting_to(node_id) | ||
| 337 | Node.where(:head_id => where(:redirect_node_id => node_id).select(:id)) | ||
| 338 | end | ||
| 339 | |||
| 312 | # The address this page will have once published. | 340 | # The address this page will have once published. |
| 313 | def prospective_unique_name | 341 | def prospective_unique_name |
| 314 | return nil if parent_node_id.nil? | 342 | return nil if parent_node_id.nil? |
