upload_d_n_d 0.9.2
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 +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 +292 -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 +90 -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,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
# Contrôleur de base contenant toute la logique commune aux attachments
|
|
5
|
+
# permanents et temporaires.
|
|
6
|
+
#
|
|
7
|
+
# Responsabilités:
|
|
8
|
+
# - Validation des paramètres (signed_blob_id, attribute_name)
|
|
9
|
+
# - Authentification et autorisation (Devise, UploadDND::Authorization)
|
|
10
|
+
# - Gestion des erreurs et logging
|
|
11
|
+
# - Helpers de rendu (Turbo Streams, JSON, HTML)
|
|
12
|
+
# - Sécurité (CSRF, validation de signature, sanitisation)
|
|
13
|
+
# - Rate limiting (contrôle du taux de requêtes)
|
|
14
|
+
class BaseAttachmentsController < ActionController::Base
|
|
15
|
+
# Inclure Pundit si disponible
|
|
16
|
+
include Pundit::Authorization if defined?(Pundit)
|
|
17
|
+
|
|
18
|
+
# Inclure le rate limiting
|
|
19
|
+
include UploadDND::RateLimiting
|
|
20
|
+
|
|
21
|
+
# Nom d'attribut autorisé (alphanumérique + underscore uniquement)
|
|
22
|
+
ATTRIBUTE_NAME_FORMAT = /\A[a-z_][a-z0-9_]*\z/
|
|
23
|
+
|
|
24
|
+
# Protection CSRF (désactivée uniquement en test)
|
|
25
|
+
skip_before_action :verify_authenticity_token, if: -> { Rails.env.test? }
|
|
26
|
+
|
|
27
|
+
# Authentification (si disponible dans l'app hôte)
|
|
28
|
+
before_action :authenticate_user_if_available!
|
|
29
|
+
|
|
30
|
+
# Gestion des erreurs
|
|
31
|
+
# IMPORTANT: rescue_from utilise le "dernier match" -> on déclare StandardError en premier
|
|
32
|
+
# pour laisser les handlers spécifiques (RecordNotFound, etc.) prendre le dessus.
|
|
33
|
+
rescue_from StandardError, with: :handle_error
|
|
34
|
+
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
|
|
35
|
+
rescue_from ActiveStorage::IntegrityError, with: :blob_integrity_error
|
|
36
|
+
rescue_from ActionController::BadRequest, with: :bad_request_error
|
|
37
|
+
|
|
38
|
+
before_action :validate_params, only: [ :create ]
|
|
39
|
+
before_action :validate_signed_blob_id_format, only: [ :create ]
|
|
40
|
+
before_action :find_blob, only: [ :create ]
|
|
41
|
+
|
|
42
|
+
# Déclaré après find_blob (@blob connu) et avant les before_action des
|
|
43
|
+
# sous-classes (autorisation, contraintes), qu'il englobe.
|
|
44
|
+
around_action :discard_blob_on_rejection, only: [ :create ]
|
|
45
|
+
|
|
46
|
+
protected
|
|
47
|
+
|
|
48
|
+
# === Purge des blobs refusés ===
|
|
49
|
+
|
|
50
|
+
# Avec DirectUpload, le blob est déjà dans le bucket : s'il est refusé
|
|
51
|
+
# (réponse en erreur ou exception, relancée), on le purge — sous les
|
|
52
|
+
# garde-fous de UploadDND::RejectedBlob.
|
|
53
|
+
def discard_blob_on_rejection
|
|
54
|
+
yield
|
|
55
|
+
rescue StandardError
|
|
56
|
+
discard_rejected_blob
|
|
57
|
+
raise
|
|
58
|
+
else
|
|
59
|
+
discard_rejected_blob if response.status >= 400
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Ne purge que si l'autorisation a été posée comme réussie
|
|
63
|
+
# (grant_upload_authorization!) : une requête refusée faute de droits, ou
|
|
64
|
+
# dont l'autorisation n'a pas abouti (exception, adaptateur inconnu), ne
|
|
65
|
+
# modifie rien. Un échec de la purge ne masque pas l'erreur d'origine : il
|
|
66
|
+
# est journalisé, le blob restera au nettoyage.
|
|
67
|
+
def discard_rejected_blob
|
|
68
|
+
return unless upload_authorized?
|
|
69
|
+
|
|
70
|
+
UploadDND::RejectedBlob.discard(@blob)
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
Rails.logger.error "UploadDND: rejected blob purge failed - #{e.class}: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# À appeler par chaque contrôle d'autorisation, une fois réussi.
|
|
76
|
+
def grant_upload_authorization!
|
|
77
|
+
@upload_authorized = true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def revoke_upload_authorization!
|
|
81
|
+
@upload_authorized = false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def upload_authorized?
|
|
85
|
+
@upload_authorized == true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# === Callbacks de validation ===
|
|
89
|
+
|
|
90
|
+
# SÉCURITÉ : le signed_id vient du navigateur et figure dans chaque URL de
|
|
91
|
+
# fichier. Un blob déjà rattaché (record ou fichier temporaire) n'est pas
|
|
92
|
+
# rattaché une seconde fois : il serait partagé avec un autre record.
|
|
93
|
+
# À déclarer dans les sous-classes après l'autorisation, avant toute écriture.
|
|
94
|
+
def reject_attached_blob
|
|
95
|
+
return unless @blob&.attachments&.exists?
|
|
96
|
+
|
|
97
|
+
Rails.logger.warn "UploadDND: already attached blob #{@blob.id} refused"
|
|
98
|
+
render_error(I18n.t("upload_d_n_d.errors.blob_already_attached"), :unprocessable_entity)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
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
|
|
106
|
+
|
|
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
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def find_blob
|
|
115
|
+
@blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id])
|
|
116
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
117
|
+
render_error(I18n.t("upload_d_n_d.errors.invalid_blob_signature"), :unauthorized)
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# === Helpers de rendu ===
|
|
122
|
+
|
|
123
|
+
def render_error(message, status = :unprocessable_entity)
|
|
124
|
+
render json: { error: message }, status: status
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def render_turbo_stream_replace(target, partial:, locals:)
|
|
128
|
+
html = render_to_string(partial: partial, formats: [ :html ], locals: locals)
|
|
129
|
+
turbo_stream_markup = %(<turbo-stream action="replace" target="#{ERB::Util.html_escape(target)}"><template>#{html}</template></turbo-stream>)
|
|
130
|
+
|
|
131
|
+
render body: turbo_stream_markup, content_type: "text/vnd.turbo-stream.html"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# === Gestion des erreurs ===
|
|
135
|
+
|
|
136
|
+
def record_not_found
|
|
137
|
+
render_error(I18n.t("upload_d_n_d.errors.record_not_found"), :not_found)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def blob_integrity_error
|
|
141
|
+
render_error(I18n.t("upload_d_n_d.errors.corrupted"), :unprocessable_entity)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def handle_error(exception)
|
|
145
|
+
Rails.logger.error "UploadDND Error: #{exception.class} - #{exception.message}"
|
|
146
|
+
Rails.logger.error exception.backtrace.join("\n")
|
|
147
|
+
|
|
148
|
+
render_error(I18n.t("upload_d_n_d.errors.upload_error"), :internal_server_error)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Le détail de l'exception reste dans les logs, jamais dans la réponse.
|
|
152
|
+
def bad_request_error(exception)
|
|
153
|
+
Rails.logger.warn "UploadDND BadRequest: #{exception.message}"
|
|
154
|
+
render_error(I18n.t("upload_d_n_d.errors.bad_request"), :bad_request)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# === Logging ===
|
|
158
|
+
|
|
159
|
+
def log_upload_success
|
|
160
|
+
return unless UploadDND.configuration.respond_to?(:on_upload_success)
|
|
161
|
+
|
|
162
|
+
UploadDND.configuration.on_upload_success&.call(@blob, current_user)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# === Helpers d'authentification ===
|
|
166
|
+
|
|
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
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def authenticate_user_if_available!
|
|
180
|
+
# Si l'app hôte a Devise, on l'utilise
|
|
181
|
+
if defined?(Devise) && respond_to?(:authenticate_user!, true)
|
|
182
|
+
authenticate_user!
|
|
183
|
+
# Sinon, on vérifie qu'un current_user existe si config l'exige
|
|
184
|
+
elsif UploadDND.configuration.require_authentication
|
|
185
|
+
unless current_user.present?
|
|
186
|
+
render_error(I18n.t("upload_d_n_d.errors.authentication_required"), :unauthorized)
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# === Helpers d'autorisation ===
|
|
193
|
+
|
|
194
|
+
# Délègue au port d'autorisation (UploadDND::Authorization) : lève
|
|
195
|
+
# Denied, RuleMissing ou ConfigurationError. Fail-closed sans adaptateur.
|
|
196
|
+
def authorize_with_adapter!(subject, action)
|
|
197
|
+
UploadDND::Authorization.authorize!(self, subject, action)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def render_forbidden
|
|
201
|
+
render_error(I18n.t("upload_d_n_d.errors.unauthorized"), :forbidden)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# === Helpers de session ===
|
|
205
|
+
|
|
206
|
+
def current_session_id
|
|
207
|
+
UploadDND::SessionId.for(session)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# === Validation de sécurité ===
|
|
211
|
+
|
|
212
|
+
def validate_signed_blob_id_format
|
|
213
|
+
signed_blob_id = params[:signed_blob_id]
|
|
214
|
+
|
|
215
|
+
return if signed_blob_id.blank?
|
|
216
|
+
|
|
217
|
+
# Vérifier le format du signed_blob_id
|
|
218
|
+
unless signed_blob_id.is_a?(String) &&
|
|
219
|
+
signed_blob_id.length > 20 &&
|
|
220
|
+
signed_blob_id.match?(/\A[A-Za-z0-9\-_+=]+\z/)
|
|
221
|
+
Rails.logger.warn "UploadDND: Invalid signed_blob_id format attempted"
|
|
222
|
+
render_error(I18n.t("upload_d_n_d.errors.invalid_file_token"), :unauthorized)
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def sanitize_filename(filename)
|
|
228
|
+
# Supprime les caractères dangereux du nom de fichier
|
|
229
|
+
filename = filename.to_s.strip
|
|
230
|
+
|
|
231
|
+
# Remplace les caractères dangereux
|
|
232
|
+
filename = filename.gsub(/[^\w\.\-]/, "_")
|
|
233
|
+
|
|
234
|
+
# Limite la longueur
|
|
235
|
+
filename = filename.truncate(255, omission: "")
|
|
236
|
+
|
|
237
|
+
# Assure qu'il reste une extension
|
|
238
|
+
filename = "file#{File.extname(filename)}" if filename.blank?
|
|
239
|
+
|
|
240
|
+
filename
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# === Support Turbo Streams ===
|
|
244
|
+
|
|
245
|
+
def turbo_stream_request?
|
|
246
|
+
params[:format].to_s == "turbo_stream" ||
|
|
247
|
+
request.headers["Accept"].to_s.include?("text/vnd.turbo-stream.html")
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
# Contrôleur dédié à la gestion des attachments temporaires.
|
|
5
|
+
#
|
|
6
|
+
# Responsabilités:
|
|
7
|
+
# - Créer des TemporaryAttachment pour les formulaires qui n'ont pas encore de record
|
|
8
|
+
# - Supprimer des fichiers temporaires (avant soumission du formulaire)
|
|
9
|
+
# - Renvoyer la liste des fichiers temporaires pour mise à jour UI
|
|
10
|
+
#
|
|
11
|
+
# Utilisé par:
|
|
12
|
+
# - GET /upload_d_n_d/temporary_attachments?attribute_name=x (liste de la session courante)
|
|
13
|
+
# - POST /upload_d_n_d/temporary_attachments (lors de l'upload)
|
|
14
|
+
# - DELETE /upload_d_n_d/temporary_attachments/:id (suppression avant soumission)
|
|
15
|
+
# - DELETE /upload_d_n_d/temporary_attachments?attribute_name=x (purge, ex: annulation du formulaire)
|
|
16
|
+
#
|
|
17
|
+
# Workflow:
|
|
18
|
+
# 1. User upload file in form (no record yet) → create TemporaryAttachment
|
|
19
|
+
# 2. User submit form → UsersController calls attach_upload_d_n_d_files helper
|
|
20
|
+
# 3. Helper transfers temp files to real record and destroys TemporaryAttachment
|
|
21
|
+
class TemporaryAttachmentsController < BaseAttachmentsController
|
|
22
|
+
# Autorisation (UploadDND::Authorization, fail-closed)
|
|
23
|
+
before_action :authorize_upload!, only: [ :create ]
|
|
24
|
+
before_action :authorize_delete!, only: [ :destroy, :purge ]
|
|
25
|
+
|
|
26
|
+
before_action :find_temp_attachment, only: [ :destroy ]
|
|
27
|
+
before_action :reject_attached_blob, only: [ :create ]
|
|
28
|
+
before_action :validate_blob_constraints, only: [ :create ]
|
|
29
|
+
|
|
30
|
+
# GET /upload_d_n_d/temporary_attachments?attribute_name=documents
|
|
31
|
+
#
|
|
32
|
+
# Renvoie le HTML du partial `temp_files_list` pour les fichiers temporaires
|
|
33
|
+
# de la session courante (ex: restaurer la zone après un rechargement de page
|
|
34
|
+
# sans repasser par le composant).
|
|
35
|
+
#
|
|
36
|
+
# Sécurité: pas d'autorisation Pundit — le scope par session_id est le filtre.
|
|
37
|
+
# Un utilisateur ne peut voir que ses propres fichiers temporaires.
|
|
38
|
+
def index
|
|
39
|
+
attribute_name = params[:attribute_name].to_s
|
|
40
|
+
return render_error(I18n.t("upload_d_n_d.errors.missing_params"), :bad_request) if attribute_name.blank?
|
|
41
|
+
return render_error(I18n.t("upload_d_n_d.errors.invalid_attribute_name"), :bad_request) unless attribute_name.match?(ATTRIBUTE_NAME_FORMAT)
|
|
42
|
+
|
|
43
|
+
files = TemporaryAttachment.for_session(current_session_id)
|
|
44
|
+
.for_attribute(attribute_name)
|
|
45
|
+
.with_blobs
|
|
46
|
+
|
|
47
|
+
render partial: "upload_d_n_d/shared/temp_files_list",
|
|
48
|
+
formats: [ :html ],
|
|
49
|
+
locals: { files: files, attribute_name: attribute_name },
|
|
50
|
+
layout: false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# POST /upload_d_n_d/temporary_attachments
|
|
54
|
+
#
|
|
55
|
+
# Params:
|
|
56
|
+
# - signed_blob_id: String (ActiveStorage signed blob ID)
|
|
57
|
+
# - attribute_name: String (ex: "avatar", "documents")
|
|
58
|
+
#
|
|
59
|
+
# Returns:
|
|
60
|
+
# - Turbo Stream pour remplacer la liste de fichiers
|
|
61
|
+
# - JSON {status: 'ok'} en mode API
|
|
62
|
+
def create
|
|
63
|
+
attribute_name = params[:attribute_name]
|
|
64
|
+
|
|
65
|
+
# Sanitize filename avant de créer l'attachment
|
|
66
|
+
@blob.update(filename: sanitize_filename(@blob.filename.to_s))
|
|
67
|
+
|
|
68
|
+
# SÉCURITÉ : Pour les attributs au singulier (has_one_attached probable),
|
|
69
|
+
# purger les fichiers temporaires existants avant d'en créer un nouveau
|
|
70
|
+
# Convention Rails : avatar, cover, logo = singulier = has_one
|
|
71
|
+
# documents, images, files = pluriel = has_many
|
|
72
|
+
if attribute_name.to_s == attribute_name.to_s.singularize
|
|
73
|
+
# Attribut au singulier → probablement has_one_attached
|
|
74
|
+
# Purger les fichiers temporaires existants pour cette session + attribut
|
|
75
|
+
TemporaryAttachment.for_session(current_session_id)
|
|
76
|
+
.for_attribute(attribute_name)
|
|
77
|
+
.destroy_all
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
temp_attachment = TemporaryAttachment.create!(
|
|
81
|
+
session_id: current_session_id,
|
|
82
|
+
attribute_name: attribute_name,
|
|
83
|
+
user_id: current_user&.id
|
|
84
|
+
)
|
|
85
|
+
temp_attachment.blob.attach(@blob)
|
|
86
|
+
|
|
87
|
+
render_temp_file_list(attribute_name)
|
|
88
|
+
log_upload_success
|
|
89
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
90
|
+
render_error(I18n.t("upload_d_n_d.errors.save_failed", errors: e.message), :unprocessable_entity)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# DELETE /upload_d_n_d/temporary_attachments/:id
|
|
94
|
+
#
|
|
95
|
+
# Supprime un fichier temporaire avant soumission du formulaire.
|
|
96
|
+
#
|
|
97
|
+
# Sécurité: On ne peut supprimer que les fichiers de sa propre session/user_id.
|
|
98
|
+
def destroy
|
|
99
|
+
attribute_name = @temp_attachment.attribute_name
|
|
100
|
+
@temp_attachment.destroy
|
|
101
|
+
|
|
102
|
+
render_temp_file_list(attribute_name)
|
|
103
|
+
log_deletion
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# DELETE /upload_d_n_d/temporary_attachments?attribute_name=documents
|
|
107
|
+
#
|
|
108
|
+
# Purge tous les fichiers temporaires de la session courante pour cet attribut.
|
|
109
|
+
# Typiquement appelé par le bouton « Annuler » d'un formulaire de création,
|
|
110
|
+
# pour que les fichiers déposés ne réapparaissent pas au prochain formulaire.
|
|
111
|
+
#
|
|
112
|
+
# Sécurité: scopé sur la session courante uniquement. Renvoie 204.
|
|
113
|
+
def purge
|
|
114
|
+
attribute_name = params[:attribute_name].to_s
|
|
115
|
+
return render_error(I18n.t("upload_d_n_d.errors.missing_params"), :bad_request) if attribute_name.blank?
|
|
116
|
+
return render_error(I18n.t("upload_d_n_d.errors.invalid_attribute_name"), :bad_request) unless attribute_name.match?(ATTRIBUTE_NAME_FORMAT)
|
|
117
|
+
|
|
118
|
+
purged = TemporaryAttachment.for_session(current_session_id)
|
|
119
|
+
.for_attribute(attribute_name)
|
|
120
|
+
.destroy_all
|
|
121
|
+
|
|
122
|
+
Rails.logger.info "UploadDND: #{purged.size} temporary file(s) purged - #{attribute_name} by #{current_user&.id || 'anonymous'}"
|
|
123
|
+
head :no_content
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def find_temp_attachment
|
|
129
|
+
# Sécurité: on ne peut supprimer que les fichiers de sa propre session ou utilisateur
|
|
130
|
+
scope = TemporaryAttachment.where(session_id: current_session_id)
|
|
131
|
+
scope = scope.or(TemporaryAttachment.where(user_id: current_user&.id)) if current_user
|
|
132
|
+
|
|
133
|
+
@temp_attachment = scope.find(params[:id])
|
|
134
|
+
rescue ActiveRecord::RecordNotFound
|
|
135
|
+
render_error(I18n.t("upload_d_n_d.errors.file_not_found_or_denied"), :not_found)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def render_temp_file_list(attribute_name)
|
|
139
|
+
files = TemporaryAttachment.for_session(current_session_id)
|
|
140
|
+
.for_attribute(attribute_name)
|
|
141
|
+
.with_blobs
|
|
142
|
+
|
|
143
|
+
if turbo_stream_request?
|
|
144
|
+
render_turbo_stream_replace(
|
|
145
|
+
"udnd_files_list_#{attribute_name}",
|
|
146
|
+
partial: "upload_d_n_d/shared/temp_files_list",
|
|
147
|
+
locals: { files: files, attribute_name: attribute_name }
|
|
148
|
+
)
|
|
149
|
+
else
|
|
150
|
+
respond_to do |format|
|
|
151
|
+
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.any { render json: { status: "ok", message: I18n.t("upload_d_n_d.success.file_uploaded") }, status: :ok }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def log_deletion
|
|
159
|
+
Rails.logger.info "UploadDND: Temporary file deleted - #{@temp_attachment.attribute_name} by #{current_user&.id || 'anonymous'}"
|
|
160
|
+
end
|
|
161
|
+
|
|
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).
|
|
165
|
+
def validate_blob_constraints
|
|
166
|
+
return unless @blob
|
|
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
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# === Autorisation ===
|
|
186
|
+
|
|
187
|
+
def authorize_upload!
|
|
188
|
+
authorize_with_adapter!(:upload_d_n_d_temporary_attachment, :create)
|
|
189
|
+
grant_upload_authorization!
|
|
190
|
+
rescue UploadDND::Authorization::Denied
|
|
191
|
+
render_forbidden
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def authorize_delete!
|
|
195
|
+
authorize_with_adapter!(:upload_d_n_d_temporary_attachment, :destroy)
|
|
196
|
+
rescue UploadDND::Authorization::Denied
|
|
197
|
+
render_forbidden
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
module ApplicationHelper
|
|
5
|
+
# URL de suppression d'un fichier attaché.
|
|
6
|
+
#
|
|
7
|
+
# Mode :engine (défaut) : route DELETE du moteur de la gem, identifiée
|
|
8
|
+
# par record_gid — aucune route requise côté app, quel que soit le
|
|
9
|
+
# routing du record (imbriqué, singulier, namespacé).
|
|
10
|
+
#
|
|
11
|
+
# Mode :host (legacy, config.delete_route = :host) : route membre
|
|
12
|
+
# remove_file du record côté app (convention : resources + member
|
|
13
|
+
# remove_file). Nécessite une route polymorphique — voir README,
|
|
14
|
+
# section "Records imbriqués". Point d'extension : redéfinissez cette
|
|
15
|
+
# méthode dans le ApplicationHelper de votre app.
|
|
16
|
+
def udnd_remove_file_url(record, attachment, attribute_name)
|
|
17
|
+
if UploadDND.configuration.delete_route == :host
|
|
18
|
+
query = { file_id: attachment.id, attribute_name: attribute_name }.to_query
|
|
19
|
+
"#{udnd_record_path(record)}/remove_file?#{query}"
|
|
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
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def udnd_record_path(record)
|
|
30
|
+
main_app.polymorphic_path(record)
|
|
31
|
+
rescue NoMethodError, ActionController::UrlGenerationError
|
|
32
|
+
raise UploadDND::UnroutableRecordError, <<~MSG
|
|
33
|
+
#{record.class.name} n'a pas de route polymorphique : impossible de
|
|
34
|
+
construire l'URL de suppression de fichier.
|
|
35
|
+
|
|
36
|
+
Deux remèdes :
|
|
37
|
+
|
|
38
|
+
1. Déclarer la résolution polymorphique dans config/routes.rb (recommandé).
|
|
39
|
+
Exemple pour une resource singulière imbriquée
|
|
40
|
+
(resource :convention sous resources :projects) :
|
|
41
|
+
|
|
42
|
+
resolve("Convention") { |convention, options| route_for(:project_convention, convention.project, options) }
|
|
43
|
+
|
|
44
|
+
2. Ou surcharger udnd_remove_file_url(record, attachment, attribute_name)
|
|
45
|
+
dans le ApplicationHelper de votre app.
|
|
46
|
+
|
|
47
|
+
Voir la section "Records imbriqués" du README de upload_d_n_d.
|
|
48
|
+
MSG
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# lib/upload_d_n_d/controllers/helpers.rb
|
|
2
|
+
module UploadDND
|
|
3
|
+
module Controllers
|
|
4
|
+
module Helpers
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
def attach_upload_d_n_d_files(record, attribute_name)
|
|
8
|
+
# On cherche les fichiers temporaires liés à cette session
|
|
9
|
+
temps = UploadDND::TemporaryAttachment.where(
|
|
10
|
+
session_id: UploadDND::SessionId.for(session),
|
|
11
|
+
attribute_name: attribute_name
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
temps.each do |temp|
|
|
15
|
+
record.send(attribute_name).attach(temp.blob)
|
|
16
|
+
temp.destroy # On nettoie après attachement
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|