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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bc35467ae9c71f396f1168a6c900f8a828e87ad9491b99abd0f07c3ebfe49ab
4
- data.tar.gz: 477cb07d1fda946dfccea4181327a4a5c8ce978998c2183a7fd7b38f1fd1ff6d
3
+ metadata.gz: 7a16cbbec64d738e23701d35ec650f5b9b378b494d470b2611ea7415f3e31d3b
4
+ data.tar.gz: 32fdff2762b16165eb808a46a1446d99c855c779d9e8e83ee64954260bf42d97
5
5
  SHA512:
6
- metadata.gz: '09adbe224dd2403b21da5e590e667686dea3c514f9c6c5a11f8cd2b528cf2506883cc283e99b916795afaf9a3278b7ff31c8aa9de41cb8a2ff3b403c5c947d29'
7
- data.tar.gz: b5427eda02e120647c435f27df467126cbe24faa060260cd71833b02c57f4aa5adca316db0f6354606b20e632113eb1a48edeba72b8c5928aa94270ee44818fa
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-field-ref-value="<%= field_ref %>"
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
- # Référence signée du champ, renvoyée au dépôt : le serveur en déduit les
46
- # limites du modèle. nil sans record (limites globales).
47
- def field_ref
48
- return unless record && record.class.respond_to?(:reflect_on_attachment)
49
- return unless record.class.reflect_on_attachment(attribute_name)
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::FieldReference.generate(record.class, attribute_name)
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 dédié à la gestion des attachments permanents (sur records existants).
4
+ # Contrôleur des attachments permanents (sur records existants).
5
5
  #
6
- # Responsabilités:
7
- # - Attacher des fichiers à des records ActiveRecord déjà persistés
8
- # - Valider que le record existe via GlobalID
9
- # - Gérer has_one vs has_many attachments
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
- # Utilisé par:
13
- # - POST /upload_d_n_d/attachments (lors de l'upload sur record existant)
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
- # Autorisation (UploadDND::Authorization, fail-closed)
25
- before_action :authorize_upload!, only: [ :create ]
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
- attribute_name = params[:attribute_name]
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
- authorize_record_upload!(record)
58
- return if performed?
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
- # Limites du champ, lues sur le modèle du record (vérifiées avant toute
70
- # écriture ; le blob refusé est purgé).
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
- # Détache un fichier d'un record existant, sans dépendre des routes de
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 = GlobalID::Locator.locate(params[:record_gid])
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 annoncés
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
- def validate_destroy_params
142
- unless params[:record_gid].present? && params[:attribute_name].present?
143
- render_error(I18n.t("upload_d_n_d.errors.missing_params"), :bad_request)
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
- unless params[:attribute_name].match?(/\A[a-z_][a-z0-9_]*\z/)
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
- class BaseAttachmentsController < ActionController::Base
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
- unless params[:signed_blob_id].present? && params[:attribute_name].present?
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
- # Validation du nom d'attribut (alphanumeric + underscore uniquement)
108
- unless params[:attribute_name].match?(ATTRIBUTE_NAME_FORMAT)
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
- def current_user
168
- # Tente de récupérer current_user depuis l'application hôte
169
- # En utilisant warden (Devise)
170
- if defined?(Warden) && request.env["warden"]
171
- request.env["warden"].user
172
- elsif respond_to?(:super, true)
173
- super
174
- else
175
- nil
176
- end
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 = params[: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 visé : celles du modèle si le composant a fourni sa
163
- # référence signée (field_ref), sinon la configuration globale (ancien
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
- constraints = field_constraints
169
- return if performed?
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
- UploadDND::Engine.routes.url_helpers.attachment_path(
22
- attachment.id,
23
- record_gid: record.to_global_id.to_s,
24
- attribute_name: attribute_name
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
- fieldRef: String,
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
- attribute_name: this.attributeNameValue,
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, {