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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +229 -0
- data/Rakefile +8 -0
- data/app/assets/config/upload_d_n_d_manifest.js +3 -0
- data/app/assets/stylesheets/upload_d_n_d/application.css +604 -0
- data/app/components/upload_d_n_d/upload_component.html.erb +53 -0
- data/app/components/upload_d_n_d/upload_component.rb +204 -0
- data/app/controllers/concerns/upload_d_n_d/rate_limiting.rb +89 -0
- data/app/controllers/upload_d_n_d/attachments_controller.rb +218 -0
- data/app/controllers/upload_d_n_d/base_attachments_controller.rb +250 -0
- data/app/controllers/upload_d_n_d/temporary_attachments_controller.rb +200 -0
- data/app/helpers/upload_d_n_d/application_helper.rb +51 -0
- data/app/helpers/upload_d_n_d/controllers/helpers.rb +21 -0
- data/app/javascript/upload_d_n_d/controllers/preview_controller.js +217 -0
- data/app/javascript/upload_d_n_d/controllers/uploads_controller.js +396 -0
- data/app/javascript/upload_d_n_d/index.js +13 -0
- data/app/jobs/upload_d_n_d/application_job.rb +4 -0
- data/app/jobs/upload_d_n_d/cleanup_temporary_attachments_job.rb +17 -0
- data/app/jobs/upload_d_n_d/purge_orphan_blobs_job.rb +25 -0
- data/app/jobs/upload_d_n_d/purge_rejected_blob_job.rb +24 -0
- data/app/models/upload_d_n_d/application_record.rb +5 -0
- data/app/models/upload_d_n_d/temporary_attachment.rb +129 -0
- data/app/views/upload_d_n_d/shared/_file_icon.html.erb +9 -0
- data/app/views/upload_d_n_d/shared/_files_list.html.erb +63 -0
- data/app/views/upload_d_n_d/shared/_temp_files_list.html.erb +53 -0
- data/config/importmap.rb +5 -0
- data/config/initializers/inflections.rb +11 -0
- data/config/locales/upload_d_n_d.en.yml +80 -0
- data/config/locales/upload_d_n_d.fr.yml +80 -0
- data/config/routes.rb +12 -0
- data/docs/CHANGELOG.md +282 -0
- data/lib/generators/upload_d_n_d/POLICY_README +49 -0
- data/lib/generators/upload_d_n_d/USAGE +80 -0
- data/lib/generators/upload_d_n_d/install_generator.rb +174 -0
- data/lib/generators/upload_d_n_d/mime_type_helper.rb +106 -0
- data/lib/generators/upload_d_n_d/model_generator.rb +313 -0
- data/lib/generators/upload_d_n_d/policy_generator.rb +25 -0
- data/lib/generators/upload_d_n_d/templates/POST_INSTALL +62 -0
- data/lib/generators/upload_d_n_d/templates/concerns/controllers_uploadable.rb +115 -0
- data/lib/generators/upload_d_n_d/templates/concerns/upload_authorization.rb +91 -0
- data/lib/generators/upload_d_n_d/templates/concerns/uploadable.rb +101 -0
- data/lib/generators/upload_d_n_d/templates/controller.rb.tt +60 -0
- data/lib/generators/upload_d_n_d/templates/helpers/upload_helper.rb +105 -0
- data/lib/generators/upload_d_n_d/templates/initializers/upload_d_n_d.rb +69 -0
- data/lib/generators/upload_d_n_d/templates/migrations/create_upload_d_n_d_temporary_attachments.rb +18 -0
- data/lib/generators/upload_d_n_d/templates/policies/upload_d_n_d_attachment_policy.rb +37 -0
- data/lib/generators/upload_d_n_d/templates/policies/upload_d_n_d_temporary_attachment_policy.rb +56 -0
- data/lib/generators/upload_d_n_d/templates/validators/file_validator.rb +171 -0
- data/lib/tasks/upload_d_n_d_storage.rake +27 -0
- data/lib/tasks/upload_d_n_d_tasks.rake +20 -0
- data/lib/upload_d_n_d/attachment_lookup.rb +24 -0
- data/lib/upload_d_n_d/authorization/can_can_can_adapter.rb +29 -0
- data/lib/upload_d_n_d/authorization/no_authorization.rb +13 -0
- data/lib/upload_d_n_d/authorization/pundit_adapter.rb +41 -0
- data/lib/upload_d_n_d/authorization/unconfigured.rb +15 -0
- data/lib/upload_d_n_d/authorization.rb +66 -0
- data/lib/upload_d_n_d/configuration.rb +97 -0
- data/lib/upload_d_n_d/controllers/uploadable.rb +271 -0
- data/lib/upload_d_n_d/engine.rb +50 -0
- data/lib/upload_d_n_d/field_constraints.rb +92 -0
- data/lib/upload_d_n_d/field_reference.rb +44 -0
- data/lib/upload_d_n_d/file_validator.rb +156 -0
- data/lib/upload_d_n_d/mime_types.rb +75 -0
- data/lib/upload_d_n_d/orphan_blobs.rb +151 -0
- data/lib/upload_d_n_d/rejected_blob.rb +42 -0
- data/lib/upload_d_n_d/session_id.rb +25 -0
- data/lib/upload_d_n_d/uploadable.rb +117 -0
- data/lib/upload_d_n_d/version.rb +3 -0
- data/lib/upload_d_n_d.rb +62 -0
- 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
|