monkrb 0.22.2 → 0.23.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 +46 -0
- data/README.md +3 -2
- data/lib/monk/base.rb +5 -0
- data/lib/monk/generators/base.rb +1 -0
- data/lib/monk/generators/storage.rb +28 -0
- data/lib/monk/generators.rb +1 -1
- data/lib/monk/storage/backends/file.rb +242 -0
- data/lib/monk/storage/backends/s3.rb +197 -0
- data/lib/monk/storage/errors.rb +34 -0
- data/lib/monk/storage/input.rb +83 -0
- data/lib/monk/storage/key.rb +40 -0
- data/lib/monk/storage/object.rb +7 -0
- data/lib/monk/storage/routes.rb +110 -0
- data/lib/monk/storage/sig_v4.rb +124 -0
- data/lib/monk/storage/sniff.rb +81 -0
- data/lib/monk/storage/token.rb +45 -0
- data/lib/monk/storage/upload.rb +10 -0
- data/lib/monk/storage/url.rb +126 -0
- data/lib/monk/storage.rb +184 -0
- data/lib/monk/templates/base/Dockerfile +5 -0
- data/lib/monk/templates/base/Gemfile +1 -1
- data/lib/monk/templates/base/bin/server +20 -0
- data/lib/monk/templates/base/setup.md +10 -0
- data/lib/monk/templates/storage/agents.md +35 -0
- data/lib/monk/templates/storage/app/routes/storage.rb +67 -0
- data/lib/monk/templates/storage/config/storage.rb +21 -0
- data/lib/monk/templates/storage/setup.md +43 -0
- data/lib/monk/templates/storage/test/storage_test.rb +33 -0
- data/lib/monk/version.rb +1 -1
- metadata +20 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module Storage
|
|
3
|
+
# Checks on the arguments both backends take, so they refuse the same
|
|
4
|
+
# input the same way. Keys have their own module (Key).
|
|
5
|
+
module Input
|
|
6
|
+
DEFAULT_CONTENT_TYPE = "application/octet-stream".freeze
|
|
7
|
+
CHUNK_BYTES = 64 * 1024
|
|
8
|
+
# SigV4's longest signed link: 7 days. A url is valid until the end
|
|
9
|
+
# of its cache window plus expires_in, so the two together stay
|
|
10
|
+
# under it.
|
|
11
|
+
MAX_LINK_SECONDS = 7 * 24 * 3600
|
|
12
|
+
# type/subtype with optional parameters, and nothing that could end a
|
|
13
|
+
# header line.
|
|
14
|
+
CONTENT_TYPE = %r{\A[a-z0-9][a-z0-9!\#$&^_.+-]*/[a-z0-9][a-z0-9!\#$&^_.+-]*(;[ \t]*[ -~]*)?\z}i
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def content_type!(value)
|
|
19
|
+
return value if value.is_a?(String) && value.match?(CONTENT_TYPE)
|
|
20
|
+
|
|
21
|
+
raise ArgumentError, "content_type: must be a MIME type such as \"image/png\", not #{value.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A String, or an IO the backend can measure, read, and read again
|
|
25
|
+
# (S3 hashes the body before sending it).
|
|
26
|
+
def body!(body)
|
|
27
|
+
return body if body.is_a?(String)
|
|
28
|
+
return body if %i[size read rewind].all? { |name| body.respond_to?(name) }
|
|
29
|
+
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
"a stored body is a String or an IO that responds to size, read and rewind (File, Tempfile, " \
|
|
32
|
+
"StringIO), not #{body.class}; write a pipe to a Tempfile first"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def link_times!(expires_in:, cache_window:)
|
|
36
|
+
{ expires_in: expires_in, cache_window: cache_window }.each do |name, value|
|
|
37
|
+
next if value.is_a?(Integer) && value.positive?
|
|
38
|
+
|
|
39
|
+
raise ArgumentError, "#{name}: is a number of seconds above 0, not #{value.inspect}"
|
|
40
|
+
end
|
|
41
|
+
return if expires_in + cache_window <= MAX_LINK_SECONDS
|
|
42
|
+
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"expires_in: #{expires_in} + cache_window: #{cache_window} is more than 7 days (#{MAX_LINK_SECONDS}), " \
|
|
45
|
+
"the longest a signed S3 link can be valid"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def byte_size!(value)
|
|
49
|
+
return value if value.is_a?(Integer) && !value.negative?
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "byte_size: is the upload's length in bytes, not #{value.inspect}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The body's SHA-256 in hex, read in chunks and rewound, so an IO can
|
|
55
|
+
# then be sent from the start.
|
|
56
|
+
def sha256(body)
|
|
57
|
+
return Digest::SHA256.hexdigest(body) if body.is_a?(String)
|
|
58
|
+
|
|
59
|
+
digest = Digest::SHA256.new
|
|
60
|
+
body.rewind
|
|
61
|
+
while (chunk = body.read(CHUNK_BYTES))
|
|
62
|
+
digest << chunk
|
|
63
|
+
end
|
|
64
|
+
body.rewind
|
|
65
|
+
digest.hexdigest
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A Range of byte offsets -> [first, last], last nil for "to the end".
|
|
69
|
+
def byte_span(range)
|
|
70
|
+
first = range.begin if range.is_a?(Range)
|
|
71
|
+
last = range.end if range.is_a?(Range)
|
|
72
|
+
unless first.is_a?(Integer) && first >= 0 && (last.nil? || last.is_a?(Integer))
|
|
73
|
+
raise ArgumentError, "range: is a Range of byte offsets from 0, e.g. 0..15, not #{range.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
last -= 1 if last && range.exclude_end?
|
|
77
|
+
raise ArgumentError, "range: #{range.inspect} is empty" if last && last < first
|
|
78
|
+
|
|
79
|
+
[first, last]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Monk
|
|
4
|
+
module Storage
|
|
5
|
+
# Keys name objects in the bucket (or under the file backend's root).
|
|
6
|
+
# Monk makes them random and never from a user's filename, which rules
|
|
7
|
+
# out path traversal and keeps file names out of URLs
|
|
8
|
+
# (docs/adr/0018-storage-s3-compatible-direct-upload.md).
|
|
9
|
+
module Key
|
|
10
|
+
# Lowercase only: macOS disks are case-insensitive, and the file
|
|
11
|
+
# backend must not merge two keys a bucket keeps apart. A segment
|
|
12
|
+
# can't start with a dot, so no "." or ".." segment, and no empty one.
|
|
13
|
+
FORMAT = %r{\A[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*\z}
|
|
14
|
+
MAX_BYTES = 512
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# "tmp/1f0c...": 128 random bits under prefix.
|
|
19
|
+
def generate(prefix:)
|
|
20
|
+
validate!(prefix)
|
|
21
|
+
|
|
22
|
+
"#{prefix}/#{SecureRandom.hex(16)}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Runs on every call to either backend, so a key that would mean
|
|
26
|
+
# something else on disk than in a bucket never reaches one.
|
|
27
|
+
def validate!(key)
|
|
28
|
+
raise InvalidKeyError, "a storage key is a String, not #{key.class}" unless key.is_a?(String)
|
|
29
|
+
raise InvalidKeyError, "storage key is #{key.bytesize} bytes; at most #{MAX_BYTES}" if key.bytesize > MAX_BYTES
|
|
30
|
+
unless key.match?(FORMAT)
|
|
31
|
+
raise InvalidKeyError,
|
|
32
|
+
"storage key #{key.inspect}: segments of a-z, 0-9, . _ and -, each starting with a letter or digit, " \
|
|
33
|
+
"joined by / (e.g. from Monk::Storage.generate_key(prefix: \"tmp\"))"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
key
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module Storage
|
|
3
|
+
ROUTE_PREFIX = "/_monk/storage/".freeze
|
|
4
|
+
|
|
5
|
+
# PUT and GET /_monk/storage/<token>: the file backend standing in for
|
|
6
|
+
# a bucket's signed URLs, refusing what a bucket would refuse, the way
|
|
7
|
+
# it would (docs/history/plan-storage.md decisions 7 and 8). Served whenever
|
|
8
|
+
# the backend is file://, in any environment, since its links only
|
|
9
|
+
# work if the routes exist.
|
|
10
|
+
module Routes
|
|
11
|
+
# Shareable, not only frozen: worker Ractors serve these routes.
|
|
12
|
+
NOSNIFF = Ractor.make_shareable({ "x-content-type-options" => "nosniff" })
|
|
13
|
+
RANGE = /\Abytes=(\d*)-(\d*)\z/
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# A Rack response, or nil when the request isn't for these routes.
|
|
18
|
+
def response(env, backend, now: Time.now.to_i)
|
|
19
|
+
path = env["PATH_INFO"].to_s
|
|
20
|
+
return nil unless backend.is_a?(Backends::File) && path.start_with?(ROUTE_PREFIX)
|
|
21
|
+
|
|
22
|
+
payload = Token.verify(path.delete_prefix(ROUTE_PREFIX), backend.secret)
|
|
23
|
+
return refuse(403, "signature doesn't match") unless payload
|
|
24
|
+
return refuse(403, "link expired") unless payload[:e].is_a?(Integer) && payload[:e] > now
|
|
25
|
+
return refuse(403, "this link is for #{payload[:m]}") unless env["REQUEST_METHOD"] == payload[:m]
|
|
26
|
+
|
|
27
|
+
case payload[:m]
|
|
28
|
+
when "PUT" then upload(env, backend, payload)
|
|
29
|
+
when "GET" then download(env, backend, payload, now)
|
|
30
|
+
else refuse(403, "signature doesn't match")
|
|
31
|
+
end
|
|
32
|
+
rescue InvalidKeyError
|
|
33
|
+
refuse(403, "signature doesn't match")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The browser's PUT. Content-Length and Content-Type are inside the
|
|
37
|
+
# token, as in a presigned S3 PUT: another value is a 403. A body
|
|
38
|
+
# shorter or longer than its Content-Length is a 400.
|
|
39
|
+
def upload(env, backend, payload)
|
|
40
|
+
return refuse(403, "Content-Length isn't the signed one") unless env["CONTENT_LENGTH"] == payload[:l].to_s
|
|
41
|
+
return refuse(403, "Content-Type isn't the signed one") unless env["CONTENT_TYPE"] == payload[:t]
|
|
42
|
+
|
|
43
|
+
md5 = backend.receive(payload[:k], env["rack.input"], content_type: payload[:t], byte_size: payload[:l])
|
|
44
|
+
return refuse(400, "the body isn't Content-Length bytes") unless md5
|
|
45
|
+
|
|
46
|
+
[200, NOSNIFF.merge("etag" => %("#{md5}")), []]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The signed GET: the stored bytes, as their stored type, cached
|
|
50
|
+
# privately until the link expires. A Range is answered with 206, as
|
|
51
|
+
# a bucket does: Safari won't play <audio> or <video> without it.
|
|
52
|
+
def download(env, backend, payload, now)
|
|
53
|
+
object = backend.head(payload[:k])
|
|
54
|
+
return refuse(404, "no such object") unless object
|
|
55
|
+
|
|
56
|
+
headers = NOSNIFF.merge(
|
|
57
|
+
"content-type" => object.content_type,
|
|
58
|
+
"accept-ranges" => "bytes",
|
|
59
|
+
"etag" => %("#{object.etag}"),
|
|
60
|
+
"cache-control" => "private, max-age=#{payload[:e] - now}",
|
|
61
|
+
)
|
|
62
|
+
span = span(env["HTTP_RANGE"], object.byte_size)
|
|
63
|
+
return whole(backend, payload[:k], headers) if span.nil?
|
|
64
|
+
return refuse(416, "range not satisfiable", "content-range" => "bytes */#{object.byte_size}") if span == :none
|
|
65
|
+
|
|
66
|
+
first, last = span
|
|
67
|
+
bytes = backend.get(payload[:k], range: first..last)
|
|
68
|
+
[206, headers.merge("content-range" => "bytes #{first}-#{last}/#{object.byte_size}",
|
|
69
|
+
"content-length" => bytes.bytesize.to_s,), [bytes],]
|
|
70
|
+
rescue NotFoundError
|
|
71
|
+
refuse(404, "no such object")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def whole(backend, key, headers)
|
|
75
|
+
bytes = backend.get(key)
|
|
76
|
+
[200, headers.merge("content-length" => bytes.bytesize.to_s), [bytes]]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# One range of a Range header -> [first, last] within size; :none
|
|
80
|
+
# when it can't be satisfied; nil to ignore the header and send the
|
|
81
|
+
# whole object (no header, a syntax this doesn't read, several
|
|
82
|
+
# ranges), as RFC 9110 allows.
|
|
83
|
+
def span(header, size)
|
|
84
|
+
match = header && RANGE.match(header)
|
|
85
|
+
return nil unless match
|
|
86
|
+
|
|
87
|
+
first, last = match.captures
|
|
88
|
+
return nil if first.empty? && last.empty?
|
|
89
|
+
|
|
90
|
+
if first.empty?
|
|
91
|
+
suffix = last.to_i
|
|
92
|
+
return :none if suffix.zero? || size.zero?
|
|
93
|
+
|
|
94
|
+
return [[size - suffix, 0].max, size - 1]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
first = first.to_i
|
|
98
|
+
last = last.empty? ? size - 1 : [last.to_i, size - 1].min
|
|
99
|
+
return nil if last < first && first < size
|
|
100
|
+
return :none if first >= size
|
|
101
|
+
|
|
102
|
+
[first, last]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def refuse(status, reason, extra = {})
|
|
106
|
+
[status, NOSNIFF.merge("content-type" => "text/plain; charset=utf-8").merge(extra), ["#{reason}\n"]]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
# Loaded here, at boot: left to Digest.const_missing it loads on the first
|
|
3
|
+
# signed request, and a worker Ractor signing another in parallel can
|
|
4
|
+
# catch it half-defined (test/digest_preload_test.rb).
|
|
5
|
+
require "digest/sha2"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
module Monk
|
|
9
|
+
module Storage
|
|
10
|
+
# AWS Signature Version 4, for the S3 backend: the Authorization header
|
|
11
|
+
# of a request Monk sends, and the query string of a link a browser
|
|
12
|
+
# uses (a presigned PUT, a signed GET). Pure functions of their
|
|
13
|
+
# arguments, the time included, so they're tested against AWS's
|
|
14
|
+
# published vectors without stubbing Time (docs/history/plan-storage.md
|
|
15
|
+
# decision 10).
|
|
16
|
+
#
|
|
17
|
+
# Paths and query strings are encoded the S3 way: each byte outside
|
|
18
|
+
# A-Z a-z 0-9 - _ . ~ as %XX, "/" kept in paths, and paths encoded once
|
|
19
|
+
# and never normalized (S3 differs from other AWS services there).
|
|
20
|
+
module SigV4
|
|
21
|
+
ALGORITHM = "AWS4-HMAC-SHA256".freeze
|
|
22
|
+
# The payload hash of a presigned link: the browser sends the body,
|
|
23
|
+
# so it can't be signed in advance.
|
|
24
|
+
UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD".freeze
|
|
25
|
+
EMPTY_PAYLOAD = Digest::SHA256.hexdigest("").freeze
|
|
26
|
+
MAX_EXPIRES = 7 * 24 * 3600
|
|
27
|
+
UNRESERVED = /[^A-Za-z0-9\-_.~]/
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# The headers to add to a request: x-amz-date and authorization.
|
|
32
|
+
# headers: are the ones to sign, host included (and
|
|
33
|
+
# x-amz-content-sha256 for S3); their names may be in any case.
|
|
34
|
+
# payload_hash: is the body's SHA-256 in hex. now: is a Unix time.
|
|
35
|
+
def sign_headers(method:, path:, headers:, payload_hash:, credentials:, now:, query: {})
|
|
36
|
+
date = timestamp(now)
|
|
37
|
+
signed = canonical_headers(headers.merge("x-amz-date" => date))
|
|
38
|
+
signature = signature(method, path, canonical_query(query), signed, payload_hash, credentials, date)
|
|
39
|
+
{
|
|
40
|
+
"x-amz-date" => date,
|
|
41
|
+
"authorization" => "#{ALGORITHM} Credential=#{credentials[:access_key]}/#{scope(credentials, date)}, " \
|
|
42
|
+
"SignedHeaders=#{signed.keys.join(";")}, Signature=#{signature}",
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The query string of a presigned link, signature last: the request's
|
|
47
|
+
# own query: parameters, then the X-Amz-* ones. headers: are the ones
|
|
48
|
+
# the client must send exactly (host, and for a presigned PUT
|
|
49
|
+
# content-length and content-type).
|
|
50
|
+
def presign(method:, path:, headers:, expires_in:, credentials:, now:, query: {},
|
|
51
|
+
payload_hash: UNSIGNED_PAYLOAD)
|
|
52
|
+
unless expires_in.is_a?(Integer) && expires_in.between?(1, MAX_EXPIRES)
|
|
53
|
+
raise ArgumentError, "a presigned link is valid 1 to #{MAX_EXPIRES} seconds, not #{expires_in.inspect}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
date = timestamp(now)
|
|
57
|
+
signed = canonical_headers(headers)
|
|
58
|
+
params = query.merge(
|
|
59
|
+
"X-Amz-Algorithm" => ALGORITHM,
|
|
60
|
+
"X-Amz-Credential" => "#{credentials[:access_key]}/#{scope(credentials, date)}",
|
|
61
|
+
"X-Amz-Date" => date,
|
|
62
|
+
"X-Amz-Expires" => expires_in.to_s,
|
|
63
|
+
"X-Amz-SignedHeaders" => signed.keys.join(";"),
|
|
64
|
+
)
|
|
65
|
+
canonical = canonical_query(params)
|
|
66
|
+
"#{canonical}&X-Amz-Signature=#{signature(method, path, canonical, signed, payload_hash, credentials, date)}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def signature(method, path, query, headers, payload_hash, credentials, date)
|
|
70
|
+
request = canonical_request(method, path, query, headers, payload_hash)
|
|
71
|
+
OpenSSL::HMAC.hexdigest("SHA256", signing_key(credentials, date), string_to_sign(request, credentials, date))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def canonical_request(method, path, query, headers, payload_hash)
|
|
75
|
+
[
|
|
76
|
+
method,
|
|
77
|
+
encode_path(path),
|
|
78
|
+
query,
|
|
79
|
+
headers.map { |name, value| "#{name}:#{value}\n" }.join,
|
|
80
|
+
headers.keys.join(";"),
|
|
81
|
+
payload_hash,
|
|
82
|
+
].join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def string_to_sign(canonical_request, credentials, date)
|
|
86
|
+
[ALGORITHM, date, scope(credentials, date), Digest::SHA256.hexdigest(canonical_request)].join("\n")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def signing_key(credentials, date)
|
|
90
|
+
key = "AWS4#{credentials[:secret_key]}"
|
|
91
|
+
[date[0, 8], credentials[:region], credentials.fetch(:service, "s3"), "aws4_request"].each do |part|
|
|
92
|
+
key = OpenSSL::HMAC.digest("SHA256", key, part)
|
|
93
|
+
end
|
|
94
|
+
key
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def scope(credentials, date)
|
|
98
|
+
"#{date[0, 8]}/#{credentials[:region]}/#{credentials.fetch(:service, "s3")}/aws4_request"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Lowercase names, sorted; values trimmed, inner runs of spaces made one.
|
|
102
|
+
def canonical_headers(headers)
|
|
103
|
+
headers.to_h { |name, value| [name.to_s.downcase, value.to_s.strip.gsub(/\s+/, " ")] }.sort.to_h
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def canonical_query(params)
|
|
107
|
+
params.map { |name, value| [encode(name.to_s), encode(value.to_s)] }.sort
|
|
108
|
+
.map { |name, value| "#{name}=#{value}" }.join("&")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def encode_path(path)
|
|
112
|
+
path.split("/", -1).map { |segment| encode(segment) }.join("/")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def encode(text)
|
|
116
|
+
text.b.gsub(UNRESERVED) { |byte| format("%%%02X", byte.ord) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def timestamp(now)
|
|
120
|
+
Time.at(now).utc.strftime("%Y%m%dT%H%M%SZ")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module Storage
|
|
3
|
+
# A file's type from its first bytes, since the Content-Type a client
|
|
4
|
+
# sends can't be trusted. Defence in depth: the type a signed link
|
|
5
|
+
# serves is the one the app signed into presigned_put from its allowed
|
|
6
|
+
# list, and this catches a file whose bytes don't match it
|
|
7
|
+
# (docs/history/plan-storage.md decision 12). At confirm, read only these
|
|
8
|
+
# bytes:
|
|
9
|
+
#
|
|
10
|
+
# head = Monk::Storage.get(key, range: Monk::Storage::Sniff::RANGE)
|
|
11
|
+
# Monk::Storage::Sniff.refuse_svg!(head)
|
|
12
|
+
# Monk::Storage::Sniff.content_type(head) # => "image/png" or nil
|
|
13
|
+
module Sniff
|
|
14
|
+
# 64 bytes, not 16: an Ogg page's header is 27 bytes plus one byte
|
|
15
|
+
# per segment, so OpusHead starts at byte 28 on a usual first page.
|
|
16
|
+
RANGE = 0..63
|
|
17
|
+
|
|
18
|
+
PNG = "\x89PNG\r\n\x1A\n".b.freeze
|
|
19
|
+
JPEG = "\xFF\xD8\xFF".b.freeze
|
|
20
|
+
GIFS = Ractor.make_shareable(["GIF87a".b, "GIF89a".b])
|
|
21
|
+
PDF = "%PDF-".b.freeze
|
|
22
|
+
# Where an Ogg page's segment count sits; the segment table follows.
|
|
23
|
+
OGG_SEGMENTS_AT = 26
|
|
24
|
+
# Lowercased before matching, so <SVG and <?XML count too.
|
|
25
|
+
SVG_MARKERS = Ractor.make_shareable(["<svg".b, "<?xml".b])
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# "image/png", "image/jpeg", "image/gif", "image/webp", "audio/ogg"
|
|
30
|
+
# (an Ogg stream whose first packet is OpusHead), "application/pdf",
|
|
31
|
+
# or nil for anything else, an empty String included.
|
|
32
|
+
def content_type(bytes)
|
|
33
|
+
head = prefix(bytes)
|
|
34
|
+
|
|
35
|
+
if head.start_with?(PNG) then "image/png"
|
|
36
|
+
elsif head.start_with?(JPEG) then "image/jpeg"
|
|
37
|
+
elsif head.start_with?(*GIFS) then "image/gif"
|
|
38
|
+
elsif webp?(head) then "image/webp"
|
|
39
|
+
elsif opus?(head) then "audio/ogg"
|
|
40
|
+
elsif head.start_with?(PDF) then "application/pdf"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Raises UnsafeContentError when <svg or <?xml appears in the first
|
|
45
|
+
# bytes: SVG can carry scripts, and an SVG may start with an XML
|
|
46
|
+
# declaration, a comment or a doctype before its <svg. Only RANGE is
|
|
47
|
+
# looked at, so the XMP metadata deep inside a JPEG or PDF doesn't
|
|
48
|
+
# count. Returns bytes.
|
|
49
|
+
def refuse_svg!(bytes)
|
|
50
|
+
head = prefix(bytes).downcase
|
|
51
|
+
if SVG_MARKERS.any? { |marker| head.include?(marker) }
|
|
52
|
+
raise UnsafeContentError, "the file starts like SVG or XML, which can carry scripts; refused"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
bytes
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def prefix(bytes)
|
|
59
|
+
unless bytes.is_a?(String)
|
|
60
|
+
raise ArgumentError,
|
|
61
|
+
"Sniff takes the file's first bytes as a String, not #{bytes.class}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
bytes.byteslice(RANGE).b
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# RIFF, a 4-byte size, then WEBP.
|
|
68
|
+
def webp?(head)
|
|
69
|
+
head.start_with?("RIFF") && head.byteslice(8, 4) == "WEBP"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def opus?(head)
|
|
73
|
+
return false unless head.start_with?("OggS") && head.bytesize > OGG_SEGMENTS_AT
|
|
74
|
+
|
|
75
|
+
packet_at = OGG_SEGMENTS_AT + 1 + head.getbyte(OGG_SEGMENTS_AT)
|
|
76
|
+
head.byteslice(packet_at, 8) == "OpusHead"
|
|
77
|
+
end
|
|
78
|
+
private_class_method :prefix, :webp?, :opus?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
module Monk
|
|
5
|
+
module Storage
|
|
6
|
+
# The file backend's signed links: /_monk/storage/<token> stands in
|
|
7
|
+
# for a bucket's signed URL. A token is base64url(JSON payload) + "." +
|
|
8
|
+
# base64url(HMAC-SHA256 of that first part), the payload
|
|
9
|
+
# {m: "GET"|"PUT", k: key, e: expires_at, l: length, t: type}, l and t
|
|
10
|
+
# for uploads only (docs/history/plan-storage.md decision 8).
|
|
11
|
+
module Token
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def sign(payload, secret)
|
|
15
|
+
body = encode(JSON.generate(payload))
|
|
16
|
+
"#{body}.#{encode(OpenSSL::HMAC.digest("SHA256", secret, body))}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The payload, with symbol keys, or nil when the token is malformed
|
|
20
|
+
# or its signature doesn't match. Expiry is the caller's check.
|
|
21
|
+
def verify(token, secret)
|
|
22
|
+
body, signature = token.split(".", -1)
|
|
23
|
+
return nil unless body && signature && token.count(".") == 1
|
|
24
|
+
|
|
25
|
+
expected = encode(OpenSSL::HMAC.digest("SHA256", secret, body))
|
|
26
|
+
return nil unless OpenSSL.secure_compare(expected, signature)
|
|
27
|
+
|
|
28
|
+
payload = JSON.parse(decode(body), symbolize_names: true)
|
|
29
|
+
payload.is_a?(Hash) ? payload : nil
|
|
30
|
+
rescue JSON::ParserError, ArgumentError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def encode(bytes)
|
|
35
|
+
[bytes].pack("m0").tr("+/", "-_").delete("=")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def decode(text)
|
|
39
|
+
raise ArgumentError, "not base64url" unless text.match?(/\A[A-Za-z0-9_-]*\z/)
|
|
40
|
+
|
|
41
|
+
(text.tr("-_", "+/") + ("=" * (-text.length % 4))).unpack1("m0")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module Storage
|
|
3
|
+
# What presigned_put returns: the URL the browser PUTs the file to, and
|
|
4
|
+
# exactly the headers it must send with it. Content-Type and
|
|
5
|
+
# Content-Length are inside the signature, so the bucket refuses any
|
|
6
|
+
# other (browsers set Content-Length themselves; it's listed for
|
|
7
|
+
# clarity).
|
|
8
|
+
Upload = Data.define(:url, :headers)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module Monk
|
|
4
|
+
module Storage
|
|
5
|
+
# STORAGE_URL -> a backend value. Runs once, from Monk::Storage.configure
|
|
6
|
+
# in the main Ractor at boot, so a malformed URL fails the boot rather
|
|
7
|
+
# than the first upload (same posture as Monk::Mail::URL). Error
|
|
8
|
+
# messages redact the secret key: the URL is a secret.
|
|
9
|
+
module URL
|
|
10
|
+
SUPPORTED = "file://, s3://".freeze
|
|
11
|
+
S3_EXAMPLE = "s3://KEY:SECRET@bucket?endpoint=fsn1.your-objectstorage.com®ion=fsn1".freeze
|
|
12
|
+
S3_OPTIONS = %w[endpoint region path_style].freeze
|
|
13
|
+
# S3's rule for bucket names that also work in a host name
|
|
14
|
+
# (virtual-hosted addressing, the default).
|
|
15
|
+
BUCKET = /\A[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]\z/
|
|
16
|
+
HOST = /\A[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?\z/
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def parse(url)
|
|
21
|
+
case url[%r{\A([a-z][a-z0-9+.-]*)://}i, 1]&.downcase
|
|
22
|
+
when "file" then file(url)
|
|
23
|
+
when "s3" then s3(url)
|
|
24
|
+
else invalid!(url, "unsupported scheme; supported: #{SUPPORTED}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# file://storage/development (relative to the current directory) or
|
|
29
|
+
# file:///var/lib/app/storage (absolute).
|
|
30
|
+
def file(url)
|
|
31
|
+
dir = url.sub(%r{\A[a-z]+://}i, "")
|
|
32
|
+
invalid!(url, "missing directory, e.g. file://storage/production") if dir.empty?
|
|
33
|
+
invalid!(url, "a directory takes no options") if dir.include?("?")
|
|
34
|
+
|
|
35
|
+
Backends::File.new(root: File.expand_path(dir), secret: nil)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def s3(url)
|
|
39
|
+
uri = URI.parse(url)
|
|
40
|
+
options = s3_options(uri, url)
|
|
41
|
+
endpoint, tls = endpoint(options["endpoint"], url)
|
|
42
|
+
Backends::S3.new(
|
|
43
|
+
bucket: bucket(uri, url),
|
|
44
|
+
endpoint: endpoint,
|
|
45
|
+
region: required(options, "region", url),
|
|
46
|
+
access_key: credential(uri.user, "access key", url),
|
|
47
|
+
secret_key: credential(uri.password, "secret key", url),
|
|
48
|
+
path_style: path_style(options["path_style"], url),
|
|
49
|
+
tls: tls,
|
|
50
|
+
)
|
|
51
|
+
rescue URI::InvalidURIError
|
|
52
|
+
invalid!(url, "not a valid URL (percent-encode / @ : in the keys); expected e.g. #{S3_EXAMPLE}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def s3_options(uri, url)
|
|
56
|
+
options = URI.decode_www_form(uri.query || "").to_h
|
|
57
|
+
unknown = options.keys - S3_OPTIONS
|
|
58
|
+
invalid!(url, "unknown option(s) #{unknown.join(", ")}; supported: #{S3_OPTIONS.join(", ")}") if unknown.any?
|
|
59
|
+
|
|
60
|
+
options
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def bucket(uri, url)
|
|
64
|
+
invalid!(url, "a port goes in endpoint=, not after the bucket") if uri.port
|
|
65
|
+
invalid!(url, "nothing goes after the bucket but options") unless ["", "/"].include?(uri.path)
|
|
66
|
+
|
|
67
|
+
name = uri.host.to_s
|
|
68
|
+
invalid!(url, "missing bucket, e.g. #{S3_EXAMPLE}") if name.empty?
|
|
69
|
+
invalid!(url, "bucket #{name.inspect}: 3-63 of a-z, 0-9, . and -") unless name.match?(BUCKET)
|
|
70
|
+
|
|
71
|
+
name
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A bare host means HTTPS. http:// sends the credentials and every
|
|
75
|
+
# body in clear: accepted for a local server (floci in Monk's own
|
|
76
|
+
# suite), refused in staging and production.
|
|
77
|
+
def endpoint(value, url)
|
|
78
|
+
invalid!(url, "missing endpoint=, e.g. #{S3_EXAMPLE}") if value.nil? || value.empty?
|
|
79
|
+
|
|
80
|
+
scheme = value[%r{\A([a-z]+)://}i, 1]&.downcase
|
|
81
|
+
invalid!(url, "endpoint= must be https:// or http://") unless [nil, "https", "http"].include?(scheme)
|
|
82
|
+
|
|
83
|
+
host = value.sub(%r{\A[a-z]+://}i, "").chomp("/")
|
|
84
|
+
invalid!(url, "endpoint= must be a host, optionally with a port") unless host.match?(HOST)
|
|
85
|
+
|
|
86
|
+
tls = scheme != "http"
|
|
87
|
+
if !tls && (Monk.env.staging? || Monk.env.production?)
|
|
88
|
+
invalid!(url, "endpoint=http:// sends the keys in clear; it's for a local server, " \
|
|
89
|
+
"not MONK_ENV=#{Monk.env}",)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
[host.downcase, tls]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def path_style(value, url)
|
|
96
|
+
case value
|
|
97
|
+
when nil, "0" then false
|
|
98
|
+
when "1" then true
|
|
99
|
+
else invalid!(url, "path_style= must be 1 or 0")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def required(options, name, url)
|
|
104
|
+
value = options[name]
|
|
105
|
+
invalid!(url, "missing #{name}=, e.g. #{S3_EXAMPLE}") if value.nil? || value.empty?
|
|
106
|
+
|
|
107
|
+
value
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A secret with @, : or / in it has to be percent-encoded in the URL.
|
|
111
|
+
def credential(component, name, url)
|
|
112
|
+
invalid!(url, "missing #{name}, e.g. #{S3_EXAMPLE}") if component.nil? || component.empty?
|
|
113
|
+
|
|
114
|
+
URI.decode_uri_component(component)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def invalid!(url, reason)
|
|
118
|
+
raise InvalidStorageUrlError, "STORAGE_URL #{redact(url).inspect}: #{reason}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def redact(url)
|
|
122
|
+
url.to_s.sub(%r{(://[^:/@]*:)[^@/]*@}, '\1***@')
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|