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,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Validateur de fichiers pour ActiveStorage
|
|
4
|
+
# Utilise Marcel pour la détection MIME et vérifie le contenu réel des fichiers
|
|
5
|
+
#
|
|
6
|
+
# @example Utilisation avec has_one_attached
|
|
7
|
+
# class User < ApplicationRecord
|
|
8
|
+
# has_one_attached :avatar
|
|
9
|
+
#
|
|
10
|
+
# validates :avatar, file: {
|
|
11
|
+
# content_type: ['image/png', 'image/jpeg'],
|
|
12
|
+
# max_size: 5.megabytes
|
|
13
|
+
# }
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @example Utilisation avec has_many_attached
|
|
17
|
+
# class Project < ApplicationRecord
|
|
18
|
+
# has_many_attached :documents
|
|
19
|
+
#
|
|
20
|
+
# validates :documents, file: {
|
|
21
|
+
# content_type: ['application/pdf', 'application/msword'],
|
|
22
|
+
# max_size: 10.megabytes,
|
|
23
|
+
# max_files: 5
|
|
24
|
+
# }
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
class FileValidator < ActiveModel::EachValidator
|
|
28
|
+
def validate_each(record, attribute, value)
|
|
29
|
+
return unless value.attached?
|
|
30
|
+
|
|
31
|
+
Array(value).each do |file|
|
|
32
|
+
validate_content_type(record, attribute, file)
|
|
33
|
+
validate_size(record, attribute, file)
|
|
34
|
+
validate_mime_type(record, attribute, file) if options[:strict_mime_check]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
validate_file_count(record, attribute, value) if value.respond_to?(:count)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Valide le content_type déclaré
|
|
43
|
+
def validate_content_type(record, attribute, file)
|
|
44
|
+
return unless options[:content_type].present?
|
|
45
|
+
|
|
46
|
+
unless file.content_type.in?(Array(options[:content_type]))
|
|
47
|
+
record.errors.add(
|
|
48
|
+
attribute,
|
|
49
|
+
:content_type,
|
|
50
|
+
message: options[:message] || "n'est pas un type de fichier valide"
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Valide la taille du fichier
|
|
56
|
+
def validate_size(record, attribute, file)
|
|
57
|
+
return unless options[:max_size].present?
|
|
58
|
+
|
|
59
|
+
if file.byte_size > options[:max_size]
|
|
60
|
+
max_size_in_mb = options[:max_size] / 1.megabyte
|
|
61
|
+
record.errors.add(
|
|
62
|
+
attribute,
|
|
63
|
+
:max_size,
|
|
64
|
+
message: options[:message] || "doit être inférieur à #{max_size_in_mb}MB"
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Valide le nombre de fichiers (pour has_many_attached)
|
|
70
|
+
def validate_file_count(record, attribute, value)
|
|
71
|
+
return unless options[:max_files].present?
|
|
72
|
+
|
|
73
|
+
if value.count > options[:max_files]
|
|
74
|
+
record.errors.add(
|
|
75
|
+
attribute,
|
|
76
|
+
:max_files,
|
|
77
|
+
message: options[:message] || "ne peut pas avoir plus de #{options[:max_files]} fichiers"
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Détecte le vrai type MIME en analysant le contenu du fichier
|
|
83
|
+
# Protège contre le spoofing de fichiers (ex: .exe renommé en .jpg)
|
|
84
|
+
def validate_mime_type(record, attribute, file)
|
|
85
|
+
return unless options[:content_type].present?
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
# Marcel analyse le contenu réel du fichier pour détecter son type
|
|
89
|
+
detected_content_type = detect_content_type(record, attribute, file)
|
|
90
|
+
|
|
91
|
+
declared_content_type = file.content_type
|
|
92
|
+
|
|
93
|
+
# Vérifie que le type détecté est dans la liste des types autorisés
|
|
94
|
+
unless detected_content_type.in?(Array(options[:content_type]))
|
|
95
|
+
record.errors.add(
|
|
96
|
+
attribute,
|
|
97
|
+
:spoofed_content_type,
|
|
98
|
+
message: options[:spoofing_message] ||
|
|
99
|
+
"Le type de fichier détecté (#{detected_content_type}) ne correspond pas aux types autorisés"
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Vérifie que le type détecté correspond au type déclaré
|
|
104
|
+
if detected_content_type != declared_content_type
|
|
105
|
+
record.errors.add(
|
|
106
|
+
attribute,
|
|
107
|
+
:mime_type_mismatch,
|
|
108
|
+
message: options[:spoofing_message] ||
|
|
109
|
+
"Le type de fichier détecté (#{detected_content_type}) " \
|
|
110
|
+
"ne correspond pas au type déclaré (#{declared_content_type})"
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
record.errors.add(
|
|
115
|
+
attribute,
|
|
116
|
+
:mime_type_detection_failed,
|
|
117
|
+
message: "Impossible de vérifier le type de fichier : #{e.message}"
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Marcel a besoin des octets du fichier. Avec un champ fichier classique, le
|
|
123
|
+
# fichier part avec le formulaire et n'est envoyé au stockage qu'après le
|
|
124
|
+
# `save` : à la validation, `file.download` lèverait
|
|
125
|
+
# `ActiveStorage::FileNotFoundError`. On lit alors la source encore en attente
|
|
126
|
+
# (`attachment_changes`) sans la consommer pour l'envoi au stockage. Avec un
|
|
127
|
+
# blob déjà stocké (zone de dépôt), on retombe sur `file.download`.
|
|
128
|
+
def detect_content_type(record, attribute, file)
|
|
129
|
+
io = pending_upload_io(record, attribute, file)
|
|
130
|
+
return Marcel::MimeType.for(file.download, name: file.filename.to_s) unless io
|
|
131
|
+
|
|
132
|
+
io.rewind if io.respond_to?(:rewind)
|
|
133
|
+
Marcel::MimeType.for(io, name: file.filename.to_s)
|
|
134
|
+
ensure
|
|
135
|
+
io.rewind if io.respond_to?(:rewind)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def pending_upload_io(record, attribute, file)
|
|
139
|
+
return unless record.respond_to?(:attachment_changes)
|
|
140
|
+
|
|
141
|
+
change = record.attachment_changes[attribute.to_s]
|
|
142
|
+
return unless change
|
|
143
|
+
|
|
144
|
+
blob = file.respond_to?(:blob) ? file.blob : file
|
|
145
|
+
attachable = pending_attachables_by_key(change)[blob&.key]
|
|
146
|
+
attachable && readable_io(attachable)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# { blob.key => attachable } des sources non encore stockées. has_one :
|
|
150
|
+
# CreateOne (blob + attachable). has_many : CreateMany (blobs + attachables,
|
|
151
|
+
# même ordre). La `key` est fiable avant le save (l'id du blob est encore nil).
|
|
152
|
+
def pending_attachables_by_key(change)
|
|
153
|
+
if change.respond_to?(:blobs) && change.respond_to?(:attachables)
|
|
154
|
+
change.blobs.map(&:key).zip(change.attachables).to_h
|
|
155
|
+
elsif change.respond_to?(:blob) && change.respond_to?(:attachable)
|
|
156
|
+
{ change.blob&.key => change.attachable }
|
|
157
|
+
else
|
|
158
|
+
{}
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def readable_io(attachable)
|
|
163
|
+
if attachable.respond_to?(:tempfile) # UploadedFile (ActionDispatch / Rack::Test)
|
|
164
|
+
attachable.tempfile
|
|
165
|
+
elsif attachable.is_a?(Hash)
|
|
166
|
+
attachable[:io]
|
|
167
|
+
elsif attachable.respond_to?(:read) # IO, File, StringIO...
|
|
168
|
+
attachable
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :upload_d_n_d do
|
|
4
|
+
desc "Liste les blobs orphelins (sans rattachement, > min_age, non cités dans un texte). Simulation sauf APPLY=1"
|
|
5
|
+
task purge_orphans: :environment do
|
|
6
|
+
apply = ENV["APPLY"] == "1"
|
|
7
|
+
results = UploadDND::OrphanBlobs.call
|
|
8
|
+
|
|
9
|
+
results.each do |result|
|
|
10
|
+
blob = result.blob
|
|
11
|
+
puts "blob ##{blob.id} | #{blob.created_at.to_date} | #{blob.filename} | " \
|
|
12
|
+
"#{ActiveSupport::NumberHelper.number_to_human_size(blob.byte_size)} | service=#{blob.service_name}"
|
|
13
|
+
if result.purgeable?
|
|
14
|
+
blob.purge if apply
|
|
15
|
+
puts " → #{apply ? "supprimé" : "à supprimer"}"
|
|
16
|
+
else
|
|
17
|
+
puts " → cité dans #{result.citations.join(", ")} : conservé"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
purgeable = results.select(&:purgeable?)
|
|
22
|
+
size = ActiveSupport::NumberHelper.number_to_human_size(purgeable.sum { |result| result.blob.byte_size })
|
|
23
|
+
puts "#{apply ? "APPLIQUÉ" : "SIMULATION (APPLY=1 pour appliquer)"} : " \
|
|
24
|
+
"#{purgeable.size} blob(s) #{apply ? "supprimé(s)" : "à supprimer"} (#{size}), " \
|
|
25
|
+
"#{results.size - purgeable.size} cité(s) dans un texte et conservé(s)."
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
namespace :upload_d_n_d do
|
|
2
|
+
desc "Link gem JS into node_modules for jsbundling compatibility"
|
|
3
|
+
task :link_js do
|
|
4
|
+
gem_spec = Gem.loaded_specs["upload_d_n_d"]
|
|
5
|
+
next unless gem_spec
|
|
6
|
+
|
|
7
|
+
gem_path = gem_spec.gem_dir
|
|
8
|
+
link_path = Rails.root.join("node_modules", "upload_d_n_d")
|
|
9
|
+
|
|
10
|
+
unless link_path.exist?
|
|
11
|
+
FileUtils.mkdir_p(link_path.dirname)
|
|
12
|
+
FileUtils.ln_s(gem_path, link_path.to_s)
|
|
13
|
+
puts "upload_d_n_d: linked JS #{link_path} → #{gem_path}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if Rake::Task.task_defined?("javascript:build")
|
|
19
|
+
Rake::Task["javascript:build"].enhance([ "upload_d_n_d:link_js" ])
|
|
20
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
# Résout un attachment ActiveStorage à partir d'un nom d'attribut venu du
|
|
5
|
+
# client (paramètre de requête, ou attribute_name stocké dans un
|
|
6
|
+
# TemporaryAttachment).
|
|
7
|
+
#
|
|
8
|
+
# SÉCURITÉ : seule la réflexion du modèle fait foi. Un nom qui ne désigne pas
|
|
9
|
+
# un attachment déclaré (has_one_attached / has_many_attached) n'est jamais
|
|
10
|
+
# appelé sur le record — `respond_to?` + `send` laisserait passer `destroy`,
|
|
11
|
+
# `delete`, `touch`...
|
|
12
|
+
module AttachmentLookup
|
|
13
|
+
# @return [ActiveStorage::Attached::One, ActiveStorage::Attached::Many, nil]
|
|
14
|
+
def self.find(record, attribute_name)
|
|
15
|
+
return if record.nil? || attribute_name.blank?
|
|
16
|
+
return unless record.class.respond_to?(:reflect_on_attachment)
|
|
17
|
+
|
|
18
|
+
name = attribute_name.to_s
|
|
19
|
+
return unless record.class.reflect_on_attachment(name)
|
|
20
|
+
|
|
21
|
+
record.public_send(name)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
module Authorization
|
|
5
|
+
# Adaptateur CanCanCan : `authorize! :action, sujet` sur le contrôleur.
|
|
6
|
+
# Les sujets génériques sont des symboles, à autoriser dans l'Ability :
|
|
7
|
+
# can :create, :upload_d_n_d_attachment
|
|
8
|
+
# Sans règle, CanCanCan refuse : il n'y a pas de RuleMissing.
|
|
9
|
+
module CanCanCanAdapter
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def authorize!(controller, subject, action, namespace: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
13
|
+
unless defined?(::CanCan::AccessDenied) && controller.respond_to?(:authorize!, true)
|
|
14
|
+
raise ConfigurationError,
|
|
15
|
+
"authorization_adapter is :cancancan but #{controller.class} cannot call authorize! " \
|
|
16
|
+
"(CanCanCan missing)"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
call(controller, subject, action)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(controller, subject, action)
|
|
23
|
+
controller.send(:authorize!, action, subject)
|
|
24
|
+
rescue ::CanCan::AccessDenied
|
|
25
|
+
raise Denied
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
module Authorization
|
|
5
|
+
# authorization_adapter = :none — opt-out explicite : seule
|
|
6
|
+
# l'authentification est vérifiée. Signalé au démarrage.
|
|
7
|
+
module NoAuthorization
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def authorize!(_controller, _subject, _action, namespace: nil); end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
module Authorization
|
|
5
|
+
# Adaptateur Pundit : `authorize sujet, :action?` sur le contrôleur, qui
|
|
6
|
+
# doit inclure Pundit::Authorization (verify_authorized reste satisfait).
|
|
7
|
+
module PunditAdapter
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def authorize!(controller, subject, action, namespace: nil)
|
|
11
|
+
unless defined?(::Pundit) && controller.respond_to?(:authorize, true)
|
|
12
|
+
raise ConfigurationError,
|
|
13
|
+
"authorization_adapter is :pundit but #{controller.class} cannot call authorize " \
|
|
14
|
+
"(Pundit missing or Pundit::Authorization not included)"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
call(controller, subject, :"#{action}?", namespace)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Avec un namespace, la policy namespacée (authorize [:admin, record] ->
|
|
21
|
+
# Admin::RecordPolicy) est essayée d'abord, puis la policy top-level : un
|
|
22
|
+
# namespace de contrôleur (module: dans les routes) ne reflète pas
|
|
23
|
+
# forcément celui des policies.
|
|
24
|
+
def call(controller, subject, query, namespace)
|
|
25
|
+
if namespace.present?
|
|
26
|
+
begin
|
|
27
|
+
return controller.send(:authorize, [ *namespace, subject ], query)
|
|
28
|
+
rescue ::Pundit::NotDefinedError
|
|
29
|
+
nil # repli sur la policy top-level
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
controller.send(:authorize, subject, query)
|
|
34
|
+
rescue ::Pundit::NotAuthorizedError
|
|
35
|
+
raise Denied
|
|
36
|
+
rescue ::Pundit::NotDefinedError
|
|
37
|
+
raise RuleMissing
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
module Authorization
|
|
5
|
+
# Aucun adaptateur utilisable (nil, valeur inconnue) : tout est refusé.
|
|
6
|
+
module Unconfigured
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def authorize!(_controller, subject, action, namespace: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
10
|
+
Rails.logger.warn "UploadDND: #{action} on #{subject.class} refused - no authorization adapter configured"
|
|
11
|
+
raise Denied
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
# Port d'autorisation : la question que la gem pose — « l'utilisateur
|
|
5
|
+
# courant peut-il faire <action> sur <sujet> ? » — indépendamment de la
|
|
6
|
+
# librairie qui y répond. Chaque librairie est un adaptateur, choisi par
|
|
7
|
+
# config.authorization_adapter.
|
|
8
|
+
#
|
|
9
|
+
# Fail-closed : un adaptateur absent (nil) ou inconnu refuse tout.
|
|
10
|
+
# L'absence d'autorisation ne s'obtient qu'explicitement, avec :none.
|
|
11
|
+
module Authorization
|
|
12
|
+
# Refus. `cause` porte l'exception native de la librairie, s'il y en a une.
|
|
13
|
+
class Denied < UploadDND::Error; end
|
|
14
|
+
|
|
15
|
+
# Aucune règle pour ce sujet (Pundit : policy absente). L'appelant décide
|
|
16
|
+
# du repli ; à défaut, l'erreur remonte (500).
|
|
17
|
+
class RuleMissing < UploadDND::Error; end
|
|
18
|
+
|
|
19
|
+
# Adaptateur déclaré mais inutilisable (librairie absente, contrôleur sans
|
|
20
|
+
# la méthode d'autorisation).
|
|
21
|
+
class ConfigurationError < UploadDND::Error; end
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# @param controller [ActionController::Base] contrôleur de la requête
|
|
25
|
+
# @param subject [Object, Symbol] record, ou symbole d'une policy générique
|
|
26
|
+
# @param action [Symbol] :create, :update, :destroy...
|
|
27
|
+
# @param namespace [Array<Symbol>, nil] namespace de policy à essayer d'abord
|
|
28
|
+
# @raise [Denied, RuleMissing, ConfigurationError]
|
|
29
|
+
def authorize!(controller, subject, action, namespace: nil)
|
|
30
|
+
adapter.authorize!(controller, subject, action, namespace: namespace)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def adapter(name = UploadDND.configuration.authorization_adapter)
|
|
34
|
+
key = name.respond_to?(:to_sym) ? name.to_sym : name
|
|
35
|
+
adapters.fetch(key, Unconfigured)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Avertit au démarrage quand les uploads ne sont pas protégés par une
|
|
39
|
+
# librairie d'autorisation.
|
|
40
|
+
def report(logger = Rails.logger)
|
|
41
|
+
configured = UploadDND.configuration.authorization_adapter
|
|
42
|
+
current = adapter(configured)
|
|
43
|
+
|
|
44
|
+
if current == NoAuthorization
|
|
45
|
+
logger.warn "UploadDND: authorization disabled (authorization_adapter = :none) - " \
|
|
46
|
+
"uploads and deletions are only checked for authentication"
|
|
47
|
+
elsif current == Unconfigured
|
|
48
|
+
logger.warn "UploadDND: no usable authorization adapter (authorization_adapter = #{configured.inspect}) - " \
|
|
49
|
+
"all uploads and deletions are refused. Set config.authorization_adapter " \
|
|
50
|
+
"to :pundit, :cancancan or :none."
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def adapters
|
|
57
|
+
{ pundit: PunditAdapter, cancancan: CanCanCanAdapter, none: NoAuthorization }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
require "upload_d_n_d/authorization/pundit_adapter"
|
|
64
|
+
require "upload_d_n_d/authorization/can_can_can_adapter"
|
|
65
|
+
require "upload_d_n_d/authorization/no_authorization"
|
|
66
|
+
require "upload_d_n_d/authorization/unconfigured"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UploadDND
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :max_size,
|
|
6
|
+
:accepted_types,
|
|
7
|
+
:strict_mime_check,
|
|
8
|
+
:main_color,
|
|
9
|
+
:allow_anonymous_uploads,
|
|
10
|
+
:require_authentication,
|
|
11
|
+
:temporary_files_retention,
|
|
12
|
+
:rejected_blob_purge_window,
|
|
13
|
+
:orphan_blob_min_age,
|
|
14
|
+
:orphan_scan_columns,
|
|
15
|
+
:orphan_scan_patterns,
|
|
16
|
+
:orphan_scan_excluded_tables,
|
|
17
|
+
:authorization_adapter,
|
|
18
|
+
:validator_class,
|
|
19
|
+
:active_storage_service,
|
|
20
|
+
:on_upload_success,
|
|
21
|
+
:on_upload_error,
|
|
22
|
+
:on_file_removed,
|
|
23
|
+
:delete_route,
|
|
24
|
+
:rate_limit_enabled,
|
|
25
|
+
:rate_limit_max_requests,
|
|
26
|
+
:rate_limit_period
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
# Valeurs par défaut
|
|
30
|
+
@max_size = 10.megabytes
|
|
31
|
+
@accepted_types = ".pdf,.doc,.docx,.jpg,.jpeg,.png,.gif"
|
|
32
|
+
@strict_mime_check = false
|
|
33
|
+
@main_color = "#E97451"
|
|
34
|
+
@allow_anonymous_uploads = false
|
|
35
|
+
@require_authentication = true # Par défaut, authentification requise
|
|
36
|
+
@temporary_files_retention = 7 # jours
|
|
37
|
+
# Un blob refusé n'est purgé que s'il a été créé dans cette fenêtre
|
|
38
|
+
# (envoi en cours) : un blob plus ancien peut être cité dans un HTML
|
|
39
|
+
# stocké. nil désactive la purge des blobs refusés.
|
|
40
|
+
@rejected_blob_purge_window = 1.hour
|
|
41
|
+
# Nettoyage des blobs orphelins (opt-in via UploadDND::PurgeOrphanBlobsJob
|
|
42
|
+
# ou la tâche rake upload_d_n_d:purge_orphans) :
|
|
43
|
+
# - âge minimal avant qu'un blob sans rattachement soit candidat ;
|
|
44
|
+
# - liste blanche des colonnes à scanner ("table.colonne"), nil = toutes
|
|
45
|
+
# les colonnes texte/JSON (borne la mémoire sur une grosse base) ;
|
|
46
|
+
# - motifs LIKE supplémentaires (hôte/bucket du service de stockage) ;
|
|
47
|
+
# - tables à exclure en plus des tables système.
|
|
48
|
+
@orphan_blob_min_age = 2.days
|
|
49
|
+
@orphan_scan_columns = nil
|
|
50
|
+
@orphan_scan_patterns = nil
|
|
51
|
+
@orphan_scan_excluded_tables = []
|
|
52
|
+
@authorization_adapter = detect_authorization_adapter
|
|
53
|
+
@validator_class = "FileValidator"
|
|
54
|
+
@active_storage_service = nil # Utilise le service par défaut de Rails
|
|
55
|
+
@on_upload_success = nil
|
|
56
|
+
@on_upload_error = nil
|
|
57
|
+
@on_file_removed = nil
|
|
58
|
+
# :engine (défaut) : la suppression passe par le moteur de la gem,
|
|
59
|
+
# aucune route requise côté app (symétrique de l'upload).
|
|
60
|
+
# :host (legacy) : la suppression passe par la route membre remove_file
|
|
61
|
+
# de l'app (concern Controllers::Uploadable) — nécessite une route
|
|
62
|
+
# polymorphique pour le record (cf. README, "Records imbriqués").
|
|
63
|
+
@delete_route = :engine
|
|
64
|
+
@rate_limit_enabled = false
|
|
65
|
+
@rate_limit_max_requests = 10
|
|
66
|
+
@rate_limit_period = 60 # secondes
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Détecte l'adaptateur d'autorisation disponible. nil si aucun : tout
|
|
72
|
+
# est alors refusé (UploadDND::Authorization), sauf :none explicite.
|
|
73
|
+
def detect_authorization_adapter
|
|
74
|
+
return :pundit if defined?(::Pundit)
|
|
75
|
+
return :cancancan if defined?(::CanCan::Ability)
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class << self
|
|
82
|
+
attr_writer :configuration
|
|
83
|
+
|
|
84
|
+
def configuration
|
|
85
|
+
@configuration ||= Configuration.new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def configure
|
|
89
|
+
yield(configuration)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Reset de la configuration (utile pour les tests)
|
|
93
|
+
def reset_configuration!
|
|
94
|
+
@configuration = Configuration.new
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|