upload_d_n_d 0.9.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +13 -0
- data/app/components/upload_d_n_d/upload_component.html.erb +2 -4
- data/app/components/upload_d_n_d/upload_component.rb +19 -14
- data/app/controllers/upload_d_n_d/attachments_controller.rb +38 -133
- data/app/controllers/upload_d_n_d/base_attachments_controller.rb +62 -22
- data/app/controllers/upload_d_n_d/temporary_attachments_controller.rb +13 -20
- data/app/helpers/upload_d_n_d/application_helper.rb +5 -6
- data/app/javascript/upload_d_n_d/controllers/uploads_controller.js +4 -15
- data/app/models/upload_d_n_d/temporary_attachment.rb +49 -11
- data/app/views/upload_d_n_d/shared/_files_list.html.erb +1 -1
- data/docs/CHANGELOG.md +36 -0
- data/lib/generators/upload_d_n_d/install_generator.rb +10 -14
- data/lib/generators/upload_d_n_d/templates/POST_INSTALL +28 -26
- data/lib/generators/upload_d_n_d/templates/initializers/upload_d_n_d.rb +4 -3
- data/lib/upload_d_n_d/configuration.rb +12 -0
- data/lib/upload_d_n_d/file_validator.rb +8 -5
- data/lib/upload_d_n_d/js_link.rb +16 -8
- data/lib/upload_d_n_d/mime_types.rb +8 -0
- data/lib/upload_d_n_d/signed_target.rb +83 -0
- data/lib/upload_d_n_d/uploadable.rb +9 -42
- data/lib/upload_d_n_d/version.rb +1 -1
- data/lib/upload_d_n_d.rb +1 -1
- data/package.json +5 -3
- data/styles.css +4 -0
- metadata +3 -4
- data/app/helpers/upload_d_n_d/controllers/helpers.rb +0 -21
- data/lib/generators/upload_d_n_d/templates/concerns/upload_authorization.rb +0 -91
- data/lib/upload_d_n_d/field_reference.rb +0 -44
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a16cbbec64d738e23701d35ec650f5b9b378b494d470b2611ea7415f3e31d3b
|
|
4
|
+
data.tar.gz: 32fdff2762b16165eb808a46a1446d99c855c779d9e8e83ee64954260bf42d97
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 381d1e0884e50cf3fc72109db3e5ae0912ebf0bed0084fc6216ff6a692b49844b4b838f4e40232cd36f895ea10fd7b6721179e4e19e87ad365a6586c420bf483
|
|
7
|
+
data.tar.gz: 20fa9e3fe22f13ccf6d22cf75b0df2436d3a0f3b4046216d3e27dd8ef1c642ef0d3517e9003c97af44a65905a566edd7645a10c14e77209389280386b37d4630
|
data/README.md
CHANGED
|
@@ -57,6 +57,19 @@ Everything else (JS, CSS, components, concerns) is served from the gem: upgradin
|
|
|
57
57
|
|
|
58
58
|
With **jsbundling-rails** (bun/esbuild), the `upload_d_n_d:link_js` task copies the gem's JS/CSS into `node_modules/upload_d_n_d` so your bundler can resolve `import "upload_d_n_d"` and `@import "upload_d_n_d/styles"`. It is a real directory (not a symlink), so the gem's own imports of `@hotwired/stimulus` and `@rails/activestorage` resolve against your app's `node_modules`. The task runs automatically on `javascript:build` (so `assets:precompile` at deploy is always up to date). Note: a watch-mode dev server such as `bin/dev` calls the bundler directly and does **not** run the task, so after upgrading the gem run `bin/rails javascript:build` once locally to refresh the copy.
|
|
59
59
|
|
|
60
|
+
CSS — one line, whichever bundler:
|
|
61
|
+
|
|
62
|
+
```css
|
|
63
|
+
/* Tailwind 3 (postcss-import), esbuild or bun */
|
|
64
|
+
@import "upload_d_n_d/styles";
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
With **Sprockets** (asset pipeline) instead of a JS bundler, use the stylesheet tag:
|
|
68
|
+
|
|
69
|
+
```erb
|
|
70
|
+
<%= stylesheet_link_tag "upload_d_n_d/application", media: "all" %>
|
|
71
|
+
```
|
|
72
|
+
|
|
60
73
|
ActiveStorage must be installed (`bin/rails active_storage:install`). With an external S3-like bucket and a strict CSP, serve files same-origin:
|
|
61
74
|
|
|
62
75
|
```ruby
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
<div class="udnd-container"
|
|
2
2
|
style="--udnd-accent-color: <%= main_color %>; --udnd-accent-rgb: <%= main_color_rgb %>;"
|
|
3
3
|
data-controller="upload-d-n-d--uploads"
|
|
4
|
-
data-upload-d-n-d--uploads-record-id-value="<%= record&.id %>"
|
|
5
|
-
data-upload-d-n-d--uploads-record-gid-value="<%= record_gid %>"
|
|
6
4
|
data-upload-d-n-d--uploads-attribute-name-value="<%= attribute_name %>"
|
|
7
5
|
data-upload-d-n-d--uploads-attach-url-value="<%= attach_path %>"
|
|
8
6
|
data-upload-d-n-d--uploads-max-size-value="<%= max_size %>"
|
|
9
7
|
data-upload-d-n-d--uploads-accepted-types-value="<%= accepted_types %>"
|
|
10
8
|
data-upload-d-n-d--uploads-mime-types-value="<%= accepted_mime_types %>"
|
|
11
|
-
data-upload-d-n-d--uploads-
|
|
9
|
+
data-upload-d-n-d--uploads-target-value="<%= signed_target %>"
|
|
12
10
|
data-upload-d-n-d--uploads-multiple-value="<%= multiple %>"
|
|
13
11
|
data-upload-d-n-d--uploads-i18n-value="<%= i18n_messages.to_json %>">
|
|
14
12
|
|
|
@@ -46,7 +44,7 @@
|
|
|
46
44
|
<%# Record persisté : ses attachments. Sinon : les fichiers temporaires de la session,
|
|
47
45
|
pour que la zone reste remplie après un rechargement de page. %>
|
|
48
46
|
<% if record&.persisted? %>
|
|
49
|
-
<%= render "upload_d_n_d/shared/files_list", record: record, attribute_name: attribute_name %>
|
|
47
|
+
<%= render "upload_d_n_d/shared/files_list", record: record, attribute_name: attribute_name, namespace: namespace %>
|
|
50
48
|
<% else %>
|
|
51
49
|
<%= render "upload_d_n_d/shared/temp_files_list", files: files, attribute_name: attribute_name %>
|
|
52
50
|
<% end %>
|
|
@@ -42,13 +42,26 @@ module UploadDND
|
|
|
42
42
|
UploadDND::MimeTypes.from_accepted_types(@accepted_types).join(",")
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
# Cible signée renvoyée au dépôt et à la suppression : le serveur en déduit
|
|
46
|
+
# modèle, attribut, record et namespace, sans faire confiance au client.
|
|
47
|
+
#
|
|
48
|
+
# Exige un `record:` (même non persisté) pour pouvoir signer le couple
|
|
49
|
+
# modèle/attribut, et que l'attribut soit déclaré `uploadable` sur le modèle.
|
|
50
|
+
def signed_target
|
|
51
|
+
unless record
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"upload_field(#{attribute_name.inspect}) exige record: (même une instance neuve) " \
|
|
54
|
+
"pour signer la cible d'upload."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
model_class = record.class
|
|
58
|
+
unless model_class.respond_to?(:uploadable_attributes) && model_class.uploadable_attributes.key?(attribute_name.to_sym)
|
|
59
|
+
raise ArgumentError,
|
|
60
|
+
"#{attribute_name.inspect} n'est pas déclaré `uploadable` sur #{model_class} : " \
|
|
61
|
+
"déclarez-le avec `uploadable :#{attribute_name}, ...`."
|
|
62
|
+
end
|
|
50
63
|
|
|
51
|
-
UploadDND::
|
|
64
|
+
UploadDND::SignedTarget.for(record, attribute_name, namespace: namespace)
|
|
52
65
|
end
|
|
53
66
|
|
|
54
67
|
def model_name
|
|
@@ -58,14 +71,6 @@ module UploadDND
|
|
|
58
71
|
record.class.name.underscore.tr("/", "_")
|
|
59
72
|
end
|
|
60
73
|
|
|
61
|
-
# Génère le GlobalID pour le record (fonctionne avec tous les namespaces)
|
|
62
|
-
def record_gid
|
|
63
|
-
# Un record non persisté n'a pas d'ID => GlobalID impossible (MissingModelIdError).
|
|
64
|
-
return nil unless record&.persisted?
|
|
65
|
-
|
|
66
|
-
record.to_global_id.to_s
|
|
67
|
-
end
|
|
68
|
-
|
|
69
74
|
# Détermine l'URL d'attachement : soit vers le moteur de la gem, soit vers une route spécifique
|
|
70
75
|
def attach_path
|
|
71
76
|
action_path = record&.persisted? ? :attachments_path : :temporary_attachments_path
|
|
@@ -1,94 +1,52 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module UploadDND
|
|
4
|
-
# Contrôleur
|
|
4
|
+
# Contrôleur des attachments permanents (sur records existants).
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# - Renvoyer la liste des fichiers pour mise à jour UI
|
|
6
|
+
# La cible (modèle, attribut, record, namespace de policy) provient d'un jeton
|
|
7
|
+
# signé (`UploadDND::SignedTarget`) émis par le composant : le client ne
|
|
8
|
+
# désigne plus le record ni l'attribut en clair. L'autorisation porte sur le
|
|
9
|
+
# record (`:update`), avec le namespace du jeton, en fail-closed.
|
|
11
10
|
#
|
|
12
|
-
#
|
|
13
|
-
# - POST /upload_d_n_d/attachments
|
|
14
|
-
#
|
|
15
|
-
# Workflow:
|
|
16
|
-
# 1. User uploads file on edit form → record_gid present in params
|
|
17
|
-
# 2. Controller locates record via GlobalID
|
|
18
|
-
# 3. Attaches blob directly to record.attribute_name
|
|
19
|
-
# 4. Saves record with validations
|
|
20
|
-
# 5. Returns updated file list
|
|
21
|
-
#
|
|
22
|
-
# Note: Pour les formulaires SANS record (ex: new User), utilisez TemporaryAttachmentsController.
|
|
11
|
+
# Routes :
|
|
12
|
+
# - POST /upload_d_n_d/attachments
|
|
13
|
+
# - DELETE /upload_d_n_d/attachments/:id
|
|
23
14
|
class AttachmentsController < BaseAttachmentsController
|
|
24
|
-
|
|
25
|
-
before_action :
|
|
15
|
+
before_action :find_target, only: [ :create, :destroy ]
|
|
16
|
+
before_action :authorize_target!, only: [ :create, :destroy ]
|
|
26
17
|
before_action :reject_attached_blob, only: [ :create ]
|
|
27
18
|
|
|
28
|
-
before_action :validate_record_gid_presence, only: [ :create ]
|
|
29
|
-
before_action :validate_destroy_params, only: [ :destroy ]
|
|
30
|
-
|
|
31
19
|
# POST /upload_d_n_d/attachments
|
|
32
20
|
#
|
|
33
|
-
# Params:
|
|
34
|
-
# - signed_blob_id: String (ActiveStorage signed blob ID)
|
|
35
|
-
# - attribute_name: String (ex: "avatar", "documents")
|
|
36
|
-
# - record_gid: String (GlobalID du record, ex: "gid://app/User/123")
|
|
37
|
-
# - multiple: String ("true" ou "false", optionnel)
|
|
38
|
-
#
|
|
39
|
-
# Returns:
|
|
40
|
-
# - Turbo Stream pour remplacer la liste de fichiers
|
|
41
|
-
# - JSON {status: 'ok'} en mode API
|
|
21
|
+
# Params : signed_blob_id, target (jeton signé).
|
|
42
22
|
def create
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
begin
|
|
46
|
-
record = GlobalID::Locator.locate(params[:record_gid])
|
|
47
|
-
rescue ActiveRecord::RecordNotFound
|
|
48
|
-
render_error(I18n.t("upload_d_n_d.errors.blob_not_found"), :not_found)
|
|
49
|
-
return
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
unless record
|
|
53
|
-
render_error(I18n.t("upload_d_n_d.errors.blob_not_found"), :not_found)
|
|
54
|
-
return
|
|
55
|
-
end
|
|
23
|
+
record = @target_record
|
|
24
|
+
attribute_name = @target.attribute
|
|
56
25
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
# SÉCURITÉ : l'attribut doit être un attachment déclaré sur le modèle.
|
|
61
|
-
# Détection SERVEUR du type d'attachment (has_one vs has_many) :
|
|
62
|
-
# NE JAMAIS faire confiance au paramètre client params[:multiple]
|
|
26
|
+
# L'attribut du jeton est déjà vérifié `uploadable` ; on résout l'objet
|
|
27
|
+
# d'association (has_one vs has_many) par réflexion, jamais par le client.
|
|
63
28
|
attachment = UploadDND::AttachmentLookup.find(record, attribute_name)
|
|
64
29
|
unless attachment
|
|
65
30
|
render_error(I18n.t("upload_d_n_d.errors.invalid_attribute", attribute: attribute_name), :unprocessable_entity)
|
|
66
31
|
return
|
|
67
32
|
end
|
|
68
33
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
constraints = UploadDND::FieldConstraints.for(record.class, attribute_name)
|
|
72
|
-
if (violation = constraints.violation(@blob))
|
|
73
|
-
render_error(constraints.error_message(violation), :unprocessable_entity)
|
|
34
|
+
if (violation = @target.constraints.violation(@blob))
|
|
35
|
+
render_error(@target.constraints.error_message(violation), :unprocessable_entity)
|
|
74
36
|
return
|
|
75
37
|
end
|
|
76
38
|
|
|
77
|
-
# Sanitize filename avant l'attachement
|
|
78
39
|
@blob.update(filename: sanitize_filename(@blob.filename.to_s))
|
|
79
40
|
|
|
80
41
|
if attachment.is_a?(ActiveStorage::Attached::Many)
|
|
81
|
-
# has_many_attached: ajouter à la collection
|
|
82
42
|
attachment.attach(@blob)
|
|
83
43
|
elsif attachment.is_a?(ActiveStorage::Attached::One)
|
|
84
|
-
# has_one_attached: remplacer (purge l'ancien automatiquement)
|
|
85
44
|
record.send("#{attribute_name}=", @blob)
|
|
86
45
|
else
|
|
87
46
|
render_error(I18n.t("upload_d_n_d.errors.not_active_storage_attachment", attribute: attribute_name), :unprocessable_entity)
|
|
88
47
|
return
|
|
89
48
|
end
|
|
90
49
|
|
|
91
|
-
# Sauvegarde avec validation
|
|
92
50
|
if record.save
|
|
93
51
|
render_file_list(record, attribute_name)
|
|
94
52
|
log_upload_success
|
|
@@ -101,29 +59,14 @@ module UploadDND
|
|
|
101
59
|
|
|
102
60
|
# DELETE /upload_d_n_d/attachments/:id
|
|
103
61
|
#
|
|
104
|
-
#
|
|
105
|
-
# l'app hôte (contrairement au flux legacy remove_file du concern
|
|
106
|
-
# Controllers::Uploadable). Symétrique du POST #create.
|
|
107
|
-
#
|
|
108
|
-
# Params:
|
|
109
|
-
# - id: Integer (ActiveStorage::Attachment id)
|
|
110
|
-
# - record_gid: String (GlobalID du record)
|
|
111
|
-
# - attribute_name: String (ex: "avatar", "documents")
|
|
62
|
+
# Params : id (ActiveStorage::Attachment), target (jeton signé).
|
|
112
63
|
def destroy
|
|
113
|
-
record =
|
|
114
|
-
|
|
115
|
-
unless record
|
|
116
|
-
render_error(I18n.t("upload_d_n_d.errors.record_not_found"), :not_found)
|
|
117
|
-
return
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
authorize_destroy!(record)
|
|
121
|
-
return if performed?
|
|
64
|
+
record = @target_record
|
|
65
|
+
attribute_name = @target.attribute
|
|
122
66
|
|
|
123
67
|
attachment = ActiveStorage::Attachment.find_by(id: params[:id])
|
|
124
|
-
attribute_name = params[:attribute_name]
|
|
125
68
|
|
|
126
|
-
# Sécurité : le fichier doit appartenir au record ET à l'attribut
|
|
69
|
+
# Sécurité : le fichier doit appartenir au record ET à l'attribut signés.
|
|
127
70
|
if attachment && attachment.record == record && attachment.name == attribute_name.to_s
|
|
128
71
|
attachment.purge_later
|
|
129
72
|
record.reload
|
|
@@ -138,81 +81,43 @@ module UploadDND
|
|
|
138
81
|
|
|
139
82
|
private
|
|
140
83
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
84
|
+
# Autorisation sur le record désigné par le jeton signé : policy `:update`,
|
|
85
|
+
# avec le namespace du jeton (ex. Admin::RecordPolicy). Fail-closed : sans
|
|
86
|
+
# policy utilisable (ni namespacée ni top-level), la requête est refusée
|
|
87
|
+
# (403), avec un avertissement en log nommant la policy manquante.
|
|
88
|
+
def authorize_target!
|
|
89
|
+
@target_record = @target.record
|
|
90
|
+
unless @target_record
|
|
91
|
+
render_error(I18n.t("upload_d_n_d.errors.record_not_found"), :not_found)
|
|
144
92
|
return
|
|
145
93
|
end
|
|
146
94
|
|
|
147
|
-
|
|
148
|
-
render_error(I18n.t("upload_d_n_d.errors.invalid_attribute_name"), :bad_request)
|
|
149
|
-
nil
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
# Autorisation de la suppression : règle du record (update), avec repli
|
|
154
|
-
# sur la règle générique de la gem si le modèle n'en a pas (Pundit : pas
|
|
155
|
-
# de policy). Fail-closed : sans règle du tout, RuleMissing remonte en 500
|
|
156
|
-
# plutôt que de laisser passer.
|
|
157
|
-
def authorize_destroy!(record)
|
|
158
|
-
begin
|
|
159
|
-
authorize_with_adapter!(record, :update)
|
|
160
|
-
rescue UploadDND::Authorization::RuleMissing
|
|
161
|
-
authorize_with_adapter!(:upload_d_n_d_attachment, :destroy)
|
|
162
|
-
end
|
|
163
|
-
rescue UploadDND::Authorization::Denied
|
|
164
|
-
render_forbidden
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# Autorisation de l'upload sur le record lui-même (policy update?), en plus
|
|
168
|
-
# de la policy générique vérifiée en before_action : le record_gid vient du
|
|
169
|
-
# client, sans ce contrôle tout utilisateur autorisé à uploader pouvait
|
|
170
|
-
# attacher un fichier à n'importe quel record. Si le modèle n'a pas de
|
|
171
|
-
# règle, la règle générique (create) déjà vérifiée fait foi.
|
|
172
|
-
def authorize_record_upload!(record)
|
|
173
|
-
revoke_upload_authorization!
|
|
174
|
-
begin
|
|
175
|
-
authorize_with_adapter!(record, :update)
|
|
176
|
-
rescue UploadDND::Authorization::RuleMissing
|
|
177
|
-
nil # règle générique déjà vérifiée
|
|
178
|
-
end
|
|
95
|
+
authorize_with_adapter!(@target_record, :update, namespace: @target.namespace)
|
|
179
96
|
grant_upload_authorization!
|
|
97
|
+
rescue ActiveRecord::RecordNotFound
|
|
98
|
+
render_error(I18n.t("upload_d_n_d.errors.record_not_found"), :not_found)
|
|
99
|
+
rescue UploadDND::Authorization::RuleMissing
|
|
100
|
+
Rails.logger.warn "UploadDND: aucune policy d'autorisation pour " \
|
|
101
|
+
"#{@target.model_class} (namespace: #{@target.namespace.inspect}) - refus (fail-closed)"
|
|
102
|
+
render_forbidden
|
|
180
103
|
rescue UploadDND::Authorization::Denied
|
|
181
104
|
render_forbidden
|
|
182
105
|
end
|
|
183
106
|
|
|
184
|
-
def validate_record_gid_presence
|
|
185
|
-
unless params[:record_gid].present?
|
|
186
|
-
render_error(I18n.t("upload_d_n_d.errors.record_gid_required"), :bad_request)
|
|
187
|
-
nil
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
107
|
def render_file_list(record, attribute_name)
|
|
192
108
|
if turbo_stream_request?
|
|
193
109
|
render_turbo_stream_replace(
|
|
194
110
|
"udnd_files_list_#{attribute_name}",
|
|
195
111
|
partial: "upload_d_n_d/shared/files_list",
|
|
196
|
-
locals: { record: record, attribute_name: attribute_name }
|
|
112
|
+
locals: { record: record, attribute_name: attribute_name, namespace: @target.namespace }
|
|
197
113
|
)
|
|
198
114
|
else
|
|
199
115
|
respond_to do |format|
|
|
200
116
|
format.json { render json: { status: "ok", message: I18n.t("upload_d_n_d.success.file_added") }, status: :ok }
|
|
201
|
-
format.html { redirect_back fallback_location: root_path, notice: I18n.t("upload_d_n_d.success.file_added") }
|
|
117
|
+
format.html { redirect_back fallback_location: (main_app.respond_to?(:root_path) ? main_app.root_path : "/"), notice: I18n.t("upload_d_n_d.success.file_added") }
|
|
202
118
|
format.any { render json: { status: "ok", message: I18n.t("upload_d_n_d.success.file_added") }, status: :ok }
|
|
203
119
|
end
|
|
204
120
|
end
|
|
205
121
|
end
|
|
206
|
-
|
|
207
|
-
# === Autorisation ===
|
|
208
|
-
|
|
209
|
-
# Policy générique ; la policy du record est vérifiée ensuite
|
|
210
|
-
# (authorize_record_upload!), une fois le record localisé.
|
|
211
|
-
def authorize_upload!
|
|
212
|
-
authorize_with_adapter!(:upload_d_n_d_attachment, :create)
|
|
213
|
-
grant_upload_authorization!
|
|
214
|
-
rescue UploadDND::Authorization::Denied
|
|
215
|
-
render_forbidden
|
|
216
|
-
end
|
|
217
122
|
end
|
|
218
123
|
end
|
|
@@ -11,7 +11,10 @@ module UploadDND
|
|
|
11
11
|
# - Helpers de rendu (Turbo Streams, JSON, HTML)
|
|
12
12
|
# - Sécurité (CSRF, validation de signature, sanitisation)
|
|
13
13
|
# - Rate limiting (contrôle du taux de requêtes)
|
|
14
|
-
|
|
14
|
+
# Contrôleur parent configurable (config.parent_controller) : par défaut
|
|
15
|
+
# ActionController::Base (isolé), ou "ApplicationController" pour hériter des
|
|
16
|
+
# hooks d'authentification/autorisation/multi-tenant de l'app hôte (F6).
|
|
17
|
+
class BaseAttachmentsController < UploadDND.configuration.parent_controller.constantize
|
|
15
18
|
# Inclure Pundit si disponible
|
|
16
19
|
include Pundit::Authorization if defined?(Pundit)
|
|
17
20
|
|
|
@@ -27,6 +30,14 @@ module UploadDND
|
|
|
27
30
|
# Authentification (si disponible dans l'app hôte)
|
|
28
31
|
before_action :authenticate_user_if_available!
|
|
29
32
|
|
|
33
|
+
# Le moteur fait sa propre autorisation (port adaptateur), pas via la
|
|
34
|
+
# convention Pundit de l'app hôte. On marque les vérifications Pundit comme
|
|
35
|
+
# satisfaites : les after_action verify_* de l'hôte passent alors, quel que
|
|
36
|
+
# soit leur nom (certaines apps les regroupent dans un callback au nom
|
|
37
|
+
# custom) — sans masquer d'erreur ni dépendre du nom du callback. Enregistré
|
|
38
|
+
# inconditionnellement (la présence de Pundit est vérifiée à l'exécution).
|
|
39
|
+
before_action :satisfy_pundit_verifications
|
|
40
|
+
|
|
30
41
|
# Gestion des erreurs
|
|
31
42
|
# IMPORTANT: rescue_from utilise le "dernier match" -> on déclare StandardError en premier
|
|
32
43
|
# pour laisser les handlers spécifiques (RecordNotFound, etc.) prendre le dessus.
|
|
@@ -38,6 +49,8 @@ module UploadDND
|
|
|
38
49
|
before_action :validate_params, only: [ :create ]
|
|
39
50
|
before_action :validate_signed_blob_id_format, only: [ :create ]
|
|
40
51
|
before_action :find_blob, only: [ :create ]
|
|
52
|
+
# find_target est enregistré par chaque sous-classe (actions distinctes) :
|
|
53
|
+
# le re-déclarer ici puis dans une sous-classe écraserait ses conditions.
|
|
41
54
|
|
|
42
55
|
# Déclaré après find_blob (@blob connu) et avant les before_action des
|
|
43
56
|
# sous-classes (autorisation, contraintes), qu'il englobe.
|
|
@@ -99,25 +112,34 @@ module UploadDND
|
|
|
99
112
|
end
|
|
100
113
|
|
|
101
114
|
def validate_params
|
|
102
|
-
|
|
103
|
-
render_error(I18n.t("upload_d_n_d.errors.missing_params"), :bad_request)
|
|
104
|
-
return
|
|
105
|
-
end
|
|
115
|
+
return if params[:signed_blob_id].present? && params[:target].present?
|
|
106
116
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
render_error(I18n.t("upload_d_n_d.errors.invalid_attribute_name"), :bad_request)
|
|
110
|
-
nil
|
|
111
|
-
end
|
|
117
|
+
render_error(I18n.t("upload_d_n_d.errors.missing_params"), :bad_request)
|
|
118
|
+
nil
|
|
112
119
|
end
|
|
113
120
|
|
|
114
121
|
def find_blob
|
|
115
122
|
@blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id])
|
|
123
|
+
return if @blob
|
|
124
|
+
|
|
125
|
+
# find_signed renvoie nil (sans lever) sur signature invalide ou blob
|
|
126
|
+
# supprimé : sans cette garde, @blob nil provoquait un 500 plus bas.
|
|
127
|
+
render_error(I18n.t("upload_d_n_d.errors.invalid_blob_signature"), :unauthorized)
|
|
128
|
+
nil
|
|
116
129
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
117
130
|
render_error(I18n.t("upload_d_n_d.errors.invalid_blob_signature"), :unauthorized)
|
|
118
131
|
nil
|
|
119
132
|
end
|
|
120
133
|
|
|
134
|
+
# Vérifie et décode la cible signée (remplace les record_gid/attribute_name
|
|
135
|
+
# bruts du client). Toute cible absente ou invalide est refusée.
|
|
136
|
+
def find_target
|
|
137
|
+
@target = UploadDND::SignedTarget.verify(params[:target])
|
|
138
|
+
rescue UploadDND::SignedTarget::Invalid
|
|
139
|
+
render_error(I18n.t("upload_d_n_d.errors.invalid_field_reference"), :bad_request)
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
121
143
|
# === Helpers de rendu ===
|
|
122
144
|
|
|
123
145
|
def render_error(message, status = :unprocessable_entity)
|
|
@@ -145,6 +167,10 @@ module UploadDND
|
|
|
145
167
|
Rails.logger.error "UploadDND Error: #{exception.class} - #{exception.message}"
|
|
146
168
|
Rails.logger.error exception.backtrace.join("\n")
|
|
147
169
|
|
|
170
|
+
# Si la réponse est déjà rendue (ex. un after_action qui lève après coup),
|
|
171
|
+
# ne pas re-rendre : cela provoquerait un DoubleRenderError.
|
|
172
|
+
return if performed?
|
|
173
|
+
|
|
148
174
|
render_error(I18n.t("upload_d_n_d.errors.upload_error"), :internal_server_error)
|
|
149
175
|
end
|
|
150
176
|
|
|
@@ -164,16 +190,17 @@ module UploadDND
|
|
|
164
190
|
|
|
165
191
|
# === Helpers d'authentification ===
|
|
166
192
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
193
|
+
# Public : l'app hôte vérifie souvent `respond_to?(:current_user)` (sans
|
|
194
|
+
# include_all) dans ses before_action / pour alimenter Current.user ; une
|
|
195
|
+
# méthode protected y répondrait faux. Priorité au `current_user` fourni par
|
|
196
|
+
# le parent (parent_controller = "ApplicationController"), repli Warden sinon.
|
|
197
|
+
# NB : `defined?(super)` teste la présence d'une méthode parente (et non
|
|
198
|
+
# `respond_to?(:super)`, qui cherchait à tort une méthode nommée :super).
|
|
199
|
+
public def current_user
|
|
200
|
+
return super if defined?(super)
|
|
201
|
+
return request.env["warden"].user if defined?(Warden) && request.env["warden"]
|
|
202
|
+
|
|
203
|
+
nil
|
|
177
204
|
end
|
|
178
205
|
|
|
179
206
|
def authenticate_user_if_available!
|
|
@@ -193,8 +220,21 @@ module UploadDND
|
|
|
193
220
|
|
|
194
221
|
# Délègue au port d'autorisation (UploadDND::Authorization) : lève
|
|
195
222
|
# Denied, RuleMissing ou ConfigurationError. Fail-closed sans adaptateur.
|
|
196
|
-
def authorize_with_adapter!(subject, action)
|
|
197
|
-
UploadDND::Authorization.authorize!(self, subject, action)
|
|
223
|
+
def authorize_with_adapter!(subject, action, namespace: nil)
|
|
224
|
+
UploadDND::Authorization.authorize!(self, subject, action, namespace: namespace)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Marque la requête comme « vérifiée » côté Pundit (API officielle), pour que
|
|
228
|
+
# les after_action verify_authorized / verify_policy_scoped de l'app hôte
|
|
229
|
+
# passent, indépendamment du nom du callback qui les appelle. Le moteur
|
|
230
|
+
# applique sa propre autorisation (adaptateur) : ces marqueurs n'affaiblissent
|
|
231
|
+
# rien, ils neutralisent seulement l'assertion de dev de l'hôte.
|
|
232
|
+
def satisfy_pundit_verifications
|
|
233
|
+
# skip_policy_scope / skip_authorization sont PROTECTED dans Pundit : le
|
|
234
|
+
# second argument (include_all) est indispensable, sinon respond_to? renvoie
|
|
235
|
+
# false et les marqueurs ne sont jamais posés.
|
|
236
|
+
skip_policy_scope if respond_to?(:skip_policy_scope, true)
|
|
237
|
+
skip_authorization if respond_to?(:skip_authorization, true)
|
|
198
238
|
end
|
|
199
239
|
|
|
200
240
|
def render_forbidden
|
|
@@ -20,6 +20,7 @@ module UploadDND
|
|
|
20
20
|
# 3. Helper transfers temp files to real record and destroys TemporaryAttachment
|
|
21
21
|
class TemporaryAttachmentsController < BaseAttachmentsController
|
|
22
22
|
# Autorisation (UploadDND::Authorization, fail-closed)
|
|
23
|
+
before_action :find_target, only: [ :create ]
|
|
23
24
|
before_action :authorize_upload!, only: [ :create ]
|
|
24
25
|
before_action :authorize_delete!, only: [ :destroy, :purge ]
|
|
25
26
|
|
|
@@ -60,7 +61,7 @@ module UploadDND
|
|
|
60
61
|
# - Turbo Stream pour remplacer la liste de fichiers
|
|
61
62
|
# - JSON {status: 'ok'} en mode API
|
|
62
63
|
def create
|
|
63
|
-
attribute_name =
|
|
64
|
+
attribute_name = @target.attribute
|
|
64
65
|
|
|
65
66
|
# Sanitize filename avant de créer l'attachment
|
|
66
67
|
@blob.update(filename: sanitize_filename(@blob.filename.to_s))
|
|
@@ -149,7 +150,7 @@ module UploadDND
|
|
|
149
150
|
else
|
|
150
151
|
respond_to do |format|
|
|
151
152
|
format.json { render json: { status: "ok", message: I18n.t("upload_d_n_d.success.file_uploaded") }, status: :ok }
|
|
152
|
-
format.html { redirect_back fallback_location: root_path, notice: I18n.t("upload_d_n_d.success.file_added") }
|
|
153
|
+
format.html { redirect_back fallback_location: (main_app.respond_to?(:root_path) ? main_app.root_path : "/"), notice: I18n.t("upload_d_n_d.success.file_added") }
|
|
153
154
|
format.any { render json: { status: "ok", message: I18n.t("upload_d_n_d.success.file_uploaded") }, status: :ok }
|
|
154
155
|
end
|
|
155
156
|
end
|
|
@@ -159,27 +160,13 @@ module UploadDND
|
|
|
159
160
|
Rails.logger.info "UploadDND: Temporary file deleted - #{@temp_attachment.attribute_name} by #{current_user&.id || 'anonymous'}"
|
|
160
161
|
end
|
|
161
162
|
|
|
162
|
-
# Limites du champ
|
|
163
|
-
#
|
|
164
|
-
# client, appel direct).
|
|
163
|
+
# Limites du champ visé, lues sur le modèle via la cible signée (obligatoire
|
|
164
|
+
# au dépôt temporaire : plus de repli silencieux sur la configuration globale).
|
|
165
165
|
def validate_blob_constraints
|
|
166
166
|
return unless @blob
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
violation = constraints.violation(@blob)
|
|
172
|
-
render_error(constraints.error_message(violation), :unprocessable_entity) if violation
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def field_constraints
|
|
176
|
-
return UploadDND::FieldConstraints.global if params[:field_ref].blank?
|
|
177
|
-
|
|
178
|
-
UploadDND::FieldReference.constraints(params[:field_ref], params[:attribute_name])
|
|
179
|
-
rescue UploadDND::FieldReference::Invalid
|
|
180
|
-
Rails.logger.warn "UploadDND: invalid field reference for #{params[:attribute_name]}"
|
|
181
|
-
render_error(I18n.t("upload_d_n_d.errors.invalid_field_reference"), :bad_request)
|
|
182
|
-
nil
|
|
168
|
+
violation = @target.constraints.violation(@blob)
|
|
169
|
+
render_error(@target.constraints.error_message(violation), :unprocessable_entity) if violation
|
|
183
170
|
end
|
|
184
171
|
|
|
185
172
|
# === Autorisation ===
|
|
@@ -187,12 +174,18 @@ module UploadDND
|
|
|
187
174
|
def authorize_upload!
|
|
188
175
|
authorize_with_adapter!(:upload_d_n_d_temporary_attachment, :create)
|
|
189
176
|
grant_upload_authorization!
|
|
177
|
+
rescue UploadDND::Authorization::RuleMissing
|
|
178
|
+
Rails.logger.warn "UploadDND: aucune policy UploadDNDTemporaryAttachmentPolicy (create?) - refus (fail-closed)"
|
|
179
|
+
render_forbidden
|
|
190
180
|
rescue UploadDND::Authorization::Denied
|
|
191
181
|
render_forbidden
|
|
192
182
|
end
|
|
193
183
|
|
|
194
184
|
def authorize_delete!
|
|
195
185
|
authorize_with_adapter!(:upload_d_n_d_temporary_attachment, :destroy)
|
|
186
|
+
rescue UploadDND::Authorization::RuleMissing
|
|
187
|
+
Rails.logger.warn "UploadDND: aucune policy UploadDNDTemporaryAttachmentPolicy (destroy?) - refus (fail-closed)"
|
|
188
|
+
render_forbidden
|
|
196
189
|
rescue UploadDND::Authorization::Denied
|
|
197
190
|
render_forbidden
|
|
198
191
|
end
|
|
@@ -13,16 +13,15 @@ module UploadDND
|
|
|
13
13
|
# remove_file). Nécessite une route polymorphique — voir README,
|
|
14
14
|
# section "Records imbriqués". Point d'extension : redéfinissez cette
|
|
15
15
|
# méthode dans le ApplicationHelper de votre app.
|
|
16
|
-
def udnd_remove_file_url(record, attachment, attribute_name)
|
|
16
|
+
def udnd_remove_file_url(record, attachment, attribute_name, namespace: nil)
|
|
17
17
|
if UploadDND.configuration.delete_route == :host
|
|
18
18
|
query = { file_id: attachment.id, attribute_name: attribute_name }.to_query
|
|
19
19
|
"#{udnd_record_path(record)}/remove_file?#{query}"
|
|
20
20
|
else
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
21
|
+
# Jeton régénéré côté serveur : il survit aux re-rendus Turbo Stream du
|
|
22
|
+
# partial (les boutons de suppression portent toujours une cible valide).
|
|
23
|
+
token = UploadDND::SignedTarget.for(record, attribute_name, namespace: namespace)
|
|
24
|
+
UploadDND::Engine.routes.url_helpers.attachment_path(attachment.id, target: token)
|
|
26
25
|
end
|
|
27
26
|
end
|
|
28
27
|
|
|
@@ -6,13 +6,11 @@ export default class extends Controller {
|
|
|
6
6
|
static values = {
|
|
7
7
|
attachUrl: String,
|
|
8
8
|
attributeName: String,
|
|
9
|
-
recordId: String,
|
|
10
|
-
recordGid: String,
|
|
11
9
|
multiple: Boolean,
|
|
12
10
|
maxSize: Number,
|
|
13
11
|
acceptedTypes: String,
|
|
14
12
|
mimeTypes: String,
|
|
15
|
-
|
|
13
|
+
target: String,
|
|
16
14
|
i18n: Object
|
|
17
15
|
}
|
|
18
16
|
|
|
@@ -152,20 +150,11 @@ export default class extends Controller {
|
|
|
152
150
|
}
|
|
153
151
|
|
|
154
152
|
attachBlob(blob) {
|
|
153
|
+
// Cible signée (modèle, attribut, record, namespace) : le serveur ne fait
|
|
154
|
+
// pas confiance au client pour désigner la cible ni le namespace de policy.
|
|
155
155
|
const payload = {
|
|
156
156
|
signed_blob_id: blob.signed_id,
|
|
157
|
-
|
|
158
|
-
multiple: this.multipleValue
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Référence signée du champ : le serveur applique les limites du modèle
|
|
162
|
-
if (this.fieldRefValue) {
|
|
163
|
-
payload.field_ref = this.fieldRefValue
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Si on a un record existant, on passe son GlobalID
|
|
167
|
-
if (this.hasRecordGidValue && this.recordGidValue) {
|
|
168
|
-
payload.record_gid = this.recordGidValue
|
|
157
|
+
target: this.targetValue
|
|
169
158
|
}
|
|
170
159
|
|
|
171
160
|
fetch(this.attachUrlValue, {
|