summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CONTRIBUTING.md89
-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/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.rb305
-rw-r--r--app/models/node_action.rb169
-rw-r--r--app/models/page.rb17
-rw-r--r--app/models/user.rb31
-rw-r--r--app/views/admin/_recent_changes.html.erb24
-rw-r--r--app/views/admin/index.html.erb23
-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/_recent_change_item.html.erb9
-rw-r--r--app/views/nodes/destroy.html.erb2
-rw-r--r--app/views/nodes/new.html.erb4
-rw-r--r--app/views/nodes/recent.html.erb6
-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/csp_log.rb4
-rw-r--r--config/initializers/error_log.rb20
-rw-r--r--config/locales/de.yml36
-rw-r--r--config/locales/en.yml36
-rw-r--r--config/routes.rb15
-rw-r--r--db/migrate/20260714233414_create_node_actions.rb19
-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.js116
-rw-r--r--public/javascripts/admin_search.js43
-rw-r--r--public/javascripts/related_assets.js9
-rw-r--r--public/stylesheets/admin.css117
-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.rb12
-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.rb241
-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
62 files changed, 2578 insertions, 298 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c26956c
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,89 @@
1# Contributing to CCCMS
2
3## Stack
4Rails 8.1, Ruby 3.2, PostgreSQL 16, ImageMagick 7 (`magick`, not `convert`).
5No Sprockets pipeline for public or admin static assets except
6`admin_bundle.js` — everything else is served straight from `public/` and
7cache-busted via file mtime (`ApplicationHelper#mtime_busted_path`), not
8fingerprinted content hashing.
9
10## Content model
11- **Node** — the tree structure (`awesome_nested_set`-derived, migrating
12 toward a plain `parent_id` model — both may be present during that
13 transition). Holds three separate Page references: `head` (published),
14 `draft` (being worked on), `autosave` (in-progress typing).
15- **Page** — one revision's content. `belongs_to :node`, except `autosave`
16 layers specifically: their `node_id` is deliberately `nil`, which is what
17 keeps them out of `node.pages` (the real revision history) entirely —
18 an autosave is not a revision.
19- **Page::Translation** — Globalize's own generated class (`translates
20 :title, :abstract, :body` on `Page`). Not a top-level `PageTranslation`
21 constant, and it has `page_id`, not a `page` association reader.
22
23## Publishing lifecycle
24There is exactly one path from an edit to a published change:
25`autosave!` (writes into the autosave layer, creating it via
26`clone_attributes_from` on the previous head/draft if it doesn't exist yet)
27→ `save_draft!` (clones the *entire* autosave into the draft layer,
28wholesale — every locale, not just the one just edited) → `publish_draft!`
29(promotes draft to head). Nothing writes to `draft` or `head` directly.
30Locking (`lock_for_editing!`, raising `LockedByAnotherUser`) gates every
31step; a lock can exist with no draft or autosave at all (acquisition is
32deliberately separate from content creation).
33
34## Locale architecture
35Presentation locale (which language the admin chrome or public page
36renders in) and content-target locale (which translation a given screen is
37reading or writing) are two different concerns and never share a
38mechanism. The route's `:locale` segment governs the former only. The
39primary node editor always targets the default locale, enforced via
40`Globalize.with_locale` (never `I18n.locale`, which would leak into
41generated URLs through `default_url_options`). Non-default-locale editing
42uses a separately-named route param (`:translation_locale`), deliberately
43distinct from `:locale` so the two can never be conflated by a link helper.
44Globalize's configured fallback chain is correct for public rendering and
45wrong for editing — anywhere a screen needs to know a translation's real,
46unborrowed state, read the `Page::Translation` row directly rather than the
47locale-dependent attribute accessor.
48
49## Assets
50`FileAttachment` (`app/models/concerns/file_attachment.rb`) is a from-
51scratch Paperclip replacement. `STYLES` is a hash of style name to a full
52ImageMagick argument array (not just a geometry string — some styles need
53`-gravity`/`-extent` alongside `-resize`, which a single string can't
54express). `generate_variants` loops over `STYLES` generically; adding a
55style needs no other code change, only `bundle exec rake
56assets:regenerate_variants` to backfill existing assets. The URL contract
57(`/system/uploads/:id/:style/:filename`) is stable and safe to parse
58directly (the numeric id is always the second path segment) rather than
59reconstructed through model methods.
60
61## Aggregator / shortcode system
62`[aggregate ...]` in page body text is parsed by
63`ContentHelper#aggregate?` into an options hash, then executed by
64`Page.aggregate`. Only `tags`, `children` (`"direct"`/`"all"`, combined
65with the current node), `order_by`/`order_direction`, and `limit` are
66actually consumed. Only the first `[aggregate ...]` in a given body is
67ever processed (`aggregate?` does a single, non-global match).
68
69## TinyMCE integration
70`extended_valid_elements` must define a parent element and its commonly-
71nested children symmetrically and explicitly together — leaving one
72element on the schema's default rule while the other is explicitly
73redefined can cause content loss on save, even when the child would be
74perfectly valid under the untouched default alone. `valid_classes` denies
75all classes by default (`'*': ''`); anything meant to render needs an
76explicit per-element allowance. `content_style` injects CSS into the
77editing iframe directly — the app's real public stylesheet is never loaded
78there, so anything that needs to render correctly while editing needs its
79own rule supplied this way. Constrained-input features (fixed placement
80choices, for instance) are built as custom toolbar buttons with their own
81dialog and `editor.insertContent()`, not the native image dialog or
82`file_picker_callback` — those bring their own resize/edit affordances that
83would undermine a genuinely fixed set of choices.
84
85## Testing
86Shared test-only convenience methods (that need to operate across many
87model/controller test files) live directly on `ActiveSupport::TestCase`,
88in `test/test_helper.rb` — the framework's own designated extension point
89for exactly this, not a monkey-patch of a production model class.
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..5a3b55e
--- /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 CSP_LOGGER.warn("CSP violation: #{directive} blocked=#{report['blocked-uri']} at=#{at}")
16 else
17 CSP_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 bff1733..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,10 +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_name = Node.find(@parent_id).title
33 end
34 end 33 end
35 34
36 def create 35 def create
@@ -48,14 +47,14 @@ class NodesController < ApplicationController
48 @node.draft.save! 47 @node.draft.save!
49 48
50 @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)
51 53
52 redirect_to(edit_node_path(@node)) 54 redirect_to(edit_node_path(@node))
53 else 55 else
54 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 56 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
55 if params[:parent_id].present? 57 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id)
56 @parent_id = params[:parent_id]
57 @parent_name = Node.find(@parent_id).title
58 end
59 render :new 58 render :new
60 end 59 end
61 end 60 end
@@ -63,6 +62,9 @@ class NodesController < ApplicationController
63 def show 62 def show
64 @page = @node.draft || @node.head 63 @page = @node.draft || @node.head
65 @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)
66 end 68 end
67 69
68 def edit 70 def edit
@@ -142,12 +144,44 @@ class NodesController < ApplicationController
142 redirect_to node_path(@node) 144 redirect_to node_path(@node)
143 end 145 end
144 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
145 def destroy 173 def destroy
146 @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)
147 end 181 end
148 182
149 def publish 183 def publish
150 @node.publish_draft! 184 @node.publish_draft!(current_user)
151 flash[:notice] = "Draft has been published" 185 flash[:notice] = "Draft has been published"
152 redirect_to node_path(@node) 186 redirect_to node_path(@node)
153 end 187 end
@@ -217,6 +251,11 @@ class NodesController < ApplicationController
217 @sitemap_descendant_counts = descendant_counts_for(@sitemap) 251 @sitemap_descendant_counts = descendant_counts_for(@sitemap)
218 end 252 end
219 253
254 def trashed
255 @nodes = Node.trash.children.order(:slug)
256 .paginate(:page => params[:page], :per_page => 50)
257 end
258
220 private 259 private
221 260
222 def slug_for(title) 261 def slug_for(title)
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 311c5c0..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
@@ -170,10 +204,12 @@ class Node < ApplicationRecord
170 self.autosave.destroy 204 self.autosave.destroy
171 self.autosave_id = nil 205 self.autosave_id = nil
172 self.save! 206 self.save!
207 NodeAction.record!(:node => self, :user => current_user, :action => "discard_autosave")
173 elsif self.draft && self.head 208 elsif self.draft && self.head
174 self.draft.destroy 209 self.draft.destroy
175 self.draft_id = nil 210 self.draft_id = nil
176 self.save! 211 self.save!
212 NodeAction.record!(:node => self, :user => current_user, :action => "destroy_draft")
177 end 213 end
178 214
179 self.unlock! unless self.draft 215 self.unlock! unless self.draft
@@ -188,54 +224,189 @@ class Node < ApplicationRecord
188 end 224 end
189 end 225 end
190 226
191 def publish_draft! 227 def publish_draft! current_user = nil
192 # Return nil if nothing to publish and no staged changes 228 # Return nil if nothing to publish and no staged changes
193 return nil unless self.draft || staged_slug || staged_parent_id 229 return nil unless self.draft || staged_slug || staged_parent_id
194 230
195 if self.draft 231 if in_trash? || trash_node?
196 self.head = self.draft 232 raise ActiveRecord::RecordInvalid.new(self), "Cannot publish a node in the Trash"
197 self.head.published_at ||= Time.now
198 self.head.save!
199 self.draft = nil
200 end 233 end
201 234
202 if staged_slug && (staged_slug != slug) 235 path_before = self.unique_name
203 self.slug = staged_slug 236
204 self.staged_slug = nil 237 ActiveRecord::Base.transaction do
205 end 238 if self.draft
239 outgoing_head = self.head
240 self.head = self.draft
241 self.head.published_at ||= Time.now
242 self.head.save!
243 self.draft = nil
244
245 NodeAction.record!(:node => self, :page => self.head, :user => current_user,
246 :action => "publish", :via => "draft",
247 **NodeAction.head_diff(outgoing_head, self.head))
248 end
249
250 if staged_slug && (staged_slug != slug)
251 self.slug = staged_slug
252 self.staged_slug = nil
253 end
206 254
207 if staged_parent_id && (staged_parent_id != parent_id) 255 if staged_parent_id && (staged_parent_id != parent_id)
208 new_parent = Node.find(staged_parent_id) 256 new_parent = Node.find(staged_parent_id)
257
258 if new_parent == self || self.descendants.include?(new_parent)
259 raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants"
260 end
261
262 self.staged_parent_id = nil
263 self.save!
264 self.move_to_child_of(new_parent)
265 else
266 unless self.save
267 raise ActiveRecord::RecordInvalid.new(self)
268 end
269 end
209 270
210 if new_parent == self || self.descendants.include?(new_parent) 271 self.reload
211 raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants" 272 self.update_unique_name
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 })
212 end 277 end
278 self.unlock!
279 self
280 end
281 end
282
283 def restore_revision! revision, current_user = nil
284 page = self.pages.find_by_revision(revision)
285 return nil unless page
213 286
214 self.staged_parent_id = nil 287 ActiveRecord::Base.transaction do
288 outgoing_head = self.head
289 self.head = page
215 self.save! 290 self.save!
216 self.move_to_child_of(new_parent) 291
217 else 292 NodeAction.record!(:node => self, :page => page, :user => current_user,
218 unless self.save 293 :action => "publish", :via => "revision",
219 raise ActiveRecord::RecordInvalid.new(self) 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
220 end 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"
221 end 352 end
222 353
223 self.reload 354 ActiveRecord::Base.transaction do
224 self.update_unique_name 355 path_before = unique_name
225 self.send(:update_unique_names_of_children) 356 move_to_child_of(new_parent)
226 self.unlock! 357 self.reload
227 self 358 update_unique_name
359 send(:update_unique_names_of_children)
360
361 NodeAction.record!(:node => self, :user => current_user, :action => "restore_from_trash",
362 :path => { "from" => path_before, "to" => unique_name })
363 self
364 end
228 end 365 end
229 366
230 def restore_revision! revision 367 # Final deletion -- only from inside the Trash. Removes the whole
231 if page = self.pages.find_by_revision(revision) 368 # subtree, deepest first, each node through a real destroy! so every
232 self.head = page 369 # per-node cascade runs (the categorical difference from the old
233 self.save 370 # delete_all nuke). refuse_destroy_with_children on bare destroy is
234 else 371 # untouched
235 nil 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!)
236 end 387 end
237 end 388 end
238 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
239 # 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
240 def unique_path 411 def unique_path
241 unique_name.to_s.split("/") 412 unique_name.to_s.split("/")
@@ -246,9 +417,12 @@ class Node < ApplicationRecord
246 parent.nil? ? [slug] : parent.path_to_root.push(slug) 417 parent.nil? ? [slug] : parent.path_to_root.push(slug)
247 end 418 end
248 419
420 def computed_unique_name
421 path_to_root[1..-1].join("/") # excluding root
422 end
423
249 def current_unique_name 424 def current_unique_name
250 path = path_to_root[1..-1] # excluding root 425 self.unique_name = computed_unique_name
251 self.unique_name = path.join("/")
252 end 426 end
253 427
254 def update_unique_name 428 def update_unique_name
@@ -288,6 +462,20 @@ class Node < ApplicationRecord
288 autosave || draft || head 462 autosave || draft || head
289 end 463 end
290 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
291 # 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
292 # stay the same eventhough the slug or positions changes. 480 # stay the same eventhough the slug or positions changes.
293 # Can be removed after a year or so ;) 481 # Can be removed after a year or so ;)
@@ -328,7 +516,7 @@ class Node < ApplicationRecord
328 end 516 end
329 517
330 def self.drafts_and_autosaves(current_user_id: nil) 518 def self.drafts_and_autosaves(current_user_id: nil)
331 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
332 return scope.order("updated_at DESC") unless current_user_id 520 return scope.order("updated_at DESC") unless current_user_id
333 521
334 scope.order( 522 scope.order(
@@ -336,6 +524,15 @@ class Node < ApplicationRecord
336 ) 524 )
337 end 525 end
338 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
339 def self.recently_changed 536 def self.recently_changed
340 includes(:head).where( 537 includes(:head).where(
341 "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",
@@ -392,4 +589,36 @@ class Node < ApplicationRecord
392 end 589 end
393 end 590 end
394 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
395end 624end
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
new file mode 100644
index 0000000..13bd5ba
--- /dev/null
+++ b/app/models/node_action.rb
@@ -0,0 +1,169 @@
1class NodeAction < ApplicationRecord
2 belongs_to :node, optional: true
3 belongs_to :page, optional: true
4 belongs_to :user, optional: true
5
6 validates :action, presence: true
7 validates :occurred_at, presence: true
8
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)
86 create!(
87 :node => node,
88 :page => page,
89 :user => user,
90 :action => action,
91 :locale => locale,
92 :occurred_at => occurred_at || Time.now,
93 :inferred_from => inferred_from,
94 :metadata => {
95 "username" => user&.login,
96 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
97 node&.head&.title || node&.draft&.title
98 },
99 }.merge(extra.stringify_keys)
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
160 end
161
162 def actor_name
163 metadata["username"] || "unknown"
164 end
165
166 def subject_name
167 metadata["human_readable_node_name"] || node&.unique_name || "deleted node"
168 end
169end
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/_recent_changes.html.erb b/app/views/admin/_recent_changes.html.erb
deleted file mode 100644
index 88b5e93..0000000
--- a/app/views/admin/_recent_changes.html.erb
+++ /dev/null
@@ -1,24 +0,0 @@
1<h2>Recent Changes</h2>
2<div id="draft_list">
3 <table>
4 <tr class="header">
5 <th>Title</th>
6 <th>path</th>
7 <th>user</th>
8 <th>date</th>
9 <th></th>
10 </tr>
11 <% @recent_changes.each do |node| %>
12 <tr>
13 <td><%= truncated_title_for_node node %></td>
14 <td><%= node.unique_name %></td>
15 <td><%= node.draft.user.login rescue "" %></td>
16 <td><%= node.updated_at.to_fs(:db) %></td>
17 <td class="actions">
18 <%= link_to 'Show', node_path(node) %>
19 <%= link_to "Revisions", revision_path(:id => node.id) %>
20 </td>
21 </tr>
22 <% end %>
23 </table>
24</div> \ No newline at end of file
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
index bdb555e..319530d 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -42,21 +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 <% @recent_changes.each do |node| %> 46 <%= render partial: "node_actions/action_row", collection: @actions, as: :action %>
47 <li> 47 </table>
48 <div> 48 <%= link_to "See all recent changes →", admin_log_path %>
49 <%= link_to title_for_node(node), node_path(node) %>
50 <span class="field_hint"><%= link_to_path("#{node.unique_name} ↗", node.unique_name) %></span>
51 </div>
52 <span class="dashboard_widget_meta">
53 <%= (editor = node_head_editor(node)) ? t("published_by", editor: editor) : t("published") %>, <%= relative_time_phrase(node.head.updated_at) %>
54 </span>
55 </li>
56 <% end %>
57 </ul>
58 <%= link_to "See all recent changes →", recent_nodes_path %>
59 </div> 49 </div>
50
60</div> 51</div>
61 52
62<div id="dashboard_housekeeping"> 53<div id="dashboard_housekeeping">
@@ -77,5 +68,9 @@
77 <%= link_to menu_items_path, class: "action_button" do %> 68 <%= link_to menu_items_path, class: "action_button" do %>
78 <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation 69 <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation
79 <% 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 %>
80 </div> 75 </div>
81</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/_recent_change_item.html.erb b/app/views/nodes/_recent_change_item.html.erb
new file mode 100644
index 0000000..754a775
--- /dev/null
+++ b/app/views/nodes/_recent_change_item.html.erb
@@ -0,0 +1,9 @@
1<li>
2 <div>
3 <%= link_to title_for_node(node), node_path(node) %>
4 <span class="field_hint"><%= link_to_path("#{node.unique_name} ↗", node.unique_name) %></span>
5 </div>
6 <span class="dashboard_widget_meta">
7 <%= (editor = node_head_editor(node)) ? t("last_edited_by", editor: editor) : t("last_edited") %>, <%= relative_time_phrase(node.head.updated_at) %>
8 </span>
9</li>
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 b63a606..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 %> 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/recent.html.erb b/app/views/nodes/recent.html.erb
index 3602775..d256253 100644
--- a/app/views/nodes/recent.html.erb
+++ b/app/views/nodes/recent.html.erb
@@ -1,3 +1,7 @@
1<h1>Recently changed</h1> 1<h1>Recently changed</h1>
2 2
3<%= render 'node_list' %> 3<%= will_paginate @nodes %>
4<ul id="recent_changes_full_list">
5 <%= render partial: "nodes/recent_change_item", collection: @nodes, as: :node %>
6</ul>
7<%= will_paginate @nodes %>
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/csp_log.rb b/config/initializers/csp_log.rb
new file mode 100644
index 0000000..1dde9c4
--- /dev/null
+++ b/config/initializers/csp_log.rb
@@ -0,0 +1,4 @@
1# CSP violation reports get their own file, independent of config.logger.
2CSP_LOGGER = Rails.env.production? ?
3 ActiveSupport::Logger.new(Rails.root.join("log", "csp_violations.log")) :
4 Rails.logger
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 550ccb3..f64f6cd 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -16,6 +16,8 @@ de:
16 open_today: "Das Chaos, lokal und heute offen" 16 open_today: "Das Chaos, lokal und heute offen"
17 published: "veröffentlicht" 17 published: "veröffentlicht"
18 published_by: "veröffentlicht durch %{editor}" 18 published_by: "veröffentlicht durch %{editor}"
19 last_edited: "zuletzt bearbeitet"
20 last_edited_by: "zuletzt bearbeitet durch %{editor}"
19 editor_self: "du" 21 editor_self: "du"
20 publisher_self: "dich" 22 publisher_self: "dich"
21 date: 23 date:
@@ -80,3 +82,37 @@ de:
80 page_gap: "&hellip;" 82 page_gap: "&hellip;"
81 container_aria_label: "Seitennavigation" 83 container_aria_label: "Seitennavigation"
82 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 73271ed..8a8acc1 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -17,6 +17,8 @@ en:
17 open_today: "Open today" 17 open_today: "Open today"
18 published: "published" 18 published: "published"
19 published_by: "published by %{editor}" 19 published_by: "published by %{editor}"
20 last_edited: "last edited"
21 last_edited_by: "last edited by %{editor}"
20 publisher_self: "you" 22 publisher_self: "you"
21 editor_self: "you" 23 editor_self: "you"
22 24
@@ -32,3 +34,37 @@ en:
32 page_gap: "&hellip;" 34 page_gap: "&hellip;"
33 container_aria_label: "Pagination" 35 container_aria_label: "Pagination"
34 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/20260714233414_create_node_actions.rb b/db/migrate/20260714233414_create_node_actions.rb
new file mode 100644
index 0000000..6c18285
--- /dev/null
+++ b/db/migrate/20260714233414_create_node_actions.rb
@@ -0,0 +1,19 @@
1class CreateNodeActions < ActiveRecord::Migration[8.1]
2 def change
3 create_table :node_actions do |t|
4 t.references :node, foreign_key: { on_delete: :nullify }
5 t.references :page, foreign_key: { on_delete: :nullify }
6 t.references :user, foreign_key: { on_delete: :nullify }
7 t.string :action, null: false
8 t.string :locale
9 t.string :inferred_from
10 t.jsonb :metadata, null: false
11 t.datetime :occurred_at, null: false
12
13 t.timestamps
14 end
15
16 add_index :node_actions, [:node_id, :occurred_at]
17 add_index :node_actions, :action
18 end
19end
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 0c9ec66..b7bdc10 100644
--- a/public/javascripts/admin_interface.js
+++ b/public/javascripts/admin_interface.js
@@ -1,15 +1,3 @@
1function hide_all() {
2 $('#recent_changes_toggle').attr("class", "unselected");
3 $('#my_work_toggle').attr("class", "unselected");
4 $('#current_drafts_toggle').attr("class", "unselected");
5 $('#admin_sitemap_toggle').attr("class", "unselected");
6
7 $('#current_drafts_table').hide();
8 $('#my_work_table').hide();
9 $('#recent_changes_table').hide();
10 $('#admin_sitemap_table').hide();
11}
12
13$(document).ready(function () { 1$(document).ready(function () {
14 admin_search.initialize(); 2 admin_search.initialize();
15 3
@@ -69,6 +57,10 @@ $(document).ready(function () {
69 move_to_search.initialize_search(); 57 move_to_search.initialize_search();
70 } 58 }
71 59
60 if ($("#restore_search_term").length != 0) {
61 restore_search.initialize_search();
62 }
63
72 if ($("#event_node_search_term").length != 0) { 64 if ($("#event_node_search_term").length != 0) {
73 event_search.initialize_search(); 65 event_search.initialize_search();
74 } 66 }
@@ -85,63 +77,13 @@ $(document).ready(function () {
85 cccms.preview.initialize(); 77 cccms.preview.initialize();
86 } 78 }
87 79
88 if ($('#recent_changes_toggle').length != 0) {
89 hide_all();
90 $('#recent_changes_toggle').attr("class", "selected");
91 $('#recent_changes_table').show();
92
93 $('#recent_changes_toggle').bind("click", function(){
94 hide_all();
95 $('#recent_changes_toggle').attr("class", "selected");
96 $('#recent_changes_table').show();
97 return false;
98 });
99
100 $('#my_work_toggle').bind("click", function(){
101 hide_all();
102 $('#my_work_toggle').attr("class", "selected");
103 $('#my_work_table').show();
104 return false;
105 });
106
107 $('#admin_wizard_my_work').bind("click", function(){
108 hide_all();
109 $('#my_work_toggle').attr("class", "selected");
110 $('#my_work_table').show();
111 return false;
112 });
113
114 $('#current_drafts_toggle').bind("click", function(){
115 hide_all();
116 $('#current_drafts_toggle').attr("class", "selected");
117 $('#current_drafts_table').show();
118 return false;
119 });
120
121 $('#admin_sitemap_toggle').bind("click", function(){
122 hide_all();
123 $('#admin_sitemap_toggle').attr("class", "selected");
124 $('#admin_sitemap_table').show();
125 return false;
126 });
127
128 $('#admin_wizard_create_page').bind("click", function(){
129 hide_all();
130 $('#admin_sitemap_toggle').attr("class", "selected");
131 $('#admin_sitemap_table').show();
132 return false;
133 });
134 }
135
136 jQuery.ajaxSetup({ 80 jQuery.ajaxSetup({
137 'beforeSend': function(xhr) {xhr.setRequestHeader("Accept", "text/javascript");} 81 'beforeSend': function(xhr) {xhr.setRequestHeader("Accept", "text/javascript");}
138 }); 82 });
139 83
140 $(document).ajaxSend(function(event, request, settings) { 84 $(document).ajaxSend(function(event, request, settings) {
141 if (typeof(AUTH_TOKEN) == "undefined") return; 85 var meta = document.querySelector("meta[name='csrf-token']");
142 // settings.data is a serialized string like "foo=bar&baz=boink" (or null) 86 if (meta) request.setRequestHeader("X-CSRF-Token", meta.content);
143 settings.data = settings.data || "";
144 settings.data += (settings.data ? "&" : "") + "authenticity_token=" + encodeURIComponent(AUTH_TOKEN);
145 }); 87 });
146 88
147}); 89});
@@ -475,8 +417,10 @@ rrule_builder = {
475 }); 417 });
476 $("#rrule_monthly_ordinal").bind("change", function() { 418 $("#rrule_monthly_ordinal").bind("change", function() {
477 $("#rrule_ordinal_fields").toggle($(this).is(":checked")); 419 $("#rrule_ordinal_fields").toggle($(this).is(":checked"));
420 rrule_builder.toggle_custom_ordinal_fields();
478 rrule_builder.sync(); 421 rrule_builder.sync();
479 }); 422 });
423 $("#rrule_ordinal").bind("change", rrule_builder.toggle_custom_ordinal_fields);
480 $("#rrule_exclude_month").bind("change", function() { 424 $("#rrule_exclude_month").bind("change", function() {
481 $("#rrule_excluded_month").toggle($(this).is(":checked")); 425 $("#rrule_excluded_month").toggle($(this).is(":checked"));
482 rrule_builder.sync(); 426 rrule_builder.sync();
@@ -493,6 +437,12 @@ rrule_builder = {
493 $("#rrule_monthly_options").show(); 437 $("#rrule_monthly_options").show();
494 }, 438 },
495 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
496 sync : function() { 446 sync : function() {
497 var freq = $("input[name='rrule_freq']:checked").val(); 447 var freq = $("input[name='rrule_freq']:checked").val();
498 var parts = []; 448 var parts = [];
@@ -508,7 +458,16 @@ rrule_builder = {
508 } else { 458 } else {
509 parts.push("FREQ=MONTHLY"); 459 parts.push("FREQ=MONTHLY");
510 if ($("#rrule_monthly_ordinal").is(":checked")) { 460 if ($("#rrule_monthly_ordinal").is(":checked")) {
511 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 }
512 } 471 }
513 } 472 }
514 473
@@ -543,8 +502,33 @@ rrule_builder = {
543 } else if (parts.FREQ === "MONTHLY") { 502 } else if (parts.FREQ === "MONTHLY") {
544 $("input[name='rrule_freq'][value='monthly']").prop("checked", true); 503 $("input[name='rrule_freq'][value='monthly']").prop("checked", true);
545 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
546 var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/); 521 var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/);
547 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) {
548 $("#rrule_monthly_ordinal").prop("checked", true); 532 $("#rrule_monthly_ordinal").prop("checked", true);
549 $("#rrule_ordinal_fields").show(); 533 $("#rrule_ordinal_fields").show();
550 $("#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 b5b8707..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 ============================================================ */
@@ -1121,6 +1128,25 @@ div#draft_list table td.actions a {
1121 font-size: 0.9em; 1128 font-size: 0.9em;
1122} 1129}
1123 1130
1131#recent_changes_full_list {
1132 list-style: none;
1133 padding: 0;
1134 margin: 0;
1135}
1136
1137#recent_changes_full_list li {
1138 padding: 0.75rem 0;
1139 border-bottom: 1px solid #eee;
1140}
1141
1142#recent_changes_full_list li:last-child {
1143 border-bottom: none;
1144}
1145
1146#recent_changes_full_list li > div {
1147 margin-bottom: 0.25rem;
1148}
1149
1124/* ============================================================ 1150/* ============================================================
1125 Search widgets 1151 Search widgets
1126 ============================================================ */ 1152 ============================================================ */
@@ -1475,3 +1501,92 @@ div#image_browser ul li {
1475 gap: 8px; 1501 gap: 8px;
1476 flex-wrap: wrap; 1502 flex-wrap: wrap;
1477} 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 8fa732f..feaacd0 100644
--- a/test/controllers/page_translations_controller_test.rb
+++ b/test/controllers/page_translations_controller_test.rb
@@ -82,4 +82,16 @@ class PageTranslationsControllerTest < ActionController::TestCase
82 assert_equal page_count_before, node.pages.count 82 assert_equal page_count_before, node.pages.count
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
86 test "destroy removes the translation, logs nothing, and redirects" do
87 login_as :quentin
88 node = Node.root.children.create!(:slug => "translation_destroy_log_test")
89 Globalize.with_locale(:en) { node.draft.update!(:title => "English title") }
90
91 assert_no_difference "NodeAction.count" do
92 delete :destroy, params: { :node_id => node.id, :translation_locale => "en" }
93 end
94
95 assert_redirected_to node_path(node)
96 end
85end 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 959387d..ab66f81 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -621,4 +621,245 @@ class NodeTest < ActiveSupport::TestCase
621 assert_equal "Edited directly on autosave", Globalize.with_locale(:en) { autosave.title } 621 assert_equal "Edited directly on autosave", Globalize.with_locale(:en) { autosave.title }
622 assert_equal "v2", Globalize.with_locale(:de) { autosave.title } 622 assert_equal "v2", Globalize.with_locale(:de) { autosave.title }
623 end 623 end
624
625 test "publish_draft! logs a NodeAction crediting the actual publisher" do
626 node = Node.root.children.create!(:slug => "publish_log_test")
627 node.draft.update!(:title => "Version one")
628
629 node.publish_draft!(@user1)
630
631 action = NodeAction.last
632 assert_equal node, action.node
633 assert_equal node.head, action.page
634 assert_equal @user1, action.user
635 assert_equal "publish", action.action
636 assert_equal "draft", action.metadata["via"]
637 end
638
639 test "publish_draft! called with no user logs no actor, not a guessed one" do
640 node = Node.root.children.create!(:slug => "publish_log_no_user_test")
641 node.draft.update!(:title => "Version one")
642
643 node.publish_draft!
644
645 action = NodeAction.last
646 assert_nil action.user
647 assert_nil action.metadata["username"]
648 end
649
650 test "publish_draft! with nothing pending creates no NodeAction" do
651 node = Node.root.children.create!(:slug => "publish_log_noop_test")
652 node.publish_draft!
653 count_before = NodeAction.count
654
655 result = node.publish_draft!
656
657 assert_nil result
658 assert_equal count_before, NodeAction.count
659 end
660
661 test "revert! logs discard_autosave for an in-progress autosave" do
662 node = create_node_with_published_page
663 node.lock_for_editing!(@user1)
664 node.autosave!({:title => "in progress"}, @user1)
665
666 node.revert!(@user1)
667
668 action = NodeAction.last
669 assert_equal node, action.node
670 assert_equal @user1, action.user
671 assert_equal "discard_autosave", action.action
672 end
673
674 test "revert! logs destroy_draft for a draft with a head behind it" do
675 node = create_node_with_published_page
676 find_or_create_draft(node, @user1)
677
678 node.revert!(@user1)
679
680 action = NodeAction.last
681 assert_equal node, action.node
682 assert_equal @user1, action.user
683 assert_equal "destroy_draft", action.action
684 end
685
686 test "revert! with nothing to revert logs nothing" do
687 node = create_node_with_published_page
688 node.lock_for_editing!(@user1)
689 count_before = NodeAction.count
690
691 node.revert!(@user1)
692
693 assert_equal count_before, NodeAction.count
694 end
695
696 test "publish_draft! records the title diff in metadata" do
697 node = create_node_with_published_page
698 Globalize.with_locale(:de) { node.head.update!(:title => "Original Title") }
699 find_or_create_draft(node, @user1)
700 Globalize.with_locale(:de) { node.draft.update!(:title => "New Title") }
701
702 node.publish_draft!(@user1)
703
704 action = NodeAction.last
705 assert_equal "draft", action.metadata["via"]
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! }
864 end
624end 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 = {})