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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|