dis 2.0.0 → 2.2.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/README.md +22 -2
- data/lib/dis/controller.rb +112 -0
- data/lib/dis/response_body.rb +115 -0
- data/lib/dis/version.rb +1 -1
- data/lib/dis.rb +3 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d4c3624aed32276988e39638489e694b4755991ec156d6e334013ff3e9f6dcae
|
|
4
|
+
data.tar.gz: 381f0c8bad7c0895f556d08040b2e83f514a8b52deca71db27740802048b5029
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 911c9655763e26056cd0a93e32b37a546983798d24271be047d4c720a8a8fcbc5db0152dafabf147cf3cc788e60e6cf7e53222afc414345b0ac483a470967af0
|
|
7
|
+
data.tar.gz: efe469600850c64269abfbd2fecdf221cb70d8a1de9200022891440cc432d37bde55b0535f88fed462e8fa51c8b90fdd492f0079f7333d28be9b55599487f19a
|
data/README.md
CHANGED
|
@@ -125,8 +125,7 @@ document.with_data_file { |path| Vips::Image.new_from_file(path.to_s).avg }
|
|
|
125
125
|
|
|
126
126
|
`open_data` returns an open file, valid until you close it — even if
|
|
127
127
|
the content is deleted or evicted from a cache layer meanwhile. Use it
|
|
128
|
-
when the reader outlives the current call stack
|
|
129
|
-
body handed to the web server.
|
|
128
|
+
when the reader outlives the current call stack.
|
|
130
129
|
|
|
131
130
|
```ruby
|
|
132
131
|
document.open_data { |file| file.read }
|
|
@@ -134,6 +133,27 @@ document.open_data { |file| file.read }
|
|
|
134
133
|
file = document.open_data # caller closes it
|
|
135
134
|
```
|
|
136
135
|
|
|
136
|
+
### Sending data from a controller
|
|
137
|
+
|
|
138
|
+
Include `Dis::Controller` and use `send_dis_data` to stream a record's
|
|
139
|
+
data to the client. It works like `send_file`, but reads through an
|
|
140
|
+
open descriptor rather than a path, so the response is unaffected if
|
|
141
|
+
the content is evicted or deleted while it is being written.
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
class DocumentsController < ApplicationController
|
|
145
|
+
include Dis::Controller
|
|
146
|
+
|
|
147
|
+
def show
|
|
148
|
+
send_dis_data(Document.find(params[:id]), disposition: "inline")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`filename` and `content_type` default to the record's own metadata.
|
|
154
|
+
The full set of options is `filename`, `content_type`, `disposition`
|
|
155
|
+
and `status`.
|
|
156
|
+
|
|
137
157
|
## Layers
|
|
138
158
|
|
|
139
159
|
The underlying storage consists of one or more layers. Each layer
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dis
|
|
4
|
+
# = Dis Controller
|
|
5
|
+
#
|
|
6
|
+
# Adds {#send_dis_data} for serving stored data from a controller.
|
|
7
|
+
#
|
|
8
|
+
# class DocumentsController < ApplicationController
|
|
9
|
+
# include Dis::Controller
|
|
10
|
+
#
|
|
11
|
+
# def show
|
|
12
|
+
# send_dis_data(Document.find(params[:id]))
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
module Controller
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
included do
|
|
19
|
+
include ActionController::DataStreaming
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Sends the record's data to the client. Works like +send_file+,
|
|
25
|
+
# but reads through an open descriptor rather than a path, so the
|
|
26
|
+
# response is unaffected if the content is evicted or deleted
|
|
27
|
+
# while it is being written.
|
|
28
|
+
#
|
|
29
|
+
# +filename+ and +content_type+ default to the record's own
|
|
30
|
+
# metadata.
|
|
31
|
+
#
|
|
32
|
+
# Responds with +206 Partial Content+ to a range request, or +416
|
|
33
|
+
# Range Not Satisfiable+ if the range lies beyond the data. Several
|
|
34
|
+
# ranges are sent as +multipart/byteranges+.
|
|
35
|
+
#
|
|
36
|
+
# @param record [Dis::Model] the record to send data from
|
|
37
|
+
# @param filename [String, nil] suggested filename
|
|
38
|
+
# @param content_type [String, nil] the content type
|
|
39
|
+
# @param disposition [String] +"attachment"+ or +"inline"+
|
|
40
|
+
# @param status [Integer] the HTTP status code
|
|
41
|
+
# @return [void]
|
|
42
|
+
# @raise [Dis::Errors::NotFoundError] if the data is not found
|
|
43
|
+
#
|
|
44
|
+
# @example
|
|
45
|
+
# send_dis_data(document, disposition: "inline")
|
|
46
|
+
def send_dis_data(record, filename: nil, content_type: nil,
|
|
47
|
+
disposition: "attachment", status: 200)
|
|
48
|
+
file = record.open_data
|
|
49
|
+
ranges = dis_byte_ranges(file.size, status)
|
|
50
|
+
response.headers["Accept-Ranges"] = "bytes"
|
|
51
|
+
return dis_unsatisfiable_range(file) if ranges && ranges.empty?
|
|
52
|
+
|
|
53
|
+
type = content_type || dis_metadata(record, :content_type)
|
|
54
|
+
dis_send_file_headers(record, filename, type, disposition)
|
|
55
|
+
dis_send_body(
|
|
56
|
+
Dis::ResponseBody.new(file, ranges:, content_type: type), file.size,
|
|
57
|
+
status
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def dis_byte_ranges(size, status)
|
|
62
|
+
return unless status == 200
|
|
63
|
+
return unless dis_if_range_matches?
|
|
64
|
+
|
|
65
|
+
Rack::Utils.get_byte_ranges(request.get_header("HTTP_RANGE"), size)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def dis_if_range_matches?
|
|
69
|
+
if_range = request.get_header("HTTP_IF_RANGE")
|
|
70
|
+
return true if if_range.blank?
|
|
71
|
+
|
|
72
|
+
[response.etag, response.headers["Last-Modified"]].any? do |validator|
|
|
73
|
+
validator.present? && if_range == validator
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def dis_unsatisfiable_range(file)
|
|
78
|
+
response.headers["Content-Range"] = "bytes */#{file.size}"
|
|
79
|
+
file.close
|
|
80
|
+
head :range_not_satisfiable
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def dis_send_file_headers(record, filename, type, disposition)
|
|
84
|
+
send_file_headers!(
|
|
85
|
+
{ filename: filename || dis_metadata(record, :filename),
|
|
86
|
+
type:, disposition: }.compact
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def dis_send_body(body, size, status)
|
|
91
|
+
self.status = body.ranges.any? ? :partial_content : status
|
|
92
|
+
dis_partial_headers(body, size)
|
|
93
|
+
response.headers["Content-Length"] = body.length.to_s
|
|
94
|
+
self.response_body = body
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def dis_partial_headers(body, size)
|
|
98
|
+
if body.multipart?
|
|
99
|
+
self.content_type =
|
|
100
|
+
"multipart/byteranges; boundary=#{body.boundary}"
|
|
101
|
+
elsif body.range
|
|
102
|
+
response.headers["Content-Range"] =
|
|
103
|
+
"bytes #{body.range.begin}-#{body.range.end}/#{size}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def dis_metadata(record, name)
|
|
108
|
+
attribute = record.class.dis_attributes[name]
|
|
109
|
+
record[attribute] if attribute
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Dis
|
|
6
|
+
# = Dis Response Body
|
|
7
|
+
#
|
|
8
|
+
# Rack body that streams an open file and closes it once the
|
|
9
|
+
# response has been sent. The file may already be unlinked, so it is
|
|
10
|
+
# read through the open descriptor and never by path.
|
|
11
|
+
#
|
|
12
|
+
# Streams only the given ranges when any are given, as a
|
|
13
|
+
# +multipart/byteranges+ payload if there is more than one.
|
|
14
|
+
class ResponseBody
|
|
15
|
+
CHUNK_SIZE = 16_384
|
|
16
|
+
|
|
17
|
+
delegate :closed?, to: :@file
|
|
18
|
+
|
|
19
|
+
# @return [Array<Range>] the ranges being streamed
|
|
20
|
+
attr_reader :ranges
|
|
21
|
+
|
|
22
|
+
# @return [String, nil] the multipart boundary, when multipart
|
|
23
|
+
attr_reader :boundary
|
|
24
|
+
|
|
25
|
+
# @param file [File] an open, readable file
|
|
26
|
+
# @param ranges [Array<Range>, nil] byte ranges, or nil for all of it
|
|
27
|
+
# @param content_type [String, nil] content type of the parts
|
|
28
|
+
def initialize(file, ranges: nil, content_type: nil)
|
|
29
|
+
@file = file
|
|
30
|
+
@ranges = Array(ranges)
|
|
31
|
+
@content_type = content_type
|
|
32
|
+
@boundary = SecureRandom.hex(16) if multipart?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Boolean] whether more than one range is being streamed
|
|
36
|
+
def multipart?
|
|
37
|
+
ranges.length > 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Range, nil] the range being streamed, unless multipart
|
|
41
|
+
def range
|
|
42
|
+
ranges.first unless multipart?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the number of bytes that will be written.
|
|
46
|
+
#
|
|
47
|
+
# @return [Integer]
|
|
48
|
+
def length
|
|
49
|
+
return @file.size if ranges.empty?
|
|
50
|
+
return multipart_length if multipart?
|
|
51
|
+
|
|
52
|
+
range.size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns the contents as a binary string.
|
|
56
|
+
#
|
|
57
|
+
# @return [String]
|
|
58
|
+
def body
|
|
59
|
+
(+"").b.tap { |out| each { |chunk| out << chunk } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Yields the contents in chunks.
|
|
63
|
+
#
|
|
64
|
+
# @yieldparam chunk [String] a chunk of the contents
|
|
65
|
+
# @return [void]
|
|
66
|
+
def each(&)
|
|
67
|
+
return stream(0, @file.size, &) if ranges.empty?
|
|
68
|
+
return stream(range.begin, range.size, &) unless multipart?
|
|
69
|
+
|
|
70
|
+
each_part(&)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Closes the underlying file.
|
|
74
|
+
#
|
|
75
|
+
# @return [void]
|
|
76
|
+
def close
|
|
77
|
+
@file.close unless @file.closed?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def each_part(&)
|
|
83
|
+
ranges.each do |range|
|
|
84
|
+
yield heading(range)
|
|
85
|
+
stream(range.begin, range.size, &)
|
|
86
|
+
end
|
|
87
|
+
yield terminator
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stream(offset, remaining)
|
|
91
|
+
@file.seek(offset)
|
|
92
|
+
while remaining.positive? &&
|
|
93
|
+
(chunk = @file.read([CHUNK_SIZE, remaining].min))
|
|
94
|
+
remaining -= chunk.bytesize
|
|
95
|
+
yield chunk
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def multipart_length
|
|
100
|
+
ranges.sum { |range| heading(range).bytesize + range.size } +
|
|
101
|
+
terminator.bytesize
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def heading(range)
|
|
105
|
+
"\r\n--#{boundary}\r\n" \
|
|
106
|
+
"Content-Type: #{@content_type}\r\n" \
|
|
107
|
+
"Content-Range: bytes #{range.begin}-#{range.end}/#{@file.size}\r\n" \
|
|
108
|
+
"\r\n"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def terminator
|
|
112
|
+
"\r\n--#{boundary}--\r\n"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/dis/version.rb
CHANGED
data/lib/dis.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "fog/core"
|
|
|
6
6
|
require "fog/local"
|
|
7
7
|
require "active_job"
|
|
8
8
|
require "concurrent"
|
|
9
|
+
require "dis/controller"
|
|
9
10
|
require "dis/engine"
|
|
10
11
|
require "dis/errors"
|
|
11
12
|
require "dis/jobs"
|
|
@@ -13,6 +14,7 @@ require "dis/logging"
|
|
|
13
14
|
require "dis/layer"
|
|
14
15
|
require "dis/layers"
|
|
15
16
|
require "dis/model"
|
|
17
|
+
require "dis/response_body"
|
|
16
18
|
require "dis/storage"
|
|
17
19
|
require "dis/validations"
|
|
18
20
|
|
|
@@ -27,6 +29,7 @@ require "dis/validations"
|
|
|
27
29
|
# configure layers via {Dis::Storage.layers}.
|
|
28
30
|
#
|
|
29
31
|
# @see Dis::Model
|
|
32
|
+
# @see Dis::Controller
|
|
30
33
|
# @see Dis::Storage
|
|
31
34
|
# @see Dis::Layer
|
|
32
35
|
module Dis
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dis
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Inge Jørgensen
|
|
@@ -71,6 +71,20 @@ dependencies:
|
|
|
71
71
|
- - ">="
|
|
72
72
|
- !ruby/object:Gem::Version
|
|
73
73
|
version: '0'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: rack
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: 2.2.4
|
|
81
|
+
type: :runtime
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: 2.2.4
|
|
74
88
|
- !ruby/object:Gem::Dependency
|
|
75
89
|
name: rails
|
|
76
90
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -110,6 +124,7 @@ files:
|
|
|
110
124
|
- LICENSE
|
|
111
125
|
- README.md
|
|
112
126
|
- lib/dis.rb
|
|
127
|
+
- lib/dis/controller.rb
|
|
113
128
|
- lib/dis/engine.rb
|
|
114
129
|
- lib/dis/errors.rb
|
|
115
130
|
- lib/dis/jobs.rb
|
|
@@ -123,6 +138,7 @@ files:
|
|
|
123
138
|
- lib/dis/model.rb
|
|
124
139
|
- lib/dis/model/class_methods.rb
|
|
125
140
|
- lib/dis/model/data.rb
|
|
141
|
+
- lib/dis/response_body.rb
|
|
126
142
|
- lib/dis/storage.rb
|
|
127
143
|
- lib/dis/validations.rb
|
|
128
144
|
- lib/dis/validations/data_presence.rb
|