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/client.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "pathname"
|
|
|
6
6
|
|
|
7
7
|
module Fileboy
|
|
8
8
|
DEFAULT_API_URL = "https://api.fileboy.io".freeze
|
|
9
|
+
DEFAULT_WEBSITE_URL = "https://fileboy.io".freeze
|
|
9
10
|
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
|
|
|
11
12
|
# A client for one Fileboy app.
|
|
@@ -21,19 +22,25 @@ module Fileboy
|
|
|
21
22
|
HTTP_METHODS = {
|
|
22
23
|
get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch, delete: Net::HTTP::Delete
|
|
23
24
|
}.freeze
|
|
25
|
+
# Marks an option that was not passed, where nil has a meaning of its own (the root folder)
|
|
26
|
+
NOT_GIVEN = Object.new.freeze
|
|
27
|
+
private_constant :NOT_GIVEN
|
|
24
28
|
|
|
25
|
-
attr_reader :public_key, :api_url
|
|
29
|
+
attr_reader :public_key, :api_url, :website_url
|
|
26
30
|
|
|
27
31
|
# @param public_key [String] your app's public key
|
|
28
32
|
# @param private_key [String, nil] your app's private key, for signed requests
|
|
29
33
|
# @param api_url [String] override to point at another Fileboy server, for example a CDN host
|
|
30
34
|
# @param timeout [Numeric] seconds to wait for a response (uploads included)
|
|
31
|
-
|
|
35
|
+
# @param website_url [String] where share pages are built, https://fileboy.io unless you develop Fileboy itself
|
|
36
|
+
def initialize(public_key:, private_key: nil, api_url: DEFAULT_API_URL, timeout: 60, open_timeout: 10,
|
|
37
|
+
website_url: DEFAULT_WEBSITE_URL)
|
|
32
38
|
raise ConfigurationError, "public_key is required" if public_key.to_s.empty?
|
|
33
39
|
|
|
34
40
|
@public_key = public_key
|
|
35
41
|
@private_key = private_key
|
|
36
42
|
@api_url = api_url.to_s.chomp("/")
|
|
43
|
+
@website_url = website_url.to_s.chomp("/")
|
|
37
44
|
@timeout = timeout
|
|
38
45
|
@open_timeout = open_timeout
|
|
39
46
|
end
|
|
@@ -63,7 +70,7 @@ module Fileboy
|
|
|
63
70
|
url += "/#{ERB::Util.url_encode(filename)}" if filename
|
|
64
71
|
params = []
|
|
65
72
|
transform = Transforms.build(transforms)
|
|
66
|
-
params << "transform=#{transform}" unless transform.empty?
|
|
73
|
+
params << "transform=#{Transforms.encode_query(transform)}" unless transform.empty?
|
|
67
74
|
params << "download" if download
|
|
68
75
|
url += "?#{params.join('&')}" unless params.empty?
|
|
69
76
|
url
|
|
@@ -81,6 +88,15 @@ module Fileboy
|
|
|
81
88
|
"#{@api_url}/videos/#{video_id}/poster"
|
|
82
89
|
end
|
|
83
90
|
|
|
91
|
+
# Seek-preview thumbnails of a video: `format: :vtt` (the WebVTT most players read) or :json
|
|
92
|
+
# (the same as a storyboard). For an app that requires signed reads, add `sign_video`'s query.
|
|
93
|
+
def build_video_thumbnails_url(video_id, format: :vtt)
|
|
94
|
+
format = format.to_s
|
|
95
|
+
raise InvalidArgumentError, "format must be vtt or json" unless %w[vtt json].include?(format)
|
|
96
|
+
|
|
97
|
+
"#{@api_url}/videos/#{video_id}/thumbnails.#{format}"
|
|
98
|
+
end
|
|
99
|
+
|
|
84
100
|
# --- Files ---------------------------------------------------------------------------------
|
|
85
101
|
|
|
86
102
|
# Upload a file from a path (String or Pathname) or an IO (File, StringIO, Tempfile).
|
|
@@ -88,11 +104,16 @@ module Fileboy
|
|
|
88
104
|
# @param name [String, nil] name to store the file under. Defaults to the file name.
|
|
89
105
|
# @param mime [String, nil] content type. Guessed from the name when omitted.
|
|
90
106
|
# @param metadata [Hash{String=>String}, nil] custom string key/value pairs stored with the file
|
|
107
|
+
# @param folder_id [String, nil] put the file in this folder. Without a folder it goes at the root.
|
|
108
|
+
# @param folder_path [String, nil] put the file in the folder with this path, like "/Products/Shoes".
|
|
109
|
+
# With the private key missing folders are created; with only the public key it must exist.
|
|
91
110
|
# @return [Fileboy::FileData]
|
|
92
|
-
def upload_file(file, name: nil, mime: nil, metadata: nil)
|
|
111
|
+
def upload_file(file, name: nil, mime: nil, metadata: nil, folder_id: nil, folder_path: nil)
|
|
112
|
+
add = folder_fields(folder_id, folder_path)
|
|
93
113
|
io, filename, opened = open_upload(file, name)
|
|
94
114
|
data = { name: name || filename, mime: mime || Mime.for_filename(filename) }
|
|
95
115
|
data[:metadata] = stringify(metadata) if metadata
|
|
116
|
+
data.merge!(add)
|
|
96
117
|
form = [["file", io, { filename: filename, content_type: data[:mime] }], ["data", JSON.generate(data)]]
|
|
97
118
|
FileData.from_api(request(:post, "/files/upload", form: form).fetch("data"))
|
|
98
119
|
ensure
|
|
@@ -106,11 +127,13 @@ module Fileboy
|
|
|
106
127
|
# Ask Fileboy to fetch a public http(s) URL and store the result.
|
|
107
128
|
#
|
|
108
129
|
# @param expect [String, Array<String>, nil] accepted types, like "image/*" or ".png"
|
|
109
|
-
|
|
130
|
+
# @param folder_id, folder_path the folder to put it in, as in {#upload_file}
|
|
131
|
+
def upload_file_from_url(url, name: nil, metadata: nil, expect: nil, folder_id: nil, folder_path: nil)
|
|
110
132
|
payload = { url: url }
|
|
111
133
|
payload[:name] = name if name
|
|
112
134
|
payload[:metadata] = stringify(metadata) if metadata
|
|
113
135
|
payload[:expect] = expect if expect
|
|
136
|
+
payload.merge!(folder_fields(folder_id, folder_path))
|
|
114
137
|
FileData.from_api(request(:post, "/files/upload", json: payload).fetch("data"))
|
|
115
138
|
end
|
|
116
139
|
|
|
@@ -135,10 +158,21 @@ module Fileboy
|
|
|
135
158
|
# Upload a video. Videos are processed in the background: poll {#fetch_video_data} and wait
|
|
136
159
|
# for `extractionStatus` to finish. Not available with the shared demo key.
|
|
137
160
|
#
|
|
161
|
+
# `description` is plain text shown on the watch page (up to 5000 characters). `tags` (up to
|
|
162
|
+
# 50, no commas) and `metadata` (your own string key-value data) are private.
|
|
163
|
+
#
|
|
164
|
+
# fileboy.upload_video("intro.mp4", description: "Welcome", tags: ["onboarding"],
|
|
165
|
+
# metadata: { course_id: "c-1" })
|
|
166
|
+
#
|
|
138
167
|
# @return [Fileboy::Video]
|
|
139
|
-
def upload_video(file, name: nil, mime: nil)
|
|
168
|
+
def upload_video(file, name: nil, mime: nil, description: nil, tags: nil, metadata: nil)
|
|
140
169
|
io, filename, opened = open_upload(file, name)
|
|
141
170
|
data = { name: name || filename }
|
|
171
|
+
data[:description] = description unless description.nil?
|
|
172
|
+
data[:tags] = Array(tags).map(&:to_s) unless tags.nil?
|
|
173
|
+
unless metadata.nil?
|
|
174
|
+
data[:metadata] = metadata.to_h.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v.to_s }
|
|
175
|
+
end
|
|
142
176
|
form = [["file", io, { filename: filename, content_type: mime || Mime.for_filename(filename) }],
|
|
143
177
|
["data", JSON.generate(data)]]
|
|
144
178
|
Video.from_api(request(:post, "/videos/upload", form: form))
|
|
@@ -214,12 +248,17 @@ module Fileboy
|
|
|
214
248
|
# @param metadata [Hash, nil] only files whose metadata has all of these pairs
|
|
215
249
|
# @param created_before [Time, String, nil]
|
|
216
250
|
# @param created_after [Time, String, nil]
|
|
251
|
+
# @param folder_id [String, nil] only the files directly in this folder. "root" for the files in no folder.
|
|
252
|
+
# @param folder_path [String, nil] only the files directly in the folder with this path. "/" is the root.
|
|
253
|
+
# @param recursive [Boolean, nil] with folder_id or folder_path, include the files in every subfolder
|
|
217
254
|
# @return [Fileboy::Page] of {Fileboy::FileData}
|
|
218
255
|
def list_files(limit: nil, cursor: nil, sort: nil, order: nil, label: nil, mime: nil, name: nil, hash: nil,
|
|
219
|
-
metadata: nil, created_before: nil, created_after: nil
|
|
256
|
+
metadata: nil, created_before: nil, created_after: nil, folder_id: nil, folder_path: nil,
|
|
257
|
+
recursive: nil)
|
|
220
258
|
query = list_query(
|
|
221
259
|
limit: limit, cursor: cursor, sort: camelize(sort), order: order, label: label, mime: mime, name: name,
|
|
222
|
-
hash: hash, metadata: metadata, createdBefore: created_before, createdAfter: created_after
|
|
260
|
+
hash: hash, metadata: metadata, createdBefore: created_before, createdAfter: created_after,
|
|
261
|
+
folderId: folder_id, folderPath: folder_path, recursive: recursive
|
|
223
262
|
)
|
|
224
263
|
Page.from_api(manage(:get, "/files", query: query), FileData)
|
|
225
264
|
end
|
|
@@ -243,8 +282,11 @@ module Fileboy
|
|
|
243
282
|
# @param id_or_label [String] a file id, or a label name
|
|
244
283
|
# @param labels [Hash, nil] `{ add: [...], remove: [...] }`. A label points at one file, so
|
|
245
284
|
# adding one that is on another file moves it here.
|
|
285
|
+
# @param folder_id [String, nil] move the file to this folder. `folder_id: nil` moves it to the root.
|
|
286
|
+
# @param folder_path [String, nil] move the file to the folder with this path, creating missing folders
|
|
246
287
|
# @return [Fileboy::FileData]
|
|
247
|
-
def update_file(id_or_label, name: nil, mime: nil, metadata: nil, replace_metadata: nil, labels: nil
|
|
288
|
+
def update_file(id_or_label, name: nil, mime: nil, metadata: nil, replace_metadata: nil, labels: nil,
|
|
289
|
+
folder_id: NOT_GIVEN, folder_path: nil, share_page: nil)
|
|
248
290
|
body = {}
|
|
249
291
|
body[:name] = name unless name.nil?
|
|
250
292
|
body[:mime] = mime unless mime.nil?
|
|
@@ -259,9 +301,22 @@ module Fileboy
|
|
|
259
301
|
body[:labels][:add] = Array(add).map(&:to_s) if add
|
|
260
302
|
body[:labels][:remove] = Array(remove).map(&:to_s) if remove
|
|
261
303
|
end
|
|
304
|
+
body[:folderId] = folder_id unless folder_id.equal?(NOT_GIVEN)
|
|
305
|
+
body[:folderPath] = folder_path unless folder_path.nil?
|
|
306
|
+
body[:sharePage] = share_page ? true : false unless share_page.nil?
|
|
262
307
|
FileData.from_api(manage(:patch, "/files/#{file_ref(id_or_label)}", json: body).fetch("data"))
|
|
263
308
|
end
|
|
264
309
|
|
|
310
|
+
# A photo's camera, lens, exposure, date taken, GPS position and other EXIF, IPTC and XMP
|
|
311
|
+
# metadata. It includes personal data (GPS, serial numbers), so it needs the private key;
|
|
312
|
+
# `personal: false` leaves that out, for example before showing it to your own users.
|
|
313
|
+
#
|
|
314
|
+
# @return [Fileboy::FileExif]
|
|
315
|
+
def fetch_file_exif(id_or_label, personal: true)
|
|
316
|
+
query = personal == false ? "personal=false" : nil
|
|
317
|
+
FileExif.from_api(manage(:get, "/files/#{file_ref(id_or_label)}/exif", query: query).fetch("data"))
|
|
318
|
+
end
|
|
319
|
+
|
|
265
320
|
# Delete a file and its stored data. Raises {Fileboy::NotFoundError} when there is no such
|
|
266
321
|
# file, which includes one that is already deleted. Returns true.
|
|
267
322
|
def delete_file(id_or_label)
|
|
@@ -282,15 +337,37 @@ module Fileboy
|
|
|
282
337
|
BulkDeleteResult.from_api(manage(:post, "/files/delete", json: { ids: ids }).fetch("data"))
|
|
283
338
|
end
|
|
284
339
|
|
|
340
|
+
# Move up to 100 files into a folder. `folder_id: nil` (the default) moves them to the root;
|
|
341
|
+
# `folder_path:` moves them to the folder with that path, creating missing folders. Unknown ids
|
|
342
|
+
# come back in `not_found`.
|
|
343
|
+
#
|
|
344
|
+
# fileboy.move_files([id1, id2], folder_path: "/Products/Shoes")
|
|
345
|
+
#
|
|
346
|
+
# @return [Fileboy::MoveFilesResult]
|
|
347
|
+
def move_files(ids, folder_id: nil, folder_path: nil)
|
|
348
|
+
ids = Array(ids).map(&:to_s)
|
|
349
|
+
raise InvalidArgumentError, "No ids given" if ids.empty?
|
|
350
|
+
|
|
351
|
+
body = { ids: ids }
|
|
352
|
+
if folder_path.nil?
|
|
353
|
+
body[:folderId] = folder_id
|
|
354
|
+
else
|
|
355
|
+
body[:folderPath] = folder_path
|
|
356
|
+
end
|
|
357
|
+
MoveFilesResult.from_api(manage(:post, "/files/move", json: body).fetch("data"))
|
|
358
|
+
end
|
|
359
|
+
|
|
285
360
|
# One page of your videos. `status` is :pending, :processing, :ready or :failed, `sort` is
|
|
286
|
-
# :created_at or :name.
|
|
361
|
+
# :created_at or :name. `tags` keeps videos that have every one of the tags (case-sensitive)
|
|
362
|
+
# and `metadata` those whose metadata has all of the pairs. Other options are as in
|
|
363
|
+
# {#list_files}.
|
|
287
364
|
#
|
|
288
365
|
# @return [Fileboy::Page] of {Fileboy::ManagedVideo}
|
|
289
|
-
def list_videos(limit: nil, cursor: nil, sort: nil, order: nil, name: nil, status: nil,
|
|
290
|
-
created_after: nil)
|
|
366
|
+
def list_videos(limit: nil, cursor: nil, sort: nil, order: nil, name: nil, status: nil, tags: nil,
|
|
367
|
+
metadata: nil, created_before: nil, created_after: nil)
|
|
291
368
|
query = list_query(
|
|
292
369
|
limit: limit, cursor: cursor, sort: camelize(sort), order: order, name: name, status: status,
|
|
293
|
-
createdBefore: created_before, createdAfter: created_after
|
|
370
|
+
tags: tags, metadata: metadata, createdBefore: created_before, createdAfter: created_after
|
|
294
371
|
)
|
|
295
372
|
Page.from_api(manage(:get, "/videos", query: query), ManagedVideo)
|
|
296
373
|
end
|
|
@@ -302,11 +379,35 @@ module Fileboy
|
|
|
302
379
|
each_in_pages(:list_videos, filters, &block)
|
|
303
380
|
end
|
|
304
381
|
|
|
305
|
-
#
|
|
382
|
+
# Change a video's name, description, tags or metadata. Only the fields you pass change.
|
|
383
|
+
#
|
|
384
|
+
# fileboy.update_video(id, description: "Launch day", tags: { add: ["launch"], remove: ["draft"] },
|
|
385
|
+
# metadata: { order_id: "42", old: nil })
|
|
306
386
|
#
|
|
387
|
+
# @param description [String, nil] plain text shown on the watch page, "" removes it
|
|
388
|
+
# @param tags [Array, Hash, nil] an Array replaces the tags ([] removes them all),
|
|
389
|
+
# `{ add: [...], remove: [...] }` changes some
|
|
390
|
+
# @param metadata [Hash, nil] merged into the existing metadata, a nil value removes that key.
|
|
391
|
+
# Pass `replace_metadata: true` to replace it all.
|
|
307
392
|
# @return [Fileboy::ManagedVideo]
|
|
308
|
-
def update_video(video_id, name:)
|
|
309
|
-
|
|
393
|
+
def update_video(video_id, name: nil, description: nil, tags: nil, metadata: nil, replace_metadata: nil)
|
|
394
|
+
body = {}
|
|
395
|
+
body[:name] = name unless name.nil?
|
|
396
|
+
body[:description] = description unless description.nil?
|
|
397
|
+
if tags.is_a?(Hash)
|
|
398
|
+
add = tags[:add] || tags["add"]
|
|
399
|
+
remove = tags[:remove] || tags["remove"]
|
|
400
|
+
body[:tags] = {}
|
|
401
|
+
body[:tags][:add] = Array(add).map(&:to_s) if add
|
|
402
|
+
body[:tags][:remove] = Array(remove).map(&:to_s) if remove
|
|
403
|
+
elsif !tags.nil?
|
|
404
|
+
body[:tags] = Array(tags).map(&:to_s)
|
|
405
|
+
end
|
|
406
|
+
unless metadata.nil?
|
|
407
|
+
body[:metadata] = metadata.to_h.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v.nil? ? nil : v.to_s }
|
|
408
|
+
end
|
|
409
|
+
body[:replaceMetadata] = replace_metadata unless replace_metadata.nil?
|
|
410
|
+
ManagedVideo.from_api(manage(:patch, "/videos/#{path_id(video_id)}", json: body).fetch("data"))
|
|
310
411
|
end
|
|
311
412
|
|
|
312
413
|
# Delete a video with its original, renditions, posters and transcripts. Returns true.
|
|
@@ -340,16 +441,229 @@ module Fileboy
|
|
|
340
441
|
true
|
|
341
442
|
end
|
|
342
443
|
|
|
343
|
-
# How many files and videos the app has and how much they store, with the
|
|
444
|
+
# How many files and videos the app has and how much they store, with the limits set on the app.
|
|
344
445
|
#
|
|
345
446
|
# @return [Fileboy::Usage]
|
|
346
447
|
def fetch_usage
|
|
347
448
|
Usage.from_api(manage(:get, "/usage").fetch("data"))
|
|
348
449
|
end
|
|
349
450
|
|
|
451
|
+
# An image's colour palette: its dominant colour, up to `colors` main colours (0 to 16, default
|
|
452
|
+
# 6) with their share of the image, the named swatches (vibrant, muted...) and a readable text
|
|
453
|
+
# colour for each. Worked out at upload, or on the first request for older images. Files that are
|
|
454
|
+
# not JPEG, PNG, WebP, BMP or TIFF images raise {Fileboy::BadRequestError} with code
|
|
455
|
+
# "unsupported_image". In a browser, use the palette transform on an image URL instead.
|
|
456
|
+
#
|
|
457
|
+
# @return [Hash] the palette as the API returns it, with string keys ("dominant", "colors",
|
|
458
|
+
# "swatches", "transparentShare")
|
|
459
|
+
def fetch_file_palette(id_or_label, colors: nil)
|
|
460
|
+
query = colors.nil? ? nil : "colors=#{Integer(colors)}"
|
|
461
|
+
manage(:get, "/files/#{file_ref(id_or_label)}/palette", query: query).fetch("data")
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# What the app stores by kind (images, other files, video originals, renditions, posters,
|
|
465
|
+
# captions and cached copies), its biggest files and videos, and a daily total. `days` (1 to
|
|
466
|
+
# 365, default 30) is how far back the totals go, `top` (1 to 50, default 10) how many files
|
|
467
|
+
# and videos are listed. `storage_bytes` is the same total {#fetch_usage} reports.
|
|
468
|
+
#
|
|
469
|
+
# @return [Fileboy::StorageUsage]
|
|
470
|
+
def fetch_storage_usage(days: nil, top: nil)
|
|
471
|
+
StorageUsage.from_api(manage(:get, "/usage/storage", query: list_query(days: days, top: top)).fetch("data"))
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# --- Folders (needs the private key) -------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
# One page of your folders, sorted by name. With no options it lists every folder.
|
|
477
|
+
#
|
|
478
|
+
# @param parent_id [String, nil] only the folders directly in this one. "root" for the top level.
|
|
479
|
+
# @param name [String, nil] only folders whose name contains this text
|
|
480
|
+
# @param sort [Symbol, String, nil] :name (default) or :created_at
|
|
481
|
+
# @param path [String, nil] the folder with exactly this path, like "/Products/Shoes". The page
|
|
482
|
+
# holds it or nothing.
|
|
483
|
+
# @return [Fileboy::Page] of {Fileboy::Folder}
|
|
484
|
+
def list_folders(parent_id: nil, name: nil, sort: nil, order: nil, limit: nil, cursor: nil, path: nil)
|
|
485
|
+
query = list_query(
|
|
486
|
+
parentId: parent_id, name: name, sort: camelize(sort), order: order, limit: limit, cursor: cursor, path: path
|
|
487
|
+
)
|
|
488
|
+
Page.from_api(manage(:get, "/folders", query: query), Folder)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Every folder matching the filters, following every page. See {#each_file}.
|
|
492
|
+
def each_folder(**filters, &block)
|
|
493
|
+
return enum_for(:each_folder, **filters) unless block
|
|
494
|
+
|
|
495
|
+
each_in_pages(:list_folders, filters, &block)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# One folder, with its `ancestors`.
|
|
499
|
+
#
|
|
500
|
+
# @return [Fileboy::Folder]
|
|
501
|
+
def fetch_folder(folder_id)
|
|
502
|
+
Folder.from_api(manage(:get, "/folders/#{path_id(folder_id, "folder id")}").fetch("data"))
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Create a folder, at the root or in `parent_id`. Raises {Fileboy::BadRequestError} with code
|
|
506
|
+
# "folder_exists" when that folder already has one of the same name. Pass `path:` instead to
|
|
507
|
+
# create every folder on the path that is missing and reuse those that exist.
|
|
508
|
+
#
|
|
509
|
+
# shoes = fileboy.create_folder(path: "/Products/Shoes")
|
|
510
|
+
# fileboy.create_folder("Sale", parent_id: shoes.id)
|
|
511
|
+
#
|
|
512
|
+
# @return [Fileboy::Folder]
|
|
513
|
+
def create_folder(name = nil, parent_id: nil, path: nil)
|
|
514
|
+
if path
|
|
515
|
+
raise InvalidArgumentError, "Pass a name (and parent_id) or a path, not both" if name || parent_id
|
|
516
|
+
|
|
517
|
+
body = { path: path }
|
|
518
|
+
else
|
|
519
|
+
raise InvalidArgumentError, "No name given" if name.to_s.empty?
|
|
520
|
+
|
|
521
|
+
body = { name: name }
|
|
522
|
+
body[:parentId] = parent_id if parent_id
|
|
523
|
+
end
|
|
524
|
+
Folder.from_api(manage(:post, "/folders", json: body).fetch("data"))
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Rename a folder or move it into `parent_id`. `parent_id: nil` moves it to the root. Its files
|
|
528
|
+
# go with it, and no file URL changes.
|
|
529
|
+
#
|
|
530
|
+
# @return [Fileboy::Folder]
|
|
531
|
+
def update_folder(folder_id, name: nil, parent_id: NOT_GIVEN)
|
|
532
|
+
body = {}
|
|
533
|
+
body[:name] = name unless name.nil?
|
|
534
|
+
body[:parentId] = parent_id unless parent_id.equal?(NOT_GIVEN)
|
|
535
|
+
Folder.from_api(manage(:patch, "/folders/#{path_id(folder_id, "folder id")}", json: body).fetch("data"))
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Delete a folder. By default only an empty folder can be deleted, otherwise it raises
|
|
539
|
+
# {Fileboy::BadRequestError} with code "folder_not_empty". `contents: :move_to_parent` moves its
|
|
540
|
+
# files and subfolders up a level first; `contents: :delete` deletes them, files included (at
|
|
541
|
+
# most 1000 files).
|
|
542
|
+
#
|
|
543
|
+
# @return [Fileboy::FolderDeleteResult]
|
|
544
|
+
def delete_folder(folder_id, contents: nil)
|
|
545
|
+
path = "/folders/#{path_id(folder_id, "folder id")}"
|
|
546
|
+
FolderDeleteResult.from_api(manage(:delete, path, query: list_query(contents: camelize(contents))).fetch("data"))
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# --- Expiring links (needs the private key) ------------------------------------------------
|
|
550
|
+
|
|
551
|
+
# Make an expiring link to a file that you can revoke later. The link works until it expires
|
|
552
|
+
# or is revoked, even if the file is "Signed URLs only" or the app requires signed reads. For a
|
|
553
|
+
# fresh link on every page view, {#sign_url} is the better tool.
|
|
554
|
+
#
|
|
555
|
+
# link = fileboy.create_file_link(id, expires_in: 7 * 24 * 3600, note: "for the auditors")
|
|
556
|
+
# link.url
|
|
557
|
+
#
|
|
558
|
+
# @param id_or_label [String] a file id, or a label name
|
|
559
|
+
# @param expires_in [Integer, nil] whole seconds from now, one minute to 365 days. Give this or `expires_at`.
|
|
560
|
+
# @param expires_at [Time, DateTime, String, nil] when the link stops working; a String must be ISO 8601 with a zone
|
|
561
|
+
# @param transform [String, Hash, Array, nil] an image transform, like "resize:400x_;format:webp",
|
|
562
|
+
# or any form {Fileboy::Transforms.build} takes
|
|
563
|
+
# @param download [Boolean] true to ask the browser to download instead of showing inline
|
|
564
|
+
# @param filename [String, nil] the name the file is served under
|
|
565
|
+
# @param note [String, nil] a note for yourself, shown in the dashboard
|
|
566
|
+
# @return [Fileboy::FileLink]
|
|
567
|
+
def create_file_link(id_or_label, expires_in: nil, expires_at: nil, transform: nil, download: false, filename: nil,
|
|
568
|
+
note: nil)
|
|
569
|
+
raise InvalidArgumentError, "Give exactly one of expires_in or expires_at" if expires_in.nil? == expires_at.nil?
|
|
570
|
+
|
|
571
|
+
body = {}
|
|
572
|
+
body[:expiresIn] = expires_in unless expires_in.nil?
|
|
573
|
+
body[:expiresAt] = iso_time(expires_at) unless expires_at.nil?
|
|
574
|
+
transform = Transforms.build(transform)
|
|
575
|
+
body[:transform] = transform unless transform.empty?
|
|
576
|
+
body[:download] = true if download
|
|
577
|
+
body[:filename] = filename.to_s unless filename.to_s.empty?
|
|
578
|
+
body[:note] = note.to_s unless note.to_s.empty?
|
|
579
|
+
FileLink.from_api(manage(:post, "/files/#{file_ref(id_or_label)}/links", json: body).fetch("data"))
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# One page of a file's links, newest first.
|
|
583
|
+
#
|
|
584
|
+
# @param status [Symbol, String, nil] :active for only the links that still work, or :all (the default)
|
|
585
|
+
# @param limit [Integer, nil] page size, 1 to 100
|
|
586
|
+
# @param cursor [String, nil] the `next_cursor` of the previous page
|
|
587
|
+
# @return [Fileboy::Page] of {Fileboy::FileLink}
|
|
588
|
+
def list_file_links(id_or_label, status: nil, limit: nil, cursor: nil)
|
|
589
|
+
query = list_query(status: status, limit: limit, cursor: cursor)
|
|
590
|
+
Page.from_api(manage(:get, "/files/#{file_ref(id_or_label)}/links", query: query), FileLink)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Every link of a file, following every page. Without a block it returns an Enumerator.
|
|
594
|
+
# Takes the same filters as {#list_file_links}.
|
|
595
|
+
def each_file_link(id_or_label, **filters, &block)
|
|
596
|
+
return enum_for(:each_file_link, id_or_label, **filters) unless block
|
|
597
|
+
|
|
598
|
+
each_in_pages(:list_file_links, filters, id_or_label, &block)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Turn a link off for good. Safe to repeat.
|
|
602
|
+
#
|
|
603
|
+
# @return [Fileboy::FileLink] with status "revoked"
|
|
604
|
+
def revoke_file_link(id_or_label, link_id)
|
|
605
|
+
path = "/files/#{file_ref(id_or_label)}/links/#{path_id(link_id, 'link id')}"
|
|
606
|
+
FileLink.from_api(manage(:delete, path).fetch("data"))
|
|
607
|
+
end
|
|
608
|
+
|
|
350
609
|
# --- Tokens --------------------------------------------------------------------------------
|
|
351
610
|
|
|
352
611
|
# A short-lived signed token for handing a browser limited access. Needs a private key.
|
|
612
|
+
# Sign the URL of a file, image or video so it works when the app requires signed reads.
|
|
613
|
+
# Needs the private key, so do this on your server. Expires in an hour unless you say otherwise.
|
|
614
|
+
#
|
|
615
|
+
# client.sign_url(client.build_image_url(id, transforms: { resize: [400, 400] }), expires_in: 600)
|
|
616
|
+
#
|
|
617
|
+
# @param url [String]
|
|
618
|
+
# @param expires_in [Integer] seconds from now
|
|
619
|
+
# @param expires_at [Time, Integer] an exact expiry
|
|
620
|
+
# @param ip [String, nil] only this address or CIDR range can use the URL
|
|
621
|
+
# @return [String]
|
|
622
|
+
def sign_url(url, expires_in: Signing::DEFAULT_EXPIRES_IN, expires_at: nil, ip: nil)
|
|
623
|
+
raise ConfigurationError, "Signing needs the app's private key. Create the client with private_key:" unless @private_key
|
|
624
|
+
|
|
625
|
+
Signing.sign_url(url, @private_key, expires_in: expires_in, expires_at: expires_at, ip: ip)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# The file's share page: a page anyone can open, with a preview and a download button. It only
|
|
629
|
+
# works once the page is turned on (`update_file(id, share_page: true)` or the dashboard). For a
|
|
630
|
+
# file that needs a signed URL, send {#sign_share_page_url} instead.
|
|
631
|
+
# @return [String]
|
|
632
|
+
def build_share_page_url(file_id)
|
|
633
|
+
raise InvalidArgumentError, "No file id given" if file_id.to_s.empty?
|
|
634
|
+
|
|
635
|
+
"#{@website_url}/f/#{ERB::Util.url_encode(file_id.to_s)}"
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# A share page link for a file that needs a signed URL (Signed URLs only, or an app that requires
|
|
639
|
+
# signed reads). The page's preview and download stop working when the link expires. Needs the
|
|
640
|
+
# private key, so do this on your server.
|
|
641
|
+
# @return [String]
|
|
642
|
+
def sign_share_page_url(file_id, expires_in: Signing::DEFAULT_EXPIRES_IN, expires_at: nil, ip: nil)
|
|
643
|
+
sign_url(build_share_page_url(file_id), expires_in: expires_in, expires_at: expires_at, ip: ip)
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# The query string that unlocks every URL of one video (manifests, segments, poster, captions
|
|
647
|
+
# and data). Pass it to the video player or add it to any /videos/{id}/... URL.
|
|
648
|
+
# @return [String]
|
|
649
|
+
def sign_video(video_id, expires_in: Signing::DEFAULT_EXPIRES_IN, expires_at: nil, ip: nil)
|
|
650
|
+
raise ConfigurationError, "Signing needs the app's private key. Create the client with private_key:" unless @private_key
|
|
651
|
+
|
|
652
|
+
Signing.sign_video_query(video_id, @private_key, expires_in: expires_in, expires_at: expires_at, ip: ip)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# Whether reads of this app need a signed URL.
|
|
656
|
+
# @return [Boolean]
|
|
657
|
+
def fetch_require_signed_reads
|
|
658
|
+
manage(:get, "/app/settings").dig("data", "requireSignedReads")
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Turn signed reads on or off. Turning them on stops every unsigned link working at once.
|
|
662
|
+
# @return [Boolean] the new value
|
|
663
|
+
def update_require_signed_reads(value)
|
|
664
|
+
manage(:patch, "/app/settings", json: { requireSignedReads: value }).dig("data", "requireSignedReads")
|
|
665
|
+
end
|
|
666
|
+
|
|
353
667
|
def create_access_token(read: false, write: false, upload: false, expires_in: 60)
|
|
354
668
|
raise ConfigurationError, "Cannot create access tokens without a private key" unless @private_key
|
|
355
669
|
|
|
@@ -388,6 +702,10 @@ module Fileboy
|
|
|
388
702
|
|
|
389
703
|
if key == :metadata
|
|
390
704
|
value.each { |k, v| params << ["metadata[#{k}]", v.to_s] }
|
|
705
|
+
elsif key == :tags
|
|
706
|
+
# Tags can't contain commas, so one comma-separated parameter is unambiguous
|
|
707
|
+
tags = Array(value).map(&:to_s)
|
|
708
|
+
params << ["tags", tags.join(",")] unless tags.empty?
|
|
391
709
|
elsif value.respond_to?(:utc)
|
|
392
710
|
params << [key.to_s, value.utc.iso8601]
|
|
393
711
|
else
|
|
@@ -401,10 +719,10 @@ module Fileboy
|
|
|
401
719
|
value&.to_s&.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
|
|
402
720
|
end
|
|
403
721
|
|
|
404
|
-
def each_in_pages(method, filters)
|
|
722
|
+
def each_in_pages(method, filters, *args)
|
|
405
723
|
cursor = filters.delete(:cursor)
|
|
406
724
|
loop do
|
|
407
|
-
page = public_send(method, **filters, cursor: cursor)
|
|
725
|
+
page = public_send(method, *args, **filters, cursor: cursor)
|
|
408
726
|
page.items.each { |item| yield item }
|
|
409
727
|
cursor = page.next_cursor
|
|
410
728
|
break if cursor.nil? || cursor.empty?
|
|
@@ -427,6 +745,23 @@ module Fileboy
|
|
|
427
745
|
ERB::Util.url_encode(value.to_s)
|
|
428
746
|
end
|
|
429
747
|
|
|
748
|
+
def folder_fields(folder_id, folder_path)
|
|
749
|
+
raise InvalidArgumentError, "Pass folder_id or folder_path, not both" if folder_id && folder_path
|
|
750
|
+
|
|
751
|
+
fields = {}
|
|
752
|
+
fields[:folderId] = folder_id if folder_id
|
|
753
|
+
fields[:folderPath] = folder_path if folder_path
|
|
754
|
+
fields
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# A Time, DateTime or Date as ISO 8601 in UTC with a trailing Z. Strings are sent as they are.
|
|
758
|
+
def iso_time(value)
|
|
759
|
+
return value if value.is_a?(String)
|
|
760
|
+
|
|
761
|
+
value = value.to_time if !value.is_a?(Time) && value.respond_to?(:to_time)
|
|
762
|
+
value.respond_to?(:getutc) ? value.getutc.iso8601 : value.to_s
|
|
763
|
+
end
|
|
764
|
+
|
|
430
765
|
def stringify(metadata)
|
|
431
766
|
metadata.to_h.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v.to_s }
|
|
432
767
|
end
|