upload_d_n_d 0.9.1

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.

Potentially problematic release.


This version of upload_d_n_d might be problematic. Click here for more details.

Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +229 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/config/upload_d_n_d_manifest.js +3 -0
  6. data/app/assets/stylesheets/upload_d_n_d/application.css +604 -0
  7. data/app/components/upload_d_n_d/upload_component.html.erb +53 -0
  8. data/app/components/upload_d_n_d/upload_component.rb +204 -0
  9. data/app/controllers/concerns/upload_d_n_d/rate_limiting.rb +89 -0
  10. data/app/controllers/upload_d_n_d/attachments_controller.rb +218 -0
  11. data/app/controllers/upload_d_n_d/base_attachments_controller.rb +250 -0
  12. data/app/controllers/upload_d_n_d/temporary_attachments_controller.rb +200 -0
  13. data/app/helpers/upload_d_n_d/application_helper.rb +51 -0
  14. data/app/helpers/upload_d_n_d/controllers/helpers.rb +21 -0
  15. data/app/javascript/upload_d_n_d/controllers/preview_controller.js +217 -0
  16. data/app/javascript/upload_d_n_d/controllers/uploads_controller.js +396 -0
  17. data/app/javascript/upload_d_n_d/index.js +13 -0
  18. data/app/jobs/upload_d_n_d/application_job.rb +4 -0
  19. data/app/jobs/upload_d_n_d/cleanup_temporary_attachments_job.rb +17 -0
  20. data/app/jobs/upload_d_n_d/purge_orphan_blobs_job.rb +25 -0
  21. data/app/jobs/upload_d_n_d/purge_rejected_blob_job.rb +24 -0
  22. data/app/models/upload_d_n_d/application_record.rb +5 -0
  23. data/app/models/upload_d_n_d/temporary_attachment.rb +129 -0
  24. data/app/views/upload_d_n_d/shared/_file_icon.html.erb +9 -0
  25. data/app/views/upload_d_n_d/shared/_files_list.html.erb +63 -0
  26. data/app/views/upload_d_n_d/shared/_temp_files_list.html.erb +53 -0
  27. data/config/importmap.rb +5 -0
  28. data/config/initializers/inflections.rb +11 -0
  29. data/config/locales/upload_d_n_d.en.yml +80 -0
  30. data/config/locales/upload_d_n_d.fr.yml +80 -0
  31. data/config/routes.rb +12 -0
  32. data/docs/CHANGELOG.md +282 -0
  33. data/lib/generators/upload_d_n_d/POLICY_README +49 -0
  34. data/lib/generators/upload_d_n_d/USAGE +80 -0
  35. data/lib/generators/upload_d_n_d/install_generator.rb +174 -0
  36. data/lib/generators/upload_d_n_d/mime_type_helper.rb +106 -0
  37. data/lib/generators/upload_d_n_d/model_generator.rb +313 -0
  38. data/lib/generators/upload_d_n_d/policy_generator.rb +25 -0
  39. data/lib/generators/upload_d_n_d/templates/POST_INSTALL +62 -0
  40. data/lib/generators/upload_d_n_d/templates/concerns/controllers_uploadable.rb +115 -0
  41. data/lib/generators/upload_d_n_d/templates/concerns/upload_authorization.rb +91 -0
  42. data/lib/generators/upload_d_n_d/templates/concerns/uploadable.rb +101 -0
  43. data/lib/generators/upload_d_n_d/templates/controller.rb.tt +60 -0
  44. data/lib/generators/upload_d_n_d/templates/helpers/upload_helper.rb +105 -0
  45. data/lib/generators/upload_d_n_d/templates/initializers/upload_d_n_d.rb +69 -0
  46. data/lib/generators/upload_d_n_d/templates/migrations/create_upload_d_n_d_temporary_attachments.rb +18 -0
  47. data/lib/generators/upload_d_n_d/templates/policies/upload_d_n_d_attachment_policy.rb +37 -0
  48. data/lib/generators/upload_d_n_d/templates/policies/upload_d_n_d_temporary_attachment_policy.rb +56 -0
  49. data/lib/generators/upload_d_n_d/templates/validators/file_validator.rb +171 -0
  50. data/lib/tasks/upload_d_n_d_storage.rake +27 -0
  51. data/lib/tasks/upload_d_n_d_tasks.rake +20 -0
  52. data/lib/upload_d_n_d/attachment_lookup.rb +24 -0
  53. data/lib/upload_d_n_d/authorization/can_can_can_adapter.rb +29 -0
  54. data/lib/upload_d_n_d/authorization/no_authorization.rb +13 -0
  55. data/lib/upload_d_n_d/authorization/pundit_adapter.rb +41 -0
  56. data/lib/upload_d_n_d/authorization/unconfigured.rb +15 -0
  57. data/lib/upload_d_n_d/authorization.rb +66 -0
  58. data/lib/upload_d_n_d/configuration.rb +97 -0
  59. data/lib/upload_d_n_d/controllers/uploadable.rb +271 -0
  60. data/lib/upload_d_n_d/engine.rb +50 -0
  61. data/lib/upload_d_n_d/field_constraints.rb +92 -0
  62. data/lib/upload_d_n_d/field_reference.rb +44 -0
  63. data/lib/upload_d_n_d/file_validator.rb +156 -0
  64. data/lib/upload_d_n_d/mime_types.rb +75 -0
  65. data/lib/upload_d_n_d/orphan_blobs.rb +151 -0
  66. data/lib/upload_d_n_d/rejected_blob.rb +42 -0
  67. data/lib/upload_d_n_d/session_id.rb +25 -0
  68. data/lib/upload_d_n_d/uploadable.rb +117 -0
  69. data/lib/upload_d_n_d/version.rb +3 -0
  70. data/lib/upload_d_n_d.rb +62 -0
  71. metadata +150 -0
