inline_forms 8.1.47 → 8.1.53

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/CHANGELOG.md +149 -0
  4. data/Gemfile +3 -0
  5. data/README.rdoc +47 -0
  6. data/app/assets/stylesheets/inline_forms/inline_forms.scss +34 -0
  7. data/app/controllers/concerns/file_slots_concern.rb +211 -0
  8. data/app/controllers/inline_forms/file_trash_controller.rb +115 -0
  9. data/app/controllers/inline_forms_controller.rb +82 -16
  10. data/app/helpers/inline_forms_file_trash_helper.rb +131 -0
  11. data/app/helpers/inline_forms_helper.rb +5 -1
  12. data/app/models/concerns/inline_forms/stored_files.rb +350 -0
  13. data/app/models/inline_forms/current.rb +8 -0
  14. data/app/models/inline_forms/file_trash_sweep.rb +37 -0
  15. data/app/models/inline_forms/trashed_file.rb +205 -0
  16. data/app/views/inline_forms/_file_slot.html.erb +39 -0
  17. data/app/views/inline_forms/_header.html.erb +3 -0
  18. data/app/views/inline_forms/_model_top_bar.html.erb +3 -4
  19. data/app/views/inline_forms/_show.html.erb +6 -0
  20. data/app/views/inline_forms/_versions.html.erb +4 -4
  21. data/app/views/inline_forms/_versions_file_entry.html.erb +36 -0
  22. data/app/views/inline_forms/_versions_list.html.erb +11 -2
  23. data/app/views/inline_forms/file_field_frame.html.erb +22 -0
  24. data/app/views/inline_forms/file_trash/index.html.erb +112 -0
  25. data/app/views/inline_forms/file_trash_panel.html.erb +90 -0
  26. data/app/views/layouts/inline_forms.html.erb +5 -1
  27. data/lib/generators/inline_forms/file_trash_generator.rb +36 -0
  28. data/lib/generators/inline_forms/templates/create_inline_forms_file_trash.rb.erb +41 -0
  29. data/lib/generators/inline_forms_addto_generator.rb +47 -0
  30. data/lib/generators/inline_forms_generator.rb +19 -2
  31. data/lib/generators/templates/model.erb +4 -0
  32. data/lib/inline_forms/form_element_from_callee.rb +25 -0
  33. data/lib/inline_forms/form_elements/simple_file_field_helper.rb +30 -2
  34. data/lib/inline_forms/gem_files.rb +5 -0
  35. data/lib/inline_forms/version.rb +1 -1
  36. data/lib/inline_forms.rb +55 -0
  37. data/lib/locales/inline_forms.en.yml +87 -0
  38. data/lib/locales/inline_forms.nl.yml +87 -3
  39. data/lib/tasks/inline_forms_files.rake +61 -0
  40. data/test/dummy/app/controllers/application_controller.rb +5 -0
  41. data/test/dummy/app/controllers/documents_controller.rb +4 -0
  42. data/test/dummy/app/controllers/dossiers_controller.rb +4 -0
  43. data/test/dummy/app/models/ability.rb +15 -0
  44. data/test/dummy/app/models/document.rb +33 -0
  45. data/test/dummy/app/models/dossier.rb +38 -0
  46. data/test/dummy/config/application.rb +4 -3
  47. data/test/dummy/config/routes.rb +13 -0
  48. data/test/inline_forms_addto_generator_test.rb +47 -0
  49. data/test/inline_forms_generator_test.rb +24 -0
  50. data/test/integration/field_authorization_test.rb +131 -0
  51. data/test/integration/file_slots_test.rb +574 -0
  52. data/test/integration/layout_title_test.rb +56 -0
  53. data/test/integration/model_top_bar_test.rb +9 -0
  54. data/test/integration/revert_skipped_columns_test.rb +59 -0
  55. data/test/integration_test_helper.rb +62 -0
  56. metadata +34 -3
  57. data/.claude/settings.local.json +0 -9
  58. data/CLAUDE.md +0 -68
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4f5d1ebb432475fd4db2c7eb7e92962b408b30a602c387f06af0b64a338cc4b
4
- data.tar.gz: ac8c2e604daa44def24c578fced85d308c2ab7fe1b4f8f023af11e470ed97a50
3
+ metadata.gz: 698cbe91c323804016404aed86eda2e03d171b2a5df33ab775d3cca68f3d4341
4
+ data.tar.gz: 7ea3231464409d606c023f4b34aeeb2212d293abeaefc7a876e5ec488f6dcc8b
5
5
  SHA512:
6
- metadata.gz: 9717b909093e9b6c48db6a9325093bfbfc738b18f25d495a28095109b54e51e1671fa97df157570a85e55ef6f4ca278f340fbb7d1d58f6e73a05bf8cb7009f65
7
- data.tar.gz: 3559bac6657e4ca87d33dc952d9031305db0682d6b1538e373c8193bc4e546f58d470949117d54a261676292f4fd48adb92f915cc096e76b4d69768e7de8fa92
6
+ metadata.gz: b2c7081ad209dcb79cc50ffa9cd61eaa920a9e44b609d4460a70df0aecfc65532ade283e4335836d0f824714e76107aa888931992d52f777e96868a28336ca4b
7
+ data.tar.gz: 0b7dc0db8f243a6d06467fa353d423252a97c8c33b14940f24264c99aa3d851253d8580a78e2ce0265d856925ae06753da1cb1d8b793c6716014262b03e093eb
data/.gitignore CHANGED
@@ -6,6 +6,9 @@ rdoc
6
6
 
