tus-server 0.2.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/tus/input.rb ADDED
@@ -0,0 +1,31 @@
1
+ module Tus
2
+ class Input
3
+ def initialize(input)
4
+ @input = input
5
+ @bytes_read = 0
6
+ end
7
+
8
+ def read(*args)
9
+ result = @input.read(*args)
10
+ @bytes_read += result.bytesize if result.is_a?(String)
11
+ result
12
+ end
13
+
14
+ def eof?
15
+ @bytes_read == size
16
+ end
17
+
18
+ def rewind
19
+ @input.rewind
20
+ @bytes_read = 0
21
+ end
22
+
23
+ def size
24
+ @input.size
25
+ end
26
+
27
+ def close
28
+ # Rack input shouldn't be closed, we just support the interface
29
+ end
30
+ end
31
+ end
data/lib/tus/server.rb CHANGED
@@ -2,10 +2,12 @@ require "roda"
2
2
 
3
3
  require "tus/storage/filesystem"
4
4
  require "tus/info"
5
- require "tus/expirator"
5
+ require "tus/input"
6
6
  require "tus/checksum"
7
+ require "tus/errors"
7
8
 
8
9
  require "securerandom"
10
+ require "time"
9
11
 
10
12
  module Tus
11
13
  class Server < Roda
@@ -20,18 +22,18 @@ module Tus
20
22
  SUPPORTED_CHECKSUM_ALGORITHMS = %w[sha1 sha256 sha384 sha512 md5 crc32]
21
23
  RESUMABLE_CONTENT_TYPE = "application/offset+octet-stream"
22
24
 
23
- opts[:max_size] = 1024*1024*1024
24
- opts[:expiration_time] = 7*24*60*60
25
- opts[:expiration_interval] = 60*60
25
+ opts[:max_size] = 1024*1024*1024
26
+ opts[:expiration_time] = 7*24*60*60
27
+ opts[:disposition] = "inline"
26
28
 
27
29
  plugin :all_verbs
28
30
  plugin :delete_empty_headers
29
31
  plugin :request_headers
30
32
  plugin :not_allowed
33
+ plugin :streaming
34
+ plugin :error_handler
31
35
 
32
36
  route do |r|
33
- expire_files
34
-
35
37
  if request.headers["X-HTTP-Method-Override"]
36
38
  request.env["REQUEST_METHOD"] = request.headers["X-HTTP-Method-Override"]
37
39
  end
@@ -56,12 +58,12 @@ module Tus
56
58
  end
57
59
 
58
60
  r.post do
59
- validate_upload_concat! if request.headers["Upload-Concat"]
60
61
  validate_upload_length! unless request.headers["Upload-Concat"].to_s.start_with?("final") || request.headers["Upload-Defer-Length"] == "1"
61
62
  validate_upload_metadata! if request.headers["Upload-Metadata"]
63
+ validate_upload_concat! if request.headers["Upload-Concat"]
62
64
 