@@ -0,0 +1,204 @@
1
+ module UploadDND
2
+ class UploadComponent < ViewComponent::Base
3
+ attr_reader :record, :attribute_name, :multiple, :accepted_types, :max_size, :user, :namespace
4
+
5
+ def initialize(
6
+ attribute_name:,
7
+ record: nil,
8
+ user: nil,
9
+ multiple: nil,
10
+ accepted_types: nil,
11
+ max_size: nil,
12
+ namespace: nil
13
+ )
14
+ @record = record
15
+ @user = user
16
+ @attribute_name = attribute_name
17
+ @namespace = namespace
18
+
19
+ # Récupère la config depuis le modèle si disponible, sinon utilise les params ou la config globale
20
+ model_config = get_uploadable_config
21
+
22
+ # Détection automatique de multiple basée sur has_one vs has_many
23
+ # Peut être surchargé explicitement via le paramètre ou la config du modèle
24
+ @multiple = if multiple.nil?
25
+ model_config[:multiple].nil? ? detect_multiple_from_attachment : model_config[:multiple]
26
+ else
27
+ multiple
28
+ end
29
+
30
+ # Limites du champ, identiques à celles appliquées par le serveur.
31
+ # accepted_types: et max_size: ne changent que l'affichage : le serveur
32
+ # applique toujours les limites du modèle.
33
+ @constraints = field_constraints
34
+ @accepted_types = accepted_types || @constraints.accepted_extensions
35
+ @max_size = max_size || @constraints.max_size
36
+ end
37
+
38
+ # Types MIME acceptés, pour le contrôle du navigateur
39
+ def accepted_mime_types
40
+ return @constraints.content_types.join(",") if @accepted_types == @constraints.accepted_extensions
41
+
42
+ UploadDND::MimeTypes.from_accepted_types(@accepted_types).join(",")
43
+ end
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)
50
+
51
+ UploadDND::FieldReference.generate(record.class, attribute_name)
52
+ end
53
+
54
+ def model_name
55
+ return attribute_name.to_s.singularize unless record
56
+
57
+ # Convertit "Admin::Article" en "admin_article" (pour les IDs DOM)
58
+ record.class.name.underscore.tr("/", "_")
59
+ end
60
+
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
+ # Détermine l'URL d'attachement : soit vers le moteur de la gem, soit vers une route spécifique
70
+ def attach_path
71
+ action_path = record&.persisted? ? :attachments_path : :temporary_attachments_path
72
+
73
+ # Si pas de namespace, on utilise les routes internes de l'engine (plus robuste)
74
+ return UploadDND::Engine.routes.url_helpers.send(action_path) if namespace.blank?
75
+
76
+ # Avec namespace, on utilise polymorphic_path pour supporter les routes de l'app hôte
77
+ begin
78
+ action = record&.persisted? ? :attachments : :temporary_attachments
79
+ helpers.polymorphic_path([ namespace, :upload_d_n_d, action ])
80
+ rescue NoMethodError, ArgumentError, ViewComponent::HelpersCalledBeforeRenderError
81
+ # En cas d'erreur (ex: helpers non dispos avant render), fallback sur l'engine
82
+ UploadDND::Engine.routes.url_helpers.send(action_path)
83
+ end
84
+ end
85
+
86
+ def files
87
+ if record&.persisted?
88
+ get_attachments
89
+ else
90
+ # Fichiers temporaires de la session courante (formulaire sans record persisté)
91
+ UploadDND::TemporaryAttachment
92
+ .for_session(UploadDND::SessionId.for(helpers.session))
93
+ .for_attribute(attribute_name)
94
+ .with_blobs
95
+ end
96
+ end
97
+
98
+ # --- Thème / Couleurs ---
99
+ # Exposé pour permettre au composant d'injecter des variables CSS cohérentes
100
+ def main_color
101
+ UploadDND.configuration.main_color.presence || "#E97451"
102
+ end
103
+
104
+ # Renvoie "r g b" (ex: "233 116 81") pour usage avec `rgb(var(--udnd-accent-rgb) / 0.2)`
105
+ def main_color_rgb
106
+ hex = main_color.to_s.delete_prefix("#")
107
+ hex = hex.chars.map { |c| "#{c}#{c}" }.join if hex.length == 3
108
+
109
+ return "233 116 81" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
110
+
111
+ r = hex[0..1].to_i(16)
112
+ g = hex[2..3].to_i(16)
113
+ b = hex[4..5].to_i(16)
114
+ "#{r} #{g} #{b}"
115
+ end
116
+
117
+ private
118
+
119
+ def field_constraints
120
+ return UploadDND::FieldConstraints.global unless record
121
+
122
+ UploadDND::FieldConstraints.for(record.class, attribute_name)
123
+ end
124
+
125
+ def get_uploadable_config
126
+ return {} unless record&.class.respond_to?(:uploadable_attributes)
127
+
128
+ record.class.uploadable_attributes[attribute_name.to_sym] || {}
129
+ end
130
+
131
+ # Détecte automatiquement si l'attachment est has_one ou has_many
132
+ def detect_multiple_from_attachment
133
+ return false unless record&.respond_to?(attribute_name)
134
+
135
+ attachment = record.send(attribute_name)
136
+
137
+ # has_many_attached retourne une collection qui respond_to :attach
138
+ # has_one_attached retourne un objet qui ne respond_to pas :each
139
+ attachment.is_a?(ActiveStorage::Attached::Many)
140
+ end
141
+
142
+ def get_attachments
143
+ return [] unless record&.respond_to?(attribute_name)
144
+
145
+ attachment = record.public_send(attribute_name)
146
+ case attachment
147
+ when ActiveStorage::Attached::Many
148
+ attachment.blobs
149
+ when ActiveStorage::Attached::One
150
+ attachment.attached? ? [ attachment.blob ] : []
151
+ else
152
+ []
153
+ end
154
+ end
155
+
156
+ # Retourne les messages i18n pour JavaScript
157
+ def i18n_messages
158
+ {
159
+ alerts: {
160
+ singleFileOnly: {
161
+ title: I18n.t("upload_d_n_d.javascript.alerts.single_file_only.title"),
162
+ text: I18n.t("upload_d_n_d.javascript.alerts.single_file_only.text")
163
+ },
164
+ uploadError: {
165
+ title: I18n.t("upload_d_n_d.javascript.alerts.upload_error.title"),
166
+ text: I18n.t("upload_d_n_d.javascript.alerts.upload_error.text")
167
+ },
168
+ fileTooLarge: {
169
+ title: I18n.t("upload_d_n_d.javascript.alerts.file_too_large.title"),
170
+ text: I18n.t("upload_d_n_d.javascript.alerts.file_too_large.text")
171
+ },
172
+ emptyFile: {
173
+ title: I18n.t("upload_d_n_d.javascript.alerts.empty_file.title"),
174
+ text: I18n.t("upload_d_n_d.javascript.alerts.empty_file.text")
175
+ },
176
+ invalidFormat: {
177
+ title: I18n.t("upload_d_n_d.javascript.alerts.invalid_format.title"),
178
+ text: I18n.t("upload_d_n_d.javascript.alerts.invalid_format.text")
179
+ },
180
+ suspiciousType: {
181
+ title: I18n.t("upload_d_n_d.javascript.alerts.suspicious_type.title"),
182
+ text: I18n.t("upload_d_n_d.javascript.alerts.suspicious_type.text")
183
+ },
184
+ attachError: {
185
+ title: I18n.t("upload_d_n_d.javascript.alerts.attach_error.title"),
186
+ text: I18n.t("upload_d_n_d.javascript.alerts.attach_error.text")
187
+ },
188
+ deleteError: {
189
+ title: I18n.t("upload_d_n_d.javascript.alerts.delete_error.title"),
190
+ text: I18n.t("upload_d_n_d.javascript.alerts.delete_error.text")
191
+ }
192
+ },
193
+ confirms: {
194
+ deleteFile: {
195
+ title: I18n.t("upload_d_n_d.javascript.confirms.delete_file.title"),
196
+ text: I18n.t("upload_d_n_d.javascript.confirms.delete_file.text"),
197
+ confirmButton: I18n.t("upload_d_n_d.javascript.confirms.delete_file.confirm_button"),
198
+ cancelButton: I18n.t("upload_d_n_d.javascript.confirms.delete_file.cancel_button")
199
+ }
200
+ }
201
+ }
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UploadDND
4
+ module RateLimiting
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_action :check_rate_limit, if: :rate_limiting_enabled?
9
+ end
10
+
11
+ private
12
+
13
+ def rate_limiting_enabled?
14
+ UploadDND.configuration.rate_limit_enabled
15
+ end
16
+
17
+ def check_rate_limit
18
+ # Clé basée sur l'IP et l'utilisateur si disponible
19
+ key = rate_limit_key
20
+
21
+ # Récupère le compteur actuel pour cette clé
22
+ count = increment_rate_limit_counter(key)
23
+
24
+ max_requests = UploadDND.configuration.rate_limit_max_requests
25
+ period = UploadDND.configuration.rate_limit_period
26
+
27
+ if count > max_requests
28
+ Rails.logger.warn "UploadDND RateLimit: #{key} exceeded limit (#{count}/#{max_requests})"
29
+
30
+ # Calcule le temps restant avant reset
31
+ retry_after = rate_limit_reset_time(key)
32
+
33
+ response.headers["Retry-After"] = retry_after.to_s
34
+ response.headers["X-RateLimit-Limit"] = max_requests.to_s
35
+ response.headers["X-RateLimit-Remaining"] = "0"
36
+ response.headers["X-RateLimit-Reset"] = (Time.current + retry_after.seconds).to_i.to_s
37
+
38
+ render_error(
39
+ I18n.t("upload_d_n_d.errors.rate_limited", seconds: retry_after),
40
+ :too_many_requests
41
+ )
42
+
43
+ false # Stop processing
44
+ else
45
+ # Ajoute les headers informatifs
46
+ response.headers["X-RateLimit-Limit"] = max_requests.to_s
47
+ response.headers["X-RateLimit-Remaining"] = (max_requests - count).to_s
48
+ response.headers["X-RateLimit-Reset"] = (Time.current + period.seconds).to_i.to_s
49
+
50
+ true # Continue processing
51
+ end
52
+ end
53
+
54
+ def rate_limit_key
55
+ # Combine IP et user_id si disponible pour éviter le partage de limite
56
+ ip = request.remote_ip
57
+ user_identifier = current_user&.id || "anonymous"
58
+
59
+ "upload_dnd:rate_limit:#{user_identifier}:#{ip}"
60
+ end
61
+
62
+ def increment_rate_limit_counter(key)
63
+ period = UploadDND.configuration.rate_limit_period
64
+
65
+ # increment est atomique, contrairement à read + write qui laisse
66
+ # passer des requêtes concurrentes sous la barre du compteur.
67
+ # Certains stores (NullStore, Memcached sans clé existante) renvoient
68
+ # nil : on initialise alors le compteur en format raw.
69
+ Rails.cache.increment(key, 1, expires_in: period.seconds) || begin
70
+ Rails.cache.write(key, 1, raw: true, expires_in: period.seconds)
71
+ 1
72
+ end
73
+ end
74
+
75
+ def rate_limit_reset_time(key)
76
+ # Récupère le TTL du cache pour cette clé
77
+ # Si le cache ne supporte pas ttl, retourne la période par défaut
78
+ period = UploadDND.configuration.rate_limit_period
79
+
80
+ begin
81
+ # Certains cache stores supportent `ttl` (Redis, Memcached)
82
+ ttl = Rails.cache.instance_variable_get(:@data)&.ttl(key)
83
+ ttl && ttl > 0 ? ttl : period
84
+ rescue
85
+ period
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UploadDND
4
+ # Contrôleur dédié à la gestion des attachments permanents (sur records existants).
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
11
+ #
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.
23
+ class AttachmentsController < BaseAttachmentsController
24
+ # Autorisation (UploadDND::Authorization, fail-closed)
25
+ before_action :authorize_upload!, only: [ :create ]
26
+ before_action :reject_attached_blob, only: [ :create ]
27
+
28
+ before_action :validate_record_gid_presence, only: [ :create ]
29
+ before_action :validate_destroy_params, only: [ :destroy ]
30
+
31
+ # POST /upload_d_n_d/attachments
32
+ #
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
42
+ 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
56
+
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]
63
+ attachment = UploadDND::AttachmentLookup.find(record, attribute_name)
64
+ unless attachment
65
+ render_error(I18n.t("upload_d_n_d.errors.invalid_attribute", attribute: attribute_name), :unprocessable_entity)
66
+ return
67
+ end
68
+
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)
74
+ return
75
+ end
76
+
77
+ # Sanitize filename avant l'attachement
78
+ @blob.update(filename: sanitize_filename(@blob.filename.to_s))
79
+
80
+ if attachment.is_a?(ActiveStorage::Attached::Many)
81
+ # has_many_attached: ajouter à la collection
82
+ attachment.attach(@blob)
83
+ elsif attachment.is_a?(ActiveStorage::Attached::One)
84
+ # has_one_attached: remplacer (purge l'ancien automatiquement)
85
+ record.send("#{attribute_name}=", @blob)
86
+ else
87
+ render_error(I18n.t("upload_d_n_d.errors.not_active_storage_attachment", attribute: attribute_name), :unprocessable_entity)
88
+ return
89
+ end
90
+
91
+ # Sauvegarde avec validation
92
+ if record.save
93
+ render_file_list(record, attribute_name)
94
+ log_upload_success
95
+ else
96
+ render_error(I18n.t("upload_d_n_d.errors.save_failed", errors: record.errors.full_messages.join(", ")), :unprocessable_entity)
97
+ end
98
+ rescue ActiveRecord::RecordInvalid => e
99
+ render_error(I18n.t("upload_d_n_d.errors.save_failed", errors: e.message), :unprocessable_entity)
100
+ end
101
+
102
+ # DELETE /upload_d_n_d/attachments/:id
103
+ #
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")
112
+ 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?
122
+
123
+ attachment = ActiveStorage::Attachment.find_by(id: params[:id])
124
+ attribute_name = params[:attribute_name]
125
+
126
+ # Sécurité : le fichier doit appartenir au record ET à l'attribut annoncés
127
+ if attachment && attachment.record == record && attachment.name == attribute_name.to_s
128
+ attachment.purge_later
129
+ record.reload
130
+
131
+ UploadDND.configuration.on_file_removed&.call(record, attribute_name, current_user)
132
+
133
+ render_file_list(record, attribute_name)
134
+ else
135
+ render_error(I18n.t("upload_d_n_d.errors.file_not_found_or_denied"), :not_found)
136
+ end
137
+ end
138
+
139
+ private
140
+
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)
144
+ return
145
+ end
146
+
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
179
+ grant_upload_authorization!
180
+ rescue UploadDND::Authorization::Denied
181
+ render_forbidden
182
+ end
183
+
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
+ def render_file_list(record, attribute_name)
192
+ if turbo_stream_request?
193
+ render_turbo_stream_replace(
194
+ "udnd_files_list_#{attribute_name}",
195
+ partial: "upload_d_n_d/shared/files_list",
196
+ locals: { record: record, attribute_name: attribute_name }
197
+ )
198
+ else
199
+ respond_to do |format|
200
+ 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") }
202
+ format.any { render json: { status: "ok", message: I18n.t("upload_d_n_d.success.file_added") }, status: :ok }
203
+ end
204
+ end
205
+ 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
+ end
218
+ end