paper_trail_history 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +248 -7
  3. data/Rakefile +63 -0
  4. data/app/controllers/paper_trail_history/application_controller.rb +79 -1
  5. data/app/controllers/paper_trail_history/models_controller.rb +7 -20
  6. data/app/controllers/paper_trail_history/records_controller.rb +9 -13
  7. data/app/controllers/paper_trail_history/versions_controller.rb +19 -6
  8. data/app/helpers/paper_trail_history/application_helper.rb +72 -0
  9. data/app/models/paper_trail_history/trackable_model.rb +173 -17
  10. data/app/models/paper_trail_history/version_decorator.rb +115 -18
  11. data/app/models/paper_trail_history/version_service.rb +275 -38
  12. data/app/views/layouts/paper_trail_history/application.html.erb +10 -102
  13. data/app/views/paper_trail_history/models/index.html.erb +17 -13
  14. data/app/views/paper_trail_history/models/show.html.erb +9 -9
  15. data/app/views/paper_trail_history/models/versions.html.erb +7 -6
  16. data/app/views/paper_trail_history/records/show.html.erb +10 -10
  17. data/app/views/paper_trail_history/records/versions.html.erb +9 -8
  18. data/app/views/paper_trail_history/shared/_pagination.html.erb +6 -0
  19. data/app/views/paper_trail_history/shared/_scripts.html.erb +34 -0
  20. data/app/views/paper_trail_history/shared/_styles.html.erb +79 -0
  21. data/app/views/paper_trail_history/shared/_version_filters.html.erb +12 -12
  22. data/app/views/paper_trail_history/shared/_versions_table.html.erb +12 -14
  23. data/app/views/paper_trail_history/versions/show.html.erb +24 -33
  24. data/config/locales/de.yml +107 -1
  25. data/config/locales/en.yml +108 -2
  26. data/lib/paper_trail_history/configuration.rb +226 -0
  27. data/lib/paper_trail_history/engine.rb +10 -0
  28. data/lib/paper_trail_history/version.rb +2 -1
  29. data/lib/paper_trail_history.rb +44 -1
  30. metadata +21 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d4cf46205ebd10b78ce9d159df2a384e2b00071a80efad39bf2e79bbcb84b05
4
- data.tar.gz: 7b1155089a6b7328d76d63d137fb527e0e68219da77da09f1358d824c6f2bfe5
3
+ metadata.gz: e8d783c6662b5a2e8b0a95cb2b3919ff0003b1e41a896299a319b57208f0cc5b
4
+ data.tar.gz: ff66f4f17756391bd5c7194a49c39198315ca3263ab1918e511f7e786d5554fe
5
5
  SHA512:
6
- metadata.gz: 92abd0ba47e73f3980186c8f52819b58b7cdf33becc042d49703233a070c3a43a312f45d2d69ecd9bc10e67bb974675c93749f05eb528dd1806f410763e36ff4
7
- data.tar.gz: f9a7cd1637b73274d866138706ad49ff1f1b1222575f4aca7dff2f8114584c9b30bcfb20f71ed731fb0af1234cb6bebef631f5eb2d23584a0bcd571dbee8cbec
6
+ metadata.gz: 7aca6f92cae47c07111415c234b4c3357028f997dc8325ab9ba20ad79308bba5e86557a63f188ad8166a495798cfc6d6685908a532e6aef24b32787340ad080b
7
+ data.tar.gz: 7981eea56e36d5d74c37e7445a29b0f4f8148a8ed5d4dd8182d17063e8de39c610c127495f5ba8c2818960896c1e1839f8256db4a42603d11268094e6adf5da8
data/README.md CHANGED
@@ -34,14 +34,239 @@ Rails.application.routes.draw do
34
34
  end
