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,204 @@
|
|
|
1
|
+
module UploadDND
|
|
2
|
+
class UploadComponent < ViewComponent::Base
|
|
3
|
+
attr_reader :record, :attribute_name, :multiple, :accepted_types, :max_size, :user, :namespace
|
|
4
|
+
|
|
5
|
+
def initialize(
|
|
6
|
+
attribute_name:,
|
|
7
|
+
record: nil,
|
|
8
|
+
user: nil,
|
|
9
|
+
multiple: nil,
|
|
10
|
+
accepted_types: nil,
|
|
11
|
+
max_size: nil,
|
|
12
|
+
namespace: nil
|
|
13
|
+
)
|
|
14
|
+
@record = record
|
|
15
|
+
@user = user
|
|
16
|
+
@attribute_name = attribute_name
|
|
17
|
+
@namespace = namespace
|
|
18
|
+
|
|
19
|
+
# Récupère la config depuis le modèle si disponible, sinon utilise les params ou la config globale
|
|
20
|
+
model_config = get_uploadable_config
|
|
21
|
+
|
|
22
|
+
# Détection automatique de multiple basée sur has_one vs has_many
|
|
23
|
+
# Peut être surchargé explicitement via le paramètre ou la config du modèle
|
|
24
|
+
@multiple = if multiple.nil?
|
|
25
|
+
model_config[:multiple].nil? ? detect_multiple_from_attachment : model_config[:multiple]
|
|
26
|
+
else
|
|
27
|
+
multiple
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Limites du champ, identiques à celles appliquées par le serveur.
|
|
31
|
+
# accepted_types: et max_size: ne changent que l'affichage : le serveur
|
|
32
|
+
# applique toujours les limites du modèle.
|
|
33
|
+
@constraints = field_constraints
|
|
34
|
+
@accepted_types = accepted_types || @constraints.accepted_extensions
|
|
35
|
+
@max_size = max_size || @constraints.max_size
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Types MIME acceptés, pour le contrôle du navigateur
|
|
39
|
+
def accepted_mime_types
|
|
40
|
+
return @constraints.content_types.join(",") if @accepted_types == @constraints.accepted_extensions
|
|
41
|
+
|
|
42
|
+
UploadDND::MimeTypes.from_accepted_types(@accepted_types).join(",")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Référence signée du champ, renvoyée au dépôt : le serveur en déduit les
|
|
46
|
+
# limites du modèle. nil sans record (limites globales).
|
|
47
|
+
def field_ref
|
|
48
|
+
return unless record && record.class.respond_to?(:reflect_on_attachment)
|
|
49
|
+
return unless record.class.reflect_on_attachment(attribute_name)
|
|
50
|
+
|
|
51
|
+
UploadDND::FieldReference.generate(record.class, attribute_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def model_name
|
|
55
|
+
return attribute_name.to_s.singularize unless record
|
|
56
|
+
|
|
57
|
+
# Convertit "Admin::Article" en "admin_article" (pour les IDs DOM)
|
|
58
|
+
record.class.name.underscore.tr("/", "_")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Génère le GlobalID pour le record (fonctionne avec tous les namespaces)
|
|
62
|
+
def record_gid
|
|
63
|
+
# Un record non persisté n'a pas d'ID => GlobalID impossible (MissingModelIdError).
|
|
64
|
+
return nil unless record&.persisted?
|
|
65
|
+
|
|
66
|
+
record.to_global_id.to_s
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Détermine l'URL d'attachement : soit vers le moteur de la gem, soit vers une route spécifique
|
|
70
|
+
def attach_path
|
|
71
|
+
action_path = record&.persisted? ? :attachments_path : :temporary_attachments_path
|
|
72
|
+
|
|
73
|
+
# Si pas de namespace, on utilise les routes internes de l'engine (plus robuste)
|
|
74
|
+
return UploadDND::Engine.routes.url_helpers.send(action_path) if namespace.blank?
|
|
75
|
+
|
|
76
|
+
# Avec namespace, on utilise polymorphic_path pour supporter les routes de l'app hôte
|
|
77
|
+
begin
|
|
78
|
+
action = record&.persisted? ? :attachments : :temporary_attachments
|
|
79
|
+
helpers.polymorphic_path([ namespace, :upload_d_n_d, action ])
|
|
80
|
+
rescue NoMethodError, ArgumentError, ViewComponent::HelpersCalledBeforeRenderError
|
|
81
|
+
# En cas d'erreur (ex: helpers non dispos avant render), fallback sur l'engine
|
|
82
|
+
UploadDND::Engine.routes.url_helpers.send(action_path)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def files
|
|
87
|
+
if record&.persisted?
|
|
88
|
+
get_attachments
|
|
89
|
+
else
|
|
90
|
+
# Fichiers temporaires de la session courante (formulaire sans record persisté)
|
|
91
|
+
UploadDND::TemporaryAttachment
|
|
92
|
+
.for_session(UploadDND::SessionId.for(helpers.session))
|
|
93
|
+
.for_attribute(attribute_name)
|
|
94
|
+
.with_blobs
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# --- Thème / Couleurs ---
|
|
99
|
+
# Exposé pour permettre au composant d'injecter des variables CSS cohérentes
|
|
100
|
+
def main_color
|
|
101
|
+
UploadDND.configuration.main_color.presence || "#E97451"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Renvoie "r g b" (ex: "233 116 81") pour usage avec `rgb(var(--udnd-accent-rgb) / 0.2)`
|
|
105
|
+
def main_color_rgb
|
|
106
|
+
hex = main_color.to_s.delete_prefix("#")
|
|
107
|
+
hex = hex.chars.map { |c| "#{c}#{c}" }.join if hex.length == 3
|
|
108
|
+
|
|
109
|
+
return "233 116 81" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
110
|
+
|
|
111
|
+
r = hex[0..1].to_i(16)
|
|
112
|
+
g = hex[2..3].to_i(16)
|
|
113
|
+
b = hex[4..5].to_i(16)
|
|
114
|
+
"#{r} #{g} #{b}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def field_constraints
|
|
120
|
+
return UploadDND::FieldConstraints.global unless record
|
|
121
|
+
|
|
122
|
+
UploadDND::FieldConstraints.for(record.class, attribute_name)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def get_uploadable_config
|
|
126
|
+
return {} unless record&.class.respond_to?(:uploadable_attributes)
|
|
127
|
+
|
|
128
|
+
record.class.uploadable_attributes[attribute_name.to_sym] || {}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Détecte automatiquement si l'attachment est has_one ou has_many
|
|
132
|
+
def detect_multiple_from_attachment
|
|
133
|
+
return false unless record&.respond_to?(attribute_name)
|
|
134
|
+
|
|
135
|
+
attachment = record.send(attribute_name)
|
|
136
|
+
|
|
137
|
+
# has_many_attached retourne une collection qui respond_to :attach
|
|
138
|
+
# has_one_attached retourne un objet qui ne respond_to pas :each
|
|
139
|
+
attachment.is_a?(ActiveStorage::Attached::Many)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def get_attachments
|
|
143
|
+
return [] unless record&.respond_to?(attribute_name)
|
|
144
|
+
|
|
145
|
+
attachment = record.public_send(attribute_name)
|
|
146
|
+
case attachment
|
|
147
|
+
when ActiveStorage::Attached::Many
|
|
148
|
+
attachment.blobs
|
|
149
|
+
when ActiveStorage::Attached::One
|
|
150
|
+
attachment.attached? ? [ attachment.blob ] : []
|
|
151
|
+
else
|
|
152
|
+
[]
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Retourne les messages i18n pour JavaScript
|
|
157
|
+
def i18n_messages
|
|
158
|
+
{
|
|
159
|
+
alerts: {
|
|
160
|
+
singleFileOnly: {
|
|
161
|
+
title: I18n.t("upload_d_n_d.javascript.alerts.single_file_only.title"),
|
|
162
|
+
text: I18n.t("upload_d_n_d.javascript.alerts.single_file_only.text")
|
|
163
|
+
},
|
|
164
|
+
uploadError: {
|
|
165
|
+
title: I18n.t("upload_d_n_d.javascript.alerts.upload_error.title"),
|
|
166
|
+
text: I18n.t("upload_d_n_d.javascript.alerts.upload_error.text")
|
|
167
|
+
},
|
|
168
|
+
fileTooLarge: {
|
|
169
|
+
title: I18n.t("upload_d_n_d.javascript.alerts.file_too_large.title"),
|
|
170
|
+
text: I18n.t("upload_d_n_d.javascript.alerts.file_too_large.text")
|
|
171
|
+
},
|
|
172
|
+
emptyFile: {
|
|
173
|
+
title: I18n.t("upload_d_n_d.javascript.alerts.empty_file.title"),
|
|
174
|
+
text: I18n.t("upload_d_n_d.javascript.alerts.empty_file.text")
|
|
175
|
+
},
|
|
176
|
+
invalidFormat: {
|
|
177
|
+
title: I18n.t("upload_d_n_d.javascript.alerts.invalid_format.title"),
|
|
178
|
+
text: I18n.t("upload_d_n_d.javascript.alerts.invalid_format.text")
|
|
179
|
+
},
|
|
180
|
+
suspiciousType: {
|
|
181
|
+
title: I18n.t("upload_d_n_d.javascript.alerts.suspicious_type.title"),
|
|
182
|
+
text: I18n.t("upload_d_n_d.javascript.alerts.suspicious_type.text")
|
|
183
|
+
},
|
|
184
|
+
attachError: {
|
|
185
|
+
title: I18n.t("upload_d_n_d.javascript.alerts.attach_error.title"),
|
|
186
|
+
text: I18n.t("upload_d_n_d.javascript.alerts.attach_error.text")
|
|
187
|
+
},
|
|
188
|
+
deleteError: {
|
|
189
|
+
title: I18n.t("upload_d_n_d.javascript.alerts.delete_error.title"),
|
|
190
|
+
text: I18n.t("upload_d_n_d.javascript.alerts.delete_error.text")
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
confirms: {
|
|
194
|
+
deleteFile: {
|
|
195
|
+
title: I18n.t("upload_d_n_d.javascript.confirms.delete_file.title"),
|
|
196
|
+
text: I18n.t("upload_d_n_d.javascript.confirms.delete_file.text"),
|
|
197
|
+
confirmButton: I18n.t("upload_d_n_d.javascript.confirms.delete_file.confirm_button"),
|
|
198
|
+
cancelButton: I18n.t("upload_d_n_d.javascript.confirms.delete_file.cancel_button")
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
module RateLimiting
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
before_action :check_rate_limit, if: :rate_limiting_enabled?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def rate_limiting_enabled?
|
|
14
|
+
UploadDND.configuration.rate_limit_enabled
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def check_rate_limit
|
|
18
|
+
# Clé basée sur l'IP et l'utilisateur si disponible
|
|
19
|
+
key = rate_limit_key
|
|
20
|
+
|
|
21
|
+
# Récupère le compteur actuel pour cette clé
|
|
22
|
+
count = increment_rate_limit_counter(key)
|
|
23
|
+
|
|
24
|
+
max_requests = UploadDND.configuration.rate_limit_max_requests
|
|
25
|
+
period = UploadDND.configuration.rate_limit_period
|
|
26
|
+
|
|
27
|
+
if count > max_requests
|
|
28
|
+
Rails.logger.warn "UploadDND RateLimit: #{key} exceeded limit (#{count}/#{max_requests})"
|
|
29
|
+
|
|
30
|
+
# Calcule le temps restant avant reset
|
|
31
|
+
retry_after = rate_limit_reset_time(key)
|
|
32
|
+
|
|
33
|
+
response.headers["Retry-After"] = retry_after.to_s
|
|
34
|
+
response.headers["X-RateLimit-Limit"] = max_requests.to_s
|
|
35
|
+
response.headers["X-RateLimit-Remaining"] = "0"
|
|
36
|
+
response.headers["X-RateLimit-Reset"] = (Time.current + retry_after.seconds).to_i.to_s
|
|
37
|
+
|
|
38
|
+
render_error(
|
|
39
|
+
I18n.t("upload_d_n_d.errors.rate_limited", seconds: retry_after),
|
|
40
|
+
:too_many_requests
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
false # Stop processing
|
|
44
|
+
else
|
|
45
|
+
# Ajoute les headers informatifs
|
|
46
|
+
response.headers["X-RateLimit-Limit"] = max_requests.to_s
|
|
47
|
+
response.headers["X-RateLimit-Remaining"] = (max_requests - count).to_s
|
|
48
|
+
response.headers["X-RateLimit-Reset"] = (Time.current + period.seconds).to_i.to_s
|
|
49
|
+
|
|
50
|
+
true # Continue processing
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rate_limit_key
|
|
55
|
+
# Combine IP et user_id si disponible pour éviter le partage de limite
|
|
56
|
+
ip = request.remote_ip
|
|
57
|
+
user_identifier = current_user&.id || "anonymous"
|
|
58
|
+
|
|
59
|
+
"upload_dnd:rate_limit:#{user_identifier}:#{ip}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def increment_rate_limit_counter(key)
|
|
63
|
+
period = UploadDND.configuration.rate_limit_period
|
|
64
|
+
|
|
65
|
+
# increment est atomique, contrairement à read + write qui laisse
|
|
66
|
+
# passer des requêtes concurrentes sous la barre du compteur.
|
|
67
|
+
# Certains stores (NullStore, Memcached sans clé existante) renvoient
|
|
68
|
+
# nil : on initialise alors le compteur en format raw.
|
|
69
|
+
Rails.cache.increment(key, 1, expires_in: period.seconds) || begin
|
|
70
|
+
Rails.cache.write(key, 1, raw: true, expires_in: period.seconds)
|
|
71
|
+
1
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def rate_limit_reset_time(key)
|
|
76
|
+
# Récupère le TTL du cache pour cette clé
|
|
77
|
+
# Si le cache ne supporte pas ttl, retourne la période par défaut
|
|
78
|
+
period = UploadDND.configuration.rate_limit_period
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
# Certains cache stores supportent `ttl` (Redis, Memcached)
|
|
82
|
+
ttl = Rails.cache.instance_variable_get(:@data)&.ttl(key)
|
|
83
|
+
ttl && ttl > 0 ? ttl : period
|
|
84
|
+
rescue
|
|
85
|
+
period
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
# Contrôleur dédié à la gestion des attachments permanents (sur records existants).
|
|
5
|
+
#
|
|
6
|
+
# Responsabilités:
|
|
7
|
+
# - Attacher des fichiers à des records ActiveRecord déjà persistés
|
|
8
|
+
# - Valider que le record existe via GlobalID
|
|
9
|
+
# - Gérer has_one vs has_many attachments
|
|
10
|
+
# - Renvoyer la liste des fichiers pour mise à jour UI
|
|
11
|
+
#
|
|
12
|
+
# Utilisé par:
|
|
13
|
+
# - POST /upload_d_n_d/attachments (lors de l'upload sur record existant)
|
|
14
|
+
#
|
|
15
|
+
# Workflow:
|
|
16
|
+
# 1. User uploads file on edit form → record_gid present in params
|
|
17
|
+
# 2. Controller locates record via GlobalID
|
|
18
|
+
# 3. Attaches blob directly to record.attribute_name
|
|
19
|
+
# 4. Saves record with validations
|
|
20
|
+
# 5. Returns updated file list
|
|
21
|
+
#
|
|
22
|
+
# Note: Pour les formulaires SANS record (ex: new User), utilisez TemporaryAttachmentsController.
|
|
23
|
+
class AttachmentsController < BaseAttachmentsController
|
|
24
|
+
# Autorisation (UploadDND::Authorization, fail-closed)
|
|
25
|
+
before_action :authorize_upload!, only: [ :create ]
|
|
26
|
+
before_action :reject_attached_blob, only: [ :create ]
|
|
27
|
+
|
|
28
|
+
before_action :validate_record_gid_presence, only: [ :create ]
|
|
29
|
+
before_action :validate_destroy_params, only: [ :destroy ]
|
|
30
|
+
|
|
31
|
+
# POST /upload_d_n_d/attachments
|
|
32
|
+
#
|
|
33
|
+
# Params:
|
|
34
|
+
# - signed_blob_id: String (ActiveStorage signed blob ID)
|
|
35
|
+
# - attribute_name: String (ex: "avatar", "documents")
|
|
36
|
+
# - record_gid: String (GlobalID du record, ex: "gid://app/User/123")
|
|
37
|
+
# - multiple: String ("true" ou "false", optionnel)
|
|
38
|
+
#
|
|
39
|
+
# Returns:
|
|
40
|
+
# - Turbo Stream pour remplacer la liste de fichiers
|
|
41
|
+
# - JSON {status: 'ok'} en mode API
|
|
42
|
+
def create
|
|
43
|
+
attribute_name = params[:attribute_name]
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
record = GlobalID::Locator.locate(params[:record_gid])
|
|
47
|
+
rescue ActiveRecord::RecordNotFound
|
|
48
|
+
render_error(I18n.t("upload_d_n_d.errors.blob_not_found"), :not_found)
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
unless record
|
|
53
|
+
render_error(I18n.t("upload_d_n_d.errors.blob_not_found"), :not_found)
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
authorize_record_upload!(record)
|
|
58
|
+
return if performed?
|
|
59
|
+
|
|
60
|
+
# SÉCURITÉ : l'attribut doit être un attachment déclaré sur le modèle.
|
|
61
|
+
# Détection SERVEUR du type d'attachment (has_one vs has_many) :
|
|
62
|
+
# NE JAMAIS faire confiance au paramètre client params[:multiple]
|
|
63
|
+
attachment = UploadDND::AttachmentLookup.find(record, attribute_name)
|
|
64
|
+
unless attachment
|
|
65
|
+
render_error(I18n.t("upload_d_n_d.errors.invalid_attribute", attribute: attribute_name), :unprocessable_entity)
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Limites du champ, lues sur le modèle du record (vérifiées avant toute
|
|
70
|
+
# écriture ; le blob refusé est purgé).
|
|
71
|
+
constraints = UploadDND::FieldConstraints.for(record.class, attribute_name)
|
|
72
|
+
if (violation = constraints.violation(@blob))
|
|
73
|
+
render_error(constraints.error_message(violation), :unprocessable_entity)
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Sanitize filename avant l'attachement
|
|
78
|
+
@blob.update(filename: sanitize_filename(@blob.filename.to_s))
|
|
79
|
+
|
|
80
|
+
if attachment.is_a?(ActiveStorage::Attached::Many)
|
|
81
|
+
# has_many_attached: ajouter à la collection
|
|
82
|
+
attachment.attach(@blob)
|
|
83
|
+
elsif attachment.is_a?(ActiveStorage::Attached::One)
|
|
84
|
+
# has_one_attached: remplacer (purge l'ancien automatiquement)
|
|
85
|
+
record.send("#{attribute_name}=", @blob)
|
|
86
|
+
else
|
|
87
|
+
render_error(I18n.t("upload_d_n_d.errors.not_active_storage_attachment", attribute: attribute_name), :unprocessable_entity)
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Sauvegarde avec validation
|
|
92
|
+
if record.save
|
|
93
|
+
render_file_list(record, attribute_name)
|
|
94
|
+
log_upload_success
|
|
95
|
+
else
|
|
96
|
+
render_error(I18n.t("upload_d_n_d.errors.save_failed", errors: record.errors.full_messages.join(", ")), :unprocessable_entity)
|
|
97
|
+
end
|
|
98
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
99
|
+
render_error(I18n.t("upload_d_n_d.errors.save_failed", errors: e.message), :unprocessable_entity)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# DELETE /upload_d_n_d/attachments/:id
|
|
103
|
+
#
|
|
104
|
+
# Détache un fichier d'un record existant, sans dépendre des routes de
|
|
105
|
+
# l'app hôte (contrairement au flux legacy remove_file du concern
|
|
106
|
+
# Controllers::Uploadable). Symétrique du POST #create.
|
|
107
|
+
#
|
|
108
|
+
# Params:
|
|
109
|
+
# - id: Integer (ActiveStorage::Attachment id)
|
|
110
|
+
# - record_gid: String (GlobalID du record)
|
|
111
|
+
# - attribute_name: String (ex: "avatar", "documents")
|
|
112
|
+
def destroy
|
|
113
|
+
record = GlobalID::Locator.locate(params[:record_gid])
|
|
114
|
+
|
|
115
|
+
unless record
|
|
116
|
+
render_error(I18n.t("upload_d_n_d.errors.record_not_found"), :not_found)
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
authorize_destroy!(record)
|
|
121
|
+
return if performed?
|
|
122
|
+
|
|
123
|
+
attachment = ActiveStorage::Attachment.find_by(id: params[:id])
|
|
124
|
+
attribute_name = params[:attribute_name]
|
|
125
|
+
|
|
126
|
+
# Sécurité : le fichier doit appartenir au record ET à l'attribut annoncés
|
|
127
|
+
if attachment && attachment.record == record && attachment.name == attribute_name.to_s
|
|
128
|
+
attachment.purge_later
|
|
129
|
+
record.reload
|
|
130
|
+
|
|
131
|
+
UploadDND.configuration.on_file_removed&.call(record, attribute_name, current_user)
|
|
132
|
+
|
|
133
|
+
render_file_list(record, attribute_name)
|
|
134
|
+
else
|
|
135
|
+
render_error(I18n.t("upload_d_n_d.errors.file_not_found_or_denied"), :not_found)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def validate_destroy_params
|
|
142
|
+
unless params[:record_gid].present? && params[:attribute_name].present?
|
|
143
|
+
render_error(I18n.t("upload_d_n_d.errors.missing_params"), :bad_request)
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
unless params[:attribute_name].match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
148
|
+
render_error(I18n.t("upload_d_n_d.errors.invalid_attribute_name"), :bad_request)
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Autorisation de la suppression : règle du record (update), avec repli
|
|
154
|
+
# sur la règle générique de la gem si le modèle n'en a pas (Pundit : pas
|
|
155
|
+
# de policy). Fail-closed : sans règle du tout, RuleMissing remonte en 500
|
|
156
|
+
# plutôt que de laisser passer.
|
|
157
|
+
def authorize_destroy!(record)
|
|
158
|
+
begin
|
|
159
|
+
authorize_with_adapter!(record, :update)
|
|
160
|
+
rescue UploadDND::Authorization::RuleMissing
|
|
161
|
+
authorize_with_adapter!(:upload_d_n_d_attachment, :destroy)
|
|
162
|
+
end
|
|
163
|
+
rescue UploadDND::Authorization::Denied
|
|
164
|
+
render_forbidden
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Autorisation de l'upload sur le record lui-même (policy update?), en plus
|
|
168
|
+
# de la policy générique vérifiée en before_action : le record_gid vient du
|
|
169
|
+
# client, sans ce contrôle tout utilisateur autorisé à uploader pouvait
|
|
170
|
+
# attacher un fichier à n'importe quel record. Si le modèle n'a pas de
|
|
171
|
+
# règle, la règle générique (create) déjà vérifiée fait foi.
|
|
172
|
+
def authorize_record_upload!(record)
|
|
173
|
+
revoke_upload_authorization!
|
|
174
|
+
begin
|
|
175
|
+
authorize_with_adapter!(record, :update)
|
|
176
|
+
rescue UploadDND::Authorization::RuleMissing
|
|
177
|
+
nil # règle générique déjà vérifiée
|
|
178
|
+
end
|
|
179
|
+
grant_upload_authorization!
|
|
180
|
+
rescue UploadDND::Authorization::Denied
|
|
181
|
+
render_forbidden
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_record_gid_presence
|
|
185
|
+
unless params[:record_gid].present?
|
|
186
|
+
render_error(I18n.t("upload_d_n_d.errors.record_gid_required"), :bad_request)
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def render_file_list(record, attribute_name)
|
|
192
|
+
if turbo_stream_request?
|
|
193
|
+
render_turbo_stream_replace(
|
|
194
|
+
"udnd_files_list_#{attribute_name}",
|
|
195
|
+
partial: "upload_d_n_d/shared/files_list",
|
|
196
|
+
locals: { record: record, attribute_name: attribute_name }
|
|
197
|
+
)
|
|
198
|
+
else
|
|
199
|
+
respond_to do |format|
|
|
200
|
+
format.json { render json: { status: "ok", message: I18n.t("upload_d_n_d.success.file_added") }, status: :ok }
|
|
201
|
+
format.html { redirect_back fallback_location: root_path, notice: I18n.t("upload_d_n_d.success.file_added") }
|
|
202
|
+
format.any { render json: { status: "ok", message: I18n.t("upload_d_n_d.success.file_added") }, status: :ok }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# === Autorisation ===
|
|
208
|
+
|
|
209
|
+
# Policy générique ; la policy du record est vérifiée ensuite
|
|
210
|
+
# (authorize_record_upload!), une fois le record localisé.
|
|
211
|
+
def authorize_upload!
|
|
212
|
+
authorize_with_adapter!(:upload_d_n_d_attachment, :create)
|
|
213
|
+
grant_upload_authorization!
|
|
214
|
+
rescue UploadDND::Authorization::Denied
|
|
215
|
+
render_forbidden
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|