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/node_actions_controller.rb14
-rw-r--r--app/controllers/nodes_controller.rb61
-rw-r--r--app/controllers/page_translations_controller.rb3
-rw-r--r--app/controllers/revisions_controller.rb2
-rw-r--r--app/helpers/node_actions_helper.rb183
-rw-r--r--app/models/concerns/nested_tree.rb8
-rw-r--r--app/models/concerns/rrule_humanizer.rb49
-rw-r--r--app/models/node.rb252
-rw-r--r--app/models/node_action.rb154
-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/events/_rrule_builder.html.erb33
-rw-r--r--app/views/events/edit.html.erb28
-rw-r--r--app/views/events/new.html.erb5
-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/_change_details.html.erb27
-rw-r--r--app/views/node_actions/index.html.erb13
-rw-r--r--app/views/nodes/destroy.html.erb2
-rw-r--r--app/views/nodes/new.html.erb4
-rw-r--r--app/views/nodes/show.html.erb64
-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.yml34
-rw-r--r--config/locales/en.yml34
-rw-r--r--config/routes.rb15
-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--lib/tasks/node_actions.rake61
-rw-r--r--public/javascripts/admin_interface.js56
-rw-r--r--public/javascripts/admin_search.js43
-rw-r--r--public/javascripts/related_assets.js9
-rw-r--r--public/stylesheets/admin.css98
-rw-r--r--test/controllers/content_controller_test.rb13
-rw-r--r--test/controllers/csp_reports_controller_test.rb8
-rw-r--r--test/controllers/node_actions_controller_test.rb51
-rw-r--r--test/controllers/nodes_controller_test.rb120
-rw-r--r--test/controllers/page_translations_controller_test.rb11
-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.rb25
-rw-r--r--test/models/helpers/node_actions_helper_test.rb146
-rw-r--r--test/models/node_action_test.rb128
-rw-r--r--test/models/node_test.rb164
-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
57 files changed, 2285 insertions, 204 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/node_actions_controller.rb b/app/controllers/node_actions_controller.rb
new file mode 100644
index 0000000..6e46719
--- /dev/null
+++ b/app/controllers/node_actions_controller.rb
@@ -0,0 +1,14 @@
1class NodeActionsController < ApplicationController
2
3 before_action :login_required
4
5 layout 'admin'
6
7 def index
8 @actions = NodeAction.order(:occurred_at => :desc, :id => :desc)
9 @actions = @actions.where(:node_id => params[:node_id]) if params[:node_id].present?
10 @actions = @actions.where(:user_id => params[:user_id]) if params[:user_id].present?
11 @actions = @actions.includes(:node, :user)
12 .paginate(:page => params[:page], :per_page => 50)
13 end
14end
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index ed5f8ef..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]
@@ -27,12 +29,7 @@ class NodesController < ApplicationController
27 def new 29 def new
28 @node = Node.new node_params 30 @node = Node.new node_params
29 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 31 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
30 if params.has_key?(:parent_id) 32 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id)
31 @parent_id = params[:parent_id]
32 parent = Node.find(@parent_id)
33 @parent_name = parent.title
34 @parent_unique_name = parent.current_unique_name
35 end
36 end 33 end
37 34
38 def create 35 def create
@@ -50,14 +47,14 @@ class NodesController < ApplicationController
50 @node.draft.save! 47 @node.draft.save!
51 48
52 @node.update!(default_template_name: config[:template]) if config && config[:template] 49 @node.update!(default_template_name: config[:template]) if config && config[:template]
50 NodeAction.record!(:node => @node, :page => @node.draft, :user => current_user,
51 :action => "create",
52 :title => params[:title], :path => @node.unique_name)
53 53
54 redirect_to(edit_node_path(@node)) 54 redirect_to(edit_node_path(@node))
55 else 55 else
56 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 56 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
57 if params[:parent_id].present? 57 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id)
58 @parent_id = params[:parent_id]
59 @parent_name = Node.find(@parent_id).title
60 end
61 render :new 58 render :new
62 end 59 end
63 end 60 end
@@ -65,6 +62,9 @@ class NodesController < ApplicationController
65 def show 62 def show
66 @page = @node.draft || @node.head 63 @page = @node.draft || @node.head
67 @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)
68 end 68 end
69 69
70 def edit 70 def edit
@@ -144,8 +144,40 @@ class NodesController < ApplicationController
144 redirect_to node_path(@node) 144 redirect_to node_path(@node)
145 end 145 end
146 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
147 def destroy 173 def destroy
148 @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)
149 end 181 end
150 182
151 def publish 183 def publish
@@ -219,6 +251,11 @@ class NodesController < ApplicationController
219 @sitemap_descendant_counts = descendant_counts_for(@sitemap) 251 @sitemap_descendant_counts = descendant_counts_for(@sitemap)
220 end 252 end
221 253
254 def trashed
255 @nodes = Node.trash.children.order(:slug)
256 .paginate(:page => params[:page], :per_page => 50)
257 end
258
222 private 259 private
223 260
224 def slug_for(title) 261 def slug_for(title)
diff --git a/app/controllers/page_translations_controller.rb b/app/controllers/page_translations_controller.rb
index be4f488..38a7c4f 100644
--- a/app/controllers/page_translations_controller.rb
+++ b/app/controllers/page_translations_controller.rb
@@ -63,9 +63,6 @@ class PageTranslationsController < ApplicationController
63 draft.translations.where(:locale => @locale).delete_all 63 draft.translations.where(:locale => @locale).delete_all
64 draft.reload 64 draft.reload
65 65
66 NodeAction.record!(:node => @node, :page => draft, :user => current_user,
67 :action => "translation_destroy", :locale => @locale)
68
69 flash[:notice] = "#{@locale.upcase} translation removed from the draft. Publish to make this permanent." 66 flash[:notice] = "#{@locale.upcase} translation removed from the draft. Publish to make this permanent."
70 redirect_to node_path(@node) 67 redirect_to node_path(@node)
71 rescue LockedByAnotherUser => e 68 rescue LockedByAnotherUser => e
diff --git a/app/controllers/revisions_controller.rb b/app/controllers/revisions_controller.rb
index c5932a4..c1237a9 100644
--- a/app/controllers/revisions_controller.rb
+++ b/app/controllers/revisions_controller.rb
@@ -49,7 +49,7 @@ class RevisionsController < ApplicationController
49 49
50 def restore 50 def restore
51 page = Page.find(params[:id]) 51 page = Page.find(params[:id])
52 page.node.restore_revision! page.revision 52 page.node.restore_revision! page.revision, current_user
53 flash[:notice] = "Revision #{page.revision} restored" 53 flash[:notice] = "Revision #{page.revision} restored"
54 redirect_to node_path(page.node) 54 redirect_to node_path(page.node)
55 end 55 end
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
new file mode 100644
index 0000000..fd8cc36
--- /dev/null
+++ b/app/helpers/node_actions_helper.rb
@@ -0,0 +1,183 @@
1module NodeActionsHelper
2 include ERB::Util
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
29 # One sentence per entry, rendered from metadata alone, so entries
30 # stay renderable after the rows they reference are gone. Live
31 # associations only upgrade plain names to links. Every metadata
32 # value passes through h() here -- this helper is the escaping
33 # boundary; the locale sentences are trusted, the data never is.
34 def action_summary action
35 renderer = "summarize_#{action.action}"
36 return send(renderer, action) if respond_to?(renderer, true)
37
38 # Unknown-verb fallback: the log outlives the vocabulary. A renamed
39 # or future verb degrades to an ugly sentence, never to a 500.
40 t("node_actions.unknown", :actor => actor_ref(action),
41 :action => h(action.action), :subject => subject_ref(action)).html_safe
42 end
43
44
45 def action_details? action
46 m = action.metadata
47 return true if m["translation_diff"].present?
48 return true if m["title"].is_a?(Hash) && m.dig("title", "from") != m.dig("title", "to")
49 %w[author tags template_changed assets_changed
50 abstract_changed body_changed].any? { |key| m[key].present? }
51 end
52
53 # Plain strings by design -- safe_join in the template escapes them.
54 def default_locale_changes action
55 m = action.metadata
56 items = []
57 if m["title"].is_a?(Hash) && m.dig("title", "from") && m.dig("title", "from") != m.dig("title", "to")
58 items << t("node_actions.detail_title",
59 :from => m.dig("title", "from"), :to => m.dig("title", "to"))
60 end
61 items << t("node_actions.detail_author",
62 :from => m.dig("author", "from"), :to => m.dig("author", "to")) if m["author"]
63 items << t("node_actions.detail_tags",
64 :from => Array(m.dig("tags", "from")).join(", "),
65 :to => Array(m.dig("tags", "to")).join(", ")) if m["tags"]
66 items << t("node_actions.abstract_changed") if m["abstract_changed"]
67 items << t("node_actions.body_changed") if m["body_changed"]
68 items << t("node_actions.template_changed") if m["template_changed"]
69 items << t("node_actions.assets_changed") if m["assets_changed"]
70 items
71 end
72
73 def translation_changes diff
74 case diff["status"]
75 when "added" then [t("node_actions.locale_added", :title => diff.dig("title", "to"))]
76 when "removed" then [t("node_actions.locale_removed", :title => diff.dig("title", "from"))]
77 else
78 items = []
79 items << t("node_actions.detail_title",
80 :from => diff.dig("title", "from"), :to => diff.dig("title", "to")) if diff["title"]
81 items << t("node_actions.abstract_changed") if diff["abstract_changed"]
82 items << t("node_actions.body_changed") if diff["body_changed"]
83 items
84 end
85 end
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
113 private
114
115 def revision_ref action, key
116 label = t(key)
117 return label unless action.node && action.page
118 link_to(label, node_revision_path(action.node, action.page))
119 end
120
121 def actor_ref action
122 action.user ? link_to(h(action.actor_name), admin_log_path(:user_id => action.user_id))
123 : h(action.actor_name)
124 end
125
126 def subject_ref action
127 action.node ? link_to(h(action.subject_name), node_path(action.node))
128 : h(action.subject_name)
129 end
130
131 def summarize_publish action
132 if action.metadata["via"] == "revision"
133 t("node_actions.publish_rollback",
134 :actor => actor_ref(action), :subject => subject_ref(action),
135 :revision => revision_ref(action, "node_actions.revision_earlier")).html_safe
136 elsif action.metadata.dig("title", "from").nil?
137 author = action.metadata.dig("author", "to")
138 key = author ? "node_actions.publish_first_with_author" : "node_actions.publish_first"
139 t(key, :actor => actor_ref(action), :subject => subject_ref(action),
140 :author => h(author)).html_safe
141 else
142 t("node_actions.publish",
143 :actor => actor_ref(action), :subject => subject_ref(action),
144 :revision => revision_ref(action, "node_actions.revision_new")).html_safe
145 end
146 end
147
148 def summarize_move action
149 t("node_actions.move", :actor => actor_ref(action), :subject => subject_ref(action),
150 :from => h(action.metadata.dig("path", "from")),
151 :to => h(action.metadata.dig("path", "to"))).html_safe
152 end
153
154 def summarize_create action
155 t("node_actions.create", :actor => actor_ref(action), :subject => subject_ref(action),
156 :path => h(action.metadata["path"])).html_safe
157 end
158
159 def summarize_discard_autosave action
160 t("node_actions.discard_autosave",
161 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe
162 end
163
164 def summarize_destroy_draft action
165 t("node_actions.destroy_draft",
166 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe
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
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/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb
index 8231de8..07141c1 100644
--- a/app/models/concerns/rrule_humanizer.rb
+++ b/app/models/concerns/rrule_humanizer.rb
@@ -15,8 +15,8 @@ module RruleHumanizer
15 }.freeze 15 }.freeze
16 16
17 ORDINAL_NAMES = { 17 ORDINAL_NAMES = {
18 de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, 18 de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", 5=>"fünften", -1=>"letzten", -2=>"vorletzten" },
19 en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" } 19 en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", 5=>"fifth", -1=>"last", -2=>"second-to-last" }
20 }.freeze 20 }.freeze
21 21
22 MONTH_NAMES = { 22 MONTH_NAMES = {
@@ -35,7 +35,8 @@ module RruleHumanizer
35 ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] 35 ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en]
36 months = MONTH_NAMES[loc] || MONTH_NAMES[:en] 36 months = MONTH_NAMES[loc] || MONTH_NAMES[:en]
37 37
38 days = byday&.split(",")&.map do |d| 38 byday_values = byday&.split(",")
39 days = byday_values&.map do |d|
39 if d =~ /^(-?\d+)([A-Z]{2})$/ 40 if d =~ /^(-?\d+)([A-Z]{2})$/
40 "#{ordinals[$1.to_i]} #{weekdays[$2]}" 41 "#{ordinals[$1.to_i]} #{weekdays[$2]}"
41 else 42 else
@@ -43,6 +44,32 @@ module RruleHumanizer
43 end 44 end
44 end 45 end
45 46
47 excluded_monthly_ordinal = nil
48 excluded_monthly_weekday = nil
49 selected_monthly_ordinals = nil
50 selected_monthly_weekday = nil
51 if freq == "MONTHLY" && byday_values.present?
52 ordinal_days = byday_values.map { |d| d.match(/^([1-5])([A-Z]{2})$/) }
53 if ordinal_days.all?
54 positions = ordinal_days.map { |match| match[1].to_i }.uniq.sort
55 weekdays_in_rule = ordinal_days.map { |match| match[2] }.uniq
56 if weekdays_in_rule.size == 1
57 selected_monthly_ordinals = positions
58 selected_monthly_weekday = weekdays_in_rule.first
59 missing_positions = (1..5).to_a - positions
60 if positions.size == 4 && missing_positions.size == 1
61 excluded_monthly_ordinal = missing_positions.first
62 excluded_monthly_weekday = selected_monthly_weekday
63 end
64 end
65 end
66 end
67
68 join_ordinals = lambda do |ordinal_values, conjunction|
69 names = ordinal_values.map { |ordinal| ordinals[ordinal] }
70 names.size > 1 ? "#{names[0..-2].join(', ')} #{conjunction} #{names.last}" : names.first
71 end
72
46 base = 73 base =
47 case loc 74 case loc
48 when :de 75 when :de
@@ -59,14 +86,26 @@ module RruleHumanizer
59 interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" 86 interval == 2 ? "Alle zwei Wochen" : "Wöchentlich"
60 end 87 end
61 when "MONTHLY" 88 when "MONTHLY"
62 days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" 89 if excluded_monthly_ordinal
90 "Jeden #{weekdays[excluded_monthly_weekday]} im Monat, außer dem #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}"
91 elsif selected_monthly_ordinals
92 "Jeden #{join_ordinals.call(selected_monthly_ordinals, 'und')} #{weekdays[selected_monthly_weekday]} im Monat"
93 else
94 days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich"
95 end
63 end 96 end
64 else 97 else
65 case freq 98 case freq
66 when "WEEKLY" 99 when "WEEKLY"
67 days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") 100 days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly")
68 when "MONTHLY" 101 when "MONTHLY"
69 days ? "Every #{days.join(' and ')} of the month" : "Monthly" 102 if excluded_monthly_ordinal
103 "Every #{weekdays[excluded_monthly_weekday]} of the month, except the #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}"
104 elsif selected_monthly_ordinals
105 "Every #{join_ordinals.call(selected_monthly_ordinals, 'and')} #{weekdays[selected_monthly_weekday]} of the month"
106 else
107 days ? "Every #{days.join(' and ')} of the month" : "Monthly"
108 end
70 end 109 end
71 end 110 end
72 return nil unless base 111 return nil unless base
diff --git a/app/models/node.rb b/app/models/node.rb
index b41df06..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,17 +228,23 @@ 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
235 path_before = self.unique_name
236
197 ActiveRecord::Base.transaction do 237 ActiveRecord::Base.transaction do
198 if self.draft 238 if self.draft
199 previous_title = self.head ? Globalize.with_locale(I18n.default_locale) { self.head.title } : nil 239 outgoing_head = self.head
200 self.head = self.draft 240 self.head = self.draft
201 self.head.published_at ||= Time.now 241 self.head.published_at ||= Time.now
202 self.head.save! 242 self.head.save!
203 self.draft = nil 243 self.draft = nil
204 244
205 new_title = Globalize.with_locale(I18n.default_locale) { self.head.title }
206 NodeAction.record!(:node => self, :page => self.head, :user => current_user, 245 NodeAction.record!(:node => self, :page => self.head, :user => current_user,
207 :action => "publish", :from => previous_title, :to => new_title) 246 :action => "publish", :via => "draft",
247 **NodeAction.head_diff(outgoing_head, self.head))
208 end 248 end
209 249
210 if staged_slug && (staged_slug != slug) 250 if staged_slug && (staged_slug != slug)
@@ -231,20 +271,142 @@ class Node < ApplicationRecord
231 self.reload 271 self.reload
232 self.update_unique_name 272 self.update_unique_name
233 self.send(:update_unique_names_of_children) 273 self.send(:update_unique_names_of_children)
274 if self.unique_name != path_before
275 NodeAction.record!(:node => self, :user => current_user, :action => "move",
276 :path => { "from" => path_before, "to" => self.unique_name })
277 end
234 self.unlock! 278 self.unlock!
235 self 279 self
236 end 280 end
237 end 281 end
238 282
239 def restore_revision! revision 283 def restore_revision! revision, current_user = nil
240 if page = self.pages.find_by_revision(revision) 284 page = self.pages.find_by_revision(revision)
285 return nil unless page
286
287 ActiveRecord::Base.transaction do
288 outgoing_head = self.head
241 self.head = page 289 self.head = page
242 self.save 290 self.save!
243 else 291
244 nil 292 NodeAction.record!(:node => self, :page => page, :user => current_user,
293 :action => "publish", :via => "revision",
294 **NodeAction.head_diff(outgoing_head, page))
295 self
296 end
297 end
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
245 end 364 end
246 end 365 end
247 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
248 # 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
249 def unique_path 411 def unique_path
250 unique_name.to_s.split("/") 412 unique_name.to_s.split("/")
@@ -255,9 +417,12 @@ class Node < ApplicationRecord
255 parent.nil? ? [slug] : parent.path_to_root.push(slug) 417 parent.nil? ? [slug] : parent.path_to_root.push(slug)
256 end 418 end
257 419
420 def computed_unique_name
421 path_to_root[1..-1].join("/") # excluding root
422 end
423
258 def current_unique_name 424 def current_unique_name
259 path = path_to_root[1..-1] # excluding root 425 self.unique_name = computed_unique_name
260 self.unique_name = path.join("/")
261 end 426 end
262 427
263 def update_unique_name 428 def update_unique_name
@@ -297,6 +462,20 @@ class Node < ApplicationRecord
297 autosave || draft || head 462 autosave || draft || head
298 end 463 end
299 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
300 # 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
301 # stay the same eventhough the slug or positions changes. 480 # stay the same eventhough the slug or positions changes.
302 # Can be removed after a year or so ;) 481 # Can be removed after a year or so ;)
@@ -337,7 +516,7 @@ class Node < ApplicationRecord
337 end 516 end
338 517
339 def self.drafts_and_autosaves(current_user_id: nil) 518 def self.drafts_and_autosaves(current_user_id: nil)
340 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
341 return scope.order("updated_at DESC") unless current_user_id 520 return scope.order("updated_at DESC") unless current_user_id
342 521
343 scope.order( 522 scope.order(
@@ -345,6 +524,15 @@ class Node < ApplicationRecord
345 ) 524 )
346 end 525 end
347 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
348 def self.recently_changed 536 def self.recently_changed
349 includes(:head).where( 537 includes(:head).where(
350 "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",
@@ -401,4 +589,36 @@ class Node < ApplicationRecord
401 end 589 end
402 end 590 end
403 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
404end 624end
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index f32b5b7..13bd5ba 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -6,21 +6,157 @@ class NodeAction < ApplicationRecord
6 validates :action, presence: true 6 validates :action, presence: true
7 validates :occurred_at, presence: true 7 validates :occurred_at, presence: true
8 8
9 def self.record!(node:, action:, user: nil, page: nil, locale: nil, **extra) 9 # == Metadata contract ==
10 #
11 # metadata is written once at creation, never updated. It is the
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.
14 # All keys are strings. Pairs are always {"from" => x, "to" => y}.
15 # Optional pairs only when the sides differ; booleans only when
16 # true; required keys always present. Titles and names are read
17 # pinned to I18n.default_locale unless inside a locale-keyed block.
18 #
19 # Baseline, every entry (written here, not by call sites):
20 # "username" -- actor's login at write time
21 # "human_readable_node_name" -- node title, default locale
22 #
23 # "create":
24 # "title" -- initial title, flat string (NOT a pair)
25 # "path" -- unique path at creation, flat string. Historical
26 # value only; never a join key.
27 #
28 # "publish" (any promotion to head; diff computed BEFORE head is
29 # re-pointed, over the union of both pages' locales, by head_diff --
30 # shared with the backfill):
31 # "via" -- "draft" | "revision" (rollback). Always written;
32 # absent means a pre-contract entry.
33 # "title" -- pair, always; "from" null on first publish
34 # "author" -- pair, when the byline changed (incl. first publish)
35 # "tags" -- pair of arrays, when changed
36 # "assets_changed", "template_changed",
37 # "abstract_changed", "body_changed"
38 # -- the last two for the default locale; page_id links
39 # to the revision for the real diff (never stored)
40 # "translation_diff" -- only when a non-default locale differs:
41 # { "<locale>" => {
42 # "status" -- "added" | "removed" | "changed"
43 # "title" -- pair, only when it differs; "from" null when
44 # added, "to" null when removed
45 # "abstract_changed", "body_changed" -- status "changed" only
46 # } }
47 #
48 # "move" (reparent and/or path change; one entry at the subtree
49 # root, descendants get none):
50 # "path" -- pair
51 #
52 # "trash" (subtree into the Trash; every head in the subtree is
53 # demoted first; one entry at the root; snapshots the
54 # leaving-public-view state, since Trash holds no heads and destroy
55 # can no longer know):
56 # "path" -- pair; "from" doubles as the restore hint
57 # "was_published" -- boolean
58 # "demoted_heads" -- integer count, only when positive
59 # "final_published_at" -- ISO8601 string, only when present
60 #
61 # "restore_from_trash" (reparent back to a living node; returns as
62 # drafts, republication is a separate witnessed act):
63 # "path" -- pair
64 #
65 # "destroy" (only from inside the Trash, never with children; the
66 # entry is written in the same transaction before the row dies):
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.
70 #
71 # Reserved: "demote" (via "trash" | "depublish") for an explicit
72 # depublish workflow, if ever built.
73 #
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.
83
84 def self.record!(node:, action:, user: nil, page: nil, locale: nil,
85 occurred_at: nil, inferred_from: nil, **extra)
10 create!( 86 create!(
11 :node => node, 87 :node => node,
12 :page => page, 88 :page => page,
13 :user => user, 89 :user => user,
14 :action => action, 90 :action => action,
15 :locale => locale, 91 :locale => locale,
16 :occurred_at => Time.now, 92 :occurred_at => occurred_at || Time.now,
17 :metadata => { 93 :inferred_from => inferred_from,
94 :metadata => {
18 "username" => user&.login, 95 "username" => user&.login,
19 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) { 96 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
20 node&.head&.title || node&.draft&.title 97 node&.head&.title || node&.draft&.title
21 }, 98 },
22 }.merge(extra.stringify_keys) 99 }.merge(extra.stringify_keys)
23 ) 100 )
101 end
102
103 # Computes the publish-entry diff between an outgoing head and the
104 # page replacing it, in the exact metadata shape the contract above
105 # specifies. Pure function of its two arguments -- shared verbatim by
106 # publish_draft!, restore_revision!, and the backfill task. Reads
107 # translation rows directly, never locale-dependent accessors, so a
108 # fallback value is never mistaken for real content. Returns
109 # symbol-keyed top level for splatting into record!; nested keys are
110 # strings and jsonb serialization stringifies the rest at write time.
111 def self.head_diff old_page, new_page
112 default = I18n.default_locale
113
114 title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title }
115 diff = { :title => { "from" => title_of.call(old_page),
116 "to" => title_of.call(new_page) } }
117 unless old_page
118 diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user
119 return diff
120 end
121
122 old_author, new_author = old_page.user&.login, new_page.user&.login
123 diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author
124
125 old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort
126 diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags
127
128 diff[:template_changed] = true if old_page.template_name != new_page.template_name
129 diff[:assets_changed] = true if old_page.assets.map(&:id) != new_page.assets.map(&:id)
130
131 old_t = old_page.translations.find_by(:locale => default)
132 new_t = new_page.translations.find_by(:locale => default)
133 diff[:abstract_changed] = true if old_t&.abstract != new_t&.abstract
134 diff[:body_changed] = true if old_t&.body != new_t&.body
135
136 locales = (old_page.translated_locales | new_page.translated_locales) - [default]
137 translation_diff = {}
138
139 locales.sort_by(&:to_s).each do |locale|
140 o = old_page.translations.find_by(:locale => locale)
141 n = new_page.translations.find_by(:locale => locale)
142
143 if o.nil?
144 translation_diff[locale.to_s] = { "status" => "added",
145 "title" => { "from" => nil, "to" => n.title } }
146 elsif n.nil?
147 translation_diff[locale.to_s] = { "status" => "removed",
148 "title" => { "from" => o.title, "to" => nil } }
149 elsif o.title != n.title || o.abstract != n.abstract || o.body != n.body
150 entry = { "status" => "changed" }
151 entry["title"] = { "from" => o.title, "to" => n.title } if o.title != n.title
152 entry["abstract_changed"] = true if o.abstract != n.abstract
153 entry["body_changed"] = true if o.body != n.body
154 translation_diff[locale.to_s] = entry
155 end
156 end
157
158 diff[:translation_diff] = translation_diff if translation_diff.any?
159 diff
24 end 160 end
25 161
26 def actor_name 162 def actor_name
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/events/_rrule_builder.html.erb b/app/views/events/_rrule_builder.html.erb
new file mode 100644
index 0000000..1ffec0a
--- /dev/null
+++ b/app/views/events/_rrule_builder.html.erb
@@ -0,0 +1,33 @@
1<div id="rrule_builder">
2 <p>
3 <label><%= radio_button_tag "rrule_freq", "weekly", true %> Weekly</label>
4 <label><%= radio_button_tag "rrule_freq", "monthly" %> Monthly</label>
5 </p>
6 <div id="rrule_weekly_options">
7 <p><label><%= check_box_tag "rrule_biweekly" %> Every 2 weeks</label></p>
8 <p>
9 <% %w[MO TU WE TH FR SA SU].each do |code| %>
10 <label><%= check_box_tag "rrule_byday_#{code}" %> <%= RruleHumanizer::WEEKDAY_NAMES_ABBR[:de][code] %></label>
11 <% end %>
12 </p>
13 </div>
14 <div id="rrule_monthly_options" style="display: none;">
15 <p><label><%= check_box_tag "rrule_monthly_ordinal" %> On a specific weekday each month</label></p>
16 <p id="rrule_ordinal_fields" style="display: none;">
17 <%= select_tag "rrule_ordinal", options_for_select([["1.", 1], ["2.", 2], ["3.", 3], ["4.", 4], ["letzter", -1], ["vorletzter", -2], ["Selected weeks", "custom"]]) %>
18 <%= select_tag "rrule_ordinal_day", options_for_select(%w[MO TU WE TH FR SA SU].map { |c| [RruleHumanizer::WEEKDAY_NAMES[:de][c], c] }) %>
19 </p>
20 <p id="rrule_custom_ordinal_fields" style="display: none;">
21 Weeks of the month:
22 <% (1..5).each do |ordinal| %>
23 <label><%= check_box_tag "rrule_custom_ordinal_#{ordinal}" %> <%= ordinal %>.</label>
24 <% end %>
25 </p>
26 </div>
27 <p>
28 <label><%= check_box_tag "rrule_exclude_month" %> Except in one month</label>
29 <%= select_tag "rrule_excluded_month", options_for_select((1..12).map { |m| [RruleHumanizer::MONTH_NAMES[:de][m-1], m] }), style: "display: none;" %>
30 </p>
31 <span class="field_hint">Builds the field below automatically. If your pattern is more complex than this covers, just edit it directly below - these controls won't touch it unless you use them.</span>
32</div>
33<%= f.text_field :rrule, id: "event_rrule" %>
diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb
index b6564a4..4532ff2 100644
--- a/app/views/events/edit.html.erb
+++ b/app/views/events/edit.html.erb
@@ -40,33 +40,7 @@
40 40
41 <div class="node_description">Recurrence</div> 41 <div class="node_description">Recurrence</div>
42 <div class="node_content"> 42 <div class="node_content">
43 <div id="rrule_builder"> 43 <%= render "rrule_builder", f: f %>
44 <p>
45 <label><%= radio_button_tag "rrule_freq", "weekly", true %> Weekly</label>
46 <label><%= radio_button_tag "rrule_freq", "monthly" %> Monthly</label>
47 </p>
48 <div id="rrule_weekly_options">
49 <p><label><%= check_box_tag "rrule_biweekly" %> Every 2 weeks</label></p>
50 <p>
51 <% %w[MO TU WE TH FR SA SU].each do |code| %>
52 <label><%= check_box_tag "rrule_byday_#{code}" %> <%= RruleHumanizer::WEEKDAY_NAMES_ABBR[:de][code] %></label>
53 <% end %>
54 </p>
55 </div>
56 <div id="rrule_monthly_options" style="display: none;">
57 <p><label><%= check_box_tag "rrule_monthly_ordinal" %> On a specific weekday each month</label></p>
58 <p id="rrule_ordinal_fields" style="display: none;">
59 <%= select_tag "rrule_ordinal", options_for_select([["1.", 1], ["2.", 2], ["3.", 3], ["4.", 4], ["letzter", -1], ["vorletzter", -2]]) %>
60 <%= select_tag "rrule_ordinal_day", options_for_select(%w[MO TU WE TH FR SA SU].map { |c| [RruleHumanizer::WEEKDAY_NAMES[:de][c], c] }) %>
61 </p>
62 </div>
63 <p>
64 <label><%= check_box_tag "rrule_exclude_month" %> Except in one month</label>
65 <%= select_tag "rrule_excluded_month", options_for_select((1..12).map { |m| [RruleHumanizer::MONTH_NAMES[:de][m-1], m] }), style: "display: none;" %>
66 </p>
67 <span class="field_hint">Builds the field below automatically. If your pattern is more complex than this covers, just edit it directly below - these controls won't touch it unless you use them.</span>
68 </div>
69 <%= f.text_field :rrule, id: "event_rrule" %>
70 </div> 44 </div>
71 45
72 <div class="node_description">Title</div> 46 <div class="node_description">Title</div>
diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb
index 7a1ee7a..028fce7 100644
--- a/app/views/events/new.html.erb
+++ b/app/views/events/new.html.erb
@@ -21,8 +21,8 @@
21 <div class="node_description">End time</div> 21 <div class="node_description">End time</div>
22 <div class="node_content"><%= f.datetime_select :end_time %></div> 22 <div class="node_content"><%= f.datetime_select :end_time %></div>
23 23
24 <div class="node_description">Rrule</div> 24 <div class="node_description">Recurrence</div>
25 <div class="node_content"><%= f.text_field :rrule %></div> 25 <div class="node_content"><%= render "rrule_builder", f: f %></div>
26 26
27 <div class="node_description">Title</div> 27 <div class="node_description">Title</div>
28 <div class="node_content"> 28 <div class="node_content">
@@ -53,4 +53,3 @@
53 </div> 53 </div>
54 </div> 54 </div>
55<% end %> 55<% end %>
56
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/_change_details.html.erb b/app/views/node_actions/_change_details.html.erb
new file mode 100644
index 0000000..2583e8b
--- /dev/null
+++ b/app/views/node_actions/_change_details.html.erb
@@ -0,0 +1,27 @@
1<details class="node_action_details">
2 <summary><%= t("node_actions.show_changes") %></summary>
3 <table>
4 <% if (default_items = default_locale_changes(action_entry)).any? %>
5 <tr>
6 <th><%= I18n.default_locale.to_s.upcase %></th>
7 <td>
8 <%= safe_join(default_items, ", ") %>
9 <% if action_entry.page && action_entry.node %>
10 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page) %>
11 <% end %>
12 </td>
13 </tr>
14 <% end %>
15 <% (action_entry.metadata["translation_diff"] || {}).each do |locale, diff| %>
16 <tr>
17 <th><%= locale.upcase %></th>
18 <td>
19 <%= safe_join(translation_changes(diff), ", ") %>
20 <% if action_entry.page && action_entry.node %>
21 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page, :locale => locale) %>
22 <% end %>
23 </td>
24 </tr>
25 <% end %>
26 </table>
27</details>
diff --git a/app/views/node_actions/index.html.erb b/app/views/node_actions/index.html.erb
new file mode 100644
index 0000000..20ef5b3
--- /dev/null
+++ b/app/views/node_actions/index.html.erb
@@ -0,0 +1,13 @@
1<h1><%= t("node_actions.heading") %></h1>
2
3<% if params[:node_id].present? || params[:user_id].present? %>
4 <p><%= link_to t("node_actions.show_all"), admin_log_path %></p>
5<% end %>
6
7<%= will_paginate @actions %>
8
9<table id="node_action_list">
10 <%= render partial: "node_actions/action_row", collection: @actions, as: :action %>
11</table>
12
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 49149d5..890d46e 100644
--- a/app/views/nodes/new.html.erb
+++ b/app/views/nodes/new.html.erb
@@ -32,8 +32,8 @@
32 <div id="parent_search_field" style="<%= @selected_kind == "generic" ? "" : "display: none;" %>"> 32 <div id="parent_search_field" style="<%= @selected_kind == "generic" ? "" : "display: none;" %>">
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_name %> 35 <%= text_field_tag :parent_search_term, @parent&.title %>
36 <%= hidden_field_tag :parent_id, @parent_id, data: { unique_name: @parent_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 66f04f9..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">
@@ -170,19 +221,22 @@
170 </div> 221 </div>
171 </div> 222 </div>
172 223
173 <div class="node_description">Revisions</div> 224 <div class="node_description">History</div>
174 <div class="node_content node_info_group"> 225 <div class="node_content node_info_group">
175 <details> 226 <details>
176 <summary> 227 <summary>
177 <%= pluralize(@node.pages.count, 'revision', 'revisions') %> 228 <%= pluralize(@node.pages.count, 'published revision', 'published revisions') %>
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>
185 <p class="revisions_full_history_link"><%= link_to 'Full history (diff / restore)', node_revisions_path(@node) %></p> 239 <p class="revisions_full_history_link"><%= link_to 'Revision history (diff / restore)', node_revisions_path(@node) %> | <%= link_to t("node_actions.heading"), admin_log_path(:node_id => @node.id) %></p>
186 </div> 240 </div>
187 241
188 242
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 74cd183..f64f6cd 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -82,3 +82,37 @@ de:
82 page_gap: "&hellip;" 82 page_gap: "&hellip;"
83 container_aria_label: "Seitennavigation" 83 container_aria_label: "Seitennavigation"
84 page_aria_label: "Seite %{page}" 84 page_aria_label: "Seite %{page}"
85
86 node_actions:
87 heading: "Letzte Änderungen"
88 show_all: "Alle Einträge zeigen"
89 backfilled: "rekonstruiert"
90 show_changes: "Änderungen an Übersetzungen"
91 view_revision: "Diese Revision ansehen"
92 unknown: "%{actor} hat %{action} auf %{subject} ausgeführt"
93 publish: "%{actor} hat %{revision} von %{subject} veröffentlicht"
94 publish_rollback: "%{actor} hat %{subject} auf %{revision} zurückgesetzt"
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})"
97 create: "%{actor} hat %{subject} unter %{path} angelegt"
98 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben"
99 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verworfen"
100 destroy_draft: "%{actor} hat den Entwurf von %{subject} verworfen"
101 locale_added: "Übersetzung angelegt, Titel \"%{title}\""
102 locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\""
103 abstract_changed: "Abstract geändert"
104 body_changed: "Text geändert"
105 revision_new: "eine neue Revision"
106 revision_earlier: "eine frühere Revision"
107 node_history: "Chronik"
108 detail_title: "Titel „%{from}“ → „%{to}“"
109 detail_author: "Autor %{from} → %{to}"
110 detail_tags: "Tags %{from} → %{to}"
111 template_changed: "Template 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 c52280d..8a8acc1 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -34,3 +34,37 @@ en:
34 page_gap: "&hellip;" 34 page_gap: "&hellip;"
35 container_aria_label: "Pagination" 35 container_aria_label: "Pagination"
36 page_aria_label: "Page %{page}" 36 page_aria_label: "Page %{page}"
37
38 node_actions:
39 heading: "Action log"
40 show_all: "Show all entries"
41 backfilled: "backfilled"
42 show_changes: "translation changes"
43 view_revision: "view this revision"
44 unknown: "%{actor} did %{action} on %{subject}"
45 publish: "%{actor} published %{revision} of %{subject}"
46 publish_rollback: "%{actor} rolled %{subject} back to %{revision}"
47 publish_first: "%{actor} published %{subject} for the first time"
48 publish_first_with_author: "%{actor} published %{subject} for the first time (author: %{author})"
49 create: "%{actor} created %{subject} at %{path}"
50 move: "%{actor} moved %{subject} from %{from} to %{to}"
51 discard_autosave: "%{actor} discarded unsaved changes on %{subject}"
52 destroy_draft: "%{actor} discarded the draft of %{subject}"
53 locale_added: "translation added, titled \"%{title}\""
54 locale_removed: "translation removed, last titled \"%{title}\""
55 abstract_changed: "abstract changed"
56 body_changed: "body changed"
57 revision_new: "a new revision"
58 revision_earlier: "an earlier revision"
59 node_history: "node history"
60 detail_title: "title \"%{from}\" → \"%{to}\""
61 detail_author: "author %{from} → %{to}"
62 detail_tags: "tags %{from} → %{to}"
63 template_changed: "template 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 8e5ff23..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',
@@ -86,6 +91,8 @@ Cccms::Application.routes.draw do
86 match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get 91 match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get
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
94 match 'log' => 'node_actions#index', :as => :admin_log, :via => :get
95 match 'boom' => 'admin#boom', :as => :admin_boom, :via => :get
89 end 96 end
90 97
91 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete 98 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete
@@ -102,11 +109,11 @@ Cccms::Application.routes.draw do
102 109
103 resource :session 110 resource :session
104 111
105 get 'rss/updates', :to => 'rss#updates', :as => :rss 112 get 'rss/updates', :to => 'rss#updates', :as => :rss, :defaults => { :format => :xml }
106 get 'rss/updates.:format', :to => 'rss#updates', :as => :rss_feed, 113 get 'rss/updates.:format', :to => 'rss#updates', :as => :rss_feed, :defaults => { :format => :xml },
107 :constraints => { :format => /xml|rdf/ } 114 :constraints => { :format => /xml|rdf/ }
108 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 }
109 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 },
110 :constraints => { :format => /xml/ } 117 :constraints => { :format => /xml/ }
111 118
112 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/lib/tasks/node_actions.rake b/lib/tasks/node_actions.rake
new file mode 100644
index 0000000..fdd286c
--- /dev/null
+++ b/lib/tasks/node_actions.rake
@@ -0,0 +1,61 @@
1namespace :node_actions do
2 desc "Rebuild inferred NodeAction entries from existing revisions and " \
3 "node timestamps. Witnessed entries (inferred_from IS NULL) are " \
4 "never touched; previously inferred ones are deleted and " \
5 "regenerated, so the task is idempotent. Scope with NODE_ID=n."
6 task :backfill => :environment do
7 scope = Node.where.not(:parent_id => nil)
8 scope = scope.where(:id => ENV["NODE_ID"]) if ENV["NODE_ID"]
9
10 created = 0
11
12 ActiveRecord::Base.transaction do
13 stale = NodeAction.where.not(:inferred_from => nil)
14 stale = stale.where(:node_id => ENV["NODE_ID"]) if ENV["NODE_ID"]
15 puts "Removing #{stale.count} previously inferred entries"
16 stale.delete_all
17
18 witnessed_creates = NodeAction.where(:action => "create", :inferred_from => nil).pluck(:node_id).to_set
19 witnessed_promotes = NodeAction.where(:action => "publish", :inferred_from => nil).pluck(:page_id).to_set
20
21 scope.find_each do |node|
22 # Every page row except the current draft was once promoted to
23 # head. Autosaves never carry node_id, so they cannot appear.
24 revisions = node.pages.to_a.reject { |page| page.id == node.draft_id }
25 first_page = node.pages.first
26
27 unless witnessed_creates.include?(node.id)
28 NodeAction.record!(
29 :node => node, :page => first_page, :user => first_page&.editor,
30 :action => "create",
31 :occurred_at => node.created_at,
32 :inferred_from => "from_node_created_at",
33 :title => first_page&.translations&.find_by(:locale => I18n.default_locale)&.title,
34 :path => node.unique_name,
35 :human_readable_node_name =>
36 first_page&.translations&.find_by(:locale => I18n.default_locale)&.title)
37 created += 1
38 end
39
40 previous = nil
41 revisions.each do |page|
42 unless witnessed_promotes.include?(page.id)
43 diff = NodeAction.head_diff(previous, page)
44 NodeAction.record!(
45 :node => node, :page => page, :user => page.editor,
46 :action => "publish",
47 :occurred_at => page.updated_at,
48 :inferred_from => "from_page_revision",
49 :via => "draft",
50 :human_readable_node_name => diff[:title]["to"],
51 **diff)
52 created += 1
53 end
54 previous = page
55 end
56 end
57 end
58
59 puts "Created #{created} inferred entries"
60 end
61end
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js
index 9ce0b9e..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});
@@ -415,8 +417,10 @@ rrule_builder = {
415 }); 417 });
416 $("#rrule_monthly_ordinal").bind("change", function() { 418 $("#rrule_monthly_ordinal").bind("change", function() {
417 $("#rrule_ordinal_fields").toggle($(this).is(":checked")); 419 $("#rrule_ordinal_fields").toggle($(this).is(":checked"));
420 rrule_builder.toggle_custom_ordinal_fields();
418 rrule_builder.sync(); 421 rrule_builder.sync();
419 }); 422 });
423 $("#rrule_ordinal").bind("change", rrule_builder.toggle_custom_ordinal_fields);
420 $("#rrule_exclude_month").bind("change", function() { 424 $("#rrule_exclude_month").bind("change", function() {
421 $("#rrule_excluded_month").toggle($(this).is(":checked")); 425 $("#rrule_excluded_month").toggle($(this).is(":checked"));
422 rrule_builder.sync(); 426 rrule_builder.sync();
@@ -433,6 +437,12 @@ rrule_builder = {
433 $("#rrule_monthly_options").show(); 437 $("#rrule_monthly_options").show();
434 }, 438 },
435 439
440 toggle_custom_ordinal_fields : function() {
441 $("#rrule_custom_ordinal_fields").toggle(
442 $("#rrule_monthly_ordinal").is(":checked") && $("#rrule_ordinal").val() === "custom"
443 );
444 },
445
436 sync : function() { 446 sync : function() {
437 var freq = $("input[name='rrule_freq']:checked").val(); 447 var freq = $("input[name='rrule_freq']:checked").val();
438 var parts = []; 448 var parts = [];
@@ -448,7 +458,16 @@ rrule_builder = {
448 } else { 458 } else {
449 parts.push("FREQ=MONTHLY"); 459 parts.push("FREQ=MONTHLY");
450 if ($("#rrule_monthly_ordinal").is(":checked")) { 460 if ($("#rrule_monthly_ordinal").is(":checked")) {
451 parts.push("BYDAY=" + $("#rrule_ordinal").val() + $("#rrule_ordinal_day").val()); 461 var ordinal = $("#rrule_ordinal").val();
462 if (ordinal === "custom") {
463 var customDays = [];
464 $("input[id^='rrule_custom_ordinal_']:checked").each(function() {
465 customDays.push($(this).attr("id").replace("rrule_custom_ordinal_", "") + $("#rrule_ordinal_day").val());
466 });
467 if (customDays.length > 0) parts.push("BYDAY=" + customDays.join(","));
468 } else {
469 parts.push("BYDAY=" + ordinal + $("#rrule_ordinal_day").val());
470 }
452 } 471 }
453 } 472 }
454 473
@@ -483,8 +502,33 @@ rrule_builder = {
483 } else if (parts.FREQ === "MONTHLY") { 502 } else if (parts.FREQ === "MONTHLY") {
484 $("input[name='rrule_freq'][value='monthly']").prop("checked", true); 503 $("input[name='rrule_freq'][value='monthly']").prop("checked", true);
485 rrule_builder.show_monthly_options(); 504 rrule_builder.show_monthly_options();
505 var ordinalDays = parts.BYDAY && parts.BYDAY.split(",");
506 var customOrdinalDay = null;
507 var customOrdinals = [];
508 var customOrdinalsValid = ordinalDays && ordinalDays.length > 0;
509 if (customOrdinalsValid) {
510 ordinalDays.forEach(function(value) {
511 var customMatch = value.match(/^([1-5])([A-Z]{2})$/);
512 if (!customMatch || (customOrdinalDay && customOrdinalDay !== customMatch[2])) {
513 customOrdinalsValid = false;
514 return;
515 }
516 customOrdinalDay = customMatch[2];
517 customOrdinals.push(customMatch[1]);
518 });
519 }
520
486 var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/); 521 var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/);
487 if (match) { 522 if (customOrdinalsValid && (customOrdinals.length > 1 || customOrdinals[0] === "5")) {
523 $("#rrule_monthly_ordinal").prop("checked", true);
524 $("#rrule_ordinal_fields").show();
525 $("#rrule_ordinal").val("custom");
526 $("#rrule_ordinal_day").val(customOrdinalDay);
527 customOrdinals.forEach(function(ordinal) {
528 $("#rrule_custom_ordinal_" + ordinal).prop("checked", true);
529 });
530 rrule_builder.toggle_custom_ordinal_fields();
531 } else if (match) {
488 $("#rrule_monthly_ordinal").prop("checked", true); 532 $("#rrule_monthly_ordinal").prop("checked", true);
489 $("#rrule_ordinal_fields").show(); 533 $("#rrule_ordinal_fields").show();
490 $("#rrule_ordinal").val(match[1]); 534 $("#rrule_ordinal").val(match[1]);
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 5f0b165..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 ============================================================ */
@@ -1494,3 +1501,92 @@ div#image_browser ul li {
1494 gap: 8px; 1501 gap: 8px;
1495 flex-wrap: wrap; 1502 flex-wrap: wrap;
1496} 1503}
1504
1505/* ============================================================
1506 Action log
1507 ============================================================ */
1508
1509#node_action_list td.node_action_body {
1510 border-bottom: 1px solid #f1f1f1;
1511}
1512
1513#node_action_list td.node_action_time {
1514 white-space: nowrap;
1515 vertical-align: top;
1516 font-size: 0.8em;
1517 color: #777;
1518 padding-right: 1em;
1519}
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
1530#node_action_list td.node_action_body {
1531 vertical-align: top;
1532}
1533
1534.node_action_inferred {
1535 opacity: 0.5;
1536 font-size: smaller;
1537 margin-left: 0.5em;
1538}
1539
1540.node_action_zoom {
1541 font-size: smaller;
1542 margin-left: 0.5em;
1543}
1544
1545.node_action_details summary {
1546 margin-top: 0.2rem;
1547 cursor: pointer;
1548 font-size: smaller;
1549 color: #777;
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/node_actions_controller_test.rb b/test/controllers/node_actions_controller_test.rb
new file mode 100644
index 0000000..8bc68ec
--- /dev/null
+++ b/test/controllers/node_actions_controller_test.rb
@@ -0,0 +1,51 @@
1require 'test_helper'
2
3class NodeActionsControllerTest < ActionController::TestCase
4
5 def setup
6 login_as :quentin
7 end
8
9 def logged_node slug, title
10 node = Node.root.children.create!(:slug => slug)
11 Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => title) }
12 node
13 end
14
15 test "index renders" do
16 get :index
17 assert_response :success
18 end
19
20 test "index filters by node" do
21 node_a = logged_node("log_filter_a", "Alpha Subject")
22 node_b = logged_node("log_filter_b", "Beta Subject")
23 NodeAction.record!(:node => node_a, :action => "create", :user => users(:quentin))
24 NodeAction.record!(:node => node_b, :action => "create", :user => users(:quentin))
25
26 get :index, params: { :node_id => node_a.id }
27
28 assert_response :success
29 assert_includes @response.body, "Alpha Subject"
30 assert_not_includes @response.body, "Beta Subject"
31 end
32
33 test "index filters by user" do
34 node = logged_node("log_filter_user", "Gamma Subject")
35 NodeAction.record!(:node => node, :action => "create", :user => users(:quentin))
36
37 get :index, params: { :user_id => users(:quentin).id }
38 assert_includes @response.body, "Gamma Subject"
39
40 other = User.where.not(:id => users(:quentin).id).first
41 get :index, params: { :user_id => other.id }
42 assert_not_includes @response.body, "Gamma Subject"
43 end
44
45 test "index requires login" do
46 session[:user_id] = nil
47 @controller.instance_variable_set(:@current_user, nil)
48 get :index
49 assert_response :redirect
50 end
51end
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb
index 9da9514..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
@@ -647,4 +658,103 @@ class NodesControllerTest < ActionController::TestCase
647 assert_select ".sitemap_node_actions", :text => /Create Child/ 658 assert_select ".sitemap_node_actions", :text => /Create Child/
648 assert_select ".sitemap_node_actions", :text => /Revisions/, :count => 0 659 assert_select ".sitemap_node_actions", :text => /Revisions/, :count => 0
649 end 660 end
661
662 test "create logs a create NodeAction with path and title" do
663 login_as :quentin
664
665 assert_difference "NodeAction.count" do
666 post :create, params: { :kind => "generic", :title => "Brand New", :parent_id => Node.root.id }
667 end
668
669 action = NodeAction.last
670 assert_equal "create", action.action
671 assert_equal users(:quentin), action.user
672 assert_equal Node.last, action.node
673 assert_equal "Brand New", action.metadata["title"]
674 assert_equal Node.last.unique_name, action.metadata["path"]
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
650end 760end
diff --git a/test/controllers/page_translations_controller_test.rb b/test/controllers/page_translations_controller_test.rb
index 82f8bce..feaacd0 100644
--- a/test/controllers/page_translations_controller_test.rb
+++ b/test/controllers/page_translations_controller_test.rb
@@ -83,18 +83,15 @@ class PageTranslationsControllerTest < ActionController::TestCase
83 assert_equal "in progress", Globalize.with_locale(:en) { node.autosave.title } 83 assert_equal "in progress", Globalize.with_locale(:en) { node.autosave.title }
84 end 84 end
85 85
86 test "destroy logs a translation_destroy NodeAction" do 86 test "destroy removes the translation, logs nothing, and redirects" do
87 login_as :quentin 87 login_as :quentin
88 node = Node.root.children.create!(:slug => "translation_destroy_log_test") 88 node = Node.root.children.create!(:slug => "translation_destroy_log_test")
89 Globalize.with_locale(:en) { node.draft.update!(:title => "English title") } 89 Globalize.with_locale(:en) { node.draft.update!(:title => "English title") }
90 90
91 delete :destroy, params: { :node_id => node.id, :translation_locale => "en" } 91 assert_no_difference "NodeAction.count" do
92 delete :destroy, params: { :node_id => node.id, :translation_locale => "en" }
93 end
92 94
93 action = NodeAction.last
94 assert_equal node, action.node
95 assert_equal "en", action.locale
96 assert_equal "translation_destroy", action.action
97 assert_equal users(:quentin), action.user
98 assert_redirected_to node_path(node) 95 assert_redirected_to node_path(node)
99 end 96 end
100end 97end
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 279ff73..d747171 100644
--- a/test/models/concerns/rrule_humanizer_test.rb
+++ b/test/models/concerns/rrule_humanizer_test.rb
@@ -47,6 +47,24 @@ class RruleHumanizerTest < ActiveSupport::TestCase
47 assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en) 47 assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en)
48 end 48 end
49 49
50 test "monthly selected weeks" do
51 assert_equal "Jeden ersten, zweiten und fünften Dienstag im Monat",
52 humanize("FREQ=MONTHLY;BYDAY=1TU,2TU,5TU")
53 assert_equal "Every first, second and fifth Tuesday of the month",
54 humanize("FREQ=MONTHLY;BYDAY=1TU,2TU,5TU", :en)
55 end
56
57 test "monthly fifth weekday" do
58 assert_equal "Jeden fünften Dienstag im Monat", humanize("FREQ=MONTHLY;BYDAY=5TU")
59 assert_equal "Every fifth Tuesday of the month", humanize("FREQ=MONTHLY;BYDAY=5TU", :en)
60 end
61
62 test "monthly weekday excluding one ordinal" do
63 rrule = "FREQ=MONTHLY;BYDAY=1TU,3TU,4TU,5TU"
64 assert_equal "Jeden Dienstag im Monat, außer dem zweiten Dienstag", humanize(rrule)
65 assert_equal "Every Tuesday of the month, except the second Tuesday", humanize(rrule, :en)
66 end
67
50 test "monthly no byday" do 68 test "monthly no byday" do
51 assert_equal "Monatlich", humanize("FREQ=MONTHLY") 69 assert_equal "Monatlich", humanize("FREQ=MONTHLY")
52 assert_equal "Monthly", humanize("FREQ=MONTHLY", :en) 70 assert_equal "Monthly", humanize("FREQ=MONTHLY", :en)
@@ -96,4 +114,11 @@ class RruleHumanizerTest < ActiveSupport::TestCase
96 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
97 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)
98 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
99end 124end
diff --git a/test/models/helpers/node_actions_helper_test.rb b/test/models/helpers/node_actions_helper_test.rb
new file mode 100644
index 0000000..1b72ec9
--- /dev/null
+++ b/test/models/helpers/node_actions_helper_test.rb
@@ -0,0 +1,146 @@
1require 'test_helper'
2
3class NodeActionsHelperTest < ActionView::TestCase
4 def setup
5 @original_locale = I18n.locale
6 I18n.locale = :en
7 end
8
9 def default_url_options
10 { :locale => nil }
11 end
12
13 def icon(_name, **)
14 '<svg class="stub-icon"></svg>'.html_safe
15 end
16
17 def teardown
18 I18n.locale = @original_locale
19 end
20
21 def entry action, metadata = {}, node: nil, user: nil, page: nil
22 NodeAction.create!(:node => node, :user => user, :page => page,
23 :action => action, :occurred_at => Time.now,
24 :metadata => { "username" => "quentin",
25 "human_readable_node_name" => "Subject" }.merge(metadata))
26 end
27
28 test "publish renders the revision sentence, not the titles" do
29 out = action_summary(entry("publish",
30 { "via" => "draft", "title" => { "from" => "Old", "to" => "New" } }))
31
32 assert_includes out, "quentin"
33 assert_includes out, "new revision"
34 assert_not_includes out, "Old"
35 end
36
37 test "first publish uses its own sentence" do
38 out = action_summary(entry("publish",
39 { "via" => "draft", "title" => { "from" => nil, "to" => "New" } }))
40
41 assert_includes out, "for the first time"
42 end
43
44 test "rollback publishes get the rollback sentence" do
45 out = action_summary(entry("publish",
46 { "via" => "revision", "title" => { "from" => "Now", "to" => "Then" } }))
47
48 assert_includes out, "rolled"
49 end
50
51 test "move renders the path pair" do
52 out = action_summary(entry("move",
53 { "path" => { "from" => "a/b", "to" => "a/c" } }))
54
55 assert_includes out, "a/b"
56 assert_includes out, "a/c"
57 end
58
59 test "unknown verbs degrade to a generic sentence, never an error" do
60 out = action_summary(entry("frobnicate"))
61
62 assert_includes out, "frobnicate"
63 assert_includes out, "quentin"
64 end
65
66 test "dead references render as plain names from metadata, no links" do
67 out = action_summary(entry("publish",
68 { "title" => { "from" => "Old", "to" => "New" } }))
69
70 assert_not_includes out, "<a "
71 assert_includes out, "Subject"
72 end
73
74 test "metadata values are escaped" do
75 out = action_summary(entry("publish",
76 { "human_readable_node_name" => "<b>bold</b>" }))
77
78 assert_not_includes out, "<b>"
79 end
80
81 test "live associations upgrade names to links" do
82 out = action_summary(entry("publish",
83 { "title" => { "from" => "Old", "to" => "New" } },
84 :user => users(:quentin)))
85
86 assert_includes out, "<a "
87 end
88
89 test "details are guarded off when nothing but an unchanged title is present" do
90 unchanged = entry("publish", { "title" => { "from" => "Same", "to" => "Same" } })
91 changed = entry("publish", { "title" => { "from" => "Old", "to" => "New" } })
92
93 assert_not action_details?(unchanged)
94 assert action_details?(changed)
95 end
96
97 test "first publish with a byline names the author" do
98 out = action_summary(entry("publish",
99 { "title" => { "from" => nil, "to" => "New" },
100 "author" => { "from" => nil, "to" => "quentin" } }))
101
102 assert_includes out, "author"
103 end
104
105 test "create entries with their flat title render and are guarded off details" do
106 e = entry("create", { "title" => "Initial", "path" => "a/b" })
107
108 assert_includes action_summary(e), "a/b"
109 assert_not action_details?(e)
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
146end
diff --git a/test/models/node_action_test.rb b/test/models/node_action_test.rb
new file mode 100644
index 0000000..b177cca
--- /dev/null
+++ b/test/models/node_action_test.rb
@@ -0,0 +1,128 @@
1require "test_helper"
2
3class NodeActionTest < ActiveSupport::TestCase
4
5 # Standalone pages, no node -- same pattern autosave relies on.
6 # Default-locale attributes are written pinned, never ambient.
7 def build_page en: nil, **attrs
8 page = Globalize.with_locale(I18n.default_locale) do
9 Page.create!({ :title => "Titel" }.merge(attrs))
10 end
11 page.translations.create!({ :locale => "en" }.merge(en)) if en
12 page.reload
13 end
14
15 test "first publish yields exactly the title pair, from nil" do
16 diff = NodeAction.head_diff(nil, build_page(:title => "Erstausgabe"))
17
18 assert_equal({ :title => { "from" => nil, "to" => "Erstausgabe" } }, diff)
19 end
20
21 test "first publish carries the byline when the page has one" do
22 diff = NodeAction.head_diff(nil, build_page(:user => users(:quentin)))
23
24 assert_equal({ "from" => nil, "to" => users(:quentin).login }, diff[:author])
25 end
26
27 test "title pair is always present, even when unchanged" do
28 diff = NodeAction.head_diff(build_page, build_page)
29
30 assert_equal "Titel", diff[:title]["from"]
31 assert_equal "Titel", diff[:title]["to"]
32 end
33
34 test "author pair present only when the byline changed" do
35 u1, u2 = users(:quentin), users(:aaron)
36
37 assert_nil NodeAction.head_diff(build_page(:user => u1), build_page(:user => u1))[:author]
38
39 changed = NodeAction.head_diff(build_page(:user => u1), build_page(:user => u2))
40 assert_equal({ "from" => u1.login, "to" => u2.login }, changed[:author])
41 end
42
43 test "tags pair present only when the tag set changed" do
44 old_page = build_page; old_page.tag_list = "alpha, beta"; old_page.save!
45 new_page = build_page; new_page.tag_list = "beta, gamma"; new_page.save!
46
47 diff = NodeAction.head_diff(old_page.reload, new_page.reload)
48 assert_equal %w[alpha beta], diff[:tags]["from"]
49 assert_equal %w[beta gamma], diff[:tags]["to"]
50
51 same = NodeAction.head_diff(old_page, old_page)
52 assert_nil same[:tags]
53 end
54
55 test "template_changed flag only when true" do
56 old_page = build_page(:template_name => "standard_template")
57
58 assert NodeAction.head_diff(old_page, build_page(:template_name => "update"))[:template_changed]
59 assert_nil NodeAction.head_diff(old_page, build_page(:template_name => "standard_template"))[:template_changed]
60 end
61
62 test "assets_changed flag when the attached set differs" do
63 asset = Asset.create!(:name => "diff probe",
64 :upload_file_name => "test_image.png",
65 :upload_content_type => "image/png",
66 :upload_file_size => 49854,
67 :upload_updated_at => Time.current)
68 old_page, new_page = build_page, build_page
69 new_page.related_assets.create!(:asset_id => asset.id, :position => 1)
70
71
72 diff = NodeAction.head_diff(old_page, new_page.reload)
73 assert diff[:assets_changed]
74 assert_nil NodeAction.head_diff(old_page, old_page)[:assets_changed]
75 end
76
77 test "default-locale abstract and body changes become flags, only when true" do
78 diff = NodeAction.head_diff(build_page(:abstract => "a"), build_page(:abstract => "b"))
79
80 assert diff[:abstract_changed]
81 assert_nil diff[:body_changed]
82 end
83
84 test "added locale carries status and the new title" do
85 diff = NodeAction.head_diff(build_page, build_page(en: { :title => "English" }))
86
87 entry = diff[:translation_diff]["en"]
88 assert_equal "added", entry["status"]
89 assert_equal({ "from" => nil, "to" => "English" }, entry["title"])
90 end
91
92 test "removed locale keeps the vanished title" do
93 diff = NodeAction.head_diff(build_page(en: { :title => "English" }), build_page)
94
95 entry = diff[:translation_diff]["en"]
96 assert_equal "removed", entry["status"]
97 assert_equal "English", entry["title"]["from"]
98 assert_nil entry["title"]["to"]
99 end
100
101 test "changed locale carries only what actually differs" do
102 old_page = build_page(en: { :title => "Same", :body => "old" })
103 new_page = build_page(en: { :title => "Same", :body => "new" })
104
105 entry = NodeAction.head_diff(old_page, new_page)[:translation_diff]["en"]
106 assert_equal "changed", entry["status"]
107 assert_nil entry["title"]
108 assert entry["body_changed"]
109 assert_nil entry["abstract_changed"]
110 end
111
112 test "untouched locales are absent; identical pages yield no translation_diff at all" do
113 old_page = build_page(en: { :title => "Same" })
114 new_page = build_page(en: { :title => "Same" })
115
116 assert_nil NodeAction.head_diff(old_page, new_page)[:translation_diff]
117 end
118
119 test "record! passes provenance and historical timestamps through" do
120 long_ago = 2.years.ago
121 action = NodeAction.record!(:node => nil, :action => "publish",
122 :occurred_at => long_ago,
123 :inferred_from => "from_page_revision")
124
125 assert_equal "from_page_revision", action.inferred_from
126 assert_in_delta long_ago.to_f, action.occurred_at.to_f, 1.0
127 end
128end
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index 022fff6..ab66f81 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -633,6 +633,7 @@ class NodeTest < ActiveSupport::TestCase
633 assert_equal node.head, action.page 633 assert_equal node.head, action.page
634 assert_equal @user1, action.user 634 assert_equal @user1, action.user
635 assert_equal "publish", action.action 635 assert_equal "publish", action.action
636 assert_equal "draft", action.metadata["via"]
636 end 637 end
637 638
638 test "publish_draft! called with no user logs no actor, not a guessed one" do 639 test "publish_draft! called with no user logs no actor, not a guessed one" do
@@ -692,7 +693,7 @@ class NodeTest < ActiveSupport::TestCase
692 assert_equal count_before, NodeAction.count 693 assert_equal count_before, NodeAction.count
693 end 694 end
694 695
695 test "publish_draft! records the title's from/to in metadata" do 696 test "publish_draft! records the title diff in metadata" do
696 node = create_node_with_published_page 697 node = create_node_with_published_page
697 Globalize.with_locale(:de) { node.head.update!(:title => "Original Title") } 698 Globalize.with_locale(:de) { node.head.update!(:title => "Original Title") }
698 find_or_create_draft(node, @user1) 699 find_or_create_draft(node, @user1)
@@ -701,7 +702,164 @@ class NodeTest < ActiveSupport::TestCase
701 node.publish_draft!(@user1) 702 node.publish_draft!(@user1)
702 703
703 action = NodeAction.last 704 action = NodeAction.last
704 assert_equal "Original Title", action.metadata["from"] 705 assert_equal "draft", action.metadata["via"]
705 assert_equal "New Title", action.metadata["to"] 706 assert_equal "Original Title", action.metadata.dig("title", "from")
707 assert_equal "New Title", action.metadata.dig("title", "to")
708 end
709
710 test "publishing a staged slug change logs a move with the path pair" do
711 node = create_node_with_published_page
712 path_before = node.unique_name
713 node.staged_slug = "moved-#{node.slug}"
714 node.save!
715 publish_count_before = NodeAction.where(:action => "publish").count
716
717 node.publish_draft!(@user1)
718
719 node.reload
720 assert_not_equal path_before, node.unique_name
721
722 action = NodeAction.where(:action => "move").last
723 assert_equal node, action.node
724 assert_equal @user1, action.user
725 assert_equal path_before, action.metadata.dig("path", "from")
726 assert_equal node.unique_name, action.metadata.dig("path", "to")
727
728 # No draft was pending: path change alone must not fabricate a publish.
729 assert_equal publish_count_before, NodeAction.where(:action => "publish").count
730 end
731
732 test "publishing a draft together with a staged move logs two entries" do
733 node = create_node_with_published_page
734 find_or_create_draft(node, @user1)
735 node.staged_slug = "relocated-#{node.slug}"
736 node.save!
737
738 assert_difference "NodeAction.count", 2 do
739 node.publish_draft!(@user1)
740 end
741
742 assert_equal %w[move publish],
743 NodeAction.order(:id).last(2).map(&:action).sort
744 end
745
746 test "restore_revision! logs a publish via revision" do
747 node = create_node_with_published_page
748 Globalize.with_locale(:de) { node.head.update!(:title => "First") }
749 first_head = node.head
750 find_or_create_draft(node, @user1)
751 Globalize.with_locale(:de) { node.draft.update!(:title => "Second") }
752 node.publish_draft!(@user1)
753
754 node.restore_revision!(first_head.revision, @user1)
755
756 action = NodeAction.last
757 assert_equal "publish", action.action
758 assert_equal "revision", action.metadata["via"]
759 assert_equal first_head, action.page
760 assert_equal @user1, action.user
761 assert_equal "Second", action.metadata.dig("title", "from")
762 assert_equal "First", action.metadata.dig("title", "to")
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! }
706 end 864 end
707end 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 = {})