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
@@ -26,10 +26,12 @@ class InlineFormsController < ApplicationController
26
26
  include InlineForms::FormElements::HelperIncludes
27
27
  include CancanConcern
28
28
  include VersionsConcern
29
+ include FileSlotsConcern
29
30
 
30
31
  before_action :getKlass
31
32
 
32
- load_and_authorize_resource except: :revert, no_params: true if cancan_enabled?
33
+ # +revert+ and the file-slot actions authorize themselves (attribute-level).
34
+ load_and_authorize_resource except: [ :revert, *FileSlotsConcern::FILE_SLOT_ACTIONS ], no_params: true if cancan_enabled?
33
35
  # :index shows a list of all objects from class @Klass, using will_paginate,
34
36
  # including a link to 'new', that allows you to create a new record.
35
37
  def index
@@ -125,6 +127,8 @@ class InlineFormsController < ApplicationController
125
127
  @form_element = params[:form_element]
126
128
  @sub_id = params[:sub_id]
127
129
  @update_span = params[:update]
130
+ return unless field_request_permitted?(:update)
131
+ return unless file_replace_permitted?
128
132
  respond_to do |format|
129
133
  format.html { render_turbo_field(:field_edit) }
130
134
  end
@@ -191,6 +195,8 @@ class InlineFormsController < ApplicationController
191
195
  @form_element = params[:form_element]
192
196
  @sub_id = params[:sub_id]
193
197
  @update_span = params[:update]
198
+ return unless field_request_permitted?(:update)
199
+ return unless file_replace_permitted?
194
200
  # Defense-in-depth: the UI renders a pending field as a read-only
195
201
  # placeholder (never links to edit/update), but a hand-crafted request
196
202
  # could still target a column that is not migrated yet. Refuse cleanly
@@ -232,6 +238,10 @@ class InlineFormsController < ApplicationController
232
238
  @attribute = params[:attribute]
233
239
  @form_element = params[:form_element]
234
240
  close = params[:close] || false
241
+ # Single-field show (cancel link, post-update render): same whitelist as
242
+ # edit/update -- the associated/has_one branches below `send` the
243
+ # attribute name to the record.
244
+ return if @attribute && !field_request_permitted?(:read)
235
245
  if @form_element == "associated"
236
246
  @sub_id = params[:sub_id]
237
247
  if @sub_id.to_i > 0
@@ -318,11 +328,7 @@ class InlineFormsController < ApplicationController
318
328
  return head :forbidden unless destroy_permitted?
319
329
  return head :not_found unless @parent
320
330
 
321
- @object = @version.reify(
322
- has_many: true,
323
- has_and_belongs_to_many: true,
324
- belongs_to: true
325
- )
331
+ @object = reify_for_revert(@version)
326
332
  # PaperTrail::Version#reify returns nil for `create` events because
327
333
  # there is no prior state to roll back to. The versions list view
328
334
  # hides the Restore link for `create` rows, but guard here too in
@@ -358,8 +364,14 @@ class InlineFormsController < ApplicationController
358
364
  @rich_text_record.save!
359
365
  @parent.touch if @parent.respond_to?(:touch)
360
366
  else
361
- @parent = persist_reverted_primary!(@object)
362
- restore_rich_texts_for_reverted_parent!(@parent)
367
+ # One transaction: the record, its files coming back out of the trash
368
+ # (undo of a destroy) and its rich texts are restored together or not.
369
+ @object.class.transaction do
370
+ file_entries = InlineForms::StoredFiles.prepare_revert!(@object)
371
+ @parent = persist_reverted_primary!(@object, @version)
372
+ InlineForms::StoredFiles.finish_revert!(file_entries, by: inline_forms_current_user_id)
373
+ restore_rich_texts_for_reverted_parent!(@parent)
374
+ end
363
375
  @parent.reload
364
376
  end
365
377
  render_revert_response if row_html_turbo_allowed?
@@ -367,6 +379,39 @@ class InlineFormsController < ApplicationController
367
379
 
368
380
  private
369
381
 
