x-uploads 1.0.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.
@@ -0,0 +1,440 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "invalid_media"
4
+ require_relative "utils"
5
+
6
+ module X
7
+ module Uploads
8
+ # The media an upload reads, given as a file path or as an IO
9
+ #
10
+ # Media given as a String, a Pathname, or any other path to a file is read from that file, and media given as an
11
+ # IO open on a file, such as a File or a Tempfile, is read through that IO, whether or not its name still leads
12
+ # to the file, as it no longer does once a Tempfile is unlinked: either is read a chunk at a time, so that media of
13
+ # any size uploads without being held in memory. Media given as any other IO, such as a StringIO, is read once,
14
+ # and held, since an IO that is not open on a file cannot be read again by position. An IO that can seek, as a
15
+ # File and a StringIO can, is read from its start, whatever position it holds, and given back at that position,
16
+ # so that the media can be checked and then uploaded, and a pipe, which cannot, is read from where it is.
17
+ #
18
+ # Internal to x-uploads: the uploaders resolve what they were given to one of these, rather than read a path
19
+ # themselves, so that a path and an IO upload the same way.
20
+ #
21
+ # @api private
22
+ class Source
23
+ # Bytes read from the start of media that names no file, enough for every signature {Signature} reads
24
+ SNIFF_BYTES = 512
25
+ # The bytes a path of media is not written with, but its contents may be: a path holds no NUL byte, and names
26
+ # no file with a line break in any use an upload is meant for, while an image read with File.binread holds a
27
+ # NUL byte, and subtitles a line break
28
+ CONTENT_BYTES = /[\0\n]/
29
+ # The message of the error raised for the contents of media given where its path belongs
30
+ NOT_A_PATH = "media must be a path to the media, or an IO that reads it, such as a StringIO, not the contents " \
31
+ "of the media: a String that holds a NUL byte or a line break names no file"
32
+ # The paths Ruby gives the standard streams, which name no file, even when a stream reads one, as $stdin
33
+ # redirected from a file does
34
+ STREAM_PATHS = %w[<STDIN> <STDOUT> <STDERR>].freeze
35
+ # The words for media that names no file, in the message of an error it raises
36
+ UNNAMED = "the media given"
37
+ private_constant :SNIFF_BYTES, :CONTENT_BYTES, :NOT_A_PATH, :STREAM_PATHS, :UNNAMED
38
+
39
+ # The source of media given as a path or as an IO
40
+ #
41
+ # An IO that names a file is flushed first, so that what it has written reaches the file the upload reads.
42
+ #
43
+ # A String is a path, so one that holds a NUL byte or a line break, as the contents of media given in its place
44
+ # do, such as the bytes of an image or the text of subtitles, raises, rather than be looked for as a file named
45
+ # by all of it.
46
+ #
47
+ # @api private
48
+ # @param media [String, Pathname, IO, StringIO, Source] the path to the media, or an IO open on it
49
+ # @return [Source] the source, which is what was given if that is already one
50
+ # @raise [ArgumentError] if the media is neither a path nor an IO, or is a String that holds the contents of
51
+ # media rather than a path
52
+ # @raise [InvalidMedia] if the media is an IO that cannot be read, or one the system cannot say what it is open
53
+ # on
54
+ # @example The source of a file
55
+ # Uploads::Source.for("cat.jpg")
56
+ # @example The source of media held in memory
57
+ # Uploads::Source.for(StringIO.new(bytes))
58
+ def self.for(media)
59
+ case media
60
+ when Source then media
61
+ when String then media.b.match?(CONTENT_BYTES) ? raise(ArgumentError, NOT_A_PATH) : Path.new(media)
62
+ else named_or_read(media)
63
+ end
64
+ end
65
+
66
+ # The source of media given as a path that is not a String, or as an IO
67
+ #
68
+ # A path, such as a Pathname, which cannot seek, is read from the file it names, as is a File or a Tempfile that
69
+ # was closed, which can no longer be read through, and an IO open on a file, which can, through that IO, as
70
+ # $stdin is when it is redirected from a file. Any other IO is read to its end, such as a StringIO, or a pipe,
71
+ # whose path is nil, or $stdin reading a pipe or a terminal: an IO open on something that is not a file or a
72
+ # directory, such as a pipe, cannot be read by position, as a file is. One whose path is nil that was closed,
73
+ # such as a pipe, then raises InvalidMedia, as it cannot be read.
74
+ #
75
+ # @api private
76
+ # @param media [Pathname, IO, StringIO, Object] the path or the IO
77
+ # @return [Source] the source
78
+ # @raise [ArgumentError] if the media is neither a path nor an IO
79
+ # @raise [InvalidMedia] if the media is an IO that cannot be read, or one the system cannot say what it is open
80
+ # on
81
+ def self.named_or_read(media)
82
+ named = media.respond_to?(:to_path) && media.to_path
83
+ if named && path_alone?(media)
84
+ Path.new(media)
85
+ elsif named && on_file?(media)
86
+ Handle.new(media)
87
+ elsif media.respond_to?(:read)
88
+ buffered(media)
89
+ else
90
+ raise ArgumentError, "media must be a path or an IO that reads one, not #{media.class}"
91
+ end
92
+ end
93
+ private_class_method :named_or_read
94
+
95
+ # Check whether media that names a file can be read from that name alone
96
+ # @api private
97
+ # @param media [Pathname, IO] the path, or the IO, which has a path
98
+ # @return [Boolean] true if the media cannot seek, as a path cannot, or is an IO that was closed
99
+ def self.path_alone?(media) = !media.respond_to?(:seek) || (media.respond_to?(:closed?) && media.closed?)
100
+ private_class_method :path_alone?
101
+
102
+ # Check whether an IO is open on a file or a directory, which a Handle reads
103
+ # @api private
104
+ # @param media [IO] the IO, which has a path
105
+ # @return [Boolean] true if the IO is open on a file or a directory, rather than a pipe, a device, or a socket
106
+ # @raise [InvalidMedia] if the system cannot say what the IO is open on, or the IO was closed meanwhile
107
+ def self.on_file?(media)
108
+ reading(UNNAMED) do
109
+ stat = media.stat
110
+ stat.file? || stat.directory?
111
+ end
112
+ end
113
+ private_class_method :on_file?
114
+
115
+ # The source of media given as an IO that is not open on a file
116
+ #
117
+ # An IO that can seek, as a StringIO can, is read from its start, as a File is, whatever position it holds, such
118
+ # as the end it is left at once it has been written to, and is given back at that position, so that what infers
119
+ # the type of the media leaves it to be uploaded. One that cannot, as a pipe cannot, is read from where it is to
120
+ # its end. An IO is put in binary mode first, since media is bytes, and a pipe or $stdin reads in text mode on
121
+ # Windows, which would turn each CRLF of the media, such as the one in the signature of a PNG, into a line feed.
122
+ # An IO that cannot be read, as a closed StringIO or one open for writing alone cannot, raises InvalidMedia, as
123
+ # a File that cannot be read does when the upload checks it, and so does one the system refuses to read, as it
124
+ # does a socket that is not connected, with the error of the system as its cause.
125
+ #
126
+ # @api private
127
+ # @param media [StringIO, IO, Object] the IO
128
+ # @return [Buffer] the source
129
+ # @raise [InvalidMedia] if the IO cannot be read
130
+ def self.buffered(media)
131
+ reading(UNNAMED) do
132
+ position = position_of(media)
133
+ media.binmode if media.is_a?(IO)
134
+ Buffer.new(read_whole(media, position))
135
+ end
136
+ end
137
+ private_class_method :buffered
138
+
139
+ # Read media before a request, raising InvalidMedia if it cannot be read
140
+ #
141
+ # What an upload reads of its media before it sends a request, what the media is open on, its size, its
142
+ # signature, and the whole of media sent in a single request or held, is read with it, so that a file deleted
143
+ # once the upload has checked it, a disk that fails, or an IO closed while it is read, as by another thread,
144
+ # raises InvalidMedia, as media that cannot be read does, with the error as its cause. The message gives the
145
+ # reason of an error of the system bare, as "No such file or directory", without the function of Ruby and the
146
+ # path that Ruby adds to it, since the message names the media already, and that of any other error as it is.
147
+ # The chunks of an upload are read without it, since a chunk that cannot be read fails an upload that is already
148
+ # initialized, with the error as the cause of that.
149
+ #
150
+ # @api private
151
+ # @param description [String] the media in words, which the message of the error begins with
152
+ # @yield reads the media
153
+ # @return [Object] what the block returns
154
+ # @raise [InvalidMedia] if the read raises an IOError, as that of a closed IO, or an error of the system
155
+ # @example Read the size of a file
156
+ # Uploads::Source.reading("cat.jpg") { File.size("cat.jpg") }
157
+ def self.reading(description)
158
+ yield
159
+ rescue IOError, SystemCallError => e
160
+ errno = e.errno if e.is_a?(SystemCallError)
161
+ raise InvalidMedia, "#{description} cannot be read: #{errno ? SystemCallError.new(errno) : e}"
162
+ end
163
+
164
+ # Read an IO to its end, from its start if it can seek
165
+ #
166
+ # An IO that can seek is given back at its position, even if reading it raises.
167
+ #
168
+ # @api private
169
+ # @param media [StringIO, IO, Object] the IO
170
+ # @param position [Integer, nil] the position of the IO, or nil for an IO that cannot seek
171
+ # @return [String] the bytes read
172
+ def self.read_whole(media, position)
173
+ media.seek(0) if position
174
+ media.read.to_s.b
175
+ ensure
176
+ media.seek(position) if position
177
+ end
178
+ private_class_method :read_whole
179
+
180
+ # The position of an IO that can seek
181
+ # @api private
182
+ # @param media [StringIO, IO, Object] the IO
183
+ # @return [Integer, nil] the position, or nil for an IO that cannot seek
184
+ def self.position_of(media)
185
+ media.pos if media.respond_to?(:seek)
186
+ rescue SystemCallError
187
+ nil
188
+ end
189
+ private_class_method :position_of
190
+
191
+ # The name of the file the media was given as
192
+ #
193
+ # Media that names no file, which {Path} alone does, has none.
194
+ #
195
+ # @api private
196
+ # @return [String, nil] the file name, or nil for media that names none
197
+ # @example The name of media given as a path
198
+ # Uploads::Source.for("cat.jpg").name # => "cat.jpg"
199
+ attr_reader :name
200
+
201
+ # The media in words, for the message of an error it raises
202
+ # @api private
203
+ # @return [String] the file name, or a phrase for media that names none
204
+ # @example Describe media held in memory
205
+ # Uploads::Source.for(StringIO.new(bytes)).description # => "the media given"
206
+ def description = name || UNNAMED
207
+
208
+ # The lowercase extension of the file the media names
209
+ #
210
+ # It has no dot, and is "" for media that names no file.
211
+ #
212
+ # @api private
213
+ # @return [String] the extension
214
+ # @example The extension of media given as a path
215
+ # Uploads::Source.for("cat.JPG").extension # => "jpg"
216
+ def extension = Utils.extension(name.to_s)
217
+
218
+ # The bytes a signature is read from
219
+ #
220
+ # They are fewer than SNIFF_BYTES only if the media is shorter, and none at all if it is empty.
221
+ #
222
+ # @api private
223
+ # @return [String] the leading bytes of the media
224
+ # @raise [InvalidMedia] if the system refuses to read the media, or it was closed meanwhile
225
+ # @example Read the signature of media
226
+ # Uploads::Source.for("cat.gif").sniff # => "GIF89a..."
227
+ def sniff = reading { read(SNIFF_BYTES, 0).to_s }
228
+
229
+ # Media read from the file it names, a chunk at a time
230
+ # @api private
231
+ class Path < Source
232
+ # Initialize the source of media given as a path
233
+ # @api private
234
+ # @param path [String, Pathname] the path to the media
235
+ # @return [Path] the source
236
+ # @example The source of a file
237
+ # Uploads::Source::Path.new("cat.jpg")
238
+ def initialize(path)
239
+ @name = File.path(path)
240
+ end
241
+
242
+ # Whether the file exists
243
+ #
244
+ # An upload of media that is not there is refused.
245
+ #
246
+ # @api private
247
+ # @return [Boolean] true if the file exists
248
+ def exist? = File.exist?(name)
249
+
250
+ # Whether the media can be read
251
+ #
252
+ # A file that is not there, that is a directory, or that the process has no permission to read, cannot be.
253
+ #
254
+ # @api private
255
+ # @return [Boolean] true if the media is a file that can be read
256
+ def readable? = File.file?(name) && File.readable?(name)
257
+
258
+ # The size of the media in bytes
259
+ #
260
+ # It is read once, when first asked, so that an upload checks, declares, and appends the same number of bytes,
261
+ # whatever the file does meanwhile.
262
+ #
263
+ # @api private
264
+ # @return [Integer] the size in bytes
265
+ # @raise [InvalidMedia] if the system refuses to read the size of the file
266
+ def size = @size ||= reading { File.size(name) }
267
+
268
+ # The whole of the media
269
+ # @api private
270
+ # @return [String] the bytes of the media
271
+ # @raise [InvalidMedia] if the system refuses to read the file
272
+ def content = reading { File.binread(name) }
273
+
274
+ # A run of the media, which the chunks of an upload are read with, from any thread
275
+ # @api private
276
+ # @param length [Integer] the number of bytes to read
277
+ # @param offset [Integer] the byte to read from, which is within the media
278
+ # @return [String] the bytes
279
+ def read(length, offset) = File.binread(name, length, offset)
280
+ end
281
+
282
+ # Media read through an IO open on a file, a chunk at a time
283
+ #
284
+ # The IO is read by position, from the start of the file, whatever position it holds, and is left at that
285
+ # position. Reading by position flushes what the IO has written, as reading its size does, so that is read with
286
+ # the rest.
287
+ # The file need not be named by the path the IO holds: an unlinked Tempfile is read, as is one created anonymous,
288
+ # whose path is its directory, and $stdin redirected from a file, whose path is "<STDIN>", neither of which so
289
+ # names a file.
290
+ #
291
+ # @api private
292
+ class Handle < Source
293
+ # Initialize the source of media read through an IO open on a file
294
+ # @api private
295
+ # @param io [IO] the IO, which answers to_path
296
+ # @return [Handle] the source
297
+ # @example The source of a File
298
+ # Uploads::Source::Handle.new(File.open("cat.jpg", "rb"))
299
+ def initialize(io)
300
+ @io = io
301
+ path = File.path(io)
302
+ @name = path unless STREAM_PATHS.include?(path) || File.directory?(path)
303
+ @mutex = Mutex.new
304
+ end
305
+
306
+ # Whether the media exists, which media open on a file always does
307
+ # @api private
308
+ # @return [Boolean] true
309
+ def exist? = true
310
+
311
+ # Whether the media can be read: the IO is open on a file, and open for reading
312
+ # @api private
313
+ # @return [Boolean] true if the IO is open on a file for reading
314
+ # @raise [InvalidMedia] if the system cannot say what the IO is open on, or the IO was closed meanwhile
315
+ def readable? = reading { @io.stat.file? && open_for_reading? }
316
+
317
+ # The size of the media in bytes
318
+ #
319
+ # A File or a Tempfile reads it with size, which flushes what it has written first, and an IO that answers no
320
+ # size, as $stdin does, from the file it is open on. It is read once, when first asked, so that an upload
321
+ # checks, declares, and appends the same number of bytes, whatever the file does meanwhile.
322
+ #
323
+ # @api private
324
+ # @return [Integer] the size in bytes
325
+ # @raise [InvalidMedia] if the system refuses to read the size of the file, or the IO was closed meanwhile
326
+ def size = @size ||= reading { @io.respond_to?(:size) ? @io.size : @io.stat.size }
327
+
328
+ # The whole of the media
329
+ # @api private
330
+ # @return [String] the bytes of the media
331
+ # @raise [InvalidMedia] if the system refuses to read the file, or the IO was closed meanwhile
332
+ def content = reading { read(size, 0) }
333
+
334
+ # A run of the media, which the chunks of an upload are read with, from any thread
335
+ #
336
+ # It is read at its offset with pread, which leaves the position of the IO alone, so that uploads of one IO,
337
+ # and code of the caller's own that reads it meanwhile, never read where another moved it to. An IO that cannot
338
+ # pread, as one on Windows cannot, is read by seeking to the offset, with the chunks of the upload in turn,
339
+ # and each gives the IO back at the position it held.
340
+ #
341
+ # @api private
342
+ # @param length [Integer] the number of bytes to read
343
+ # @param offset [Integer] the byte to read from, which is within the media
344
+ # @return [String] the bytes, which are fewer than length, or none, if the file has fewer past the offset
345
+ def read(length, offset)
346
+ return seek_and_read(length, offset) unless @io.respond_to?(:pread)
347
+
348
+ @io.pread(length, offset)
349
+ rescue EOFError
350
+ ""
351
+ end
352
+
353
+ private
354
+
355
+ # Read a run of the media by seeking to its offset
356
+ #
357
+ # The IO is given back at the position it held.
358
+ #
359
+ # @api private
360
+ # @param length [Integer] the number of bytes to read
361
+ # @param offset [Integer] the byte to read from
362
+ # @return [String] the bytes, or none if the file ends at the offset
363
+ def seek_and_read(length, offset)
364
+ @mutex.synchronize do
365
+ position = @io.pos
366
+ begin
367
+ @io.seek(offset)
368
+ @io.read(length).to_s
369
+ ensure
370
+ @io.seek(position)
371
+ end
372
+ end
373
+ end
374
+
375
+ # Whether the IO is open for reading, told by reading nothing from it
376
+ # @api private
377
+ # @return [Boolean] true if the IO can be read
378
+ def open_for_reading?
379
+ @io.read(0)
380
+ true
381
+ rescue IOError
382
+ false
383
+ end
384
+ end
385
+
386
+ # Media read from an IO that is not open on a file, and held until the upload has finished
387
+ # @api private
388
+ class Buffer < Source
389
+ # Initialize the source of media read from an IO
390
+ # @api private
391
+ # @param content [String] the bytes read from the IO
392
+ # @return [Buffer] the source
393
+ # @example The source of media held in memory
394
+ # Uploads::Source::Buffer.new(bytes)
395
+ def initialize(content)
396
+ @content = content
397
+ end
398
+
399
+ # Whether the media exists, which media already read always does
400
+ # @api private
401
+ # @return [Boolean] true
402
+ def exist? = true
403
+
404
+ # Whether the media can be read, which media already read always can
405
+ # @api private
406
+ # @return [Boolean] true
407
+ def readable? = true
408
+
409
+ # The size of the media in bytes
410
+ # @api private
411
+ # @return [Integer] the size in bytes
412
+ def size = @content.bytesize
413
+
414
+ # The whole of the media
415
+ # @api private
416
+ # @return [String] the bytes of the media
417
+ attr_reader :content
418
+
419
+ # A run of the media, which the chunks of an upload are read with, from any thread
420
+ # @api private
421
+ # @param length [Integer] the number of bytes to read
422
+ # @param offset [Integer] the byte to read from, which is within the media
423
+ # @return [String] the bytes
424
+ def read(length, offset)
425
+ @content.byteslice(offset, length) #: String
426
+ end
427
+ end
428
+
429
+ private
430
+
431
+ # Read media before a request, raising InvalidMedia if it cannot be read
432
+ # @api private
433
+ # @yield reads the media
434
+ # @return [Object] what the block returns
435
+ # @raise [InvalidMedia] if the read raises an IOError, as that of a closed IO, or an error of the system
436
+ def reading(&) = Source.reading(description, &)
437
+ end
438
+ private_constant :Source
439
+ end
440
+ end