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.
- checksums.yaml +4 -4
- data/README.md +13 -0
- data/app/components/upload_d_n_d/upload_component.html.erb +2 -4
- data/app/components/upload_d_n_d/upload_component.rb +19 -14
- data/app/controllers/upload_d_n_d/attachments_controller.rb +38 -133
- data/app/controllers/upload_d_n_d/base_attachments_controller.rb +62 -22
- data/app/controllers/upload_d_n_d/temporary_attachments_controller.rb +13 -20
- data/app/helpers/upload_d_n_d/application_helper.rb +5 -6
- data/app/javascript/upload_d_n_d/controllers/uploads_controller.js +4 -15
- data/app/models/upload_d_n_d/temporary_attachment.rb +49 -11
- data/app/views/upload_d_n_d/shared/_files_list.html.erb +1 -1
- data/docs/CHANGELOG.md +36 -0
- data/lib/generators/upload_d_n_d/install_generator.rb +10 -14
- data/lib/generators/upload_d_n_d/templates/POST_INSTALL +28 -26
- data/lib/generators/upload_d_n_d/templates/initializers/upload_d_n_d.rb +4 -3
- data/lib/upload_d_n_d/configuration.rb +12 -0
- data/lib/upload_d_n_d/file_validator.rb +8 -5
- data/lib/upload_d_n_d/js_link.rb +16 -8
- data/lib/upload_d_n_d/mime_types.rb +8 -0
- data/lib/upload_d_n_d/signed_target.rb +83 -0
- data/lib/upload_d_n_d/uploadable.rb +9 -42
- data/lib/upload_d_n_d/version.rb +1 -1
- data/lib/upload_d_n_d.rb +1 -1
- data/package.json +5 -3
- data/styles.css +4 -0
- metadata +3 -4
- data/app/helpers/upload_d_n_d/controllers/helpers.rb +0 -21
- data/lib/generators/upload_d_n_d/templates/concerns/upload_authorization.rb +0 -91
- data/lib/upload_d_n_d/field_reference.rb +0 -44
|
@@ -47,27 +47,46 @@ module UploadDND
|
|
|
47
47
|
# @user,
|
|
48
48
|
# { 'avatar' => :avatar, 'documents' => :documents }
|
|
49
49
|
# )
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
target_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
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
unless
|
|
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 "
|
|
44
|
+
say "Les concerns (Uploadable, Controllers::Uploadable, FileValidator) " \
|
|
45
|
+
"sont fournis par le runtime de la gem (lib/) — rien à copier.", :green
|
|
45
46
|
|
|
46
|
-
#
|
|
47
|
-
#
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
4. CSS :
|
|
27
32
|
|
|
28
|
-
|
|
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
|
-
|
|
36
|
+
5. Autorisation (Pundit ou CanCanCan) — fail-closed :
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
156
|
-
|
|
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).
|
data/lib/upload_d_n_d/js_link.rb
CHANGED
|
@@ -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
|
|
40
|
+
return :unchanged if up_to_date?(source, dest)
|
|
41
41
|
|
|
42
|
-
replace(source, dest) # notre copie,
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
data/lib/upload_d_n_d/version.rb
CHANGED
data/lib/upload_d_n_d.rb
CHANGED
data/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "upload_d_n_d",
|
|
3
|
-
"version": "0.
|
|
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";
|