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.
- checksums.yaml +4 -4
- data/README.md +248 -7
- data/Rakefile +63 -0
- data/app/controllers/paper_trail_history/application_controller.rb +79 -1
- data/app/controllers/paper_trail_history/models_controller.rb +7 -20
- data/app/controllers/paper_trail_history/records_controller.rb +9 -13
- data/app/controllers/paper_trail_history/versions_controller.rb +19 -6
- data/app/helpers/paper_trail_history/application_helper.rb +72 -0
- data/app/models/paper_trail_history/trackable_model.rb +173 -17
- data/app/models/paper_trail_history/version_decorator.rb +115 -18
- data/app/models/paper_trail_history/version_service.rb +275 -38
- data/app/views/layouts/paper_trail_history/application.html.erb +10 -102
- data/app/views/paper_trail_history/models/index.html.erb +17 -13
- data/app/views/paper_trail_history/models/show.html.erb +9 -9
- data/app/views/paper_trail_history/models/versions.html.erb +7 -6
- data/app/views/paper_trail_history/records/show.html.erb +10 -10
- data/app/views/paper_trail_history/records/versions.html.erb +9 -8
- data/app/views/paper_trail_history/shared/_pagination.html.erb +6 -0
- data/app/views/paper_trail_history/shared/_scripts.html.erb +34 -0
- data/app/views/paper_trail_history/shared/_styles.html.erb +79 -0
- data/app/views/paper_trail_history/shared/_version_filters.html.erb +12 -12
- data/app/views/paper_trail_history/shared/_versions_table.html.erb +12 -14
- data/app/views/paper_trail_history/versions/show.html.erb +24 -33
- data/config/locales/de.yml +107 -1
- data/config/locales/en.yml +108 -2
- data/lib/paper_trail_history/configuration.rb +226 -0
- data/lib/paper_trail_history/engine.rb +10 -0
- data/lib/paper_trail_history/version.rb +2 -1
- data/lib/paper_trail_history.rb +44 -1
- metadata +21 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8d783c6662b5a2e8b0a95cb2b3919ff0003b1e41a896299a319b57208f0cc5b
|
|
4
|
+
data.tar.gz: ff66f4f17756391bd5c7194a49c39198315ca3263ab1918e511f7e786d5554fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
@
|
|
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 =
|
|
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
|
-
@
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
28
|
-
|
|
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
|