upload_d_n_d 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of upload_d_n_d might be problematic. Click here for more details.

Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +229 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/config/upload_d_n_d_manifest.js +3 -0
  6. data/app/assets/stylesheets/upload_d_n_d/application.css +604 -0
  7. data/app/components/upload_d_n_d/upload_component.html.erb +53 -0
  8. data/app/components/upload_d_n_d/upload_component.rb +204 -0
  9. data/app/controllers/concerns/upload_d_n_d/rate_limiting.rb +89 -0
  10. data/app/controllers/upload_d_n_d/attachments_controller.rb +218 -0
  11. data/app/controllers/upload_d_n_d/base_attachments_controller.rb +250 -0
  12. data/app/controllers/upload_d_n_d/temporary_attachments_controller.rb +200 -0
  13. data/app/helpers/upload_d_n_d/application_helper.rb +51 -0
  14. data/app/helpers/upload_d_n_d/controllers/helpers.rb +21 -0
  15. data/app/javascript/upload_d_n_d/controllers/preview_controller.js +217 -0
  16. data/app/javascript/upload_d_n_d/controllers/uploads_controller.js +396 -0
  17. data/app/javascript/upload_d_n_d/index.js +13 -0
  18. data/app/jobs/upload_d_n_d/application_job.rb +4 -0
  19. data/app/jobs/upload_d_n_d/cleanup_temporary_attachments_job.rb +17 -0
  20. data/app/jobs/upload_d_n_d/purge_orphan_blobs_job.rb +25 -0
  21. data/app/jobs/upload_d_n_d/purge_rejected_blob_job.rb +24 -0
  22. data/app/models/upload_d_n_d/application_record.rb +5 -0
  23. data/app/models/upload_d_n_d/temporary_attachment.rb +129 -0
  24. data/app/views/upload_d_n_d/shared/_file_icon.html.erb +9 -0
  25. data/app/views/upload_d_n_d/shared/_files_list.html.erb +63 -0
  26. data/app/views/upload_d_n_d/shared/_temp_files_list.html.erb +53 -0
  27. data/config/importmap.rb +5 -0
  28. data/config/initializers/inflections.rb +11 -0
  29. data/config/locales/upload_d_n_d.en.yml +80 -0
  30. data/config/locales/upload_d_n_d.fr.yml +80 -0
  31. data/config/routes.rb +12 -0
  32. data/docs/CHANGELOG.md +282 -0
  33. data/lib/generators/upload_d_n_d/POLICY_README +49 -0
  34. data/lib/generators/upload_d_n_d/USAGE +80 -0
  35. data/lib/generators/upload_d_n_d/install_generator.rb +174 -0
  36. data/lib/generators/upload_d_n_d/mime_type_helper.rb +106 -0
  37. data/lib/generators/upload_d_n_d/model_generator.rb +313 -0
  38. data/lib/generators/upload_d_n_d/policy_generator.rb +25 -0
  39. data/lib/generators/upload_d_n_d/templates/POST_INSTALL +62 -0
  40. data/lib/generators/upload_d_n_d/templates/concerns/controllers_uploadable.rb +115 -0
  41. data/lib/generators/upload_d_n_d/templates/concerns/upload_authorization.rb +91 -0
  42. data/lib/generators/upload_d_n_d/templates/concerns/uploadable.rb +101 -0
  43. data/lib/generators/upload_d_n_d/templates/controller.rb.tt +60 -0
  44. data/lib/generators/upload_d_n_d/templates/helpers/upload_helper.rb +105 -0
  45. data/lib/generators/upload_d_n_d/templates/initializers/upload_d_n_d.rb +69 -0
  46. data/lib/generators/upload_d_n_d/templates/migrations/create_upload_d_n_d_temporary_attachments.rb +18 -0
  47. data/lib/generators/upload_d_n_d/templates/policies/upload_d_n_d_attachment_policy.rb +37 -0
  48. data/lib/generators/upload_d_n_d/templates/policies/upload_d_n_d_temporary_attachment_policy.rb +56 -0
  49. data/lib/generators/upload_d_n_d/templates/validators/file_validator.rb +171 -0
  50. data/lib/tasks/upload_d_n_d_storage.rake +27 -0
  51. data/lib/tasks/upload_d_n_d_tasks.rake +20 -0
  52. data/lib/upload_d_n_d/attachment_lookup.rb +24 -0
  53. data/lib/upload_d_n_d/authorization/can_can_can_adapter.rb +29 -0
  54. data/lib/upload_d_n_d/authorization/no_authorization.rb +13 -0
  55. data/lib/upload_d_n_d/authorization/pundit_adapter.rb +41 -0
  56. data/lib/upload_d_n_d/authorization/unconfigured.rb +15 -0
  57. data/lib/upload_d_n_d/authorization.rb +66 -0
  58. data/lib/upload_d_n_d/configuration.rb +97 -0
  59. data/lib/upload_d_n_d/controllers/uploadable.rb +271 -0
  60. data/lib/upload_d_n_d/engine.rb +50 -0
  61. data/lib/upload_d_n_d/field_constraints.rb +92 -0
  62. data/lib/upload_d_n_d/field_reference.rb +44 -0
  63. data/lib/upload_d_n_d/file_validator.rb +156 -0
  64. data/lib/upload_d_n_d/mime_types.rb +75 -0
  65. data/lib/upload_d_n_d/orphan_blobs.rb +151 -0
  66. data/lib/upload_d_n_d/rejected_blob.rb +42 -0
  67. data/lib/upload_d_n_d/session_id.rb +25 -0
  68. data/lib/upload_d_n_d/uploadable.rb +117 -0
  69. data/lib/upload_d_n_d/version.rb +3 -0
  70. data/lib/upload_d_n_d.rb +62 -0
  71. metadata +150 -0
@@ -0,0 +1,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