7
7
  # yard generated
8
8
  doc
9
+ # ...but not the schema-edit gem's shipped docs: its install generator copies
10
+ # doc/schema-apply-workflow.yml.example into the host app.
11
+ !inline_forms_schema_edit/doc/
9
12
  .yardoc
10
13
 
11
14
  # bundler
data/CHANGELOG.md CHANGED
@@ -4,6 +4,155 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [8.1.53] - 2026-10-04
8
+
9
+ ### Fixed
10
+
11
+ - **Page title in the `inline_forms` layout.** `layouts/inline_forms` rendered `t('application_name')` into `<title>`, and no en translation existed (not in the gem, not in the generated app), so every page of a fresh example app (`/apartments`, `/file_trash`, …) got the title `<span class="translation_missing" …>Application Name</span> v8.1.51`: a bare `t()` returns that html_safe span, which `<title>` shows as text. The layout now calls `t('application_name', default: application_name)`, falling back to the host's `ApplicationHelper#application_name` (which the header partial already needs). With a default, `t()` returns plain text that ERB escapes, so a missing translation can never put markup into `<title>`.
12
+ - **Installer:** `config/locales/inline_forms_local.en.yml` now also defines `application_name: <app name>`, next to `inline_forms.general.application_title`.
13
+ - **nl locale:** removed the placeholder `application_name: naam van de applicatie` from `inline_forms.nl.yml`. It was the only definition of the key, so nl users saw "naam van de applicatie v…" as the page title; they now get the app name.
14
+
15
+ ### Tests
16
+
17
+ - Gem: `test/integration/layout_title_test.rb`: the title falls back to the helper, uses a host translation when present, ignores the old nl placeholder, and escapes markup in the translation.
18
+ - Example app: `example_app_layout_title_test.rb`: the installer defines the en `application_name`; `/apartments` and `/file_trash` are titled "<app name> v<version>", in en and for an nl user.
19
+
20
+ ### Lockstep
21
+
22
+ - validation_hints 8.1.53, inline_forms_installer 8.1.53, inline_forms_schema_edit 8.1.53.
23
+
24
+ ## [8.1.52] - 2026-10-04
25
+
26
+ ### Removed
27
+
28
+ - **"+ field" link removed from the engine's model top bar.** Since 8.1.45 (`cfd0977`) every app with the `inline_forms_schema_edit` routes showed the dev-only schema GUI link on every page, production included. It was meant for the example app only. It now ships only in the example app, as `app/views/inline_forms/_model_top_bar_left.html.erb`. An app installed with `--schema-edit` alone gets the routes and tables, but no nav link.
29
+
30
+ ### Added
31
+
32
+ - Model top bar hook: if the app has an `inline_forms/_model_top_bar_left` partial, it is rendered after the title, for app-specific `<li>` items. Nothing is rendered when the partial is absent.
33
+
34
+ ### Fixed
35
+
36
+ - **Gem packaging:** `InlineFormsGemFiles` sweeps untracked files too, so a local build also packaged `.claude/launch.json`, `.claude/settings.local.json` and `CLAUDE.md`. They are now excluded hard, like `stuff/`.
37
+
38
+ ### Tests
39
+
40
+ - Gem: the engine top bar has no schema GUI link even when the schema routes are drawn.
41
+ - Example app: the top bar links the schema GUI.
42
+
43
+ ### Lockstep
44
+
45
+ - validation_hints 8.1.52, inline_forms_installer 8.1.52, inline_forms_schema_edit 8.1.52.
46
+
47
+ ## [8.1.51] - 2026-10-04
48
+
49
+ ### Changed
50
+
51
+ - **File trash link moved to the user menu.** `link_to_inline_forms_file_trash` now sits in the user (logout) menu of the top bar, in the gem's `_header` and the example app's. It is still shown only to users who may `:read_file_trash` on `:all`, and it no longer depends on the admin-only "More" menu.
52
+
53
+ ### Fixed
54
+
55
+ - **Global trash page:** the "Delete selected permanently" button and the checkbox column are shown only when a row on the page can still be purged. Before, the Restored and Purged states showed the button with nothing to select. New `InlineForms::TrashedFile#purgeable?` (trashed, or expired and not yet swept).
56
+
57
+ ### Notes
58
+
59
+ - Known issues, parked (local note `stuff/2026-10-04-carrierwave-file-elements-oddities.md`) with the CarrierWave file elements (`file_field`, `audio_field`, `image_field`, `multi_image_field`): submitting without a file erases the file, no empty-state icon on file_field, no type allowlist, the gallery replaces instead of appending. Parked; not changed in this release.
60
+
61
+ ### Tests
62
+
63
+ - Gem: the global page offers bulk purge only when a listed row is purgeable.
64
+ - Example app: the user menu links the trash next to logout.
65
+
66
+ ### Lockstep
67
+
68
+ - validation_hints 8.1.51, inline_forms_installer 8.1.51, inline_forms_schema_edit 8.1.51.
69
+
70
+ ## [8.1.50] - 2026-10-03
71
+
72
+ ### Added
73
+
74
+ - **Declared file slots with a file trash (`InlineForms::StoredFiles`).** A `:simple_file_field` whose bytes live in the record's own table can now be replaced, removed, restored (undo) and permanently deleted, with history. Opt-in per model:
75
+
76
+ ```ruby
77
+ class Client < ApplicationRecord
78
+ include InlineForms::StoredFiles
79
+ inline_forms_file :filename # data / content_type
80
+ inline_forms_file :begeleidingsplan_filename # begeleidingsplan_data / _content_type
81
+ end
82
+ ```
83
+
84
+ The macro defines the upload setter (in a prepended module; a hand-written `<attr>=` for a declared slot raises outside production), adds the slot's three columns to PaperTrail's `skip:` on that model only (in either order with `has_paper_trail`), and trashes files on `destroy`. Removing or replacing never deletes bytes: they move to `inline_forms_trashed_files` and stay restorable for `InlineForms.file_trash_retention` (default 30 days; `nil` = never expire). Purge deletes the bytes and keeps a tombstone (filename, size, SHA-256, who/when); `InlineForms.file_trash_redact_on_purge` (default off) also blanks the filename. `InlineForms.max_file_size` (default nil) adds a size validation.
85
+ - **Field UI for declared slots.** Download link (served by the gem: `disposition: attachment`, `Cache-Control: no-store`, 404 on an empty slot), a replace icon, a remove icon with a confirm dialog, an inline "removed · undo" notice, and on an empty slot a "restore removed file" link. Each control is shown only when the matching CanCan action is granted.
86
+ - **Per-record trash panel** ("Removed files (n)") beside Versions, limited to the slots of the attribute list being shown (tabs): download, restore, delete permanently; restored and purged rows stay as greyed history with who/when and the expiry date.
87
+ - **File events in the Versions panel** (uploaded / removed / replaced / restored / purged) via `inline_forms_history_for`, merged on top of `inline_forms_versions_for` (which hosts may still override). The restore link on a file event uses `restore_file`, not `revert`.
88
+ - **Global trash page** (`/file_trash`, `InlineForms::FileTrashController`) for users who may `:read_file_trash` on `:all` (superadmin): filter by model and state, download / restore per row, bulk "delete permanently" (one transaction, every row authorized), files of deleted records, and the retention sweep's heartbeat with a warning when it is overdue (48 h). Header link via `link_to_inline_forms_file_trash`.
89
+ - **CanCan actions**, all checked attribute-level on the server: `:replace_file` (upload over an occupied slot, on top of `:update`), `:remove_file`, `:restore_file` (+ `:replace_file` when the slot is occupied, `:update` when empty), `:purge_file`, `:download_trashed_file`, `:read_file_trash`. Nothing is granted by default when CanCanCan is loaded; `can :read, :all` does not grant any of them.
90
+ - **Routes:** `InlineForms.file_routes(self)` inside a resource block (member routes `download_file`, `remove_file`, `restore_file`, `purge_file`, `trashed_file`, `file_trash`; order-independent), and `InlineForms.draw_file_trash_routes(self)` for the global page.
91
+ - **Generator** `rails g inline_forms:file_trash`: migration for `inline_forms_trashed_files` (LONGBLOB on MySQL) and `inline_forms_file_trash_sweeps`.
92
+ - **Rake tasks:** `inline_forms:file_trash:purge_expired` (daily from cron; records a heartbeat, exits non-zero on failure), `inline_forms:files:audit` (read-only: bytes without a filename, filenames without bytes), `inline_forms:files:trash_orphans CONFIRM=yes` (moves orphaned bytes into the trash).
93
+ - `rails g inline_forms Model x:simple_file_field` and `rails g inline_forms_addto Model x:simple_file_field` now generate a declared slot: the three columns, `include InlineForms::StoredFiles` + `inline_forms_file :x`, and `InlineForms.file_routes(self)` in the resource block (addto: when the block can be found, otherwise a reminder). No values hash is needed for a declared slot.
94
+ - en/nl translations under `inline_forms.files.*`; `errors.messages.inline_forms_file_too_large` / `inline_forms_file_missing`.
95
+
96
+ ### Changed
97
+
98
+ - `InlineFormsController#revert` runs in one transaction. Undoing a `destroy` puts the record's `record_destroyed` files back onto it *before* `save!` (so presence validations on the filename pass) and marks those trash rows restored; a slot whose file was purged in the meantime comes back empty. Reverting an update restores the slot columns to their DB values, so pre-adoption version rows that still carry a filename can no longer put an old name on the current bytes.
99
+ - Submitting the edit form of a declared slot without a file is a validation error ("no file chosen") instead of a silent success; a string param for a declared slot is never written.
100
+
101
+ ### Fixed
102
+
103
+ - `version_modified_by` looked up `Devise.mappings[:users]`, but Devise keys mappings by the singular scope (`:user`), so every "Done by" in the Versions panel showed "Unknown".
104
+ - Versions list header columns now line up with the rows (an "When" column was missing).
105
+
106
+ ### Upgrade notes (existing hosts)
107
+
108
+ 1. `rails g inline_forms:file_trash` + `db:migrate`.
109
+ 2. `rake inline_forms:files:audit` (and `trash_orphans CONFIRM=yes` if it reports orphaned bytes).
110
+ 3. Per model with raw-byte file columns: `include InlineForms::StoredFiles` + `inline_forms_file :attr`; delete the hand-written `attr=` setters and `sanitize_filename`.
111
+ 4. Routes: `InlineForms.file_routes(self)` in each such resource block, `InlineForms.draw_file_trash_routes(self)` once.
112
+ 5. Ability: grant the file actions per role (`:purge_file` typically superadmin only).
113
+ 6. Host download actions: `send_inline_forms_file(record.inline_forms_file_download(:attr))` after `authorize! :read, record, :attr` (or link to `download_file`).
114
+ 7. Initializer: `InlineForms.file_trash_retention = 30.days` (default) etc.
115
+ 8. Cron: `bin/rails inline_forms:file_trash:purge_expired` daily (inside the running container on Kamal hosts).
116
+ 9. Add the slot attribute names to `config.filter_parameters` (uploaded filenames are otherwise logged).
117
+
118
+ Rolling back to 8.1.49 leaves the trash table in place (bytes nobody can see): purge or restore the trash first, and disable the cron job.
119
+
120
+ ### Tests
121
+
122
+ - `test/integration/file_slots_test.rb` (dummy `Document` with two slots): upload/replace/identical re-upload, permissions per action, crafted string, size limit, failed save leaves no trash row, remove/restore/swap/purge/redact, IDOR, downloads, panel, destroy + undo (with a presence validation), update revert with an old filename in the version, retention sweep, global page incl. all-or-nothing bulk purge. Generator tests for both generators.
123
+ - Example app: `FormElementShowcase#manual` is a declared slot; `example_app_showcase_file_slot_test.rb` covers the generated wiring end to end.
124
+
125
+ ### Lockstep
126
+
127
+ - validation_hints 8.1.50, inline_forms_installer 8.1.50, inline_forms_schema_edit 8.1.50.
128
+
129
+ ## [8.1.49] - 2026-10-03
130
+
131
+ ### Security
132
+
133
+ - **Attribute-level authorization is now enforced on single-field requests.** `edit`, `update` and the single-attribute `show` were guarded only by `load_and_authorize_resource`, which authorizes the record; CanCanCan passes attribute-scoped rules when no attribute is given, so `cannot :update, Client, [:secret]` only hid the edit link and a hand-made PATCH still overwrote the field (and an attribute-scoped `can :update, Client, [:a, :b]` allowed every attribute). These actions now call `authorize!(:update, object, attribute)` (`:read` for `show`) when CanCanCan is loaded. Denials raise `CanCan::AccessDenied` like the record-level check.
134
+ - **`attribute` / `form_element` params are whitelisted** against the attribute list (`@inline_forms_attribute_list || object.inline_forms_attribute_list`) before use; anything else gets `400 Bad Request`. Previously `form_element` went straight into `send("#{form_element}_update")`, and `show` with `form_element=has_one` sent the `attribute` param to the record (`attribute=destroy` destroyed it on a GET). The edit links of the delegating elements (`plain_text_area` → `plain_text`, `text_area` → `rich_text`) are accepted via `InlineForms::FORM_ELEMENT_DELEGATES`; see `InlineForms.attribute_list_row_for`.
135
+
136
+ ### Tests
137
+
138
+ - The dummy app now loads CanCanCan (`Ability` grants everything; tests narrow it with `Ability.restrictions`). New `test/integration/field_authorization_test.rb`: crafted PATCH on a `cannot :update` attribute, attribute-scoped `can`, edit/show denials, mismatched/unknown `form_element`, unlisted attribute, the `show` method-send.
139
+
140
+ ### Lockstep
141
+
142
+ - validation_hints 8.1.49, inline_forms_installer 8.1.49, inline_forms_schema_edit 8.1.49.
143
+
144
+ ## [8.1.48] - 2026-10-03
145
+
146
+ ### Fixed
147
+
148
+ - **Revert no longer wipes columns excluded from PaperTrail.** `InlineFormsController#revert` reified versions with PaperTrail's default `unversioned_attributes: :nil`, which sets every `has_paper_trail skip:` column to nil on the live record; the following `save!` persisted it. Hosts that keep file bytes in a skipped column (e.g. `skip: [:data]` with a versioned `filename`) lost the bytes on every revert of an unrelated edit while the filename stayed, so downloads returned empty files. All revert-path reifies (including `revert_authorization_subject`, which reifies the same cached `version.item` first, and the ActionText restore) now pass `unversioned_attributes: :preserve`. Replaying a `destroy` revert onto a row that already exists now copies only the columns stored in the version, instead of overwriting skipped columns with nil from the fresh reified record.
149
+ - Regression tests: `test/integration/revert_skipped_columns_test.rb` (dummy `Dossier` model with `skip: [:data]`).
150
+ - Note: bytes in a skipped column are never in a version, so undoing a `destroy` still cannot bring them back; and rows already damaged by earlier reverts are not repaired by this fix.
151
+
152
+ ### Lockstep
153
+
154
+ - validation_hints 8.1.48, inline_forms_installer 8.1.48, inline_forms_schema_edit 8.1.48.
155
+
7
156
  ## [8.1.47] - 2026-07-25