35
35
  ```
36
36
 
37
+ Then configure access control - **mounting alone is not enough**, see below.
38
+
39
+ ## Security
40
+
41
+ > [!IMPORTANT]
42
+ > This engine has **no authentication of its own**. It exposes the complete audit
43
+ > trail of every versioned model - including historical values of attributes that
44
+ > may since have been changed or redacted - and it exposes a `PATCH` endpoint that
45
+ > overwrites live records. Treat the mount point like an admin console.
46
+
47
+ `PaperTrailHistory::ApplicationController` does **not** inherit from your
48
+ application's `ApplicationController`, so none of your `before_action` filters run
49
+ inside the engine. You have to grant access explicitly.
50
+
51
+ Since 0.3.0 the engine **refuses every request with `403 Forbidden` unless you
52
+ configure it**. Development and test environments stay open (with a log warning) so
53
+ that the dummy app and your test suite keep working without an initializer.
54
+
55
+ ### Configuration
56
+
57
+ Create `config/initializers/paper_trail_history.rb`:
58
+
59
+ ```ruby
60
+ PaperTrailHistory.configure do |config|
61
+ # Option A: inherit from a controller that already authenticates.
62
+ # The engine keeps its own layout, only the filters are inherited.
63
+ config.parent_controller = 'Admin::BaseController'
64
+
65
+ # Option B: run your own filter. Executed via instance_exec in the controller,
66
+ # so current_user, session, redirect_to, head and main_app.* are all available.
67
+ config.authenticate_with = -> { redirect_to main_app.root_path unless current_user&.admin? }
68
+
69
+ # Optional: a separate gate for the destructive restore action.
70
+ # This one is a PREDICATE - return true to allow, false to deny.
71
+ # If unset, anyone who passes authentication may restore.
72
+ config.authorize_restore_with = -> { current_user.owner? }
73
+ end
74
+ ```
75
+
76
+ ### Assets and Content Security Policy
77
+
78
+ The interface uses Bootstrap, loaded from jsDelivr by default and pinned with a
79
+ subresource integrity hash so a tampered file is rejected by the browser. The
80
+ engine's own inline `<style>` and `<script>` carry the CSP nonce when your
81
+ application generates one, so a nonce-based policy works without `unsafe-inline`.
82
+
83
+ To serve the files yourself - required for an air-gapped network, or a policy
84
+ that allows no third-party origin - point them at your own assets and drop the
85
+ integrity hashes:
86
+
87
+ ```ruby
88
+ config.assets = {
89
+ bootstrap_css: { href: '/assets/bootstrap.css' },
90
+ bootstrap_icons_css: { href: '/assets/bootstrap-icons.css' },
91
+ bootstrap_js: { href: '/assets/bootstrap.bundle.js' }
92
+ }
93
+ ```
94
+
95
+ > [!WARNING]
96
+ > If your nonce generator returns an empty string the browser rejects the whole
97
+ > nonce source and blocks all inline style and script. Rails' commented-out
98
+ > suggestion, `request.session.id.to_s`, does exactly that on a request with no
99
+ > session yet. Prefer something that always yields a value, e.g.
100
+ > `->(_request) { SecureRandom.base64(16) }`.
101
+
102
+ ### Models with a non-integer primary key
103
+
104
+ The engine looks records up by the model's own primary key, so a model using a
105
+ UUID or any other custom key works.
106
+
107
+ > [!NOTE]
108
+ > PaperTrail's generated `versions` table declares `item_id` as `bigint`. A UUID
109
+ > written into that column becomes `0`, and the version history for such a model
110
+ > stays empty. This is a property of your versions table, not of this engine - if
111
+ > you version models with non-integer keys, `item_id` has to be a string column.
112
+
113
+ ### Single table inheritance
114
+
115
+ PaperTrail records the **base class** name in `item_type`, so a version created
116
+ by `Admin < User` is stored as `"User"`. The engine looks versions up by base
117
+ class and narrows them with PaperTrail's optional `item_subtype` column, which
118
+ holds the real class name.
119
+
120
+ If your versions table has an `item_subtype` column, each STI subclass shows
121
+ exactly its own history, and the base class shows all of it (matching
122
+ ActiveRecord's own STI semantics). Without that column the subtypes are
123
+ indistinguishable in the data, so a subclass falls back to showing its base
124
+ class's versions. To add it:
125
+
126
+ ```ruby
127
+ add_column :versions, :item_subtype, :string
128
+ add_index :versions, %i[item_type item_subtype]
129
+ ```
130
+
131
+ ### Version counts on the model list
132
+
133
+ The model list does **not** show a version count per model, because building that
134
+ column costs one `COUNT` query per version table and a count reads the whole
135
+ table. An application that gives each model its own version table therefore pays
136
+ one full table read per model, on its landing page. With ~120 models and tables
137
+ in the millions of rows, that is the difference between an instant page and a
138
+ page that takes tens of seconds.
139
+
140
+ Each model's own page always shows its count - that is a single query.
141
+
142
+ If your installation is small enough not to care, turn the column on:
143
+
144
+ ```ruby
145
+ config.show_version_counts = true
146
+ ```
147
+
148
+ > [!TIP]
149
+ > No index makes this faster. When a model has its own version table every row
150
+ > shares the same `item_type`, so an index on it cannot narrow the scan —
151
+ > counting is inherently proportional to the number of rows.
152
+
153
+ ### Page size
154
+
155
+ Version tables grow without bound, so the engine reads one page at a time rather
156
+ than loading a model's entire history into memory. The default is 25 rows:
157
+
158
+ ```ruby
159
+ config.page_limit = 50
160
+ ```
161
+
162
+ Pagination uses [Pagy](https://github.com/ddnexus/pagy). The limit is passed per
163
+ query rather than written into `Pagy::OPTIONS`, so mounting this engine does not
164
+ change pagination defaults elsewhere in your application.
165
+
166
+ ### Translations
167
+
168
+ Every string in the interface goes through I18n. The gem ships **English and
169
+ German**; dates and times use per-locale format strings, so each language orders
170
+ them its own way.
171
+
172
+ To translate the interface into another language, add a locale file under the
173
+ `paper_trail_history` scope - see `config/locales/en.yml` in this gem for the
174
+ full set of keys. To override individual strings, define the same key in your
175
+ application; your locale files load after the engine's and win.
176
+
177
+ > [!NOTE]
178
+ > Because only `en` and `de` ship, an application running under a third locale
179
+ > gets missing-translation errors unless I18n fallbacks are enabled:
180
+ > ```ruby
181
+ > config.i18n.fallbacks = [:en]
182
+ > ```
183
+
184
+ ### Redacting sensitive attributes
185
+
186
+ The interface shows every attribute of a record, and every before/after value in
187
+ a version diff. Without redaction that means password digests, API tokens and
188
+ session keys - including *historical* values that have since been rotated.
189
+
190
+ By default the engine hides whatever your application already hides from its
191
+ logs, i.e. `Rails.application.config.filter_parameters`. Attributes matched by
192
+ that list render as `[FILTERED]` on both the record page and the diff. To hide
193
+ more:
194
+
195
+ ```ruby
196
+ config.filter_attributes += [:internal_note, /_secret\z/]
197
+ ```
198
+
199
+ The list accepts everything `ActiveSupport::ParameterFilter` accepts - symbols,
200
+ strings (substring match), regular expressions and procs - and matching is
201
+ delegated to that class, so it behaves exactly like Rails log filtering.
202
+
203
+ > [!NOTE]
204
+ > Rails' default `filter_parameters` includes `:email`, so email addresses are
205
+ > redacted out of the box. Set `config.filter_attributes` explicitly if that is
206
+ > not what you want.
207
+
208
+ Either `parent_controller` or `authenticate_with` satisfies the check; you can use
209
+ both. Note the asymmetry: `authenticate_with` is a **filter** (halt the chain
210
+ yourself with `redirect_to`/`head`, which lets you pass Devise's
211
+ `authenticate_user!` straight in), while `authorize_restore_with` is a
212
+ **predicate** that returns a boolean.
213
+
214
+ `parent_controller` is read once, when Rails first loads the engine controller.
215
+ Set it in an initializer - changing it at runtime has no effect.
216
+
217
+ ### Route-level protection
218
+
219
+ Configuration composes with a route constraint, and using both is a good idea:
220
+
221
+ ```ruby
222
+ authenticate :user, ->(user) { user.admin? } do
223
+ mount PaperTrailHistory::Engine, at: '/revisions'
224
+ end
225
+ ```
226
+
227
+ ### Running without authentication
228
+
229
+ If a different layer protects the mount point (a VPN, a reverse proxy, an
230
+ IP allowlist), opt out explicitly:
231
+
232
+ ```ruby
233
+ config.allow_unauthenticated_access = true
234
+ ```
235
+
37
236
  ## Prerequisites
38
237
 
39
238
  This engine requires:
40
- - Rails >= 7.2
239
+ - Ruby >= 3.3.0
240
+ - Rails >= 8.0
41
241
  - PaperTrail >= 15.0 (configured with `has_paper_trail` in your models)
242
+ - Pagy ~> 43.0 (installed automatically, see the note in the changelog)
42
243
 
43
244
  Make sure you have PaperTrail properly configured in your Rails application before using this engine.
44
245
 
246
+ ### YAML deserialization (required for restoring)
247
+
248
+ PaperTrail stores the previous state of a record as YAML. Since Rails 7.1, Rails
249
+ loads only an explicitly permitted set of classes out of a YAML column, and that
250
+ set does **not** include `ActiveSupport::TimeWithZone`. Every model with
251
+ `created_at`/`updated_at` therefore fails to reify, and restoring dies with:
252
+
253
+ ```
254
+ Tried to load unspecified class: ActiveSupport::TimeWithZone
255
+ ```
256
+
257
+ Browsing history is unaffected - only restoring breaks. Permit the types your
258
+ models actually store, in `config/application.rb`:
259
+
260
+ ```ruby
261
+ config.active_record.yaml_column_permitted_classes = [
262
+ Symbol, Date, Time, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone, BigDecimal
263
+ ]
264
+ ```
265
+
266
+ Add any other class your models serialize (for example `ActiveSupport::HashWithIndifferentAccess`).
267
+ If a class is missing, the engine reports which setting to change instead of
268
+ showing the raw Psych error.
269
+
45
270
  ## Usage
46
271
 
47
272
  After mounting the engine, navigate to `/revisions` (or whatever path you chose) in your browser to access the interface.
@@ -57,7 +282,7 @@ After mounting the engine, navigate to `/revisions` (or whatever path you chose)
57
282
  - View all versions for a specific model
58
283
  - Filter by event type, user, date range
59
284
  - Search within version content
60
- - Pagination support
285
+ - Pagination (25 rows per page by default, see `config.page_limit`)
61
286
 
62
287
  3. **Record Versions** (`/revisions/models/:model_name/:record_id/versions`)
63
288
  - View version history for a specific record
@@ -139,6 +364,22 @@ bundle exec rake test TESTOPTS="-v"
139
364
  bundle exec rubocop
140
365
  ```
