fileboy 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +179 -11
- data/lib/fileboy/client.rb +354 -19
- data/lib/fileboy/file_data.rb +193 -8
- data/lib/fileboy/signing.rb +198 -0
- data/lib/fileboy/transforms.rb +362 -15
- data/lib/fileboy/version.rb +1 -1
- data/lib/fileboy.rb +1 -0
- metadata +2 -1
data/lib/fileboy/file_data.rb
CHANGED
|
@@ -2,10 +2,13 @@ require "time"
|
|
|
2
2
|
|
|
3
3
|
module Fileboy
|
|
4
4
|
# The details of one stored file, as returned by an upload, `fetch_file_data` or the management calls.
|
|
5
|
-
# `labels` lists the labels pointing at the file and
|
|
5
|
+
# `labels` lists the labels pointing at the file and `share_page` says whether its share page
|
|
6
|
+
# (https://fileboy.io/f/{id}) is on. Both are only filled by the management calls.
|
|
7
|
+
# `folder_id` and `folder_path` (like "/Products/Shoes") say which folder the file is in, and
|
|
8
|
+
# are nil at the root. Uploads and the management calls fill them in.
|
|
6
9
|
FileData = Struct.new(
|
|
7
10
|
:id, :name, :original_filename, :mime, :metadata, :file_size, :content_hash, :signed,
|
|
8
|
-
:dies_at, :created_at, :labels,
|
|
11
|
+
:dies_at, :created_at, :labels, :folder_id, :folder_path, :share_page,
|
|
9
12
|
keyword_init: true
|
|
10
13
|
) do
|
|
11
14
|
# Build from the `data` object of an API response.
|
|
@@ -21,19 +24,34 @@ module Fileboy
|
|
|
21
24
|
signed: data["signed"],
|
|
22
25
|
dies_at: parse_time(data["diesAt"]),
|
|
23
26
|
created_at: parse_time(data["createdAt"]),
|
|
24
|
-
labels: data["labels"] || []
|
|
27
|
+
labels: data["labels"] || [],
|
|
28
|
+
folder_id: data["folderId"],
|
|
29
|
+
folder_path: data["folderPath"],
|
|
30
|
+
share_page: data["sharePage"] == true
|
|
25
31
|
)
|
|
26
32
|
end
|
|
27
33
|
|
|
28
34
|
def self.parse_time(value)
|
|
29
35
|
value.nil? || value == "" ? nil : Time.parse(value)
|
|
30
36
|
end
|
|
37
|
+
|
|
38
|
+
# The strings of a JSON list, [] for anything else.
|
|
39
|
+
def self.string_list(value)
|
|
40
|
+
value.is_a?(Array) ? value.select { |v| v.is_a?(String) } : []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The string values of a JSON object, {} for anything else.
|
|
44
|
+
def self.string_map(value)
|
|
45
|
+
value.is_a?(Hash) ? value.select { |_k, v| v.is_a?(String) } : {}
|
|
46
|
+
end
|
|
31
47
|
end
|
|
32
48
|
|
|
33
49
|
# The details of one video, as returned by `upload_video`.
|
|
34
|
-
Video = Struct.new(:id, :name, :raw_file_id, keyword_init: true) do
|
|
50
|
+
Video = Struct.new(:id, :name, :raw_file_id, :description, :tags, :metadata, keyword_init: true) do
|
|
35
51
|
def self.from_api(data)
|
|
36
|
-
new(id: data["id"], name: data["name"], raw_file_id: data["rawFileId"]
|
|
52
|
+
new(id: data["id"], name: data["name"], raw_file_id: data["rawFileId"],
|
|
53
|
+
description: data["description"] || "", tags: FileData.string_list(data["tags"]),
|
|
54
|
+
metadata: FileData.string_map(data["metadata"]))
|
|
37
55
|
end
|
|
38
56
|
end
|
|
39
57
|
|
|
@@ -68,7 +86,7 @@ module Fileboy
|
|
|
68
86
|
# "failed". `urls` is nil until ready, else a hash with :manifest, :hls, :dash and :poster.
|
|
69
87
|
ManagedVideo = Struct.new(
|
|
70
88
|
:id, :name, :created_at, :status, :status_message, :duration, :width, :height, :play_count,
|
|
71
|
-
:last_played_at, :urls,
|
|
89
|
+
:last_played_at, :urls, :description, :tags, :metadata,
|
|
72
90
|
keyword_init: true
|
|
73
91
|
) do
|
|
74
92
|
def self.from_api(data)
|
|
@@ -78,7 +96,9 @@ module Fileboy
|
|
|
78
96
|
status: data["status"], status_message: data["statusMessage"], duration: data["duration"],
|
|
79
97
|
width: data["width"], height: data["height"], play_count: data["playCount"],
|
|
80
98
|
last_played_at: FileData.parse_time(data["lastPlayedAt"]),
|
|
81
|
-
urls: urls && urls.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
|
|
99
|
+
urls: urls && urls.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v },
|
|
100
|
+
description: data["description"] || "", tags: FileData.string_list(data["tags"]),
|
|
101
|
+
metadata: FileData.string_map(data["metadata"])
|
|
82
102
|
)
|
|
83
103
|
end
|
|
84
104
|
end
|
|
@@ -91,6 +111,73 @@ module Fileboy
|
|
|
91
111
|
end
|
|
92
112
|
end
|
|
93
113
|
|
|
114
|
+
# A folder. `path` is where it is, like "/Products/Shoes", and `parent_id` is nil at the root.
|
|
115
|
+
# `file_count` and `folder_count` count what is directly inside it. `ancestors` are the folders
|
|
116
|
+
# above it from the top, as {FolderRef}s, and are only filled in when one folder is fetched,
|
|
117
|
+
# created or updated (nil in lists).
|
|
118
|
+
Folder = Struct.new(
|
|
119
|
+
:id, :name, :path, :parent_id, :file_count, :folder_count, :created_at, :ancestors,
|
|
120
|
+
keyword_init: true
|
|
121
|
+
) do
|
|
122
|
+
def self.from_api(data)
|
|
123
|
+
ancestors = data["ancestors"]
|
|
124
|
+
new(
|
|
125
|
+
id: data["id"], name: data["name"], path: data["path"], parent_id: data["parentId"],
|
|
126
|
+
file_count: data["fileCount"] || 0, folder_count: data["folderCount"] || 0,
|
|
127
|
+
created_at: FileData.parse_time(data["createdAt"]),
|
|
128
|
+
ancestors: ancestors&.map { |a| FolderRef.new(id: a["id"], name: a["name"]) }
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
FolderRef = Struct.new(:id, :name, keyword_init: true)
|
|
133
|
+
|
|
134
|
+
# The result of `move_files`. `folder` is the {Folder} the files are in now, nil for the root.
|
|
135
|
+
# `not_found` holds unknown ids and other apps' files.
|
|
136
|
+
MoveFilesResult = Struct.new(:moved, :not_found, :folder, keyword_init: true) do
|
|
137
|
+
def self.from_api(data)
|
|
138
|
+
new(
|
|
139
|
+
moved: data["moved"] || [], not_found: data["notFound"] || [],
|
|
140
|
+
folder: data["folder"] && Folder.from_api(data["folder"])
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The result of `delete_folder`: how many folders and files were deleted or moved up a level.
|
|
146
|
+
FolderDeleteResult = Struct.new(
|
|
147
|
+
:deleted_folders, :deleted_files, :moved_files, :moved_folders,
|
|
148
|
+
keyword_init: true
|
|
149
|
+
) do
|
|
150
|
+
def self.from_api(data)
|
|
151
|
+
new(
|
|
152
|
+
deleted_folders: data["deletedFolders"] || 0, deleted_files: data["deletedFiles"] || 0,
|
|
153
|
+
moved_files: data["movedFiles"] || 0, moved_folders: data["movedFolders"] || 0
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# An expiring link to one file, made with `create_file_link`. `url` works until `expires_at` or
|
|
159
|
+
# until the link is revoked, whatever the file's own read settings. `status` is "active",
|
|
160
|
+
# "expired" or "revoked"; `created_via` is "api" or "dashboard". Times are `Time` or nil.
|
|
161
|
+
FileLink = Struct.new(
|
|
162
|
+
:id, :file_id, :url, :status, :expires_at, :revoked_at, :transform, :download, :filename, :note,
|
|
163
|
+
:created_via, :created_at, :last_used_at, :use_count,
|
|
164
|
+
keyword_init: true
|
|
165
|
+
) do
|
|
166
|
+
def self.from_api(data)
|
|
167
|
+
new(
|
|
168
|
+
id: data["id"], file_id: data["fileId"], url: data["url"], status: data["status"],
|
|
169
|
+
expires_at: FileData.parse_time(data["expiresAt"]), revoked_at: FileData.parse_time(data["revokedAt"]),
|
|
170
|
+
transform: data["transform"].to_s, download: data["download"] == true, filename: data["filename"].to_s,
|
|
171
|
+
note: data["note"].to_s, created_via: data["createdVia"], created_at: FileData.parse_time(data["createdAt"]),
|
|
172
|
+
last_used_at: FileData.parse_time(data["lastUsedAt"]), use_count: data["useCount"] || 0
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def active?
|
|
177
|
+
status == "active"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
94
181
|
# The result of `delete_files`. `failed` is a list of {FailedDelete}.
|
|
95
182
|
BulkDeleteResult = Struct.new(:deleted, :not_found, :failed, keyword_init: true) do
|
|
96
183
|
def self.from_api(data)
|
|
@@ -102,7 +189,8 @@ module Fileboy
|
|
|
102
189
|
end
|
|
103
190
|
FailedDelete = Struct.new(:id, :message, keyword_init: true)
|
|
104
191
|
|
|
105
|
-
#
|
|
192
|
+
# The limits set on the app, which Fileboy sets from your plan (or an agreement). A nil limit
|
|
193
|
+
# means none is set on the app, not unlimited; the dashboard then shows the plan's standard limit.
|
|
106
194
|
UsageLimits = Struct.new(:files, :videos, :storage_bytes, keyword_init: true)
|
|
107
195
|
|
|
108
196
|
# What the app stores. `storage_bytes` counts files, image copies, video renditions and posters.
|
|
@@ -115,4 +203,101 @@ module Fileboy
|
|
|
115
203
|
)
|
|
116
204
|
end
|
|
117
205
|
end
|
|
206
|
+
|
|
207
|
+
# Where a photo was taken, in decimal degrees (negative is south or west). Personal data.
|
|
208
|
+
ExifGps = Struct.new(:latitude, :longitude, :altitude, keyword_init: true)
|
|
209
|
+
|
|
210
|
+
EXIF_FIELDS = {
|
|
211
|
+
"make" => :make, "model" => :model, "lensMake" => :lens_make, "lensModel" => :lens_model,
|
|
212
|
+
"focalLength" => :focal_length, "focalLength35mm" => :focal_length_35mm, "aperture" => :aperture,
|
|
213
|
+
"exposureTime" => :exposure_time, "shutterSpeed" => :shutter_speed, "iso" => :iso,
|
|
214
|
+
"exposureCompensation" => :exposure_compensation, "exposureProgram" => :exposure_program,
|
|
215
|
+
"meteringMode" => :metering_mode, "flashFired" => :flash_fired, "whiteBalance" => :white_balance,
|
|
216
|
+
"dateTaken" => :date_taken, "timezone" => :timezone, "orientation" => :orientation, "width" => :width,
|
|
217
|
+
"height" => :height, "software" => :software, "colorSpace" => :color_space,
|
|
218
|
+
"colorProfile" => :color_profile, "title" => :title, "description" => :description, "artist" => :artist,
|
|
219
|
+
"copyright" => :copyright, "rating" => :rating, "city" => :city, "state" => :state, "country" => :country,
|
|
220
|
+
"sublocation" => :sublocation, "bodySerialNumber" => :body_serial_number,
|
|
221
|
+
"lensSerialNumber" => :lens_serial_number, "cameraOwnerName" => :camera_owner_name
|
|
222
|
+
}.freeze
|
|
223
|
+
|
|
224
|
+
# The normalised metadata of a photo. A field is nil when the file does not say. `date_taken` is
|
|
225
|
+
# the ISO 8601 text the file records, with an offset only when it has one (`timezone`). `gps`, the
|
|
226
|
+
# place names, the serial numbers and `camera_owner_name` are personal data.
|
|
227
|
+
ExifFields = Struct.new(*EXIF_FIELDS.values, :keywords, :gps, keyword_init: true) do
|
|
228
|
+
def self.from_api(data)
|
|
229
|
+
values = EXIF_FIELDS.to_h { |camel, snake| [snake, data[camel]] }
|
|
230
|
+
gps = data["gps"]
|
|
231
|
+
values[:gps] = gps && ExifGps.new(latitude: gps["latitude"], longitude: gps["longitude"], altitude: gps["altitude"])
|
|
232
|
+
new(**values, keywords: data["keywords"] || [])
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# A file's EXIF, IPTC and XMP metadata, from {Client#fetch_file_exif}. `status` is "found",
|
|
237
|
+
# "none" (an image with no metadata) or "unsupported" (not an image type that is read). `raw` has
|
|
238
|
+
# every tag read, by source: "exif", "iptc" and "xmp".
|
|
239
|
+
FileExif = Struct.new(:file_id, :status, :format, :extracted_at, :location_removed, :personal_data, :fields,
|
|
240
|
+
:raw, :warnings, keyword_init: true) do
|
|
241
|
+
def self.from_api(data)
|
|
242
|
+
raw = data["raw"] || {}
|
|
243
|
+
new(
|
|
244
|
+
file_id: data["fileId"], status: data["status"], format: data["format"],
|
|
245
|
+
extracted_at: FileData.parse_time(data["extractedAt"]), location_removed: data["locationRemoved"] == true,
|
|
246
|
+
personal_data: data["personalData"] != false, fields: ExifFields.from_api(data["fields"] || {}),
|
|
247
|
+
raw: %w[exif iptc xmp].to_h { |k| [k, raw[k] || {}] }, warnings: data["warnings"] || []
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# The kinds stored files are counted under. Every stored file is exactly one kind.
|
|
253
|
+
STORAGE_KINDS = %w[
|
|
254
|
+
images files videoOriginals videoRenditions videoPosters videoCaptions imageTransforms otherGenerated
|
|
255
|
+
].freeze
|
|
256
|
+
|
|
257
|
+
# How many stored files of one kind there are and their size in bytes.
|
|
258
|
+
StorageKindUsage = Struct.new(:count, :bytes, keyword_init: true)
|
|
259
|
+
|
|
260
|
+
# A video and what it stores, in bytes. `storage_bytes` is all of its parts together.
|
|
261
|
+
StorageVideo = Struct.new(
|
|
262
|
+
:id, :name, :created_at, :status, :storage_bytes, :original_bytes, :rendition_bytes, :poster_bytes,
|
|
263
|
+
:caption_bytes,
|
|
264
|
+
keyword_init: true
|
|
265
|
+
) do
|
|
266
|
+
def self.from_api(data)
|
|
267
|
+
new(
|
|
268
|
+
id: data["id"], name: data["name"], created_at: FileData.parse_time(data["createdAt"]), status: data["status"],
|
|
269
|
+
storage_bytes: data["storageBytes"] || 0, original_bytes: data["originalBytes"] || 0,
|
|
270
|
+
rendition_bytes: data["renditionBytes"] || 0, poster_bytes: data["posterBytes"] || 0,
|
|
271
|
+
caption_bytes: data["captionBytes"] || 0
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The app's stored bytes on one UTC day (`date` is "YYYY-MM-DD"), with bytes by kind.
|
|
277
|
+
StorageSnapshot = Struct.new(:date, :storage_bytes, :kinds, keyword_init: true)
|
|
278
|
+
|
|
279
|
+
# What the app stores: by kind (a hash keyed by the API's kind names, every kind present), its
|
|
280
|
+
# biggest files and videos, and a daily total, oldest first. `storage_limit` is the limit set on the
|
|
281
|
+
# app in bytes, as {Client#fetch_usage} returns it; nil means none is set on the app, not unlimited.
|
|
282
|
+
StorageUsage = Struct.new(
|
|
283
|
+
:storage_bytes, :storage_limit, :kinds, :largest_files, :largest_videos, :history,
|
|
284
|
+
keyword_init: true
|
|
285
|
+
) do
|
|
286
|
+
def self.from_api(data)
|
|
287
|
+
kinds = STORAGE_KINDS.to_h { |k| [k, StorageKindUsage.new(count: 0, bytes: 0)] }
|
|
288
|
+
(data["kinds"] || {}).each do |name, value|
|
|
289
|
+
kinds[name] = StorageKindUsage.new(count: value["count"] || 0, bytes: value["bytes"] || 0)
|
|
290
|
+
end
|
|
291
|
+
new(
|
|
292
|
+
storage_bytes: data["storageBytes"] || 0,
|
|
293
|
+
storage_limit: (data["limits"] || {})["storageBytes"],
|
|
294
|
+
kinds: kinds,
|
|
295
|
+
largest_files: (data["largestFiles"] || []).map { |f| FileData.from_api(f) },
|
|
296
|
+
largest_videos: (data["largestVideos"] || []).map { |v| StorageVideo.from_api(v) },
|
|
297
|
+
history: (data["history"] || []).map do |h|
|
|
298
|
+
StorageSnapshot.new(date: h["date"], storage_bytes: h["storageBytes"] || 0, kinds: h["kinds"] || {})
|
|
299
|
+
end
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
118
303
|
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "resolv"
|
|
3
|
+
|
|
4
|
+
module Fileboy
|
|
5
|
+
# Signed (expiring) URLs for apps that require signed reads. Make them on your server with the
|
|
6
|
+
# app's private key:
|
|
7
|
+
#
|
|
8
|
+
# url = Fileboy.client.sign_url(Fileboy.client.build_image_url(id, transforms: { resize: [400, 400] }),
|
|
9
|
+
# expires_in: 600)
|
|
10
|
+
#
|
|
11
|
+
# The format is described at https://fileboy.io/docs/delivery/signed-urls and pinned by
|
|
12
|
+
# packages/sdk-fixtures/signing.json, which every Fileboy SDK and the API test against.
|
|
13
|
+
module Signing
|
|
14
|
+
VERSION = "FBS1".freeze
|
|
15
|
+
DEFAULT_EXPIRES_IN = 3600
|
|
16
|
+
SIGNATURE_PARAMS = %w[fb_exp fb_sig fb_ip].freeze
|
|
17
|
+
UUID = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Percent-encode every UTF-8 byte except A-Z a-z 0-9 - . _ ~ with uppercase hex.
|
|
22
|
+
def escape(value)
|
|
23
|
+
value.to_s.dup.force_encoding(Encoding::BINARY).each_byte.map do |byte|
|
|
24
|
+
if (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39) || [0x2d, 0x2e, 0x5f, 0x7e].include?(byte)
|
|
25
|
+
byte.chr
|
|
26
|
+
else
|
|
27
|
+
format("%%%02X", byte)
|
|
28
|
+
end
|
|
29
|
+
end.join
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The exact string that gets signed.
|
|
33
|
+
def canonical_string(scope:, id:, expires:, transform: "", filename: "", ip: "")
|
|
34
|
+
[
|
|
35
|
+
VERSION,
|
|
36
|
+
"scope=#{escape(scope)}",
|
|
37
|
+
"id=#{escape(id)}",
|
|
38
|
+
"transform=#{escape(transform)}",
|
|
39
|
+
"filename=#{escape(filename)}",
|
|
40
|
+
"exp=#{expires}",
|
|
41
|
+
"ip=#{escape(ip)}"
|
|
42
|
+
].join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The fb_sig value for some claims.
|
|
46
|
+
def sign_claims(private_key, **claims)
|
|
47
|
+
digest = OpenSSL::HMAC.digest("SHA256", private_key, canonical_string(**claims))
|
|
48
|
+
[digest].pack("m0").tr("+/", "-_").delete("=")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Sign a Fileboy URL so it works when the app requires signed reads. The URL is kept as
|
|
52
|
+
# written; fb_exp (and fb_ip) and fb_sig are added to its query.
|
|
53
|
+
#
|
|
54
|
+
# @param url [String] the URL of a file, image or video
|
|
55
|
+
# @param private_key [String]
|
|
56
|
+
# @param expires_in [Integer] seconds from now, default one hour
|
|
57
|
+
# @param expires_at [Time, Integer] an exact expiry, wins over expires_in
|
|
58
|
+
# @param ip [String, nil] only requests from this address or CIDR range can use the URL
|
|
59
|
+
# @param now [Time] replaces the current time, for tests
|
|
60
|
+
def sign_url(url, private_key, expires_in: DEFAULT_EXPIRES_IN, expires_at: nil, ip: nil, now: Time.now)
|
|
61
|
+
raise ConfigurationError, "Signing needs the app's private key" if private_key.to_s.empty?
|
|
62
|
+
|
|
63
|
+
expires = resolve_expiry(expires_in, expires_at, now)
|
|
64
|
+
ip = nil if ip == ""
|
|
65
|
+
check_ip!(ip) if ip
|
|
66
|
+
claims, query = claims_from_url(url)
|
|
67
|
+
signature = sign_claims(private_key, **claims, expires: expires, ip: ip.to_s)
|
|
68
|
+
|
|
69
|
+
without_fragment, hash, fragment = url.partition("#")
|
|
70
|
+
base = without_fragment.split("?", 2).first
|
|
71
|
+
signed_query = query.empty? ? "" : "#{query}&"
|
|
72
|
+
signed_query += "fb_exp=#{expires}"
|
|
73
|
+
signed_query += "&fb_ip=#{escape(ip)}" if ip
|
|
74
|
+
signed_query += "&fb_sig=#{signature}"
|
|
75
|
+
"#{base}?#{signed_query}#{hash}#{fragment}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The query string that unlocks every URL of one video: manifests, segments, poster, captions
|
|
79
|
+
# and data. Make it last at least as long as the video takes to watch.
|
|
80
|
+
def sign_video_query(video_id, private_key, expires_in: DEFAULT_EXPIRES_IN, expires_at: nil, ip: nil, now: Time.now)
|
|
81
|
+
raise ConfigurationError, "Signing needs the app's private key" if private_key.to_s.empty?
|
|
82
|
+
raise InvalidArgumentError, "#{video_id.inspect} is not a video id" unless UUID.match?(video_id.to_s)
|
|
83
|
+
|
|
84
|
+
expires = resolve_expiry(expires_in, expires_at, now)
|
|
85
|
+
ip = nil if ip == ""
|
|
86
|
+
check_ip!(ip) if ip
|
|
87
|
+
signature = sign_claims(private_key, scope: "video", id: video_id.downcase, expires: expires, ip: ip.to_s)
|
|
88
|
+
"fb_exp=#{expires}#{ip ? "&fb_ip=#{escape(ip)}" : ""}&fb_sig=#{signature}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# What a URL refers to, and what is left of its query once any signature is removed. Mirrors
|
|
92
|
+
# the server's own reading of the URL.
|
|
93
|
+
def claims_from_url(url)
|
|
94
|
+
without_fragment = url.partition("#").first
|
|
95
|
+
raw_path, _, raw_query = without_fragment.partition("?")
|
|
96
|
+
raw_path = raw_path.sub(%r{\A[A-Za-z][A-Za-z0-9+.-]*://[^/]*}, "")
|
|
97
|
+
path = percent_decode(raw_path) or unsupported!(url)
|
|
98
|
+
segs = path.gsub(%r{\A/+|/+\z}, "").split("/", -1)
|
|
99
|
+
unsupported!(url) if segs.any?(&:empty?)
|
|
100
|
+
|
|
101
|
+
a, b, c, d = segs
|
|
102
|
+
claims =
|
|
103
|
+
if segs.length == 2 && %w[files images].include?(a) then { scope: "file", id: b }
|
|
104
|
+
elsif segs.length == 3 && %w[files images].include?(a) && b == "get" then { scope: "file", id: c }
|
|
105
|
+
elsif segs.length == 4 && %w[files images].include?(a) && b == "get" then { scope: "file", id: c, filename: d }
|
|
106
|
+
elsif segs.length == 3 && a == "files" && b == "data" then { scope: "file-data", id: c }
|
|
107
|
+
elsif segs.length == 3 && a == "files" && c == "data" then { scope: "file-data", id: b }
|
|
108
|
+
elsif segs.length >= 3 && a == "videos" then { scope: "video", id: b }
|
|
109
|
+
# A share page, on the website: https://fileboy.io/f/{id}
|
|
110
|
+
elsif segs.length == 2 && a == "f" then { scope: "share", id: b }
|
|
111
|
+
elsif segs.length == 3 && a == "files" && c == "share" then { scope: "share", id: b }
|
|
112
|
+
else unsupported!(url)
|
|
113
|
+
end
|
|
114
|
+
unsupported!(url) unless valid_id?(claims[:id], %w[video share].include?(claims[:scope]))
|
|
115
|
+
claims[:id] = claims[:id].downcase unless claims[:id].include?("+")
|
|
116
|
+
|
|
117
|
+
kept = []
|
|
118
|
+
unless raw_query.empty?
|
|
119
|
+
raw_query.split("&", -1).each do |pair|
|
|
120
|
+
# partition, not split: an empty component ("a&&b", a trailing "&") gives "" and is kept
|
|
121
|
+
key = pair.partition("=").first
|
|
122
|
+
name = percent_decode(key, plus: true)
|
|
123
|
+
next if name && SIGNATURE_PARAMS.include?(name)
|
|
124
|
+
|
|
125
|
+
kept << pair
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
if claims[:scope] == "file"
|
|
130
|
+
# the server reads the query with semicolons as data, and the first transform wins
|
|
131
|
+
raw_query.gsub(";", "%3B").split("&", -1).each do |pair|
|
|
132
|
+
next if pair.empty?
|
|
133
|
+
|
|
134
|
+
key, _, value = pair.partition("=")
|
|
135
|
+
name = percent_decode(key, plus: true)
|
|
136
|
+
decoded = percent_decode(value, plus: true)
|
|
137
|
+
if name.nil? || decoded.nil?
|
|
138
|
+
raise InvalidArgumentError, "Cannot sign #{url}: its query string is malformed"
|
|
139
|
+
end
|
|
140
|
+
if name == "transform"
|
|
141
|
+
claims[:transform] = decoded
|
|
142
|
+
break
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
[claims, kept.join("&")]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def percent_decode(string, plus: false)
|
|
150
|
+
return nil if string =~ /%(?![0-9A-Fa-f]{2})/
|
|
151
|
+
|
|
152
|
+
string = string.tr("+", " ") if plus
|
|
153
|
+
decoded = string.gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }.dup.force_encoding(Encoding::BINARY)
|
|
154
|
+
decoded.force_encoding(Encoding::UTF_8)
|
|
155
|
+
decoded.valid_encoding? ? decoded : nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def valid_id?(id, uuid_only)
|
|
159
|
+
if id.include?("+")
|
|
160
|
+
parts = id.split("+", -1)
|
|
161
|
+
return !uuid_only && parts.length == 2 && !parts[0].empty? && !parts[1].empty?
|
|
162
|
+
end
|
|
163
|
+
UUID.match?(id)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def resolve_expiry(expires_in, expires_at, now)
|
|
167
|
+
expires =
|
|
168
|
+
if expires_at
|
|
169
|
+
expires_at.respond_to?(:to_f) ? expires_at.to_f : expires_at
|
|
170
|
+
else
|
|
171
|
+
now.to_f + expires_in.to_f
|
|
172
|
+
end
|
|
173
|
+
expires = expires.floor
|
|
174
|
+
raise InvalidArgumentError, "The expiry must be a time in the future" unless expires.positive?
|
|
175
|
+
|
|
176
|
+
expires
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def check_ip!(spec)
|
|
180
|
+
address, prefix = spec.split("/", 2)
|
|
181
|
+
v4 = Resolv::IPv4::Regex.match?(address)
|
|
182
|
+
v6 = !v4 && Resolv::IPv6::Regex.match?(address) && !address.include?("%")
|
|
183
|
+
ok = v4 || v6
|
|
184
|
+
if ok && !prefix.nil?
|
|
185
|
+
ok = prefix.match?(/\A[0-9]{1,3}\z/) && prefix.to_i <= (v4 ? 32 : 128)
|
|
186
|
+
end
|
|
187
|
+
raise InvalidArgumentError, "Invalid ip address or range: #{spec}" unless ok && !spec.end_with?("/")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def unsupported!(url)
|
|
191
|
+
raise InvalidArgumentError,
|
|
192
|
+
"Cannot sign #{url}: it is not the URL of a Fileboy file, image, video or share page " \
|
|
193
|
+
"(for example /files/get/{id}, /images/get/{id}, /videos/{id}/manifest or /f/{id})"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private_class_method :percent_decode, :valid_id?, :resolve_expiry, :check_ip!, :unsupported!
|
|
197
|
+
end
|
|
198
|
+
end
|