8
157
 
9
158
  ### Fixed
data/Gemfile CHANGED
@@ -13,6 +13,9 @@ group :test do
13
13
  gem "will_paginate", "~> 4.0"
14
14
  gem "paper_trail", "~> 17.0"
15
15
  gem "turbo-rails"
16
+ # Generated apps always authorize through CanCanCan; the dummy loads it so
17
+ # the controller's attribute-level checks run (test/dummy/app/models/ability.rb).
18
+ gem "cancancan", "~> 3.6"
16
19
  # The schema-GUI engine gem (same repo, own gemspec). Test group so the
17
20
  # dummy app's `Bundler.require(*Rails.groups)` loads it and the engine's
18
21
  # schema_edit integration tests exercise the extracted controller/views.
data/README.rdoc CHANGED
@@ -263,6 +263,53 @@ In every case the Turbo wiring is the same: +link_options: { data: { turbo_frame
263
263
 
264
264
  New apps get +rails-i18n+ from RubyGems (+ '~> 8.1'+), not from the +svenfuchs/rails-i18n+ Git repository. The installer pins +rails ~> 8.1+ with +config.load_defaults 8.1+; the published +rails-i18n+ 8.x line matches that stack.
265
265
 
266
+ == File slots with undo and a trash (+InlineForms::StoredFiles+)
267
+
268
+ A +:simple_file_field+ can keep its bytes in the record's own table (a binary /
269
+ LONGBLOB column plus a content-type column). Declare the slot on the model:
270
+
271
+ class Client < ApplicationRecord
272
+ include InlineForms::StoredFiles
273
+ inline_forms_file :filename # columns: filename, data, content_type
274
+ inline_forms_file :begeleidingsplan_filename # begeleidingsplan_filename, _data, _content_type
275
+ end
276
+
277
+ # attribute list row: no values hash needed
278
+ [ :begeleidingsplan_filename, :simple_file_field ],
279
+
280
+ # routes.rb
281
+ resources :clients do
282
+ post 'revert', :on => :member
283
+ get 'list_versions', :on => :member
284
+ InlineForms.file_routes(self)
285
+ end
286
+ InlineForms.draw_file_trash_routes(self) # global trash page, /file_trash
287
+
288
+ Run <tt>rails g inline_forms:file_trash</tt> once for the trash tables, and
289
+ schedule <tt>bin/rails inline_forms:file_trash:purge_expired</tt> daily.
290
+ <tt>rails g inline_forms Model x:simple_file_field</tt> and
291
+ +inline_forms_addto+ generate all of this for you.
292
+
293
+ Users then get replace / remove (to the trash) / undo / restore, a "Removed
294
+ files" panel per record, file events in Versions, and (superadmin) a page with
295
+ every trashed file. Removing or replacing never deletes bytes; they stay
296
+ restorable for +InlineForms.file_trash_retention+ (30 days) until the sweep or
297
+ a user purges them (bytes gone, a tombstone with name, size and SHA-256 kept).
298
+ The slot columns are kept out of PaperTrail, so reverting a version never
299
+ touches a file.
300
+
301
+ Every action is a CanCan action, checked per attribute on the server, and none
302
+ is granted by default: +:replace_file+ (on top of +:update+), +:remove_file+,
303
+ +:restore_file+, +:purge_file+, +:download_trashed_file+, +:read_file_trash+.
304
+ The global page needs +:read_file_trash+ on +:all+.
305
+
306
+ Settings: +InlineForms.file_trash_retention+ (nil = never expire),
307
+ +InlineForms.file_trash_redact_on_purge+ (blank the filename on purge),
308
+ +InlineForms.max_file_size+. Maintenance: <tt>rake inline_forms:files:audit</tt>
309
+ (read-only consistency report) and <tt>inline_forms:files:trash_orphans
310
+ CONFIRM=yes</tt>. Backups are outside the gem's reach: a purged file stays in
311
+ database backups until their own rotation.
312
+
266
313
  == File uploads (CarrierWave)
267
314
 
268
315
  The +:image_field+ form element uses CarrierWave. Generated apps depend on +carrierwave '~> 3.1'+ from RubyGems, store uploads on the local filesystem under +public/uploads/+, and use the default uploader produced by +rails generate uploader Image+. CarrierWave 3.1 supports Rails 6.0 through 8.0 and is the upstream maintenance line.
@@ -514,3 +514,37 @@ turbo-frame {
514
514
  #input_search {
515
515
  border: none !important;
516
516
  }
517
+
518
+ /* Declared file slots (InlineForms::StoredFiles): the filename may break
519
+ anywhere; the action icons never wrap apart. */
520
+ .inline_forms-file-name {
521
+ overflow-wrap: anywhere;
522
+ }
523
+ .inline_forms-file-actions {
524
+ white-space: nowrap;
525
+ margin-left: 0.5em;
526
+ a + a {
527
+ margin-left: 0.6em;
528
+ }
529
+ }
530
+ .inline_forms-file-undo {
531
+ margin-left: 0.5em;
532
+ white-space: nowrap;
533
+ }
534
+ .inline_forms-file-hint,
535
+ .inline_forms-file-meta {
536
+ display: block;
537
+ font-size: 0.85em;
538
+ opacity: 0.75;
539
+ }
540
+ .inline_forms-file-notice {
541
+ margin: 0.25em 0 0;
542
+ font-size: 0.9em;
543
+ }
544
+ .inline_forms-file-expiring {
545
+ color: var(--if-color-flash-accent);
546
+ font-weight: bold;
547
+ }
548
+ .inline_forms-file-trash-history {
549
+ opacity: 0.55;
550
+ }
@@ -0,0 +1,211 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ # Actions for declared file slots (InlineForms::StoredFiles) on a record:
4
+ # download, remove, restore (undo), purge, download a trashed file, and the
5
+ # per-record trash panel. Routed by InlineForms.file_routes(self) inside the
6
+ # resource block.
7
+ #
8
+ # Every action is excluded from load_and_authorize_resource and authorizes
9
+ # itself, attribute-level, because CanCanCan skips attribute-scoped rules
10
+ # when no attribute is given:
11
+ #
12
+ # download_file :read on the record + attribute
13
+ # remove_file :remove_file on the record + attribute
14
+ # restore_file :restore_file + :replace_file (slot occupied) or
15
+ # :update (slot empty) on the attribute
16
+ # purge_file :purge_file on the record + attribute
17
+ # trashed_file :download_trashed_file on the record + attribute
18
+ # file_trash :read_file_trash on the record (each slot filtered)
19
+ #
20
+ # The attribute must be a declared slot that the model's attribute list shows
21
+ # as :simple_file_field (400 otherwise); a trash entry must belong to this
22
+ # record and attribute (404 otherwise), so ids can't be guessed across records.
23
+ module FileSlotsConcern
24
+ extend ActiveSupport::Concern
25
+
26
+ FILE_SLOT_ACTIONS = %i[download_file remove_file restore_file purge_file trashed_file file_trash].freeze
27
+
28
+ included do
29
+ before_action :set_inline_forms_current_user
30
+ helper_method :inline_forms_file_trash_slots_param
31
+ end
32
+
33
+ def download_file
34
+ @object = referenced_object
35
+ return unless file_slot_request_permitted?(:read)
36
+
37
+ file = @object.inline_forms_file_download(@attribute)
38
+ return render_missing_file unless file
39
+
40
+ send_inline_forms_file(file)
41
+ end
42
+
43
+ def remove_file
44
+ @object = referenced_object
45
+ return unless file_slot_request_permitted?(:remove_file)
46
+
47
+ @file_entry = @object.inline_forms_remove_file!(@attribute, by: inline_forms_current_user_id)
48
+ @file_notice = @file_entry ? :removed : :already_empty
49
+ render_file_slot_streams
50
+ end
51
+
52
+ def restore_file
53
+ @object = referenced_object
54
+ return unless file_slot_request_permitted?(:restore_file)
55
+
56
+ entry = find_file_entry
57
+ return head :not_found unless entry
58
+ authorize!(@object.inline_forms_file_present?(@attribute) ? :replace_file : :update, @object, @attribute.to_sym) if cancan_enabled?
59
+
60
+ begin
61
+ entry.restore!(by: inline_forms_current_user_id)
62
+ @object.reload
63
+ @file_notice = :restored
64
+ rescue InlineForms::TrashedFile::NotRestorable
65
+ @file_notice = :not_restorable
66
+ end
67
+ render_file_slot_streams
68
+ end
69
+
70
+ def purge_file
71
+ @object = referenced_object
72
+ return unless file_slot_request_permitted?(:purge_file)
73
+
74
+ entry = find_file_entry
75
+ return head :not_found unless entry
76
+
77
+ @file_notice = entry.purge!(by: inline_forms_current_user_id) ? :purged : :not_purgeable
78
+ render_file_slot_streams
79
+ end
80
+
81
+ def trashed_file
82
+ @object = referenced_object
83
+ return unless file_slot_request_permitted?(:download_trashed_file)
84
+
85
+ entry = find_file_entry
86
+ return head :not_found unless entry
87
+ return render_missing_file unless entry.trashed?
88
+
89
+ data = entry.file_data
90
+ return render_missing_file if data.nil?
91
+
92
+ # Access to possibly mis-filed personal data: log who opened what.
93
+ Rails.logger.info(
94
+ "inline_forms file_trash download entry=#{entry.id} #{entry.record_type}##{entry.record_id}." \
95
+ "#{entry.attribute_name} user=#{inline_forms_current_user_id.inspect}"
96
+ )
97
+ # Never render a trashed file inline (stored HTML/SVG would run on our
98
+ # origin): always an attachment, always octet-stream.
99
+ send_inline_forms_file({ data: data, filename: entry.filename.presence || "file", type: "application/octet-stream" })
100
+ end
101
+
102
+ def file_trash
103
+ @object = referenced_object
104
+ authorize!(:read_file_trash, @object) if cancan_enabled?
105
+ @update_span = params[:update]
106
+ @file_trash_slots = file_trash_slots_from_params
107
+ @file_trash_open = params[:close].blank?
108
+ render "inline_forms/file_trash_panel", layout: "turbo_rails/frame"
109
+ end
110
+
111
+ # For host download actions that predate download_file (e.g. a `dl` route
112
+ # already linked from elsewhere):
113
+ # def dl
114
+ # @client = Client.find(params[:id])
115
+ # authorize! :read, @client, :filename
116
+ # send_inline_forms_file(@client.inline_forms_file_download(:filename)) or head :not_found
117
+ # end
118
+ def send_inline_forms_file(file)
119
+ return render_missing_file if file.nil?
120
+
121
+ response.headers["Cache-Control"] = "no-store"
122
+ send_data file[:data], filename: file[:filename], type: file[:type], disposition: :attachment
123
+ end
124
+
125
+ private
126
+
127
+ def set_inline_forms_current_user
128
+ InlineForms::Current.user_id = inline_forms_current_user_id
129
+ end
130
+
131
+ def inline_forms_current_user_id
132
+ user = respond_to?(:current_user, true) ? current_user : nil
133
+ user.respond_to?(:id) ? user.id : nil
134
+ end
135
+
136
+ # Validates the attribute (declared slot shown as :simple_file_field in the
137
+ # attribute list), then authorizes +action+ on it. Renders and returns false
138
+ # when refused.
139
+ def file_slot_request_permitted?(action)
140
+ @attribute = params[:attribute].to_s
141
+ @form_element = "simple_file_field"
142
+ @update_span = params[:update]
143
+ unless inline_forms_file_slot_listed?(@object, @attribute)
144
+ head :bad_request
145
+ return false
146
+ end
147
+ authorize!(action, @object, @attribute.to_sym) if cancan_enabled?
148
+ true
149
+ end
150
+
151
+ def inline_forms_file_slot_listed?(object, attribute)
152
+ klass = object.class
153
+ return false unless klass.respond_to?(:inline_forms_file_slot?) && klass.inline_forms_file_slot?(attribute)
154
+
155
+ attributes = @inline_forms_attribute_list || object.inline_forms_attribute_list
156
+ InlineForms.attribute_list_row_for(attributes, attribute, "simple_file_field").present?
157
+ end
158
+
159
+ def find_file_entry
160
+ InlineForms::TrashedFile.without_data.for_slot(@object, @attribute).trash_entries.find_by(id: params[:trash_id])
161
+ end
162
+
163
+ # The slots the trash panel lists: the ones the page passed (the current
164
+ # tab's), reduced to declared, listed, readable slots.
165
+ def file_trash_slots_from_params
166
+ requested = params[:slots].to_s.split(",").map(&:strip).reject(&:empty?)
167
+ requested = @object.class.inline_forms_file_slots.keys.map(&:to_s) if requested.empty? && @object.class.respond_to?(:inline_forms_file_slots)
168
+ requested.select do |attribute|
169
+ inline_forms_file_slot_listed?(@object, attribute) &&
170
+ (cancan_disabled? || can?(:read_file_trash, @object, attribute.to_sym))
171
+ end
172
+ end
173
+
174
+ def inline_forms_file_trash_slots_param
175
+ Array(@file_trash_slots).join(",")
176
+ end
177
+
178
+ def render_missing_file
179
+ render plain: t("inline_forms.files.missing"), status: :not_found
180
+ end
181
+
182
+ # The field, the record's trash panel and its versions panel all change
183
+ # after a file action; replace the three frames in one response.
184
+ def render_file_slot_streams
185
+ @file_trash_slots = file_trash_slots_from_params
186
+ @file_trash_open = params[:panel] == "open"
187
+ field_id = "#{@object.class.name.underscore}_#{@object.id}_#{@attribute}"
188
+ trash_id = helpers.inline_forms_file_trash_turbo_frame_id(@object)
189
+ versions_id = helpers.inline_forms_versions_turbo_frame_id(@object)
190
+ @inline_forms_turbo_field = true
191
+
192
+ field_html = render_to_string("inline_forms/file_field_frame", layout: false, formats: [ :html ],
193
+ locals: { frame_id: field_id })
194
+ streams = [ turbo_stream.replace(field_id, field_html) ]
195
+ # Only send the panels this user may see on the record page.
196
+ if cancan_disabled? || can?(:read_file_trash, @object)
197
+ trash_html = render_to_string("inline_forms/file_trash_panel", layout: false, formats: [ :html ],
198
+ locals: { update_span: trash_id })
199
+ streams << turbo_stream.replace(trash_id, trash_html)
200
+ end
201
+ if cancan_disabled? || can?(:list_versions, @object)
202
+ versions_html = render_to_string("inline_forms/versions_panel", layout: false, formats: [ :html ],
203
+ locals: { update_span: versions_id, object: @object })
204
+ streams << turbo_stream.replace(versions_id, versions_html)
205
+ end
206
+ respond_to do |format|
207
+ format.turbo_stream { render turbo_stream: streams }
208
+ format.html { render html: field_html, layout: "turbo_rails/frame" }
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,115 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ # The cross-record file trash (superadmin): every trashed file of every model,
4
+ # filterable, with download / restore / purge and a bulk purge. It is also the
5
+ # only place to purge files whose record was deleted, and it shows the
6
+ # retention sweep's heartbeat. Routed by InlineForms.draw_file_trash_routes.
7
+ #
8
+ # Access: authorize!(:read_file_trash, :all) -- granted by `can :manage, :all`
9
+ # (superadmin), never by `can :read, :all` (a different action) or by
10
+ # per-model grants. Each row action is authorized again against the file's
11
+ # record (or, when the record is gone, its class) and attribute.
12
+ class InlineForms::FileTrashController < ::ApplicationController
13
+ include CancanConcern
14
+
15
+ layout "inline_forms"
16
+
17
+ before_action { InlineForms::Current.user_id = current_user_id }
18
+ before_action { authorize!(:read_file_trash, :all) if cancan_enabled? }
19
+
20
+ STATES = %w[trashed expired restored purged all].freeze
21
+ PER_PAGE = 50
22
+
23
+ def index
24
+ @models = InlineForms::TrashedFile.trash_entries.distinct.pluck(:record_type).sort
25
+ @model = params[:model].presence_in(@models)
26
+ @state = params[:state].presence_in(STATES) || "trashed"
27
+ @entries = filtered_entries.paginate(page: params[:page], per_page: PER_PAGE)
28
+ @last_sweep = InlineForms::FileTrashSweep.last_run
29
+ @sweep_overdue = InlineForms::FileTrashSweep.overdue?
30
+ end
31
+
32
+ # Bulk purge of the checked rows: one transaction, every row authorized
33
+ # (any refusal rolls the whole request back).
34
+ def purge
35
+ ids = Array(params[:ids]).map(&:to_i).reject(&:zero?)
36
+ return redirect_to(back_to_index, alert: t("inline_forms.files.global.nothing_selected")) if ids.empty?
37
+
38
+ purged = 0
39
+ InlineForms::TrashedFile.transaction do
40
+ InlineForms::TrashedFile.without_data.trash_entries.where(id: ids).each do |entry|
41
+ authorize_entry!(:purge_file, entry)
42
+ purged += 1 if entry.purge!(by: current_user_id)
43
+ end
44
+ end
45
+ redirect_to back_to_index, notice: t("inline_forms.files.global.purged_count", count: purged)
46
+ end
47
+
48
+ def restore
49
+ entry = find_entry
50
+ record = entry.owner
51
+ return redirect_to(back_to_index, alert: t("inline_forms.files.notice_not_restorable")) unless record
52
+
53
+ authorize_entry!(:restore_file, entry, record)
54
+ if cancan_enabled?
55
+ authorize!(record.inline_forms_file_present?(entry.attribute_name) ? :replace_file : :update,
56
+ record, entry.attribute_name.to_sym)
57
+ end
58
+ entry.restore!(by: current_user_id)
59
+ redirect_to back_to_index, notice: t("inline_forms.files.notice_restored")
60
+ rescue InlineForms::TrashedFile::NotRestorable
61
+ redirect_to back_to_index, alert: t("inline_forms.files.notice_not_restorable")
62
+ end
63
+
64
+ def download
65
+ entry = find_entry
66
+ authorize_entry!(:download_trashed_file, entry)
67
+ data = entry.trashed? ? entry.file_data : nil
68
+ return render(plain: t("inline_forms.files.missing"), status: :not_found) if data.nil?
69
+
70
+ Rails.logger.info(
71
+ "inline_forms file_trash download entry=#{entry.id} #{entry.record_type}##{entry.record_id}." \
72
+ "#{entry.attribute_name} user=#{current_user_id.inspect}"
73
+ )
74
+ response.headers["Cache-Control"] = "no-store"
75
+ send_data data, filename: entry.filename.presence || "file", type: "application/octet-stream", disposition: :attachment
76
+ end
77
+
78
+ private
79
+
80
+ def filtered_entries
81
+ scope = InlineForms::TrashedFile.without_data.trash_entries.newest_first
82
+ scope = scope.where(record_type: @model) if @model
83
+ case @state
84
+ when "trashed" then scope.restorable
85
+ when "expired" then scope.still_trashed.expired
86
+ when "restored" then scope.where.not(restored_at: nil)
87
+ when "purged" then scope.where.not(purged_at: nil)
88
+ else scope
89
+ end
90
+ end
91
+
92
+ def find_entry
93
+ InlineForms::TrashedFile.without_data.trash_entries.find(params[:id])
94
+ end
95
+
96
+ # Against the record when it still exists, else its class: a grant like
97
+ # `can :purge_file, Client` then still covers files of deleted clients.
98
+ def authorize_entry!(action, entry, record = entry.owner)
99
+ return unless cancan_enabled?
100
+
101
+ subject = record || entry.owner_class
102
+ raise CanCan::AccessDenied.new(nil, action, entry) unless subject
103
+
104
+ authorize!(action, subject, entry.attribute_name.to_sym)
105
+ end
106
+
107
+ def back_to_index
108
+ inline_forms_file_trash_path(params.permit(:model, :state, :page).to_h.compact_blank)
109
+ end
110
+
111
+ def current_user_id
112
+ user = respond_to?(:current_user, true) ? current_user : nil
113
+ user.respond_to?(:id) ? user.id : nil
114
+ end
115
+ end