382
+ # Single-field requests (edit, update, show with an attribute) name the
383
+ # field and its form element in params, and both are client-controlled.
384
+ # +load_and_authorize_resource+ only authorizes the record: CanCanCan's
385
+ # Rule#matches_attributes? passes any attribute-scoped rule when no
386
+ # attribute is given, so `cannot :update, Client, [:secret]` was never
387
+ # applied server-side (only the edit link was hidden), and the form
388
+ # element went straight into `send("#{form_element}_update")`.
389
+ #
390
+ # Refuse (400) unless attribute + form_element name a row of the model's
391
+ # attribute list (as the UI's links do), then authorize the attribute
392
+ # itself. Renders and returns false when the request is refused.
393
+ def field_request_permitted?(action)
394
+ attributes = @inline_forms_attribute_list || @object.inline_forms_attribute_list
395
+ unless InlineForms.attribute_list_row_for(attributes, @attribute, @form_element)
396
+ head :bad_request
397
+ return false
398
+ end
399
+ authorize!(action, @object, @attribute.to_sym) if cancan_enabled?
400
+ true
401
+ end
402
+
403
+ # Uploading over a file that is already in a declared slot is a replace:
404
+ # the old file goes to the trash, i.e. it is removed. That needs
405
+ # :replace_file on top of :update (an empty slot only needs :update).
406
+ def file_replace_permitted?
407
+ return true unless @form_element.to_s == "simple_file_field"
408
+ return true unless @object.class.respond_to?(:inline_forms_file_slot?) && @object.class.inline_forms_file_slot?(@attribute)
409
+ return true unless @object.inline_forms_file_present?(@attribute)
410
+
411
+ authorize!(:replace_file, @object, @attribute.to_sym) if cancan_enabled?
412
+ true
413
+ end
414
+
370
415
  # Hard-destroy gate. Generated apps restrict destroy to a Devise user with
371
416
  # `role?(:superadmin)` (Role model + roles HABTM). Hosts without that role
372
417
  # system (e.g. the engine's test/dummy harness, or apps using a different
@@ -380,6 +425,23 @@ class InlineFormsController < ApplicationController
380
425
  user.role?(:superadmin)
381
426
  end
382
427
 
428
+ # Every reify on the revert path goes through here. +unversioned_attributes:
429
+ # :preserve+ is essential: PaperTrail's default (+:nil+) sets every column
430
+ # missing from the version's +object+ -- i.e. each +has_paper_trail skip:+
431
+ # column, typically file bytes like +data+ -- to nil on the live record, and
432
+ # the following +save!+ would persist that (filename kept, bytes gone).
433
+ # +revert_authorization_subject+ reifies the same version first and
434
+ # +version.item+ is cached, so it must use these options too or it nils the
435
+ # in-memory record before the real reify runs.
436
+ def reify_for_revert(version)
437
+ version.reify(
438
+ has_many: true,
439
+ has_and_belongs_to_many: true,
440
+ belongs_to: true,
441
+ unversioned_attributes: :preserve
442
+ )
443
+ end
444
+
383
445
  # Persist a reified *primary* record (Apartment, Photo, ...) for +revert+.
384
446
  #
385
447
  # +reify+ on a +destroy+ version returns a record with +new_record? == true+
@@ -392,13 +454,21 @@ class InlineFormsController < ApplicationController
392
454
  # photos.id+). Mirror the rich-text upsert (8.1.16): when a row with that PK
393
455
  # already exists, copy the reified column values onto it and +save!+ that
394
456
  # instead, so reverting is idempotent across repeated delete/undo cycles.
395
- def persist_reverted_primary!(object)
457
+ #
458
+ # Copy only the columns the version actually stored: a +destroy+ reify is a
459
+ # fresh +klass.new+, so columns in +has_paper_trail skip:+ (file bytes such
460
+ # as +data+) are nil on it and must not overwrite the live row's values.
461
+ def persist_reverted_primary!(object, version)
396
462
  klass = object.class
397
463
  primary_key = klass.primary_key
398
464
 
399
465
  if object.new_record? && object.id.present? && klass.exists?(object.id)
400
466
  existing = klass.find(object.id)
401
- existing.assign_attributes(object.attributes.except(primary_key))
467
+ versioned = (version.object_deserialized || {}).keys
468
+ # Declared file slots are never reverted through versions (old version
469
+ # rows may still carry a filename): the live row keeps its file.
470
+ versioned -= klass.inline_forms_file_columns if klass.respond_to?(:inline_forms_file_columns)
471
+ existing.assign_attributes(object.attributes.slice(*versioned).except(primary_key))
402
472
  existing.save!
