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.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/CHANGELOG.md +149 -0
- data/Gemfile +3 -0
- data/README.rdoc +47 -0
- data/app/assets/stylesheets/inline_forms/inline_forms.scss +34 -0
- data/app/controllers/concerns/file_slots_concern.rb +211 -0
- data/app/controllers/inline_forms/file_trash_controller.rb +115 -0
- data/app/controllers/inline_forms_controller.rb +82 -16
- data/app/helpers/inline_forms_file_trash_helper.rb +131 -0
- data/app/helpers/inline_forms_helper.rb +5 -1
- data/app/models/concerns/inline_forms/stored_files.rb +350 -0
- data/app/models/inline_forms/current.rb +8 -0
- data/app/models/inline_forms/file_trash_sweep.rb +37 -0
- data/app/models/inline_forms/trashed_file.rb +205 -0
- data/app/views/inline_forms/_file_slot.html.erb +39 -0
- data/app/views/inline_forms/_header.html.erb +3 -0
- data/app/views/inline_forms/_model_top_bar.html.erb +3 -4
- data/app/views/inline_forms/_show.html.erb +6 -0
- data/app/views/inline_forms/_versions.html.erb +4 -4
- data/app/views/inline_forms/_versions_file_entry.html.erb +36 -0
- data/app/views/inline_forms/_versions_list.html.erb +11 -2
- data/app/views/inline_forms/file_field_frame.html.erb +22 -0
- data/app/views/inline_forms/file_trash/index.html.erb +112 -0
- data/app/views/inline_forms/file_trash_panel.html.erb +90 -0
- data/app/views/layouts/inline_forms.html.erb +5 -1
- data/lib/generators/inline_forms/file_trash_generator.rb +36 -0
- data/lib/generators/inline_forms/templates/create_inline_forms_file_trash.rb.erb +41 -0
- data/lib/generators/inline_forms_addto_generator.rb +47 -0
- data/lib/generators/inline_forms_generator.rb +19 -2
- data/lib/generators/templates/model.erb +4 -0
- data/lib/inline_forms/form_element_from_callee.rb +25 -0
- data/lib/inline_forms/form_elements/simple_file_field_helper.rb +30 -2
- data/lib/inline_forms/gem_files.rb +5 -0
- data/lib/inline_forms/version.rb +1 -1
- data/lib/inline_forms.rb +55 -0
- data/lib/locales/inline_forms.en.yml +87 -0
- data/lib/locales/inline_forms.nl.yml +87 -3
- data/lib/tasks/inline_forms_files.rake +61 -0
- data/test/dummy/app/controllers/application_controller.rb +5 -0
- data/test/dummy/app/controllers/documents_controller.rb +4 -0
- data/test/dummy/app/controllers/dossiers_controller.rb +4 -0
- data/test/dummy/app/models/ability.rb +15 -0
- data/test/dummy/app/models/document.rb +33 -0
- data/test/dummy/app/models/dossier.rb +38 -0
- data/test/dummy/config/application.rb +4 -3
- data/test/dummy/config/routes.rb +13 -0
- data/test/inline_forms_addto_generator_test.rb +47 -0
- data/test/inline_forms_generator_test.rb +24 -0
- data/test/integration/field_authorization_test.rb +131 -0
- data/test/integration/file_slots_test.rb +574 -0
- data/test/integration/layout_title_test.rb +56 -0
- data/test/integration/model_top_bar_test.rb +9 -0
- data/test/integration/revert_skipped_columns_test.rb +59 -0
- data/test/integration_test_helper.rb +62 -0
- metadata +34 -3
- data/.claude/settings.local.json +0 -9
- data/CLAUDE.md +0 -68
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 698cbe91c323804016404aed86eda2e03d171b2a5df33ab775d3cca68f3d4341
|
|
4
|
+
data.tar.gz: 7ea3231464409d606c023f4b34aeeb2212d293abeaefc7a876e5ec488f6dcc8b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b2c7081ad209dcb79cc50ffa9cd61eaa920a9e44b609d4460a70df0aecfc65532ade283e4335836d0f824714e76107aa888931992d52f777e96868a28336ca4b
|
|
7
|
+
data.tar.gz: 0b7dc0db8f243a6d06467fa353d423252a97c8c33b14940f24264c99aa3d851253d8580a78e2ce0265d856925ae06753da1cb1d8b793c6716014262b03e093eb
|
data/.gitignore
CHANGED
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
|