141
366
 
367
+ ### API Documentation
368
+
369
+ The public API is documented with YARD. CI fails if a public object loses its
370
+ documentation, so new API needs a docstring to merge.
371
+
372
+ ```bash
373
+ # Generate HTML docs into doc/
374
+ bundle exec rake yard
375
+
376
+ # List anything public that is undocumented (this is what CI runs)
377
+ bundle exec rake yard:coverage
378
+ ```
379
+
380
+ Internal helpers are tagged `@api private` and are excluded from both the
381
+ generated docs and the coverage gate.
382
+
142
383
  ### Testing Different Components
143
384
 
144
385
  ```bash
@@ -178,14 +419,14 @@ The dummy app includes:
178
419
  For comprehensive compatibility testing, use the provided Gemfiles:
179
420
 
180
421
  ```bash
181
- # Test against Rails 7.2
182
- BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle install
183
- BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle exec rake test
184
-
185
422
  # Test against Rails 8.0
186
- BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile bundle install
423
+ BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile bundle install
187
424
  BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile bundle exec rake test
188
425
 
426
+ # Test against Rails 8.1
427
+ BUNDLE_GEMFILE=gemfiles/rails_8.1.gemfile bundle install
428
+ BUNDLE_GEMFILE=gemfiles/rails_8.1.gemfile bundle exec rake test
429
+
189
430
  # Test against Rails main branch
