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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 89eeed42abead646dab758d0391d98faeb9e2387f5e716a0fd9004e36448fa99
4
+ data.tar.gz: 349ff62605b1ae15fe685bd28882a07a3a459a5e5d47e0c34581eb4aef01e46c
5
+ SHA512:
6
+ metadata.gz: bcb751f061b219670240f8ffcbe0ebd1a7b21034668b299d335b2511dfd67f570554ce6a66e4ad918e4a11e2ee53e7e5744f7c53e8578e5f604a86d274286665
7
+ data.tar.gz: b86e190d252c90ebc9a99622bd20d81b05957d37fea6e6c42d521a8036e9c787bf11ab9645e59ac98c45bed8393a024f7cff5efbfa4fa310650e25fc1862ac07
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2025-2026 Pierre Cherbero
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # UploadDND
2
+
3
+ [![CI](https://github.com/Peyochanchan/upload_d_n_d/actions/workflows/ci.yml/badge.svg)](https://github.com/Peyochanchan/upload_d_n_d/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT-LICENSE)
5
+ ![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-red)
6
+ ![Rails](https://img.shields.io/badge/rails-7.2%20%7C%208.0%20%7C%208.1-red)
7
+
8
+ 🇫🇷 [Version française](README.fr.md)
9
+
10
+ Secure drag & drop file uploads for Rails, built on ActiveStorage, Stimulus and ViewComponent.
11
+
12
+ One line in a view gives you a drop zone with progress, previews and removal — while every file is checked server-side against the limits declared on the model, and every request goes through your authorization layer, fail-closed.
13
+
14
+ ```erb
15
+ <%= upload_field :documents, record: @project %>
16
+ ```
17
+
18
+ ## Why
19
+
20
+ ActiveStorage handles storage; it does not handle the hard parts of a production upload flow:
21
+
22
+ - **Trusting the browser.** `accept` attributes and JS size checks are UX, not security. UploadDND re-applies the model's limits on the server as soon as the file lands, and optionally sniffs the real content type (Marcel) to catch spoofed extensions.
23
+ - **Uploading before the record exists.** Creation forms need files before `save`. Temporary uploads are scoped to the session, survive a page reload, and are re-validated when wired to the record.
24
+ - **Authorization gaps.** Upload endpoints are easy to leave open. Here every upload and deletion goes through Pundit or CanCanCan; with no usable adapter, everything is refused (403). Disabling it (`:none`) is explicit and reported at boot.
25
+ - **Storage garbage.** DirectUpload puts the file in your bucket before anyone validates it. Rejected blobs are purged in the background, and an opt-in job removes orphan blobs — without touching files still referenced in stored HTML.
26
+
27
+ ## Features
28
+
29
+ - Drag & drop with progress bar, image and PDF previews
30
+ - `has_one_attached` and `has_many_attached`, with `max_files`
31
+ - Server-side validation: allowlisted MIME types, size, count, optional anti-spoofing
32
+ - Temporary uploads for creation forms, persisted across reloads
33
+ - Fail-closed authorization (Pundit, CanCanCan, or explicit opt-out)
34
+ - One file, one owner: an already-attached `signed_id` can't be re-attached elsewhere
35
+ - Rate limiting on the engine endpoints
36
+ - Cleanup jobs for expired temporary files, rejected and orphan blobs
37
+ - Works in any namespace (`Admin::`, `Manager::`…), no routes required in the host app
38
+ - Generators for install, models and Pundit policies
39
+ - I18n (EN/FR) server messages
40
+
41
+ ## Installation
42
+
43
+ ```ruby
44
+ # Gemfile
45
+ gem "upload_d_n_d", github: "Peyochanchan/upload_d_n_d"
46
+ ```
47
+
48
+ ```bash
49
+ bundle install
50
+ bin/rails generate upload_d_n_d:install
51
+ bin/rails db:migrate
52
+ ```
53
+
54
+ The installer adds `view_component` and `stimulus-rails` if missing, creates the temporary uploads migration, mounts the engine, writes the initializer and, if Pundit is present, the policies.
55
+
56
+ Everything else (JS, CSS, components, concerns) is served from the gem: upgrading is a `bundle update`.
57
+
58
+ ActiveStorage must be installed (`bin/rails active_storage:install`). With an external S3-like bucket and a strict CSP, serve files same-origin:
59
+
60
+ ```ruby
61
+ config.active_storage.resolve_model_to_route = :rails_storage_proxy
62
+ ```
63
+
64
+ ## Usage
65
+
66
+ ### Declare the field on the model
67
+
68
+ ```ruby
69
+ class Project < ApplicationRecord
70
+ include UploadDND::Uploadable
71
+
72
+ uploadable :cover, # declares has_one_attached
73
+ accept: [:jpg, :png, :webp],
74
+ max_size: 5.megabytes,
75
+ strict_mime_check: true
76
+
77
+ uploadable :documents, # declares has_many_attached
78
+ accept: [:pdf, :docx],
79
+ max_size: 20.megabytes,
80
+ multiple: true,
81
+ max_files: 10
82
+ end
83
+ ```
84
+
85
+ The model is the source of truth: the same limits drive the browser checks and the server checks. Options passed to the view helper can only narrow them.
86
+
87
+ `strict_mime_check: true` sniffs the file's real bytes (Marcel) to catch a spoofed extension. It works both ways: with the gem's dropzone (DirectUpload — the content is read back from storage, where the file already sits) and with a plain Rails `file_field` (the file rides along with the form — the content is read from the pending upload, before it reaches storage). Either way the check runs during model validation, so a spoofed file is never persisted.
88
+
89
+ Or let the generator print the snippets for you:
90
+
91
+ ```bash
92
+ bin/rails g upload_d_n_d:model Project --attribute=documents --multiple --content_type=pdf docx --max_size=20
93
+ ```
94
+
95
+ ### Use it in a form
96
+
97
+ ```erb
98
+ <%= form_with model: @project do |f| %>
99
+ <%= upload_session_field %> <%# only needed for new records %>
100
+
101
+ <%= f.text_field :name %>
102
+ <%= upload_field :cover, record: @project %>
103
+ <%= upload_field :documents, record: @project %>
104
+
105
+ <%= f.submit %>
106
+ <% end %>
107
+ ```
108
+
109
+ For a new record, files are stored temporarily and attached on save:
110
+
111
+ ```ruby
112
+ class ProjectsController < ApplicationController
113
+ include UploadDND::Controllers::Uploadable
114
+
115
+ def create
116
+ @project = Project.new(project_params)
117
+ wire_temporary_uploads(@project) # checks the session, re-validates each file
118
+
119
+ if @project.save
120
+ redirect_to @project
121
+ else
122
+ render :new, status: :unprocessable_entity
123
+ end
124
+ end
125
+ end
126
+ ```
127
+
128
+ ### Authorize
129
+
130
+ ```ruby
131
+ # Pundit — uploads and deletions on a record require update?
132
+ class ProjectPolicy < ApplicationPolicy
133
+ def update?
134
+ record.members.include?(user)
135
+ end
136
+ end
137
+ ```
138
+
139
+ ```ruby
140
+ # CanCanCan
141
+ can :create, :upload_d_n_d_attachment
142
+ can [:create, :destroy], :upload_d_n_d_temporary_attachment
143
+ can :update, Project, members: { id: user.id }
144
+ ```
145
+
146
+ `bin/rails g upload_d_n_d:policy` generates the gem's generic Pundit policies. The full rule matrix is in [docs/AUTHORIZATION.md](docs/AUTHORIZATION.md).
147
+
148
+ ## Security model
149
+
150
+ | Layer | What it does |
151
+ |---|---|
152
+ | Content-type allowlist | Only declared types are accepted |
153
+ | Anti-spoofing | `strict_mime_check: true` inspects the actual bytes |
154
+ | Server-side limits | Size, type and count enforced on upload, again when temporary files are wired |
155
+ | Fail-closed authorization | No adapter → 403; `:none` must be explicit |
156
+ | Session isolation | Temporary files only visible to the session that uploaded them |
157
+ | Ownership | An attached blob can't be claimed by another record |
158
+ | Rate limiting | Per user + IP on the engine endpoints (`Rails.cache` backed) |
159
+ | Rejected blob purge | Background purge, never for attached blobs or unauthorized requests |
160
+
161
+ ActiveStorage's own `POST /rails/active_storage/direct_uploads` belongs to your app, not the gem: throttle it too, for example with [rack-attack](https://github.com/rack/rack-attack).
162
+
163
+ ## Maintenance jobs
164
+
165
+ ```yaml
166
+ # config/recurring.yml (solid_queue)
167
+ production:
168
+ cleanup_temporary_uploads:
169
+ class: UploadDND::CleanupTemporaryAttachmentsJob
170
+ schedule: every day at 2am
171
+ purge_orphan_blobs:
172
+ class: UploadDND::PurgeOrphanBlobsJob
173
+ args: [{ "apply": true }]
174
+ schedule: every sunday at 3am
175
+ ```
176
+
177
+ The orphan purge is opt-in and conservative: a blob is removed only if it has no attachment, is older than `orphan_blob_min_age`, and isn't referenced anywhere in the database's text and JSON columns (bucket key, `signed_id` or Action Text sgid). Dry-run first:
178
+
179
+ ```bash
180
+ bin/rails upload_d_n_d:purge_orphans # report only
181
+ APPLY=1 bin/rails upload_d_n_d:purge_orphans # delete
182
+ ```
183
+
184
+ PostgreSQL is required for the reference scan; on other databases the job raises instead of deleting blindly.
185
+
186
+ ## Configuration
187
+
188
+ ```ruby
189
+ # config/initializers/upload_d_n_d.rb
190
+ UploadDND.configure do |config|
191
+ config.max_size = 10.megabytes
192
+ config.accepted_types = ".pdf,.jpg,.jpeg,.png"
193
+ config.main_color = "#E97451"
194
+ config.temporary_files_retention = 7 # days
195
+
196
+ # :pundit / :cancancan are auto-detected; :none disables authorization
197
+ # config.authorization_adapter = :pundit
198
+
199
+ config.rate_limit_enabled = true
200
+ config.rate_limit_max_requests = 30 # one request per file
201
+ config.rate_limit_period = 60
202
+
203
+ config.on_upload_success = ->(blob, user) { AuditLog.record(:upload, blob, user) }
204
+ config.on_file_removed = ->(record, attribute_name, user) { AuditLog.record(:removal, record, user) }
205
+ end
206
+ ```
207
+
208
+ The French README covers every option in detail: nested and singular resources, custom delete routes, cancelling temporary uploads, styling.
209
+
210
+ ## Development
211
+
212
+ ```bash
213
+ bundle install
214
+ bundle exec rspec
215
+ bin/rubocop
216
+ ```
217
+
218
+ The suite runs against a dummy app on PostgreSQL. CI runs RuboCop and the spec suite.
219
+
220
+ ## Docs
221
+
222
+ - [Authorization](docs/AUTHORIZATION.md)
223
+ - [Generator examples](docs/GENERATOR_EXAMPLES.md)
224
+ - [Migrating to 0.5](docs/MIGRATION_0.5.md)
225
+ - [Changelog](docs/CHANGELOG.md)
226
+
227
+ ## License
228
+
229
+ Released under the [MIT License](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,3 @@
1
+ //= link_directory ../stylesheets/upload_d_n_d .css
2
+ //= link upload_d_n_d/index.js
3
+ //= link_tree ../../javascript/upload_d_n_d/controllers .js