studio-engine 0.95.0 → 0.96.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 +131 -16
- data/README.md +193 -12
- data/app/javascript/studio/application.js +2 -0
- data/app/javascript/studio/controllers/modal_host_controller.js +40 -0
- data/app/javascript/studio/modal_host.js +574 -0
- data/app/models/concerns/sluggable.rb +11 -1
- data/app/models/studio/knowledge_doc/recording.rb +214 -0
- data/app/models/studio/knowledge_doc.rb +3 -0
- data/app/views/layouts/studio/_head.html.erb +10 -2
- data/app/views/studio/emails/index.html.erb +13 -0
- data/app/views/studio/modals/_host.html.erb +33 -597
- data/app/views/studio/modals/_load_convention.html.erb +6 -27
- data/app/views/studio/modals/_scoped_host.html.erb +14 -257
- data/app/views/studio/modals/auth/_resend_footer.html.erb +2 -2
- data/app/views/studio/modals/blocks/_processing_card.html.erb +1 -1
- data/app/views/style/_modals.html.erb +12 -15
- data/db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb +19 -0
- data/lib/studio/component_gallery.rb +13 -3
- data/lib/studio/email_image_generator.rb +125 -0
- data/lib/studio/engine.rb +2 -5
- data/lib/studio/knowledge_recording.rb +579 -0
- data/lib/studio/knowledge_transcript.rb +185 -0
- data/lib/studio/s3/multipart.rb +193 -0
- data/lib/studio/theme_resolver.rb +11 -22
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +33 -1
- data/lib/tasks/studio_knowledge.rake +89 -0
- metadata +10 -1
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Studio
|
|
6
|
+
# The rules for a knowledge document's RECORDING (the audio or video of a
|
|
7
|
+
# meeting whose transcript is the document): what counts as one, how big it
|
|
8
|
+
# may be, and how one is fetched from a URL. Studio::KnowledgeDoc#attach_recording!
|
|
9
|
+
# and #attach_recording_from_url! are the callers; the upload itself is
|
|
10
|
+
# Studio::S3::Multipart.
|
|
11
|
+
#
|
|
12
|
+
# Loaded on first use (`require "studio/knowledge_recording"`), not by
|
|
13
|
+
# `require "studio"`, so an app that never attaches a recording pays nothing.
|
|
14
|
+
#
|
|
15
|
+
# WHAT COUNTS AS A RECORDING. The file's own first bytes decide, never a
|
|
16
|
+
# name alone and never a header a remote server sent:
|
|
17
|
+
#
|
|
18
|
+
# container first bytes stored as
|
|
19
|
+
# MP4 / M4V / MOV / M4A ....ftyp video/mp4, video/quicktime, audio/mp4
|
|
20
|
+
# WebM 1A 45 DF A3 video/webm, audio/webm
|
|
21
|
+
# MP3 ID3, or an MPEG frame sync audio/mpeg
|
|
22
|
+
# WAV RIFF....WAVE audio/wav
|
|
23
|
+
# Ogg OggS audio/ogg, video/ogg
|
|
24
|
+
#
|
|
25
|
+
# The extension picks WITHIN a container (`.m4a` is audio/mp4, `.mov` is
|
|
26
|
+
# video/quicktime) and must agree with it: a file named `.mp4` whose bytes
|
|
27
|
+
# are WebM is refused, and so is any extension off the list. The stored
|
|
28
|
+
# content type always comes from TYPES, so the bucket never serves a
|
|
29
|
+
# recording as text/html or anything else a browser would run.
|
|
30
|
+
#
|
|
31
|
+
# THE BOUNDS. README, "Knowledge recordings", walks every phase of a fetch
|
|
32
|
+
# and of an upload with its time bound, its size bound and what is left
|
|
33
|
+
# behind if the process dies there. The constants are below; `fetch` and
|
|
34
|
+
# Meter say how each is enforced.
|
|
35
|
+
module KnowledgeRecording
|
|
36
|
+
class Error < StandardError; end
|
|
37
|
+
# The source was refused before, or instead of, being stored.
|
|
38
|
+
class Refused < Error; end
|
|
39
|
+
class NotARecording < Refused; end
|
|
40
|
+
class TooLarge < Refused; end
|
|
41
|
+
class FetchFailed < Error; end
|
|
42
|
+
|
|
43
|
+
# 4 GB. Recordings run 100 MB to 1 GB; this leaves room for a long
|
|
44
|
+
# meeting in high definition and still fits a dyno's ephemeral disk.
|
|
45
|
+
# It must stay at or under Studio::S3::Trash::MAX_COPY_BYTES (5 GiB): a
|
|
46
|
+
# replaced recording is trashed with one CopyObject, and a larger object
|
|
47
|
+
# could be stored but never replaced. The suite pins the two together.
|
|
48
|
+
MAX_BYTES = 4 * 1024 * 1024 * 1024
|
|
49
|
+
# Long enough for a presigned download URL, short enough that a redirect
|
|
50
|
+
# cannot hand us a megabyte of Location header to parse.
|
|
51
|
+
MAX_URL_BYTES = 8_192
|
|
52
|
+
MAX_REDIRECTS = 3
|
|
53
|
+
MAX_ADDRESSES = 4
|
|
54
|
+
# One hour for the whole fetch: every connect, every response's headers,
|
|
55
|
+
# every redirect and the body. Enforced on every read and write of the
|
|
56
|
+
# socket (Meter), not only as body chunks arrive. The one thing that can
|
|
57
|
+
# run past it is a DNS lookup already in flight (ImageCache's
|
|
58
|
+
# RESOLVE_DEADLINE, 6 s).
|
|
59
|
+
FETCH_DEADLINE = 3_600
|
|
60
|
+
# Seconds for one address to accept a connection and finish TLS.
|
|
61
|
+
CONNECT_DEADLINE = 20
|
|
62
|
+
# Seconds from sending a request to having its status line and headers,
|
|
63
|
+
# any 1xx responses included.
|
|
64
|
+
HEADER_DEADLINE = 30
|
|
65
|
+
# Bytes that may come off the socket without becoming body: the status
|
|
66
|
+
# line, headers and 1xx responses of one hop together, and after that the
|
|
67
|
+
# chunk-size lines and trailers between two pieces of body. Net::HTTP
|
|
68
|
+
# buffers these in memory with no limit of its own.
|
|
69
|
+
MAX_UNDELIVERED_BYTES = 64 * 1024
|
|
70
|
+
# The only port fetched from. A download link is https on 443; any other
|
|
71
|
+
# port would let the fetch be pointed at arbitrary services on public hosts.
|
|
72
|
+
PORT = 443
|
|
73
|
+
HEAD_BYTES = 16
|
|
74
|
+
MAX_FILENAME_CHARS = 80
|
|
75
|
+
# Refused before any bytes are read when the declared type is something
|
|
76
|
+
# else. A server may say nothing, or octet-stream; the bytes still decide.
|
|
77
|
+
GENERIC_CONTENT_TYPES = %w[application/octet-stream binary/octet-stream].freeze
|
|
78
|
+
REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
79
|
+
|
|
80
|
+
# extension => the content type it is stored under.
|
|
81
|
+
TYPES = {
|
|
82
|
+
"mp4" => "video/mp4",
|
|
83
|
+
"m4v" => "video/mp4",
|
|
84
|
+
"mov" => "video/quicktime",
|
|
85
|
+
"m4a" => "audio/mp4",
|
|
86
|
+
"webm" => "video/webm",
|
|
87
|
+
"weba" => "audio/webm",
|
|
88
|
+
"mp3" => "audio/mpeg",
|
|
89
|
+
"wav" => "audio/wav",
|
|
90
|
+
"ogg" => "audio/ogg",
|
|
91
|
+
"oga" => "audio/ogg",
|
|
92
|
+
"ogv" => "video/ogg"
|
|
93
|
+
}.freeze
|
|
94
|
+
|
|
95
|
+
# container => the extensions that may name it. The first is the default
|
|
96
|
+
# for a source with no extension.
|
|
97
|
+
CONTAINERS = {
|
|
98
|
+
isobmff: %w[mp4 m4v mov m4a],
|
|
99
|
+
webm: %w[webm weba],
|
|
100
|
+
mp3: %w[mp3],
|
|
101
|
+
wav: %w[wav],
|
|
102
|
+
ogg: %w[ogg oga ogv]
|
|
103
|
+
}.freeze
|
|
104
|
+
|
|
105
|
+
# ISO base media brands that name something other than the default.
|
|
106
|
+
ISOBMFF_BRANDS = { "qt " => "mov", "M4A " => "m4a", "M4B " => "m4a" }.freeze
|
|
107
|
+
# Second byte of an MPEG-1/2/2.5 Layer III frame header after 0xFF.
|
|
108
|
+
MP3_SYNC_BYTES = [0xFB, 0xFA, 0xF3, 0xF2, 0xE3, 0xE2].freeze
|
|
109
|
+
EBML_MAGIC = "\x1A\x45\xDF\xA3".b.freeze
|
|
110
|
+
|
|
111
|
+
# What `identify!` answers: the content type to store, the extension the
|
|
112
|
+
# object key ends in, and the size read from the open file.
|
|
113
|
+
Identified = Struct.new(:content_type, :extension, :byte_size, keyword_init: true)
|
|
114
|
+
|
|
115
|
+
class << self
|
|
116
|
+
# The container the first bytes name, or nil. `head` is at most
|
|
117
|
+
# HEAD_BYTES long; nothing here scans.
|
|
118
|
+
def container_of(head)
|
|
119
|
+
head = head.to_s.b
|
|
120
|
+
return :isobmff if head.bytesize >= 12 && head.byteslice(4, 4) == "ftyp"
|
|
121
|
+
return :webm if head.start_with?(EBML_MAGIC)
|
|
122
|
+
return :wav if head.bytesize >= 12 && head.start_with?("RIFF") && head.byteslice(8, 4) == "WAVE"
|
|
123
|
+
return :ogg if head.start_with?("OggS")
|
|
124
|
+
return :mp3 if head.start_with?("ID3")
|
|
125
|
+
return :mp3 if head.bytesize >= 2 && head.getbyte(0) == 0xFF && MP3_SYNC_BYTES.include?(head.getbyte(1))
|
|
126
|
+
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Reads HEAD_BYTES of the file at `path` and answers an Identified, or
|
|
131
|
+
# raises: NotARecording when the bytes are no container on the list or
|
|
132
|
+
# the extension disagrees with them, TooLarge past MAX_BYTES.
|
|
133
|
+
#
|
|
134
|
+
# `filename` is the name whose extension is judged (default: the
|
|
135
|
+
# path's). A name with no extension is judged by its bytes alone.
|
|
136
|
+
def identify!(path, filename: nil, max_bytes: MAX_BYTES)
|
|
137
|
+
path = path.to_s
|
|
138
|
+
raise Refused, "no such file: #{path}" unless File.file?(path)
|
|
139
|
+
|
|
140
|
+
head, size = File.open(path, "rb") { |file| [file.read(HEAD_BYTES), file.size] }
|
|
141
|
+
raise NotARecording, "#{File.basename(path)} is empty" if size.zero?
|
|
142
|
+
raise TooLarge, "#{File.basename(path)} is #{size} bytes, over the #{max_bytes}-byte cap" if size > max_bytes
|
|
143
|
+
|
|
144
|
+
container = container_of(head) ||
|
|
145
|
+
raise(NotARecording, "#{File.basename(path)} is not a recording: its first bytes are not MP4, MOV, M4A, " \
|
|
146
|
+
"WebM, MP3, WAV or Ogg")
|
|
147
|
+
extension = extension_for(container, head, filename || path)
|
|
148
|
+
Identified.new(content_type: TYPES.fetch(extension), extension: extension, byte_size: size)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A file name for the object key: the source's base name, cut to
|
|
152
|
+
# MAX_FILENAME_CHARS, with the extension the bytes earned. The caller
|
|
153
|
+
# sanitizes it (Studio::KnowledgeDoc.sanitize_filename).
|
|
154
|
+
def filename_for(name, extension)
|
|
155
|
+
base = File.basename(name.to_s, ".*")[0, MAX_FILENAME_CHARS].to_s
|
|
156
|
+
base = "recording" if base.strip.empty?
|
|
157
|
+
"#{base}.#{extension}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# `url` as it may be printed or logged: scheme and host, nothing else. A
|
|
161
|
+
# download URL carries its credential in the query or in the path, and
|
|
162
|
+
# every failure message reaches a log drain.
|
|
163
|
+
def redact(url)
|
|
164
|
+
require "uri"
|
|
165
|
+
uri = URI.parse(url.to_s)
|
|
166
|
+
return "(a URL with no host)" if uri.host.to_s.empty?
|
|
167
|
+
|
|
168
|
+
"#{uri.scheme}://#{uri.host[0, 253]}/…"
|
|
169
|
+
rescue URI::InvalidURIError
|
|
170
|
+
"(an unparsable URL)"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A link a page may render: an http(s) URL with a host, at most
|
|
174
|
+
# MAX_URL_BYTES long. Answers the stripped String, or nil. This is the
|
|
175
|
+
# check on recording_source_url, which is DISPLAYED and never fetched,
|
|
176
|
+
# so it asks only that the link cannot be `javascript:` or `data:`.
|
|
177
|
+
def web_link(value)
|
|
178
|
+
require "uri"
|
|
179
|
+
text = value.to_s.strip
|
|
180
|
+
return nil if text.empty? || text.bytesize > MAX_URL_BYTES
|
|
181
|
+
|
|
182
|
+
uri = URI.parse(text)
|
|
183
|
+
%w[http https].include?(uri.scheme) && !uri.host.to_s.empty? ? text : nil
|
|
184
|
+
rescue URI::InvalidURIError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Downloads `url` to a temporary file and yields its path. The file is
|
|
189
|
+
# deleted when the block returns or raises (an interrupt included).
|
|
190
|
+
#
|
|
191
|
+
# THIS IS A SERVER-SIDE FETCH OF A CALLER-SUPPLIED URL, run with the
|
|
192
|
+
# bucket's credentials in the process. Every hop, the first and each
|
|
193
|
+
# redirect, goes through Studio::ImageCache.vet_source_url! (the engine's
|
|
194
|
+
# one SSRF guard: public addresses only, however the host is written and
|
|
195
|
+
# wherever its name resolves), must be https on port 443, may carry no
|
|
196
|
+
# user:password, and is connected to an address that hop was vetted
|
|
197
|
+
# against (ImageCache.pinned_http), so the name cannot resolve somewhere
|
|
198
|
+
# else between the check and the connection. A host that vets to NO
|
|
199
|
+
# address (no resolver) is refused rather than connected to by name.
|
|
200
|
+
#
|
|
201
|
+
# WHAT IS YIELDED IS A COMPLETE BODY OR NOTHING. A response that declares
|
|
202
|
+
# a Content-Length and sends any other number of bytes raises FetchFailed
|
|
203
|
+
# before the block runs; so does a chunked body that ends before its last
|
|
204
|
+
# chunk. A body with NO declared length and no chunking ends when the
|
|
205
|
+
# server closes the connection, and a cut there cannot be told from the
|
|
206
|
+
# end: that one case is unchecked.
|
|
207
|
+
#
|
|
208
|
+
# Nothing of the URL's path is used to name the file: a path can carry
|
|
209
|
+
# the credential.
|
|
210
|
+
#
|
|
211
|
+
# `resolver:` is the callable names resolve with. The default is the
|
|
212
|
+
# guard's own, or the system resolver where the guard's default is none
|
|
213
|
+
# (a Rails test environment): this fetch never runs unresolved.
|
|
214
|
+
def fetch(url, max_bytes: MAX_BYTES, resolver: default_resolver, deadline: FETCH_DEADLINE,
|
|
215
|
+
header_deadline: HEADER_DEADLINE)
|
|
216
|
+
require "tempfile"
|
|
217
|
+
stop_at = monotonic + deadline
|
|
218
|
+
current = url.to_s
|
|
219
|
+
answer = nil
|
|
220
|
+
|
|
221
|
+
Tempfile.create(["studio-knowledge-recording", ".part"]) do |sink|
|
|
222
|
+
sink.binmode
|
|
223
|
+
# Bounded: MAX_REDIRECTS + 1 requests at most.
|
|
224
|
+
(MAX_REDIRECTS + 1).times do
|
|
225
|
+
raise FetchFailed, "#{redact(url)} did not finish within #{deadline} seconds" if monotonic > stop_at
|
|
226
|
+
|
|
227
|
+
vetted = vet!(current, resolver)
|
|
228
|
+
answer = request(vetted, sink, max_bytes, stop_at, header_deadline)
|
|
229
|
+
break unless answer.is_a?(String)
|
|
230
|
+
|
|
231
|
+
current = join_location(vetted.uri, answer)
|
|
232
|
+
end
|
|
233
|
+
raise FetchFailed, "too many redirects (more than #{MAX_REDIRECTS}) from #{redact(url)}" if answer.is_a?(String)
|
|
234
|
+
|
|
235
|
+
sink.flush
|
|
236
|
+
return yield(sink.path)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def default_resolver
|
|
241
|
+
require "studio/image_cache"
|
|
242
|
+
Studio::ImageCache.resolver || Studio::ImageCache::SYSTEM_RESOLVER
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The connection for one vetted address. A seam: the suite replaces it;
|
|
246
|
+
# production is always the guard's pinned connection.
|
|
247
|
+
def connection(uri, address)
|
|
248
|
+
Studio::ImageCache.pinned_http(uri, address)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def monotonic
|
|
252
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private
|
|
256
|
+
|
|
257
|
+
def extension_for(container, head, name)
|
|
258
|
+
allowed = CONTAINERS.fetch(container)
|
|
259
|
+
given = File.extname(name.to_s).delete_prefix(".").downcase
|
|
260
|
+
return default_extension(container, head) if given.empty?
|
|
261
|
+
return given if allowed.include?(given)
|
|
262
|
+
|
|
263
|
+
raise NotARecording, "the name ends in .#{given[0, 16]} but the bytes are #{container} " \
|
|
264
|
+
"(expected one of: #{allowed.map { |ext| ".#{ext}" }.join(', ')})"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def default_extension(container, head)
|
|
268
|
+
return CONTAINERS.fetch(container).first unless container == :isobmff
|
|
269
|
+
|
|
270
|
+
ISOBMFF_BRANDS.fetch(head.to_s.b.byteslice(8, 4).to_s, "mp4")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# One hop through the guard, plus what this fetch adds to it: https on
|
|
274
|
+
# port 443 only, no credentials in the URL, a length cap, and at least
|
|
275
|
+
# one vetted address to pin the connection to.
|
|
276
|
+
def vet!(url, resolver)
|
|
277
|
+
require "studio/image_cache"
|
|
278
|
+
raise Refused, "URL is #{url.bytesize} bytes, over the #{MAX_URL_BYTES}-byte cap" if url.bytesize > MAX_URL_BYTES
|
|
279
|
+
# Asked of the text first so a URL that is not https costs no DNS
|
|
280
|
+
# lookup. The parsed scheme is checked again below; that one decides.
|
|
281
|
+
unless url[0, 8].to_s.casecmp?("https://")
|
|
282
|
+
raise Refused, "refused #{redact(url)}: a recording is fetched over https only"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
vetted =
|
|
286
|
+
begin
|
|
287
|
+
Studio::ImageCache.vet_source_url!(url, resolver: resolver)
|
|
288
|
+
rescue Studio::ImageCache::InvalidSourceURL => error
|
|
289
|
+
raise Refused, "refused #{redact(url)}: #{error.message.gsub(url.inspect, redact(url))}"
|
|
290
|
+
rescue URI::InvalidURIError
|
|
291
|
+
raise Refused, "refused an unparsable URL"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
raise Refused, "refused #{redact(url)}: a recording is fetched over https only" unless vetted.uri.scheme == "https"
|
|
295
|
+
raise Refused, "refused #{redact(url)}: only port #{PORT} is fetched from" unless vetted.uri.port == PORT
|
|
296
|
+
raise Refused, "refused #{redact(url)}: the URL carries a user or password" if vetted.uri.userinfo
|
|
297
|
+
raise Refused, "refused #{redact(url)}: its host was vetted against no address" if vetted.addresses.empty?
|
|
298
|
+
|
|
299
|
+
vetted
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def join_location(base, location)
|
|
303
|
+
raise Refused, "redirect Location is over the #{MAX_URL_BYTES}-byte cap" if location.bytesize > MAX_URL_BYTES
|
|
304
|
+
|
|
305
|
+
URI.join(base, location).to_s
|
|
306
|
+
rescue URI::InvalidURIError
|
|
307
|
+
raise Refused, "refused a redirect to an unparsable URL from #{redact(base.to_s)}"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# One GET to the first vetted address that accepts a connection, at most
|
|
311
|
+
# MAX_ADDRESSES of them. Answers :stored, or the Location of a redirect.
|
|
312
|
+
# Only a failure to CONNECT moves on to the next address; once a request
|
|
313
|
+
# has been sent, a failure is the fetch's failure.
|
|
314
|
+
def request(vetted, sink, max_bytes, stop_at, header_deadline)
|
|
315
|
+
candidates = vetted.addresses.first(MAX_ADDRESSES)
|
|
316
|
+
|
|
317
|
+
candidates.each_with_index do |address, index|
|
|
318
|
+
sink.rewind
|
|
319
|
+
sink.truncate(0)
|
|
320
|
+
return get(vetted.uri, address, sink, max_bytes, stop_at, header_deadline)
|
|
321
|
+
rescue Unreachable => error
|
|
322
|
+
next unless index == candidates.size - 1
|
|
323
|
+
|
|
324
|
+
raise FetchFailed, "could not connect to #{vetted.host}: #{error.message}"
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# An address that would not take a connection in time.
|
|
329
|
+
Unreachable = Class.new(StandardError)
|
|
330
|
+
ConnectTimedOut = Class.new(StandardError)
|
|
331
|
+
private_constant :Unreachable, :ConnectTimedOut
|
|
332
|
+
|
|
333
|
+
# A connected, TLS-verified Net::HTTP for one address, or Unreachable.
|
|
334
|
+
#
|
|
335
|
+
# The Timeout here is the ONE place a timeout wrapper is used, and it
|
|
336
|
+
# covers only the connect and the TLS handshake: Net::HTTP bounds the TCP
|
|
337
|
+
# connect itself but gives each wait of the handshake its own
|
|
338
|
+
# open_timeout, so a peer that drips handshake bytes is otherwise bounded
|
|
339
|
+
# only by OpenSSL's size limits. It is safe here because nothing of ours
|
|
340
|
+
# exists yet to be left half-done: no request has been sent and the sink
|
|
341
|
+
# is empty, and Net::HTTP#connect closes the socket on any StandardError
|
|
342
|
+
# (ConnectTimedOut is one) before re-raising. After this, no timeout
|
|
343
|
+
# wrapper is used at all; time is bounded inside the reads (Meter).
|
|
344
|
+
def open!(uri, address, stop_at)
|
|
345
|
+
require "net/http"
|
|
346
|
+
require "timeout"
|
|
347
|
+
remaining = stop_at - monotonic
|
|
348
|
+
raise FetchFailed, "#{redact(uri.to_s)} did not finish in time" if remaining <= 0
|
|
349
|
+
|
|
350
|
+
http = connection(uri, address)
|
|
351
|
+
# Net::HTTP retries an idempotent request once on a read timeout or a
|
|
352
|
+
# reset, on a NEW socket, and calls the response block again: the body
|
|
353
|
+
# would be written to the sink twice and the retry would be unmetered.
|
|
354
|
+
http.max_retries = 0
|
|
355
|
+
Timeout.timeout([CONNECT_DEADLINE, remaining].min, ConnectTimedOut) { http.start }
|
|
356
|
+
http
|
|
357
|
+
rescue ConnectTimedOut, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EADDRNOTAVAIL,
|
|
358
|
+
Net::OpenTimeout, SocketError => error
|
|
359
|
+
# The deadline can land just as the connection finishes; a socket
|
|
360
|
+
# that did open is closed, not left for the collector.
|
|
361
|
+
hang_up(http)
|
|
362
|
+
raise Unreachable, error.class.name
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# THE RESPONSE BODY IS NEVER READ UNBOUNDED. Net::HTTP drains whatever a
|
|
366
|
+
# block leaves unread, into memory, when the block returns; so a redirect
|
|
367
|
+
# or an error is left by `throw`, which unwinds past the drain, and the
|
|
368
|
+
# connection is closed with the body unread.
|
|
369
|
+
#
|
|
370
|
+
# Every failure after the connection is made leaves here as FetchFailed
|
|
371
|
+
# (or a Refused), naming the host and never the rest of the URL.
|
|
372
|
+
def get(uri, address, sink, max_bytes, stop_at, header_deadline)
|
|
373
|
+
require "openssl"
|
|
374
|
+
http = open!(uri, address, stop_at)
|
|
375
|
+
meter = Meter.install!(http, [stop_at, monotonic + header_deadline].min)
|
|
376
|
+
|
|
377
|
+
catch(:answered) do
|
|
378
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
379
|
+
# identity: Net::HTTP would otherwise ask for gzip and inflate it,
|
|
380
|
+
# and the byte cap must count what arrives, not what it expands to.
|
|
381
|
+
request["Accept-Encoding"] = "identity"
|
|
382
|
+
request["User-Agent"] = "studio-engine knowledge recording fetch"
|
|
383
|
+
http.request(request) do |response|
|
|
384
|
+
status = response.code.to_i
|
|
385
|
+
if REDIRECT_STATUSES.include?(status) && !response["location"].to_s.empty?
|
|
386
|
+
throw :answered, response["location"].to_s
|
|
387
|
+
end
|
|
388
|
+
raise FetchFailed, "#{redact(uri.to_s)} answered #{status}" unless status == 200
|
|
389
|
+
|
|
390
|
+
declared = check_headers!(response, uri, max_bytes)
|
|
391
|
+
# The headers are in: from here the clock is the whole fetch's.
|
|
392
|
+
meter.delivered!
|
|
393
|
+
meter.stop_at = stop_at
|
|
394
|
+
store(response, sink, max_bytes, meter, uri, declared)
|
|
395
|
+
throw :answered, :stored
|
|
396
|
+
end
|
|
397
|
+
raise FetchFailed, "#{redact(uri.to_s)} gave no response"
|
|
398
|
+
end
|
|
399
|
+
rescue Unreachable, Error
|
|
400
|
+
raise
|
|
401
|
+
rescue Meter::Expired
|
|
402
|
+
raise FetchFailed, "#{redact(uri.to_s)} did not finish in time (#{HEADER_DEADLINE} seconds for a " \
|
|
403
|
+
"response's headers, #{FETCH_DEADLINE} for the whole fetch)"
|
|
404
|
+
rescue Meter::Overrun
|
|
405
|
+
raise FetchFailed, "#{redact(uri.to_s)} sent more than #{MAX_UNDELIVERED_BYTES} bytes of headers or framing"
|
|
406
|
+
rescue Net::ReadTimeout, Net::WriteTimeout, Net::OpenTimeout, Net::ProtocolError, Net::HTTPBadResponse,
|
|
407
|
+
Net::HTTPHeaderSyntaxError, EOFError, IOError, SystemCallError, SocketError, Timeout::Error,
|
|
408
|
+
OpenSSL::SSL::SSLError => error
|
|
409
|
+
detail = error.message.to_s[0, 160].gsub(/[^[:print:]]/, "?")
|
|
410
|
+
raise FetchFailed, "#{redact(uri.to_s)} failed mid-fetch: #{error.class}: #{detail}"
|
|
411
|
+
ensure
|
|
412
|
+
hang_up(http)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def hang_up(http)
|
|
416
|
+
http.finish if http&.started?
|
|
417
|
+
rescue IOError, SystemCallError
|
|
418
|
+
nil
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Answers the declared Content-Length (an Integer), or nil when the body
|
|
422
|
+
# is chunked or declares none.
|
|
423
|
+
def check_headers!(response, uri, max_bytes)
|
|
424
|
+
encoding = response["content-encoding"].to_s.strip.downcase
|
|
425
|
+
unless encoding.empty? || encoding == "identity"
|
|
426
|
+
raise Refused, "#{redact(uri.to_s)} answered with Content-Encoding #{encoding[0, 32].inspect}; only identity is read"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
declared = response["content-type"].to_s.split(";").first.to_s.strip.downcase
|
|
430
|
+
unless declared.empty? || declared.start_with?("video/", "audio/") || GENERIC_CONTENT_TYPES.include?(declared)
|
|
431
|
+
raise NotARecording, "#{redact(uri.to_s)} answered #{declared[0, 64].inspect}, not audio or video"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
return nil if response.chunked?
|
|
435
|
+
|
|
436
|
+
length = response["content-length"]
|
|
437
|
+
return nil if length.nil?
|
|
438
|
+
# Two Content-Length headers arrive joined by a comma; that, a sign or
|
|
439
|
+
# a blank is a response this fetch cannot measure, so it is refused.
|
|
440
|
+
raise Refused, "#{redact(uri.to_s)} sent a Content-Length that is not a number" unless length.match?(/\A\d{1,20}\z/)
|
|
441
|
+
raise TooLarge, "#{redact(uri.to_s)} declares #{length} bytes, over the #{max_bytes}-byte cap" if length.to_i > max_bytes
|
|
442
|
+
|
|
443
|
+
length.to_i
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Streams the body to `sink`. Each chunk is counted BEFORE it is written,
|
|
447
|
+
# so the file on disk never passes max_bytes; the first HEAD_BYTES are
|
|
448
|
+
# judged as soon as they arrive, so a page of HTML sent as octet-stream
|
|
449
|
+
# stops at its first chunk; and each chunk tells the meter that what came
|
|
450
|
+
# off the socket became body. When a length was declared, the body must
|
|
451
|
+
# be exactly that long: Net::HTTP itself reports no error for a body that
|
|
452
|
+
# ends early.
|
|
453
|
+
def store(response, sink, max_bytes, meter, uri, declared)
|
|
454
|
+
written = 0
|
|
455
|
+
head = +"".b
|
|
456
|
+
judged = false
|
|
457
|
+
response.read_body do |chunk|
|
|
458
|
+
meter.delivered!
|
|
459
|
+
written += chunk.bytesize
|
|
460
|
+
raise TooLarge, "#{redact(uri.to_s)} sent more than the #{max_bytes}-byte cap" if written > max_bytes
|
|
461
|
+
|
|
462
|
+
unless judged
|
|
463
|
+
head << chunk.byteslice(0, HEAD_BYTES - head.bytesize)
|
|
464
|
+
if head.bytesize >= HEAD_BYTES
|
|
465
|
+
judged = true
|
|
466
|
+
container_of(head) || raise(NotARecording, "#{redact(uri.to_s)} did not send a recording")
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
sink.write(chunk)
|
|
470
|
+
end
|
|
471
|
+
if declared && written != declared
|
|
472
|
+
raise FetchFailed, "#{redact(uri.to_s)} sent #{written} bytes of #{declared} declared; the download was cut short"
|
|
473
|
+
end
|
|
474
|
+
raise NotARecording, "#{redact(uri.to_s)} sent #{written} bytes, too few to be a recording" unless judged
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Sits between Net::HTTP's read buffer and the socket of ONE connection,
|
|
479
|
+
# and bounds what Net::HTTP itself does not: how long the connection may
|
|
480
|
+
# be read at all, and how many bytes may come off it without becoming body.
|
|
481
|
+
#
|
|
482
|
+
# WHY IT EXISTS. Net::HTTP reads the status line, headers, 1xx responses,
|
|
483
|
+
# chunk-size lines and trailers itself, into memory, with no size limit and
|
|
484
|
+
# only a per-read timeout. A server that sends header lines forever, one
|
|
485
|
+
# endless line, an endless chunk-size line, a header every half second or
|
|
486
|
+
# `100 Continue` forever was never stopped by a deadline or a byte cap
|
|
487
|
+
# that only the body's chunks could see (review round 1, measured at
|
|
488
|
+
# gigabytes of memory in seconds).
|
|
489
|
+
#
|
|
490
|
+
# HOW. Net::BufferedIO calls four things on its io: read_nonblock, to_io
|
|
491
|
+
# (to wait on), write_nonblock and close. This wraps the io it was given
|
|
492
|
+
# and overrides the first two:
|
|
493
|
+
# read_nonblock refuses once the deadline has passed, and counts the
|
|
494
|
+
# bytes read since `delivered!` was last called; past
|
|
495
|
+
# MAX_UNDELIVERED_BYTES it raises Overrun. `fetch` calls
|
|
496
|
+
# delivered! as each piece of body is handed to it.
|
|
497
|
+
# to_io answers a waiter whose waits are cut to the time left,
|
|
498
|
+
# so a silent socket is given up at the deadline, not a
|
|
499
|
+
# read timeout later.
|
|
500
|
+
# No thread is interrupted and nothing is raised asynchronously: every
|
|
501
|
+
# stop is an ordinary exception raised in the reading thread, between
|
|
502
|
+
# reads.
|
|
503
|
+
#
|
|
504
|
+
# It reaches into Net::HTTP for the buffer (the @socket and @io instance
|
|
505
|
+
# variables; net-http offers no hook). `install!` REFUSES THE FETCH when
|
|
506
|
+
# either is not what it expects, so a net-http this was not written for
|
|
507
|
+
# cannot run unmetered. Written against net-http 0.9.1 and net-protocol
|
|
508
|
+
# 0.2.2; the suite runs real Net::HTTP through it.
|
|
509
|
+
class Meter < SimpleDelegator
|
|
510
|
+
class Overrun < StandardError; end
|
|
511
|
+
class Expired < StandardError; end
|
|
512
|
+
|
|
513
|
+
Waiter = Struct.new(:io, :meter) do
|
|
514
|
+
def wait_readable(timeout = nil) = meter.wait(io, :wait_readable, timeout)
|
|
515
|
+
def wait_writable(timeout = nil) = meter.wait(io, :wait_writable, timeout)
|
|
516
|
+
def inspect = "#<metered socket>"
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
attr_writer :stop_at
|
|
520
|
+
|
|
521
|
+
def self.install!(http, stop_at)
|
|
522
|
+
buffered = http.instance_variable_get(:@socket)
|
|
523
|
+
raw = buffered.instance_variable_get(:@io) if buffered.is_a?(Net::BufferedIO)
|
|
524
|
+
unless raw.respond_to?(:read_nonblock) && raw.respond_to?(:to_io)
|
|
525
|
+
raise FetchFailed, "this net-http cannot be metered, so the fetch was not made (Studio::KnowledgeRecording::Meter)"
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
meter = new(raw, stop_at)
|
|
529
|
+
buffered.instance_variable_set(:@io, meter)
|
|
530
|
+
meter
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def initialize(io, stop_at)
|
|
534
|
+
super(io)
|
|
535
|
+
@stop_at = stop_at
|
|
536
|
+
@undelivered = 0
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# What has come off the socket so far has been handed on as body.
|
|
540
|
+
def delivered!
|
|
541
|
+
@undelivered = 0
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def read_nonblock(length, buffer = nil, exception: true)
|
|
545
|
+
time_left(nil)
|
|
546
|
+
result = __getobj__.read_nonblock(length, buffer, exception: exception)
|
|
547
|
+
if result.is_a?(String)
|
|
548
|
+
@undelivered += result.bytesize
|
|
549
|
+
raise Overrun, "more than #{MAX_UNDELIVERED_BYTES} bytes" if @undelivered > MAX_UNDELIVERED_BYTES
|
|
550
|
+
end
|
|
551
|
+
result
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def to_io
|
|
555
|
+
Waiter.new(__getobj__.to_io, self)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# One wait on the socket, cut to the time left. A wait that ran out
|
|
559
|
+
# because it was CUT is the deadline (Expired); one that ran out on its
|
|
560
|
+
# own is the caller's read timeout (nil, as IO#wait_readable answers).
|
|
561
|
+
def wait(io, direction, timeout)
|
|
562
|
+
allowed = time_left(timeout)
|
|
563
|
+
ready = io.public_send(direction, allowed)
|
|
564
|
+
raise Expired, "deadline passed" if ready.nil? && (timeout.nil? || allowed < timeout)
|
|
565
|
+
|
|
566
|
+
ready
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# `timeout` cut to the seconds left before the deadline. Raises Expired
|
|
570
|
+
# when none are left.
|
|
571
|
+
def time_left(timeout)
|
|
572
|
+
remaining = @stop_at - KnowledgeRecording.monotonic
|
|
573
|
+
raise Expired, "deadline passed" if remaining <= 0
|
|
574
|
+
|
|
575
|
+
timeout.nil? ? remaining : [timeout, remaining].min
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
end
|