190
431
  BUNDLE_GEMFILE=gemfiles/rails_main.gemfile bundle install
191
432
  BUNDLE_GEMFILE=gemfiles/rails_main.gemfile bundle exec rake test
data/Rakefile CHANGED
@@ -26,4 +26,67 @@ namespace :test do
26
26
  end
27
27
  end
28
28
 
29
+ begin
30
+ require 'yard'
31
+
32
+ YARD::Rake::YardocTask.new(:yard)
33
+
34
+ namespace :yard do
35
+ desc 'Fail when a public object of the API has no documentation'
36
+ task :coverage do
37
+ # The registry must be empty and the files must be read again. YARD keeps a
38
+ # cache in .yardoc, and a run against that cache reports the state of the
39
+ # last run instead of the state of the code.
40
+ YARD::Registry.clear
41
+ YARD::CLI::Yardoc.run('--no-output', '--no-save', '--no-stats', '--quiet')
42
+
43
+ # The raw text of the docstring decides. `blank?` and `present?` do not
44
+ # work here: YARD gives every method whose name ends with a question mark
45
+ # an automatic `@return [Boolean]` tag, thus such a method looks
46
+ # documented even without a single comment line.
47
+ undocumented = YARD::Registry.all.reject do |object|
48
+ object.docstring.all.to_s.strip.present? ||
49
+ object.visibility != :public ||
50
+ object.tag(:api)&.text == 'private'
51
+ end
52
+
53
+ unless undocumented.empty?
54
+ undocumented.sort_by(&:path).each { |object| warn("undocumented: #{object.path}") }
55
+ abort("#{undocumented.size} public objects of the API have no documentation.")
56
+ end
57
+
58
+ puts 'The public API is documented.'
59
+ end
60
+ end
61
+ rescue LoadError
62
+ # YARD is a development dependency. The tasks are missing without it.
63
+ nil
64
+ end
65
+
66
+ # The schema of the dummy application is in Git and names a Rails version. An
67
+ # older Rails refuses a newer schema with "Unknown migration version", thus the
68
+ # file must name the lowest supported Rails.
69
+ #
70
+ # This is a Rake task and not a test. The CI runs the migrations before the
71
+ # suite, and on a newer Rails that run rewrites the file. A test would then read
72
+ # the fresh file and not the file of the commit.
73
+ LOWEST_SUPPORTED_RAILS_SCHEMA = '8.0'
74
+
75
+ desc 'Check that the schema of the dummy application names the lowest supported Rails'
76
+ task :check_dummy_schema do
77
+ path = File.expand_path('test/dummy/db/schema.rb', __dir__)
78
+ declared = File.read(path)[/ActiveRecord::Schema\[([\d.]+)\]/, 1]
79
+
80
+ if declared == LOWEST_SUPPORTED_RAILS_SCHEMA
81
+ puts "The dummy schema names Rails #{declared}."
82
+ else
83
+ abort(
84
+ "test/dummy/db/schema.rb names Rails #{declared.inspect}, expected " \
85
+ "#{LOWEST_SUPPORTED_RAILS_SCHEMA.inspect}. Running the migrations on a newer Rails " \
86
+ 'rewrites this line. Set it back before you commit, else the matrix job of the ' \
87
+ 'lowest Rails cannot load the schema.'
88
+ )
89
+ end
90
+ end
91
+
29
92
  task default: :test
