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 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ FileHutch::Engine.routes.draw do
4
+ resources :uploads, only: :create, controller: "direct_uploads" do
5
+ post :complete, on: :member
6
+ end
7
+ end
data/exe/file_hutch ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "file_hutch"
5
+
6
+ exit FileHutch::CLI.new(ARGV).run