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,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UploadDND
4
+ module Controllers
5
+ module Uploadable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ # Gestion des erreurs ActiveStorage
10
+ rescue_from ActiveStorage::IntegrityError do |_e|
11
+ respond_to do |format|
12
+ format.turbo_stream do
13
+ flash[:alert] = I18n.t("upload_d_n_d.errors.corrupted")
14
+ render turbo_stream: turbo_stream.replace("flash_messages", partial: "shared/flashes"),
15
+ status: :unprocessable_entity
16
+ end
17
+ format.json { render json: { error: I18n.t("upload_d_n_d.errors.corrupted") }, status: :unprocessable_entity }
18
+ end
19
+ end
20
+
21
+ rescue_from ActiveStorage::UnrepresentableError do |_e|
22
+ respond_to do |format|
23
+ format.turbo_stream do
24
+ flash[:alert] = I18n.t("upload_d_n_d.errors.unrepresentable")
25
+ render turbo_stream: turbo_stream.replace("flash_messages", partial: "shared/flashes"),
26
+ status: :unprocessable_entity
27
+ end
28
+ format.json { render json: { error: I18n.t("upload_d_n_d.errors.unrepresentable") }, status: :unprocessable_entity }
29
+ end
30
+ end
31
+ end
32
+
33
+ # Transfère le session_id d'upload temporaire vers le record,
34
+ # uniquement si le param correspond à la session courante (sécurité).
35
+ #
36
+ # @param record [ApplicationRecord] le record sur lequel wirer le session_id
37
+ def wire_temporary_uploads(record)
38
+ sid = params[:temp_upload_session_id]
39
+ if sid.present? && sid == UploadDND::SessionId.for(session)
40
+ record.temp_upload_session_id = sid
41
+ end
42
+ end
43
+
44
+ # Attache un fichier à un record existant
45
+ # Utilise les fichiers temporaires uploadés via UploadDND
46
+ #
47
+ # @param record [ApplicationRecord] le record sur lequel attacher
48
+ # @param attribute_name [String, Symbol] le nom de l'attribut (ex: :avatar, :documents)
49
+ def attach_upload_d_n_d_files(record, attribute_name)
50
+ # Délègue au modèle TemporaryAttachment pour éviter la duplication
51
+ UploadDND::TemporaryAttachment.attach_to_record(
52
+ UploadDND::SessionId.for(session),
53
+ record,
54
+ { attribute_name.to_s => attribute_name }
55
+ )
56
+ end
57
+
58
+ # Autorise un record pour update via UploadDND::Authorization
59
+ # (fail-closed). Avec Pundit, la policy namespacée du controller est
60
+ # essayée d'abord (Admin::RecordPolicy), puis la policy top-level.
61
+ #
62
+ # L'erreur native de la librairie (Pundit::NotAuthorizedError...) est
63
+ # relancée telle quelle, pour les rescue_from de l'app.
64
+ #
65
+ # @param record [ApplicationRecord] le record à autoriser
66
+ def authorize_record_for_update(record)
67
+ UploadDND::Authorization.authorize!(self, record, :update, namespace: policy_namespace)
68
+ rescue UploadDND::Authorization::Denied, UploadDND::Authorization::RuleMissing => e
69
+ raise e.cause || e
70
+ end
71
+
72
+ # Action pour attacher un fichier à un record existant (appelée via route de l'app)
73
+ # Route attendue : POST /:model_name/:id/attach_file
74
+ def attach_file
75
+ # Utilise l'instance variable définie par before_action (ex: @project)
76
+ record = instance_variable_get("@#{controller_name.singularize}")
77
+
78
+ # Si pas d'instance variable, cherche le record via GlobalID (support namespaces)
79
+ unless record
80
+ if params[:record_gid].present?
81
+ record = GlobalID::Locator.locate(params[:record_gid])
82
+ else
83
+ # Fallback pour compatibilité (à déprécier)
84
+ model_name = params[:model_name] || controller_name.singularize
85
+ record = find_record_for_upload(model_name, params[:id])
86
+ end
87
+ end
88
+
89
+ authorize_record_for_update(record)
90
+ # Posé seulement si l'autorisation a réussi : sans lui, rien n'est purgé.
91
+ authorized = true
92
+
93
+ blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id])
94
+ attribute_name = params[:attribute_name]
95
+
96
+ # SÉCURITÉ : un blob déjà rattaché (signed_id visible dans chaque URL de
97
+ # fichier) n'est pas rattaché une seconde fois. Pas de purge : il
98
+ # appartient à un autre record.
99
+ if blob&.attachments&.exists?
100
+ render_attach_file_error(I18n.t("upload_d_n_d.errors.blob_already_attached"))
101
+ return
102
+ end
103
+
104
+ # SÉCURITÉ : attribute_name vient du client — seul un attachment déclaré
105
+ # sur le modèle est résolu, jamais une méthode quelconque (destroy...).
106
+ attachment = UploadDND::AttachmentLookup.find(record, attribute_name)
107
+ unless attachment
108
+ discard_rejected_upload_blob(blob, authorized)
109
+ render_attach_file_error(I18n.t("upload_d_n_d.errors.invalid_attribute", attribute: attribute_name))
110
+ return
111
+ end
112
+
113
+ constraints = UploadDND::FieldConstraints.for(record.class, attribute_name)
114
+ if blob && (violation = constraints.violation(blob))
115
+ discard_rejected_upload_blob(blob, authorized)
116
+ render_attach_file_error(constraints.error_message(violation))
117
+ return
118
+ end
119
+
120
+ attachment.attach(blob)
121
+
122
+ if record.save
123
+ respond_to do |format|
124
+ format.turbo_stream do
125
+ flash.now[:notice] = I18n.t("upload_d_n_d.success.file_added")
126
+ render turbo_stream: [
127
+ turbo_stream.replace(
128
+ "udnd_files_list_#{attribute_name}",
129
+ partial: "upload_d_n_d/shared/files_list",
130
+ locals: { record: record.reload, attribute_name: attribute_name }
131
+ ),
132
+ turbo_stream.replace("flash_messages", partial: "shared/flashes")
133
+ ]
134
+ end
135
+ format.json { render json: { status: "ok" }, status: :ok }
136
+ end
137
+ else
138
+ discard_rejected_upload_blob(blob, authorized)
139
+ respond_to do |format|
140
+ format.turbo_stream do
141
+ flash.now[:alert] = I18n.t("upload_d_n_d.errors.save_failed", errors: record.errors.full_messages.join(", "))
142
+ render turbo_stream: turbo_stream.replace("flash_messages", partial: "shared/flashes"),
143
+ status: :unprocessable_entity
144
+ end
145
+ format.json { render json: { error: record.errors.full_messages }, status: :unprocessable_entity }
146
+ end
147
+ end
148
+ rescue StandardError
149
+ # Refus de droits ou autorisation non aboutie : authorized est nil,
150
+ # rien n'est purgé. L'exception est toujours relancée.
151
+ discard_rejected_upload_blob(blob, authorized)
152
+ raise
153
+ end
154
+
155
+ # Action pour supprimer un fichier d'un record existant (appelée via route de l'app)
156
+ # Route attendue : DELETE /:model_name/:id/remove_file
157
+ def remove_file
158
+ # Utilise l'instance variable définie par before_action (ex: @project)
159
+ record = instance_variable_get("@#{controller_name.singularize}")
160
+
161
+ # Si pas d'instance variable, cherche le record via GlobalID (support namespaces)
162
+ unless record
163
+ if params[:record_gid].present?
164
+ record = GlobalID::Locator.locate(params[:record_gid])
165
+ else
166
+ # Fallback pour compatibilité (à déprécier)
167
+ model_name = params[:model_name] || controller_name.singularize
168
+ record = find_record_for_upload(model_name, params[:id])
169
+ end
170
+ end
171
+
172
+ authorize_record_for_update(record)
173
+
174
+ attachment = ActiveStorage::Attachment.find(params[:file_id])
175
+ attribute_name = params[:attribute_name]
176
+
177
+ # Vérification de sécurité : le fichier appartient bien au record
178
+ if attachment.record == record && attachment.name == attribute_name.to_s
179
+ attachment.purge_later
180
+ record.reload
181
+
182
+ respond_to do |format|
183
+ format.turbo_stream do
184
+ flash.now[:notice] = I18n.t("upload_d_n_d.success.file_removed")
185
+ render turbo_stream: [
186
+ turbo_stream.replace(
187
+ "udnd_files_list_#{attribute_name}",
188
+ partial: "upload_d_n_d/shared/files_list",
189
+ locals: { record: record, attribute_name: attribute_name }
190
+ ),
191
+ turbo_stream.replace("flash_messages", partial: "shared/flashes")
192
+ ]
193
+ end
194
+ format.json { render json: { status: "ok" }, status: :ok }
195
+ end
196
+ else
197
+ respond_to do |format|
198
+ format.turbo_stream do
199
+ flash.now[:alert] = I18n.t("upload_d_n_d.errors.file_not_found_or_denied")
200
+ render turbo_stream: turbo_stream.replace("flash_messages", partial: "shared/flashes"),
201
+ status: :not_found
202
+ end
203
+ format.json { render json: { error: I18n.t("upload_d_n_d.errors.blob_not_found") }, status: :not_found }
204
+ end
205
+ end
206
+ end
207
+
208
+ private
209
+
210
+ def render_attach_file_error(message)
211
+ respond_to do |format|
212
+ format.turbo_stream do
213
+ flash.now[:alert] = message
214
+ render turbo_stream: turbo_stream.replace("flash_messages", partial: "shared/flashes"), status: :unprocessable_entity
215
+ end
216
+ format.json { render json: { error: message }, status: :unprocessable_entity }
217
+ end
218
+ end
219
+
220
+ # Blob envoyé par DirectUpload puis refusé : purgé sous les garde-fous de
221
+ # UploadDND::RejectedBlob, et seulement si l'autorisation a réussi. Un échec
222
+ # de la purge ne masque pas l'erreur d'origine.
223
+ def discard_rejected_upload_blob(blob, authorized)
224
+ return unless authorized
225
+
226
+ UploadDND::RejectedBlob.discard(blob)
227
+ rescue StandardError => e
228
+ Rails.logger.error "UploadDND: rejected blob purge failed - #{e.class}: #{e.message}"
229
+ end
230
+
231
+ # Retourne le namespace du controller si présent
232
+ # Ex: Admin::ProjectsController => "Admin"
233
+ # Ex: Admin::Internal::ProjectsController => "Admin::Internal"
234
+ def controller_namespace
235
+ controller_class = self.class.name.to_s
236
+ namespace_parts = controller_class.split("::")
237
+ if namespace_parts.length > 1
238
+ namespace_parts[0...-1].join("::")
239
+ else
240
+ nil
241
+ end
242
+ end
243
+
244
+ # Namespace du controller en symboles : Admin::Internal -> [:admin, :internal]
245
+ def policy_namespace
246
+ controller_namespace&.underscore&.split("/")&.map(&:to_sym)
247
+ end
248
+
249
+ # @deprecated Utilisez GlobalID à la place (params[:record_gid])
250
+ # Trouve le record pour l'upload
251
+ # Supporte les namespaces (ex: "admin_article" -> Admin::Article)
252
+ def find_record_for_upload(model_name, record_id)
253
+ # Tente de détecter le namespace depuis le controller
254
+ if (ns = controller_namespace)
255
+ # Convertit "article" en "Admin::Article" si on est dans Admin::ArticlesController
256
+ class_name = "#{ns}::#{model_name.classify}"
257
+ begin
258
+ model_class = class_name.constantize
259
+ return model_class.find(record_id)
260
+ rescue NameError
261
+ # Fallback si le namespace ne contient pas ce modèle
262
+ end
263
+ end
264
+
265
+ # Fallback standard (sans namespace)
266
+ model_class = model_name.classify.constantize
267
+ model_class.find(record_id)
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,50 @@
1
+ module UploadDND
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace UploadDND
4
+
5
+ # Configuration des acronymes pour que Rails reconnaisse DND
6
+ initializer "upload_d_n_d.inflections" do
7
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
8
+ inflect.acronym "DND"
9
+ end
10
+
11
+ ActiveSupport::Inflector.inflections(:fr) do |inflect|
12
+ inflect.acronym "DND"
13
+ end
14
+ end
15
+
16
+ # Helpers de la gem disponibles dans les vues de l'app hôte
17
+ # (les partials de la gem sont rendus depuis les controllers de l'app,
18
+ # qui n'incluent pas les helpers d'un engine isolé par défaut)
19
+ initializer "upload_d_n_d.helpers" do
20
+ ActiveSupport.on_load(:action_controller_base) do
21
+ helper UploadDND::Engine.helpers
22
+ end
23
+ end
24
+
25
+ # Uploads non protégés par une librairie d'autorisation : signalé au
26
+ # démarrage, une fois les initializers de l'app passés.
27
+ config.after_initialize do
28
+ UploadDND::Authorization.report
29
+ end
30
+
31
+ # Assets : JS (importmap) + CSS (asset pipeline)
32
+ initializer "upload_d_n_d.assets" do |app|
33
+ # JS : rend les fichiers disponibles pour importmap
34
+ app.config.assets.paths << root.join("app/javascript")
35
+
36
+ # CSS : rend upload_d_n_d/application.css disponible via stylesheet_link_tag
37
+ app.config.assets.paths << root.join("app/assets/stylesheets")
38
+ end
39
+
40
+ # Importmap : pins de la gem (upload_d_n_d, controllers Stimulus), lus par
41
+ # importmap-rails avec ceux de l'app. Avant son initializer, qui dessine
42
+ # config.importmap.paths.
43
+ initializer "upload_d_n_d.importmap", before: "importmap" do |app|
44
+ if app.config.respond_to?(:importmap)
45
+ app.config.importmap.paths << root.join("config/importmap.rb")
46
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UploadDND
4
+ # Limites d'un champ d'upload : taille maximale, types MIME acceptés,
5
+ # contrôle MIME strict. Source unique pour le composant, les contrôleurs et
6
+ # le transfert des fichiers temporaires.
7
+ #
8
+ # Value object (Data) : immuable, deux instances aux mêmes valeurs sont
9
+ # égales. Construit depuis `uploadable` du modèle ; chaque limite que le
10
+ # modèle ne déclare pas retombe sur la configuration globale.
11
+ FieldConstraints = Data.define(:max_size, :content_types, :strict_mime_check) do
12
+ class << self
13
+ def for(model_class, attribute_name)
14
+ options = uploadable_options(model_class, attribute_name)
15
+ return global unless options
16
+
17
+ config = UploadDND.configuration
18
+ new(
19
+ max_size: options[:max_size] || config.max_size,
20
+ content_types: options[:content_type] || MimeTypes.from_accepted_types(config.accepted_types),
21
+ strict_mime_check: options.key?(:strict_mime_check) ? options[:strict_mime_check] : config.strict_mime_check
22
+ )
23
+ end
24
+
25
+ def global
26
+ config = UploadDND.configuration
27
+ new(
28
+ max_size: config.max_size,
29
+ content_types: MimeTypes.from_accepted_types(config.accepted_types),
30
+ strict_mime_check: config.strict_mime_check
31
+ )
32
+ end
33
+
34
+ private
35
+
36
+ def uploadable_options(model_class, attribute_name)
37
+ return unless model_class.respond_to?(:uploadable_attributes)
38
+
39
+ model_class.uploadable_attributes[attribute_name.to_sym]
40
+ end
41
+ end
42
+
43
+ def initialize(max_size:, content_types:, strict_mime_check:)
44
+ super(
45
+ max_size: max_size,
46
+ content_types: Array(content_types).map(&:to_s).uniq.freeze,
47
+ strict_mime_check: strict_mime_check == true
48
+ )
49
+ end
50
+
51
+ # Extensions pour l'attribut `accept` et les messages : ".jpg,.jpeg,.png"
52
+ def accepted_extensions
53
+ MimeTypes.to_extensions(content_types)
54
+ end
55
+
56
+ # @return [Symbol, nil] :too_large, :invalid_type, :suspicious_type ou
57
+ # :mime_detection_failed ; nil si le blob respecte les limites
58
+ def violation(blob)
59
+ return :too_large if max_size.present? && blob.byte_size > max_size
60
+ return if content_types.empty?
61
+ return :invalid_type unless content_types.include?(blob.content_type)
62
+
63
+ detected_type_violation(blob) if strict_mime_check
64
+ end
65
+
66
+ def error_message(violation)
67
+ case violation
68
+ when :too_large
69
+ I18n.t("upload_d_n_d.errors.too_large", max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size))
70
+ when :invalid_type
71
+ I18n.t("upload_d_n_d.errors.invalid_type", types: accepted_extensions)
72
+ else
73
+ I18n.t("upload_d_n_d.errors.#{violation}")
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ # Anti-spoofing : type réel lu dans le contenu. Le détail d'un échec reste
80
+ # dans les logs, jamais dans la réponse.
81
+ def detected_type_violation(blob)
82
+ detected = Marcel::MimeType.for(blob.download, name: blob.filename.to_s)
83
+ return if detected.blank?
84
+ return :invalid_type unless content_types.include?(detected)
85
+
86
+ :suspicious_type unless detected == blob.content_type
87
+ rescue StandardError => e
88
+ Rails.logger.warn "UploadDND: MIME detection failed - #{e.class}: #{e.message}"
89
+ :mime_detection_failed
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UploadDND
4
+ # Référence signée d'un champ (modèle + attribut), émise par le composant et
5
+ # renvoyée au dépôt : le serveur retrouve les limites du champ sans faire
6
+ # confiance au client. Elle ne porte pas les limites elles-mêmes : le modèle
7
+ # reste la seule autorité.
8
+ module FieldReference
9
+ PURPOSE = :upload_d_n_d_field
10
+
11
+ class Invalid < UploadDND::Error; end
12
+
13
+ class << self
14
+ def generate(model_class, attribute_name)
15
+ verifier.generate([ model_class.name, attribute_name.to_s ], purpose: PURPOSE)
16
+ end
17
+
18
+ # @raise [Invalid] signature fausse, autre attribut, modèle ou attachment inconnu
19
+ # @return [UploadDND::FieldConstraints]
20
+ def constraints(token, attribute_name)
21
+ model_name, attribute = verified(token)
22
+ raise Invalid unless attribute.present? && attribute == attribute_name.to_s
23
+
24
+ model_class = model_name.to_s.safe_constantize
25
+ raise Invalid unless model_class.respond_to?(:reflect_on_attachment) && model_class.reflect_on_attachment(attribute)
26
+
27
+ FieldConstraints.for(model_class, attribute)
28
+ end
29
+
30
+ private
31
+
32
+ def verified(token)
33
+ payload = verifier.verified(token.to_s, purpose: PURPOSE)
34
+ payload.is_a?(Array) && payload.size == 2 ? payload : raise(Invalid)
35
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError
36
+ raise Invalid
37
+ end
38
+
39
+ def verifier
40
+ Rails.application.message_verifier("upload_d_n_d/field_reference")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Validateur de fichiers pour ActiveStorage.
4
+ #
5
+ # Rails associe `validates :avatar, file: {...}` à une constante `FileValidator`.
6
+ # Pour garder l'API ergonomique, on expose `UploadDND::FileValidator` ET on
7
+ # fournit un alias top-level `::FileValidator` si absent.
8
+ module UploadDND
9
+ class FileValidator < ActiveModel::EachValidator
10
+ def validate_each(record, attribute, value)
11
+ return unless value.attached?
12
+
13
+ Array(value).each do |file|
14
+ validate_content_type(record, attribute, file)
15
+ validate_size(record, attribute, file)
16
+ validate_mime_type(record, attribute, file) if options[:strict_mime_check]
17
+ end
18
+
19
+ validate_file_count(record, attribute, value) if value.respond_to?(:count)
20
+ end
21
+
22
+ private
23
+
24
+ def validate_content_type(record, attribute, file)
25
+ return unless options[:content_type].present?
26
+
27
+ unless file.content_type.in?(Array(options[:content_type]))
28
+ record.errors.add(
29
+ attribute,
30
+ :content_type,
31
+ message: options[:message] || "n'est pas un type de fichier valide"
32
+ )
33
+ end
34
+ end
35
+
36
+ def validate_size(record, attribute, file)
37
+ return unless options[:max_size].present?
38
+
39
+ if file.byte_size > options[:max_size]
40
+ max_size_in_mb = options[:max_size] / 1.megabyte
41
+ record.errors.add(
42
+ attribute,
43
+ :max_size,
44
+ message: options[:message] || "doit être inférieur à #{max_size_in_mb}MB"
45
+ )
46
+ end
47
+ end
48
+
49
+ def validate_file_count(record, attribute, value)
50
+ return unless options[:max_files].present?
51
+
52
+ if value.count > options[:max_files]
53
+ record.errors.add(
54
+ attribute,
55
+ :max_files,
56
+ message: options[:message] || "ne peut pas avoir plus de #{options[:max_files]} fichiers"
57
+ )
58
+ end
59
+ end
60
+
61
+ # Détecte le vrai type MIME en analysant le contenu du fichier (anti-spoofing).
62
+ def validate_mime_type(record, attribute, file)
63
+ return unless options[:content_type].present?
64
+
65
+ detected_content_type = detect_content_type(record, attribute, file)
66
+ declared_content_type = file.content_type
67
+
68
+ unless detected_content_type.in?(Array(options[:content_type]))
69
+ record.errors.add(
70
+ attribute,
71
+ :spoofed_content_type,
72
+ message: options[:spoofing_message] ||
73
+ "Le type de fichier détecté (#{detected_content_type}) ne correspond pas aux types autorisés"
74
+ )
75
+ end
76
+
77
+ if detected_content_type != declared_content_type
78
+ record.errors.add(
79
+ attribute,
80
+ :mime_type_mismatch,
81
+ message: options[:spoofing_message] ||
82
+ "Le type de fichier détecté (#{detected_content_type}) " \
83
+ "ne correspond pas au type déclaré (#{declared_content_type})"
84
+ )
85
+ end
86
+ rescue StandardError => e
87
+ # Le détail reste dans les logs : le message d'erreur est affiché.
88
+ Rails.logger.warn "UploadDND: MIME detection failed - #{e.class}: #{e.message}"
89
+ record.errors.add(
90
+ attribute,
91
+ :mime_type_detection_failed,
92
+ message: I18n.t("upload_d_n_d.errors.mime_detection_failed")
93
+ )
94
+ end
95
+
96
+ # Marcel a besoin des octets du fichier. Avec la zone de dépôt, le blob est
97
+ # déjà sur le stockage et `file.download` suffit. Avec un champ fichier
98
+ # classique, le fichier part avec le formulaire et n'est envoyé au stockage
99
+ # qu'après le `save` : à la validation, `file.download` lèverait
100
+ # `ActiveStorage::FileNotFoundError`. On lit alors la source encore en
101
+ # attente (`attachment_changes`), sans la consommer pour l'envoi au stockage.
102
+ def detect_content_type(record, attribute, file)
103
+ io = pending_upload_io(record, attribute, file)
104
+ return Marcel::MimeType.for(file.download, name: file.filename.to_s) unless io
105
+
106
+ io.rewind if io.respond_to?(:rewind)
107
+ Marcel::MimeType.for(io, name: file.filename.to_s)
108
+ ensure
109
+ io.rewind if io.respond_to?(:rewind)
110
+ end
111
+
112
+ # IO de la source non encore stockée pour ce fichier, ou nil si le blob est
113
+ # déjà sur le stockage (zone de dépôt, signed_id, blob existant).
114
+ def pending_upload_io(record, attribute, file)
115
+ return unless record.respond_to?(:attachment_changes)
116
+
117
+ change = record.attachment_changes[attribute.to_s]
118
+ return unless change
119
+
120
+ blob = file.respond_to?(:blob) ? file.blob : file
121
+ attachable = pending_attachables_by_key(change)[blob&.key]
122
+ attachable && readable_io(attachable)
123
+ end
124
+
125
+ # { blob.key => attachable } des sources non encore stockées. La `key` est
126
+ # fiable même avant le save (l'id du blob est encore nil, donc pas de
127
+ # comparaison par id). has_one : CreateOne (blob + attachable). has_many :
128
+ # CreateMany (blobs + attachables, même ordre). Une suppression ne crée rien.
129
+ def pending_attachables_by_key(change)
130
+ if change.respond_to?(:blobs) && change.respond_to?(:attachables)
131
+ change.blobs.map(&:key).zip(change.attachables).to_h
132
+ elsif change.respond_to?(:blob) && change.respond_to?(:attachable)
133
+ { change.blob&.key => change.attachable }
134
+ else
135
+ {}
136
+ end
137
+ end
138
+
139
+ # Extrait un IO des sources d'upload gérées. Les autres (signed_id, blob
140
+ # déjà stocké) renvoient nil pour retomber sur `file.download`. Duck typing
141
+ # volontaire : `Rack::Test::UploadedFile` n'est pas chargé en production, on
142
+ # ne référence donc pas la constante.
143
+ def readable_io(attachable)
144
+ if attachable.respond_to?(:tempfile) # UploadedFile (ActionDispatch / Rack::Test)
145
+ attachable.tempfile
146
+ elsif attachable.is_a?(Hash)
147
+ attachable[:io]
148
+ elsif attachable.respond_to?(:read) # IO, File, StringIO...
149
+ attachable
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ # Alias top-level pour l'API `validates ..., file: ...`
156
+ ::FileValidator = UploadDND::FileValidator unless defined?(::FileValidator)
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UploadDND
4
+ module MimeTypes
5
+ EXTENSION_TO_MIME = {
6
+ jpg: "image/jpeg",
7
+ jpeg: "image/jpeg",
8
+ png: "image/png",
9
+ gif: "image/gif",
10
+ webp: "image/webp",
11
+ svg: "image/svg+xml",
12
+ pdf: "application/pdf",
13
+ doc: "application/msword",
14
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
15
+ xls: "application/vnd.ms-excel",
16
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
17
+ ppt: "application/vnd.ms-powerpoint",
18
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
19
+ odt: "application/vnd.oasis.opendocument.text",
20
+ txt: "text/plain",
21
+ csv: "text/csv",
22
+ zip: "application/zip"
23
+ }.freeze
24
+
25
+ MIME_TO_EXTENSION = EXTENSION_TO_MIME.each_with_object({}) do |(ext, mime), hash|
26
+ hash[mime] ||= ".#{ext}"
27
+ end.freeze
28
+
29
+ # Toutes les extensions d'un type : image/jpeg -> [".jpg", ".jpeg"]
30
+ MIME_TO_EXTENSIONS = EXTENSION_TO_MIME.each_with_object({}) do |(ext, mime), hash|
31
+ (hash[mime] ||= []) << ".#{ext}"
32
+ end.transform_values(&:freeze).freeze
33
+
34
+ # Converts an array of symbols (extensions) and/or MIME strings into MIME strings.
35
+ #
36
+ # @param types [Array<Symbol, String>] e.g. [:jpg, :png, "application/pdf"]
37
+ # @return [Array<String>] e.g. ["image/jpeg", "image/png", "application/pdf"]
38
+ def self.resolve(types)
39
+ Array(types).map do |type|
40
+ case type
41
+ when Symbol
42
+ EXTENSION_TO_MIME.fetch(type) { raise ArgumentError, "Unknown extension: #{type.inspect}" }
43
+ else
44
+ type.to_s
45
+ end
46
+ end
47
+ end
48
+
49
+ # Converts an array of MIME strings into a comma-separated extensions string
50
+ # suitable for the HTML `accept` attribute. Every known extension of a type
51
+ # is listed: the browser checks the extension, a ".jpeg" file must pass.
52
+ #
53
+ # @param mime_types [Array<String>] e.g. ["image/jpeg", "image/png"]
54
+ # @return [String] e.g. ".jpg,.jpeg,.png"
55
+ def self.to_extensions(mime_types)
56
+ Array(mime_types).flat_map { |mime| MIME_TO_EXTENSIONS.fetch(mime) { [ mime ] } }.uniq.join(",")
57
+ end
58
+
59
+ # Converts the global `accepted_types` setting (extensions and/or MIME
60
+ # types, comma-separated) into MIME strings.
61
+ #
62
+ # @param value [String, Array<String>] e.g. ".pdf,.png" or "image/png"
63
+ # @return [Array<String>] e.g. ["application/pdf", "image/png"]
64
+ def self.from_accepted_types(value)
65
+ types = Array(value).flat_map { |item| item.to_s.split(",") }.map(&:strip).reject(&:blank?)
66
+
67
+ types.filter_map do |type|
68
+ next type if type.include?("/")
69
+
70
+ ext = type.start_with?(".") ? type : ".#{type}"
71
+ Marcel::MimeType.for(name: "file#{ext}")
72
+ end.uniq
73
+ end
74
+ end
75
+ end