upload_d_n_d 0.9.4 → 0.10.0

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.
@@ -47,27 +47,46 @@ module UploadDND
47
47
  # @user,
48
48
  # { 'avatar' => :avatar, 'documents' => :documents }
49
49
  # )
50
- def attach_to_record(session_id, record, attribute_map = {})
50
+ # Transfère les fichiers temporaires d'une session vers un record.
51
+ #
52
+ # @param attribute_map [Hash, nil] mapping source => cible. nil (callback
53
+ # after_create) = identité sur les attributs `uploadable` du modèle.
54
+ #
55
+ # SÉCURITÉ (F3) : on ne traite que les attributs explicitement demandés
56
+ # (clés de `attribute_map`, ou les `uploadable_attributes` du modèle), et
57
+ # seulement s'ils sont déclarés `uploadable` — un attribute_name forgé vers
58
+ # un autre champ (ex. "signed_contract") n'est jamais transféré.
59
+ # SÉCURITÉ (F4) : `max_files` et les contraintes du champ sont vérifiés au
60
+ # transfert (le déplacement par `update!` ne relance pas la validation).
61
+ def attach_to_record(session_id, record, attribute_map = nil)
62
+ uploadable = record.class.respond_to?(:uploadable_attributes) ? record.class.uploadable_attributes : {}
63
+ counts = Hash.new { |hash, attr| hash[attr] = current_attachment_count(record, attr) }
64
+
51
65
  for_session(session_id).find_each do |temp_attachment|
52
- source_attr = temp_attachment.attribute_name
53
- target_attr = attribute_map[source_attr]&.to_s || source_attr
66
+ target_attr = resolve_target(temp_attachment.attribute_name, attribute_map)
67
+ next if target_attr.nil? # non demandé explicitement : on laisse (nettoyage orphelins)
54
68
 
55
- # SÉCURITÉ : attribute_name vient du client — seul un attachment
56
- # déclaré sur le modèle est résolu, jamais une méthode quelconque.
57
- attachment = UploadDND::AttachmentLookup.find(record, target_attr)
58
- unless attachment
59
- temp_attachment.destroy
60
- next
61
- end
69
+ # Champ non uploadable : ne pas transférer ET ne pas détruire — ce peut
70
+ # être un temporaire d'un autre formulaire ouvert dans la même session
71
+ # (autre modèle/onglet). Le job d'expiration les nettoie.
72
+ next unless uploadable.key?(target_attr.to_sym)
62
73
 
74
+ attachment = UploadDND::AttachmentLookup.find(record, target_attr)
63
75
  blob_attachment = temp_attachment.blob_attachment
64
- if blob_attachment.nil? || temp_attachment.violates_constraints_of?(record, target_attr)
76
+ if attachment.nil? || blob_attachment.nil? || temp_attachment.violates_constraints_of?(record, target_attr)
65
77
  temp_attachment.destroy
66
78
  next
67
79
  end
68
80
 
69
81
  if attachment.is_a?(ActiveStorage::Attached::Many)
82
+ max = uploadable[target_attr.to_sym][:max_files]
83
+ if max && counts[target_attr] >= max
84
+ Rails.logger.warn "UploadDND: #{record.class}##{target_attr} max_files (#{max}) atteint au transfert - fichier refusé"
85
+ temp_attachment.destroy
86
+ next
87
+ end
70
88
  blob_attachment.update!(record: record, name: target_attr)
89
+ counts[target_attr] += 1
71
90
  elsif attachment.is_a?(ActiveStorage::Attached::One)
72
91
  if attachment.attached?
73
92
  temp_attachment.destroy
@@ -80,6 +99,25 @@ module UploadDND
80
99
  temp_attachment.destroy
81
100
  end
82
101
  end
102
+
103
+ private
104
+
105
+ # Cible pour un attribut source. Avec une map : uniquement ses clés (nil
106
+ # sinon). Sans map (after_create) : identité (filtré ensuite sur uploadable).
107
+ def resolve_target(source_attr, attribute_map)
108
+ return source_attr if attribute_map.nil?
109
+
110
+ attribute_map[source_attr]&.to_s
111
+ end
112
+
113
+ def current_attachment_count(record, attribute)
114
+ attachment = UploadDND::AttachmentLookup.find(record, attribute)
115
+ case attachment
116
+ when ActiveStorage::Attached::Many then attachment.count
117
+ when ActiveStorage::Attached::One then attachment.attached? ? 1 : 0
118
+ else 0
119
+ end
120
+ end
83
121
  end
84
122
 
85
123
  # Méthodes d'instance
@@ -23,7 +23,7 @@
23
23
  </div>
24
24
  </div>
25
25
 
