summaryrefslogtreecommitdiff
path: root/app/models/page.rb
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-08-19 16:05:40 +0200
committererdgeist <erdgeist@erdgeist.org>2026-08-19 16:05:40 +0200
commit4df88b601b1900c287051d827eaff46f498f60d0 (patch)
treeb6db47fbb256dc68601f2aa31430d8636018f115 /app/models/page.rb
parentedf0d64aa2c837b4bb28787483f98cb4815601c2 (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/models/page.rb')
-rw-r--r--app/models/page.rb36
1 files changed, 32 insertions, 4 deletions
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?