summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock3
-rw-r--r--app/controllers/admin_controller.rb9
-rw-r--r--app/controllers/content_controller.rb2
-rw-r--r--app/controllers/csp_reports_controller.rb22
-rw-r--r--app/controllers/nodes_controller.rb46
-rw-r--r--app/helpers/node_actions_helper.rb66
-rw-r--r--app/models/concerns/nested_tree.rb8
-rw-r--r--app/models/node.rb218
-rw-r--r--app/models/node_action.rb111
-rw-r--r--app/models/page.rb17
-rw-r--r--app/models/user.rb31
-rw-r--r--app/views/admin/index.html.erb13
-rw-r--r--app/views/layouts/admin.html.erb5
-rw-r--r--app/views/layouts/application.html.erb10
-rw-r--r--app/views/node_actions/_action_row.html.erb18
-rw-r--r--app/views/node_actions/index.html.erb16
-rw-r--r--app/views/nodes/destroy.html.erb2
-rw-r--r--app/views/nodes/new.html.erb2
-rw-r--r--app/views/nodes/show.html.erb58
-rw-r--r--app/views/nodes/trashed.html.erb42
-rw-r--r--config/initializers/content_security_policy.rb49
-rw-r--r--config/initializers/error_log.rb20
-rw-r--r--config/locales/de.yml10
-rw-r--r--config/locales/en.yml6
-rw-r--r--config/routes.rb14
-rw-r--r--db/migrate/20260716165542_add_password_digest_to_users.rb5
-rw-r--r--db/migrate/20260717192043_drop_nested_set_columns_from_nodes.rb6
-rw-r--r--lib/authentication.rb16
-rw-r--r--lib/ccc_conventions.rb5
-rw-r--r--public/javascripts/admin_interface.js10
-rw-r--r--public/javascripts/admin_search.js43
-rw-r--r--public/javascripts/related_assets.js9
-rw-r--r--public/stylesheets/admin.css66
-rw-r--r--test/controllers/content_controller_test.rb13
-rw-r--r--test/controllers/csp_reports_controller_test.rb8
-rw-r--r--test/controllers/nodes_controller_test.rb105
-rw-r--r--test/fixtures/nodes.yml10
-rw-r--r--test/integration/csp_header_test.rb12
-rw-r--r--test/models/concerns/rrule_humanizer_test.rb7
-rw-r--r--test/models/helpers/node_actions_helper_test.rb39
-rw-r--r--test/models/node_test.rb101
-rw-r--r--test/models/node_trash_test.rb168
-rw-r--r--test/models/page_test.rb35
-rw-r--r--test/models/user_test.rb72
45 files changed, 1334 insertions, 195 deletions
diff --git a/Gemfile b/Gemfile
index 97257a9..cad34df 100644
--- a/Gemfile
+++ b/Gemfile
@@ -38,6 +38,7 @@ gem 'rails_icons'
38gem 'globalize', '~> 7.0' # translated model attributes (Page title/abstract/body) 38gem 'globalize', '~> 7.0' # translated model attributes (Page title/abstract/body)
39gem 'acts_as_list' # page revision ordering 39gem 'acts_as_list' # page revision ordering
40gem 'will_paginate', '~> 3.0' 40gem 'will_paginate', '~> 3.0'
41gem 'bcrypt', '~> 3.1'
41 42
42# Pinned to git until a release widens the activerecord < 8.1 ceiling. 43# Pinned to git until a release widens the activerecord < 8.1 ceiling.
43# Both gems work correctly on Rails 8.1; the gemspec constraint is overly conservative. 44# Both gems work correctly on Rails 8.1; the gemspec constraint is overly conservative.
diff --git a/Gemfile.lock b/Gemfile.lock
index e113726..8e4d5af 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -95,6 +95,7 @@ GEM
95 activerecord (>= 6.1) 95 activerecord (>= 6.1)
96 activesupport (>= 6.1) 96 activesupport (>= 6.1)
97 base64 (0.3.0) 97 base64 (0.3.0)
98 bcrypt (3.1.22)
98 bigdecimal (4.1.2) 99 bigdecimal (4.1.2)
99 builder (3.3.0) 100 builder (3.3.0)
100 coffee-rails (4.2.2) 101 coffee-rails (4.2.2)
@@ -334,6 +335,7 @@ PLATFORMS
334DEPENDENCIES 335DEPENDENCIES
335 acts-as-taggable-on! 336 acts-as-taggable-on!
336 acts_as_list 337 acts_as_list
338 bcrypt (~> 3.1)
337 chaos_calendar! 339 chaos_calendar!
338 coffee-rails (~> 4.0) 340 coffee-rails (~> 4.0)
339 concurrent-ruby (~> 1.3) 341 concurrent-ruby (~> 1.3)
@@ -375,6 +377,7 @@ CHECKSUMS
375 acts-as-taggable-on (13.0.0) 377 acts-as-taggable-on (13.0.0)
376 acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8 378 acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8
377 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b 379 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
380 bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032
378 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd 381 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
379 builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f 382 builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
380 bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7 383 bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index 37fd78b..d9cf1be 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -6,7 +6,7 @@ class AdminController < ApplicationController
6 6
7 def index 7 def index
8 @drafts = Node.drafts_and_autosaves(current_user_id: current_user.id).limit(5) 8 @drafts = Node.drafts_and_autosaves(current_user_id: current_user.id).limit(5)
9 @recent_changes = Node.recently_changed.limit(5) 9 @actions = NodeAction.order(:occurred_at => :desc, :id => :desc).limit(5)
10 end 10 end
11 11
12 def conventions 12 def conventions
@@ -71,4 +71,11 @@ class AdminController < ApplicationController
71 end 71 end
72 end 72 end
73 end 73 end
74
75 # Deliberately raises, to verify the error-log tripwire end to end.
76 # Behind login_required like the rest of the controller; harmless --
77 # the visitor gets the ordinary 500 page.
78 def boom
79 raise "Deliberate test exception via admin/boom"
80 end
74end 81end
diff --git a/app/controllers/content_controller.rb b/app/controllers/content_controller.rb
index 8d33105..8be4bfb 100644
--- a/app/controllers/content_controller.rb
+++ b/app/controllers/content_controller.rb
@@ -31,7 +31,7 @@ class ContentController < ApplicationController
31 def render_gallery 31 def render_gallery
32 unless @page.nil? 32 unless @page.nil?
33 @images = @page.assets.images 33 @images = @page.assets.images
34 render :file => "content/gallery" 34 render :template => 'content/gallery'
35 else 35 else
36 head :not_found 36 head :not_found
37 end 37 end
diff --git a/app/controllers/csp_reports_controller.rb b/app/controllers/csp_reports_controller.rb
new file mode 100644
index 0000000..a8f8edb
--- /dev/null
+++ b/app/controllers/csp_reports_controller.rb
@@ -0,0 +1,22 @@
1class CspReportsController < ApplicationController
2 # Browsers POST application/csp-report with no CSRF token, no session.
3 skip_before_action :verify_authenticity_token
4
5 def create
6 request.body.rewind if request.body.respond_to?(:rewind)
7 raw = request.body.read(8192)
8 raw = request.raw_post if raw.blank?
9
10 report = (JSON.parse(raw)["csp-report"] rescue nil)
11
12 if report
13 directive = report["effective-directive"] || report["violated-directive"]
14 at = (URI.parse(report["document-uri"]).path rescue "unparsed")
15 Rails.logger.warn("CSP violation: #{directive} blocked=#{report['blocked-uri']} at=#{at}")
16 else
17 Rails.logger.warn("CSP violation: unparseable report (#{raw.to_s.bytesize} bytes)")
18 end
19
20 head :no_content
21 end
22end
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 9834a17..6caa827 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -13,7 +13,9 @@ class NodesController < ApplicationController
13 :publish, 13 :publish,
14 :unlock, 14 :unlock,
15 :autosave, 15 :autosave,
16 :revert 16 :revert,
17 :trash,
18 :restore_from_trash
17 ] 19 ]
18 20
19 around_action :pin_to_default_locale, :only => [:show, :edit, :update, :autosave] 21 around_action :pin_to_default_locale, :only => [:show, :edit, :update, :autosave]
@@ -60,6 +62,9 @@ class NodesController < ApplicationController
60 def show 62 def show
61 @page = @node.draft || @node.head 63 @page = @node.draft || @node.head
62 @translations = @page.translation_summary 64 @translations = @page.translation_summary
65 @page_actions = NodeAction.where(:page_id => @node.pages.select(:id))
66 .order(:occurred_at, :id)
67 .group_by(&:page_id)
63 end 68 end
64 69
65 def edit 70 def edit
@@ -139,8 +144,40 @@ class NodesController < ApplicationController
139 redirect_to node_path(@node) 144 redirect_to node_path(@node)
140 end 145 end
141 146
147 def trash
148 if @node.trash!(current_user)
149 flash[:notice] = "Page has been moved to the Trash"
150 redirect_to trashed_nodes_path
151 else
152 flash[:notice] = "Page is already in the Trash"
153 redirect_to node_path(@node)
154 end
155 rescue ActiveRecord::RecordInvalid, LockedByAnotherUser => e
156 flash[:error] = e.message
157 redirect_to node_path(@node)
158 end
159
160 def restore_from_trash
161 parent = Node.find(params[:parent_id])
162 @node.restore_from_trash!(parent, current_user)
163 flash[:notice] = "Page has been restored from the Trash"
164 redirect_to node_path(@node)
165 rescue ActiveRecord::RecordNotFound
166 flash[:error] = "Restore target not found"
167 redirect_to node_path(@node)
168 rescue ActiveRecord::RecordInvalid => e
169 flash[:error] = e.message
170 redirect_to node_path(@node)
171 end
172
142 def destroy 173 def destroy
143 @node.destroy 174 @node.destroy_from_trash!(current_user)
175 flash[:notice] = "Page has been permanently deleted"
176 redirect_to trashed_nodes_path
177
178 rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed => e
179 flash[:error] = e.message
180 redirect_to node_path(@node)
144 end 181 end
145 182
146 def publish 183 def publish
@@ -214,6 +251,11 @@ class NodesController < ApplicationController
214 @sitemap_descendant_counts = descendant_counts_for(@sitemap) 251 @sitemap_descendant_counts = descendant_counts_for(@sitemap)
215 end 252 end
216 253
254 def trashed
255 @nodes = Node.trash.children.order(:slug)
256 .paginate(:page => params[:page], :per_page => 50)
257 end
258
217 private 259 private
218 260
219 def slug_for(title) 261 def slug_for(title)
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
index 996f98d..fd8cc36 100644
--- a/app/helpers/node_actions_helper.rb
+++ b/app/helpers/node_actions_helper.rb
@@ -1,6 +1,31 @@
1module NodeActionsHelper 1module NodeActionsHelper
2 include ERB::Util 2 include ERB::Util
3 3
4 # One glyph per verb, rendered before the sentence in _action_row.
5 # Unknown verbs get the dashed circle -- the unknown-verb principle
6 # extended to iconography: the log outlives its vocabulary.
7 VERB_ICONS = {
8 "create" => "file-plus",
9 "publish" => "send",
10 "move" => "arrows-move",
11 "trash" => "trash",
12 "restore_from_trash" => "arrow-back-up",
13 "destroy" => "trash-x",
14 "discard_autosave" => "eraser",
15 "destroy_draft" => "eraser",
16 }.freeze
17
18 def verb_icon action
19 name = if action.action == "publish" && action.metadata["via"] == "revision"
20 "history"
21 else
22 VERB_ICONS.fetch(action.action, "circle-dashed")
23 end
24 content_tag(:span, icon(name, library: "tabler", "aria-hidden": true),
25 :class => "node_action_icon node_action_icon--#{name}",
26 :title => action.action)
27 end
28
4 # One sentence per entry, rendered from metadata alone, so entries 29 # One sentence per entry, rendered from metadata alone, so entries
5 # stay renderable after the rows they reference are gone. Live 30 # stay renderable after the rows they reference are gone. Live
6 # associations only upgrade plain names to links. Every metadata 31 # associations only upgrade plain names to links. Every metadata
@@ -59,6 +84,32 @@ module NodeActionsHelper
59 end 84 end
60 end 85 end
61 86
87 def revision_lifecycle_badges actions
88 return "" if actions.blank?
89
90 badges = actions.map do |action|
91 key = case action.action
92 when "create" then "node_actions.revision_created"
93 when "publish" then action.metadata["via"] == "revision" ?
94 "node_actions.revision_restored" :
95 "node_actions.revision_published"
96 end
97 next unless key
98
99 badge = h(t(key, :date => action.occurred_at.strftime("%Y-%m-%d"),
100 :actor => action.actor_name))
101 if action.inferred_from
102 badge = safe_join([badge, content_tag(:span, t("node_actions.backfilled"),
103 :class => "node_action_inferred",
104 :title => action.inferred_from)], " ")
105 end
106 badge
107 end.compact
108
109 return "" if badges.empty?
110 safe_join(["— ", safe_join(badges, " · ")])
111 end
112
62 private 113 private
63 114
64 def revision_ref action, key 115 def revision_ref action, key
@@ -114,4 +165,19 @@ module NodeActionsHelper
114 t("node_actions.destroy_draft", 165 t("node_actions.destroy_draft",
115 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe 166 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe
116 end 167 end
168
169 def summarize_trash action
170 t("node_actions.trash", :actor => actor_ref(action), :subject => subject_ref(action),
171 :from => h(action.metadata.dig("path", "from"))).html_safe
172 end
173
174 def summarize_restore_from_trash action
175 t("node_actions.restore_from_trash", :actor => actor_ref(action), :subject => subject_ref(action),
176 :to => h(action.metadata.dig("path", "to"))).html_safe
177 end
178
179 def summarize_destroy action
180 t("node_actions.destroy", :actor => actor_ref(action), :subject => subject_ref(action),
181 :path => h(action.metadata["path"])).html_safe
182 end
117end 183end
diff --git a/app/models/concerns/nested_tree.rb b/app/models/concerns/nested_tree.rb
index 9a2eb0e..befcdb3 100644
--- a/app/models/concerns/nested_tree.rb
+++ b/app/models/concerns/nested_tree.rb
@@ -9,8 +9,6 @@ module NestedTree
9 included do 9 included do
10 belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children 10 belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children
11 has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent 11 has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent
12
13 before_destroy :delete_descendants
14 end 12 end
15 13
16 class_methods do 14 class_methods do
@@ -77,10 +75,4 @@ module NestedTree
77 def move_to_child_of(new_parent) 75 def move_to_child_of(new_parent)
78 update!(parent_id: new_parent.id) 76 update!(parent_id: new_parent.id)
79 end 77 end
80
81 private
82
83 def delete_descendants
84 self.class.where(id: descendants.pluck(:id)).delete_all
85 end
86end 78end
diff --git a/app/models/node.rb b/app/models/node.rb
index 717f5b4..a440c2f 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -4,17 +4,23 @@ class Node < ApplicationRecord
4 4
5 # Associations 5 # Associations
6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy 6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy
7 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, :dependent => :destroy, optional: true 7 has_many :node_actions, :dependent => :nullify
8 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, :dependent => :destroy, optional: true 8 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true
9 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true
10 # Autosave pages carry no node_id, so has_many :pages does not cover
11 # them -- this dependent: :destroy is their only cleanup on node destroy.
9 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true 12 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true
13
10 has_many :permissions, :dependent => :destroy 14 has_many :permissions, :dependent => :destroy
11 has_many :events, :dependent => :destroy 15 has_many :events, :dependent => :destroy
12 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true 16 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true
13 17
14 # Callbacks 18 # Callbacks
15 after_create :initialize_empty_page 19 after_create :initialize_empty_page
16 before_save :check_for_changed_slug 20 before_save :check_for_changed_slug
17 after_save :update_unique_names_of_children 21 after_save :update_unique_names_of_children
22 before_destroy :refuse_destroy_with_children
23 before_destroy :refuse_destroying_trash_node
18 24
19 # Validations 25 # Validations
20 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } 26 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? }
@@ -22,6 +28,21 @@ class Node < ApplicationRecord
22 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } 28 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? }
23 validates_presence_of :parent_id, :unless => -> { Node.root.nil? } 29 validates_presence_of :parent_id, :unless => -> { Node.root.nil? }
24 30
31 validate :reserved_slug_stays_reserved
32 validate :no_head_inside_trash
33 validates :default_template_name,
34 :inclusion => { :in => ->(_) { Page.custom_templates } },
35 :allow_blank => true,
36 :if => :default_template_name_changed?
37
38 # Everything outside the Trash subtree, the Trash node included.
39 # Relies on unique_name being authoritative for tree position --
40 # the same trust public routing places in it.
41 scope :not_in_trash, -> {
42 where.not(:unique_name => CccConventions::TRASH_SLUG)
43 .where("unique_name NOT LIKE ?", "#{CccConventions::TRASH_SLUG}/%")
44 }
45
25 # Class methods 46 # Class methods
26 47
27 # Returns a page for a given node. If no revision is supplied, it returns 48 # Returns a page for a given node. If no revision is supplied, it returns
@@ -48,6 +69,19 @@ class Node < ApplicationRecord
48 nil 69 nil
49 end 70 end
50 71
72 # The Trash container node. Lazily self-creating and idempotent, so
73 # every environment acquires it on first touch.
74 # Never call from validations. the positional predicates below
75 # exist for that.
76 def self.trash
77 root.children.find_by(:slug => CccConventions::TRASH_SLUG) ||
78 root.children.create!(:slug => CccConventions::TRASH_SLUG).tap do |node|
79 Globalize.with_locale(I18n.default_locale) do
80 node.draft.update!(:title => "Trash")
81 end
82 end
83 end
84
51 # Instance Methods 85 # Instance Methods
52 86
53 # Acquires (or reaffirms) the editing lock without creating a draft or 87 # Acquires (or reaffirms) the editing lock without creating a draft or
@@ -194,6 +228,10 @@ class Node < ApplicationRecord
194 # Return nil if nothing to publish and no staged changes 228 # Return nil if nothing to publish and no staged changes
195 return nil unless self.draft || staged_slug || staged_parent_id 229 return nil unless self.draft || staged_slug || staged_parent_id
196 230
231 if in_trash? || trash_node?
232 raise ActiveRecord::RecordInvalid.new(self), "Cannot publish a node in the Trash"
233 end
234
197 path_before = self.unique_name 235 path_before = self.unique_name
198 236
199 ActiveRecord::Base.transaction do 237 ActiveRecord::Base.transaction do
@@ -258,6 +296,117 @@ class Node < ApplicationRecord
258 end 296 end
259 end 297 end
260 298
299
300 # Moves this node and its subtree into the Trash. Demotes every head
301 # in the subtree first (aggregators and search operate on heads
302 # regardless of tree position); where a node has no draft, the former
303 # head becomes its draft so content stays editable and restorable --
304 # otherwise the former head remains a plain revision. One log entry,
305 # at the root, carrying the leaving-public-view snapshot.
306 def trash! current_user = nil
307 return nil if in_trash?
308 raise ActiveRecord::RecordInvalid.new(self), "The Trash node itself cannot be trashed" if trash_node?
309
310 ActiveRecord::Base.transaction do
311 path_before = unique_name
312 was_published = head_id.present?
313 final_published_at = head&.published_at
314
315 demoted = 0
316 ([self] + descendants.to_a).each do |node|
317 next unless node.head_id
318 former = node.head
319 node.head = nil
320 node.draft_id = former.id if node.draft_id.nil?
321 node.save!
322 demoted += 1
323 end
324
325 self.reload
326 move_to_child_of(Node.trash)
327 self.reload
328 update_unique_name
329 send(:update_unique_names_of_children)
330 unlock!
331
332 metadata = { :path => { "from" => path_before, "to" => unique_name } }
333 metadata[:was_published] = true if was_published
334 metadata[:final_published_at] = final_published_at.iso8601 if final_published_at
335 metadata[:demoted_heads] = demoted if demoted > 0
336
337 NodeAction.record!(:node => self, :user => current_user, :action => "trash", **metadata)
338 self
339 end
340 end
341
342 # Returns the node to the living tree under a chosen parent. The
343 # subtree comes back exactly as it sits in the Trash: all drafts,
344 # nothing published. Republication is a separate, witnessed act
345 # per node.
346 def restore_from_trash! new_parent, current_user = nil
347 return nil unless in_trash?
348
349 if new_parent.nil? || new_parent == self || descendants.include?(new_parent) ||
350 new_parent.trash_node? || new_parent.in_trash?
351 raise ActiveRecord::RecordInvalid.new(self), "Restore target must be a living node"
352 end
353
354 ActiveRecord::Base.transaction do
355 path_before = unique_name
356 move_to_child_of(new_parent)
357 self.reload
358 update_unique_name
359 send(:update_unique_names_of_children)
360
361 NodeAction.record!(:node => self, :user => current_user, :action => "restore_from_trash",
362 :path => { "from" => path_before, "to" => unique_name })
363 self
364 end
365 end
366
367 # Final deletion -- only from inside the Trash. Removes the whole
368 # subtree, deepest first, each node through a real destroy! so every
369 # per-node cascade runs (the categorical difference from the old
370 # delete_all nuke). refuse_destroy_with_children on bare destroy is
371 # untouched
372 # One log entry at the root, per the subtree rule, written before the
373 # rows die.
374 def destroy_from_trash! current_user = nil
375 raise ActiveRecord::RecordInvalid.new(self), "Nodes are only destroyed from the Trash" unless in_trash?
376
377 ActiveRecord::Base.transaction do
378 doomed = self_and_descendants_ordered_with_level
379 .sort_by { |_, level| -level }
380 .map(&:first)
381
382 metadata = { :path => unique_name }
383 metadata[:destroyed_descendants] = doomed.size - 1 if doomed.size > 1
384
385 NodeAction.record!(:node => self, :user => current_user, :action => "destroy", **metadata)
386 doomed.each(&:destroy!)
387 end
388 end
389
390 # The most recent trash entry. Its path.from is the restore hint.
391 def last_trash_entry
392 node_actions.where(:action => "trash").order(:occurred_at => :desc, :id => :desc).first
393 end
394
395 # The node's pre-trash parent, if a living node still answers to
396 # that path. Nil when the parent was itself trashed (its unique_name
397 # changed) or deleted; a different node that has since taken over
398 # the path is a legitimate suggestion.
399 def suggested_restore_parent
400 from = last_trash_entry&.metadata&.dig("path", "from")
401 return nil unless from
402
403 parent_path = from.rpartition("/").first
404 return Node.root if parent_path.empty?
405
406 candidate = Node.find_by(:unique_name => parent_path)
407 candidate unless candidate.nil? || candidate.in_trash? || candidate.trash_node?
408 end
409
261 # returns an array with all parts of a unique_name rather than a string 410 # returns an array with all parts of a unique_name rather than a string
262 def unique_path 411 def unique_path
263 unique_name.to_s.split("/") 412 unique_name.to_s.split("/")
@@ -269,7 +418,7 @@ class Node < ApplicationRecord
269 end 418 end
270 419
271 def computed_unique_name 420 def computed_unique_name
272 path = path_to_root[1..-1].join("/") # excluding root 421 path_to_root[1..-1].join("/") # excluding root
273 end 422 end
274 423
275 def current_unique_name 424 def current_unique_name
@@ -313,6 +462,20 @@ class Node < ApplicationRecord
313 autosave || draft || head 462 autosave || draft || head
314 end 463 end
315 464
465 def trash_node?
466 parent&.root? && slug == CccConventions::TRASH_SLUG
467 end
468
469 # Inside the Trash subtree. False for the Trash node itself.
470 def in_trash?
471 current = parent
472 while current
473 return true if current.trash_node?
474 current = current.parent
475 end
476 false
477 end
478
316 # Returns immutable node id for all new nodes so that the atom feed entry ids 479 # Returns immutable node id for all new nodes so that the atom feed entry ids
317 # stay the same eventhough the slug or positions changes. 480 # stay the same eventhough the slug or positions changes.
318 # Can be removed after a year or so ;) 481 # Can be removed after a year or so ;)
@@ -353,7 +516,7 @@ class Node < ApplicationRecord
353 end 516 end
354 517
355 def self.drafts_and_autosaves(current_user_id: nil) 518 def self.drafts_and_autosaves(current_user_id: nil)
356 scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") 519 scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").not_in_trash
357 return scope.order("updated_at DESC") unless current_user_id 520 return scope.order("updated_at DESC") unless current_user_id
358 521
359 scope.order( 522 scope.order(
@@ -361,6 +524,15 @@ class Node < ApplicationRecord
361 ) 524 )
362 end 525 end
363 526
527 # Nodes are never destroyed recursively
528 # Descendants must be removed or reparented individually first.
529 # The Trash feature will be the ordinary path to deletion.
530 def refuse_destroy_with_children
531 return unless children.exists?
532 errors.add(:base, "Cannot destroy a node that still has children")
533 throw :abort
534 end
535
364 def self.recently_changed 536 def self.recently_changed
365 includes(:head).where( 537 includes(:head).where(
366 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL", 538 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL",
@@ -417,4 +589,36 @@ class Node < ApplicationRecord
417 end 589 end
418 end 590 end
419 end 591 end
592
593 private
594
595 def reserved_slug_stays_reserved
596 if parent&.root? && !trash_node_already_me?
597 errors.add(:slug, "is reserved for the Trash") if slug == CccConventions::TRASH_SLUG
598 errors.add(:staged_slug, "is reserved for the Trash") if staged_slug == CccConventions::TRASH_SLUG
599 end
600
601 if persisted? && slug_was == CccConventions::TRASH_SLUG && Node.find(id).trash_node?
602 errors.add(:slug, "of the Trash node cannot change") if slug_changed?
603 errors.add(:parent_id, "of the Trash node cannot change") if parent_id_changed?
604 errors.add(:staged_slug, "must stay empty on the Trash node") if staged_slug.present?
605 errors.add(:staged_parent_id, "must stay empty on the Trash node") if staged_parent_id.present?
606 end
607 end
608
609 def trash_node_already_me?
610 existing = Node.root&.children&.find_by(:slug => CccConventions::TRASH_SLUG)
611 existing.nil? || existing.id == id
612 end
613
614 def no_head_inside_trash
615 return unless head_id.present?
616 errors.add(:head_id, "cannot exist inside the Trash") if in_trash? || trash_node?
617 end
618
619 def refuse_destroying_trash_node
620 return unless trash_node?
621 errors.add(:base, "The Trash node cannot be destroyed")
622 throw :abort
623 end
420end 624end
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index 792ae1e..13bd5ba 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -8,79 +8,78 @@ class NodeAction < ApplicationRecord
8 8
9 # == Metadata contract == 9 # == Metadata contract ==
10 # 10 #
11 # metadata is written once at creation and never updated. It is the 11 # metadata is written once at creation, never updated. It is the
12 # single place anything that must survive deletion of the referenced 12 # single place anything that must survive deletion of the referenced
13 # rows lives; node_id/page_id/user_id are lookup and ordering only. 13 # rows lives; node_id/page_id/user_id are lookup and ordering only.
14 # All keys are strings. Pairs are always {"from" => x, "to" => y} -- 14 # All keys are strings. Pairs are always {"from" => x, "to" => y}.
15 # never _old/_new suffixes. Optional pairs are written only when the 15 # Optional pairs only when the sides differ; booleans only when
16 # two sides differ; required keys are always present. All titles and 16 # true; required keys always present. Titles and names are read
17 # names are read pinned to I18n.default_locale unless inside a 17 # pinned to I18n.default_locale unless inside a locale-keyed block.
18 # locale-keyed block.
19 # 18 #
20 # Baseline, every entry (written here, not by call sites): 19 # Baseline, every entry (written here, not by call sites):
21 # "username" -- actor's login at write time 20 # "username" -- actor's login at write time
22 # "human_readable_node_name" -- node title, default locale 21 # "human_readable_node_name" -- node title, default locale
23 # 22 #
24 # "create": 23 # "create":
25 # "title" -- initial title, default locale 24 # "title" -- initial title, flat string (NOT a pair)
26 # "path" -- unique path at creation time. Historical value only: 25 # "path" -- unique path at creation, flat string. Historical
27 # later renames/moves do NOT update old entries. Never 26 # value only; never a join key.
28 # use as a join key; node_id is the join key while the
29 # node lives.
30 # 27 #
31 # "publish" (any promotion of a page to head; the diff against the 28 # "publish" (any promotion to head; diff computed BEFORE head is
32 # outgoing head is computed BEFORE head is re-pointed, over the 29 # re-pointed, over the union of both pages' locales, by head_diff --
33 # union of both pages' locales, by the shared diff function also 30 # shared with the backfill):
34 # used by the backfill): 31 # "via" -- "draft" | "revision" (rollback). Always written;
35 # "via" -- "draft" (ordinary publish) | "revision" (rollback 32 # absent means a pre-contract entry.
36 # via restore_revision!). Always written; absent 33 # "title" -- pair, always; "from" null on first publish
37 # means a pre-contract entry. 34 # "author" -- pair, when the byline changed (incl. first publish)
38 # "title" -- pair, always present. "from" is null on a first 35 # "tags" -- pair of arrays, when changed
39 # publish (no outgoing head). 36 # "assets_changed", "template_changed",
40 # "author" -- pair, only when the byline (page.user) changed
41 # "tags" -- pair of arrays, only when changed
42 # "assets_changed", "template_changed"
43 # -- booleans, only when true; page_id links to the
44 # revision for the real diff (never stored here)
45 # "abstract_changed", "body_changed" 37 # "abstract_changed", "body_changed"
46 # -- booleans for the default locale, only when true 38 # -- the last two for the default locale; page_id links
47 # "translation_diff" -- only when any non-default locale differs: 39 # to the revision for the real diff (never stored)
40 # "translation_diff" -- only when a non-default locale differs:
48 # { "<locale>" => { 41 # { "<locale>" => {
49 # "status" -- "added" | "removed" | "changed" 42 # "status" -- "added" | "removed" | "changed"
50 # "title" -- pair; "from" null when added, 43 # "title" -- pair, only when it differs; "from" null when
51 # "to" null when removed 44 # added, "to" null when removed
52 # "abstract_changed", "body_changed" -- booleans, only when 45 # "abstract_changed", "body_changed" -- status "changed" only
53 # true, only for status "changed"
54 # } } 46 # } }
55 # 47 #
56 # "move" (reparenting and/or unique-path change; one entry at the 48 # "move" (reparent and/or path change; one entry at the subtree
57 # subtree root, descendants get none -- a descendant's own zoomed 49 # root, descendants get none):
58 # view will not show path history): 50 # "path" -- pair
59 # "path" -- pair
60 # 51 #
61 # Reserved for the Trash feature, final shape decided there: 52 # "trash" (subtree into the Trash; every head in the subtree is
62 # "demote" (via "trash" | "depublish"; carries the leaving-public- 53 # demoted first; one entry at the root; snapshots the
63 # view snapshot: head presence, final published_at), "trash", 54 # leaving-public-view state, since Trash holds no heads and destroy
64 # "restore_from_trash", "destroy" (final path only; publish-state 55 # can no longer know):
65 # snapshot lives on the entry that removed it from public view). 56 # "path" -- pair; "from" doubles as the restore hint
66 # Whether trash logs one entry or a trash+demote pair is decided 57 # "was_published" -- boolean
67 # with that feature. 58 # "demoted_heads" -- integer count, only when positive
59 # "final_published_at" -- ISO8601 string, only when present
68 # 60 #
69 # Backfilled entries mirror this vocabulary exactly. Their diff 61 # "restore_from_trash" (reparent back to a living node; returns as
70 # content is computed, not guessed (consecutive revisions still 62 # drafts, republication is a separate witnessed act):
71 # exist); only actor (from page.editor) and occurred_at are 63 # "path" -- pair
72 # inferred, and inferred_from names the heuristic per entry
73 # ("from_page_revision", "from_published_at_heuristic").
74 # inferred_from null = witnessed live.
75 # 64 #
76 # The "locale" column is currently written by no verb (it belonged 65 # "destroy" (only from inside the Trash, never with children; the
77 # to the retired translation_destroy) and is retained for backfill 66 # entry is written in the same transaction before the row dies):
78 # or future draft-scoped verbs. 67 # "path" -- final path, flat string (create-symmetric)
68 # "destroyed_descendants" -- integer, only when positive; one entry
69 # at the root, per the subtree rule.
79 # 70 #
80 # This log records; it does not undo. Reversibility stays in 71 # Reserved: "demote" (via "trash" | "depublish") for an explicit
81 # restore_revision! and the revisions system. No IP, session, or 72 # depublish workflow, if ever built.
82 # user agent, ever. Success only -- rejected attempts are not 73 #
83 # logged. 74 # Backfilled entries mirror this vocabulary; diff content is
75 # computed, only actor (page.editor) and occurred_at are inferred,
76 # inferred_from names the heuristic ("from_node_created_at",
77 # "from_page_revision"). Null = witnessed live.
78 #
79 # The "locale" column is written by no verb; retained.
80 #
81 # This log records; it does not undo. No IP, session, or user
82 # agent, ever. Success only.
84 83
85 def self.record!(node:, action:, user: nil, page: nil, locale: nil, 84 def self.record!(node:, action:, user: nil, page: nil, locale: nil,
86 occurred_at: nil, inferred_from: nil, **extra) 85 occurred_at: nil, inferred_from: nil, **extra)
diff --git a/app/models/page.rb b/app/models/page.rb
index e5a8d9d..f33d88d 100644
--- a/app/models/page.rb
+++ b/app/models/page.rb
@@ -11,11 +11,21 @@ class Page < ApplicationRecord
11 11
12 translates :title, :abstract, :body # Globalize2 12 translates :title, :abstract, :body # Globalize2
13 13
14 # Template names render as filesystem paths; only names actually
15 # present in the public template directory are acceptable. Validated
16 # only on change so legacy rows whose template file has since
17 # vanished stay saveable -- valid_template already falls back to
18 # standard_template for those at render time.
19 validates :template_name,
20 :inclusion => { :in => ->(_) { Page.custom_templates } },
21 :allow_blank => true,
22 :if => :template_name_changed?
23
14 # Associations 24 # Associations
15 belongs_to :node, optional: true 25 belongs_to :node, optional: true
16 belongs_to :user, optional: true 26 belongs_to :user, optional: true
17 belongs_to :editor, :class_name => "User", optional: true 27 belongs_to :editor, :class_name => "User", optional: true
18 has_many :related_assets 28 has_many :related_assets, :dependent => :destroy
19 has_many :assets, -> { order("position ASC") }, :through => :related_assets 29 has_many :assets, -> { order("position ASC") }, :through => :related_assets
20 30
21 # Named scopes 31 # Named scopes
@@ -77,7 +87,10 @@ class Page < ApplicationRecord
77 .paginate(:page => page, :per_page => options[:limit]) 87 .paginate(:page => page, :per_page => options[:limit])
78 end 88 end
79 89
80 scope.order("#{options[:order_by]} #{direction}") 90 column = options[:order_by].to_s.sub(/\Apages\./, "")
91 column = "id" unless %w[id published_at created_at updated_at].include?(column)
92
93 scope.order("pages.#{column} #{direction}")
81 .paginate(:page => page, :per_page => options[:limit]) 94 .paginate(:page => page, :per_page => options[:limit])
82 end 95 end
83 96
diff --git a/app/models/user.rb b/app/models/user.rb
index 92ac33a..5e47ae7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,6 +1,10 @@
1require 'digest/sha1' 1require 'digest/sha1'
2 2
3class User < ApplicationRecord 3class User < ApplicationRecord
4 has_secure_password(validations: false)
5
6 alias_method :authenticate_bcrypt, :authenticate
7
4 # Mixins and Plugins 8 # Mixins and Plugins
5 include Authentication 9 include Authentication
6 include Authentication::ByPassword 10 include Authentication::ByPassword
@@ -23,9 +27,30 @@ class User < ApplicationRecord
23 27
24 # Authenticates a user by their login name and unencrypted password. Returns the user or nil. 28 # Authenticates a user by their login name and unencrypted password. Returns the user or nil.
25 def self.authenticate(login, password) 29 def self.authenticate(login, password)
26 return nil if login.blank? || password.blank? 30 return if login.blank? || password.blank?
27 u = find_by_login(login) # need to get the salt 31
28 u && u.authenticated?(password) ? u : nil 32 user = find_by(login: login)
33 return unless user
34
35 user&.authenticate(password)
36 end
37
38 def authenticate(password)
39 if password_digest.present?
40 return self if authenticate_bcrypt(password)
41 return nil
42 end
43
44 return nil unless authenticate_legacy(password)
45
46 transaction do
47 self.password = password
48 self.crypted_password = nil
49 self.salt = nil
50 save!(validate: false)
51 end
52
53 self
29 end 54 end
30 55
31 # TODO: Do we really want to have downcase logins only? 56 # TODO: Do we really want to have downcase logins only?
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
index a9c0512..319530d 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -42,11 +42,12 @@
42 42
43 <div class="dashboard_widget"> 43 <div class="dashboard_widget">
44 <h3>Recent changes</h3> 44 <h3>Recent changes</h3>
45 <ul> 45 <table id="node_action_list">
46 <%= render partial: "nodes/recent_change_item", collection: @recent_changes, as: :node %> 46 <%= render partial: "node_actions/action_row", collection: @actions, as: :action %>
47 </ul> 47 </table>
48 <%= link_to "See all recent changes →", recent_nodes_path %> 48 <%= link_to "See all recent changes →", admin_log_path %>
49 </div> 49 </div>
50
50</div> 51</div>
51 52
52<div id="dashboard_housekeeping"> 53<div id="dashboard_housekeeping">
@@ -67,5 +68,9 @@
67 <%= link_to menu_items_path, class: "action_button" do %> 68 <%= link_to menu_items_path, class: "action_button" do %>
68 <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation 69 <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation
69 <% end %> 70 <% end %>
71 <% trash_count = Node.trash.children.count %>
72 <%= link_to trashed_nodes_path, class: "action_button" do %>
73 <%= icon("trash", library: "tabler", "aria-hidden": true) %> Trash<%= " (#{trash_count})" if trash_count > 0 %>
74 <% end %>
70 </div> 75 </div>
71</div> 76</div>
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index 0856a0f..e220beb 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -7,19 +7,18 @@
7 <%= csrf_meta_tags %> 7 <%= csrf_meta_tags %>
8 8
9 <title><%= "#{params[:controller]} | #{params[:action]}" %></title> 9 <title><%= "#{params[:controller]} | #{params[:action]}" %></title>
10 <%= javascript_tag "var AUTH_TOKEN = #{form_authenticity_token.inspect};" if protect_against_forgery? %>
11 <%= javascript_include_tag 'admin_bundle' %> 10 <%= javascript_include_tag 'admin_bundle' %>
12 <%= tinymce_assets %> 11 <%= tinymce_assets %>
13 <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/admin.css') %>"> 12 <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/admin.css') %>">
14 <script src="<%= mtime_busted_path('/javascripts/admin_search.js') %>"></script> 13 <script src="<%= mtime_busted_path('/javascripts/admin_search.js') %>"></script>
15 <script src="<%= mtime_busted_path('/javascripts/admin_interface.js') %>"></script> 14 <script src="<%= mtime_busted_path('/javascripts/admin_interface.js') %>"></script>
16 <script src="<%= mtime_busted_path('/javascripts/related_assets.js') %>"></script> 15 <script src="<%= mtime_busted_path('/javascripts/related_assets.js') %>"></script>
17 <script> 16 <%= javascript_tag nonce: true do %>
18 var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; 17 var ADMIN_SEARCH_URL = "<%= admin_search_path %>";
19 var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; 18 var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>";
20 var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>"; 19 var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>";
21 var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>"; 20 var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>";
22 </script> 21 <% end %>
23 </head> 22 </head>
24 23
25 <body> 24 <body>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index d7681af..ca867ab 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -18,12 +18,12 @@
18 <%= auto_discovery_link_tag(:atom, '/rss/updates.xml', title: "ATOM") %> 18 <%= auto_discovery_link_tag(:atom, '/rss/updates.xml', title: "ATOM") %>
19 <%= auto_discovery_link_tag(:rss, '/rss/updates.rdf', title: "RSS") %> 19 <%= auto_discovery_link_tag(:rss, '/rss/updates.rdf', title: "RSS") %>
20 20
21 <script> 21 <%= javascript_tag nonce: true do %>
22 (function() { document.addEventListener("DOMContentLoaded", function() { 22 document.addEventListener("DOMContentLoaded", function() {
23 if (localStorage.getItem('override-prefers-color-scheme', false)) 23 if (localStorage.getItem('override-prefers-color-scheme'))
24 document.getElementById("light-mode").checked = true; 24 document.getElementById("light-mode").checked = true;
25 }); })(); 25 });
26 </script> 26 <% end %>
27 27
28 </head> 28 </head>
29 29
diff --git a/app/views/node_actions/_action_row.html.erb b/app/views/node_actions/_action_row.html.erb
new file mode 100644
index 0000000..a9a3b8d
--- /dev/null
+++ b/app/views/node_actions/_action_row.html.erb
@@ -0,0 +1,18 @@
1<tr class="node_action node_action--<%= action.action %>">
2 <td class="node_action_time">
3 <span class="node_action_date"><%= action.occurred_at.strftime("%Y-%m-%d") %></span>
4 <%= verb_icon(action) %>
5 <span class="node_action_clock"><%= action.occurred_at.strftime("%H:%M") %></span>
6 </td>
7 <td class="node_action_body">
8 <%= action_summary(action) %>
9 <% if action.node_id && params[:node_id].blank? %>
10 <%= link_to t("node_actions.node_history"), admin_log_path(:node_id => action.node_id), :class => "node_action_zoom" %>
11 <% end %>
12 <% if action.inferred_from %>
13 <span class="node_action_inferred" title="<%= action.inferred_from %>"><%= t("node_actions.backfilled") %></span>
14 <% end %>
15 <%= render "node_actions/change_details", :action_entry => action if action_details?(action) %>
16 </td>
17</tr>
18
diff --git a/app/views/node_actions/index.html.erb b/app/views/node_actions/index.html.erb
index ee06213..20ef5b3 100644
--- a/app/views/node_actions/index.html.erb
+++ b/app/views/node_actions/index.html.erb
@@ -7,21 +7,7 @@
7<%= will_paginate @actions %> 7<%= will_paginate @actions %>
8 8
9<table id="node_action_list"> 9<table id="node_action_list">
10 <% @actions.each do |action| %> 10 <%= render partial: "node_actions/action_row", collection: @actions, as: :action %>
11 <tr class="node_action node_action--<%= action.action %>">
12 <td class="node_action_time"><%= action.occurred_at.strftime("%Y-%m-%d %H:%M") %></td>
13 <td class="node_action_body">
14 <%= action_summary(action) %>
15 <% if action.node_id && params[:node_id].blank? %>
16 <%= link_to t("node_actions.node_history"), admin_log_path(:node_id => action.node_id), :class => "node_action_zoom" %>
17 <% end %>
18 <% if action.inferred_from %>
19 <span class="node_action_inferred" title="<%= action.inferred_from %>"><%= t("node_actions.backfilled") %></span>
20 <% end %>
21 <%= render "change_details", :action_entry => action if action_details?(action) %>
22 </td>
23 </tr>
24 <% end %>
25</table> 11</table>
26 12
27<%= will_paginate @actions %> 13<%= will_paginate @actions %>
diff --git a/app/views/nodes/destroy.html.erb b/app/views/nodes/destroy.html.erb
deleted file mode 100644
index 065cf1d..0000000
--- a/app/views/nodes/destroy.html.erb
+++ /dev/null
@@ -1,2 +0,0 @@
1<h1>Nodes#destroy</h1>
2<p>Find me in app/views/nodes/destroy.html.erb</p>
diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb
index 38eac84..890d46e 100644
--- a/app/views/nodes/new.html.erb
+++ b/app/views/nodes/new.html.erb
@@ -33,7 +33,7 @@
33 <div class="node_description">Parent</div> 33 <div class="node_description">Parent</div>
34 <div class="node_content"> 34 <div class="node_content">
35 <%= text_field_tag :parent_search_term, @parent&.title %> 35 <%= text_field_tag :parent_search_term, @parent&.title %>
36 <%= hidden_field_tag :parent_id, @parent&.id, data: { unique_name: @parent&.current_unique_name } %> 36 <%= hidden_field_tag :parent_id, @parent&.id, data: { unique_name: @parent&.computed_unique_name } %>
37 <div id="parent_search_results" class="search_results"></div> 37 <div id="parent_search_results" class="search_results"></div>
38 </div> 38 </div>
39 </div> 39 </div>
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index ae25571..af05778 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -46,7 +46,7 @@
46 <% end %> 46 <% end %>
47 47
48 <% unless locked_by_other %> 48 <% unless locked_by_other %>
49 <% if @node.draft && !@node.autosave %> 49 <% if @node.draft && !@node.autosave && !@node.in_trash? && !@node.trash_node? %>
50 <div class="node_info_item"> 50 <div class="node_info_item">
51 <%= button_to 'Publish', publish_node_path(@node), method: :put, 51 <%= button_to 'Publish', publish_node_path(@node), method: :put,
52 form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> 52 form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %>
@@ -62,6 +62,15 @@
62 <% end %> 62 <% end %>
63 </div> 63 </div>
64 <% end %> 64 <% end %>
65 <% unless @node.trash_node? || @node.in_trash? || @node.root? %>
66 <div class="node_info_item">
67 <%= button_to trash_node_path(@node), method: :put,
68 form: { data: { confirm: "Move this page and everything beneath it to the Trash? All published content in it will go offline." }, class: 'button_to destructive' } do %>
69 <%= icon("trash", library: "tabler", "aria-hidden": true) %>
70 Move to Trash
71 <% end %>
72 </div>
73 <% end %>
65 <% end %> 74 <% end %>
66 </div> 75 </div>
67 76
@@ -70,6 +79,48 @@
70 <% end %> 79 <% end %>
71 </div> 80 </div>
72 81
82 <% if @node.in_trash? %>
83 <div class="node_description">Trash</div>
84 <div class="node_content node_info_group">
85 <div class="node_info_group_items">
86 <% if (entry = @node.last_trash_entry) && entry.metadata.dig("path", "from") %>
87 <div class="node_info_item">
88 <span class="node_info_label">Was at</span>
89 <%= entry.metadata.dig("path", "from") %>
90 </div>
91 <% end %>
92 <div class="node_info_item">
93 <span class="node_info_label">Restore to</span>
94 <% suggestion = @node.suggested_restore_parent %>
95 <%= form_tag restore_from_trash_node_path(@node), :method => :put, :class => "aligned_action_row" do %>
96 <div class="restore_picker">
97 <%= text_field_tag :restore_search_term,
98 suggestion && (suggestion.head&.title || suggestion.draft&.title || suggestion.slug),
99 :placeholder => "Search for a new parent…" %>
100 <div id="restore_search_results" class="search_results"></div>
101 </div>
102 <%= hidden_field_tag :parent_id, suggestion&.id %>
103 <%= submit_tag "Restore here", :class => "action_button action_button_no_margin_top" %>
104 <% end %>
105 <span class="field_hint">Restored pages come back unpublished. Republish each page deliberately.</span>
106 </div>
107 </div>
108 <div class="node_info_group_items">
109 <div class="node_info_item">
110 <% doomed_below = @node.descendants.count %>
111 <%= button_to node_path(@node), method: :delete,
112 form: { data: { confirm: doomed_below > 0 ?
113 "Delete this page and the #{doomed_below} pages beneath it permanently? This cannot be undone." :
114 "Delete this page permanently? This cannot be undone." },
115 class: 'button_to destructive' } do %>
116 <%= icon("trash-x", library: "tabler", "aria-hidden": true) %>
117 Delete permanently<%= " (including #{doomed_below} beneath)" if doomed_below > 0 %>
118 <% end %>
119 </div>
120 </div>
121 </div>
122 <% end %>
123
73 <div class="node_description">Translations</div> 124 <div class="node_description">Translations</div>
74 <div class="node_content node_info_group"> 125 <div class="node_content node_info_group">
75 <div class="node_info_group_items"> 126 <div class="node_info_group_items">
@@ -178,7 +229,10 @@
178 </summary> 229 </summary>
179 <ul> 230 <ul>
180 <% @node.pages.order(:revision).each do |page| %> 231 <% @node.pages.order(:revision).each do |page| %>
181 <li><%= link_to "##{page.revision} — #{page.title} (#{page.editor.try(:login)}, #{page.updated_at})", node_revision_path(@node, page) %></li> 232 <li>
233 <%= link_to "##{page.revision} — #{page.title} (#{page.editor.try(:login)}, #{page.updated_at})", node_revision_path(@node, page) %>
234 <span class="revision_lifecycle"><%= revision_lifecycle_badges(@page_actions[page.id]) %></span>
235 </li>
182 <% end %> 236 <% end %>
183 </ul> 237 </ul>
184 </details> 238 </details>
diff --git a/app/views/nodes/trashed.html.erb b/app/views/nodes/trashed.html.erb
new file mode 100644
index 0000000..9a4808e
--- /dev/null
+++ b/app/views/nodes/trashed.html.erb
@@ -0,0 +1,42 @@
1<h1>Trash</h1>
2
3<% if @nodes.empty? %>
4 <p class="field_hint">The Trash is empty.</p>
5<% else %>
6 <%= will_paginate @nodes %>
7 <table id="trashed_node_list">
8 <tr>
9 <th>Title</th>
10 <th>Was at</th>
11 <th>Trashed</th>
12 <th>Actions</th>
13 </tr>
14 <% @nodes.each do |node| %>
15 <% entry = node.last_trash_entry %>
16 <tr>
17 <% doomed_below = node.descendants.count %>
18 <td>
19 <%= link_to (node.draft&.title || node.slug), node_path(node) %><%= " (+ #{doomed_below} beneath)" if doomed_below > 0 %>
20 </td>
21 <td><%= entry&.metadata&.dig("path", "from") %></td>
22 <td>
23 <% if entry %>
24 <%= entry.occurred_at.strftime("%Y-%m-%d %H:%M") %> by <%= entry.actor_name %>
25 <% end %>
26 </td>
27 <td>
28 <%= button_to node_path(node), method: :delete,
29 form: { data: { confirm: doomed_below > 0 ?
30 "Delete \"#{node.draft&.title || node.slug}\" and the #{doomed_below} pages beneath it permanently? This cannot be undone." :
31 "Delete \"#{node.draft&.title || node.slug}\" permanently? This cannot be undone." },
32 class: 'button_to destructive' } do %>
33 <%= icon("trash-x", library: "tabler", "aria-hidden": true) %>
34 Delete permanently
35 <% end %>
36 </td>
37 </tr>
38 <% end %>
39 </table>
40 <%= will_paginate @nodes %>
41 <p class="field_hint">To restore a page, open it and pick a new parent in its Trash section.</p>
42<% end %>
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
index d51d713..1569942 100644
--- a/config/initializers/content_security_policy.rb
+++ b/config/initializers/content_security_policy.rb
@@ -4,26 +4,29 @@
4# See the Securing Rails Applications Guide for more information: 4# See the Securing Rails Applications Guide for more information:
5# https://guides.rubyonrails.org/security.html#content-security-policy-header 5# https://guides.rubyonrails.org/security.html#content-security-policy-header
6 6
7# Rails.application.configure do 7Rails.application.configure do
8# config.content_security_policy do |policy| 8 config.content_security_policy do |policy|
9# policy.default_src :self, :https 9 policy.default_src :self
10# policy.font_src :self, :https, :data 10 policy.script_src :self
11# policy.img_src :self, :https, :data 11 policy.style_src :self, :unsafe_inline
12# policy.object_src :none 12 policy.img_src :self, :data
13# policy.script_src :self, :https 13 policy.font_src :self
14# policy.style_src :self, :https 14 policy.object_src :none
15# # Specify URI for violation reports 15 policy.frame_ancestors :none
16# # policy.report_uri "/csp-violation-report-endpoint" 16 policy.base_uri :self
17# end 17 policy.form_action :self
18# 18 policy.report_uri "/csp_reports"
19# # Generate session nonces for permitted importmap, inline scripts, and inline styles. 19 end
20# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 20
21# config.content_security_policy_nonce_directives = %w(script-src style-src) 21 # Per-request nonce; script-src only. style-src keeps unsafe_inline
22# 22 # deliberately: TinyMCE emits img[style] in body content and the
23# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` 23 # public layout carries style attributes -- CSS injection is a
24# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. 24 # low-yield channel, script-src is where the protection lives.
25# # config.content_security_policy_nonce_auto = true 25 config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
26# 26 config.content_security_policy_nonce_directives = %w[script-src]
27# # Report violations without enforcing the policy. 27
28# # config.content_security_policy_report_only = true 28 # Report-only: nothing blocks. Enforcement is a later, deliberate
29# end 29 # flip once the reports have mapped reality (admin inline scripts,
30 # legacy hotlinked images in old bodies, embeds).
31 config.content_security_policy_report_only = true
32end
diff --git a/config/initializers/error_log.rb b/config/initializers/error_log.rb
new file mode 100644
index 0000000..74117cb
--- /dev/null
+++ b/config/initializers/error_log.rb
@@ -0,0 +1,20 @@
1# Every controller-level exception (the 500s) in one lean file,
2# independent of the base log level -- log/production.log can stay
3# quiet without losing error visibility.
4if Rails.env.production?
5 error_logger = ActiveSupport::Logger.new(Rails.root.join("log", "errors.log"))
6
7 ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*, payload|
8 if (exception = payload[:exception_object])
9 status = ActionDispatch::ExceptionWrapper.status_code_for_exception(exception.class.name)
10 next if status < 500
11
12 error_logger.error(
13 "#{Time.now.iso8601} [#{status}] #{payload[:controller]}##{payload[:action]} #{payload[:path]} " \
14 "-- #{exception.class}: #{exception.message}\n " +
15 Array(exception.backtrace).first(5).join("\n ")
16 )
17 end
18 end
19end
20
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 9de6aac..f64f6cd 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -89,14 +89,14 @@ de:
89 backfilled: "rekonstruiert" 89 backfilled: "rekonstruiert"
90 show_changes: "Änderungen an Übersetzungen" 90 show_changes: "Änderungen an Übersetzungen"
91 view_revision: "Diese Revision ansehen" 91 view_revision: "Diese Revision ansehen"
92 unknown: "%{actor} hat %{action} auf %{subject} aus" 92 unknown: "%{actor} hat %{action} auf %{subject} ausgeführt"
93 publish: "%{actor} hat %{revision} von %{subject} veröffentlicht" 93 publish: "%{actor} hat %{revision} von %{subject} veröffentlicht"
94 publish_rollback: "%{actor} hat %{subject} auf %{revision} zurückgesetzt" 94 publish_rollback: "%{actor} hat %{subject} auf %{revision} zurückgesetzt"
95 publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht" 95 publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht"
96 publish_first_with_author: "%{actor} hat %{subject} zum ersten Mal veröffentlicht (Autor: %{author})" 96 publish_first_with_author: "%{actor} hat %{subject} zum ersten Mal veröffentlicht (Autor: %{author})"
97 create: "%{actor} hat %{subject} unter %{path} angelegt" 97 create: "%{actor} hat %{subject} unter %{path} angelegt"
98 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben" 98 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben"
99 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verschoben" 99 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verworfen"
100 destroy_draft: "%{actor} hat den Entwurf von %{subject} verworfen" 100 destroy_draft: "%{actor} hat den Entwurf von %{subject} verworfen"
101 locale_added: "Übersetzung angelegt, Titel \"%{title}\"" 101 locale_added: "Übersetzung angelegt, Titel \"%{title}\""
102 locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\"" 102 locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\""
@@ -110,3 +110,9 @@ de:
110 detail_tags: "Tags %{from} → %{to}" 110 detail_tags: "Tags %{from} → %{to}"
111 template_changed: "Template geändert" 111 template_changed: "Template geändert"
112 assets_changed: "Bildliste geändert" 112 assets_changed: "Bildliste geändert"
113 trash: "%{actor} hat %{subject} in den Papierkorb verschoben (vorher unter %{from})"
114 restore_from_trash: "%{actor} hat %{subject} aus dem Papierkorb nach %{to} wiederhergestellt"
115 destroy: "%{actor} hat %{subject} endgültig gelöscht (zuletzt unter %{path})"
116 revision_created: "angelegt am %{date} von %{actor}"
117 revision_published: "veröffentlicht am %{date} von %{actor}"
118 revision_restored: "wiederhergestellt am %{date} von %{actor}"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index f1856c1..8a8acc1 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -62,3 +62,9 @@ en:
62 detail_tags: "tags %{from} → %{to}" 62 detail_tags: "tags %{from} → %{to}"
63 template_changed: "template changed" 63 template_changed: "template changed"
64 assets_changed: "attached images changed" 64 assets_changed: "attached images changed"
65 trash: "%{actor} moved %{subject} to the Trash (was at %{from})"
66 restore_from_trash: "%{actor} restored %{subject} from the Trash to %{to}"
67 destroy: "%{actor} permanently deleted %{subject} (last at %{path})"
68 revision_created: "created %{date} by %{actor}"
69 revision_published: "published %{date} by %{actor}"
70 revision_restored: "restored %{date} by %{actor}"
diff --git a/config/routes.rb b/config/routes.rb
index c2b9590..6ddf48c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -9,6 +9,8 @@ Cccms::Application.routes.draw do
9 defaults: { page_path: ['home'] }, 9 defaults: { page_path: ['home'] },
10 constraints: { locale: /de|en/ } 10 constraints: { locale: /de|en/ }
11 11
12 post 'csp_reports' => 'csp_reports#create'
13
12 # All application routes are scoped under an optional two-letter locale 14 # All application routes are scoped under an optional two-letter locale
13 # prefix: /de/... and /en/... Both forms are valid; the prefix is omitted 15 # prefix: /de/... and /en/... Both forms are valid; the prefix is omitted
14 # for the default locale (:de) in generated URLs via default_url_options 16 # for the default locale (:de) in generated URLs via default_url_options
@@ -44,6 +46,7 @@ Cccms::Application.routes.draw do
44 get :mine 46 get :mine
45 get :chapters 47 get :chapters
46 get :sitemap 48 get :sitemap
49 get :trashed
47 end 50 end
48 51
49 member do 52 member do
@@ -53,6 +56,8 @@ Cccms::Application.routes.draw do
53 put :revoke_shared_preview 56 put :revoke_shared_preview
54 put :autosave 57 put :autosave
55 put :revert 58 put :revert
59 put :trash
60 put :restore_from_trash
56 end 61 end
57 62
58 resources :translations, controller: 'page_translations', 63 resources :translations, controller: 'page_translations',
@@ -87,6 +92,7 @@ Cccms::Application.routes.draw do
87 match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get 92 match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get
88 match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get 93 match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get
89 match 'log' => 'node_actions#index', :as => :admin_log, :via => :get 94 match 'log' => 'node_actions#index', :as => :admin_log, :via => :get
95 match 'boom' => 'admin#boom', :as => :admin_boom, :via => :get
90 end 96 end
91 97
92 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete 98 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete
@@ -103,11 +109,11 @@ Cccms::Application.routes.draw do
103 109
104 resource :session 110 resource :session
105 111
106 get 'rss/updates', :to => 'rss#updates', :as => :rss 112 get 'rss/updates', :to => 'rss#updates', :as => :rss, :defaults => { :format => :xml }
107 get 'rss/updates.:format', :to => 'rss#updates', :as => :rss_feed, 113 get 'rss/updates.:format', :to => 'rss#updates', :as => :rss_feed, :defaults => { :format => :xml },
108 :constraints => { :format => /xml|rdf/ } 114 :constraints => { :format => /xml|rdf/ }
109 get 'rss/tags/:tag/updates', :to => 'rss#tag_updates', :as => :rss_tag 115 get 'rss/tags/:tag/updates', :to => 'rss#tag_updates', :as => :rss_tag, :defaults => { :format => :xml }
110 get 'rss/tags/:tag/updates.:format', :to => 'rss#tag_updates', :as => :rss_tag_feed, 116 get 'rss/tags/:tag/updates.:format', :to => 'rss#tag_updates', :as => :rss_tag_feed, :defaults => { :format => :xml },
111 :constraints => { :format => /xml/ } 117 :constraints => { :format => /xml/ }
112 118
113 match 'galleries/*page_path' => 'content#render_gallery', :via => :get 119 match 'galleries/*page_path' => 'content#render_gallery', :via => :get
diff --git a/db/migrate/20260716165542_add_password_digest_to_users.rb b/db/migrate/20260716165542_add_password_digest_to_users.rb
new file mode 100644
index 0000000..df711d7
--- /dev/null
+++ b/db/migrate/20260716165542_add_password_digest_to_users.rb
@@ -0,0 +1,5 @@
1class AddPasswordDigestToUsers < ActiveRecord::Migration[8.1]
2 def change
3 add_column :users, :password_digest, :string
4 end
5end
diff --git a/db/migrate/20260717192043_drop_nested_set_columns_from_nodes.rb b/db/migrate/20260717192043_drop_nested_set_columns_from_nodes.rb
new file mode 100644
index 0000000..697ab77
--- /dev/null
+++ b/db/migrate/20260717192043_drop_nested_set_columns_from_nodes.rb
@@ -0,0 +1,6 @@
1class DropNestedSetColumnsFromNodes < ActiveRecord::Migration[8.1]
2 def change
3 remove_column :nodes, :lft, :integer
4 remove_column :nodes, :rgt, :integer
5 end
6end
diff --git a/lib/authentication.rb b/lib/authentication.rb
index a936589..4d8a5ae 100644
--- a/lib/authentication.rb
+++ b/lib/authentication.rb
@@ -31,7 +31,7 @@ module Authentication
31 end 31 end
32 32
33 def make_token 33 def make_token
34 secure_digest(Time.now, (1..10).map{ rand.to_s }) 34 SecureRandom.hex(20)
35 end 35 end
36 end # class methods 36 end # class methods
37 37
@@ -46,7 +46,6 @@ module Authentication
46 include ModelInstanceMethods 46 include ModelInstanceMethods
47 47
48 # Virtual attribute for the unencrypted password 48 # Virtual attribute for the unencrypted password
49 attr_accessor :password
50 validates_presence_of :password, :if => :password_required? 49 validates_presence_of :password, :if => :password_required?
51 validates_presence_of :password_confirmation, :if => :password_required? 50 validates_presence_of :password_confirmation, :if => :password_required?
52 validates_confirmation_of :password, :if => :password_required? 51 validates_confirmation_of :password, :if => :password_required?
@@ -85,19 +84,22 @@ module Authentication
85 self.class.password_digest(password, salt) 84 self.class.password_digest(password, salt)
86 end 85 end
87 86
88 def authenticated?(password) 87 def authenticate_legacy(password)
89 crypted_password == encrypt(password) 88 crypted_password == encrypt(password)
90 end 89 end
91 90
92 # before filter 91 # before filter
93 def encrypt_password 92 def encrypt_password
94 return if password.blank? 93 return if password.blank?
95 self.salt = self.class.make_token if new_record? 94 return if password_digest.present?
95
96 self.salt ||= self.class.make_token
96 self.crypted_password = encrypt(password) 97 self.crypted_password = encrypt(password)
97 end 98 end
99
98 def password_required? 100 def password_required?
99 crypted_password.blank? || !password.blank? 101 (crypted_password.blank? && password_digest.blank?) || !password.blank?
100 end 102 end
101 end # instance methods 103 end # instance methods
102 end 104 end
103end \ No newline at end of file 105end
diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb
index 352dd3c..7ff5a3a 100644
--- a/lib/ccc_conventions.rb
+++ b/lib/ccc_conventions.rb
@@ -1,6 +1,7 @@
1module CccConventions 1module CccConventions
2 ERFA_PARENT_NAME = "club/erfas" 2 TRASH_SLUG = "trash"
3 CHAOSTREFF_PARENT_NAME = "club/chaostreffs" 3 ERFA_PARENT_NAME = "club/erfas"
4 CHAOSTREFF_PARENT_NAME = "club/chaostreffs"
4 SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze 5 SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze
5 6
6 NODE_KINDS = { 7 NODE_KINDS = {
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js
index 3988016..b7bdc10 100644
--- a/public/javascripts/admin_interface.js
+++ b/public/javascripts/admin_interface.js
@@ -57,6 +57,10 @@ $(document).ready(function () {
57 move_to_search.initialize_search(); 57 move_to_search.initialize_search();
58 } 58 }
59 59
60 if ($("#restore_search_term").length != 0) {
61 restore_search.initialize_search();
62 }
63
60 if ($("#event_node_search_term").length != 0) { 64 if ($("#event_node_search_term").length != 0) {
61 event_search.initialize_search(); 65 event_search.initialize_search();
62 } 66 }
@@ -78,10 +82,8 @@ $(document).ready(function () {
78 }); 82 });
79 83
80 $(document).ajaxSend(function(event, request, settings) { 84 $(document).ajaxSend(function(event, request, settings) {
81 if (typeof(AUTH_TOKEN) == "undefined") return; 85 var meta = document.querySelector("meta[name='csrf-token']");
82 // settings.data is a serialized string like "foo=bar&baz=boink" (or null) 86 if (meta) request.setRequestHeader("X-CSRF-Token", meta.content);
83 settings.data = settings.data || "";
84 settings.data += (settings.data ? "&" : "") + "authenticity_token=" + encodeURIComponent(AUTH_TOKEN);
85 }); 87 });
86 88
87}); 89});
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js
index 7604bb7..b53c931 100644
--- a/public/javascripts/admin_search.js
+++ b/public/javascripts/admin_search.js
@@ -72,24 +72,20 @@ function initSearchPicker(options) {
72 found = false; 72 found = false;
73 for (var i = 0; i < data.length; i++) { 73 for (var i = 0; i < data.length; i++) {
74 (function(node) { 74 (function(node) {
75 var link; 75 var anchor = $("<a>").attr("href", onSelect ? "#" : node.node_path);
76 anchor.append(document.createTextNode(node.title || ""));
77 anchor.append($("<span>", { "class": "result_path" }).text(node.unique_name || ""));
78 var link = $("<p>").append(anchor);
79
76 if (onSelect) { 80 if (onSelect) {
77 link = $( 81 anchor.bind("click", function() {
78 "<p><a href='#'>" + node.title +
79 "<span class='result_path'>" + node.unique_name + "</span></a></p>"
80 );
81 link.find("a").bind("click", function() {
82 onSelect(node); 82 onSelect(node);
83 results.slideUp(); 83 results.slideUp();
84 results.empty(); 84 results.empty();
85 return false; 85 return false;
86 }); 86 });
87 } else {
88 link = $(
89 "<p><a href='" + node.node_path + "'>" + node.title +
90 "<span class='result_path'>" + node.unique_name + "</span></a></p>"
91 );
92 } 87 }
88
93 results.append(link); 89 results.append(link);
94 found = true; 90 found = true;
95 })(data[i]); 91 })(data[i]);
@@ -150,7 +146,9 @@ dashboard_search = {
150 results.append("<p class='search_group_label'>Tags</p>"); 146 results.append("<p class='search_group_label'>Tags</p>");
151 var tag_row = $("<div class='search_tag_row'></div>"); 147 var tag_row = $("<div class='search_tag_row'></div>");
152 data.tags.forEach(function(tag) { 148 data.tags.forEach(function(tag) {
153 tag_row.append("<a class='search_tag_pill' href='" + tag.tag_path + "'>" + tag.name + "</a>"); 149 tag_row.append(
150 $("<a>", { "class": "search_tag_pill" }).attr("href", tag.tag_path).text(tag.name || "")
151 );
154 found = true; 152 found = true;
155 }); 153 });
156 results.append(tag_row); 154 results.append(tag_row);
@@ -159,10 +157,10 @@ dashboard_search = {
159 if (data.nodes.length) { 157 if (data.nodes.length) {
160 results.append("<p class='search_group_label'>Pages</p>"); 158 results.append("<p class='search_group_label'>Pages</p>");
161 data.nodes.forEach(function(node) { 159 data.nodes.forEach(function(node) {
162 results.append( 160 var anchor = $("<a>").attr("href", node.node_path);
163 "<p><a href='" + node.node_path + "'>" + node.title + 161 anchor.append(document.createTextNode(node.title || ""));
164 "<span class='result_path'>" + node.unique_name + "</span></a></p>" 162 anchor.append($("<span>", { "class": "result_path" }).text(node.unique_name || ""));
165 ); 163 results.append($("<p>").append(anchor));
166 found = true; 164 found = true;
167 }); 165 });
168 } 166 }
@@ -264,6 +262,19 @@ move_to_search = {
264 } 262 }
265}; 263};
266 264
265restore_search = {
266 initialize_search : function() {
267 initSearchPicker({
268 inputSelector: "#restore_search_term",
269 resultsSelector: "#restore_search_results",
270 onSelect: function(node) {
271 $("#restore_search_term").val(node.title);
272 $("#parent_id").val(node.node_id);
273 }
274 });
275 }
276};
277
267event_search = { 278event_search = {
268 initialize_search : function() { 279 initialize_search : function() {
269 initSearchPicker({ 280 initSearchPicker({
diff --git a/public/javascripts/related_assets.js b/public/javascripts/related_assets.js
index 803332f..3340fa7 100644
--- a/public/javascripts/related_assets.js
+++ b/public/javascripts/related_assets.js
@@ -15,12 +15,9 @@ related_assets = {
15 renderResults: function(data, results) { 15 renderResults: function(data, results) {
16 var found = false; 16 var found = false;
17 data.forEach(function(asset) { 17 data.forEach(function(asset) {
18 var item = $( 18 var item = $("<a>", { href: "#", "class": "related_asset_result" });
19 "<a href='#' class='related_asset_result'>" + 19 item.append($("<img>", { src: asset.thumb_url, alt: "" }));
20 "<img src='" + asset.thumb_url + "' alt=''>" + 20 item.append($("<span>").text(asset.name || ""));
21 "<span>" + asset.name + "</span>" +
22 "</a>"
23 );
24 item.bind("click", function() { 21 item.bind("click", function() {
25 related_assets.attach(asset.id, createUrl, list); 22 related_assets.attach(asset.id, createUrl, list);
26 return false; 23 return false;
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index c1273c6..0e11747 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -37,10 +37,13 @@ td {
37 depend on this rule for vertical padding. table.node_table td resets 37 depend on this rule for vertical padding. table.node_table td resets
38 padding-top/bottom to 0 explicitly and does not depend on it. */ 38 padding-top/bottom to 0 explicitly and does not depend on it. */
39 39
40input[type=text], textarea { 40input[type=text],
41input[type=password],
42textarea {
41 font-size: 1rem; 43 font-size: 1rem;
42 font-family: Helvetica; 44 font-family: Helvetica;
43 border: 1px solid #989898; 45 border: 1px solid #989898;
46 border-radius: 2px;
44} 47}
45 48
46select { 49select {
@@ -881,6 +884,10 @@ form.button_to button[type="submit"] {
881 margin-top: -5px; 884 margin-top: -5px;
882} 885}
883 886
887.aligned_action_row .action_button.action_button_no_margin_top {
888 margin-top: 0;
889}
890
884/* ============================================================ 891/* ============================================================
885 Page editor / metadata forms 892 Page editor / metadata forms
886 ============================================================ */ 893 ============================================================ */
@@ -1499,15 +1506,27 @@ div#image_browser ul li {
1499 Action log 1506 Action log
1500 ============================================================ */ 1507 ============================================================ */
1501 1508
1509#node_action_list td.node_action_body {
1510 border-bottom: 1px solid #f1f1f1;
1511}
1512
1502#node_action_list td.node_action_time { 1513#node_action_list td.node_action_time {
1503 white-space: nowrap; 1514 white-space: nowrap;
1504 vertical-align: top; 1515 vertical-align: top;
1505 font-family: monospace;
1506 font-size: 0.8em; 1516 font-size: 0.8em;
1507 color: #777; 1517 color: #777;
1508 padding-right: 1em; 1518 padding-right: 1em;
1509} 1519}
1510 1520
1521#node_action_list .node_action_date {
1522 display: block;
1523}
1524
1525#node_action_list .node_action_clock {
1526 float: right;
1527 margin-top: 0.2rem;
1528}
1529
1511#node_action_list td.node_action_body { 1530#node_action_list td.node_action_body {
1512 vertical-align: top; 1531 vertical-align: top;
1513} 1532}
@@ -1524,7 +1543,50 @@ div#image_browser ul li {
1524} 1543}
1525 1544
1526.node_action_details summary { 1545.node_action_details summary {
1546 margin-top: 0.2rem;
1527 cursor: pointer; 1547 cursor: pointer;
1528 font-size: smaller; 1548 font-size: smaller;
1529 color: #777; 1549 color: #777;
1530} 1550}
1551
1552.node_action_icon {
1553 color: #777;
1554 margin-right: 0.35em;
1555 vertical-align: text-top;
1556}
1557
1558.revision_lifecycle {
1559 font-size: 0.85rem;
1560 color: #777;
1561}
1562
1563/* ============================================================
1564 Trash section
1565 ============================================================ */
1566
1567.restore_form {
1568 display: flex;
1569 align-items: center;
1570 gap: 8px;
1571}
1572
1573.restore_picker {
1574 position: relative;
1575}
1576
1577.restore_picker input[type=text] {
1578 width: 22em;
1579}
1580
1581.restore_picker .search_results {
1582 position: absolute;
1583 top: 100%;
1584 left: 0;
1585 right: 0;
1586 z-index: 10;
1587 background: #fff;
1588 border: 1px solid #989898;
1589 border-top: none;
1590 max-height: 20em;
1591 overflow-y: auto;
1592}
diff --git a/test/controllers/content_controller_test.rb b/test/controllers/content_controller_test.rb
index e8bc55f..9731d08 100644
--- a/test/controllers/content_controller_test.rb
+++ b/test/controllers/content_controller_test.rb
@@ -73,8 +73,7 @@ class ContentControllerTest < ActionController::TestCase
73 def test_nonexistant_custom_template_defaults_to_standard_template 73 def test_nonexistant_custom_template_defaults_to_standard_template
74 new_node = create_node_under_root "fnord" 74 new_node = create_node_under_root "fnord"
75 draft = find_or_create_draft(new_node, @user1) 75 draft = find_or_create_draft(new_node, @user1)
76 draft.template_name = "huchibu" 76 draft.update_column(:template_name, "huchibu")
77 draft.save
78 new_node.publish_draft! 77 new_node.publish_draft!
79 78
80 get :render_page, params: { :locale => 'de', :page_path => ["fnord"] } 79 get :render_page, params: { :locale => 'de', :page_path => ["fnord"] }
@@ -105,6 +104,16 @@ class ContentControllerTest < ActionController::TestCase
105 assert_response :success 104 assert_response :success
106 File.write("/tmp/no_fill_response.html", @response.body) 105 File.write("/tmp/no_fill_response.html", @response.body)
107 end 106 end
107
108 test "render_gallery renders for a published page" do
109 node = Node.root.children.create!(:slug => "gallery_render_test")
110 Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => "Galerie") }
111 node.publish_draft!
112
113 get :render_gallery, params: { :page_path => "gallery_render_test", :locale => "de" }
114
115 assert_response :success
116 end
108 117
109 protected 118 protected
110 119
diff --git a/test/controllers/csp_reports_controller_test.rb b/test/controllers/csp_reports_controller_test.rb
new file mode 100644
index 0000000..7dd8c9e
--- /dev/null
+++ b/test/controllers/csp_reports_controller_test.rb
@@ -0,0 +1,8 @@
1require 'test_helper'
2
3class CspReportsControllerTest < ActionController::TestCase
4 test "accepts anonymous reports without CSRF" do
5 post :create, body: '{"csp-report":{"violated-directive":"script-src"}}'
6 assert_response :no_content
7 end
8end
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb
index 5b66bd3..ddc4565 100644
--- a/test/controllers/nodes_controller_test.rb
+++ b/test/controllers/nodes_controller_test.rb
@@ -140,7 +140,7 @@ class NodesControllerTest < ActionController::TestCase
140 :page => { 140 :page => {
141 :title => "Hello", 141 :title => "Hello",
142 :body => "There", 142 :body => "There",
143 :template_name => "Foobar" 143 :template_name => "title_only"
144 } 144 }
145 } 145 }
146 146
@@ -148,7 +148,17 @@ class NodesControllerTest < ActionController::TestCase
148 test_node.reload 148 test_node.reload
149 assert_equal "Hello", test_node.head.title 149 assert_equal "Hello", test_node.head.title
150 assert_equal "There", test_node.head.body 150 assert_equal "There", test_node.head.body
151 assert_equal "Foobar", test_node.head.template_name 151 assert_equal "title_only", test_node.head.template_name
152 end
153
154 def test_update_rejects_a_template_name_not_on_disk
155 test_node = Node.root.children.create! :slug => "test_node"
156 login_as :quentin
157 put :update, params: { :id => test_node.id,
158 :page => { :title => "Hello", :template_name => "Foobar" } }
159
160 test_node.reload
161 assert_not_equal "Foobar", test_node.draft&.template_name
152 end 162 end
153 163
154 test "publish draft with staged_slug unqueal slug" do 164 test "publish draft with staged_slug unqueal slug" do
@@ -417,11 +427,11 @@ class NodesControllerTest < ActionController::TestCase
417 test "show never renders a destroy link for events" do 427 test "show never renders a destroy link for events" do
418 login_as :quentin 428 login_as :quentin
419 node = create_node_with_published_page 429 node = create_node_with_published_page
420 Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) 430 event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour)
421 431
422 get :show, params: { id: node.id } 432 get :show, params: { id: node.id }
423 assert_response :success 433 assert_response :success
424 assert_select "form.button_to.destructive", count: 0 434 assert_select "form[action=?]", event_path(event), count: 0
425 end 435 end
426 436
427 test "reverting from nodes#show returns to the show page, not the editor, even if a draft remains" do 437 test "reverting from nodes#show returns to the show page, not the editor, even if a draft remains" do
@@ -469,7 +479,8 @@ class NodesControllerTest < ActionController::TestCase
469 login_as :quentin 479 login_as :quentin
470 get :show, params: { :id => node.id } 480 get :show, params: { :id => node.id }
471 assert_response :success 481 assert_response :success
472 assert_select "form.destructive", :count => 0 482 assert_select "form[action=?]", revert_node_path(node), count: 0
483 assert_select "form[action=?]", trash_node_path(node), count: 1
473 end 484 end
474 485
475 test "drafts includes a never-published node with only a draft" do 486 test "drafts includes a never-published node with only a draft" do
@@ -662,4 +673,88 @@ class NodesControllerTest < ActionController::TestCase
662 assert_equal "Brand New", action.metadata["title"] 673 assert_equal "Brand New", action.metadata["title"]
663 assert_equal Node.last.unique_name, action.metadata["path"] 674 assert_equal Node.last.unique_name, action.metadata["path"]
664 end 675 end
676
677 test "trash moves the node and redirects to the Trash" do
678 login_as :quentin
679 node = Node.root.children.create!(:slug => "trash_me")
680
681 put :trash, params: { :id => node.id }
682
683 assert_redirected_to trashed_nodes_path
684 assert node.reload.in_trash?
685 end
686
687 test "trashing the Trash node itself is refused" do
688 login_as :quentin
689
690 put :trash, params: { :id => Node.trash.id }
691
692 assert_redirected_to node_path(Node.trash)
693 assert flash[:error].present?
694 end
695
696 test "restore_from_trash reparents to the given parent" do
697 login_as :quentin
698 node = Node.root.children.create!(:slug => "restore_me")
699 node.trash!(users(:quentin))
700 target = Node.root.children.create!(:slug => "restore_home")
701
702 put :restore_from_trash, params: { :id => node.id, :parent_id => target.id }
703
704 assert_redirected_to node_path(node)
705 assert_equal target, node.reload.parent
706 end
707
708 test "destroy refuses a node outside the Trash" do
709 login_as :quentin
710 node = Node.root.children.create!(:slug => "not_deletable_here")
711
712 delete :destroy, params: { :id => node.id }
713
714 assert Node.exists?(node.id)
715 assert flash[:error].present?
716 end
717
718 test "destroy deletes a trashed node and redirects to the Trash" do
719 login_as :quentin
720 node = Node.root.children.create!(:slug => "deletable")
721 node.trash!(users(:quentin))
722
723 delete :destroy, params: { :id => node.id }
724
725 assert_not Node.exists?(node.id)
726 assert_redirected_to trashed_nodes_path
727 end
728
729 test "trash lists trashed subtree roots" do
730 login_as :quentin
731 node = Node.root.children.create!(:slug => "listed_in_trash")
732 node.trash!(users(:quentin))
733
734 get :trashed
735 assert_response :success
736 assert_select "a[href=?]", node_path(node)
737 end
738
739 test "trashed rows carry provenance and a delete for childless roots" do
740 login_as :quentin
741 node = Node.root.children.create!(:slug => "provenance_test")
742 node.trash!(users(:quentin))
743
744 get :trashed
745 assert_select "td", /quentin/
746 assert_select "form[action=?]", node_path(node), count: 1
747 end
748
749test "show annotates history rows with their lifecycle" do
750 login_as :quentin
751 node = Node.root.children.create!(:slug => "history_annotation_test")
752 Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => "Annotated") }
753 node.publish_draft!(users(:quentin))
754
755 get :show, params: { :id => node.id }
756
757 assert_response :success
758 assert_select "span.revision_lifecycle", /quentin/
759 end
665end 760end
diff --git a/test/fixtures/nodes.yml b/test/fixtures/nodes.yml
index 9ca325e..e94f10f 100644
--- a/test/fixtures/nodes.yml
+++ b/test/fixtures/nodes.yml
@@ -2,8 +2,6 @@
2 2
3root: 3root:
4 id: 1 4 id: 1
5 lft: 1
6 rgt: 10
7 parent_id: 5 parent_id:
8 slug: 6 slug:
9 unique_name: 7 unique_name:
@@ -11,8 +9,6 @@ root:
11 9
12first_child: 10first_child:
13 id: 2 11 id: 2
14 lft: 2
15 rgt: 3
16 parent_id: 1 12 parent_id: 1
17 draft_id: 100 13 draft_id: 100
18 slug: first_child 14 slug: first_child
@@ -20,8 +16,6 @@ first_child:
20 16
21second_child: 17second_child:
22 id: 3 18 id: 3
23 lft: 4
24 rgt: 5
25 parent_id: 1 19 parent_id: 1
26 draft_id: 101 20 draft_id: 101
27 slug: second_child 21 slug: second_child
@@ -29,8 +23,6 @@ second_child:
29 23
30third_child: 24third_child:
31 id: 4 25 id: 4
32 lft: 6
33 rgt: 7
34 parent_id: 1 26 parent_id: 1
35 draft_id: 102 27 draft_id: 102
36 slug: third_child 28 slug: third_child
@@ -38,8 +30,6 @@ third_child:
38 30
39fourth_child: 31fourth_child:
40 id: 5 32 id: 5
41 lft: 8
42 rgt: 9
43 parent_id: 1 33 parent_id: 1
44 slug: fourth_child 34 slug: fourth_child
45 unique_name: fourth_child 35 unique_name: fourth_child
diff --git a/test/integration/csp_header_test.rb b/test/integration/csp_header_test.rb
new file mode 100644
index 0000000..73707ed
--- /dev/null
+++ b/test/integration/csp_header_test.rb
@@ -0,0 +1,12 @@
1require 'test_helper'
2
3class CspHeaderTest < ActionDispatch::IntegrationTest
4 test "public responses carry the report-only CSP header with a nonce" do
5 get "/"
6
7 header = response.headers["Content-Security-Policy-Report-Only"]
8 assert header.present?
9 assert_includes header, "script-src"
10 assert_includes header, "nonce-"
11 end
12end
diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb
index 54c4c22..d747171 100644
--- a/test/models/concerns/rrule_humanizer_test.rb
+++ b/test/models/concerns/rrule_humanizer_test.rb
@@ -114,4 +114,11 @@ class RruleHumanizerTest < ActiveSupport::TestCase
114 test "wday_abbr falls back to :de for an unrecognized locale" do 114 test "wday_abbr falls back to :de for an unrecognized locale" do
115 assert_equal "Mo", RruleHumanizer.wday_abbr(Time.parse("2026-07-06"), :fr) 115 assert_equal "Mo", RruleHumanizer.wday_abbr(Time.parse("2026-07-06"), :fr)
116 end 116 end
117
118 test "monthly two selected weeks" do
119 assert_equal "Jeden ersten und dritten Dienstag im Monat",
120 humanize("FREQ=MONTHLY;BYDAY=1TU,3TU")
121 assert_equal "Every first and third Tuesday of the month",
122 humanize("FREQ=MONTHLY;BYDAY=1TU,3TU", :en)
123 end
117end 124end
diff --git a/test/models/helpers/node_actions_helper_test.rb b/test/models/helpers/node_actions_helper_test.rb
index 5244c19..1b72ec9 100644
--- a/test/models/helpers/node_actions_helper_test.rb
+++ b/test/models/helpers/node_actions_helper_test.rb
@@ -10,6 +10,10 @@ class NodeActionsHelperTest < ActionView::TestCase
10 { :locale => nil } 10 { :locale => nil }
11 end 11 end
12 12
13 def icon(_name, **)
14 '<svg class="stub-icon"></svg>'.html_safe
15 end
16
13 def teardown 17 def teardown
14 I18n.locale = @original_locale 18 I18n.locale = @original_locale
15 end 19 end
@@ -104,4 +108,39 @@ class NodeActionsHelperTest < ActionView::TestCase
104 assert_includes action_summary(e), "a/b" 108 assert_includes action_summary(e), "a/b"
105 assert_not action_details?(e) 109 assert_not action_details?(e)
106 end 110 end
111
112 test "trash, restore, and destroy render their paths" do
113 trash = entry("trash", { "path" => { "from" => "club/old", "to" => "trash/old" } })
114 restore = entry("restore_from_trash", { "path" => { "from" => "trash/old", "to" => "club/new" } })
115 doomed = entry("destroy", { "path" => "trash/old" })
116
117 assert_includes action_summary(trash), "club/old"
118 assert_includes action_summary(restore), "club/new"
119 assert_includes action_summary(doomed), "trash/old"
120 end
121
122 test "revision lifecycle badges cover create, publish, rollback, and inference" do
123 created = entry("create", { "title" => "x", "path" => "a/b" })
124 published = entry("publish", { "via" => "draft", "title" => { "from" => nil, "to" => "x" } })
125 restored = entry("publish", { "via" => "revision", "title" => { "from" => "y", "to" => "x" } })
126 restored.update!(:inferred_from => "from_page_revision")
127
128 out = revision_lifecycle_badges([created, published, restored])
129
130 assert_includes out, "created"
131 assert_includes out, "published"
132 assert_includes out, "restored"
133 assert_includes out, "quentin"
134 assert_includes out, "node_action_inferred"
135 assert_includes out, "<span"
136 assert_not_includes out, "&lt;span"
137
138 assert_equal "", revision_lifecycle_badges(nil)
139 end
140
141 test "verb icons map known verbs, distinguish rollbacks, and fall back" do
142 assert_includes verb_icon(entry("trash", { "path" => { "from" => "a", "to" => "t/a" } })), "node_action_icon--trash"
143 assert_includes verb_icon(entry("publish", { "via" => "revision" })), "node_action_icon--history"
144 assert_includes verb_icon(entry("frobnicate")), "node_action_icon--circle-dashed"
145 end
107end 146end
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index c8e49e5..ab66f81 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -761,4 +761,105 @@ class NodeTest < ActiveSupport::TestCase
761 assert_equal "Second", action.metadata.dig("title", "from") 761 assert_equal "Second", action.metadata.dig("title", "from")
762 assert_equal "First", action.metadata.dig("title", "to") 762 assert_equal "First", action.metadata.dig("title", "to")
763 end 763 end
764
765 test "default_template_name rejects values not present in the template directory" do
766 node = Node.root.children.build(:slug => "template_guard_test",
767 :default_template_name => "../../layouts/admin")
768 assert_not node.valid?
769
770 node.default_template_name = "standard_template"
771 assert node.valid?
772 end
773
774 test "destroying a node with children is refused" do
775 parent = Node.root.children.create!(:slug => "destroy_guard_parent")
776 parent.children.create!(:slug => "destroy_guard_child")
777
778 assert_no_difference "Node.count" do
779 assert_not parent.destroy
780 end
781 assert parent.errors[:base].any?
782 end
783
784 test "destroying a childless node leaves no orphaned pages or asset links" do
785 node = create_node_with_published_page
786 page_ids = node.pages.pluck(:id)
787 asset = Asset.create!(:name => "destroy cascade probe",
788 :upload_file_name => "test_image.png",
789 :upload_content_type => "image/png",
790 :upload_file_size => 49854,
791 :upload_updated_at => Time.current)
792 node.head.related_assets.create!(:asset_id => asset.id, :position => 1)
793
794 node.destroy
795
796 assert_equal 0, Page.where(:id => page_ids).count
797 assert_equal 0, RelatedAsset.where(:page_id => page_ids).count
798 end
799
800 test "destroying a node also removes its autosave page" do
801 node = create_node_with_published_page
802 node.lock_for_editing!(@user1)
803 node.autosave!({:title => "in flight"}, @user1)
804 autosave_id = node.autosave_id
805
806 node.destroy
807
808 assert_equal 0, Page.where(:id => autosave_id).count
809 end
810
811 test "Node.trash lazily creates the container exactly once" do
812 assert_difference "Node.count", 1 do
813 Node.trash
814 end
815 assert_no_difference "Node.count" do
816 assert_equal Node.trash, Node.trash
817 end
818 assert Node.trash.trash_node?
819 assert_not Node.trash.in_trash?
820 assert_equal "Trash", Globalize.with_locale(I18n.default_locale) { Node.trash.draft.title }
821 end
822
823 test "in_trash? walks the whole parent chain" do
824 child = Node.trash.children.create!(:slug => "trashed_thing")
825 grandchild = child.children.create!(:slug => "trashed_deeper")
826
827 assert child.in_trash?
828 assert grandchild.in_trash?
829 assert_not Node.root.children.create!(:slug => "living_thing").in_trash?
830 end
831
832 test "the reserved slug is refused for other root children, live and staged" do
833 Node.trash
834
835 assert_not Node.root.children.build(:slug => CccConventions::TRASH_SLUG).valid?
836 assert_not Node.root.children.build(:slug => "fine", :staged_slug => CccConventions::TRASH_SLUG).valid?
837 assert Node.trash.children.create!(:slug => "sub").children.build(:slug => CccConventions::TRASH_SLUG).valid?
838 end
839
840 test "the Trash node refuses rename, move, and destroy" do
841 trash = Node.trash
842 other = Node.root.children.create!(:slug => "not_the_trash")
843
844 trash.slug = "recycling"
845 assert_not trash.valid?
846
847 trash.reload.parent_id = other.id
848 assert_not trash.valid?
849
850 assert_no_difference "Node.count" do
851 assert_not trash.reload.destroy
852 end
853 end
854
855 test "a head cannot exist inside the Trash, and publishing there is refused" do
856 node = Node.trash.children.create!(:slug => "no_publish_here")
857 page = node.pages.create!
858
859 node.head = page
860 assert_not node.valid?
861
862 node.reload
863 assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft! }
864 end
764end 865end
diff --git a/test/models/node_trash_test.rb b/test/models/node_trash_test.rb
new file mode 100644
index 0000000..52069d7
--- /dev/null
+++ b/test/models/node_trash_test.rb
@@ -0,0 +1,168 @@
1require "test_helper"
2
3class NodeTrashTest < ActiveSupport::TestCase
4
5 def setup
6 @user1 = User.create!(:login => "trasher", :email => "t@example.com",
7 :password => "foobar", :password_confirmation => "foobar")
8 end
9
10 test "trash! demotes the head into the draft slot when no draft exists" do
11 node = create_node_with_published_page
12 former_head = node.head
13
14 node.trash!(@user1)
15 node.reload
16
17 assert_nil node.head_id
18 assert_equal former_head, node.draft
19 assert node.in_trash?
20 end
21
22 test "trash! keeps an existing draft; the former head stays a plain revision" do
23 node = create_node_with_published_page
24 draft = find_or_create_draft(node, @user1)
25 former_head = node.head
26
27 node.trash!(@user1)
28 node.reload
29
30 assert_nil node.head_id
31 assert_equal draft, node.draft
32 assert_includes node.pages, former_head
33 end
34
35 test "trash! demotes descendant heads and counts them in the log entry" do
36 parent = create_node_with_published_page
37 child = parent.children.create!(:slug => "trashed_child")
38 child.draft.update!(:title => "Child")
39 child.publish_draft!(@user1)
40
41 parent.trash!(@user1)
42
43 action = NodeAction.where(:action => "trash").last
44 assert_equal parent, action.node
45 assert_equal 2, action.metadata["demoted_heads"]
46 assert action.metadata["was_published"]
47 assert_nil child.reload.head_id
48 assert child.in_trash?
49 end
50
51 test "trash! logs one entry with the path pair and updates unique names" do
52 node = create_node_with_published_page
53 path_before = node.unique_name
54
55 assert_difference "NodeAction.count", 1 do
56 node.trash!(@user1)
57 end
58
59 action = NodeAction.last
60 assert_equal path_before, action.metadata.dig("path", "from")
61 assert_equal node.reload.unique_name, action.metadata.dig("path", "to")
62 assert action.metadata.dig("path", "to").start_with?(CccConventions::TRASH_SLUG)
63 end
64
65 test "trash! on an already trashed node is a logged-nothing no-op" do
66 node = create_node_with_published_page
67 node.trash!(@user1)
68
69 assert_no_difference "NodeAction.count" do
70 assert_nil node.reload.trash!(@user1)
71 end
72 end
73
74 test "restore_from_trash! reparents as drafts without republishing" do
75 node = create_node_with_published_page
76 node.trash!(@user1)
77 target = Node.root.children.create!(:slug => "restore_target")
78
79 node.reload.restore_from_trash!(target, @user1)
80 node.reload
81
82 assert_equal target, node.parent
83 assert_not node.in_trash?
84 assert_nil node.head_id
85 assert_not_nil node.draft_id
86 assert_equal "restore_from_trash", NodeAction.last.action
87 end
88
89 test "restore_from_trash! refuses targets inside the Trash and the Trash itself" do
90 node = create_node_with_published_page
91 node.trash!(@user1)
92 other_trashed = Node.trash.children.create!(:slug => "also_trashed")
93
94 assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(Node.trash, @user1) }
95 assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(other_trashed, @user1) }
96 end
97
98 test "destroy_from_trash! refuses nodes outside the Trash" do
99 node = create_node_with_published_page
100
101 assert_raises(ActiveRecord::RecordInvalid) { node.destroy_from_trash!(@user1) }
102 assert Node.exists?(node.id)
103 end
104
105 test "destroy_from_trash! removes the whole subtree bottom-up with one entry" do
106 parent = create_node_with_published_page
107 child = parent.children.create!(:slug => "doomed_child")
108 grandchild = child.children.create!(:slug => "doomed_grandchild")
109 page_ids = [parent, child, grandchild].flat_map { |n| n.pages.pluck(:id) }
110 parent.trash!(@user1)
111
112 assert_difference "NodeAction.count", 1 do
113 parent.reload.destroy_from_trash!(@user1)
114 end
115
116 assert_not Node.exists?(parent.id)
117 assert_not Node.exists?(child.id)
118 assert_not Node.exists?(grandchild.id)
119 assert_equal 0, Page.where(:id => page_ids).count
120
121 action = NodeAction.where(:action => "destroy").last
122 assert_equal 2, action.metadata["destroyed_descendants"]
123 assert_nil action.node_id
124 end
125
126 test "destroy_from_trash! logs an entry that survives the node" do
127 node = create_node_with_published_page
128 Globalize.with_locale(I18n.default_locale) { node.head.update!(:title => "Doomed") }
129 node.trash!(@user1)
130 final_path = node.reload.unique_name
131
132 node.destroy_from_trash!(@user1)
133
134 action = NodeAction.where(:action => "destroy").last
135 assert_nil action.node_id
136 assert_equal final_path, action.metadata["path"]
137 assert_equal "Doomed", action.subject_name
138 assert_equal @user1, action.user
139 end
140
141 test "suggested_restore_parent finds the old parent while it lives" do
142 parent = Node.root.children.create!(:slug => "old_home")
143 node = parent.children.create!(:slug => "comes_back")
144 node.trash!(@user1)
145
146 assert_equal parent, node.reload.suggested_restore_parent
147
148 parent.trash!(@user1)
149 assert_nil node.reload.suggested_restore_parent
150 end
151
152 test "suggested_restore_parent is root for trashed top-level nodes" do
153 node = Node.root.children.create!(:slug => "was_top_level")
154 node.trash!(@user1)
155
156 assert_equal Node.root, node.reload.suggested_restore_parent
157 end
158
159 test "trashed nodes and the Trash itself stay out of the drafts surfaces" do
160 Node.trash
161 node = create_node_with_published_page
162 node.trash!(@user1)
163
164 ids = Node.drafts_and_autosaves.pluck(:id)
165 assert_not_includes ids, Node.trash.id
166 assert_not_includes ids, node.id
167 end
168end
diff --git a/test/models/page_test.rb b/test/models/page_test.rb
index edb7c37..1f924f9 100644
--- a/test/models/page_test.rb
+++ b/test/models/page_test.rb
@@ -370,4 +370,39 @@ class PageTest < ActiveSupport::TestCase
370 assert en_entry[:changed] 370 assert en_entry[:changed]
371 refute en_entry[:exists_there] 371 refute en_entry[:exists_there]
372 end 372 end
373
374 test "aggregate ignores order_by values outside the allowlist" do
375 sql = Page.aggregate(:order_by => "pages.id; DROP TABLE pages--").to_sql
376
377 assert_not_includes sql, "DROP"
378 assert_includes sql, "pages.id ASC"
379 end
380
381 test "aggregate accepts allowlisted order columns, bare or prefixed" do
382 assert_includes Page.aggregate(:order_by => "published_at").to_sql,
383 "pages.published_at ASC"
384 assert_includes Page.aggregate(:order_by => "pages.published_at").to_sql,
385 "pages.published_at ASC"
386 end
387
388 test "template_name rejects values not present in the template directory" do
389 page = Page.create!(:title => "Template guard")
390
391 page.template_name = "../../partials/_article"
392 assert_not page.valid?
393
394 page.template_name = "standard_template"
395 assert page.valid?
396
397 page.template_name = ""
398 assert page.valid?
399 end
400
401 test "a stale legacy template_name does not block unrelated saves" do
402 page = Page.create!(:title => "Stale template")
403 page.update_column(:template_name, "long_deleted_template")
404
405 page.reload
406 assert page.update(:abstract => "still saveable")
407 end
373end 408end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index 6e4d2d7..feccce2 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -54,6 +54,78 @@ class UserTest < ActiveSupport::TestCase
54 def test_should_authenticate_user 54 def test_should_authenticate_user
55 assert_equal users(:quentin), User.authenticate('quentin', 'monkey') 55 assert_equal users(:quentin), User.authenticate('quentin', 'monkey')
56 end 56 end
57
58 def test_should_not_authenticate_wrong_password
59 assert_nil User.authenticate("quentin", "wrong password")
60 end
61
62 def test_should_not_authenticate_unknown_user
63 assert_nil User.authenticate("nosuchuser", "monkey")
64 end
65
66 def test_user_with_crypted_password_is_migrated_on_login
67 user = users(:quentin)
68
69 assert_nil user.password_digest
70
71 assert User.authenticate("quentin", "monkey")
72
73 user.reload
74
75 assert_not_nil user.password_digest
76 assert_nil user.crypted_password
77 assert_nil user.salt
78 end
79
80 def test_new_user_uses_password_digest
81 user = create_user
82
83 assert_not_nil user.password_digest
84 assert_nil user.crypted_password
85 assert_nil user.salt
86
87 assert_equal user, User.authenticate("quire", "quire69")
88 end
89
90 def test_legacy_user_is_migrated_on_login
91 user = users(:quentin)
92
93 assert_nil user.password_digest
94 assert_not_nil user.crypted_password
95 assert_not_nil user.salt
96
97 assert_equal user, User.authenticate("quentin", "monkey")
98
99 user.reload
100
101 assert_not_nil user.password_digest
102 assert_nil user.crypted_password
103 assert_nil user.salt
104 end
105
106 def test_migrated_user_authenticates_using_password_digest
107 user = users(:quentin)
108
109 # Trigger automatic migration.
110 assert_equal user, User.authenticate("quentin", "monkey")
111
112 user.reload
113
114 assert_not_nil user.password_digest
115 assert_nil user.crypted_password
116 assert_nil user.salt
117
118 # Second login should now use password_digest.
119 assert_equal user, User.authenticate("quentin", "monkey")
120 end
121
122 def test_migrated_user_can_be_updated_without_password
123 user = users(:quentin)
124 assert_equal user, User.authenticate("quentin", "monkey")
125 user.reload
126
127 assert user.update(:email => "quentin@example.org")
128 end
57 129
58protected 130protected
59 def create_user(options = {}) 131 def create_user(options = {})