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 +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +21 -0
- data/README.md +150 -0
- data/fileboy.gemspec +29 -0
- data/lib/fileboy/client.rb +297 -0
- data/lib/fileboy/errors.rb +32 -0
- data/lib/fileboy/file_data.rb +37 -0
- data/lib/fileboy/mime.rb +27 -0
- data/lib/fileboy/token.rb +35 -0
- data/lib/fileboy/transforms.rb +118 -0
- data/lib/fileboy/version.rb +3 -0
- data/lib/fileboy/webhook.rb +98 -0
- data/lib/fileboy.rb +54 -0
- metadata +100 -0
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
|
data/lib/fileboy/mime.rb
ADDED
|
@@ -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,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: []
|