@@ -1,6 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PaperTrailHistory
4
- class ApplicationController < ActionController::Base
4
+ # Base controller of the engine. It applies the access rules that the host
5
+ # application sets with PaperTrailHistory.configure.
6
+ class ApplicationController < PaperTrailHistory.config.parent_controller_class
7
+ include Pagy::Method
8
+
9
+ # The engine keeps its own layout, also when it descends from a controller
10
+ # of the host application that declares a different one.
11
+ layout 'paper_trail_history/application'
12
+
13
+ before_action :require_configured_access
14
+ before_action :run_authentication_callback
15
+
16
+ class << self
17
+ # Writes the warning about unconfigured access one time for each process.
18
+ def warn_about_unconfigured_access
19
+ return if @unconfigured_access_warned
20
+
21
+ @unconfigured_access_warned = true
22
+ Rails.logger.warn(
23
+ '[paper_trail_history] The engine runs without access control. It shows the full audit trail ' \
24
+ 'and can overwrite records. Set config.authenticate_with or config.parent_controller in an ' \
25
+ 'initializer before you deploy. See the README for details.'
26
+ )
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ # Finds the trackable model of the request, or redirects.
33
+ #
34
+ # The name of the parameter differs between the controllers, thus the caller
35
+ # gives the value.
36
+ #
37
+ # @param model_name [String] value of the parameter that names the model
38
+ # @return [TrackableModel, nil] nil after a redirect
39
+ def find_trackable_model_or_redirect(model_name)
40
+ trackable_model = TrackableModel.find(model_name)
41
+ return trackable_model if trackable_model
42
+
43
+ redirect_to models_path, alert: t('paper_trail_history.errors.model_not_found', model_name: model_name)
44
+ nil
45
+ end
46
+
47
+ # Reads one page of the given versions and decorates only that page.
48
+ #
49
+ # The engine passes the limit for each call instead of writing it into
50
+ # Pagy::OPTIONS, because Pagy::OPTIONS is global and belongs to the host
51
+ # application.
52
+ def paginate_versions(versions)
53
+ pagy, page_of_versions = pagy(:offset, versions, limit: PaperTrailHistory.config.page_limit)
54
+
55
+ [pagy, VersionDecorator.decorate_collection(page_of_versions)]
56
+ end
57
+
58
+ def require_configured_access
59
+ return if PaperTrailHistory.config.access_configured?
60
+
61
+ unless PaperTrailHistory.config.enforce_access_control?
62
+ self.class.warn_about_unconfigured_access
63
+ return
64
+ end
65
+
66
+ render plain: t('paper_trail_history.errors.access_not_configured'), status: :forbidden
67
+ end
68
+
69
+ def run_authentication_callback
70
+ callback = PaperTrailHistory.config.authenticate_with
71
+ return if callback.nil?
72
+
73
+ instance_exec(&callback)
74
+ end
75
+
76
+ def authorize_restore
77
+ callback = PaperTrailHistory.config.authorize_restore_with
78
+ return if callback.nil?
79
+ return if instance_exec(&callback)
80
+
81
+ redirect_back_or_to(root_path, alert: t('paper_trail_history.errors.restore_not_allowed'))
82
+ end
5
83
  end
