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.
@@ -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
- def initialize(public_key:, private_key: nil, api_url: DEFAULT_API_URL, timeout: 60, open_timeout: 10)
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
- def upload_file_from_url(url, name: nil, metadata: nil, expect: nil)
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. Other options are as in {#list_files}.
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, created_before: 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
- # Rename a video.
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
- ManagedVideo.from_api(manage(:patch, "/videos/#{path_id(video_id)}", json: { name: name }).fetch("data"))
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 plan limits.
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