403
473
  existing
404
474
  else
@@ -442,7 +512,7 @@ class InlineFormsController < ApplicationController
442
512
  end
443
513
 
444
514
  versions_by_name.each_value do |version|
445
- reified = version.reify
515
+ reified = version.reify(unversioned_attributes: :preserve)
446
516
  next unless reified
447
517
 
448
518
  name = reified.name.to_s
@@ -459,11 +529,7 @@ class InlineFormsController < ApplicationController
459
529
  # +revert+ is excluded from +load_and_authorize_resource+; +authorize!+ runs in the
460
530
  # action. +check_authorization+ on ApplicationController still requires that flag.
461
531
  def revert_authorization_subject(version)
462
- reified = version.reify(
463
- has_many: true,
464
- has_and_belongs_to_many: true,
465
- belongs_to: true
466
- )
532
+ reified = reify_for_revert(version)
467
533
  if defined?(ActionText::RichText) && reified.is_a?(ActionText::RichText)
468
534
  return reified.record
469
535
  end
@@ -0,0 +1,131 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ # View helpers for declared file slots (InlineForms::StoredFiles): the field's
4
+ # file actions, the per-record trash panel and file events in the versions
5
+ # history. Every permission check fails closed: with CanCan on, nothing is
6
+ # offered unless the Ability grants it.
7
+ module InlineFormsFileTrashHelper
8
+ # True when +attribute+ is a declared slot AND the host routed the file
9
+ # actions for this resource (InlineForms.file_routes). Without the routes
10
+ # the field renders exactly as before (legacy download link, no actions).
11
+ def inline_forms_file_slot_active?(object, attribute)
12
+ klass = object.class
13
+ klass.respond_to?(:inline_forms_file_slot?) &&
14
+ klass.inline_forms_file_slot?(attribute) &&
15
+ object.persisted? &&
16
+ respond_to?("download_file_#{object.model_name.singular_route_key}_path")
17
+ end
18
+
19
+ def inline_forms_file_can?(action, object, attribute = nil)
20
+ return true if inline_forms_cancan_off?
21
+
22
+ attribute ? can?(action, object, attribute.to_sym) : can?(action, object)
23
+ end
24
+
25
+ # Declared, routed slots among the rows of the attribute list being shown
26
+ # (a Client tab shows only its own slots).
27
+ def inline_forms_file_trash_slots(object, attributes = nil)
28
+ attributes ||= @inline_forms_attribute_list || object.inline_forms_attribute_list
29
+ attributes.filter_map do |attribute, form_element, *|
30
+ next unless form_element.to_s == "simple_file_field"
31
+ next unless inline_forms_file_slot_active?(object, attribute)
32
+ next unless inline_forms_file_can?(:read_file_trash, object, attribute)
33
+
34
+ attribute.to_s
35
+ end
36
+ end
37
+
38
+ # Menu link to the global trash page, for hosts with their own header.
39
+ # Renders nothing unless the routes exist and the user may read all trash.
40
+ def link_to_inline_forms_file_trash(label = nil, **options)
41
+ return unless respond_to?(:inline_forms_file_trash_path)
42
+ return unless inline_forms_cancan_off? || can?(:read_file_trash, :all)
43
+
44
+ link_to(label || t("inline_forms.files.global.title"), inline_forms_file_trash_path, options)
45
+ end
46
+
47
+ # cancan_disabled? is a helper of the engine's controllers only; a layout
48
+ # rendered by any other controller still has CanCan's can? when CanCan is on.
49
+ def inline_forms_cancan_off?
50
+ return cancan_disabled? if respond_to?(:cancan_disabled?)
51
+
52
+ !respond_to?(:can?)
53
+ end
54
+
55
+ def inline_forms_file_field_frame_id(object, attribute)
56
+ "#{object.class.name.underscore}_#{object.id}_#{attribute}"
57
+ end
58
+
59
+ def inline_forms_file_trash_turbo_frame_id(object)
60
+ "#{inline_forms_row_turbo_frame_id(object)}_file_trash"
61
+ end
62
+
63
+ def inline_forms_file_size(bytes)
64
+ bytes ? number_to_human_size(bytes) : ""
65
+ end
66
+
67
+ # Icon-only link: the label goes to title and aria-label, the icon is
68
+ # hidden from assistive tech.
69
+ def inline_forms_file_icon_link(icon, label, path, options = {})
70
+ options = options.merge(title: label, "aria-label": label)
71
+ link_to tag.i(class: icon, "aria-hidden": "true"), path, options
72
+ end
73
+
74
+ # data-* for a POST link that answers with turbo streams (field, trash
75
+ # panel and versions frames are replaced together), with a confirm dialog.
76
+ def inline_forms_file_post_data(frame_id, confirm: nil)
77
+ data = inline_forms_turbo_link_data(frame_id, method: :post, turbo_stream: true)
78
+ data[:data][:turbo_confirm] = confirm if confirm
79
+ data
80
+ end
81
+
82
+ def inline_forms_file_retention_days
83
+ retention = InlineForms.file_trash_retention
84
+ retention && (retention / 1.day).round
85
+ end
86
+
87
+ def inline_forms_file_user_name(id)
88
+ return t("inline_forms.files.by_system") if id.blank?
89
+
90
+ version_modified_by(id)
91
+ rescue StandardError
92
+ id.to_s
93
+ end
94
+
95
+ # The filename to show for a trash row (a redacted row shows a label).
96
+ def inline_forms_file_entry_name(entry)
97
+ entry.redacted? ? t("inline_forms.files.redacted_name") : entry.filename.to_s
98
+ end
99
+
100
+ # Newest restorable trash entry for each slot, keyed by attribute name.
101
+ def inline_forms_restorable_file_entries(object, slots)
102
+ return {} if slots.empty?
103
+
104
+ InlineForms::TrashedFile.without_data.for_record(object).where(attribute_name: slots)
105
+ .restorable.newest_first.each_with_object({}) do |entry, by_slot|
106
+ by_slot[entry.attribute_name] ||= entry
107
+ end
108
+ end
109
+
110
+ # The versions history: PaperTrail versions (inline_forms_versions_for,
111
+ # which hosts may override) plus file events from the trash table, each
112
+ # entry carrying :at for sorting. File events show only to users who may
113
+ # read the trash.
114
+ def inline_forms_history_for(object)
115
+ entries = inline_forms_versions_for(object).map { |entry| entry.merge(at: entry[:version].created_at) }
116
+ entries.concat(inline_forms_file_history_entries(object))
117
+ entries.sort_by { |entry| entry[:at] }
118
+ end
119
+
120
+ def inline_forms_file_history_entries(object)
121
+ slots = inline_forms_file_trash_slots(object)
122
+ return [] if slots.empty?
123
+
124
+ InlineForms::TrashedFile.without_data.for_record(object).where(attribute_name: slots).flat_map do |row|
125
+ events = [ { kind: :file, file_event: row.reason.to_sym, at: row.trashed_at, by: row.trashed_by_id, entry: row } ]
126
+ events << { kind: :file, file_event: :restored, at: row.restored_at, by: row.restored_by_id, entry: row } if row.restored?
127
+ events << { kind: :file, file_event: :purged, at: row.purged_at, by: row.purged_by_id, entry: row } if row.purged?
128
+ events
129
+ end
130
+ end
131
+ end
@@ -397,7 +397,11 @@ module InlineFormsHelper
397
397
  def version_modified_by(id)
