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.
@@ -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 is only filled by the management calls.
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
- # Plan limits. A nil limit means none is set.
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