audioproxy-rails 0.1.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 +7 -0
- data/CHANGELOG.md +27 -0
- data/MIT-LICENSE +20 -0
- data/README.md +383 -0
- data/lib/audioproxy/config.rb +155 -0
- data/lib/audioproxy/options.rb +274 -0
- data/lib/audioproxy/rails/blob_resolver.rb +174 -0
- data/lib/audioproxy/rails/helpers.rb +66 -0
- data/lib/audioproxy/rails/railtie.rb +163 -0
- data/lib/audioproxy/rails.rb +9 -0
- data/lib/audioproxy/signer.rb +40 -0
- data/lib/audioproxy/url_builder.rb +141 -0
- data/lib/audioproxy/version.rb +3 -0
- data/lib/audioproxy.rb +50 -0
- data/lib/tasks/audioproxy/rails_tasks.rake +4 -0
- metadata +107 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
require "active_support/duration"
|
|
2
|
+
|
|
3
|
+
module Audioproxy
|
|
4
|
+
# Renders the proxy's option grammar: +/+-separated +key:value+ segments,
|
|
5
|
+
# with colon-separated parts for the multi-part keys.
|
|
6
|
+
#
|
|
7
|
+
# This layer renders, it does not validate (D1). Value domains and cross-key
|
|
8
|
+
# rules belong to the proxy, which versions them with the server and returns
|
|
9
|
+
# structured 422s. The exceptions are values this module cannot render
|
|
10
|
+
# faithfully — an unknown key, a number that does not fit the grammar, a value
|
|
11
|
+
# carrying a separator — because a mangled segment is a valid-looking URL for
|
|
12
|
+
# the wrong variant, and it fails at request time, far from here.
|
|
13
|
+
module Options
|
|
14
|
+
# The proxy's fourteen option keys, canonical short spellings.
|
|
15
|
+
KEYS = %i[bd br cb ch dl f fade gain norm pk_fmt pts q sr t].freeze
|
|
16
|
+
|
|
17
|
+
# A spelled-out spelling for each canonical key, for call sites that would
|
|
18
|
+
# rather read than decode. Total over KEYS, so "does this key have an alias"
|
|
19
|
+
# never has two answers: +fade+ and +gain+ are already words and alias to
|
|
20
|
+
# themselves. The names are the proxy's own where it has one — its Options
|
|
21
|
+
# struct calls pts +peak_count+ and pk_fmt +peak_format+ — so this is one
|
|
22
|
+
# vocabulary spelled twice, not a second vocabulary (D2).
|
|
23
|
+
ALIASES = {
|
|
24
|
+
f: :format,
|
|
25
|
+
br: :bitrate,
|
|
26
|
+
q: :quality,
|
|
27
|
+
sr: :sample_rate,
|
|
28
|
+
ch: :channels,
|
|
29
|
+
bd: :bit_depth,
|
|
30
|
+
t: :trim,
|
|
31
|
+
fade: :fade,
|
|
32
|
+
gain: :gain,
|
|
33
|
+
norm: :normalize,
|
|
34
|
+
pts: :peak_count,
|
|
35
|
+
pk_fmt: :peak_format,
|
|
36
|
+
dl: :download,
|
|
37
|
+
cb: :cache_buster
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Every accepted spelling to the canonical key it renders as. Canonical
|
|
41
|
+
# keys map to themselves, so resolution is one lookup rather than a
|
|
42
|
+
# conditional.
|
|
43
|
+
CANONICAL = ALIASES.each_with_object({}) { |(key, spelled), table| table[spelled] = key }
|
|
44
|
+
.merge(KEYS.to_h { |key| [ key, key ] })
|
|
45
|
+
.freeze
|
|
46
|
+
|
|
47
|
+
# Keys whose grammar takes colon-separated parts: +t:START[:DURATION]+,
|
|
48
|
+
# +fade:IN[:OUT]+, +norm:ebu[:I[:TP[:LRA]]]+.
|
|
49
|
+
MULTI_PART_KEYS = %i[t fade norm].freeze
|
|
50
|
+
|
|
51
|
+
# Keys whose values *are* a number of seconds, and so may be written as an
|
|
52
|
+
# ActiveSupport::Duration (D6).
|
|
53
|
+
TIME_KEYS = %i[t fade].freeze
|
|
54
|
+
|
|
55
|
+
# Keys the proxy treats as opaque payloads (download filename, cache
|
|
56
|
+
# buster), rendered verbatim rather than number-formatted.
|
|
57
|
+
OPAQUE_KEYS = %i[cb dl].freeze
|
|
58
|
+
|
|
59
|
+
# The proxy caps decimals at 3 places when it parses, and hashes the
|
|
60
|
+
# normalized options string into its cache key.
|
|
61
|
+
MAX_DECIMALS = 3
|
|
62
|
+
|
|
63
|
+
# Characters a rendered value may not carry. The builder supplies '/' and
|
|
64
|
+
# ':', so a value containing either silently invents a segment or a part.
|
|
65
|
+
# '?' and '#' end the path as far as a browser is concerned, which truncates
|
|
66
|
+
# what the proxy receives below what was signed: a 403 at request time, far
|
|
67
|
+
# from the call. Whitespace and control characters are not URL bytes at all.
|
|
68
|
+
SEPARATORS = %r{[/:?#\s]|[[:cntrl:]]}
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
# Renders an ordered key => value Hash into an options segment. Caller
|
|
72
|
+
# order is preserved (D4); normalization is the proxy's business.
|
|
73
|
+
def render(options)
|
|
74
|
+
resolve(options).map { |key, value| segment(key, value) }.join("/")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Rewrites a key => value Hash onto the canonical short keys, so that
|
|
78
|
+
# everything downstream — rendering, ordering, the defaults merge — sees
|
|
79
|
+
# one vocabulary and is unaware aliases exist (D1). Insertion order is
|
|
80
|
+
# preserved, so an aliased key keeps its slot. Unrecognized keys pass
|
|
81
|
+
# through untouched, to be reported by +segment+ against the key table
|
|
82
|
+
# rather than by a second, thinner error here.
|
|
83
|
+
def resolve(options)
|
|
84
|
+
spellings = {}
|
|
85
|
+
|
|
86
|
+
options.each_with_object({}) do |(key, value), resolved|
|
|
87
|
+
canonical = CANONICAL.fetch(symbolize(key), key)
|
|
88
|
+
|
|
89
|
+
# Ruby's keyword collection keeps both spellings, and picking a winner
|
|
90
|
+
# by position would make the URL depend on argument order in a way
|
|
91
|
+
# nothing else here does (D4).
|
|
92
|
+
# inspect, not interpolation: the two spellings are often identical as
|
|
93
|
+
# text — "fade" and :fade, or a self-aliasing key given both ways —
|
|
94
|
+
# and "as fade and fade" tells the caller nothing.
|
|
95
|
+
if (first = spellings[canonical])
|
|
96
|
+
raise ArgumentError,
|
|
97
|
+
"Audioproxy option #{canonical} was given twice, as #{first.inspect} and #{key.inspect}; " \
|
|
98
|
+
"each option takes one spelling per call"
|
|
99
|
+
end
|
|
100
|
+
spellings[canonical] = key
|
|
101
|
+
|
|
102
|
+
resolved[canonical] = value
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Renders one +key:value+ segment. The key may be canonical or an alias.
|
|
107
|
+
def segment(key, value)
|
|
108
|
+
key = CANONICAL.fetch(symbolize(key)) do |unknown|
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"unknown Audioproxy option #{unknown.inspect}; known keys are #{KEYS.join(", ")}, " \
|
|
111
|
+
"each also accepted as its spelled-out alias (#{ALIASES[:br]}, #{ALIASES[:sr]}, #{ALIASES[:pk_fmt]}, …)"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
"#{key}:#{render_value(key, value)}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The proxy's canonical minimal number spelling. Strings and symbols pass
|
|
118
|
+
# through untouched — the caller opted out of formatting.
|
|
119
|
+
def format_number(value)
|
|
120
|
+
case value
|
|
121
|
+
when String then value
|
|
122
|
+
when Symbol then value.to_s
|
|
123
|
+
when Integer then value.to_s
|
|
124
|
+
when Complex
|
|
125
|
+
# Numeric, but Complex#round is undefined and Complex#to_r silently
|
|
126
|
+
# drops a zero imaginary part. Neither is a number this grammar has.
|
|
127
|
+
raise ArgumentError, "Audioproxy option values must be real numbers, got #{value.inspect}"
|
|
128
|
+
when Numeric then format_decimal(value)
|
|
129
|
+
else
|
|
130
|
+
raise ArgumentError, "Audioproxy option values must be numbers, strings or symbols, got #{value.class}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
def symbolize(key)
|
|
136
|
+
case key
|
|
137
|
+
when Symbol then key
|
|
138
|
+
when String then key.to_sym
|
|
139
|
+
else
|
|
140
|
+
raise ArgumentError, "Audioproxy option keys must be Symbols or Strings, got #{key.class}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def render_value(key, value)
|
|
145
|
+
return render_parts(key, value) if value.is_a?(Array)
|
|
146
|
+
|
|
147
|
+
format_part(key, value)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def render_parts(key, value)
|
|
151
|
+
unless MULTI_PART_KEYS.include?(key)
|
|
152
|
+
raise ArgumentError,
|
|
153
|
+
"Audioproxy option #{key}: takes a single value, got #{value.inspect}; " \
|
|
154
|
+
"only #{MULTI_PART_KEYS.join(", ")} take colon-separated parts"
|
|
155
|
+
end
|
|
156
|
+
if value.empty?
|
|
157
|
+
raise ArgumentError, "Audioproxy option #{key}: was given an empty Array"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
value.map { |part| format_part(key, part) }.join(":")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def format_part(key, value)
|
|
164
|
+
rendered = render_part(key, value)
|
|
165
|
+
|
|
166
|
+
# Every part is validated on its own, not the assembled segment: an
|
|
167
|
+
# empty part between two separators (t::30) reads as a whole value.
|
|
168
|
+
if rendered.empty?
|
|
169
|
+
raise ArgumentError, "Audioproxy option #{key}: has an empty value"
|
|
170
|
+
end
|
|
171
|
+
if (offender = rendered[SEPARATORS])
|
|
172
|
+
raise ArgumentError,
|
|
173
|
+
"Audioproxy option #{key}: must not contain #{offender.inspect}, got #{rendered.inspect}. " \
|
|
174
|
+
"The builder supplies the separators; pre-encode the value, or use raw: to write the whole options string."
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
rendered
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# dl: and cb: are opaque to the proxy, so they are opaque here too: a
|
|
181
|
+
# filename or cache buster is whatever the caller wrote.
|
|
182
|
+
def render_part(key, value)
|
|
183
|
+
# An explicit clause, because +case value when Numeric+ does not match
|
|
184
|
+
# a Duration: it overrides is_a? to answer true for Numeric, but
|
|
185
|
+
# Module#=== performs the real type check and ignores the override.
|
|
186
|
+
# Without this, t: 30.seconds is rejected as "not a number" by
|
|
187
|
+
# something that says it is one.
|
|
188
|
+
return render_duration(key, value) if value.is_a?(ActiveSupport::Duration)
|
|
189
|
+
return format_number(value) unless OPAQUE_KEYS.include?(key)
|
|
190
|
+
|
|
191
|
+
case value
|
|
192
|
+
when String then value
|
|
193
|
+
when Symbol, Numeric then format_number(value)
|
|
194
|
+
else
|
|
195
|
+
raise ArgumentError, "Audioproxy option #{key}: must be a String, Symbol or number, got #{value.class}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# A Duration is the Rails spelling of a number of seconds, so it is
|
|
200
|
+
# accepted where the value *is* seconds and refused everywhere else:
|
|
201
|
+
# br: 3.seconds rendering br:3 would be a valid-looking URL for the
|
|
202
|
+
# wrong variant, which is the failure this gem exists to prevent (D6).
|
|
203
|
+
def render_duration(key, value)
|
|
204
|
+
unless TIME_KEYS.include?(key)
|
|
205
|
+
raise ArgumentError,
|
|
206
|
+
"Audioproxy option #{key}: does not take a duration, got #{value.inspect}; " \
|
|
207
|
+
"only #{TIME_KEYS.join(", ")} take an ActiveSupport::Duration, because only their values are seconds"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# #value is the number the caller wrote (30 for 30.seconds, 0.3 for
|
|
211
|
+
# 0.3.seconds, 60 for 1.minute), so it goes through exactly the
|
|
212
|
+
# formatting that number would and renders the same bytes. #to_r is
|
|
213
|
+
# the wrong door: for 0.3.seconds it yields the double's true value,
|
|
214
|
+
# which the three-decimal cap then rejects, while a plain t: 0.3
|
|
215
|
+
# renders — Float goes through Rational(value.to_s) for that reason.
|
|
216
|
+
format_number(value.value)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Rendering is exact integer arithmetic on the value's decimal form.
|
|
220
|
+
# Neither Float#to_s nor format("%.3f") can do this job alone: the
|
|
221
|
+
# former emits the +1.0e-05+ exponent shapes the grammar rejects, and
|
|
222
|
+
# the latter re-reads the underlying binary value, so it renders
|
|
223
|
+
# 12345678901234.56 as "12345678901234.561" and truncates a BigDecimal
|
|
224
|
+
# to a double on the way past.
|
|
225
|
+
def format_decimal(value)
|
|
226
|
+
scaled = exact_decimal(value) * (10 ** MAX_DECIMALS)
|
|
227
|
+
unless scaled.denominator == 1
|
|
228
|
+
raise ArgumentError,
|
|
229
|
+
"Audioproxy option value #{value.inspect} needs more than #{MAX_DECIMALS} decimal places; " \
|
|
230
|
+
"the proxy caps decimals at #{MAX_DECIMALS} and rejects the rest as excessive precision. " \
|
|
231
|
+
"Round explicitly at the call site if that is what you mean."
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
render_scaled(scaled.numerator)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The exact decimal the caller meant, as a Rational.
|
|
238
|
+
def exact_decimal(value)
|
|
239
|
+
assert_finite!(value)
|
|
240
|
+
|
|
241
|
+
case value
|
|
242
|
+
when Float
|
|
243
|
+
# Float#to_s is the shortest decimal that round-trips to this
|
|
244
|
+
# double, which is the spelling the caller wrote. The double's own
|
|
245
|
+
# expansion is another number entirely — 0.001 is really
|
|
246
|
+
# 0.001000000000000000020816…, and rendering *that* would reject
|
|
247
|
+
# every fractional float as excessive precision.
|
|
248
|
+
Rational(value.to_s)
|
|
249
|
+
when Rational then value
|
|
250
|
+
else
|
|
251
|
+
# BigDecimal and any other real Numeric: to_r is exact, which is
|
|
252
|
+
# what a value carrying more digits than a double can hold needs.
|
|
253
|
+
value.to_r
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def assert_finite!(value)
|
|
258
|
+
return if value.is_a?(Rational) || value.finite?
|
|
259
|
+
|
|
260
|
+
raise ArgumentError, "Audioproxy option value #{value} is not a finite number"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# scaled is the value times 10**MAX_DECIMALS, exactly. Negative zero
|
|
264
|
+
# collapses on the way in: -0.0 scales to plain 0.
|
|
265
|
+
def render_scaled(scaled)
|
|
266
|
+
sign = scaled.negative? ? "-" : ""
|
|
267
|
+
whole, fraction = scaled.abs.divmod(10 ** MAX_DECIMALS)
|
|
268
|
+
return "#{sign}#{whole}" if fraction.zero?
|
|
269
|
+
|
|
270
|
+
"#{sign}#{whole}.#{format("%0#{MAX_DECIMALS}d", fraction).sub(/0+\z/, "")}"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
require "audioproxy"
|
|
3
|
+
|
|
4
|
+
module Audioproxy
|
|
5
|
+
# Raised when a blob lives on a storage service this gem cannot express as a
|
|
6
|
+
# proxy source string. Carries the way forward, because the person reading it
|
|
7
|
+
# is the demand signal for the upstream slice that would fix it (D4).
|
|
8
|
+
class UnsupportedServiceError < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Raised when an attachment has no blob behind it. Deliberately its own class:
|
|
11
|
+
# "you attached nothing" and "your storage service is unsupported" are
|
|
12
|
+
# different problems with different fixes.
|
|
13
|
+
class UnattachedError < StandardError; end
|
|
14
|
+
|
|
15
|
+
module Rails
|
|
16
|
+
# Turns ActiveStorage objects into the source strings the proxy speaks.
|
|
17
|
+
# Registered with the core by the railtie; the core itself never names an
|
|
18
|
+
# ActiveStorage constant (D5).
|
|
19
|
+
module BlobResolver
|
|
20
|
+
# Dispatch is on the blob's *service class*, never on the configured
|
|
21
|
+
# service name — :amazon and :local are labels an app picks, and an app
|
|
22
|
+
# may well call its S3 service :local (D1).
|
|
23
|
+
#
|
|
24
|
+
# Matched by class name rather than by constant, because naming
|
|
25
|
+
# ActiveStorage::Service::S3Service here would load its file, and that
|
|
26
|
+
# file requires aws-sdk-s3 — a gem an app storing on disk has no reason to
|
|
27
|
+
# bundle. Ancestor names, so an app's own subclass of a supported service
|
|
28
|
+
# resolves the way its parent does.
|
|
29
|
+
#
|
|
30
|
+
# A Mirror service is not in this table and is not unwrapped to its
|
|
31
|
+
# primary (non-goal): it raises, naming the mirror, so the operator makes
|
|
32
|
+
# the choice of which service to point at rather than the gem guessing.
|
|
33
|
+
SERVICES = {
|
|
34
|
+
"ActiveStorage::Service::S3Service" => :s3,
|
|
35
|
+
"ActiveStorage::Service::DiskService" => :disk
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
SUPPORTED = "S3 and Disk".freeze
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
def call(source)
|
|
42
|
+
blob = unwrap(source)
|
|
43
|
+
service = blob.service
|
|
44
|
+
|
|
45
|
+
case kind_of_service(service)
|
|
46
|
+
when :s3 then "s3://#{bucket_for(service)}/#{blob.key}"
|
|
47
|
+
when :disk then "local://#{DiskLayout.relative_path_for(blob.key)}"
|
|
48
|
+
else
|
|
49
|
+
raise UnsupportedServiceError, unsupported_message(service)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
def unwrap(source)
|
|
55
|
+
case source
|
|
56
|
+
when ::ActiveStorage::Blob
|
|
57
|
+
source
|
|
58
|
+
when ::ActiveStorage::Attachment
|
|
59
|
+
source.blob || raise(UnattachedError, "#{describe(source)} has no blob")
|
|
60
|
+
when ::ActiveStorage::Attached::One
|
|
61
|
+
source.blob || raise(UnattachedError, "nothing is attached to #{describe(source)}")
|
|
62
|
+
else
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
"source must be a String, an ActiveStorage::Blob, or an attachment, got #{source.class}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Both an Attachment and an Attached::One know the record and the
|
|
69
|
+
# attachment name, which is the only part of the error a caller can
|
|
70
|
+
# act on: it says *which* attachment was empty.
|
|
71
|
+
def describe(source)
|
|
72
|
+
"#{source.record.class}##{source.name}"
|
|
73
|
+
rescue NoMethodError
|
|
74
|
+
source.class.name
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def kind_of_service(service)
|
|
78
|
+
service.class.ancestors.each do |ancestor|
|
|
79
|
+
kind = SERVICES[ancestor.name]
|
|
80
|
+
return kind if kind
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# service.bucket is an Aws::S3::Bucket the service builds from its own
|
|
87
|
+
# configuration; its #name is the bucket string. The one place this
|
|
88
|
+
# accessor chain is spelled out, so an aws-sdk change lands here.
|
|
89
|
+
def bucket_for(service)
|
|
90
|
+
name = String.try_convert(service.bucket.name)
|
|
91
|
+
|
|
92
|
+
# ConfigurationError, not UnsupportedServiceError: S3 *is* supported,
|
|
93
|
+
# and telling someone their S3 service is unsupported because its
|
|
94
|
+
# bucket is unset sends them to fix the wrong thing.
|
|
95
|
+
if name.nil? || name.empty?
|
|
96
|
+
raise ConfigurationError,
|
|
97
|
+
"the S3 service behind this blob reports no bucket name; set a bucket on the " \
|
|
98
|
+
"#{service.class} in config/storage.yml"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
name
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def unsupported_message(service)
|
|
105
|
+
"Audioproxy cannot resolve blobs on #{service.class}; it supports #{SUPPORTED} services. " \
|
|
106
|
+
"Serve this blob through ActiveStorage's rails_storage_proxy mode (which answers 200 " \
|
|
107
|
+
"directly instead of redirecting) behind an https:// source on the proxy — that source " \
|
|
108
|
+
"backend is parked upstream on demand, and this error is the demand."
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The Disk service hides its layout behind DiskService#path_for: a file
|
|
113
|
+
# for key "wxyz9876" lives at {root}/wx/yz/wxyz9876, under two
|
|
114
|
+
# subdirectories hashed out of the key itself. The proxy is handed the
|
|
115
|
+
# path relative to its own AP_LOCAL_ROOT, so the gem has to reproduce that
|
|
116
|
+
# rule — which makes it the one piece of private-ish Rails API this gem
|
|
117
|
+
# depends on, and the reason it lives alone in a module with a contract
|
|
118
|
+
# test against the real DiskService (D3).
|
|
119
|
+
module DiskLayout
|
|
120
|
+
class << self
|
|
121
|
+
def relative_path_for(key)
|
|
122
|
+
string = String.try_convert(key)
|
|
123
|
+
|
|
124
|
+
if string.nil? || string.empty?
|
|
125
|
+
raise ArgumentError, "an ActiveStorage blob key must be a non-empty String, got #{key.inspect}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
reject_unsafe(string)
|
|
129
|
+
|
|
130
|
+
# folder_for can produce an empty middle segment ("ab" gives "ab/"),
|
|
131
|
+
# and a key may carry its own separators. DiskService never sees
|
|
132
|
+
# either, because it hands the join to File.expand_path, which
|
|
133
|
+
# collapses them; reproducing the layout means reproducing that
|
|
134
|
+
# normalization too, or a key like "ab" resolves to "ab//ab" here
|
|
135
|
+
# and "ab/ab" on disk. cleanpath is the collapse without expand_path's
|
|
136
|
+
# trip through the actual filesystem root.
|
|
137
|
+
Pathname.new(File.join(folder_for(string), string)).cleanpath.to_s.delete_prefix("/")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
# Mirrors DiskService#folder_for exactly.
|
|
142
|
+
def folder_for(key)
|
|
143
|
+
[ key[0..1], key[2..3] ].join("/")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# DiskService#path_for rejects these before touching the filesystem
|
|
147
|
+
# and calls it defense in depth. The same keys have to be rejected
|
|
148
|
+
# here, and for a sharper reason: this side does not touch a
|
|
149
|
+
# filesystem at all, so nothing downstream would catch them. A key
|
|
150
|
+
# of "../evil" would otherwise resolve to local://..//e/../evil and
|
|
151
|
+
# send the proxy climbing out of its AP_LOCAL_ROOT — a valid-looking
|
|
152
|
+
# URL for a file that is not the blob, which is the one thing this
|
|
153
|
+
# gem may not emit.
|
|
154
|
+
def reject_unsafe(key)
|
|
155
|
+
if key.include?("\0")
|
|
156
|
+
raise ArgumentError, "an ActiveStorage blob key must not contain null bytes, got #{key.inspect}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
traversal = begin
|
|
160
|
+
key.split("/").intersect?(%w[. ..])
|
|
161
|
+
rescue Encoding::CompatibilityError
|
|
162
|
+
raise ArgumentError, "an ActiveStorage blob key must be a valid, comparable String, got #{key.inspect}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
if traversal
|
|
166
|
+
raise ArgumentError,
|
|
167
|
+
"an ActiveStorage blob key must not contain . or .. path segments, got #{key.inspect}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "audioproxy"
|
|
2
|
+
|
|
3
|
+
module Audioproxy
|
|
4
|
+
module Rails
|
|
5
|
+
# Mixed into ActionView by the railtie. Thin on purpose: URL construction is
|
|
6
|
+
# the core's job, and these only carry it into views.
|
|
7
|
+
module Helpers
|
|
8
|
+
# Every option goes through to Audioproxy.url_for untouched, so views and
|
|
9
|
+
# jobs share one vocabulary.
|
|
10
|
+
def audioproxy_url(source, **options)
|
|
11
|
+
Audioproxy.url_for(source, **options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The html: bucket is the seam between proxy options and tag attributes
|
|
15
|
+
# (D4). Without it, proxy option keys and HTML attribute names share one
|
|
16
|
+
# namespace, and a typoed option lands silently on the <audio> element
|
|
17
|
+
# instead of raising.
|
|
18
|
+
def audioproxy_audio_tag(source, html: {}, **options)
|
|
19
|
+
unless html.is_a?(Hash)
|
|
20
|
+
raise ArgumentError, "audioproxy_audio_tag html: must be a Hash of tag attributes, got #{html.class}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
audio_tag(audioproxy_url(source, **options), **html)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `as` is supplied rather than inferred: ActionView reads it off the
|
|
27
|
+
# source's file extension, and a proxy URL ends in an encoded source
|
|
28
|
+
# segment that has none, so the inference always fails here. A preload
|
|
29
|
+
# with no `as` has no fetch destination and browsers decline to act on it.
|
|
30
|
+
#
|
|
31
|
+
# No crossorigin, matching what audioproxy_audio_tag emits. A preload
|
|
32
|
+
# whose crossorigin disagrees with the element consuming it downloads the
|
|
33
|
+
# whole variant twice; a caller who sets one on the tag sets the same one
|
|
34
|
+
# here.
|
|
35
|
+
def audioproxy_preload_link_tag(source, html: {}, **options)
|
|
36
|
+
unless html.is_a?(Hash)
|
|
37
|
+
raise ArgumentError, "audioproxy_preload_link_tag html: must be a Hash of tag attributes, got #{html.class}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attributes = { as: "audio" }.merge(html.symbolize_keys)
|
|
41
|
+
|
|
42
|
+
# A blank `as` is not an override, it is the destination-less tag this
|
|
43
|
+
# helper exists to prevent: ActionView falls through to its extension
|
|
44
|
+
# inference, which has nothing to work with, and the attribute is
|
|
45
|
+
# dropped entirely.
|
|
46
|
+
if attributes[:as].blank?
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"audioproxy_preload_link_tag as: must name a fetch destination, got #{attributes[:as].inspect} " \
|
|
49
|
+
"(a rel=preload without one is ignored by browsers; pass as: \"audio\" or omit it)"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# ActionView renders crossorigin: true as "anonymous" here and audio_tag
|
|
53
|
+
# renders it as "true", so the pair disagrees and the browser fetches the
|
|
54
|
+
# variant twice — the one failure this helper's crossorigin default (D3)
|
|
55
|
+
# exists to avoid.
|
|
56
|
+
if attributes[:crossorigin] == true
|
|
57
|
+
raise ArgumentError,
|
|
58
|
+
"audioproxy_preload_link_tag crossorigin: must be a String, got true " \
|
|
59
|
+
"(it would render as \"anonymous\" here and \"true\" on the audio tag, fetching the variant twice)"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
preload_link_tag(audioproxy_url(source, **options), attributes)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
require "active_support/core_ext/object/blank"
|
|
2
|
+
require "active_support/core_ext/hash/keys"
|
|
3
|
+
require "audioproxy/rails/helpers"
|
|
4
|
+
|
|
5
|
+
module Audioproxy
|
|
6
|
+
module Rails
|
|
7
|
+
# Wires an app's configuration and view helpers. Not an engine: there are no
|
|
8
|
+
# routes, views or migrations to mount.
|
|
9
|
+
class Railtie < ::Rails::Railtie
|
|
10
|
+
# Attribute -> environment variable. The names are the proxy's own, so a
|
|
11
|
+
# dev docker-compose can feed the app and the proxy from one env file
|
|
12
|
+
# (D2). AP_ALLOW_INSECURE maps to +unsigned+, which is only the *client's*
|
|
13
|
+
# flag — the proxy enforces its own independently.
|
|
14
|
+
ENV_VARIABLES = {
|
|
15
|
+
endpoint: "AP_ENDPOINT",
|
|
16
|
+
key: "AP_KEY",
|
|
17
|
+
salt: "AP_SALT",
|
|
18
|
+
unsigned: "AP_ALLOW_INSECURE"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# The literals Go's strconv.ParseBool accepts, which is what the proxy
|
|
22
|
+
# parses AP_ALLOW_INSECURE with. Deliberately *not* ActiveModel's boolean
|
|
23
|
+
# cast: that reads every unrecognized string as true, so a stray
|
|
24
|
+
# AP_ALLOW_INSECURE=flase would quietly ship unsigned URLs.
|
|
25
|
+
TRUE_VALUES = %w[1 t true].freeze
|
|
26
|
+
FALSE_VALUES = %w[0 f false].freeze
|
|
27
|
+
|
|
28
|
+
initializer "audioproxy.config" do
|
|
29
|
+
Railtie.apply_configuration(
|
|
30
|
+
Audioproxy.config,
|
|
31
|
+
credentials: ::Rails.application.credentials.audioproxy,
|
|
32
|
+
env: ENV
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
initializer "audioproxy.helpers" do
|
|
37
|
+
ActiveSupport.on_load(:action_view) { include Audioproxy::Rails::Helpers }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Teaches the core to accept blobs and attachments. ActiveStorage ships no
|
|
41
|
+
# load hook to hang this on, so it is a plain initializer guarded on the
|
|
42
|
+
# constant: railties are all loaded before any initializer runs, so an app
|
|
43
|
+
# that has ActiveStorage has it defined by now, and an app that dropped
|
|
44
|
+
# active_storage/engine from its requires never registers a resolver and
|
|
45
|
+
# keeps the core's plain "source must be a String".
|
|
46
|
+
#
|
|
47
|
+
# Requiring here rather than at the top of the file keeps the resolver —
|
|
48
|
+
# and its ActiveStorage constants — out of the load path of apps without
|
|
49
|
+
# it. The file itself names no service class, so nothing is loaded that
|
|
50
|
+
# would drag in aws-sdk-s3.
|
|
51
|
+
initializer "audioproxy.active_storage" do
|
|
52
|
+
if defined?(::ActiveStorage)
|
|
53
|
+
require "audioproxy/rails/blob_resolver"
|
|
54
|
+
Audioproxy.register_source_resolver(Audioproxy::Rails::BlobResolver)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Sources each attribute independently: credentials first, ENV where
|
|
60
|
+
# credentials are silent, nothing at all where both are. Absent
|
|
61
|
+
# configuration is not an error here — a signed +url_for+ later raises
|
|
62
|
+
# the core's ConfigurationError, while an app running unsigned in
|
|
63
|
+
# development never needs a key (D3). Validating at boot would break
|
|
64
|
+
# `assets:precompile` and friends in apps that never generate a URL.
|
|
65
|
+
#
|
|
66
|
+
# Runs in a railtie initializer, so an app's own
|
|
67
|
+
# `config/initializers/*.rb` gets the last word for free.
|
|
68
|
+
def apply_configuration(config, credentials:, env:)
|
|
69
|
+
credentials = normalize_credentials(credentials)
|
|
70
|
+
|
|
71
|
+
ENV_VARIABLES.each do |attribute, variable|
|
|
72
|
+
value = credentials[attribute]
|
|
73
|
+
source = :credentials
|
|
74
|
+
|
|
75
|
+
if value.nil?
|
|
76
|
+
# Blank is absent: an env file that carries AP_KEY= with nothing
|
|
77
|
+
# after it should fall through, not hand Config an empty string.
|
|
78
|
+
value = env[variable].presence
|
|
79
|
+
source = variable
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
next if value.nil?
|
|
83
|
+
|
|
84
|
+
value = coerce_boolean(value, attribute, source) if attribute == :unsigned
|
|
85
|
+
config.public_send(:"#{attribute}=", value)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
config
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
# Credentials reach us deep-symbolized in current Rails, but a stub or
|
|
93
|
+
# an older shape can hand over strings; normalizing in one place is
|
|
94
|
+
# what keeps the rest of this indifferent.
|
|
95
|
+
#
|
|
96
|
+
# An unrecognized key under `audioproxy:` raises, the same as in
|
|
97
|
+
# default_options. It is tempting to be permissive here on the grounds
|
|
98
|
+
# that a typo simply leaves a setting unconfigured and the core then
|
|
99
|
+
# raises at url_for — true of endpoint, key and salt, whose default is
|
|
100
|
+
# nil. It is false of unsigned, whose default is a working value:
|
|
101
|
+
# `unsinged: true` leaves unsigned at false and emits a signed URL
|
|
102
|
+
# where the insecure segment was meant. That is a valid-looking URL
|
|
103
|
+
# for the wrong variant, which is the one thing this gem may not do.
|
|
104
|
+
def normalize_credentials(credentials)
|
|
105
|
+
return {} if credentials.nil?
|
|
106
|
+
|
|
107
|
+
# Converts rather than type-checks, because credentials arrive as
|
|
108
|
+
# an OrderedOptions. Array is excluded by hand: it answers to to_h
|
|
109
|
+
# too, and letting one through would raise TypeError from inside
|
|
110
|
+
# each_key below or, for [], silently configure nothing.
|
|
111
|
+
hash = credentials.to_h if credentials.respond_to?(:to_h) && !credentials.is_a?(Array)
|
|
112
|
+
|
|
113
|
+
unless hash.is_a?(Hash)
|
|
114
|
+
raise ArgumentError,
|
|
115
|
+
"Audioproxy credentials must be a Hash of endpoint/key/salt/unsigned, got #{credentials.class}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
hash.each_key do |key|
|
|
119
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
120
|
+
raise ArgumentError, "Audioproxy credentials keys must be Strings or Symbols, got #{key.class}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
duplicate = hash.keys.group_by(&:to_sym).find { |_, spellings| spellings.size > 1 }
|
|
125
|
+
if duplicate
|
|
126
|
+
key, spellings = duplicate
|
|
127
|
+
raise ArgumentError,
|
|
128
|
+
"Audioproxy credentials give #{key} twice, as " \
|
|
129
|
+
"#{spellings.map(&:inspect).join(" and ")}; each setting takes one spelling"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
normalized = hash.symbolize_keys
|
|
133
|
+
|
|
134
|
+
unless (unknown = normalized.keys - ENV_VARIABLES.keys).empty?
|
|
135
|
+
raise ArgumentError,
|
|
136
|
+
"unknown Audioproxy credential #{unknown.first.inspect} under audioproxy:; " \
|
|
137
|
+
"known keys are #{ENV_VARIABLES.keys.join(", ")}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
normalized
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def coerce_boolean(value, attribute, source)
|
|
144
|
+
return value if value == true || value == false
|
|
145
|
+
|
|
146
|
+
# to_s, not String.try_convert: YAML reads `unsigned: 1` as an
|
|
147
|
+
# Integer, and try_convert would reject it with a message listing 1
|
|
148
|
+
# among the accepted literals. The same value spelled in the
|
|
149
|
+
# environment is a String and is accepted, so rejecting it here
|
|
150
|
+
# would make the two sources disagree over one written character.
|
|
151
|
+
case value.to_s.downcase
|
|
152
|
+
when *TRUE_VALUES then true
|
|
153
|
+
when *FALSE_VALUES then false
|
|
154
|
+
else
|
|
155
|
+
raise ArgumentError,
|
|
156
|
+
"Audioproxy #{attribute} from #{source} must be one of " \
|
|
157
|
+
"#{(TRUE_VALUES + FALSE_VALUES).join(", ")} (or a YAML boolean), got #{value.inspect}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|