diff options
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 | ||
| 4 | Rails 8.1, Ruby 3.2, PostgreSQL 16, ImageMagick 7 (`magick`, not `convert`). | ||
| 5 | No Sprockets pipeline for public or admin static assets except | ||
| 6 | `admin_bundle.js` — everything else is served straight from `public/` and | ||
| 7 | cache-busted via file mtime (`ApplicationHelper#mtime_busted_path`), not | ||
| 8 | fingerprinted 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 | ||
| 24 | There 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, | ||
| 28 | wholesale — every locale, not just the one just edited) → `publish_draft!` | ||
| 29 | (promotes draft to head). Nothing writes to `draft` or `head` directly. | ||
| 30 | Locking (`lock_for_editing!`, raising `LockedByAnotherUser`) gates every | ||
| 31 | step; a lock can exist with no draft or autosave at all (acquisition is | ||
| 32 | deliberately separate from content creation). | ||
| 33 | |||
| 34 | ## Locale architecture | ||
| 35 | Presentation locale (which language the admin chrome or public page | ||
| 36 | renders in) and content-target locale (which translation a given screen is | ||
| 37 | reading or writing) are two different concerns and never share a | ||
| 38 | mechanism. The route's `:locale` segment governs the former only. The | ||
| 39 | primary node editor always targets the default locale, enforced via | ||
| 40 | `Globalize.with_locale` (never `I18n.locale`, which would leak into | ||
| 41 | generated URLs through `default_url_options`). Non-default-locale editing | ||
| 42 | uses a separately-named route param (`:translation_locale`), deliberately | ||
| 43 | distinct from `:locale` so the two can never be conflated by a link helper. | ||
| 44 | Globalize's configured fallback chain is correct for public rendering and | ||
| 45 | wrong for editing — anywhere a screen needs to know a translation's real, | ||
| 46 | unborrowed state, read the `Page::Translation` row directly rather than the | ||
| 47 | locale-dependent attribute accessor. | ||
| 48 | |||
| 49 | ## Assets | ||
| 50 | `FileAttachment` (`app/models/concerns/file_attachment.rb`) is a from- | ||
| 51 | scratch Paperclip replacement. `STYLES` is a hash of style name to a full | ||
| 52 | ImageMagick argument array (not just a geometry string — some styles need | ||
| 53 | `-gravity`/`-extent` alongside `-resize`, which a single string can't | ||
| 54 | express). `generate_variants` loops over `STYLES` generically; adding a | ||
| 55 | style needs no other code change, only `bundle exec rake | ||
| 56 | assets:regenerate_variants` to backfill existing assets. The URL contract | ||
| 57 | (`/system/uploads/:id/:style/:filename`) is stable and safe to parse | ||
| 58 | directly (the numeric id is always the second path segment) rather than | ||
| 59 | reconstructed 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 | ||
| 65 | with the current node), `order_by`/`order_direction`, and `limit` are | ||
| 66 | actually consumed. Only the first `[aggregate ...]` in a given body is | ||
| 67 | ever processed (`aggregate?` does a single, non-global match). | ||
| 68 | |||
| 69 | ## TinyMCE integration | ||
| 70 | `extended_valid_elements` must define a parent element and its commonly- | ||
| 71 | nested children symmetrically and explicitly together — leaving one | ||
| 72 | element on the schema's default rule while the other is explicitly | ||
| 73 | redefined can cause content loss on save, even when the child would be | ||
| 74 | perfectly valid under the untouched default alone. `valid_classes` denies | ||
| 75 | all classes by default (`'*': ''`); anything meant to render needs an | ||
| 76 | explicit per-element allowance. `content_style` injects CSS into the | ||
| 77 | editing iframe directly — the app's real public stylesheet is never loaded | ||
| 78 | there, so anything that needs to render correctly while editing needs its | ||
| 79 | own rule supplied this way. Constrained-input features (fixed placement | ||
| 80 | choices, for instance) are built as custom toolbar buttons with their own | ||
| 81 | dialog and `editor.insertContent()`, not the native image dialog or | ||
| 82 | `file_picker_callback` — those bring their own resize/edit affordances that | ||
| 83 | would undermine a genuinely fixed set of choices. | ||
| 84 | |||
| 85 | ## Testing | ||
| 86 | Shared test-only convenience methods (that need to operate across many | ||
| 87 | model/controller test files) live directly on `ActiveSupport::TestCase`, | ||
| 88 | in `test/test_helper.rb` — the framework's own designated extension point | ||
| 89 | for exactly this, not a monkey-patch of a production model class. | ||
| @@ -38,6 +38,7 @@ gem 'rails_icons' | |||
| 38 | gem 'globalize', '~> 7.0' # translated model attributes (Page title/abstract/body) | 38 | gem 'globalize', '~> 7.0' # translated model attributes (Page title/abstract/body) |
| 39 | gem 'acts_as_list' # page revision ordering | 39 | gem 'acts_as_list' # page revision ordering |
| 40 | gem 'will_paginate', '~> 3.0' | 40 | gem 'will_paginate', '~> 3.0' |
| 41 | gem '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 | |||
| 334 | DEPENDENCIES | 335 | DEPENDENCIES |
| 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 | ||
| 74 | end | 81 | end |
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 @@ | |||
| 1 | class 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 | ||
| 22 | end | ||
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 @@ | |||
| 1 | class 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 | ||
| 14 | end | ||
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 @@ | |||
| 1 | module 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 | ||
| 183 | end | ||
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 | ||
| 86 | end | 78 | end |
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 | ||
| 395 | end | 624 | end |
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 @@ | |||
| 1 | class 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 | ||
| 169 | end | ||
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 @@ | |||
| 1 | require 'digest/sha1' | 1 | require 'digest/sha1' |
| 2 | 2 | ||
| 3 | class User < ApplicationRecord | 3 | class 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 | 7 | Rails.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 | ||
| 32 | end | ||
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. | ||
| 2 | CSP_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. | ||
| 4 | if 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 | ||
| 19 | end | ||
| 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: "…" | 82 | page_gap: "…" |
| 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: "…" | 34 | page_gap: "…" |
| 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 @@ | |||
| 1 | class 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 | ||
| 19 | end | ||
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 @@ | |||
| 1 | class AddPasswordDigestToUsers < ActiveRecord::Migration[8.1] | ||
| 2 | def change | ||
| 3 | add_column :users, :password_digest, :string | ||
| 4 | end | ||
| 5 | end | ||
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 @@ | |||
| 1 | class DropNestedSetColumnsFromNodes < ActiveRecord::Migration[8.1] | ||
| 2 | def change | ||
| 3 | remove_column :nodes, :lft, :integer | ||
| 4 | remove_column :nodes, :rgt, :integer | ||
| 5 | end | ||
| 6 | end | ||
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 |
| 103 | end \ No newline at end of file | 105 | end |
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 @@ | |||
| 1 | module CccConventions | 1 | module 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 @@ | |||
| 1 | namespace :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 | ||
| 61 | end | ||
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 @@ | |||
| 1 | function 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 | ||
| 265 | restore_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 | |||
| 267 | event_search = { | 278 | event_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 | ||
| 40 | input[type=text], textarea { | 40 | input[type=text], |
| 41 | input[type=password], | ||
| 42 | textarea { | ||
| 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 | ||
| 46 | select { | 49 | select { |
| @@ -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 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class 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 | ||
| 8 | end | ||
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 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class 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 | ||
| 51 | end | ||
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 | |||
| 749 | test "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 | ||
| 650 | end | 760 | end |
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 | ||
| 85 | end | 97 | end |
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 | ||
| 3 | root: | 3 | root: |
| 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 | ||
| 12 | first_child: | 10 | first_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 | ||
| 21 | second_child: | 17 | second_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 | ||
| 30 | third_child: | 24 | third_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 | ||
| 39 | fourth_child: | 31 | fourth_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 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class 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 | ||
| 12 | end | ||
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 | ||
| 99 | end | 124 | end |
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 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class 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, "<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 | ||
| 146 | end | ||
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 @@ | |||
| 1 | require "test_helper" | ||
| 2 | |||
| 3 | class 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 | ||
| 128 | end | ||
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 | ||
| 624 | end | 865 | end |
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 @@ | |||
| 1 | require "test_helper" | ||
| 2 | |||
| 3 | class 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 | ||
| 168 | end | ||
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 | ||
| 373 | end | 408 | end |
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 | ||
| 58 | protected | 130 | protected |
| 59 | def create_user(options = {}) | 131 | def create_user(options = {}) |
