fileboy 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8733a37ff97e6459ba45a32878b7daad2dae4c24036aa2c259fe2f0c794b937c
4
+ data.tar.gz: 2afd08a3e2d9e9690fdead7989696bf4ece30922fa0e7ffd8df77416f4126262
5
+ SHA512:
6
+ metadata.gz: 291cbb77ae1c0a7cff35c6e839df34a6575f857877634828fb9e377a8bd8d17eaee2d26d4ab0ae09ecfd0fee04b04d207bc1c960e609d9563bf458e8aecddd69
7
+ data.tar.gz: f7f30b41249df4b3d3dbf9d24d8b0ab0f70fcf6996524b3840606b55c0dfc40f9301ffb888659985d94f8eb49d66b2739ae6d49ac089c6686816854c797458f8
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - Fix: the gem failed to load on Ruby 3.4 because it required the `base64` gem, which is no longer a default gem. It now uses only the standard library.
6
+
7
+ ## 0.1.0
8
+
9
+ First release.
10
+
11
+ - Upload files from a path, an IO or a remote URL.
12
+ - Build file URLs and image transform URLs.
13
+ - Download files and images.
14
+ - Videos: upload, data, info, manifest and poster URLs, posters, transcripts.
15
+ - Webhook verification with `Fileboy::Webhook.verify`.
16
+ - Signed access tokens for apps that use a private key.
17
+ - Typed errors and a Rails-friendly `Fileboy.configure` block.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) CD2 Solutions Ltd
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,150 @@
1
+ # Fileboy for Ruby
2
+
3
+ The official Ruby client for [Fileboy](https://fileboy.io): upload files and videos, build image
4
+ URLs that resize on the fly, and read video data. It uses only the Ruby standard library.
5
+
6
+ ## Install
7
+
8
+ ```ruby
9
+ # Gemfile
10
+ gem "fileboy"
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```ruby
16
+ require "fileboy"
17
+
18
+ fileboy = Fileboy::Client.new(public_key: ENV["FILEBOY_PUBLIC_KEY"])
19
+
20
+ file = fileboy.upload_file("report.pdf") # a path, or any IO
21
+ puts file.id # store this
22
+
23
+ fileboy.build_file_url(file.id, download: true)
24
+ fileboy.build_image_url(file.id, transforms: [{ type: :resize, width: 300 }, { type: :format, format: :webp }])
25
+ # => https://api.fileboy.io/images/get/<id>?transform=resize:300x_;format:webp
26
+ ```
27
+
28
+ ## Rails
29
+
30
+ Configure once, then call `Fileboy.client` anywhere:
31
+
32
+ ```ruby
33
+ # config/initializers/fileboy.rb
34
+ Fileboy.configure do |config|
35
+ config.public_key = Rails.application.credentials.dig(:fileboy, :public_key)
36
+ # config.private_key = ... only if your app uses signed requests
37
+ # config.api_url = "https://api.fileboy.io"
38
+ end
39
+ ```
40
+
41
+ `FILEBOY_PUBLIC_KEY`, `FILEBOY_PRIVATE_KEY` and `FILEBOY_API_URL` are read from the environment when you do not set them.
42
+
43
+ ## What you can do
44
+
45
+ | Task | Method |
46
+ | --- | --- |
47
+ | Upload a path or IO | `upload_file(file, name:, mime:, metadata:)` |
48
+ | Upload from a URL | `upload_file_from_url(url, name:, metadata:, expect:)` |
49
+ | File details | `fetch_file_data(id_or_label)` |
50
+ | File URL | `build_file_url(id_or_label, filename:, download:)` |
51
+ | Image URL with transforms | `build_image_url(id_or_label, transforms:, filename:, download:)` |
52
+ | Download bytes | `download_file`, `download_image` |
53
+ | Upload a video | `upload_video(file, name:)` |
54
+ | Video details | `fetch_video_data(id)`, `fetch_video_info(id)` |
55
+ | Manifest and poster URLs | `build_video_manifest_url(id, type: :hls)`, `build_video_poster_url(id)` |
56
+ | Custom poster | `upload_video_poster(video_id, image)` |
57
+ | Transcripts | `upload_video_transcript(id, file, language: "English")`, `fetch_video_transcript(id, transcript_id)` |
58
+ | Signed access token | `create_access_token(read:, write:, upload:, expires_in:)` |
59
+
60
+ An id that is a UUID is used as is. Anything else is treated as a label in your app. Labels are
61
+ created in the dashboard.
62
+
63
+ ### Image transforms
64
+
65
+ `transforms:` takes an array of hashes applied in order. The image service supports `resize`,
66
+ `format`, `quality`, `background` and `invert`:
67
+
68
+ ```ruby
69
+ [
70
+ { type: :resize, width: 200, height: 200, fit: :cover }, # fit: contain cover fill inside outside
71
+ { type: :resize, width: 400, fit: :contain, position: "top-left", background: "#ffffff" },
72
+ { type: :format, format: :webp }, # jpeg png webp tiff bmp
73
+ { type: :quality, value: 75 },
74
+ { type: :background, color: "#fff" },
75
+ { type: :invert }
76
+ ]
77
+ ```
78
+
79
+ You can also pass a hash of `name => value`, such as `{ resize: "300x_", format: "webp", quality: 75 }`, or a ready-made string.
80
+
81
+ ## Errors
82
+
83
+ Every error is a `Fileboy::Error` with `status`, `code` (the API's error code) and `body`.
84
+
85
+ | Class | When |
86
+ | --- | --- |
87
+ | `Fileboy::AuthenticationError` | 401 |
88
+ | `Fileboy::PermissionError` | 403, including plan limits |
89
+ | `Fileboy::NotFoundError` | 404 |
90
+ | `Fileboy::RateLimitError` | 429 |
91
+ | `Fileboy::BadRequestError` | other 4xx, such as a file that is too large |
92
+ | `Fileboy::ServerError` | 5xx |
93
+ | `Fileboy::ConnectionError` | timeouts and network failures |
94
+ | `Fileboy::InvalidArgumentError`, `Fileboy::ConfigurationError` | bad arguments, before any request |
95
+
96
+ ## Notes
97
+
98
+ * File, image and video reads need no authorization, so URL builders never sign anything.
99
+ * The shared `demo` key only accepts small images and cannot upload video.
100
+ * There is no endpoint yet to update, delete or list files, so the client has no such methods.
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ bundle install
106
+ bundle exec rspec
107
+ ```
108
+
109
+ The specs run against a local fake server. The transform builder is also checked against
110
+ `packages/sdk-fixtures/transforms.json`, the same cases the Go image parser is tested with.
111
+
112
+ MIT licensed.
113
+
114
+ ## Webhooks
115
+
116
+ Verify the requests Fileboy sends to your endpoints. Use the raw body, exactly as received:
117
+
118
+ ```ruby
119
+ # app/controllers/fileboy_webhooks_controller.rb
120
+ class FileboyWebhooksController < ApplicationController
121
+ skip_forgery_protection
122
+
123
+ def create
124
+ event = Fileboy::Webhook.verify(request.raw_post, request.headers, ENV.fetch("FILEBOY_WEBHOOK_SECRET"))
125
+ if event["type"] == "video.ready"
126
+ # event["resource"]["urls"]["hls"], event["resource"]["duration"], ...
127
+ end
128
+ head :ok
129
+ rescue Fileboy::WebhookVerificationError => e
130
+ head :bad_request # e.code is "invalid_signature", "timestamp_out_of_tolerance", ...
131
+ end
132
+ end
133
+ ```
134
+
135
+ `verify` returns the parsed event (a Hash with string keys) or raises `Fileboy::WebhookVerificationError`.
136
+ The timestamp must be within 5 minutes (`tolerance_seconds:` changes that). Events carry a unique `id`
137
+ that is the same on every retry, so use it to ignore duplicates. `Fileboy::Webhook.sign(body, timestamp, secret)`
138
+ builds a signature for your own tests.
139
+
140
+ ## Publishing (maintainers)
141
+
142
+ You need a [RubyGems](https://rubygems.org) account that owns `fileboy` (the first push claims the name). The gemspec requires MFA.
143
+
144
+ 1. Bump `lib/fileboy/version.rb` and add a section to `CHANGELOG.md`.
145
+ 2. `bundle exec rspec` must pass.
146
+ 3. Build and check the contents: `gem build fileboy.gemspec && tar -tf fileboy-X.Y.Z.gem` (or `gem spec fileboy-X.Y.Z.gem files`).
147
+ 4. `gem signin`, then `gem push fileboy-X.Y.Z.gem` and enter the MFA code.
148
+ 5. Tag the commit: `git tag ruby-vX.Y.Z && git push origin ruby-vX.Y.Z`.
149
+
150
+ A pushed version cannot be replaced, only yanked (`gem yank fileboy -v X.Y.Z`), so check step 3 first.
data/fileboy.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ require_relative "lib/fileboy/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "fileboy"
5
+ spec.version = Fileboy::VERSION
6
+ spec.authors = ["CD2 Solutions"]
7
+ spec.email = ["hello@fileboy.io"]
8
+
9
+ spec.summary = "Official Ruby client for the Fileboy file, image and video API."
10
+ spec.description = "Upload files and videos to Fileboy, build file URLs and image transform URLs, " \
11
+ "and read video data and transcripts. Uses only the Ruby standard library."
12
+ spec.homepage = "https://fileboy.io/docs/libraries/ruby"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 2.7"
15
+
16
+ spec.metadata = {
17
+ "homepage_uri" => spec.homepage,
18
+ "documentation_uri" => "https://fileboy.io/docs/libraries/ruby",
19
+ "changelog_uri" => "https://github.com/CD2/Fileboy/blob/master/packages/ruby/CHANGELOG.md",
20
+ "rubygems_mfa_required" => "true"
21
+ }
22
+
23
+ spec.files = Dir["lib/**/*.rb"] + %w[README.md CHANGELOG.md LICENSE fileboy.gemspec]
24
+ spec.require_paths = ["lib"]
25
+
26
+ spec.add_development_dependency "rake", "~> 13.0"
27
+ spec.add_development_dependency "rspec", "~> 3.12"
28
+ spec.add_development_dependency "webrick", "~> 1.8"
29
+ end
@@ -0,0 +1,297 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "erb"
5
+ require "pathname"
6
+
7
+ module Fileboy
8
+ DEFAULT_API_URL = "https://api.fileboy.io".freeze
9
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
10
+
11
+ # A client for one Fileboy app.
12
+ #
13
+ # fileboy = Fileboy::Client.new(public_key: "YOUR_PUBLIC_KEY")
14
+ # file = fileboy.upload_file("report.pdf")
15
+ # fileboy.build_file_url(file.id)
16
+ #
17
+ # Reading a file (GET /files/get, /images/get, video data) needs no authorization, so URL
18
+ # builders never sign anything. Uploads send your public key, or a signed token when you also
19
+ # pass a private key.
20
+ class Client
21
+ attr_reader :public_key, :api_url
22
+
23
+ # @param public_key [String] your app's public key
24
+ # @param private_key [String, nil] your app's private key, for signed requests
25
+ # @param api_url [String] override to point at another Fileboy server, for example a CDN host
26
+ # @param timeout [Numeric] seconds to wait for a response (uploads included)
27
+ def initialize(public_key:, private_key: nil, api_url: DEFAULT_API_URL, timeout: 60, open_timeout: 10)
28
+ raise ConfigurationError, "public_key is required" if public_key.to_s.empty?
29
+
30
+ @public_key = public_key
31
+ @private_key = private_key
32
+ @api_url = api_url.to_s.chomp("/")
33
+ @timeout = timeout
34
+ @open_timeout = open_timeout
35
+ end
36
+
37
+ # --- URLs ----------------------------------------------------------------------------------
38
+
39
+ # URL that serves the original file.
40
+ #
41
+ # @param id_or_label [String] a file id, or a label name
42
+ # @param filename [String, nil] sets the downloaded file name
43
+ # @param download [Boolean] true to ask the browser to download instead of showing inline
44
+ def build_file_url(id_or_label, filename: nil, download: false)
45
+ url = "#{@api_url}/files/get/#{resolve(id_or_label)}"
46
+ url += "/#{ERB::Util.url_encode(filename)}" if filename
47
+ url += "?download" if download
48
+ url
49
+ end
50
+
51
+ # URL that serves an image, optionally transformed.
52
+ #
53
+ # fileboy.build_image_url(id, transforms: [{ type: :resize, width: 300 }, { type: :format, format: :webp }])
54
+ # fileboy.build_image_url(id, transforms: { resize: "300x_", format: "webp", quality: 75 })
55
+ #
56
+ # See {Fileboy::Transforms.build} for the accepted forms.
57
+ def build_image_url(id_or_label, transforms: nil, filename: nil, download: false)
58
+ url = "#{@api_url}/images/get/#{resolve(id_or_label)}"
59
+ url += "/#{ERB::Util.url_encode(filename)}" if filename
60
+ params = []
61
+ transform = Transforms.build(transforms)
62
+ params << "transform=#{transform}" unless transform.empty?
63
+ params << "download" if download
64
+ url += "?#{params.join('&')}" unless params.empty?
65
+ url
66
+ end
67
+
68
+ # Manifest URL for adaptive playback. `type` is :auto, :hls or :dash.
69
+ def build_video_manifest_url(video_id, type: :auto)
70
+ type = type.to_s
71
+ raise InvalidArgumentError, "type must be auto, hls or dash" unless %w[auto hls dash].include?(type)
72
+
73
+ "#{@api_url}/videos/#{video_id}/manifest?type=#{type}"
74
+ end
75
+
76
+ def build_video_poster_url(video_id)
77
+ "#{@api_url}/videos/#{video_id}/poster"
78
+ end
79
+
80
+ # --- Files ---------------------------------------------------------------------------------
81
+
82
+ # Upload a file from a path (String or Pathname) or an IO (File, StringIO, Tempfile).
83
+ #
84
+ # @param name [String, nil] name to store the file under. Defaults to the file name.
85
+ # @param mime [String, nil] content type. Guessed from the name when omitted.
86
+ # @param metadata [Hash{String=>String}, nil] custom string key/value pairs stored with the file
87
+ # @return [Fileboy::FileData]
88
+ def upload_file(file, name: nil, mime: nil, metadata: nil)
89
+ io, filename, opened = open_upload(file, name)
90
+ data = { name: name || filename, mime: mime || Mime.for_filename(filename) }
91
+ data[:metadata] = stringify(metadata) if metadata
92
+ form = [["file", io, { filename: filename, content_type: data[:mime] }], ["data", JSON.generate(data)]]
93
+ FileData.from_api(request(:post, "/files/upload", form: form).fetch("data"))
94
+ ensure
95
+ io.close if opened && io
96
+ end
97
+
98
+ def upload_file_from_path(path, **options)
99
+ upload_file(path.to_s, **options)
100
+ end
101
+
102
+ # Ask Fileboy to fetch a public http(s) URL and store the result.
103
+ #
104
+ # @param expect [String, Array<String>, nil] accepted types, like "image/*" or ".png"
105
+ def upload_file_from_url(url, name: nil, metadata: nil, expect: nil)
106
+ payload = { url: url }
107
+ payload[:name] = name if name
108
+ payload[:metadata] = stringify(metadata) if metadata
109
+ payload[:expect] = expect if expect
110
+ FileData.from_api(request(:post, "/files/upload", json: payload).fetch("data"))
111
+ end
112
+
113
+ # The details of a stored file.
114
+ def fetch_file_data(id_or_label)
115
+ FileData.from_api(request(:get, "/files/data/#{resolve(id_or_label)}").fetch("data"))
116
+ end
117
+
118
+ # The bytes of a file. `range` is [first_byte, last_byte].
119
+ def download_file(id_or_label, range: nil)
120
+ request(:get, "/files/get/#{resolve(id_or_label)}", raw: true, range: range)
121
+ end
122
+
123
+ # The bytes of an image, optionally transformed.
124
+ def download_image(id_or_label, transforms: nil, range: nil)
125
+ path = build_image_url(id_or_label, transforms: transforms).delete_prefix(@api_url)
126
+ request(:get, path, raw: true, range: range)
127
+ end
128
+
129
+ # --- Videos --------------------------------------------------------------------------------
130
+
131
+ # Upload a video. Videos are processed in the background: poll {#fetch_video_data} and wait
132
+ # for `extractionStatus` to finish. Not available with the shared demo key.
133
+ #
134
+ # @return [Fileboy::Video]
135
+ def upload_video(file, name: nil, mime: nil)
136
+ io, filename, opened = open_upload(file, name)
137
+ data = { name: name || filename }
138
+ form = [["file", io, { filename: filename, content_type: mime || Mime.for_filename(filename) }],
139
+ ["data", JSON.generate(data)]]
140
+ Video.from_api(request(:post, "/videos/upload", form: form))
141
+ ensure
142
+ io.close if opened && io
143
+ end
144
+
145
+ # Processing state, renditions, poster ids and transcripts for a video. Returns the `data`
146
+ # object of the response with string keys.
147
+ def fetch_video_data(video_id)
148
+ request(:get, "/videos/#{video_id}/data").fetch("data")
149
+ end
150
+
151
+ # Lighter details for a video: id, name, rawVideoUrl, duration, createdAt.
152
+ def fetch_video_info(video_id)
153
+ request(:get, "/videos/#{video_id}/info")
154
+ end
155
+
156
+ # Set a custom poster image. Returns the id of the stored image file.
157
+ def upload_video_poster(video_id, image, name: nil, mime: nil)
158
+ io, filename, opened = open_upload(image, name)
159
+ form = [["file", io, { filename: filename, content_type: mime || Mime.for_filename(filename) }]]
160
+ request(:post, "/videos/#{video_id}/poster/upload", form: form).fetch("data").fetch("id")
161
+ ensure
162
+ io.close if opened && io
163
+ end
164
+
165
+ # Attach a text transcript (WebVTT or plain text) in a language. Returns the transcript id.
166
+ def upload_video_transcript(video_id, file, language:, name: nil, mime: nil)
167
+ raise InvalidArgumentError, "language is required" if language.to_s.empty?
168
+
169
+ io, filename, opened = open_upload(file, name)
170
+ form = [["file", io, { filename: filename, content_type: mime || text_mime(filename) }],
171
+ ["data", JSON.generate({ language: language })]]
172
+ request(:post, "/videos/#{video_id}/transcripts/upload", form: form).fetch("data").fetch("id")
173
+ ensure
174
+ io.close if opened && io
175
+ end
176
+
177
+ # The text of a transcript.
178
+ def fetch_video_transcript(video_id, transcript_id)
179
+ request(:get, "/videos/#{video_id}/transcripts/#{transcript_id}", raw: true)
180
+ end
181
+
182
+ # The poster image bytes.
183
+ def download_video_poster(video_id)
184
+ request(:get, "/videos/#{video_id}/poster", raw: true)
185
+ end
186
+
187
+ # --- Tokens --------------------------------------------------------------------------------
188
+
189
+ # A short-lived signed token for handing a browser limited access. Needs a private key.
190
+ def create_access_token(read: false, write: false, upload: false, expires_in: 60)
191
+ raise ConfigurationError, "Cannot create access tokens without a private key" unless @private_key
192
+
193
+ Token.build(
194
+ public_key: @public_key, private_key: @private_key, expires_in: expires_in,
195
+ payload: { access: { read: read, write: write, upload: upload } }
196
+ )
197
+ end
198
+
199
+ private
200
+
201
+ # "abcd-..." UUIDs are ids; anything else is a label of this app.
202
+ def resolve(id_or_label)
203
+ value = id_or_label.to_s
204
+ raise InvalidArgumentError, "No id given" if value.empty?
205
+ return value if value.match?(UUID) || value.include?("+")
206
+
207
+ "#{@public_key}+#{ERB::Util.url_encode(value)}"
208
+ end
209
+
210
+ def stringify(metadata)
211
+ metadata.to_h.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v.to_s }
212
+ end
213
+
214
+ def text_mime(filename)
215
+ mime = Mime.for_filename(filename)
216
+ mime.start_with?("text/") ? mime : "text/plain"
217
+ end
218
+
219
+ # Returns [io, filename, opened_by_us]
220
+ def open_upload(file, name)
221
+ case file
222
+ when String, Pathname
223
+ path = file.to_s
224
+ raise InvalidArgumentError, "No such file: #{path}" unless File.file?(path)
225
+
226
+ [File.open(path, "rb"), File.basename(path), true]
227
+ else
228
+ raise InvalidArgumentError, "Pass a path or an IO to upload" unless file.respond_to?(:read)
229
+
230
+ filename = name || (file.respond_to?(:path) && file.path ? File.basename(file.path.to_s) : "upload")
231
+ file.rewind if file.respond_to?(:rewind)
232
+ [file, filename, false]
233
+ end
234
+ end
235
+
236
+ def auth_header
237
+ if @private_key
238
+ "token #{Token.build(public_key: @public_key, private_key: @private_key, expires_in: 10)}"
239
+ else
240
+ "public #{@public_key}"
241
+ end
242
+ end
243
+
244
+ def request(method, path, json: nil, form: nil, raw: false, range: nil)
245
+ uri = URI.parse("#{@api_url}#{path}")
246
+ req = (method == :post ? Net::HTTP::Post : Net::HTTP::Get).new(uri)
247
+ req["Authorization"] = auth_header
248
+ req["Accept"] = "application/json" unless raw
249
+ req["User-Agent"] = "fileboy-ruby/#{VERSION}"
250
+ req["Range"] = "bytes=#{range[0]}-#{range[1]}" if range
251
+ if json
252
+ req["Content-Type"] = "application/json"
253
+ req.body = JSON.generate(json)
254
+ elsif form
255
+ req.set_form(form, "multipart/form-data")
256
+ end
257
+
258
+ response = Net::HTTP.start(
259
+ uri.hostname, uri.port,
260
+ use_ssl: uri.scheme == "https", read_timeout: @timeout, write_timeout: @timeout, open_timeout: @open_timeout
261
+ ) { |http| http.request(req) }
262
+
263
+ raise_for_status(response) unless response.is_a?(Net::HTTPSuccess)
264
+ return response.body if raw
265
+
266
+ begin
267
+ JSON.parse(response.body)
268
+ rescue JSON::ParserError
269
+ raise Error.new("Fileboy sent a response that is not JSON", status: response.code.to_i, body: response.body)
270
+ end
271
+ rescue Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError, IOError, EOFError => e
272
+ raise ConnectionError, "Could not reach Fileboy at #{@api_url}: #{e.message}"
273
+ end
274
+
275
+ def raise_for_status(response)
276
+ status = response.code.to_i
277
+ body = response.body.to_s
278
+ parsed = begin
279
+ JSON.parse(body)
280
+ rescue JSON::ParserError
281
+ nil
282
+ end
283
+ code = parsed.is_a?(Hash) ? parsed["error"].to_s : nil
284
+ detail = parsed.is_a?(Hash) ? parsed["message"].to_s : body.strip
285
+ message = "Fileboy responded #{status}#{code.to_s.empty? ? '' : " (#{code})"}#{detail.empty? ? '' : ": #{detail}"}"
286
+ klass = case status
287
+ when 401 then AuthenticationError
288
+ when 403 then PermissionError
289
+ when 404 then NotFoundError
290
+ when 429 then RateLimitError
291
+ when 400..499 then BadRequestError
292
+ else ServerError
293
+ end
294
+ raise klass.new(message, status: status, code: code, body: parsed || body)
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,32 @@
1
+ module Fileboy
2
+ # Base class for everything this gem raises on purpose.
3
+ class Error < StandardError
4
+ # HTTP status of the response, if there was one.
5
+ attr_reader :status
6
+ # The `error` code the API sent back, for example "unauthorized" or "file_too_large".
7
+ attr_reader :code
8
+ # Parsed JSON body or the raw text of the response.
9
+ attr_reader :body
10
+
11
+ def initialize(message = nil, status: nil, code: nil, body: nil)
12
+ super(message)
13
+ @status = status
14
+ @code = code
15
+ @body = body
16
+ end
17
+ end
18
+
19
+ # The client was set up wrongly or called with bad arguments. Raised before any request is made.
20
+ class ConfigurationError < Error; end
21
+ class InvalidArgumentError < Error; end
22
+
23
+ # The network failed, timed out or the server could not be reached.
24
+ class ConnectionError < Error; end
25
+
26
+ class BadRequestError < Error; end # 400, 413, 415, 422
27
+ class AuthenticationError < Error; end # 401
28
+ class PermissionError < Error; end # 403 (includes plan limits)
29
+ class NotFoundError < Error; end # 404
30
+ class RateLimitError < Error; end # 429
31
+ class ServerError < Error; end # 5xx
32
+ end
@@ -0,0 +1,37 @@
1
+ require "time"
2
+
3
+ module Fileboy
4
+ # The details of one stored file, as returned by an upload or `fetch_file_data`.
5
+ FileData = Struct.new(
6
+ :id, :name, :original_filename, :mime, :metadata, :file_size, :content_hash, :signed,
7
+ :dies_at, :created_at,
8
+ keyword_init: true
9
+ ) do
10
+ # Build from the `data` object of an API response.
11
+ def self.from_api(data)
12
+ new(
13
+ id: data["id"],
14
+ name: data["name"],
15
+ original_filename: data["originalFilename"],
16
+ mime: data["mime"],
17
+ metadata: data["metadata"] || {},
18
+ file_size: data["fileSize"],
19
+ content_hash: data["hash"],
20
+ signed: data["signed"],
21
+ dies_at: parse_time(data["diesAt"]),
22
+ created_at: parse_time(data["createdAt"])
23
+ )
24
+ end
25
+
26
+ def self.parse_time(value)
27
+ value.nil? || value == "" ? nil : Time.parse(value)
28
+ end
29
+ end
30
+
31
+ # The details of one video, as returned by `upload_video`.
32
+ Video = Struct.new(:id, :name, :raw_file_id, keyword_init: true) do
33
+ def self.from_api(data)
34
+ new(id: data["id"], name: data["name"], raw_file_id: data["rawFileId"])
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ module Fileboy
2
+ # A small extension to MIME type table so uploads from a path get a sensible content type
3
+ # without another dependency. Pass `mime:` to upload_file for anything not listed here.
4
+ module Mime
5
+ TYPES = {
6
+ ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif",
7
+ ".webp" => "image/webp", ".avif" => "image/avif", ".heic" => "image/heic",
8
+ ".svg" => "image/svg+xml", ".bmp" => "image/bmp", ".tif" => "image/tiff", ".tiff" => "image/tiff",
9
+ ".mp4" => "video/mp4", ".m4v" => "video/mp4", ".mov" => "video/quicktime",
10
+ ".webm" => "video/webm", ".mkv" => "video/x-matroska", ".avi" => "video/x-msvideo",
11
+ ".mp3" => "audio/mpeg", ".wav" => "audio/wav", ".m4a" => "audio/mp4", ".ogg" => "audio/ogg",
12
+ ".pdf" => "application/pdf", ".zip" => "application/zip", ".json" => "application/json",
13
+ ".txt" => "text/plain", ".csv" => "text/csv", ".html" => "text/html", ".vtt" => "text/vtt",
14
+ ".srt" => "text/plain", ".md" => "text/markdown",
15
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
16
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
17
+ ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
18
+ ".xls" => "application/vnd.ms-excel", ".doc" => "application/msword"
19
+ }.freeze
20
+
21
+ module_function
22
+
23
+ def for_filename(name)
24
+ TYPES.fetch(File.extname(name.to_s).downcase, "application/octet-stream")
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,35 @@
1
+ require "openssl"
2
+ require "securerandom"
3
+ require "json"
4
+
5
+ module Fileboy
6
+ # Signed tokens for apps that use a private key. The format is `PUBLIC_KEY$NONCE$JWT`, where the
7
+ # JWT is HS256-signed with the private key. This is what the Fileboy API verifies, and what the
8
+ # Node client's createAccessToken produces.
9
+ module Token
10
+ module_function
11
+
12
+ def build(public_key:, private_key:, expires_in: 60, payload: nil, now: Time.now)
13
+ if public_key.include?("$") || private_key.include?("$")
14
+ raise InvalidArgumentError, "Keys must not contain a $ symbol"
15
+ end
16
+
17
+ nonce = SecureRandom.alphanumeric(32)
18
+ iat = now.to_i
19
+ claims = { "v" => 3, "token" => nonce, "iat" => iat, "exp" => iat + expires_in.to_i }
20
+ claims["payload"] = payload if payload
21
+ [public_key, nonce, jwt(claims, private_key)].join("$")
22
+ end
23
+
24
+ def jwt(claims, secret)
25
+ header = b64(JSON.generate({ "alg" => "HS256", "typ" => "JWT" }))
26
+ body = b64(JSON.generate(claims))
27
+ signature = OpenSSL::HMAC.digest("SHA256", secret, "#{header}.#{body}")
28
+ "#{header}.#{body}.#{b64(signature)}"
29
+ end
30
+
31
+ def b64(data)
32
+ [data].pack("m0").tr("+/", "-_").delete("=") # base64url without the base64 gem (not a default gem since Ruby 3.4)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,118 @@
1
+ module Fileboy
2
+ # Builds the value of the `transform` query parameter on image URLs, for example
3
+ # `resize:300x_~fit:cover;format:webp;quality:75`. It only emits what the Fileboy image service
4
+ # understands: resize, format, quality, background and invert.
5
+ module Transforms
6
+ FITS = %w[contain cover fill inside outside].freeze
7
+ POSITIONS = %w[top-left top top-right left center right bottom-left bottom bottom-right].freeze
8
+ FORMATS = %w[jpeg jpg png bmp tiff tif webp].freeze
9
+ TYPES = %w[resize format quality background invert].freeze
10
+
11
+ module_function
12
+
13
+ # @param transforms [Array<Hash>, Hash, String, nil]
14
+ # * an Array of hashes like `{ type: :resize, width: 300, fit: :cover }`, applied in order
15
+ # * a Hash of `name => value`, like `{ resize: "300x_", format: "webp", quality: 75 }`
16
+ # * a String, passed through untouched
17
+ # @return [String] the query value, or "" when there is nothing to apply
18
+ def build(transforms)
19
+ case transforms
20
+ when nil then ""
21
+ when String then transforms
22
+ when Hash then transforms.map { |name, value| shorthand(name, value) }.compact.join(";")
23
+ when Array then transforms.map { |t| build_one(t) }.join(";")
24
+ else
25
+ raise InvalidArgumentError, "transforms must be an Array, Hash or String"
26
+ end
27
+ end
28
+
29
+ def shorthand(name, value)
30
+ return nil if value.nil? || value.to_s.empty?
31
+ return name.to_s if value == true
32
+
33
+ "#{name}:#{value}"
34
+ end
35
+
36
+ def build_one(transform)
37
+ t = symbolize(transform)
38
+ type = t[:type].to_s
39
+ raise InvalidArgumentError, "Unknown transform #{type.inspect}. Use one of: #{TYPES.join(', ')}" unless TYPES.include?(type)
40
+
41
+ send("build_#{type}", t)
42
+ end
43
+
44
+ def build_invert(_t)
45
+ "invert"
46
+ end
47
+
48
+ def build_format(t)
49
+ "format:#{format_name(t[:format])}"
50
+ end
51
+
52
+ def build_quality(t)
53
+ value = t[:value]
54
+ unless value.is_a?(Integer) && value.between?(1, 100)
55
+ raise InvalidArgumentError, "quality must be a whole number from 1 to 100"
56
+ end
57
+
58
+ "quality:#{value}"
59
+ end
60
+
61
+ def build_background(t)
62
+ "background:#{color(t[:color])}"
63
+ end
64
+
65
+ def build_resize(t)
66
+ width = t[:width]
67
+ height = t[:height]
68
+ raise InvalidArgumentError, "resize needs a width, a height or both" if width.nil? && height.nil?
69
+
70
+ [width, height].compact.each do |n|
71
+ raise InvalidArgumentError, "resize width and height must be positive whole numbers" unless n.is_a?(Integer) && n.positive?
72
+ end
73
+
74
+ out = "resize:#{width || '_'}x#{height || '_'}"
75
+ out += "~position:#{position(t[:position])}" if t[:position]
76
+ out += "~fit:#{choice(t[:fit], FITS, 'fit')}" if t[:fit]
77
+ out += "~background:#{color(t[:background])}" if t[:background]
78
+ out += "~format:#{format_name(t[:format])}" if t[:format]
79
+ out
80
+ end
81
+
82
+ # "right top", "top right" and "top-right" all mean the same corner, which the image service
83
+ # spells top-right (vertical word first).
84
+ def position(value)
85
+ parts = value.to_s.downcase.strip.split(/[\s-]+/)
86
+ parts = parts.sort_by { |p| %w[top bottom left right].index(p) || 9 } if parts.size == 2
87
+ choice(parts.join("-"), POSITIONS, "position")
88
+ end
89
+
90
+ def format_name(value)
91
+ choice(value, FORMATS, "format")
92
+ end
93
+
94
+ def choice(value, allowed, label)
95
+ v = value.to_s.downcase
96
+ raise InvalidArgumentError, "Unsupported #{label} #{value.inspect}. Use one of: #{allowed.join(', ')}" unless allowed.include?(v)
97
+
98
+ v
99
+ end
100
+
101
+ # "#fff", "#ffffff" and "#ffffff80" become hex(...). Anything already in hex(...) form is kept.
102
+ def color(value)
103
+ s = value.to_s
104
+ return s if s.match?(/\Ahex\(([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})\)\z/i)
105
+
106
+ hex = s.delete_prefix("#")
107
+ raise InvalidArgumentError, "Colours must be hex, like #ff8800 (got #{value.inspect})" unless hex.match?(/\A([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})\z/i)
108
+
109
+ "hex(#{hex})"
110
+ end
111
+
112
+ def symbolize(hash)
113
+ raise InvalidArgumentError, "Each transform must be a Hash with a :type" unless hash.respond_to?(:to_h)
114
+
115
+ hash.to_h.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,3 @@
1
+ module Fileboy
2
+ VERSION = "0.1.1"
3
+ end
@@ -0,0 +1,98 @@
1
+ require "openssl"
2
+ require "json"
3
+
4
+ module Fileboy
5
+ # Raised by {Webhook.verify}. `code` is one of "missing_header", "invalid_timestamp",
6
+ # "timestamp_out_of_tolerance", "invalid_signature" or "invalid_body".
7
+ class WebhookVerificationError < Error; end
8
+
9
+ # Check that a webhook really came from Fileboy and is recent.
10
+ #
11
+ # Every delivery is a POST with a JSON body and two headers:
12
+ #
13
+ # * `Fileboy-Timestamp`: unix time in seconds when it was sent
14
+ # * `Fileboy-Signature`: `v1=` and the hex HMAC-SHA256, keyed with your endpoint's signing
15
+ # secret, of the string `"<timestamp>.<raw body>"`
16
+ #
17
+ # Use the exact bytes received. Parsing and re-serialising the JSON changes them and breaks the
18
+ # signature.
19
+ #
20
+ # # Rails controller
21
+ # event = Fileboy::Webhook.verify(request.raw_post, request.headers, ENV.fetch("FILEBOY_WEBHOOK_SECRET"))
22
+ # event["type"] # => "video.ready"
23
+ module Webhook
24
+ SIGNATURE_HEADER = "Fileboy-Signature".freeze
25
+ TIMESTAMP_HEADER = "Fileboy-Timestamp".freeze
26
+ DEFAULT_TOLERANCE_SECONDS = 300
27
+
28
+ module_function
29
+
30
+ # The signature Fileboy sends for this secret, timestamp and body. Mostly useful in tests.
31
+ def sign(raw_body, timestamp, secret)
32
+ "v1=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp.to_i}.#{raw_body}")
33
+ end
34
+
35
+ # @param raw_body [String] the request body exactly as received
36
+ # @param headers [#[], Hash] request headers: a Hash in any case, Rails/Rack headers, or any
37
+ # object that answers `[]`
38
+ # @param secret [String] the endpoint's signing secret from the dashboard
39
+ # @param tolerance_seconds [Integer] how far from now the timestamp may be
40
+ # @param now [Time] replaces the current time, for tests
41
+ # @return [Hash] the parsed event, with string keys
42
+ # @raise [WebhookVerificationError]
43
+ def verify(raw_body, headers, secret, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: Time.now)
44
+ unless raw_body.is_a?(String)
45
+ raise WebhookVerificationError.new("The body must be the raw String received, not parsed JSON", code: "invalid_body")
46
+ end
47
+
48
+ timestamp_header = read_header(headers, TIMESTAMP_HEADER)
49
+ signature_header = read_header(headers, SIGNATURE_HEADER)
50
+ if timestamp_header.to_s.empty? || signature_header.to_s.empty?
51
+ raise WebhookVerificationError.new("Missing #{TIMESTAMP_HEADER} or #{SIGNATURE_HEADER} header", code: "missing_header")
52
+ end
53
+ unless timestamp_header.to_s.match?(/\A\d+\z/)
54
+ raise WebhookVerificationError.new("Timestamp is not a number", code: "invalid_timestamp")
55
+ end
56
+
57
+ timestamp = timestamp_header.to_i
58
+ if (now.to_i - timestamp).abs > tolerance_seconds
59
+ raise WebhookVerificationError.new("Timestamp is outside of the allowed time window", code: "timestamp_out_of_tolerance")
60
+ end
61
+
62
+ expected = sign(raw_body, timestamp, secret)
63
+ # More than one signature may be sent in future, for example while rotating secrets
64
+ candidates = signature_header.to_s.split(/[\s,]+/).reject(&:empty?)
65
+ unless candidates.any? { |candidate| secure_compare(candidate, expected) }
66
+ raise WebhookVerificationError.new("Signature does not match", code: "invalid_signature")
67
+ end
68
+
69
+ begin
70
+ JSON.parse(raw_body)
71
+ rescue JSON::ParserError
72
+ raise WebhookVerificationError.new("Body is not valid JSON", code: "invalid_body")
73
+ end
74
+ end
75
+
76
+ # Finds a header however the framework spells it: "Fileboy-Signature", "fileboy-signature"
77
+ # or the Rack env key "HTTP_FILEBOY_SIGNATURE".
78
+ def read_header(headers, name)
79
+ variants = [name, name.downcase, "HTTP_#{name.upcase.tr('-', '_')}"]
80
+ variants.each do |key|
81
+ value = headers[key] if headers.respond_to?(:[])
82
+ return value unless value.nil?
83
+ end
84
+ if headers.respond_to?(:each_pair)
85
+ headers.each_pair { |key, value| return value if key.to_s.casecmp?(name) }
86
+ end
87
+ nil
88
+ end
89
+
90
+ def secure_compare(a, b)
91
+ return false unless a.bytesize == b.bytesize
92
+
93
+ result = 0
94
+ a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
95
+ result.zero?
96
+ end
97
+ end
98
+ end
data/lib/fileboy.rb ADDED
@@ -0,0 +1,54 @@
1
+ require "fileboy/version"
2
+ require "fileboy/errors"
3
+ require "fileboy/mime"
4
+ require "fileboy/file_data"
5
+ require "fileboy/transforms"
6
+ require "fileboy/token"
7
+ require "fileboy/webhook"
8
+ require "fileboy/client"
9
+
10
+ # Official Ruby client for Fileboy (https://fileboy.io).
11
+ #
12
+ # Fileboy.configure do |config|
13
+ # config.public_key = ENV["FILEBOY_PUBLIC_KEY"]
14
+ # end
15
+ #
16
+ # Fileboy.client.upload_file("report.pdf")
17
+ module Fileboy
18
+ # Settings for the shared client. Set these once in a Rails initializer.
19
+ class Configuration
20
+ attr_accessor :public_key, :private_key, :api_url, :timeout
21
+
22
+ def initialize
23
+ @public_key = ENV["FILEBOY_PUBLIC_KEY"]
24
+ @private_key = ENV["FILEBOY_PRIVATE_KEY"]
25
+ @api_url = ENV["FILEBOY_API_URL"] || DEFAULT_API_URL
26
+ @timeout = 60
27
+ end
28
+ end
29
+
30
+ class << self
31
+ def configuration
32
+ @configuration ||= Configuration.new
33
+ end
34
+
35
+ def configure
36
+ yield configuration
37
+ @client = nil
38
+ configuration
39
+ end
40
+
41
+ # A client built from {configuration}. Memoized, and rebuilt after #configure.
42
+ def client
43
+ @client ||= Client.new(
44
+ public_key: configuration.public_key, private_key: configuration.private_key,
45
+ api_url: configuration.api_url, timeout: configuration.timeout
46
+ )
47
+ end
48
+
49
+ def reset!
50
+ @configuration = nil
51
+ @client = nil
52
+ end
53
+ end
54
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fileboy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - CD2 Solutions
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.12'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.12'
40
+ - !ruby/object:Gem::Dependency
41
+ name: webrick
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.8'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.8'
54
+ description: Upload files and videos to Fileboy, build file URLs and image transform
55
+ URLs, and read video data and transcripts. Uses only the Ruby standard library.
56
+ email:
57
+ - hello@fileboy.io
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - LICENSE
64
+ - README.md
65
+ - fileboy.gemspec
66
+ - lib/fileboy.rb
67
+ - lib/fileboy/client.rb
68
+ - lib/fileboy/errors.rb
69
+ - lib/fileboy/file_data.rb
70
+ - lib/fileboy/mime.rb
71
+ - lib/fileboy/token.rb
72
+ - lib/fileboy/transforms.rb
73
+ - lib/fileboy/version.rb
74
+ - lib/fileboy/webhook.rb
75
+ homepage: https://fileboy.io/docs/libraries/ruby
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ homepage_uri: https://fileboy.io/docs/libraries/ruby
80
+ documentation_uri: https://fileboy.io/docs/libraries/ruby
81
+ changelog_uri: https://github.com/CD2/Fileboy/blob/master/packages/ruby/CHANGELOG.md
82
+ rubygems_mfa_required: 'true'
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '2.7'
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 4.0.24
98
+ specification_version: 4
99
+ summary: Official Ruby client for the Fileboy file, image and video API.
100
+ test_files: []