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.
@@ -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
@@ -0,0 +1,9 @@
1
+ require "audioproxy"
2
+
3
+ module Audioproxy
4
+ # Rails integration layer. Loaded by Bundler under the gem name
5
+ # "audioproxy-rails"; the railtie itself is required from lib/audioproxy.rb
6
+ # whenever Rails is present.
7
+ module Rails
8
+ end
9
+ end