file_hutch 0.1.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 +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +288 -0
- data/app/assets/javascripts/file_hutch/direct_upload_controller.js +129 -0
- data/app/controllers/file_hutch/direct_uploads_controller.rb +60 -0
- data/config/routes.rb +7 -0
- data/exe/file_hutch +6 -0
- data/lib/file_hutch/cli.rb +232 -0
- data/lib/file_hutch/client.rb +255 -0
- data/lib/file_hutch/configuration.rb +33 -0
- data/lib/file_hutch/errors.rb +96 -0
- data/lib/file_hutch/file.rb +57 -0
- data/lib/file_hutch/project.rb +49 -0
- data/lib/file_hutch/rails/attachable.rb +184 -0
- data/lib/file_hutch/rails/engine.rb +28 -0
- data/lib/file_hutch/rails.rb +5 -0
- data/lib/file_hutch/resource.rb +44 -0
- data/lib/file_hutch/upload.rb +28 -0
- data/lib/file_hutch/version.rb +5 -0
- data/lib/file_hutch/webhook.rb +52 -0
- data/lib/file_hutch.rb +63 -0
- data/lib/generators/file_hutch/attachment/attachment_generator.rb +30 -0
- data/lib/generators/file_hutch/attachment/templates/migration.rb.tt +6 -0
- data/lib/generators/file_hutch/install/install_generator.rb +34 -0
- data/lib/generators/file_hutch/install/templates/initializer.rb +18 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e2b2054f9f101d16f7ea61d375dfcbb9715838b5988c517bd547da595759f454
|
|
4
|
+
data.tar.gz: b81dc343e6ee21e17d396502b0700ff7e9ed253e5729f928eca28673248c2801
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 48a44a32ed534d38c731ab9cb9be31c856b635a642035ae5942cdcfffca73743c973f8fe11cb7a2dcccf7206e503b650d1997dd98c17f85dbcee705aebb20adc
|
|
7
|
+
data.tar.gz: 13cfab0ce82a0afa0594818e1508b994ee066790319fb621a18c7bc3033bc787a2a1afa22699c29a92bc1e783b101a1bb9e7bb9ae0e6e08465c813286d18a98a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-09-15
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
### Client
|
|
8
|
+
|
|
9
|
+
- `FileHutch::Client`: project, uploads (create, complete, and a one-call `upload` that streams
|
|
10
|
+
straight to storage), files, signed URLs, named transforms, delete.
|
|
11
|
+
- Typed errors mapped from the API's `error.code`, so callers match on a class or a code rather
|
|
12
|
+
than a message.
|
|
13
|
+
|
|
14
|
+
### Rails
|
|
15
|
+
|
|
16
|
+
- `has_file_hutch_file` for Active Record. One `<name>_file_id` column; nothing about storage
|
|
17
|
+
reaches your schema.
|
|
18
|
+
- `FileHutch::Engine` mounts the two direct-upload endpoints, keeping the API key on the server
|
|
19
|
+
behind an authorizer you define.
|
|
20
|
+
- A Stimulus controller and a framework-neutral `directUpload` function.
|
|
21
|
+
- `file_hutch:install` and `file_hutch:attachment` generators.
|
|
22
|
+
|
|
23
|
+
### Image transforms
|
|
24
|
+
|
|
25
|
+
- Transforms are named in the FileHutch dashboard; your code only ever says the name.
|
|
26
|
+
`file.transforms`, `file.transform_url("avatar")`, `user.avatar_transform_url("thumb")`,
|
|
27
|
+
`client.transforms`, `project.transform("avatar")`.
|
|
28
|
+
- `TransformsUnsupportedError` carries the message naming what to set up, rather than handing back
|
|
29
|
+
a URL that would 404.
|
|
30
|
+
|
|
31
|
+
### Fixed before release, by dogfooding against a live server
|
|
32
|
+
|
|
33
|
+
- The engine authorized `complete` with no policy at all, so every authorizer that checked one —
|
|
34
|
+
including the example in this README — rejected every completion. The policy is now looked up
|
|
35
|
+
from the file being finalized, never taken from the client.
|
|
36
|
+
- `put_to_storage` could not accept an upload rebuilt from JSON, as a browser flow returns it,
|
|
37
|
+
because it asked for a filename such an upload does not carry.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andy Leverenz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# file_hutch
|
|
2
|
+
|
|
3
|
+
Ruby and Rails client for [FileHutch](https://filehutch.com), file infrastructure for apps that
|
|
4
|
+
aren't Netflix. Your app persists an opaque file id (`file_…`). FileHutch owns uploads, private
|
|
5
|
+
files, signed URLs, and delivery. Your storage, or FileHutch's, sits behind it.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
file = FileHutch.upload("report.pdf", policy: "documents") # bytes go straight to storage
|
|
9
|
+
file.id # => "file_8fK2…" ← the only thing you store
|
|
10
|
+
file.signed_url(expires_in: 600)
|
|
11
|
+
FileHutch::File.find(file.id).delete
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Stdlib only at runtime. Rails integration switches on when Rails is present.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "file_hutch"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bin/rails generate file_hutch:install # initializer, mounts the engine, importmap pin
|
|
24
|
+
export FILE_HUTCH_API_KEY=fh_… # Dashboard → API keys (project-scoped)
|
|
25
|
+
export FILE_HUTCH_URL=https://… # only when not using FileHutch cloud
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Without Rails: `FileHutch.configure { |c| c.api_key = "fh_…" }`.
|
|
29
|
+
|
|
30
|
+
## Client
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
client = FileHutch.client # or FileHutch::Client.new(api_key:, url:)
|
|
34
|
+
|
|
35
|
+
client.project # => FileHutch::Project (storage status, policies)
|
|
36
|
+
client.upload(path_or_io, policy: "documents", metadata: { order_id: "ord_1" })
|
|
37
|
+
|
|
38
|
+
# An MD5 goes with the request, so FileHutch refuses the upload if what arrives is
|
|
39
|
+
# not what left. It is streamed, so the file is never held in memory to digest it.
|
|
40
|
+
# Pass verify: false to skip it, and only the byte count is checked.
|
|
41
|
+
client.file("file_…") # => FileHutch::File
|
|
42
|
+
client.signed_url("file_…", expires_in: 3600, disposition: "attachment") # => SignedUrl(url, expires_at)
|
|
43
|
+
client.transforms # => [FileHutch::Transform] (avatar, thumb, hero…)
|
|
44
|
+
client.transform_url("file_…", transform: "avatar", expires_in: 600) # => SignedUrl (expires_at nil if public)
|
|
45
|
+
client.delete_file("file_…") # => true; the id then reads as status "deleted"
|
|
46
|
+
|
|
47
|
+
# The three steps, explicit:
|
|
48
|
+
upload = client.create_upload(policy: "avatars", filename: "me.png", content_type: "image/png", byte_size: bytes.bytesize)
|
|
49
|
+
upload.put(bytes).complete # => ready FileHutch::File
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`upload` accepts a path, `Pathname`, `File`, `Tempfile`, `StringIO`, an
|
|
53
|
+
`ActionDispatch::Http::UploadedFile`, or raw bytes with `filename:`. Content type comes from the
|
|
54
|
+
source, then Marcel if loaded, then the extension.
|
|
55
|
+
|
|
56
|
+
`FileHutch::File`: `id filename content_type byte_size checksum visibility status metadata policy
|
|
57
|
+
url transforms created_at`, plus `ready? pending? failed? deleted? public? private? image? pdf?`,
|
|
58
|
+
`signed_url`, `url_or_signed_url`, `transform_url`, `reload`, `delete`.
|
|
59
|
+
|
|
60
|
+
### Image transforms
|
|
61
|
+
|
|
62
|
+
Transforms are **named** in the FileHutch dashboard — `avatar`, `thumb`, `hero` — and your code
|
|
63
|
+
only ever says the name. No width, no format, no provider URL syntax, so resizing every avatar in
|
|
64
|
+
your app is one dashboard edit.
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
file.transforms # => {"avatar" => "https://…", "thumb" => "https://…"}
|
|
68
|
+
file.transform_url("avatar") # public images: free, the URL is already on the payload
|
|
69
|
+
file.transform_url("avatar", expires_in: 600) # private images: signed, one request
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`FileHutch::Transform` (`client.transforms`, `project.transform("avatar")`) carries `name width
|
|
73
|
+
height fit quality format` so you can render a `srcset` or a picture element from the definitions.
|
|
74
|
+
|
|
75
|
+
Rendering depends on the project's storage. Where it cannot be done, you get a
|
|
76
|
+
`TransformsUnsupportedError` whose message says what to set up — never a URL that 404s.
|
|
77
|
+
|
|
78
|
+
### Errors
|
|
79
|
+
|
|
80
|
+
Every failure is an `FileHutch::Error`. API errors carry `code`, `status`, and `details`.
|
|
81
|
+
|
|
82
|
+
| Class | When |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `ConfigurationError` | no API key / URL |
|
|
85
|
+
| `ConnectionError` | timeout, DNS, reset |
|
|
86
|
+
| `AuthenticationError` | 401 |
|
|
87
|
+
| `NotFoundError` | unknown id |
|
|
88
|
+
| `InvalidRequestError` → `PolicyError` | bad params; content type or size the policy refuses |
|
|
89
|
+
| `StorageNotReadyError` | project has no verified storage |
|
|
90
|
+
| `InvalidStateError` | not ready, already deleted, not public |
|
|
91
|
+
| `PlanLimitError` | 402: the team is out of storage or projects on its plan |
|
|
92
|
+
| `PermissionError` | 403 / `read_only_key`: the key is read-only and this changes something |
|
|
93
|
+
| `ConfigError` | `invalid_config`: the config file has an unknown key, bad size or bad name |
|
|
94
|
+
| `TransformError` → `TransformsUnsupportedError` | unknown transform or non-image; storage that cannot render |
|
|
95
|
+
| `UploadError` | storage rejected the PUT, upload expired or incomplete, size mismatch |
|
|
96
|
+
| `StorageError` | FileHutch could not reach the bucket |
|
|
97
|
+
| `RateLimitError`, `ServerError` | 429, 5xx |
|
|
98
|
+
|
|
99
|
+
## Command line
|
|
100
|
+
|
|
101
|
+
The gem ships `file_hutch`. It reads `FILE_HUTCH_API_KEY` and `FILE_HUTCH_URL`.
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
file_hutch export > file_hutch.yml # the project as a file
|
|
105
|
+
file_hutch plan # what apply would change (a read-only key is enough)
|
|
106
|
+
file_hutch apply # make the project match the file
|
|
107
|
+
file_hutch apply --prune # also delete what the file leaves out; asks first
|
|
108
|
+
file_hutch inspect # project, environment, storage, plan and usage
|
|
109
|
+
file_hutch upload report.pdf --policy documents
|
|
110
|
+
file_hutch manifest > files.jsonl # every ready file with its object key
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
# file_hutch.yml
|
|
115
|
+
uploads:
|
|
116
|
+
avatars: { types: [image/jpeg, image/png, image/webp], max_size: 10MB, visibility: public }
|
|
117
|
+
documents: { types: [application/pdf], max_size: 25MB, visibility: private }
|
|
118
|
+
transforms:
|
|
119
|
+
avatar: { width: 256, height: 256, fit: cover, format: auto }
|
|
120
|
+
environments: [staging]
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Unknown keys are refused. Nothing is deleted without `--prune`. Hand a coding agent a
|
|
124
|
+
read-only key and it can `plan`; give it a write key when you like the plan.
|
|
125
|
+
|
|
126
|
+
## Webhooks
|
|
127
|
+
|
|
128
|
+
FileHutch signs every delivery: `FileHutch-Signature: t=<unix>,v1=<hex>`
|
|
129
|
+
where `v1 = HMAC-SHA256(secret, "<t>.<body>")`. Verify before trusting the
|
|
130
|
+
body, and deduplicate on the event `id` (deliveries are at-least-once):
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
class FileHutchWebhooksController < ActionController::API
|
|
134
|
+
def create
|
|
135
|
+
event = FileHutch::Webhook.construct_event(
|
|
136
|
+
request.raw_post, request.headers["FileHutch-Signature"], ENV.fetch("FILE_HUTCH_WEBHOOK_SECRET")
|
|
137
|
+
)
|
|
138
|
+
case event["type"]
|
|
139
|
+
when "file.created" then Document.find_by(file_hutch_file_id: event.dig("data", "file", "id"))&.update!(ready: true)
|
|
140
|
+
when "file.deleted" then Document.where(file_hutch_file_id: event.dig("data", "file", "id")).destroy_all
|
|
141
|
+
end
|
|
142
|
+
head :ok
|
|
143
|
+
rescue FileHutch::SignatureVerificationError
|
|
144
|
+
head :bad_request
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`construct_event` rejects signatures older than five minutes; pass
|
|
150
|
+
`tolerance:` to change that.
|
|
151
|
+
|
|
152
|
+
## Rails
|
|
153
|
+
|
|
154
|
+
### Model
|
|
155
|
+
|
|
156
|
+
One string column per attachment. Nothing about storage lands in your schema.
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
bin/rails generate file_hutch:attachment User avatar # adds users.avatar_file_id
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
class User < ApplicationRecord
|
|
164
|
+
has_file_hutch_file :avatar, policy: "avatars"
|
|
165
|
+
has_file_hutch_file :contract, policy: "documents", dependent: false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
user.avatar = params[:avatar] # uploaded IO → uploaded to storage on save
|
|
169
|
+
user.avatar = "file_…" # id from a browser direct upload → verified on save
|
|
170
|
+
user.avatar # => FileHutch::File or nil (fetched lazily, cached)
|
|
171
|
+
user.avatar? # id present
|
|
172
|
+
user.avatar_url # public URL (public policies only)
|
|
173
|
+
user.avatar_signed_url(expires_in: 600) # any file
|
|
174
|
+
user.avatar_transform_url("thumb") # a named transform
|
|
175
|
+
user.purge_avatar # delete remotely, clear the column
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Options: `column:` (default `<name>_file_id`), `dependent: :delete` (default; delete the file when
|
|
179
|
+
the record is destroyed or the attachment is replaced) or `false`, `verify: true` (default; an
|
|
180
|
+
id assigned from a form must be a ready file uploaded under this policy).
|
|
181
|
+
|
|
182
|
+
Staged uploads are checked against the policy locally before save, so a wrong content type or an
|
|
183
|
+
oversized file becomes a validation error, not a round trip.
|
|
184
|
+
|
|
185
|
+
### Browser-direct uploads
|
|
186
|
+
|
|
187
|
+
The browser talks to your app for the two control-plane calls (your app holds the API key) and
|
|
188
|
+
PUTs the bytes straight to storage.
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
# config/routes.rb (the install generator adds this)
|
|
192
|
+
mount FileHutch::Engine => "/file_hutch"
|
|
193
|
+
|
|
194
|
+
# config/initializers/file_hutch.rb — closed until you say who may upload
|
|
195
|
+
FileHutch.config.authorize_direct_upload = ->(controller, policy) do
|
|
196
|
+
controller.current_user.present? && %w[avatars documents].include?(policy)
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The authorizer runs on both calls. On the first, `policy` is the one being requested. On the
|
|
201
|
+
second there is no policy in the request, so it is **looked up from the file being finalized** —
|
|
202
|
+
never taken from the client, which could otherwise name a policy it likes to finish an upload made
|
|
203
|
+
under one it may not use. An authorizer that only checks the user (`->(controller) { … }`) skips
|
|
204
|
+
that lookup, and so costs nothing extra.
|
|
205
|
+
|
|
206
|
+
Register the Stimulus controller (importmap users get the pin from the generator; jsbundling users
|
|
207
|
+
copy `app/assets/javascripts/file_hutch/direct_upload_controller.js`):
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
import DirectUploadController from "file_hutch/direct_upload_controller"
|
|
211
|
+
application.register("filehutch-direct-upload", DirectUploadController)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```erb
|
|
215
|
+
<%= form_with model: @user do |f| %>
|
|
216
|
+
<div data-controller="filehutch-direct-upload" data-filehutch-direct-upload-policy-value="avatars">
|
|
217
|
+
<input type="file" accept="image/*" data-action="filehutch-direct-upload#upload">
|
|
218
|
+
<%= f.hidden_field :avatar_file_id, data: { file_hutch_direct_upload_target: "fileId" } %>
|
|
219
|
+
<progress value="0" max="100" hidden data-filehutch-direct-upload-target="progress"></progress>
|
|
220
|
+
<p data-filehutch-direct-upload-target="status"></p>
|
|
221
|
+
</div>
|
|
222
|
+
<%= f.submit %>
|
|
223
|
+
<% end %>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Submit buttons are disabled while uploading. The element dispatches `filehutch:start`,
|
|
227
|
+
`filehutch:progress`, `filehutch:complete`, and `filehutch:error`. `directUpload(file, { url,
|
|
228
|
+
policy, onProgress })` is exported for use without Stimulus.
|
|
229
|
+
|
|
230
|
+
On save, `has_file_hutch_file` verifies the submitted id is a ready file under the declared policy,
|
|
231
|
+
so a client cannot attach someone else's upload to the wrong field.
|
|
232
|
+
|
|
233
|
+
### Coming from Active Storage
|
|
234
|
+
|
|
235
|
+
| Active Storage | file_hutch |
|
|
236
|
+
| --- | --- |
|
|
237
|
+
| `has_one_attached :avatar` | `has_file_hutch_file :avatar, policy: "avatars"` |
|
|
238
|
+
| `active_storage_blobs` + `attachments` tables | `users.avatar_file_id` |
|
|
239
|
+
| `url_for(user.avatar)` | `user.avatar_url` / `user.avatar_signed_url` |
|
|
240
|
+
| `user.avatar.variant(resize_to_fill: [200, 200])` | `user.avatar_transform_url("avatar")`, defined once in the dashboard |
|
|
241
|
+
| `user.avatar.purge` | `user.purge_avatar` |
|
|
242
|
+
| `DirectUpload` JS | `file_hutch/direct_upload_controller` |
|
|
243
|
+
| service.yml, CORS, signed URL code | policies in the FileHutch dashboard |
|
|
244
|
+
|
|
245
|
+
## Development
|
|
246
|
+
|
|
247
|
+
```sh
|
|
248
|
+
bundle install
|
|
249
|
+
bin/test # unit + dummy Rails app
|
|
250
|
+
bundle exec rubocop
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Against a live FileHutch — a project with a private `documents` policy, a public `avatars` policy,
|
|
254
|
+
and a public base URL on its storage connection:
|
|
255
|
+
|
|
256
|
+
```sh
|
|
257
|
+
export FILE_HUTCH_URL=http://localhost:3000 FILE_HUTCH_API_KEY=fh_…
|
|
258
|
+
export PDF_PATH=test/fixtures/files/sample.pdf IMAGE_PATH=test/fixtures/files/sample.png
|
|
259
|
+
|
|
260
|
+
bin/dogfood # the client: the seven-step acceptance flow
|
|
261
|
+
bin/dogfood-rails # everything built on it, through the dummy app
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`bin/dogfood-rails` runs the Rails integration against a real server rather than WebMock:
|
|
265
|
+
attaching an upload and saving it, signed URLs from the record, a policy violation caught locally
|
|
266
|
+
before any round trip, replacement deleting the file it replaced, public URLs and named
|
|
267
|
+
transforms, the engine's endpoints refusing an unauthorized browser and never returning the API
|
|
268
|
+
key, an id uploaded under the wrong policy being refused, and `purge` deleting remotely.
|
|
269
|
+
|
|
270
|
+
Worth running before a release: it is what found the engine authorizing `complete` with no policy
|
|
271
|
+
at all, which the unit suite could not see because it only ever exercised `create` that way.
|
|
272
|
+
|
|
273
|
+
## Releasing
|
|
274
|
+
|
|
275
|
+
Bump `FileHutch::VERSION`, write the entry in `CHANGELOG.md`, then tag:
|
|
276
|
+
|
|
277
|
+
```sh
|
|
278
|
+
git tag v0.1.0 && git push origin v0.1.0
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The release workflow refuses a tag that disagrees with the constant, runs the suite and RuboCop,
|
|
282
|
+
and publishes through RubyGems trusted publishing — no API key lives in this repository. Configure
|
|
283
|
+
it once at https://rubygems.org/gems/file_hutch/trusted_publishers against this repository,
|
|
284
|
+
`.github/workflows/release.yml`, and the `rubygems` environment.
|
|
285
|
+
|
|
286
|
+
## License
|
|
287
|
+
|
|
288
|
+
MIT
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// FileHutch browser-direct upload.
|
|
2
|
+
//
|
|
3
|
+
// The bytes go from the browser straight to storage; only two small JSON calls
|
|
4
|
+
// hit your Rails app (mounted FileHutch::Engine), which holds the API key.
|
|
5
|
+
//
|
|
6
|
+
// Stimulus usage (register as "filehutch-direct-upload"):
|
|
7
|
+
//
|
|
8
|
+
// <div data-controller="filehutch-direct-upload"
|
|
9
|
+
// data-filehutch-direct-upload-url-value="/file_hutch/uploads"
|
|
10
|
+
// data-filehutch-direct-upload-policy-value="avatars">
|
|
11
|
+
// <input type="file" data-action="filehutch-direct-upload#upload">
|
|
12
|
+
// <input type="hidden" name="user[avatar_file_id]" data-filehutch-direct-upload-target="fileId">
|
|
13
|
+
// <progress value="0" max="100" hidden data-filehutch-direct-upload-target="progress"></progress>
|
|
14
|
+
// <p data-filehutch-direct-upload-target="status"></p>
|
|
15
|
+
// </div>
|
|
16
|
+
//
|
|
17
|
+
// Events on the element: filehutch:start, filehutch:progress ({percent}), filehutch:complete ({file}),
|
|
18
|
+
// filehutch:error ({error}). The surrounding form's submit buttons are disabled while uploading.
|
|
19
|
+
|
|
20
|
+
import { Controller } from "@hotwired/stimulus"
|
|
21
|
+
|
|
22
|
+
export class DirectUploadError extends Error {
|
|
23
|
+
constructor(message, { code, status } = {}) {
|
|
24
|
+
super(message)
|
|
25
|
+
this.name = "DirectUploadError"
|
|
26
|
+
this.code = code
|
|
27
|
+
this.status = status
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Framework-neutral: returns the ready file object.
|
|
32
|
+
export async function directUpload(file, { url, policy, csrfToken, onProgress } = {}) {
|
|
33
|
+
if (!url) throw new DirectUploadError("directUpload needs a url")
|
|
34
|
+
if (!policy) throw new DirectUploadError("directUpload needs a policy")
|
|
35
|
+
|
|
36
|
+
const created = await postJSON(url, {
|
|
37
|
+
policy,
|
|
38
|
+
filename: file.name,
|
|
39
|
+
content_type: file.type || "application/octet-stream",
|
|
40
|
+
byte_size: file.size
|
|
41
|
+
}, csrfToken)
|
|
42
|
+
|
|
43
|
+
await putToStorage(created.upload, file, onProgress)
|
|
44
|
+
|
|
45
|
+
const completed = await postJSON(`${url.replace(/\/$/, "")}/${created.upload.id}/complete`, {}, csrfToken)
|
|
46
|
+
return completed.file
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function putToStorage(upload, file, onProgress) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const xhr = new XMLHttpRequest()
|
|
52
|
+
xhr.open(upload.method || "PUT", upload.url, true)
|
|
53
|
+
Object.entries(upload.headers || {}).forEach(([name, value]) => xhr.setRequestHeader(name, value))
|
|
54
|
+
xhr.upload.onprogress = (event) => {
|
|
55
|
+
if (event.lengthComputable && onProgress) onProgress(Math.round((event.loaded / event.total) * 100))
|
|
56
|
+
}
|
|
57
|
+
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300)
|
|
58
|
+
? resolve()
|
|
59
|
+
: reject(new DirectUploadError(`Storage rejected the upload (HTTP ${xhr.status})`, { code: "storage_rejected", status: xhr.status }))
|
|
60
|
+
xhr.onerror = () => reject(new DirectUploadError("Network error talking to storage (check bucket CORS)", { code: "network" }))
|
|
61
|
+
xhr.send(file)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function postJSON(url, body, csrfToken) {
|
|
66
|
+
const headers = { "Content-Type": "application/json", "Accept": "application/json" }
|
|
67
|
+
const token = csrfToken || document.querySelector("meta[name='csrf-token']")?.content
|
|
68
|
+
if (token) headers["X-CSRF-Token"] = token
|
|
69
|
+
const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), credentials: "same-origin" })
|
|
70
|
+
const data = await response.json().catch(() => ({}))
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new DirectUploadError(data.error?.message || `Request failed (HTTP ${response.status})`, { code: data.error?.code, status: response.status })
|
|
73
|
+
}
|
|
74
|
+
return data
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default class extends Controller {
|
|
78
|
+
static targets = ["fileId", "progress", "status"]
|
|
79
|
+
static values = { url: { type: String, default: "/file_hutch/uploads" }, policy: String }
|
|
80
|
+
|
|
81
|
+
async upload(event) {
|
|
82
|
+
const file = event.target.files?.[0]
|
|
83
|
+
if (!file) return
|
|
84
|
+
|
|
85
|
+
this.busy(true)
|
|
86
|
+
this.dispatch("start", { detail: { file } })
|
|
87
|
+
try {
|
|
88
|
+
const uploaded = await directUpload(file, {
|
|
89
|
+
url: this.urlValue,
|
|
90
|
+
policy: this.policyValue,
|
|
91
|
+
onProgress: (percent) => this.progress(percent)
|
|
92
|
+
})
|
|
93
|
+
if (this.hasFileIdTarget) this.fileIdTarget.value = uploaded.id
|
|
94
|
+
this.note(`${uploaded.filename} uploaded`)
|
|
95
|
+
this.dispatch("complete", { detail: { file: uploaded } })
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (this.hasFileIdTarget) this.fileIdTarget.value = ""
|
|
98
|
+
this.note(error.message, true)
|
|
99
|
+
this.dispatch("error", { detail: { error } })
|
|
100
|
+
} finally {
|
|
101
|
+
this.busy(false)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
busy(state) {
|
|
106
|
+
this.submitButtons.forEach((button) => { button.disabled = state })
|
|
107
|
+
if (this.hasProgressTarget) {
|
|
108
|
+
this.progressTarget.hidden = !state
|
|
109
|
+
if (state) this.progress(0)
|
|
110
|
+
}
|
|
111
|
+
this.element.dataset.filehutchUploading = state ? "true" : "false"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
progress(percent) {
|
|
115
|
+
if (this.hasProgressTarget) this.progressTarget.value = percent
|
|
116
|
+
this.dispatch("progress", { detail: { percent } })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
note(message, isError = false) {
|
|
120
|
+
if (!this.hasStatusTarget) return
|
|
121
|
+
this.statusTarget.textContent = message
|
|
122
|
+
this.statusTarget.dataset.filehutchState = isError ? "error" : "ok"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get submitButtons() {
|
|
126
|
+
const form = this.element.closest("form")
|
|
127
|
+
return form ? Array.from(form.querySelectorAll("button[type=submit], input[type=submit]")) : []
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FileHutch
|
|
4
|
+
# Proxies the two control-plane calls of a browser-direct upload. The bytes
|
|
5
|
+
# still go from the browser straight to storage.
|
|
6
|
+
class DirectUploadsController < FileHutch.config.direct_upload_parent_controller.constantize
|
|
7
|
+
before_action :authorize_file_hutch_upload!
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
upload = FileHutch.client.create_upload(
|
|
11
|
+
policy: params.require(:policy), filename: params.require(:filename),
|
|
12
|
+
content_type: params.require(:content_type), byte_size: params.require(:byte_size)
|
|
13
|
+
)
|
|
14
|
+
render json: { upload: upload.to_h, file: upload.file.to_h }, status: :created
|
|
15
|
+
rescue FileHutch::Error => e
|
|
16
|
+
render_file_hutch_error(e)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def complete
|
|
20
|
+
file = FileHutch.client.complete_upload(params[:id])
|
|
21
|
+
render json: { file: file.to_h }
|
|
22
|
+
rescue FileHutch::Error => e
|
|
23
|
+
render_file_hutch_error(e)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def authorize_file_hutch_upload!
|
|
29
|
+
authorizer = FileHutch.config.authorize_direct_upload
|
|
30
|
+
if authorizer
|
|
31
|
+
arity = authorizer.arity.negative? ? 2 : authorizer.arity
|
|
32
|
+
args = [ self, arity >= 2 ? file_hutch_requested_policy : nil ].first(arity)
|
|
33
|
+
return if instance_exec(*args, &authorizer)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
message = authorizer ? "Not allowed to upload here" : "Direct uploads are disabled: set FileHutch.config.authorize_direct_upload"
|
|
37
|
+
render json: { error: { code: "forbidden", message: message } }, status: :forbidden
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# On create the policy is in the request. On complete it is not, but it is a
|
|
41
|
+
# property of the file being finalized, so it is looked up — and looked up
|
|
42
|
+
# rather than trusted from the client, which could name any policy it liked.
|
|
43
|
+
# Only authorizers that actually take a policy pay for the lookup.
|
|
44
|
+
def file_hutch_requested_policy
|
|
45
|
+
return params[:policy].to_s if action_name == "create"
|
|
46
|
+
|
|
47
|
+
FileHutch.client.file(params[:id]).policy.to_s
|
|
48
|
+
rescue StandardError
|
|
49
|
+
# A lookup that fails must not become a 500. An empty policy matches
|
|
50
|
+
# nothing, so an authorizer that checks the policy denies; one that
|
|
51
|
+
# ignores it is unaffected either way.
|
|
52
|
+
""
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def render_file_hutch_error(error)
|
|
56
|
+
status = error.respond_to?(:status) && error.status.to_i.between?(400, 599) ? error.status : :unprocessable_entity
|
|
57
|
+
render json: { error: { code: error.respond_to?(:code) ? error.code : "error", message: error.message } }, status: status
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/config/routes.rb
ADDED