398
398
  return "Unknown" if id.blank?
399
399
 
400
- user_class = Devise.mappings[:users]&.to
400
+ # Devise keys mappings by the singular scope (`devise_for :users` ->
401
+ # :user); the old `[:users]` lookup never matched, so every name was
402
+ # "Unknown".
403
+ mapping = Devise.mappings[:user] || Devise.mappings[:users] || Devise.mappings.values.first
404
+ user_class = mapping&.to
401
405
  return "Unknown" unless user_class
402
406
 
403
407
  user = user_class.find_by(id: id)
@@ -0,0 +1,350 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ require "digest"
4
+
5
+ # Declared file slots for :simple_file_field: raw bytes in the record's own
6
+ # table (a binary/LONGBLOB column), with a content-type and a filename column.
7
+ # Replaces the hand-written `<attr>=` setters hosts used to write per model.
8
+ #
9
+ # class Client < ApplicationRecord
10
+ # include InlineForms::StoredFiles
11
+ # inline_forms_file :filename # data / content_type
12
+ # inline_forms_file :begeleidingsplan_filename # begeleidingsplan_data / _content_type
13
+ # inline_forms_file :manual, data: :manual_bytes # explicit column names
14
+ # end
15
+ #
16
+ # Column names default from the attribute: `filename` -> `data` and
17
+ # `content_type`; `<base>_filename` and any other `<base>` -> `<base>_data` and
18
+ # `<base>_content_type`.
19
+ #
20
+ # What a declared slot gets:
21
+ # * a setter that accepts an uploaded file (ignores nil, like the hand-written
22
+ # ones; a String is a plain filename write for seeds/console code). The
23
+ # setter lives in a prepended module, so it wins over a `def <attr>=` in the
24
+ # class body regardless of order -- and such a def raises outside
25
+ # production, because it would be dead code that looks live.
26
+ # * replace = the old bytes go to the trash (InlineForms::TrashedFile) in the
27
+ # same transaction as the save; remove/restore/purge never lose bytes except
28
+ # through an explicit purge or the retention sweep.
29
+ # * the three slot columns are added to PaperTrail's `skip:` (whichever order
30
+ # `has_paper_trail` and `inline_forms_file` run in): file history lives in
31
+ # the trash table, never in `versions`, so a revert never touches a file.
32
+ # * destroying the record trashes its files first (reason record_destroyed);
33
+ # reverting that destroy brings them back.
34
+ #
35
+ # Requires the `inline_forms_trashed_files` table (`rails g
36
+ # inline_forms:file_trash`). Models without `inline_forms_file` are untouched.
37
+ module InlineForms::StoredFiles
38
+ extend ActiveSupport::Concern
39
+
40
+ class HandWrittenSetterError < StandardError; end
41
+
42
+ # Strips any client path (IE sends C:\...\x.pdf) and replaces everything
43
+ # but word characters, dots and dashes -- the rule StProject's hand-written
44
+ # setters used, so existing filenames keep their shape.
45
+ def self.sanitize_filename(name)
46
+ name.to_s.split(/[\\\/]/).last.to_s.gsub(/[^\w.\-]/, "_")
47
+ end
48
+
49
+ # Revert support (InlineFormsController#revert), called before the reified
50
+ # record is saved, inside the revert's transaction. Returns the trash
51
+ # entries to mark restored after the save (finish_revert!).
52
+ #
53
+ # * revert of an update (record persisted): put the slot columns back to
54
+ # their DB values -- a pre-adoption version row may still carry an old
55
+ # filename, and must not land on the current bytes.
56
+ # * undo of a destroy (row gone): copy each slot's newest restorable
57
+ # record_destroyed entry onto the record BEFORE the save, so presence
58
+ # validations on the filename pass; a slot whose file was purged in the
59
+ # meantime comes back empty (never a name without bytes).
60
+ # * row exists already (replayed undo): nothing; persist_reverted_primary!
61
+ # leaves the slot columns alone.
62
+ def self.prepare_revert!(record)
63
+ klass = record.class
64
+ return [] unless klass.respond_to?(:inline_forms_file_slots) && klass.inline_forms_file_slots.any?
65
+
66
+ if record.persisted?
67
+ record.restore_attributes(klass.inline_forms_file_columns)
68
+ return []
69
+ end
70
+ return [] if record.id.nil? || klass.unscoped.exists?(klass.primary_key => record.id)
71
+
72
+ klass.inline_forms_file_slots.filter_map do |attribute, slot|
73
+ entry = InlineForms::TrashedFile.lock.for_slot(record, attribute)
74
+ .where(reason: "record_destroyed").restorable.newest_first.first
75
+ if entry
76
+ record.send(:write_attribute, slot[:data], entry.data)
77
+ record.send(:write_attribute, attribute.to_s, entry.filename)
78
+ record.send(:write_attribute, slot[:content_type], entry.content_type)
79
+ else
80
+ [ slot[:data], attribute.to_s, slot[:content_type] ].each { |column| record.send(:write_attribute, column, nil) }
81
+ end
82
+ entry
83
+ end
84
+ end
85
+
86
+ # Marks the entries prepare_revert! put back as restored. Raises (rolling
87
+ # the revert back) if one was purged or restored concurrently.
88
+ def self.finish_revert!(entries, by: nil)
89
+ entries.each do |entry|
90
+ now = Time.current
91
+ count = InlineForms::TrashedFile.where(id: entry.id).restorable
92
+ .update_all(data: nil, restored_at: now, restored_by_id: by, updated_at: now)
93
+ raise InlineForms::TrashedFile::NotRestorable, "file #{entry.id} changed during revert" unless count == 1
94
+ end
95
+ end
96
+
97
+ # Re-merges the slot columns into PaperTrail's skip list when
98
+ # `has_paper_trail` runs after `inline_forms_file` (it replaces the options).
99
+ module PaperTrailSkipHook
100
+ def has_paper_trail(*args, **kwargs, &block)
101
+ result = super
102
+ inline_forms_merge_paper_trail_skip!
103
+ result
104
+ end
105
+ end
106
+
107
+ included do
108
+ class_attribute :inline_forms_file_slots, instance_writer: false, default: {}.freeze
109
+
110
+ validate :inline_forms_validate_staged_files
111
+ before_save :inline_forms_trash_replaced_files
112
+ after_save :inline_forms_record_uploaded_files
113
+ before_destroy :inline_forms_trash_files_on_destroy, prepend: true
114
+
115
+ singleton_class.prepend(PaperTrailSkipHook)
116
+ end
117
+
118
+ class_methods do
119
+ def inline_forms_file(attribute, data: nil, content_type: nil)
120
+ attribute = attribute.to_sym
121
+ defaults = InlineForms.file_slot_columns(attribute)
122
+ data ||= defaults[:data]
123
+ content_type ||= defaults[:content_type]
124
+ slot = { data: data.to_s, content_type: content_type.to_s }.freeze
125
+ self.inline_forms_file_slots = inline_forms_file_slots.merge(attribute => slot).freeze
126
+
127
+ inline_forms_file_setters.define_method("#{attribute}=") do |value|
128
+ inline_forms_assign_file(attribute, value) { super(value) }
129
+ end
130
+
131
+ inline_forms_hand_written_setter!(attribute) if method_defined?(:"#{attribute}=", false)
132
+ inline_forms_merge_paper_trail_skip!
133
+ attribute
134
+ end
135
+
136
+ def inline_forms_file_slot?(attribute)
137
+ inline_forms_file_slots.key?(attribute.to_s.to_sym)
138
+ end
139
+
140
+ def inline_forms_file_slot(attribute)
141
+ inline_forms_file_slots.fetch(attribute.to_s.to_sym) do
142
+ raise ArgumentError, "#{name}##{attribute} is not a declared file slot (inline_forms_file)"
143
+ end
144
+ end
145
+
146
+ # All slot columns (filename, data, content_type of every slot), strings.
147
+ def inline_forms_file_columns
148
+ inline_forms_file_slots.flat_map { |attribute, slot| [ attribute.to_s, slot[:data], slot[:content_type] ] }
149
+ end
150
+
151
+ def inline_forms_merge_paper_trail_skip!
152
+ return unless respond_to?(:paper_trail_options) && paper_trail_options.is_a?(Hash)
153
+
154
+ skip = Array(paper_trail_options[:skip]).map(&:to_s)
155
+ missing = inline_forms_file_columns - skip
156
+ return if missing.empty?
157
+
158
+ # Assign a new hash on this class: the options may be inherited from
159
+ # ApplicationRecord (generated apps call has_paper_trail there), and
160
+ # mutating that shared hash would skip these columns on every model.
161
+ self.paper_trail_options = paper_trail_options.merge(skip: skip + missing)
162
+ end
163
+
164
+ def method_added(name)
165
+ super
166
+ return unless name.to_s.end_with?("=")
167
+
168
+ attribute = name.to_s.delete_suffix("=").to_sym
169
+ inline_forms_hand_written_setter!(attribute) if inline_forms_file_slot?(attribute)
170
+ end
171
+
172
+ private
173
+
174
+ def inline_forms_file_setters
175
+ @inline_forms_file_setters ||= Module.new.tap { |setters| prepend(setters) }
176
+ end
177
+
178
+ def inline_forms_hand_written_setter!(attribute)
179
+ message = "#{name} defines its own `#{attribute}=` but #{attribute} is a declared file slot " \
180
+ "(inline_forms_file); remove the hand-written setter (and its sanitize_filename) -- " \
181
+ "the slot's setter replaces it."
182
+ raise HandWrittenSetterError, message unless defined?(Rails) && Rails.env.production?
183
+
184
+ Rails.logger&.warn("inline_forms: #{message}")
185
+ end
186
+ end
187
+
188
+ # ---- reading --------------------------------------------------------------
189
+
190
+ def inline_forms_file_present?(attribute)
191
+ self[attribute.to_s].present?
192
+ end
193
+
194
+ # { data:, filename:, type: } read fresh from the DB, or nil when the slot
195
+ # holds no bytes (an empty slot must 404, never send an empty file).
196
+ def inline_forms_file_download(attribute)
197
+ slot = self.class.inline_forms_file_slot(attribute)
198
+ row = inline_forms_file_row(slot, attribute)
199
+ return nil if row.nil? || row[0].nil?
200
+
201
+ { data: row[0], filename: row[1].presence || "download", type: row[2].presence || "application/octet-stream" }
202
+ end
203
+
204
+ # ---- writing --------------------------------------------------------------
205
+
206
+ # The edit form was submitted without a file: fail validation so the user
207
+ # sees "no file chosen" instead of a silent no-op.
208
+ def inline_forms_file_missing!(attribute)
209
+ inline_forms_file_errors[attribute.to_sym] = [ :inline_forms_file_missing ]
210
+ end
211
+
212
+ # Moves the current file to the trash and clears the slot. Returns the
213
+ # trash entry, or nil when the slot was already empty (double click, stale
214
+ # page). Writes with update_columns: an unrelated invalid column on an old
215
+ # record must not block taking a wrong file out.
216
+ def inline_forms_remove_file!(attribute, by: InlineForms::Current.user_id, reason: :removed)
217
+ slot = self.class.inline_forms_file_slot(attribute)
218
+ self.class.transaction do
219
+ row = inline_forms_file_row(slot, attribute, lock: true)
220
+ if row && (row[0] || row[1].present?)
221
+ entry = InlineForms::TrashedFile.trash!(
222
+ record: self, attribute: attribute, data: row[0], filename: row[1],
223
+ content_type: row[2], reason: reason, by: by
224
+ )
225
+ inline_forms_write_slot!(slot, attribute, nil, nil, nil)
226
+ entry
227
+ end
228
+ end
229
+ end
230
+
231
+ # Writes bytes into the slot directly (restore, revert of a destroy). A file
232
+ # already in the slot goes to the trash first. Caller holds the row lock.
233
+ def inline_forms_put_file!(attribute, data:, filename:, content_type:, by: InlineForms::Current.user_id)
234
+ slot = self.class.inline_forms_file_slot(attribute)
235
+ current = inline_forms_file_row(slot, attribute)
236
+ if current && current[0]
237
+ InlineForms::TrashedFile.trash!(
238
+ record: self, attribute: attribute, data: current[0], filename: current[1],
239
+ content_type: current[2], reason: :replaced, by: by
240
+ )
241
+ end
242
+ inline_forms_write_slot!(slot, attribute, data, filename, content_type)
243
+ end
244
+
245
+ private
246
+
247
+ def inline_forms_staged_files
248
+ @inline_forms_staged_files ||= {}
249
+ end
250
+
251
+ def inline_forms_file_errors
252
+ @inline_forms_file_errors ||= {}
253
+ end
254
+
255
+ # The setter body. Yields for a String (a plain attribute write).
256
+ def inline_forms_assign_file(attribute, value)
257
+ return if value.nil?
258
+ return yield if value.is_a?(String)
259
+
260
+ unless value.respond_to?(:read) && value.respond_to?(:original_filename)
261
+ inline_forms_file_errors[attribute] = [ :invalid ]
262
+ return
263
+ end
264
+
265
+ bytes = value.read.to_s.b
266
+ value.rewind if value.respond_to?(:rewind)
267
+ max = InlineForms.max_file_size
268
+ if max && bytes.bytesize > max
269
+ inline_forms_file_errors[attribute] = [ :inline_forms_file_too_large, { count: max / 1.megabyte } ]
270
+ return
271
+ end
272
+
273
+ inline_forms_file_errors.delete(attribute)
274
+ slot = self.class.inline_forms_file_slot(attribute)
275
+ write_attribute(slot[:data], bytes)
276
+ write_attribute(slot[:content_type], value.respond_to?(:content_type) ? value.content_type.to_s.presence : nil)
277
+ write_attribute(attribute, InlineForms::StoredFiles.sanitize_filename(value.original_filename))
278
+ inline_forms_staged_files[attribute] = { checksum: Digest::SHA256.hexdigest(bytes), byte_size: bytes.bytesize }
279
+ end
280
+
281
+ def inline_forms_validate_staged_files
282
+ inline_forms_file_errors.each do |attribute, (type, options)|
283
+ errors.add(attribute, type, **(options || {}))
284
+ end
285
+ end
286
+
287
+ # before_save, inside the save's transaction: the file being replaced goes
288
+ # to the trash. Read from the DB under a row lock (not from memory, which
289
+ # may be stale). An identical re-upload changes nothing and is not logged.
290
+ def inline_forms_trash_replaced_files
291
+ return if new_record? || inline_forms_staged_files.empty?
292
+
293
+ inline_forms_staged_files.each do |attribute, staged|
294
+ slot = self.class.inline_forms_file_slot(attribute)
295
+ old = inline_forms_file_row(slot, attribute, lock: true)
296
+ next if old.nil? || old[0].nil?
297
+
298
+ if Digest::SHA256.hexdigest(old[0]) == staged[:checksum]
299
+ staged[:unchanged] = true
300
+ next
301
+ end
302
+ InlineForms::TrashedFile.trash!(
303
+ record: self, attribute: attribute, data: old[0], filename: old[1],
304
+ content_type: old[2], reason: :replaced, by: InlineForms::Current.user_id
305
+ )
306
+ end
307
+ end
308
+
309
+ def inline_forms_record_uploaded_files
310
+ staged = inline_forms_staged_files
311
+ @inline_forms_staged_files = {}
312
+ staged.each do |attribute, info|
313
+ next if info[:unchanged]
314
+
315
+ slot = self.class.inline_forms_file_slot(attribute)
316
+ InlineForms::TrashedFile.record_upload!(
317
+ record: self, attribute: attribute, filename: self[attribute.to_s],
318
+ content_type: self[slot[:content_type]], byte_size: info[:byte_size],
319
+ checksum: info[:checksum], by: InlineForms::Current.user_id
320
+ )
321
+ end
322
+ end
323
+
324
+ def inline_forms_trash_files_on_destroy
325
+ self.class.inline_forms_file_slots.each do |attribute, slot|
326
+ data = attribute_in_database(slot[:data])
327
+ next if data.nil?
328
+
329
+ InlineForms::TrashedFile.trash!(
330
+ record: self, attribute: attribute, data: data,
331
+ filename: attribute_in_database(attribute.to_s),
332
+ content_type: attribute_in_database(slot[:content_type]),
333
+ reason: :record_destroyed, by: InlineForms::Current.user_id
334
+ )
335
+ end
336
+ end
337
+
338
+ # [data, filename, content_type] from the DB, or nil when the row is gone.
339
+ def inline_forms_file_row(slot, attribute, lock: false)
340
+ scope = self.class.unscoped.where(self.class.primary_key => id)
341
+ scope = scope.lock if lock
342
+ scope.pluck(slot[:data], attribute.to_s, slot[:content_type]).first
343
+ end
344
+
345
+ def inline_forms_write_slot!(slot, attribute, data, filename, content_type)
346
+ columns = { slot[:data] => data, attribute.to_s => filename, slot[:content_type] => content_type }
347
+ columns["updated_at"] = Time.current if has_attribute?(:updated_at)
348
+ update_columns(columns)
349
+ end
350
+ end
@@ -0,0 +1,8 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ # Per-request actor for model-level file bookkeeping (InlineForms::StoredFiles
4
+ # records who uploaded / removed / replaced a file). Set by the engine's
5
+ # controllers; nil in the console, seeds and the retention sweep.
6
+ class InlineForms::Current < ActiveSupport::CurrentAttributes
7
+ attribute :user_id
8
+ end