63
- uid = SecureRandom.hex
64
- info = Info.new(
65
+ uid = SecureRandom.hex
66
+ info = Tus::Info.new(
65
67
  "Upload-Length" => request.headers["Upload-Length"],
66
68
  "Upload-Offset" => "0",
67
69
  "Upload-Defer-Length" => request.headers["Upload-Defer-Length"],
@@ -70,23 +72,17 @@ module Tus
70
72
  "Upload-Expires" => (Time.now + expiration_time).httpdate,
71
73
  )
72
74
 
73
- storage.create_file(uid, info.to_h)
74
-
75
- if info.final_upload?
76
- length = info.partial_uploads.inject(0) do |length, partial_uid|
77
- content = storage.read_file(partial_uid)
78
- storage.patch_file(uid, content)
79
- storage.delete_file(partial_uid)
80
- length += content.length
81
- end
82
-
75
+ if info.concatenation?
76
+ length = storage.concatenate(uid, info.partial_uploads, info.to_h)
83
77
  info["Upload-Length"] = length.to_s
84
78
  info["Upload-Offset"] = length.to_s
85
-
86
- storage.update_info(uid, info.to_h)
79
+ else
80
+ storage.create_file(uid, info.to_h)
87
81
  end
88
82
 
89
- response.headers.update(info.to_h)
83
+ storage.update_info(uid, info.to_h)
84
+
85
+ response.headers.update(info.headers)
90
86
 
91
87
  file_url = "#{request.url.chomp("/")}/#{uid}"
92
88
  created!(file_url)
@@ -105,84 +101,74 @@ module Tus
105
101
  no_content!
106
102
  end
107
103
 
108
- r.delete do
109
- storage.delete_file(uid)
110
-
111
- no_content!
112
- end
113
-
114
- not_found! unless storage.file_exists?(uid)
115
-
116
- r.get do
117
- path = storage.download_file(uid)
118
- info = Info.new(storage.read_info(uid))
119
-
120
- server = Rack::File.new(File.dirname(path))
121
-
122
- result = if ::Rack.release > "2"
123
- server.serving(request, path)
124
- else
125
- server = server.dup
126
- server.path = path
127
- server.serving(env)
128
- end
129
-
130
- response.status = result[0]
131
- response.headers.update(result[1])
132
-
133
- metadata = info.metadata
134
- response.headers["Content-Disposition"] = "attachment; filename=\"#{metadata["filename"]}\"" if metadata["filename"]
135
- response.headers["Content-Type"] = metadata["content_type"] if metadata["content_type"]
136
-
137
- request.halt response.finish_with_body(result[2])
138
- end
104
+ info = Tus::Info.new(storage.read_info(uid))
139
105
 
140
106
  r.head do
141
- info = storage.read_info(uid)
142
-
143
- response.headers.update(info.to_h)
107
+ response.headers.update(info.headers)
144
108
  response.headers["Cache-Control"] = "no-store"
145
109
 
146
110
  no_content!
147
111
  end
148
112
 
149
113
  r.patch do
150
- validate_content_type!
151
-
152
- content = request.body.read
153
- info = Info.new(storage.read_info(uid))
114
+ input = Tus::Input.new(request.body)
154
115
 
155
- if info.defer_length?
116
+ if info.defer_length? && request.headers["Upload-Length"]
156
117
  validate_upload_length!
118
+
157
119
  info["Upload-Length"] = request.headers["Upload-Length"]
158
120
  info["Upload-Defer-Length"] = nil
159
121
  end
160
122
 
161
- validate_upload_checksum!(content) if request.headers["Upload-Checksum"]
123
+ validate_content_type!
124
+ validate_content_length!(info.offset, info.length)
162
125
  validate_upload_offset!(info.offset)
163
- validate_content_length!(content, info.remaining_length)
126
+ validate_upload_checksum!(input) if request.headers["Upload-Checksum"]
164
127
 
165
- storage.patch_file(uid, content)
128
+ storage.patch_file(uid, input, info.to_h)
166
129
 
167
- info["Upload-Offset"] = (info.offset + content.length).to_s
130
+ info["Upload-Offset"] = (info.offset + input.size).to_s
168
131
  info["Upload-Expires"] = (Time.now + expiration_time).httpdate
169
132
 
170
133
  storage.update_info(uid, info.to_h)
171
- response.headers.update(info.to_h)
134
+ response.headers.update(info.headers)
135
+
136
+ no_content!
137
+ end
138
+
139
+ r.get do
140
+ validate_upload_finished!(info.length, info.offset)
141
+ range = handle_range_request!(info.length)
142
+
143
+ response.headers["Content-Length"] = (range.end - range.begin + 1).to_s
144
+
145
+ metadata = info.metadata
146
+ response.headers["Content-Disposition"] = opts[:disposition]
147
+ response.headers["Content-Disposition"] << "; filename=\"#{metadata["filename"]}\"" if metadata["filename"]
148
+ response.headers["Content-Type"] = metadata["content_type"] if metadata["content_type"]
149
+
150
+ response = storage.get_file(uid, info.to_h, range: range)
151
+
152
+ stream(callback: ->{response.close}) do |out|
153
+ response.each { |chunk| out << chunk }
154
+ end
155
+ end
156
+
157
+ r.delete do
158
+ storage.delete_file(uid, info.to_h)
172
159
 
173
160
  no_content!
174
161
  end
175
162
  end
176
163
  end
177
164
 
178
- def expire_files
179
- expirator = Expirator.new(storage, interval: expiration_interval)
180
- expirator.expire_files!
165
+ error do |exception|
166
+ not_found! if exception.is_a?(Tus::NotFound)
167
+ raise
181
168
  end
182
169
 
183
170
  def validate_content_type!
184
- content_type = request.headers["Content-Type"]
185
- error!(415, "Invalid Content-Type header") if content_type != RESUMABLE_CONTENT_TYPE
171
+ error!(415, "Invalid Content-Type header") if request.content_type != RESUMABLE_CONTENT_TYPE
186
172
  end
187
173
 
188
174
  def validate_tus_resumable!
@@ -218,9 +204,17 @@ module Tus
218
204
  end
219
205
  end
220
206
 
221
- def validate_content_length!(content, remaining_length)
222
- error!(403, "Cannot modify completed upload") if remaining_length == 0
223
- error!(413, "Size of this chunk surpasses Upload-Length") if content.length > remaining_length
207
+ def validate_content_length!(current_offset, length)
208
+ if length
209
+ error!(403, "Cannot modify completed upload") if current_offset == length
210
+ error!(413, "Size of this chunk surpasses Upload-Length") if Integer(request.content_length) + current_offset > length
211
+ else
212
+ error!(413, "Size of this chunk surpasses Tus-Max-Size") if Integer(request.content_length) + current_offset > max_size
213
+ end
214
+ end
215
+
216
+ def validate_upload_finished!(length, current_offset)
217
+ error!(403, "Cannot download unfinished upload") unless length && current_offset && length == current_offset
224
218
  end
225
219
 
226
220
  def validate_upload_metadata!
@@ -250,15 +244,40 @@ module Tus
250
244
  end
251
245
  end
252
246
 
253
- def validate_upload_checksum!(content)
247
+ def validate_upload_checksum!(input)
254
248
  algorithm, checksum = request.headers["Upload-Checksum"].split(" ")
255
249
 
256
250
  error!(400, "Invalid Upload-Checksum header") if algorithm.nil? || checksum.nil?
257
251
  error!(400, "Invalid Upload-Checksum header") unless SUPPORTED_CHECKSUM_ALGORITHMS.include?(algorithm)
258
252
 
259
- unless Checksum.new(algorithm).match?(checksum, content)
260
- error!(460, "Checksum from Upload-Checksum header doesn't match generated")
253
+ generated_checksum = Tus::Checksum.generate(algorithm, input)
254
+ error!(460, "Checksum from Upload-Checksum header doesn't match generated") if generated_checksum != checksum
255
+ end
256
+
257
+ # "Range" header handling logic copied from Rack::File
258
+ def handle_range_request!(length)
259
+ if Rack.release >= "2.0"
260
+ ranges = Rack::Utils.get_byte_ranges(request.headers["Range"], length)
261
+ else
262
+ ranges = Rack::Utils.byte_ranges(request.env, length)
263
+ end
264
+
265
+ if ranges.nil? || ranges.length > 1
266
+ # No ranges, or multiple ranges (which we don't support):
267
+ response.status = 200
268
+ range = 0..length-1
269
+ elsif ranges.empty?
270
+ # Unsatisfiable. Return error, and file size:
271
+ response.headers["Content-Range"] = "bytes */#{length}"
272
+ error!(416, "Byte range unsatisfiable")
273
+ else
274
+ # Partial content:
275
+ range = ranges[0]
276
+ response.status = 206
277
+ response.headers["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{length}"
261
278
  end
279
+
280
+ range
262
281
  end
263
282
 
264
283
  def handle_cors!
@@ -1,5 +1,8 @@
1
+ require "tus/errors"
2
+
1
3
  require "pathname"
2
4
  require "json"
5
+ require "fileutils"
3
6
 
4
7
  module Tus
5
8
  module Storage
@@ -13,54 +16,90 @@ module Tus
13
16
  end
14
17
 
15
18
  def create_file(uid, info = {})
16
- write(file_path(uid), "")
17
- write(info_path(uid), info.to_json)
19
+ file_path(uid).open("wb") { |file| file.write("") }
18
20
  end
19
21
 
20
- def file_exists?(uid)
21
- file_path(uid).exist? && info_path(uid).exist?
22
- end
22
+ def concatenate(uid, part_uids, info = {})
23
+ file_path(uid).open("wb") do |file|
24
+ begin
25
+ part_uids.each do |part_uid|
26
+ IO.copy_stream(file_path(part_uid), file)
27
+ end
28
+ rescue Errno::ENOENT
29
+ raise Tus::Error, "some parts for concatenation are missing"
30
+ end
31
+ end
23
32
 
24
- def read_file(uid)
25
- file_path(uid).binread
26
- end
33
+ delete(part_uids)
27
34
 
28
- def patch_file(uid, content)
29
- write(file_path(uid), content, mode: "ab")
35
+ # server requires us to return the size of the concatenated file
36
+ file_path(uid).size
30
37
  end
31
38
 
32
- def download_file(uid)
33
- file_path(uid).to_s
34
- end
39
+ def patch_file(uid, io, info = {})
40
+ raise Tus::NotFound if !file_path(uid).exist?
35
41
 
36
- def delete_file(uid)
37
- if file_exists?(uid)
38
- file_path(uid).delete
39
- info_path(uid).delete
40
- end
42
+ file_path(uid).open("ab") { |file| IO.copy_stream(io, file) }
41
43
  end
42
44
 
43
45
  def read_info(uid)
44
- data = info_path(uid).binread
46
+ raise Tus::NotFound if !file_path(uid).exist?
47
+
48
+ begin
49
+ data = info_path(uid).binread
50
+ rescue Errno::ENOENT
51
+ data = "{}"
52
+ end
53
+
45
54
  JSON.parse(data)
46
55
  end
47
56
 
48
57
  def update_info(uid, info)
49
- write(info_path(uid), info.to_json)
58
+ info_path(uid).open("wb") { |file| file.write(info.to_json) }
59
+ end
60
+
61
+ def get_file(uid, info = {}, range: nil)
62
+ raise Tus::NotFound if !file_path(uid).exist?
63
+
64
+ file = file_path(uid).open("rb")
65
+ range ||= 0..file.size-1
66
+
67
+ chunks = Enumerator.new do |yielder|
68
+ file.seek(range.begin)
69
+ remaining_length = range.end - range.begin + 1
70
+ buffer = ""
71
+
72
+ while remaining_length > 0
73
+ chunk = file.read([16*1024, remaining_length].min, buffer)
74
+ break unless chunk
75
+ remaining_length -= chunk.length
76
+
77
+ yielder << chunk
78
+ end
79
+ end
80
+
81
+ Response.new(chunks: chunks, close: ->{file.close})
50
82
  end
51
83
 
52
- def list_files
53
- paths = Dir[directory.join("*.file")]
54
- paths.map { |path| File.basename(path, ".file") }
84
+ def delete_file(uid, info = {})
85
+ delete([uid])
55
86
  end
56
87
 
57
- private
88
+ def expire_files(expiration_date)
89
+ uids = []
58
90
 
59
- def write(pathname, content, mode: "wb")
60
- pathname.open(mode) do |file|
61
- file.sync = true
62
- file.write(content)
91
+ Pathname.glob(directory.join("*.file")).each do |pathname|
92
+ uids << pathname.basename(".*") if pathname.mtime <= expiration_date
63
93
  end
94
+
95
+ delete(uids)
96
+ end
97
+
98
+ private
99
+
100
+ def delete(uids)
101
+ paths = uids.flat_map { |uid| [file_path(uid), info_path(uid)] }
102
+ FileUtils.rm_f paths
64
103
  end
65
104
 
66
105
  def file_path(uid)
@@ -75,6 +114,21 @@ module Tus
75
114
  directory.mkpath
76
115
  directory.chmod(0755)
77
116
  end
117
+
118
+ class Response
119
+ def initialize(chunks:, close:)
120
+ @chunks = chunks
121
+ @close = close
122
+ end
123
+
124
+ def each(&block)
125
+ @chunks.each(&block)
126
+ end
127
+
128
+ def close
129
+ @close.call
130
+ end
131
+ end
78
132
  end
79
133
  end
80
134
  end