tus-server 0.9.1 → 0.10.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +22 -25
- data/lib/tus/input.rb +5 -1
- data/lib/tus/server.rb +10 -4
- data/lib/tus/storage/filesystem.rb +59 -36
- data/lib/tus/storage/gridfs.rb +138 -92
- data/lib/tus/storage/s3.rb +98 -85
- data/tus-server.gemspec +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4970687c1cfd81a01844c3626f4ee8e879410772
|
4
|
+
data.tar.gz: 017e147d7dbf79a40fb9ab1ada4a9c87b4490f81
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7b937ef67a26f46eeaa45e241bf593684ef99420d8ff41007bcf734130b891733f71a096c61a00c02bc805fbcf4aa1932260f4c85f5c4ac09fb097a8f53d4ef7
|
7
|
+
data.tar.gz: 24ee3bc3aab8dc3b4bf427dc5a65477efdf8b2dbb03edefeb8f23ee77ee0adf31c5e3d022b26223a36c0d2f69125177c958cae712a6f18feff7cde5e6a76d51d
|
data/README.md
CHANGED
@@ -62,8 +62,16 @@ require "tus/storage/filesystem"
|
|
62
62
|
Tus::Server.opts[:storage] = Tus::Storage::Filesystem.new("public/cache")
|
63
63
|
```
|
64
64
|
|
65
|
-
|
66
|
-
|
65
|
+
If the configured directory doesn't exist, it will automatically be created.
|
66
|
+
By default the UNIX permissions applied will be 0644 for files and 0755 for
|
67
|
+
directories, but you can set different permissions:
|
68
|
+
|
69
|
+
```rb
|
70
|
+
Tus::Storage::Filesystem.new("data", permissions: 0600, directory_permissions: 0777)
|
71
|
+
```
|
72
|
+
|
73
|
+
One downside of filesystem storage is that it doesn't work by default if you
|
74
|
+
want to run tus-ruby-server on multiple servers, you'd have to set up a shared
|
67
75
|
filesystem between the servers. Another downside is that you have to make sure
|
68
76
|
your servers have enough disk space. Also, if you're using Heroku, you cannot
|
69
77
|
store files on the filesystem as they won't persist.
|
@@ -88,21 +96,17 @@ client = Mongo::Client.new("mongodb://127.0.0.1:27017/mydb")
|
|
88
96
|
Tus::Server.opts[:storage] = Tus::Storage::Gridfs.new(client: client)
|
89
97
|
```
|
90
98
|
|
91
|
-
|
92
|
-
|
93
|
-
Gridfs chunk size equal to the size of the first uploaded chunk. This means
|
94
|
-
that all of the uploaded chunks need to be of equal size (except the last
|
95
|
-
chunk).
|
96
|
-
|
97
|
-
If you don't want the Gridfs chunk size to be equal to the size of the uploaded
|
98
|
-
chunks, you can hardcode the chunk size that will be used for all uploads.
|
99
|
+
By default MongoDB Gridfs stores files in chunks of 256KB, but you can change
|
100
|
+
that with the `:chunk_size` option:
|
99
101
|
|
100
102
|
```rb
|
101
|
-
Tus::Storage::Gridfs.new(client: client, chunk_size:
|
103
|
+
Tus::Storage::Gridfs.new(client: client, chunk_size: 1*1024*1024) # 1 MB
|
102
104
|
```
|
103
105
|
|
104
|
-
|
105
|
-
one
|
106
|
+
Note that if you're using the [concatenation] tus feature with Gridfs, all
|
107
|
+
partial uploads except the last one are required to fill in their Gridfs
|
108
|
+
chunks, meaning the length of each partial upload needs to be a multiple of the
|
109
|
+
`:chunk_size` number.
|
106
110
|
|
107
111
|
### Amazon S3
|
108
112
|
|
@@ -125,15 +129,9 @@ Tus::Server.opts[:storage] = Tus::Storage::S3.new(
|
|
125
129
|
)
|
126
130
|
```
|
127
131
|
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
to upload that chunk to S3, because of the differences in the Internet
|
132
|
-
connection speed between the user's computer and server.
|
133
|
-
|
134
|
-
One thing to note is that S3's multipart API requires each chunk except the last
|
135
|
-
one to be 5MB or larger, so that is the minimum chunk size that you can specify
|
136
|
-
on your tus client if you want to use the S3 storage.
|
132
|
+
One thing to note is that S3's multipart API requires each chunk except the
|
133
|
+
last to be **5MB or larger**, so that is the minimum chunk size that you can
|
134
|
+
specify on your tus client if you want to use the S3 storage.
|
137
135
|
|
138
136
|
If you want to files to be stored in a certain subdirectory, you can specify
|
139
137
|
a `:prefix` in the storage configuration.
|
@@ -174,12 +172,11 @@ def expire_files(expiration_date) ... end
|
|
174
172
|
|
175
173
|
## Maximum size
|
176
174
|
|
177
|
-
By default the
|
178
|
-
|
175
|
+
By default the size of files the tus server will accept is unlimited, but you
|
176
|
+
can configure the maximum file size:
|
179
177
|
|
180
178
|
```rb
|
181
179
|
Tus::Server.opts[:max_size] = 5 * 1024*1024*1024 # 5GB
|
182
|
-
Tus::Server.opts[:max_size] = nil # no limit
|
183
180
|
```
|
184
181
|
|
185
182
|
## Expiration
|
data/lib/tus/input.rb
CHANGED
data/lib/tus/server.rb
CHANGED
@@ -22,11 +22,12 @@ module Tus
|
|
22
22
|
SUPPORTED_CHECKSUM_ALGORITHMS = %w[sha1 sha256 sha384 sha512 md5 crc32]
|
23
23
|
RESUMABLE_CONTENT_TYPE = "application/offset+octet-stream"
|
24
24
|
|
25
|
-
opts[:max_size] =
|
25
|
+
opts[:max_size] = nil
|
26
26
|
opts[:expiration_time] = 7*24*60*60
|
27
27
|
opts[:disposition] = "inline"
|
28
28
|
|
29
29
|
plugin :all_verbs
|
30
|
+
plugin :default_headers, {"Content-Type" => ""}
|
30
31
|
plugin :delete_empty_headers
|
31
32
|
plugin :request_headers
|
32
33
|
plugin :not_allowed
|
@@ -130,6 +131,10 @@ module Tus
|
|
130
131
|
info["Upload-Offset"] = (info.offset + input.size).to_s
|
131
132
|
info["Upload-Expires"] = (Time.now + expiration_time).httpdate
|
132
133
|
|
134
|
+
if info.offset == info.length # last chunk
|
135
|
+
storage.finalize_file(uid, info.to_h) if storage.respond_to?(:finalize_file)
|
136
|
+
end
|
137
|
+
|
133
138
|
storage.update_info(uid, info.to_h)
|
134
139
|
response.headers.update(info.headers)
|
135
140
|
|
@@ -144,8 +149,8 @@ module Tus
|
|
144
149
|
|
145
150
|
metadata = info.metadata
|
146
151
|
response.headers["Content-Disposition"] = opts[:disposition]
|
147
|
-
response.headers["Content-Disposition"]
|
148
|
-
response.headers["Content-Type"] = metadata["content_type"]
|
152
|
+
response.headers["Content-Disposition"] += "; filename=\"#{metadata["filename"]}\"" if metadata["filename"]
|
153
|
+
response.headers["Content-Type"] = metadata["content_type"] || "application/octet-stream"
|
149
154
|
|
150
155
|
response = storage.get_file(uid, info.to_h, range: range)
|
151
156
|
|
@@ -208,7 +213,7 @@ module Tus
|
|
208
213
|
if length
|
209
214
|
error!(403, "Cannot modify completed upload") if current_offset == length
|
210
215
|
error!(413, "Size of this chunk surpasses Upload-Length") if Integer(request.content_length) + current_offset > length
|
211
|
-
|
216
|
+
elsif max_size
|
212
217
|
error!(413, "Size of this chunk surpasses Tus-Max-Size") if Integer(request.content_length) + current_offset > max_size
|
213
218
|
end
|
214
219
|
end
|
@@ -314,6 +319,7 @@ module Tus
|
|
314
319
|
def error!(status, message)
|
315
320
|
response.status = status
|
316
321
|
response.write(message) unless request.head?
|
322
|
+
response.headers["Content-Type"] = "text/plain"
|
317
323
|
request.halt
|
318
324
|
end
|
319
325
|
|
@@ -9,76 +9,91 @@ module Tus
|
|
9
9
|
class Filesystem
|
10
10
|
attr_reader :directory
|
11
11
|
|
12
|
-
def initialize(directory)
|
13
|
-
@directory
|
12
|
+
def initialize(directory, permissions: 0644, directory_permissions: 0755)
|
13
|
+
@directory = Pathname(directory)
|
14
|
+
@permissions = permissions
|
15
|
+
@directory_permissions = directory_permissions
|
14
16
|
|
15
17
|
create_directory! unless @directory.exist?
|
16
18
|
end
|
17
19
|
|
18
20
|
def create_file(uid, info = {})
|
19
|
-
file_path(uid).
|
21
|
+
file_path(uid).binwrite("")
|
22
|
+
file_path(uid).chmod(@permissions)
|
23
|
+
|
24
|
+
info_path(uid).binwrite("{}")
|
25
|
+
info_path(uid).chmod(@permissions)
|
20
26
|
end
|
21
27
|
|
22
28
|
def concatenate(uid, part_uids, info = {})
|
29
|
+
create_file(uid, info)
|
30
|
+
|
23
31
|
file_path(uid).open("wb") do |file|
|
24
|
-
|
25
|
-
|
32
|
+
part_uids.each do |part_uid|
|
33
|
+
# Rather than checking upfront whether all parts exist, we use
|
34
|
+
# exception flow to account for the possibility of parts being
|
35
|
+
# deleted during concatenation.
|
36
|
+
begin
|
26
37
|
IO.copy_stream(file_path(part_uid), file)
|
38
|
+
rescue Errno::ENOENT
|
39
|
+
raise Tus::Error, "some parts for concatenation are missing"
|
27
40
|
end
|
28
|
-
rescue Errno::ENOENT
|
29
|
-
raise Tus::Error, "some parts for concatenation are missing"
|
30
41
|
end
|
31
42
|
end
|
32
43
|
|
44
|
+
# Delete parts after concatenation.
|
33
45
|
delete(part_uids)
|
34
46
|
|
35
|
-
# server requires us to return the size of the concatenated file
|
47
|
+
# Tus server requires us to return the size of the concatenated file.
|
36
48
|
file_path(uid).size
|
37
49
|
end
|
38
50
|
|
39
|
-
def patch_file(uid,
|
40
|
-
|
51
|
+
def patch_file(uid, input, info = {})
|
52
|
+
exists!(uid)
|
41
53
|
|
42
|
-
file_path(uid).open("ab") { |file| IO.copy_stream(
|
54
|
+
file_path(uid).open("ab") { |file| IO.copy_stream(input, file) }
|
43
55
|
end
|
44
56
|
|
45
57
|
def read_info(uid)
|
46
|
-
|
58
|
+
exists!(uid)
|
47
59
|
|
48
|
-
|
49
|
-
data = info_path(uid).binread
|
50
|
-
rescue Errno::ENOENT
|
51
|
-
data = "{}"
|
52
|
-
end
|
53
|
-
|
54
|
-
JSON.parse(data)
|
60
|
+
JSON.parse(info_path(uid).binread)
|
55
61
|
end
|
56
62
|
|
57
63
|
def update_info(uid, info)
|
58
|
-
|
64
|
+
exists!(uid)
|
65
|
+
|
66
|
+
info_path(uid).binwrite(JSON.generate(info))
|
59
67
|
end
|
60
68
|
|
61
69
|
def get_file(uid, info = {}, range: nil)
|
62
|
-
|
70
|
+
exists!(uid)
|
63
71
|
|
64
72
|
file = file_path(uid).open("rb")
|
65
|
-
range ||= 0..file.size-1
|
73
|
+
range ||= 0..(file.size - 1)
|
74
|
+
length = range.end - range.begin + 1
|
66
75
|
|
76
|
+
# Create an Enumerator which will yield chunks of the requested file
|
77
|
+
# content, allowing tus server to efficiently stream requested content
|
78
|
+
# to the client.
|
67
79
|
chunks = Enumerator.new do |yielder|
|
68
80
|
file.seek(range.begin)
|
69
|
-
remaining_length =
|
70
|
-
buffer = ""
|
81
|
+
remaining_length = length
|
71
82
|
|
72
83
|
while remaining_length > 0
|
73
|
-
chunk = file.read([16*1024, remaining_length].min, buffer)
|
74
|
-
|
75
|
-
remaining_length -= chunk.length
|
76
|
-
|
84
|
+
chunk = file.read([16*1024, remaining_length].min, buffer ||= "") or break
|
85
|
+
remaining_length -= chunk.bytesize
|
77
86
|
yielder << chunk
|
78
87
|
end
|
79
88
|
end
|
80
89
|
|
81
|
-
|
90
|
+
# We return a response object that responds to #each, #length and #close,
|
91
|
+
# which the tus server can return directly as the Rack response.
|
92
|
+
Response.new(
|
93
|
+
chunks: chunks,
|
94
|
+
length: length,
|
95
|
+
close: ->{file.close},
|
96
|
+
)
|
82
97
|
end
|
83
98
|
|
84
99
|
def delete_file(uid, info = {})
|
@@ -86,11 +101,9 @@ module Tus
|
|
86
101
|
end
|
87
102
|
|
88
103
|
def expire_files(expiration_date)
|
89
|
-
uids =
|
90
|
-
|
91
|
-
|
92
|
-
uids << pathname.basename(".*") if pathname.mtime <= expiration_date
|
93
|
-
end
|
104
|
+
uids = directory.children
|
105
|
+
.select { |pathname| pathname.mtime <= expiration_date }
|
106
|
+
.map { |pathname| pathname.basename(".*").to_s }
|
94
107
|
|
95
108
|
delete(uids)
|
96
109
|
end
|
@@ -99,9 +112,14 @@ module Tus
|
|
99
112
|
|
100
113
|
def delete(uids)
|
101
114
|
paths = uids.flat_map { |uid| [file_path(uid), info_path(uid)] }
|
115
|
+
|
102
116
|
FileUtils.rm_f paths
|
103
117
|
end
|
104
118
|
|
119
|
+
def exists!(uid)
|
120
|
+
raise Tus::NotFound if !file_path(uid).exist?
|
121
|
+
end
|
122
|
+
|
105
123
|
def file_path(uid)
|
106
124
|
directory.join("#{uid}.file")
|
107
125
|
end
|
@@ -112,13 +130,18 @@ module Tus
|
|
112
130
|
|
113
131
|
def create_directory!
|
114
132
|
directory.mkpath
|
115
|
-
directory.chmod(
|
133
|
+
directory.chmod(@directory_permissions)
|
116
134
|
end
|
117
135
|
|
118
136
|
class Response
|
119
|
-
def initialize(chunks:, close:)
|
137
|
+
def initialize(chunks:, close:, length:)
|
120
138
|
@chunks = chunks
|
121
139
|
@close = close
|
140
|
+
@length = length
|
141
|
+
end
|
142
|
+
|
143
|
+
def length
|
144
|
+
@length
|
122
145
|
end
|
123
146
|
|
124
147
|
def each(&block)
|
data/lib/tus/storage/gridfs.rb
CHANGED
@@ -10,7 +10,7 @@ module Tus
|
|
10
10
|
class Gridfs
|
11
11
|
attr_reader :client, :prefix, :bucket, :chunk_size
|
12
12
|
|
13
|
-
def initialize(client:, prefix: "fs", chunk_size:
|
13
|
+
def initialize(client:, prefix: "fs", chunk_size: 256*1024)
|
14
14
|
@client = client
|
15
15
|
@prefix = prefix
|
16
16
|
@bucket = @client.database.fs(bucket_name: @prefix)
|
@@ -19,143 +19,115 @@ module Tus
|
|
19
19
|
end
|
20
20
|
|
21
21
|
def create_file(uid, info = {})
|
22
|
-
|
23
|
-
content_type = tus_info.metadata["content_type"]
|
22
|
+
content_type = Tus::Info.new(info).metadata["content_type"]
|
24
23
|
|
25
|
-
|
24
|
+
create_grid_file(
|
26
25
|
filename: uid,
|
27
|
-
metadata: {},
|
28
|
-
chunk_size: chunk_size,
|
29
26
|
content_type: content_type,
|
30
27
|
)
|
31
|
-
|
32
|
-
bucket.insert_one(file)
|
33
28
|
end
|
34
29
|
|
35
30
|
def concatenate(uid, part_uids, info = {})
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
if file_infos.count != part_uids.count
|
40
|
-
raise Tus::Error, "some parts for concatenation are missing"
|
41
|
-
end
|
42
|
-
|
43
|
-
chunk_sizes = file_infos.map { |file_info| file_info[:chunkSize] }
|
44
|
-
if chunk_sizes[0..-2].uniq.count > 1
|
45
|
-
raise Tus::Error, "some parts have different chunk sizes, so they cannot be concatenated"
|
46
|
-
end
|
31
|
+
grid_infos = files_collection.find(filename: {"$in" => part_uids}).to_a
|
32
|
+
grid_infos.sort_by! { |grid_info| part_uids.index(grid_info[:filename]) }
|
47
33
|
|
48
|
-
|
49
|
-
raise Tus::Error, "last part has different chunk size and is composed of more than one chunk"
|
50
|
-
end
|
34
|
+
validate_parts!(grid_infos, part_uids)
|
51
35
|
|
52
|
-
length =
|
53
|
-
|
54
|
-
tus_info = Tus::Info.new(info)
|
55
|
-
content_type = tus_info.metadata["content_type"]
|
36
|
+
length = grid_infos.map { |doc| doc[:length] }.reduce(0, :+)
|
37
|
+
content_type = Tus::Info.new(info).metadata["content_type"]
|
56
38
|
|
57
|
-
|
39
|
+
grid_file = create_grid_file(
|
58
40
|
filename: uid,
|
59
|
-
metadata: {},
|
60
|
-
chunk_size: chunk_size,
|
61
41
|
length: length,
|
62
42
|
content_type: content_type,
|
63
43
|
)
|
64
44
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
.
|
70
|
-
.update_many("$set" => {files_id: file.id}, "$inc" => {n: offset})
|
45
|
+
# Update the chunks belonging to parts so that they point to the new file.
|
46
|
+
grid_infos.inject(0) do |offset, grid_info|
|
47
|
+
result = chunks_collection
|
48
|
+
.find(files_id: grid_info[:_id])
|
49
|
+
.update_many("$set" => {files_id: grid_file.id}, "$inc" => {n: offset})
|
71
50
|
|
72
51
|
offset += result.modified_count
|
73
52
|
end
|
74
53
|
|
75
|
-
|
54
|
+
# Delete the parts after concatenation.
|
55
|
+
files_collection.delete_many(filename: {"$in" => part_uids})
|
76
56
|
|
77
|
-
# server requires us to return the size of the concatenated file
|
57
|
+
# Tus server requires us to return the size of the concatenated file.
|
78
58
|
length
|
79
59
|
end
|
80
60
|
|
81
|
-
def patch_file(uid,
|
82
|
-
|
83
|
-
raise Tus::NotFound if file_info.nil?
|
84
|
-
|
85
|
-
file_info[:md5] = Digest::MD5.new # hack for `Chunk.split` updating MD5
|
86
|
-
file_info[:chunkSize] ||= io.size
|
87
|
-
file_info = Mongo::Grid::File::Info.new(Mongo::Options::Mapper.transform(file_info, Mongo::Grid::File::Info::MAPPINGS.invert))
|
88
|
-
|
89
|
-
tus_info = Tus::Info.new(info)
|
90
|
-
last_chunk = (tus_info.length && io.size == tus_info.remaining_length)
|
91
|
-
|
92
|
-
if io.size % file_info.chunk_size != 0 && !last_chunk
|
93
|
-
raise Tus::Error,
|
94
|
-
"Input has length #{io.size} but expected it to be a multiple of " \
|
95
|
-
"chunk size #{file_info.chunk_size} or for it to be the last chunk"
|
96
|
-
end
|
61
|
+
def patch_file(uid, input, info = {})
|
62
|
+
grid_info = find_grid_info!(uid)
|
97
63
|
|
98
|
-
|
99
|
-
chunks = Mongo::Grid::File::Chunk.split(io, file_info, offset)
|
64
|
+
patch_last_chunk(input, grid_info)
|
100
65
|
|
101
|
-
|
102
|
-
|
66
|
+
grid_chunks = split_into_grid_chunks(input, grid_info)
|
67
|
+
chunks_collection.insert_many(grid_chunks)
|
68
|
+
grid_chunks.each { |grid_chunk| grid_chunk.data.data.clear } # deallocate strings
|
103
69
|
|
104
|
-
|
105
|
-
|
70
|
+
# Update the total length and refresh the upload date on each update,
|
71
|
+
# which are used in #get_file, #concatenate and #expire_files.
|
72
|
+
files_collection.find(filename: uid).update_one("$set" => {
|
73
|
+
length: grid_info[:length] + input.size,
|
106
74
|
uploadDate: Time.now.utc,
|
107
|
-
chunkSize: file_info.chunk_size,
|
108
75
|
})
|
109
76
|
end
|
110
77
|
|
111
78
|
def read_info(uid)
|
112
|
-
|
113
|
-
raise Tus::NotFound if file_info.nil?
|
79
|
+
grid_info = find_grid_info!(uid)
|
114
80
|
|
115
|
-
|
81
|
+
grid_info[:metadata]
|
116
82
|
end
|
117
83
|
|
118
84
|
def update_info(uid, info)
|
119
|
-
|
120
|
-
|
85
|
+
grid_info = find_grid_info!(uid)
|
86
|
+
|
87
|
+
files_collection.update_one({filename: uid}, {"$set" => {metadata: info}})
|
121
88
|
end
|
122
89
|
|
123
90
|
def get_file(uid, info = {}, range: nil)
|
124
|
-
|
125
|
-
raise Tus::NotFound if file_info.nil?
|
91
|
+
grid_info = find_grid_info!(uid)
|
126
92
|
|
127
|
-
|
93
|
+
range ||= 0..(grid_info[:length] - 1)
|
94
|
+
length = range.end - range.begin + 1
|
128
95
|
|
129
|
-
|
130
|
-
|
131
|
-
chunk_stop = range.end / file_info[:chunkSize] if range.end
|
96
|
+
chunk_start = range.begin / grid_info[:chunkSize]
|
97
|
+
chunk_stop = range.end / grid_info[:chunkSize]
|
132
98
|
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
99
|
+
filter = {
|
100
|
+
files_id: grid_info[:_id],
|
101
|
+
n: {"$gte" => chunk_start, "$lte" => chunk_stop}
|
102
|
+
}
|
137
103
|
|
138
|
-
|
104
|
+
# Query only the subset of chunks specified by the range query. We
|
105
|
+
# cannot use Mongo::FsBucket#open_download_stream here because it
|
106
|
+
# doesn't support changing the filter.
|
107
|
+
chunks_view = chunks_collection.find(filter).sort(n: 1)
|
139
108
|
|
109
|
+
# Create an Enumerator which will yield chunks of the requested file
|
110
|
+
# content, allowing tus server to efficiently stream requested content
|
111
|
+
# to the client.
|
140
112
|
chunks = Enumerator.new do |yielder|
|
141
113
|
chunks_view.each do |document|
|
142
114
|
data = document[:data].data
|
143
115
|
|
144
116
|
if document[:n] == chunk_start && document[:n] == chunk_stop
|
145
|
-
byte_start = range.begin %
|
146
|
-
byte_stop = range.end %
|
117
|
+
byte_start = range.begin % grid_info[:chunkSize]
|
118
|
+
byte_stop = range.end % grid_info[:chunkSize]
|
147
119
|
elsif document[:n] == chunk_start
|
148
|
-
byte_start = range.begin %
|
149
|
-
byte_stop =
|
120
|
+
byte_start = range.begin % grid_info[:chunkSize]
|
121
|
+
byte_stop = grid_info[:chunkSize] - 1
|
150
122
|
elsif document[:n] == chunk_stop
|
151
123
|
byte_start = 0
|
152
|
-
byte_stop = range.end %
|
124
|
+
byte_stop = range.end % grid_info[:chunkSize]
|
153
125
|
end
|
154
126
|
|
127
|
+
# If we're on the first or last chunk, return a subset of the chunk
|
128
|
+
# specified by the given range, otherwise return the full chunk.
|
155
129
|
if byte_start && byte_stop
|
156
|
-
|
157
|
-
yielder << partial_data
|
158
|
-
partial_data.clear # deallocate chunk string
|
130
|
+
yielder << data[byte_start..byte_stop]
|
159
131
|
else
|
160
132
|
yielder << data
|
161
133
|
end
|
@@ -164,26 +136,100 @@ module Tus
|
|
164
136
|
end
|
165
137
|
end
|
166
138
|
|
167
|
-
|
139
|
+
# We return a response object that responds to #each, #length and #close,
|
140
|
+
# which the tus server can return directly as the Rack response.
|
141
|
+
Response.new(
|
142
|
+
chunks: chunks,
|
143
|
+
length: length,
|
144
|
+
close: ->{chunks_view.close_query},
|
145
|
+
)
|
168
146
|
end
|
169
147
|
|
170
148
|
def delete_file(uid, info = {})
|
171
|
-
|
172
|
-
bucket.delete(
|
149
|
+
grid_info = files_collection.find(filename: uid).first
|
150
|
+
bucket.delete(grid_info[:_id]) if grid_info
|
173
151
|
end
|
174
152
|
|
175
153
|
def expire_files(expiration_date)
|
176
|
-
|
177
|
-
|
154
|
+
grid_infos = files_collection.find(uploadDate: {"$lte" => expiration_date}).to_a
|
155
|
+
grid_info_ids = grid_infos.map { |info| info[:_id] }
|
156
|
+
|
157
|
+
files_collection.delete_many(_id: {"$in" => grid_info_ids})
|
158
|
+
chunks_collection.delete_many(files_id: {"$in" => grid_info_ids})
|
159
|
+
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
def create_grid_file(**options)
|
164
|
+
file_options = {metadata: {}, chunk_size: chunk_size}.merge(options)
|
165
|
+
grid_file = Mongo::Grid::File.new("", file_options)
|
166
|
+
|
167
|
+
bucket.insert_one(grid_file)
|
168
|
+
|
169
|
+
grid_file
|
170
|
+
end
|
171
|
+
|
172
|
+
def split_into_grid_chunks(io, grid_info)
|
173
|
+
grid_info[:md5] = Digest::MD5.new # hack for `Chunk.split` updating MD5
|
174
|
+
grid_info = Mongo::Grid::File::Info.new(Mongo::Options::Mapper.transform(grid_info, Mongo::Grid::File::Info::MAPPINGS.invert))
|
175
|
+
offset = chunks_collection.count(files_id: grid_info.id)
|
178
176
|
|
179
|
-
|
180
|
-
|
177
|
+
Mongo::Grid::File::Chunk.split(io, grid_info, offset)
|
178
|
+
end
|
179
|
+
|
180
|
+
def patch_last_chunk(input, grid_info)
|
181
|
+
if grid_info[:length] % grid_info[:chunkSize] != 0
|
182
|
+
last_chunk = chunks_collection.find(files_id: grid_info[:_id]).sort(n: -1).limit(1).first
|
183
|
+
data = last_chunk[:data].data
|
184
|
+
data << input.read(grid_info[:chunkSize] - data.length)
|
185
|
+
|
186
|
+
chunks_collection.find(files_id: grid_info[:_id], n: last_chunk[:n])
|
187
|
+
.update_one("$set" => {data: BSON::Binary.new(data)})
|
188
|
+
|
189
|
+
data.clear # deallocate string
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def find_grid_info!(uid)
|
194
|
+
files_collection.find(filename: uid).first or raise Tus::NotFound
|
195
|
+
end
|
196
|
+
|
197
|
+
def validate_parts!(grid_infos, part_uids)
|
198
|
+
validate_parts_presence!(grid_infos, part_uids)
|
199
|
+
validate_parts_full_chunks!(grid_infos)
|
200
|
+
end
|
201
|
+
|
202
|
+
def validate_parts_presence!(grid_infos, part_uids)
|
203
|
+
if grid_infos.count != part_uids.count
|
204
|
+
raise Tus::Error, "some parts for concatenation are missing"
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
def validate_parts_full_chunks!(grid_infos)
|
209
|
+
grid_infos.each do |grid_info|
|
210
|
+
if grid_info[:length] % grid_info[:chunkSize] != 0 && grid_info != grid_infos.last
|
211
|
+
raise Tus::Error, "cannot concatenate parts which aren't evenly distributed across chunks"
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
def files_collection
|
217
|
+
bucket.files_collection
|
218
|
+
end
|
219
|
+
|
220
|
+
def chunks_collection
|
221
|
+
bucket.chunks_collection
|
181
222
|
end
|
182
223
|
|
183
224
|
class Response
|
184
|
-
def initialize(chunks:, close:)
|
225
|
+
def initialize(chunks:, close:, length:)
|
185
226
|
@chunks = chunks
|
186
227
|
@close = close
|
228
|
+
@length = length
|
229
|
+
end
|
230
|
+
|
231
|
+
def length
|
232
|
+
@length
|
187
233
|
end
|
188
234
|
|
189
235
|
def each(&block)
|
data/lib/tus/storage/s3.rb
CHANGED
@@ -16,13 +16,14 @@ module Tus
|
|
16
16
|
|
17
17
|
attr_reader :client, :bucket, :prefix, :upload_options
|
18
18
|
|
19
|
-
def initialize(bucket:, prefix: nil, upload_options: {}, **client_options)
|
19
|
+
def initialize(bucket:, prefix: nil, upload_options: {}, thread_count: 10, **client_options)
|
20
20
|
resource = Aws::S3::Resource.new(**client_options)
|
21
21
|
|
22
|
-
@client
|
23
|
-
@bucket
|
24
|
-
@prefix
|
22
|
+
@client = resource.client
|
23
|
+
@bucket = resource.bucket(bucket)
|
24
|
+
@prefix = prefix
|
25
25
|
@upload_options = upload_options
|
26
|
+
@thread_count = thread_count
|
26
27
|
end
|
27
28
|
|
28
29
|
def create_file(uid, info = {})
|
@@ -40,73 +41,33 @@ module Tus
|
|
40
41
|
|
41
42
|
info["multipart_id"] = multipart_upload.id
|
42
43
|
info["multipart_parts"] = []
|
44
|
+
|
45
|
+
multipart_upload
|
43
46
|
end
|
44
47
|
|
45
48
|
def concatenate(uid, part_uids, info = {})
|
46
|
-
create_file(uid, info)
|
47
|
-
|
48
|
-
multipart_upload = object(uid).multipart_upload(info["multipart_id"])
|
49
|
-
|
50
|
-
queue = Queue.new
|
51
|
-
part_uids.each_with_index do |part_uid, idx|
|
52
|
-
queue << {
|
53
|
-
copy_source: [bucket.name, object(part_uid).key].join("/"),
|
54
|
-
part_number: idx + 1
|
55
|
-
}
|
56
|
-
end
|
57
|
-
|
58
|
-
threads = 10.times.map do
|
59
|
-
Thread.new do
|
60
|
-
Thread.current.abort_on_exception = true
|
61
|
-
completed = []
|
62
|
-
|
63
|
-
begin
|
64
|
-
loop do
|
65
|
-
multipart_copy_task = queue.deq(true) rescue break
|
49
|
+
multipart_upload = create_file(uid, info)
|
66
50
|
|
67
|
-
|
68
|
-
|
51
|
+
objects = part_uids.map { |part_uid| object(part_uid) }
|
52
|
+
parts = copy_parts(objects, multipart_upload)
|
69
53
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
completed << {
|
74
|
-
part_number: part_number,
|
75
|
-
etag: response.copy_part_result.etag,
|
76
|
-
}
|
77
|
-
end
|
78
|
-
|
79
|
-
completed
|
80
|
-
rescue
|
81
|
-
queue.clear
|
82
|
-
raise
|
83
|
-
end
|
84
|
-
end
|
54
|
+
parts.each do |part|
|
55
|
+
info["multipart_parts"] << { "part_number" => part[:part_number], "etag" => part[:etag] }
|
85
56
|
end
|
86
57
|
|
87
|
-
|
88
|
-
|
89
|
-
multipart_upload.complete(multipart_upload: {parts: parts})
|
58
|
+
finalize_file(uid, info)
|
90
59
|
|
91
60
|
delete(part_uids.flat_map { |part_uid| [object(part_uid), object("#{part_uid}.info")] })
|
92
61
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
rescue
|
62
|
+
# Tus server requires us to return the size of the concatenated file.
|
63
|
+
object = client.head_object(bucket: bucket.name, key: object(uid).key)
|
64
|
+
object.content_length
|
65
|
+
rescue => error
|
98
66
|
abort_multipart_upload(multipart_upload) if multipart_upload
|
99
|
-
raise
|
67
|
+
raise error
|
100
68
|
end
|
101
69
|
|
102
70
|
def patch_file(uid, io, info = {})
|
103
|
-
tus_info = Tus::Info.new(info)
|
104
|
-
last_chunk = (tus_info.length && io.size == tus_info.remaining_length)
|
105
|
-
|
106
|
-
if io.size < MIN_PART_SIZE && !last_chunk
|
107
|
-
raise Tus::Error, "Chunk size cannot be smaller than 5MB"
|
108
|
-
end
|
109
|
-
|
110
71
|
upload_id = info["multipart_id"]
|
111
72
|
part_number = info["multipart_parts"].count + 1
|
112
73
|
|
@@ -114,30 +75,27 @@ module Tus
|
|
114
75
|
multipart_part = multipart_upload.part(part_number)
|
115
76
|
md5 = Tus::Checksum.new("md5").generate(io)
|
116
77
|
|
117
|
-
|
118
|
-
response = multipart_part.upload(body: io, content_md5: md5)
|
119
|
-
rescue Aws::S3::Errors::NoSuchUpload
|
120
|
-
raise Tus::NotFound
|
121
|
-
end
|
78
|
+
response = multipart_part.upload(body: io, content_md5: md5)
|
122
79
|
|
123
80
|
info["multipart_parts"] << {
|
124
81
|
"part_number" => part_number,
|
125
82
|
"etag" => response.etag[/"(.+)"/, 1],
|
126
83
|
}
|
84
|
+
rescue Aws::S3::Errors::NoSuchUpload
|
85
|
+
raise Tus::NotFound
|
86
|
+
end
|
127
87
|
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
parts: info["multipart_parts"].map do |part|
|
133
|
-
{part_number: part["part_number"], etag: part["etag"]}
|
134
|
-
end
|
135
|
-
}
|
136
|
-
)
|
137
|
-
|
138
|
-
info.delete("multipart_id")
|
139
|
-
info.delete("multipart_parts")
|
88
|
+
def finalize_file(uid, info = {})
|
89
|
+
upload_id = info["multipart_id"]
|
90
|
+
parts = info["multipart_parts"].map do |part|
|
91
|
+
{ part_number: part["part_number"], etag: part["etag"] }
|
140
92
|
end
|
93
|
+
|
94
|
+
multipart_upload = object(uid).multipart_upload(upload_id)
|
95
|
+
multipart_upload.complete(multipart_upload: {parts: parts})
|
96
|
+
|
97
|
+
info.delete("multipart_id")
|
98
|
+
info.delete("multipart_parts")
|
141
99
|
end
|
142
100
|
|
143
101
|
def read_info(uid)
|
@@ -152,29 +110,31 @@ module Tus
|
|
152
110
|
end
|
153
111
|
|
154
112
|
def get_file(uid, info = {}, range: nil)
|
155
|
-
|
156
|
-
|
157
|
-
end
|
113
|
+
object = object(uid)
|
114
|
+
range = "bytes=#{range.begin}-#{range.end}" if range
|
158
115
|
|
159
116
|
raw_chunks = Enumerator.new do |yielder|
|
160
|
-
object
|
117
|
+
object.get(range: range) do |chunk|
|
161
118
|
yielder << chunk
|
162
119
|
chunk.clear # deallocate string
|
163
120
|
end
|
164
121
|
end
|
165
122
|
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
raise Tus::NotFound
|
170
|
-
end
|
123
|
+
# Start the request to be notified if the object doesn't exist, and to
|
124
|
+
# get Aws::S3::Object#content_length.
|
125
|
+
first_chunk = raw_chunks.next
|
171
126
|
|
172
127
|
chunks = Enumerator.new do |yielder|
|
173
128
|
yielder << first_chunk
|
174
129
|
loop { yielder << raw_chunks.next }
|
175
130
|
end
|
176
131
|
|
177
|
-
Response.new(
|
132
|
+
Response.new(
|
133
|
+
chunks: chunks,
|
134
|
+
length: object.content_length,
|
135
|
+
)
|
136
|
+
rescue Aws::S3::Errors::NoSuchKey
|
137
|
+
raise Tus::NotFound
|
178
138
|
end
|
179
139
|
|
180
140
|
def delete_file(uid, info = {})
|
@@ -226,18 +186,71 @@ module Tus
|
|
226
186
|
# multipart upload was successfully aborted or doesn't exist
|
227
187
|
end
|
228
188
|
|
189
|
+
def copy_parts(objects, multipart_upload)
|
190
|
+
parts = compute_parts(objects, multipart_upload)
|
191
|
+
queue = parts.inject(Queue.new) { |queue, part| queue << part }
|
192
|
+
|
193
|
+
threads = @thread_count.times.map { copy_part_thread(queue) }
|
194
|
+
|
195
|
+
threads.flat_map(&:value).sort_by { |part| part[:part_number] }
|
196
|
+
end
|
197
|
+
|
198
|
+
def compute_parts(objects, multipart_upload)
|
199
|
+
objects.map.with_index do |object, idx|
|
200
|
+
{
|
201
|
+
bucket: multipart_upload.bucket_name,
|
202
|
+
key: multipart_upload.object_key,
|
203
|
+
upload_id: multipart_upload.id,
|
204
|
+
copy_source: [object.bucket_name, object.key].join("/"),
|
205
|
+
part_number: idx + 1,
|
206
|
+
}
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def copy_part_thread(queue)
|
211
|
+
Thread.new do
|
212
|
+
Thread.current.abort_on_exception = true
|
213
|
+
begin
|
214
|
+
results = []
|
215
|
+
loop do
|
216
|
+
part = queue.deq(true) rescue break
|
217
|
+
results << copy_part(part)
|
218
|
+
end
|
219
|
+
results
|
220
|
+
rescue => error
|
221
|
+
queue.clear
|
222
|
+
raise error
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def copy_part(part)
|
228
|
+
response = client.upload_part_copy(part)
|
229
|
+
|
230
|
+
{ part_number: part[:part_number], etag: response.copy_part_result.etag }
|
231
|
+
end
|
232
|
+
|
229
233
|
def object(key)
|
230
234
|
bucket.object([*prefix, key].join("/"))
|
231
235
|
end
|
232
236
|
|
233
237
|
class Response
|
234
|
-
def initialize(chunks:)
|
238
|
+
def initialize(chunks:, length:)
|
235
239
|
@chunks = chunks
|
240
|
+
@length = length
|
241
|
+
end
|
242
|
+
|
243
|
+
def length
|
244
|
+
@length
|
236
245
|
end
|
237
246
|
|
238
247
|
def each(&block)
|
239
248
|
@chunks.each(&block)
|
240
249
|
end
|
250
|
+
|
251
|
+
def close
|
252
|
+
# aws-sdk doesn't provide an API to terminate the HTTP connection
|
253
|
+
end
|
241
254
|
end
|
242
255
|
end
|
243
256
|
end
|
data/tus-server.gemspec
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: tus-server
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.10.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Janko Marohnić
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2017-03-
|
11
|
+
date: 2017-03-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: roda
|