26
- <% delete_url = udnd_remove_file_url(record, attachment, attribute_name) %>
26
+ <% delete_url = udnd_remove_file_url(record, attachment, attribute_name, namespace: local_assigns[:namespace]) %>
27
27
 
28
28
  <div class="udnd-file-actions">
29
29
  <%# Bouton de prévisualisation (images et PDFs uniquement) %>
data/docs/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-10-30
11
+
12
+ Lot sécurité majeur (audit de la gem) : la cible d'un dépôt/suppression provient désormais d'un **jeton signé** (plus de paramètres bruts du client), l'autorisation est **fail-closed** et le flux des fichiers temporaires est durci. **Changement de contrat** — voir la section Migration. Mineure, rétrocompatible pour le code applicatif (le composant émet le jeton ; une app met la gem à jour et re-rend ses vues).
13
+
14
+ ### Security
15
+ - **Cible d'upload signée** (`UploadDND::SignedTarget`, remplace `UploadDND::FieldReference`) — le dépôt et la suppression sur un record ne reposent plus sur un `record_gid`/`attribute_name`/`namespace` **bruts** fournis par le client. Le composant émet un jeton **signé** (modèle + attribut + record + namespace de policy) ; le contrôleur en déduit la cible et ignore les paramètres bruts. Ferme l'usurpation de record via `GlobalID` non signé, restreint le dépôt aux seuls champs déclarés `uploadable` (un `has_one_attached`/`has_many_attached` quelconque n'est plus ciblable), et transporte le namespace de policy de façon fiable. **Coupure franche** : un client qui n'envoie pas de jeton valide est refusé.
16
+ - **Autorisation record fail-closed, avec namespace** — les routes du moteur autorisent sur le record (`:update`) en passant le namespace du jeton (`Admin::RecordPolicy` essayée d'abord, repli sur la policy top-level). Si **aucune** policy n'existe pour le record, la requête est **refusée (403)** (plus de repli sur la policy générique `UploadDNDAttachmentPolicy`), avec un avertissement en log nommant la policy manquante. La garde générique est retirée des routes permanentes ; le flux temporaire conserve `:upload_d_n_d_temporary_attachment`, désormais fail-closed lui aussi.
17
+ - **Référence de champ obligatoire au dépôt temporaire** — plus de repli silencieux sur les contraintes globales : les limites appliquées sont celles du champ visé par le jeton.
18
+ - **Transfert des fichiers temporaires durci** — `TemporaryAttachment.attach_to_record` et le callback `attach_temporary_uploads` ne transfèrent que les attributs **explicitement demandés** (clés de l'`attribute_map`, ou les `uploadable_attributes` du modèle), et seulement s'ils sont déclarés `uploadable` : un `attribute_name` forgé vers un autre champ n'est plus transféré (et un temporaire non concerné n'est pas détruit). `max_files` et les contraintes du champ sont désormais appliqués **au transfert** (le déplacement par `update!` ne relançait pas la validation).
19
+ - **Plus d'alias global `::FileValidator`** — la gem n'enregistre plus de constante `::FileValidator` au boot. Elle masquait silencieusement le `FileValidator` d'une app hôte (chargé plus tard par Zeitwerk), désactivant ses propres validations. `uploadable` référence désormais `UploadDND::FileValidator` explicitement (`validates_with`). L'installeur ne traite plus `app/validators/file_validator.rb` comme un fichier obsolète.
20
+ - **Durée de vie des jetons** — `config.signed_target_expires_in` (24 h par défaut, `nil` pour désactiver) limite la fenêtre d'usage d'un jeton de cible. L'autorisation reste revérifiée à chaque requête.
21
+ - **Énumération de records fermée** — le `record_gid` brut non signé est remplacé par le jeton signé : on ne peut plus forger d'identifiant pour sonder l'existence de records (404 vs 403).
22
+ - **Purge de blob restreinte aux requêtes autorisées sur le record** — l'autorisation de purge des blobs refusés n'est plus posée par la policy générique seule. Une requête qui échoue avant la localisation et l'autorisation du record (400/404) ne purge plus le blob déposé : sans ce correctif, un utilisateur disposant du droit générique pouvait forcer la purge d'un blob récent non rattaché dont il connaissait le `signed_id`. Les orphelins restent nettoyés par la purge dédiée.
23
+
24
+ ### Added
25
+ - **Contrôleur parent configurable** — `config.parent_controller` (défaut `"ActionController::Base"`). Mettre `"ApplicationController"` pour que les routes du moteur héritent de `pundit_user`, `current_ability` et des `before_action` d'authentification/multi-tenant de l'app hôte. Quand Pundit est présent, le moteur marque la requête comme vérifiée (`skip_policy_scope`/`skip_authorization`) pour que les `after_action verify_*` de l'hôte passent **quel que soit leur nom** — le moteur appliquant malgré tout sa propre autorisation fail-closed. `current_user` est public et délègue au parent (`super`) avant un repli Warden, pour que l'app hôte le voie (`respond_to?`) et alimente son contexte (ex. `Current.user`).
26
+
27
+ ### Fixed
28
+ - **`upload_d_n_d:link_js` : copie rafraîchie sur changement de contenu** — la tâche comparait seulement la version du `package.json` ; une gem branchée en `path:`/`git:` (même version) gardait l'ancien JS. La copie est désormais rafraîchie dès qu'un fichier diffère.
29
+ - **CSS : `@import "upload_d_n_d/styles"` fonctionne sur tous les bundlers** — un vrai fichier `styles.css` est livré à la racine du paquet (il ré-importe le CSS réel, source unique). postcss-import (Tailwind 3) ignore les `exports` et cherche un fichier réel : il le trouve désormais ; esbuild/bun continuent via `exports`. `JsLink` copie `styles.css` **et** le CSS qu'il importe ; `styles.css` est ajouté aux fichiers du gemspec.
30
+ - **Libellé : types MIME non standard** — `image/jpg` (et `image/pjpeg`) sont normalisés en `image/jpeg` → le libellé sous la zone affiche `.jpg,.jpeg` au lieu du type brut.
31
+ - **Signature de blob invalide : 401 au lieu de 500** — `find_blob` ne gardait pas le cas où `ActiveStorage::Blob.find_signed` renvoie `nil` (signature invalide ou blob supprimé), ce qui provoquait une erreur 500. La requête est désormais refusée proprement en 401.
32
+ - **`root_path` dans un moteur isolé** — la branche `format.html` (réponse non Turbo/JSON) de la liste de fichiers utilise désormais `main_app.root_path` (repli `"/"`) au lieu de `root_path`, qui levait une `NameError` (500) dans un moteur isolé.
33
+
34
+ ### Changed
35
+ - **Installeur** : message de post-installation mis à jour (jsbundling/bun + importmap, `javascript:build`, autorisation fail-closed, `parent_controller`) ; retrait du template mort `concerns/upload_authorization.rb` (autorisation via le port adaptateur).
36
+
37
+ ### Removed
38
+ - **Helper mort retiré** (`app/helpers/upload_d_n_d/controllers/helpers.rb`, `UploadDND::Controllers::Helpers`) : un duplicata inutilisé de `attach_upload_d_n_d_files` qui attachait sans passer par `AttachmentLookup` ni les contraintes de champ. L'implémentation utilisée reste le concern `UploadDND::Controllers::Uploadable`.
39
+
40
+ ### Migration
41
+ - **Jeton de test** : `UploadDND::FieldReference` est remplacé par `UploadDND::SignedTarget`. Pour poster sur les endpoints du moteur dans un spec, générer le jeton avec `UploadDND::SignedTarget.for(record, :attribut, namespace: nil)` (record persisté ou non) et le passer en `params[:target]`.
42
+ - **Changement de comportement** : un attribut forgé ou non déclaré `uploadable`, ou un record non autorisé, est désormais rejeté **dès le POST** (400 pour un jeton invalide, 403 pour l'autorisation) — et non plus à l'enregistrement.
43
+ - **Policies** : chaque modèle `uploadable` doit avoir une policy (fail-closed). Pour un espace admin, passer `namespace:` au helper (`upload_field :doc, record: @r, namespace: :admin`).
44
+ - **Multi-tenant / auth custom** : si le moteur doit voir `pundit_user`/`current_ability` ou les `before_action` de votre `ApplicationController`, régler `config.parent_controller = "ApplicationController"` dans un initializer.
45
+
10
46
  ## [0.9.4] - 2026-10-29
11
47
 
12
48
  `link_js` copie le JS/CSS dans `node_modules` au lieu d'un lien symbolique, pour que les imports tiers de la gem se résolvent dans l'app (jsbundling/bun/esbuild). Patch, sans changement d'API.
@@ -41,19 +41,11 @@ module UploadDND
41
41
  end
42
42
 
43
43
  def copy_concerns
44
- say "Installation des concerns...", :green
44
+ say "Les concerns (Uploadable, Controllers::Uploadable, FileValidator) " \
45
+ "sont fournis par le runtime de la gem (lib/) — rien à copier.", :green
45
46
 
46
- # Les concerns critiques (Uploadable, Controllers::Uploadable, FileValidator)
47
- # sont désormais fournis par le runtime de la gem (lib/).
48
- # Seul upload_authorization reste un template car il est personnalisable.
49
- say " ✓ UploadDND::Uploadable → fourni par la gem (lib/)", :cyan
50
- say " ✓ UploadDND::Controllers::Uploadable → fourni par la gem (lib/)", :cyan
51
- say " ✓ UploadDND::FileValidator → fourni par la gem (lib/)", :cyan
52
-
53
- template "concerns/upload_authorization.rb",
54
- "app/controllers/concerns/upload_d_n_d/upload_authorization.rb"
55
-
56
- # Nettoyage : supprime les anciennes copies si elles existent (migration depuis v0.3.x)
47
+ # Nettoyage : supprime les anciennes copies si elles existent (migration
48
+ # depuis les versions qui copiaient des concerns dans l'app).
57
49
  remove_stale_overrides
58
50
  end
59
51
 
@@ -132,11 +124,15 @@ module UploadDND
132
124
 
133
125
  # Supprime les fichiers qui étaient copiés par les anciennes versions (< 0.4.0)
134
126
  # et qui shadowent désormais le runtime de la gem.
127
+ # Anciennes copies désormais servies par le runtime de la gem. On n'y met
128
+ # PAS app/validators/file_validator.rb : une app hôte peut légitimement
129
+ # avoir son propre FileValidator, et la gem n'en fournit plus de global.
135
130
  STALE_OVERRIDES = [
136
131
  "app/models/concerns/upload_d_n_d/uploadable.rb",
137
132
  "app/controllers/concerns/upload_d_n_d/controllers/uploadable.rb",
138
- "app/validators/file_validator.rb",
139
- # Fichiers copiés par les versions < 0.5.0 (désormais servis depuis la gem)
133
+ # Concern d'autorisation devenu inutile (autorisation via le port adaptateur).
134
+ "app/controllers/concerns/upload_d_n_d/upload_authorization.rb",
135
+ # Fichiers copiés par les anciennes versions (désormais servis depuis la gem)
140
136
  "app/javascript/controllers/upload_d_n_d/uploads_controller.js",
141
137
  "app/javascript/controllers/upload_d_n_d/preview_controller.js",
142
138
  "app/assets/stylesheets/upload_d_n_d.css",
@@ -1,6 +1,6 @@
1
1
  ===============================================================================
2
2
 
3
- UploadDND v0.5.0 installé avec succès !
3
+ UploadDND installé avec succès !
4
4
 
5
5
  ===============================================================================
6
6
 
@@ -15,48 +15,50 @@ Prochaines étapes :
15
15
  rails active_storage:install
16
16
  rails db:migrate
17
17
 
18
- 3. Ajouter l'import JavaScript dans votre application.js :
18
+ 3. JavaScript — importer le module de la gem dans votre application.js :
19
19
 
20
20
  import "upload_d_n_d"
21
21
 
22
22
  Les contrôleurs Stimulus sont auto-enregistrés depuis la gem.
23
23
 
24
- 4. Ajouter le CSS dans votre layout :
24
+ - jsbundling (esbuild/bun) : la tâche upload_d_n_d:link_js copie le JS de la
25
+ gem dans node_modules/upload_d_n_d ; elle tourne automatiquement sur
26
+ `rails javascript:build` (et `assets:precompile` au déploiement). Après une
27
+ montée de version de la gem, lancer une fois `rails javascript:build` en
28
+ local (un serveur en watch ne déclenche pas la tâche).
29
+ - importmap : les pins de la gem sont ajoutés automatiquement.
25
30
 
26
- <%= stylesheet_link_tag "upload_d_n_d/application", media: "all" %>
31
+ 4. CSS :
27
32
 
28
- 5. (Optionnel) Configurer les policies Pundit :
33
+ - Tailwind / bundler CSS : @import "upload_d_n_d/styles";
34
+ - Sprockets / asset pipeline : <%= stylesheet_link_tag "upload_d_n_d/application", media: "all" %>
29
35
 
30
- rails generate upload_d_n_d:policy
36
+ 5. Autorisation (Pundit ou CanCanCan) — fail-closed :
31
37
 
32
- Ceci génère les deux policies :
33
- - app/policies/upload_d_n_d_temporary_attachment_policy.rb
34
- - app/policies/upload_d_n_d_attachment_policy.rb
38
+ Chaque modèle qui expose un champ `uploadable` doit avoir une policy pour le
39
+ record (ex. ProjectPolicy#update?). Sans policy, les dépôts/suppressions sont
40
+ refusés (403). Pour un espace admin, passez le namespace au helper :
35
41
 
36
- 6. Utiliser le générateur de modèle (recommandé) :
42
+ <%= upload_field :documents, record: @document, namespace: :admin %>
43
+
44
+ ce qui autorise via Admin::DocumentPolicy. Générateur de policies génériques :
45
+
46
+ rails generate upload_d_n_d:policy
47
+
48
+ 6. Déclarer le champ sur le modèle (recommandé via le générateur) :
37
49
 
38
50
  rails g upload_d_n_d:model User --attribute=avatar
39
51
  rails g upload_d_n_d:model Project --attribute=documents --multiple --content_type=pdf docx xlsx
40
52
 
41
- 7. Utiliser dans vos vues :
53
+ 7. Utiliser dans vos vues (un record: est requis, même une instance neuve) :
42
54
 
43
55
  <%= upload_field :avatar, record: @user %>
44
56
 
45
- 8. Architecture v0.5.0 :
46
-
47
- Tout est servi directement depuis la gem :
48
- - UploadDND::Uploadable (model concern)
49
- - UploadDND::Controllers::Uploadable (controller concern)
50
- - UploadDND::FileValidator (file validation)
51
- - Contrôleurs Stimulus (auto-enregistrés via importmap)
52
- - CSS (servi via asset pipeline)
53
- - ViewComponent (servi depuis la gem)
54
-
55
- Un simple `bundle update` suffit pour tout mettre à jour.
57
+ Tout le reste (concerns, FileValidator, contrôleurs Stimulus, CSS, ViewComponent)
58
+ est servi depuis la gem : un `bundle update` suffit pour mettre à jour.
56
59
 
57
- Fichiers personnalisables (copiés dans votre app) :
58
- - app/controllers/concerns/upload_d_n_d/upload_authorization.rb
59
- - app/helpers/upload_d_n_d/upload_helper.rb
60
- - config/initializers/upload_d_n_d.rb
60
+ Fichiers personnalisables copiés dans votre app :
61
+ - app/helpers/upload_d_n_d/upload_helper.rb
62
+ - config/initializers/upload_d_n_d.rb
61
63
 
62
64
  ===============================================================================
@@ -43,9 +43,10 @@ UploadDND.configure do |config|
43
43
  # pour s'en passer. Sans adaptateur utilisable, tout est refusé (403).
44
44
  # config.authorization_adapter = :pundit # ou :cancancan, ou :none
45
45
 
46
- # Validateur personnalisé
47
- # Par défaut, utilise FileValidator si disponible
48
- # config.validator_class = 'FileValidator'
46
+ # Contrôleur parent des routes du moteur (défaut "ActionController::Base").
47
+ # Mettre "ApplicationController" pour hériter de pundit_user / current_ability
48
+ # et des before_action d'authentification/multi-tenant de l'app hôte.
49
+ # config.parent_controller = "ApplicationController"
49
50
 
50
51
  # Configuration du stockage ActiveStorage (optionnel)
51
52
  # Exemple: forcer l'utilisation d'un service spécifique
@@ -21,6 +21,8 @@ module UploadDND
21
21
  :on_upload_error,
22
22
  :on_file_removed,
23
23
  :delete_route,
24
+ :parent_controller,
25
+ :signed_target_expires_in,
24
26
  :rate_limit_enabled,
25
27
  :rate_limit_max_requests,
26
28
  :rate_limit_period
@@ -61,6 +63,16 @@ module UploadDND
61
63
  # de l'app (concern Controllers::Uploadable) — nécessite une route
62
64
  # polymorphique pour le record (cf. README, "Records imbriqués").
63
65
  @delete_route = :engine
66
+ # Contrôleur parent des contrôleurs du moteur. Par défaut ActionController::Base
67
+ # (isolé). Mettre "ApplicationController" pour hériter de l'app hôte :
68
+ # pundit_user, current_ability, before_action d'authentification/multi-tenant.
69
+ # À régler dans un initializer (avant le premier chargement des contrôleurs).
70
+ @parent_controller = "ActionController::Base"
71
+ # Durée de vie des jetons de cible signée (SignedTarget). nil = pas
72
+ # d'expiration. 24h par défaut : assez long pour un onglet de formulaire
73
+ # laissé ouvert, assez court pour limiter la portée d'une fuite de jeton.
74
+ # L'autorisation reste revérifiée à chaque requête quoi qu'il arrive.
75
+ @signed_target_expires_in = 24.hours
64
76
  @rate_limit_enabled = false
65
77
  @rate_limit_max_requests = 10
66
78
  @rate_limit_period = 60 # secondes
@@ -2,9 +2,10 @@
2
2
 
3
3
  # Validateur de fichiers pour ActiveStorage.
4
4
  #
5
- # Rails associe `validates :avatar, file: {...}` à une constante `FileValidator`.
6
- # Pour garder l'API ergonomique, on expose `UploadDND::FileValidator` ET on
7
- # fournit un alias top-level `::FileValidator` si absent.
5
+ # Référencé explicitement par UploadDND::Uploadable#uploadable
6
+ # (`validates_with UploadDND::FileValidator`). On ne définit AUCUN alias
7
+ # top-level `::FileValidator` : il masquerait le validateur d'une app hôte
8
+ # chargé plus tard par Zeitwerk (voir bas de fichier).
8
9
  module UploadDND
9
10
  class FileValidator < ActiveModel::EachValidator
10
11
  def validate_each(record, attribute, value)
@@ -152,5 +153,7 @@ module UploadDND
152
153
  end
153
154
  end
154
155
 
155
- # Alias top-level pour l'API `validates ..., file: ...`
156
- ::FileValidator = UploadDND::FileValidator unless defined?(::FileValidator)
156
+ # Pas d'alias top-level `::FileValidator` : il masquerait silencieusement le
157
+ # FileValidator d'une app hôte (chargé plus tard par Zeitwerk), désactivant ses
158
+ # propres validations. `uploadable` référence `UploadDND::FileValidator`
159
+ # explicitement (voir UploadDND::Uploadable#uploadable).
@@ -37,9 +37,9 @@ module UploadDND
37
37
  :updated
38
38
  elsif File.directory?(dest)
39
39
  return :conflict unless ours?(dest) # dossier étranger : on n'y touche jamais
40
- return :unchanged if same_version?(source, dest)
40
+ return :unchanged if up_to_date?(source, dest)
41
41
 
42
- replace(source, dest) # notre copie, version périmée
42
+ replace(source, dest) # notre copie, contenu périmé
43
43
  :updated
44
44
  elsif File.exist?(dest)
45
45
  :conflict # fichier étranger
@@ -58,11 +58,16 @@ module UploadDND
58
58
  end
59
59
  private_class_method :ours?
60
60
 
61
- def same_version?(source, dest)
62
- read_json(File.join(source, "package.json"))["version"] ==
63
- read_json(File.join(dest, "package.json"))["version"]
61
+ # Compare le CONTENU (pas seulement la version du package.json) : une gem
62
+ # branchée en `path:`/`git:` peut garder la même version tout en changeant le
63
+ # JS. La copie est à jour si chaque fichier attendu existe et est identique.
64
+ def up_to_date?(source, dest)
65
+ package_files(source).all? do |rel|
66
+ target = File.join(dest, rel)
67
+ File.file?(target) && FileUtils.identical?(File.join(source, rel), target)
68
+ end
64
69
  end
65
- private_class_method :same_version?
70
+ private_class_method :up_to_date?
66
71
 
67
72
  def replace(source, dest)
68
73
  FileUtils.remove_entry(dest)
@@ -83,7 +88,7 @@ module UploadDND
83
88
  private_class_method :copy
84
89
 
85
90
  def package_files(source)
86
- exports = read_json(File.join(source, "package.json"))["exports"] || {}
91
+ pkg = read_json(File.join(source, "package.json"))
87
92
  targets = []
88
93
  collect = lambda do |value|
89
94
  case value
@@ -91,7 +96,10 @@ module UploadDND
91
96
  when Hash then value.each_value(&collect)
92
97
  end
93
98
  end
94
- exports.each_value(&collect)
99
+ (pkg["exports"] || {}).each_value(&collect)
100
+ # Le champ `style` de premier niveau (postcss-import / Tailwind 3) n'est pas
101
+ # dans `exports` : on l'ajoute pour copier aussi le styles.css racine.
102
+ targets << pkg["style"] if pkg["style"]
95
103
 
96
104
  files = [ "package.json" ]
97
105
  targets.each do |target|
@@ -34,6 +34,13 @@ module UploadDND
34
34
  (hash[mime] ||= []) << ".#{ext}"
35
35
  end.transform_values(&:freeze).freeze
36
36
 
37
+ # Types MIME non standard mais courants, normalisés vers leur forme standard
38
+ # pour que le libellé affiche des extensions et non le type brut.
39
+ MIME_SYNONYMS = {
40
+ "image/jpg" => "image/jpeg",
41
+ "image/pjpeg" => "image/jpeg"
42
+ }.freeze
43
+
37
44
  # Converts an array of symbols (extensions) and/or MIME strings into MIME strings.
38
45
  #
39
46
  # @param types [Array<Symbol, String>] e.g. [:jpg, :png, "application/pdf"]
@@ -64,6 +71,7 @@ module UploadDND
64
71
  # En dernier recours (type inconnu de la gem ET de Marcel), on garde le type
65
72
  # pour que la contrainte ne disparaisse pas de l'attribut `accept`.
66
73
  def self.extensions_for(mime)
74
+ mime = MIME_SYNONYMS.fetch(mime, mime)
67
75
  return MIME_TO_EXTENSIONS[mime] if MIME_TO_EXTENSIONS.key?(mime)
68
76
 
69
77
  marcel = Marcel::TYPE_EXTS[mime]
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UploadDND
4
+ # Cible d'upload signée : modèle + attribut + (record) + namespace de policy,
5
+ # émise par UploadComponent et vérifiée par les contrôleurs du moteur. Elle
6
+ # remplace les paramètres bruts `record_gid` / `attribute_name` / `namespace`
7
+ # du client : le serveur ne fait plus confiance au client pour désigner la
8
+ # cible ni le namespace d'autorisation.
9
+ #
10
+ # - Permanent (record persisté) : record_id présent → le record est résolu
11
+ # par `model_class.find(record_id)` (sûr, puisque le couple est signé).
12
+ # - Temporaire (record non persisté) : record_id nil.
13
+ #
14
+ # Garde-fou : l'attribut doit être déclaré via `uploadable` sur le modèle
15
+ # (pas seulement un `has_one_attached`/`has_many_attached` quelconque).
16
+ module SignedTarget
17
+ PURPOSE = :upload_d_n_d_target
18
+
19
+ class Invalid < UploadDND::Error; end
20
+
21
+ Target = Data.define(:model_class, :attribute, :record_id, :namespace) do
22
+ # Résout le record signé. Lève ActiveRecord::RecordNotFound s'il a disparu
23
+ # (traduit en 404 par le contrôleur). nil pour une cible temporaire.
24
+ def record
25
+ return nil if record_id.nil?
26
+
27
+ model_class.find(record_id)
28
+ end
29
+
30
+ def constraints
31
+ UploadDND::FieldConstraints.for(model_class, attribute)
32
+ end
33
+ end
34
+
35
+ class << self
36
+ # Construit un jeton directement depuis une instance de record (persistée
37
+ # ou non). Pratique pour les vues, helpers et specs d'apps hôtes :
38
+ # UploadDND::SignedTarget.for(@document, :file, namespace: :admin)
39
+ def for(record, attribute, namespace: nil)
40
+ generate(record.class, attribute, record_id: (record.id if record.persisted?), namespace: namespace)
41
+ end
42
+
43
+ # @param record_id [Object, nil] id du record (nil pour une cible temporaire)
44
+ # @param namespace [Array<Symbol>, nil] namespace de policy
45
+ def generate(model_class, attribute, record_id: nil, namespace: nil)
46
+ payload = [ model_class.name, attribute.to_s, record_id, Array(namespace).map(&:to_s).presence ]
47
+ verifier.generate(payload, purpose: PURPOSE, expires_in: UploadDND.configuration.signed_target_expires_in)
48
+ end
49
+
50
+ # @raise [Invalid] signature fausse, charge malformée, modèle inconnu, ou
51
+ # attribut non déclaré `uploadable`
52
+ # @return [Target]
53
+ def verify(token)
54
+ payload = verifier.verified(token.to_s, purpose: PURPOSE)
55
+ raise Invalid unless payload.is_a?(Array) && payload.size == 4
56
+
57
+ model_name, attribute, record_id, namespace = payload
58
+ model_class = model_name.to_s.safe_constantize
59
+ raise Invalid unless model_class && uploadable?(model_class, attribute)
60
+
61
+ Target.new(
62
+ model_class: model_class,
63
+ attribute: attribute.to_s,
64
+ record_id: record_id,
65
+ namespace: namespace&.map(&:to_sym)
66
+ )
67
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError
68
+ raise Invalid
69
+ end
70
+
71
+ def uploadable?(model_class, attribute)
72
+ model_class.respond_to?(:uploadable_attributes) &&
73
+ model_class.uploadable_attributes.key?(attribute.to_sym)
74
+ end
75
+
76
+ private
77
+
78
+ def verifier
79
+ Rails.application.message_verifier("upload_d_n_d/signed_target")
80
+ end
81
+ end
82
+ end
83
+ end
@@ -52,8 +52,12 @@ module UploadDND
52
52
  end
53
53
  end
54
54
 
55
- validates attribute_name,
56
- file: options.slice(:content_type, :max_size, :max_files, :message, :strict_mime_check, :spoofing_message)
55
+ # On référence UploadDND::FileValidator explicitement (plutôt que la clé
56
+ # `file:`, qui résout un `::FileValidator` global et masquerait le
57
+ # validateur de l'app hôte).
58
+ validates_with UploadDND::FileValidator,
59
+ attributes: [ attribute_name ],
60
+ **options.slice(:content_type, :max_size, :max_files, :message, :strict_mime_check, :spoofing_message)
57
61
 
58
62
  # Stocke les options pour usage ultérieur (ViewComponent, helpers, etc.)
59
63
  (@uploadable_attributes ||= {})[attribute_name] = options
@@ -68,47 +72,10 @@ module UploadDND
68
72
 
69
73
  def attach_temporary_uploads
70
74
  return unless temp_upload_session_id.present?
71
- return unless UploadDND::TemporaryAttachment.exists?(session_id: temp_upload_session_id)
72
75
 
73
- UploadDND::TemporaryAttachment.where(session_id: temp_upload_session_id).find_each do |temp_attachment|
74
- attribute_name = temp_attachment.attribute_name
75
-
76
- # SÉCURITÉ : attribute_name vient du client — seul un attachment déclaré
77
- # sur le modèle est résolu. Détection serveur du type (has_one vs has_many).
78
- attachment = UploadDND::AttachmentLookup.find(self, attribute_name)
79
- unless attachment
80
- # Si l'attribut n'est pas un attachment du record, on nettoie quand même
81
- # pour éviter de laisser des fichiers orphelins en base.
82
- temp_attachment.destroy
83
- next
84
- end
85
-
86
- blob_attachment = temp_attachment.blob_attachment
87
- if blob_attachment.nil? || temp_attachment.violates_constraints_of?(self, attribute_name)
88
- temp_attachment.destroy
89
- next
90
- end
91
-
92
- if attachment.is_a?(ActiveStorage::Attached::Many)
93
- # has_many_attached: on "déplace" l'attachment (pas de purge du blob)
94
- blob_attachment.update!(record: self, name: attribute_name)
95
- elsif attachment.is_a?(ActiveStorage::Attached::One)
96
- # has_one_attached: utiliser = (remplace automatiquement)
97
- # Ne transférer que le PREMIER fichier temporaire si plusieurs existent
98
- if attachment.attached?
99
- # Le record a déjà un fichier: on purgera le temporaire.
100
- temp_attachment.destroy
101
- next
102
- end
103
-
104
- blob_attachment.update!(record: self, name: attribute_name)
105
- end
106
-
107
- # Après déplacement, temp_attachment n'a plus d'attachment ActiveStorage.
108
- # On reload pour éviter les caches d'association avant destroy.
109
- temp_attachment.reload
110
- temp_attachment.destroy
111
- end
76
+ # Délègue au transfert durci (restreint aux champs `uploadable`, applique
77
+ # max_files et les contraintes du champ au transfert — cf. F3/F4).
78
+ UploadDND::TemporaryAttachment.attach_to_record(temp_upload_session_id, self)
112
79
 
113
80
  # Nettoie le session_id après utilisation
114
81
  @_temp_upload_session_id = nil
@@ -1,3 +1,3 @@
1
1
  module UploadDND
2
- VERSION = "0.9.4"
2
+ VERSION = "0.10.0"
3
3
  end
data/lib/upload_d_n_d.rb CHANGED
@@ -25,7 +25,7 @@ end
25
25
 
26
26
  require "upload_d_n_d/authorization"
27
27
  require "upload_d_n_d/field_constraints"
28
- require "upload_d_n_d/field_reference"
28
+ require "upload_d_n_d/signed_target"
29
29
 
30
30
  module UploadDND
31
31
  # Autoload des concerns, helpers et autres composants
data/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "upload_d_n_d",
3
- "version": "0.9.4",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "main": "app/javascript/upload_d_n_d/index.js",
6
+ "style": "./styles.css",
6
7
  "exports": {
7
8
  ".": "./app/javascript/upload_d_n_d/index.js",
8
9
  "./controllers/*": "./app/javascript/upload_d_n_d/controllers/*.js",
9
10
  "./styles": {
10
- "style": "./app/assets/stylesheets/upload_d_n_d/application.css"
11
+ "style": "./app/assets/stylesheets/upload_d_n_d/application.css",
12
+ "default": "./app/assets/stylesheets/upload_d_n_d/application.css"
11
13
  }
12
14
  },
13
15
  "dependencies": {
@@ -16,4 +18,4 @@
16
18
  "peerDependencies": {
17
19
  "@hotwired/stimulus": "^3.0.0"
18
20
  }
19
- }
21
+ }
data/styles.css ADDED
@@ -0,0 +1,4 @@
1
+ /* Point d'entrée CSS pour les bundlers (Tailwind 3/postcss-import, esbuild, bun).
2
+ @import "upload_d_n_d/styles"; → résout ce fichier, qui ré-importe le CSS
3
+ réel (source unique, servie aussi par Sprockets via app/assets). */
4
+ @import "./app/assets/stylesheets/upload_d_n_d/application.css";