6
84
  end
@@ -4,16 +4,13 @@ module PaperTrailHistory
4
4
  # Controller for managing trackable model operations and displaying version histories
5
5
  class ModelsController < ApplicationController
6
6
  def index
7
- @trackable_models = TrackableModel.all_with_counts
7
+ @show_counts = PaperTrailHistory.config.show_version_counts
8
+ @trackable_models = @show_counts ? TrackableModel.all_with_counts : TrackableModel.all
8
9
  end
9
10
 
10
11
  def show
11
- @trackable_model = TrackableModel.find(params[:name])
12
-
13
- unless @trackable_model
14
- redirect_to models_path, alert: t('paper_trail_history.errors.model_not_found', model_name: params[:name])
15
- return
16
- end
12
+ @trackable_model = find_trackable_model_or_redirect(params[:name])
13
+ return unless @trackable_model
17
14
 
18
15
  @recent_versions = VersionDecorator.decorate_collection(
19
16
  @trackable_model.recent_versions(20)
@@ -21,7 +18,7 @@ module PaperTrailHistory
21
18
  end
22
19
 
23
20
  def versions
24
- @trackable_model = find_trackable_model_or_redirect
21
+ @trackable_model = find_trackable_model_or_redirect(params[:name])
25
22
  return unless @trackable_model
26
23
 
27
24
  load_versions_data
@@ -30,19 +27,9 @@ module PaperTrailHistory
30
27
 
31
28
  private
32
29
 
33
- def find_trackable_model_or_redirect
34
- trackable_model = TrackableModel.find(params[:name])
35
- unless trackable_model
36
- redirect_to models_path, alert: t('paper_trail_history.errors.model_not_found', model_name: params[:name])
37
- return nil
38
- end
39
- trackable_model
40
- end
41
-
42
30
  def load_versions_data
43
- @versions = VersionService.for_model(params[:name], filter_params)
44
- @versions = @versions.includes(:item)
45
- @decorated_versions = VersionDecorator.decorate_collection(@versions)
31
+ @versions = VersionService.for_model(params[:name], filter_params).includes(:item)
32
+ @pagy, @decorated_versions = paginate_versions(@versions)
46
33
  end
47
34
 
48
35
  def load_filter_options
@@ -4,7 +4,7 @@ module PaperTrailHistory
4
4
  # Controller for managing individual record operations and their version histories
5
5
  class RecordsController < ApplicationController
6
6
  def show
7
- @trackable_model = find_trackable_model_or_redirect
7
+ @trackable_model = find_trackable_model_or_redirect(params[:model_name])
8
8
  return unless @trackable_model
9
9
 
10
10
  load_record_data
@@ -12,7 +12,7 @@ module PaperTrailHistory
12
12
  end
13
13
 
14
14
  def versions
15
- @trackable_model = find_trackable_model_or_redirect
15
+ @trackable_model = find_trackable_model_or_redirect(params[:model_name])
16
16
  return unless @trackable_model
17
17
 
18
18
  load_record_data
@@ -22,17 +22,11 @@ module PaperTrailHistory
22
22
 
23
23
  private
24
24
 
25
- def find_trackable_model_or_redirect
26
- trackable_model = TrackableModel.find(params[:model_name])
27
- unless trackable_model
28
- redirect_to models_path, alert: t('paper_trail_history.errors.model_not_found', model_name: params[:model_name])
29
- return nil
30
- end
31
- trackable_model
32
- end
33
-
34
25
  def load_record_data
35
- @record = @trackable_model.klass.find_by(id: params[:record_id])
26
+ klass = @trackable_model.klass
27
+ # PaperTrail writes the value of the primary key into item_id. A model can
28
+ # use a primary key that is not called id.
29
+ @record = klass.find_by(klass.primary_key => params[:record_id])
36
30
  @record_id = params[:record_id]
37
31
  end
38
32
 
@@ -43,8 +37,10 @@ module PaperTrailHistory
43
37
  end
44
38
 
45
39
  def load_versions_with_filters
40
+ # No preload of :item here. This list shows the versions of one record and
41
+ # does not show the name of the item, thus a preload would only cost a query.
46
42
  @versions = VersionService.for_record(params[:model_name], params[:record_id], filter_params)
47
- @decorated_versions = VersionDecorator.decorate_collection(@versions)
43
+ @pagy, @decorated_versions = paginate_versions(@versions)
48
44
  end
49
45
 
50
46
  def load_available_events
@@ -3,29 +3,42 @@
3
3
  module PaperTrailHistory
4
4
  # Controller for managing version operations like viewing and restoring specific versions
5
5
  class VersionsController < ApplicationController
6
+ before_action :authorize_restore, only: :restore
6
7
  before_action :find_version, only: %i[show restore]
7
8
 
8
9
  def show
9
- @decorated_version = VersionDecorator.decorate(@version)
10
10
  @trackable_model = TrackableModel.find(@version.item_type)
11
+ return redirect_to_missing_model unless @trackable_model
12
+
13
+ @decorated_version = VersionDecorator.decorate(@version)
11
14
  end
12
15
 
13
16
  def restore
14
- result = VersionService.restore_version(@version.id)
17
+ result = VersionService.restore_version(@version)
15
18
 
16
19
  if result[:success]
17
- redirect_back_or_to(version_path(@version), notice: result[:message])
20
+ redirect_back_or_to(version_path(@version, model_name: @version.item_type), notice: result[:message])
18
21
  else
19
- redirect_back_or_to(version_path(@version),
22
+ redirect_back_or_to(version_path(@version, model_name: @version.item_type),
20
23
  alert: t('paper_trail_history.errors.restore_failed', error: result[:error]))
21
24
  end
22
25
  end
23
26
 
24
27
  private
25
28
 
29
+ # The version keeps the name of its model as text. An application that
30
+ # renames or deletes a model keeps versions that point to a name which is not
31
+ # trackable now. The page must not fail with an error in this case.
32
+ def redirect_to_missing_model
33
+ redirect_to models_path,
34
+ alert: t('paper_trail_history.errors.model_not_found', model_name: @version.item_type)
35
+ end
36
+
26
37
  def find_version
27
- @version = PaperTrail::Version.find(params[:id])
28
- rescue ActiveRecord::RecordNotFound
38
+ @version = VersionService.find_version(params[:id], params[:model_name])
39
+
40
+ return if @version
41
+
29
42
  redirect_to root_path, alert: t('paper_trail_history.errors.version_not_found')
30
43
  end
31
44
  end
@@ -3,5 +3,77 @@
3
3
  module PaperTrailHistory
4
4
  # Helper module providing utility methods for PaperTrailHistory views
5
5
  module ApplicationHelper
6
+ # Generate version path with model_name context when available
7
+ def version_link_path(version, options = {})
8
+ model_name = options[:model_name] || version.item_type
9
+ version_path(version.id, model_name: model_name)
10
+ end
11
+
12
+ # Generate restore version path with model_name context
13
+ def restore_version_link_path(version, options = {})
14
+ model_name = options[:model_name] || version.item_type
15
+ restore_version_path(version.id, model_name: model_name)
16
+ end
17
+
18
+ # Gives the nonce of the Content Security Policy, or nil.
19
+ #
20
+ # An empty nonce attribute is not useful: the browser refuses it and writes
21
+ # an error. The engine writes no attribute if the host application makes no
22
+ # nonce.
23
+ #
24
+ # @return [String, nil]
25
+ def engine_csp_nonce
26
+ content_security_policy_nonce.presence
27
+ end
28
+
29
+ # Makes the link tag for one of the stylesheets of the interface.
30
+ #
31
+ # The tag carries the integrity value, and a nonce when the host application
32
+ # uses a Content Security Policy with nonces. A policy that permits no other
33
+ # origin thus still works, if the host application serves the file itself.
34
+ #
35
+ # @param name [Symbol] key in the assets configuration
36
+ # @return [ActiveSupport::SafeBuffer]
37
+ def engine_stylesheet_tag(name)
38
+ asset = PaperTrailHistory.config.assets.fetch(name)
39
+
40
+ tag.link(rel: 'stylesheet', href: asset[:href], **asset_integrity_options(asset))
41
+ end
42
+
43
+ # Makes the script tag for one of the scripts of the interface.
44
+ #
45
+ # @param name [Symbol] key in the assets configuration
46
+ # @return [ActiveSupport::SafeBuffer]
47
+ def engine_javascript_tag(name)
48
+ asset = PaperTrailHistory.config.assets.fetch(name)
49
+
50
+ content_tag(:script, '', src: asset[:href], **asset_integrity_options(asset))
51
+ end
52
+
53
+ # Gives the attributes of a record for the interface. The value of an
54
+ # attribute that the host application filters becomes a placeholder, thus a
55
+ # password digest or an API token does not appear on the page.
56
+ #
57
+ # @param record [ActiveRecord::Base]
58
+ # @return [Array<Array(String, Object)>] pairs of name and value
59
+ def displayed_attributes(record)
60
+ record.attributes.map do |name, value|
61
+ next [name, t('paper_trail_history.display.filtered')] if PaperTrailHistory.config.filtered_attribute?(name)
62
+
63
+ [name, value]
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ # A file of another origin needs crossorigin together with integrity, else
70
+ # the browser cannot check the value. A file without an integrity value gets
71
+ # neither of the two attributes.
72
+ def asset_integrity_options(asset)
73
+ options = { nonce: engine_csp_nonce }
74
+ return options if asset[:integrity].blank?
75
+
76
+ options.merge(integrity: asset[:integrity], crossorigin: 'anonymous')
77
+ end
6
78
  end
7
79
  end