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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b7ae5b13fb6a5adc46eb6e975cdc682cf27244c316624891072a31f2447a1257
4
+ data.tar.gz: fe648c6c0cc0552d30dfd4cdcdf7878ebf68fdb7961c7363b6bf2b62725c50bf
5
+ SHA512:
6
+ metadata.gz: 2c46a5ef917dca8990e4d565cd14b283a0c0592b36ca95e4cd6e81cb4fe06d2bd3ac823353e22efb5486a0d41749f0631c32faf45f4fef18b3866dba182fedfa
7
+ data.tar.gz: 878b41e27ab71a8b4e69cc8d6d0f27c1a5d79e011307508f32d8a589e99397710a2bbca8fa71df979d42c09560690914fd5f8f188332ebea096a1c03c7402097
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release.
6
+
7
+ * Signed URL building for the audioproxy server. `Audioproxy::Signer` reproduces the server's
8
+ signature byte for byte, and is checked against the server's published known-answer vectors
9
+ rather than against this gem's own output.
10
+
11
+ * Typed variant options, in both the proxy's short spellings and their aliases. Malformed options,
12
+ a `nil` source, and an endpoint carrying credentials or a query raise at configuration or call
13
+ time instead of producing a URL that looks valid and is refused by the proxy later.
14
+
15
+ * Configuration through Rails credentials or ENV, wired by a railtie. The gem contributes no
16
+ routes, no migrations, and no `app/` directory.
17
+
18
+ * View helpers: `audioproxy_url`, `audioproxy_audio_tag`, and `audioproxy_preload_link_tag`.
19
+ Proxy options and HTML attributes stay in separate namespaces, and all three render the same
20
+ bytes for the same inputs.
21
+
22
+ * ActiveStorage resolution for the S3 and Disk services. A blob on any other service raises with
23
+ the service named, rather than guessing at a public URL.
24
+
25
+ `Audioproxy::Signer` depends on stdlib and `base64` only, with no ActiveSupport and no other file
26
+ in this gem, so signature building can be lifted into a standalone gem if a non-Rails project ever
27
+ needs it.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Julian Rubisch
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,383 @@
1
+ # audioproxy-rails
2
+
3
+ Build signed variant URLs for the [audioproxy](https://github.com/audioproxy) media server from a Rails app: point at a source audio file, describe the variant you want (format, bitrate, waveform, …), and get back a URL the proxy will accept.
4
+
5
+ ## Architecture
6
+
7
+ `Audioproxy::Signer` holds signature building and depends on stdlib and `base64` only, so it can be lifted into a standalone gem with a `git mv` if a non-Rails project ever needs it. The rest of the `Audioproxy` namespace — configuration and URL assembly — is a Rails integration and uses ActiveSupport. Everything Rails-facing lives under `Audioproxy::Rails` and hooks in through a railtie, not an engine: no routes, no `app/`, no migrations.
8
+
9
+ Full Rails is a development dependency only. `require "audioproxy"` works in a plain Ruby process; the railtie is required only when `Rails::Railtie` is already defined.
10
+
11
+ ## Status
12
+
13
+ Core signing and typed options work, in both the proxy's short spellings and their aliases, as do the Railtie's credentials/ENV wiring, the view helpers — URL, `<audio>` tag and preload hint — and ActiveStorage resolution for the S3 and Disk services. Blobs on any other service raise; see [ActiveStorage](#activestorage) for what to do about that.
14
+
15
+ ## Installation
16
+
17
+ ```ruby
18
+ gem "audioproxy-rails"
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ Tell the gem where the proxy lives:
24
+
25
+ ```yaml
26
+ # bin/rails credentials:edit
27
+ audioproxy:
28
+ endpoint: https://audio.example.com
29
+ key: 7a3f9c21… # hex, from the proxy's AP_KEY
30
+ salt: 9c217a3f… # hex, from the proxy's AP_SALT
31
+ ```
32
+
33
+ Then hand a view an ActiveStorage attachment:
34
+
35
+ ```erb
36
+ <%= audioproxy_audio_tag @recording.audio,
37
+ format: "opus", bitrate: 96,
38
+ html: { controls: true } %>
39
+ ```
40
+
41
+ ```html
42
+ <audio controls="controls"
43
+ src="https://audio.example.com/zfLTfPPh…/f:opus/br:96/enc/bG9jYWw6Ly93eC95ei93…"></audio>
44
+ ```
45
+
46
+ That is the whole path. `@recording.audio` is an ordinary `has_one_attached`; the gem reads the storage service the blob lives on, turns it into the source string the proxy speaks (`s3://…` or `local://…`), renders the variant you asked for, and signs the result. The view helpers arrive through a railtie, so there is nothing to include and nothing to mount.
47
+
48
+ Blobs, attachments and `has_one_attached` associations all work, and so does a plain source string if you are not using ActiveStorage:
49
+
50
+ ```ruby
51
+ Audioproxy.url_for("s3://masters/2026/piece-final.wav", format: "opus", bitrate: 96)
52
+ ```
53
+
54
+ Where to go from here:
55
+
56
+ - [Options](#options) for the full variant vocabulary, in the proxy's short keys or their spelled-out aliases.
57
+ - [ActiveStorage](#activestorage) for which storage services are supported, and for the one deployment coupling disk storage brings with it.
58
+ - [Rails](#rails) for configuration precedence across credentials, ENV and an initializer.
59
+ - `config.unsigned = true` for development against a proxy running `AP_ALLOW_INSECURE`, where no key or salt is needed.
60
+
61
+ ## Configuration
62
+
63
+ ```ruby
64
+ Audioproxy.configure do |config|
65
+ config.endpoint = "https://audio.example.com" # absolute http(s) URL, path prefix allowed
66
+ config.key = ENV["AUDIOPROXY_KEY"] # hex string, decoded at assignment
67
+ config.salt = ENV["AUDIOPROXY_SALT"] # hex string, decoded at assignment
68
+ end
69
+ ```
70
+
71
+ `key` and `salt` are hex strings, validated eagerly: a typo raises `ArgumentError` at boot rather than in a mailer six hours later. The endpoint must be an absolute `http`/`https` URL. A path prefix (`https://cdn.example.com/audio`, for a CDN routing that prefix to the proxy) is supported and does not disturb signing, because the signature covers only the path after the signature segment. Userinfo, a query and a fragment are rejected: a base URL is scheme, host and optional path prefix, and `https://user:pass@host` would put credentials into every URL you generate.
72
+
73
+ The gem is deliberately strict about input, because the alternative is not an exception but a 403 from the proxy at request time, far from the call that caused it. A `nil` or non-String source, a `default_options` value that is not a Hash, an unrecognized option key, and a `raw:` string bracketed by `/` all raise.
74
+
75
+ In development, `config.unsigned = true` emits the literal `insecure` signature segment instead of an HMAC, matching the proxy's `AP_ALLOW_INSECURE` mode. No key or salt is needed in that mode.
76
+
77
+ ## Generating URLs
78
+
79
+ ```ruby
80
+ Audioproxy.url_for("s3://masters/2026/piece-final.wav", raw: "f:opus/br:96")
81
+ # => "https://audio.example.com/zfLTfPPh…/f:opus/br:96/enc/czM6Ly9tYXN0ZXJz…"
82
+ ```
83
+
84
+ The result is `{endpoint}/{signature}/{options}/{source}`. The source is always emitted in `enc/` form (unpadded base64url), so spaces, nested URLs, and already-escaped bytes need no special handling.
85
+
86
+ ## Options
87
+
88
+ Describe the variant with the proxy's option keys as Ruby keyword arguments, either in the proxy's own short spelling or in the spelled-out alias next to it:
89
+
90
+ ```ruby
91
+ Audioproxy.url_for("s3://masters/piece.wav", f: :opus, br: 96, t: [12.5, 30])
92
+ # => ".../f:opus/br:96/t:12.5:30/enc/..."
93
+ ```
94
+
95
+ | Key | Alias | Example | Meaning |
96
+ | --- | --- | --- | --- |
97
+ | `f` | `format` | `f: :opus` | output format |
98
+ | `br` | `bitrate` | `br: 96` | bitrate (kbps) |
99
+ | `q` | `quality` | `q: 5` | quality, for codecs that take one instead of a bitrate |
100
+ | `sr` | `sample_rate` | `sr: 44100` | sample rate |
101
+ | `ch` | `channels` | `ch: 1` | channel count |
102
+ | `bd` | `bit_depth` | `bd: 24` | bit depth |
103
+ | `t` | `trim` | `t: [12.5, 30]` | trim: start, optional duration |
104
+ | `fade` | `fade` | `fade: [1, 2]` | fade in, optional fade out |
105
+ | `gain` | `gain` | `gain: -2.5` | gain adjustment (dB) |
106
+ | `norm` | `normalize` | `norm: [:ebu, -16, -1.5, 11]` | loudness normalization: mode, then I, TP, LRA |
107
+ | `pts` | `peak_count` | `pts: 800` | peak points, for waveform output |
108
+ | `pk_fmt` | `peak_format` | `pk_fmt: :json` | peaks format |
109
+ | `dl` | `download` | `dl: "piece.mp3"` | download filename |
110
+ | `cb` | `cache_buster` | `cb: "v2"` | cache buster |
111
+
112
+ Segments render in the order you write the keywords. The gem does not sort them and does not materialize defaults; that is the proxy's normalization, and a half-normalization here would only invent a third spelling. If you want URLs to stay stable across a codebase, keep the argument order stable.
113
+
114
+ `t`, `fade` and `norm` take colon-separated parts, so they take arrays: `t: [12.5, 30]` renders `t:12.5:30`. A single part can be written as a scalar: `t: 12.5` renders `t:12.5`. Symbols and strings render alike, so `f: :opus` and `f: "opus"` are the same URL.
115
+
116
+ An unrecognized key (`bt: 96`, or a guessed alias like `bit_rate: 96`) raises `ArgumentError` listing the keys that exist and noting that each is also accepted spelled out. So does a value carrying a character that would break the path: the gem supplies the `/` and `:` separators, so a value containing one would invent a segment or a part, and a `?` or `#` would end the path in a browser, leaving the proxy with less than what was signed (a 403, at request time, nowhere near the call). Whitespace is rejected for the same reason, which means a `dl:` filename with spaces has to be pre-encoded by you; the gem will not invent an encoding, because that would change the bytes it signs.
117
+
118
+ Value *domains* are not checked: `br: 999999` renders, and the proxy rejects it with a structured 422. Duplicating the proxy's validation rules here would mean two rule sets drifting apart, with a stale client rejecting URLs a newer proxy accepts.
119
+
120
+ ### Both spellings work
121
+
122
+ Every key has the spelled-out alias in the table above, for call sites that would rather read than decode:
123
+
124
+ ```ruby
125
+ Audioproxy.url_for(source, format: :opus, bitrate: 96, sample_rate: 44100)
126
+ # => ".../f:opus/br:96/sr:44100/enc/..."
127
+ ```
128
+
129
+ An alias resolves to its canonical key before anything is rendered, so the two spellings produce byte-identical URLs and the same cache key. `fade` and `gain` are already words and are their own alias.
130
+
131
+ The short keys stay first-class rather than becoming a legacy spelling: they are the proxy's own vocabulary, they are what a `raw:` string contains, and they are what the proxy's own error messages name. Mixing the two in one call (`format: :opus, br: 96`) is fine; the gem renders it correctly, and house style is a linting matter.
132
+
133
+ Giving *both* spellings of one option in a single call raises `ArgumentError` naming both, rather than letting one silently win:
134
+
135
+ ```ruby
136
+ Audioproxy.url_for(source, bitrate: 96, br: 128)
137
+ # ArgumentError: Audioproxy option br was given twice, as bitrate and br
138
+ ```
139
+
140
+ Aliases work in `config.default_options` too, where the same conflict is rejected at assignment, so it fails at boot rather than in a mailer. Resolution happens before the defaults merge, so a default of `bitrate: 96` and a per-call `br: 128` are one key that overrides (`br:128`, in the default's position), not two bitrate segments.
141
+
142
+ ### Seconds can be written as durations
143
+
144
+ `t` and `fade` are the two keys whose values *are* seconds, so they accept an `ActiveSupport::Duration`:
145
+
146
+ ```ruby
147
+ Audioproxy.url_for(source, t: 30.seconds) # => ".../t:30/..."
148
+ Audioproxy.url_for(source, trim: [12.5, 1.minute]) # => ".../t:12.5:60/..."
149
+ Audioproxy.url_for(source, fade: [1.5.seconds, 2.seconds]) # => ".../fade:1.5:2/..."
150
+ ```
151
+
152
+ A duration renders exactly as the number of seconds it stands for, so `t: 30.seconds` and `t: 30` are one URL and one cache key. A duration anywhere else raises: `br: 3.seconds` is a bug, and rendering `br:3` from it would be a valid-looking URL for the wrong variant.
153
+
154
+ The `30.seconds` spelling itself comes from ActiveSupport's time core extensions, which Rails loads for you. In a plain Ruby process that requires only `audioproxy`, `30.seconds` raises `NoMethodError` — this gem accepts a `Duration` but does not patch `Integer` to manufacture one. Either require `active_support/core_ext/numeric/time` yourself, or write `ActiveSupport::Duration.seconds(30)`.
155
+
156
+ ### Numbers have one canonical spelling
157
+
158
+ The proxy renders numbers minimally and hashes the normalized options string into its cache key. A URL carrying `t:12.50` still works, because the proxy re-normalizes, but it is a second CDN and browser cache entry for a byte-identical variant. So numbers here render the way the proxy renders them:
159
+
160
+ | You write | It renders |
161
+ | --- | --- |
162
+ | `t: 30` or `t: 30.0` | `t:30` |
163
+ | `gain: -2.50` | `gain:-2.5` |
164
+ | `t: 0.125` | `t:0.125` |
165
+ | `gain: 0.001` | `gain:0.001`, never `1.0e-03` |
166
+ | `gain: -0.0` | `gain:0` |
167
+
168
+ Integers, floats, rationals and `BigDecimal`s all go through this, and the rendering is exact: a `BigDecimal` keeps digits a double would lose, and a large float renders the decimal you wrote rather than the binary value underneath it. Strings do not go through it at all: a string value is used verbatim, which is how you opt out of formatting.
169
+
170
+ The proxy caps decimals at three places and rejects the rest with `:excessive_precision` rather than rounding, so this gem does the same:
171
+
172
+ ```ruby
173
+ Audioproxy.url_for(source, t: 0.1234) # ArgumentError, at the call site
174
+ Audioproxy.url_for(source, t: 0.1 + 0.2) # ArgumentError: float drift is 0.30000000000000004
175
+ ```
176
+
177
+ Round explicitly where the number is computed (`t: (0.1 + 0.2).round(3)`) so the rounding is a decision in your code rather than a silent one in a URL builder.
178
+
179
+ ### `raw:` and defaults
180
+
181
+ `raw:` is a pre-rendered options string used verbatim, the escape hatch for a proxy option this gem's key table does not know yet. The builder supplies the surrounding separators, so do not bracket it with `/`. Passing `raw:` together with typed keys raises `ArgumentError`: two sources of truth for one segment is ambiguity, not composition.
182
+
183
+ ```ruby
184
+ Audioproxy.url_for(source, raw: "f:opus/t:12.5:30")
185
+ Audioproxy.url_for(source, raw: "f:opus", br: 96) # ArgumentError
186
+ Audioproxy.url_for(source, raw: "f:opus", bitrate: 96) # ArgumentError — an alias is a typed key
187
+ ```
188
+
189
+ `config.default_options` applies to every call. Typed defaults merge under typed per-call keys, key by key:
190
+
191
+ ```ruby
192
+ Audioproxy.configure { |c| c.default_options = { f: :opus, br: 96 } }
193
+
194
+ Audioproxy.url_for(source, br: 128) # => .../f:opus/br:128/...
195
+ ```
196
+
197
+ A per-call `raw:` replaces the defaults entirely, and so do per-call typed keys when the default is a `raw:` string — in either vocabulary, since an alias counts as a typed key everywhere `raw:` and typed keys are mutually exclusive. Putting `raw:` and typed keys in `default_options` together raises at configuration time. String keys work throughout, so a value read from YAML or ENV behaves the same. With no options and no defaults, the segment is `f:mp3`, the proxy's default format spelled out, because its path grammar has no optionless form.
198
+
199
+ ## Per-call overrides
200
+
201
+ Per call you can override the endpoint (for a second proxy instance or another region) and the unsigned flag, without touching the global config:
202
+
203
+ ```ruby
204
+ Audioproxy.url_for("local://a.wav", endpoint: "https://audio-eu.example.com")
205
+ Audioproxy.url_for("local://a.wav", unsigned: true)
206
+ ```
207
+
208
+ `Audioproxy.url_for` is Rails-free: it works in jobs, mailers, serializers, and plain Ruby scripts.
209
+
210
+ ## Rails
211
+
212
+ In a Rails app there is nothing to mount and, usually, nothing to write: a railtie reads your configuration and mixes the view helpers into ActionView.
213
+
214
+ ### Configuration from credentials
215
+
216
+ ```bash
217
+ bin/rails credentials:edit
218
+ ```
219
+
220
+ ```yaml
221
+ audioproxy:
222
+ endpoint: https://audio.example.com
223
+ key: 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
224
+ salt: ffeeddccbbaa99887766554433221100
225
+ ```
226
+
227
+ That is the whole setup. String and symbol keys both work, and each setting resolves on its own — you can keep `key` and `salt` in credentials and leave `endpoint` to the environment.
228
+
229
+ ### ENV parity with the proxy
230
+
231
+ Every setting also reads from an environment variable, and the names are the proxy's own:
232
+
233
+ | Setting | Variable |
234
+ | --- | --- |
235
+ | `endpoint` | `AP_ENDPOINT` |
236
+ | `key` | `AP_KEY` |
237
+ | `salt` | `AP_SALT` |
238
+ | `unsigned` | `AP_ALLOW_INSECURE` |
239
+
240
+ The names match on purpose: in development you can point a docker-compose app service and the proxy service at one shared env file and have both read the same values. An empty variable (`AP_KEY=` with nothing after it) counts as unset.
241
+
242
+ `AP_ALLOW_INSECURE` accepts `1`, `t`, `true`, `0`, `f`, `false`, case-insensitively, and raises on anything else. Those are the literals Go's `strconv.ParseBool` accepts, which is what the proxy parses the variable with — the case-insensitivity is the one liberty taken, so `True` and `TrUe` both work here where Go takes only the former. It is stricter than Rails' usual boolean cast for a reason: a cast that reads every unrecognized string as true would turn `AP_ALLOW_INSECURE=flase` into a production app emitting unsigned URLs.
243
+
244
+ In credentials the same setting takes a YAML boolean, or an unquoted `1`/`0`, which YAML hands over as an Integer. Anything under `audioproxy:` that is not `endpoint`, `key`, `salt` or `unsigned` raises, and so does one setting written twice under different spellings. That strictness earns its keep on `unsigned` in particular: the other three default to nothing and would fail loudly at `url_for`, but `unsinged: true` would leave `unsigned` at `false` and quietly emit a signed URL where you meant the `insecure` segment.
245
+
246
+ Two caveats on that flag. It sets only *this* client's behaviour, telling the gem to emit the literal `insecure` signature segment instead of an HMAC; the proxy decides independently whether it will accept one. And it belongs in development only — never set it in production, where it hands anyone who can read a URL the ability to request any variant of any source.
247
+
248
+ ### Precedence
249
+
250
+ **initializer > credentials > ENV.** The railtie reads ENV first, lays credentials over it per setting, and app initializers run afterwards, so an explicit `Audioproxy.configure` in `config/initializers/audioproxy.rb` always has the last word:
251
+
252
+ ```ruby
253
+ # config/initializers/audioproxy.rb — wins over credentials and ENV
254
+ Audioproxy.configure do |config|
255
+ config.endpoint = "https://audio-staging.example.com"
256
+ config.default_options = { format: "opus", bitrate: 96 }
257
+ end
258
+ ```
259
+
260
+ Nothing is validated at boot. An app with no credentials and no ENV boots fine — a signed `url_for` is where the missing key surfaces, and an app running unsigned in development never needs one. That keeps `assets:precompile` and similar tasks working in apps that never generate a URL. It also means URL generation belongs at request or job time, not at class-load time in a constant.
261
+
262
+ ### View helpers
263
+
264
+ `audioproxy_url` is `Audioproxy.url_for` under another name, available in every view:
265
+
266
+ ```erb
267
+ <%= audioproxy_url(@track.master_url, format: "opus", bitrate: 96) %>
268
+ ```
269
+
270
+ `audioproxy_audio_tag` builds that URL and hands it to Rails' `audio_tag`. Proxy options are keyword arguments; HTML attributes go in `html:`:
271
+
272
+ ```erb
273
+ <%= audioproxy_audio_tag @track.master_url,
274
+ format: "opus", bitrate: 96,
275
+ html: { controls: true, preload: "none", class: "player" } %>
276
+ ```
277
+
278
+ ```html
279
+ <audio controls="controls" preload="none" class="player"
280
+ src="https://audio.example.com/zfLTfPPh…/f:opus/br:96/enc/…"></audio>
281
+ ```
282
+
283
+ The `html:` bucket is not ceremony. Without it, proxy option names and HTML attribute names would share one namespace, and the gem would have to guess which one you meant for any key it did not recognize — so a mistyped `bitrat: 96` would land silently on the `<audio>` element as an attribute and quietly ship the default format instead. With the bucket, the two never mix in either direction: an unknown proxy option raises, and an `html:` entry never reaches the proxy. Proxy options never appear as tag attributes.
284
+
285
+ #### Preloading a variant
286
+
287
+ `audioproxy_preload_link_tag` emits a resource hint for a variant you are about to play. Because the first request for a variant is a render, the hint overlaps that render with page load rather than leaving someone waiting for it after a click:
288
+
289
+ ```erb
290
+ <% opus = { format: "opus", bitrate: 96 } %>
291
+
292
+ <%# in a layout that yields :head, or wherever your <head> content goes %>
293
+ <% content_for :head do %>
294
+ <%= audioproxy_preload_link_tag @track.audio, **opus, html: { fetchpriority: "high" } %>
295
+ <% end %>
296
+
297
+ <%= audioproxy_audio_tag @track.audio, **opus, html: { controls: true } %>
298
+ ```
299
+
300
+ ```html
301
+ <link rel="preload" href="https://audio.example.com/zfLTfPPh…/f:opus/br:96/enc/…" as="audio" fetchpriority="high">
302
+ ```
303
+
304
+ The shared local is the point. The hint and the element have to name the same variant, byte for byte: one differing option is a different URL, a different cache key, and a preload the browser never matches to the `<audio>` element that needed it. Writing the options out twice is how that goes wrong.
305
+
306
+ `as="audio"` is supplied for you. Rails infers `as` from a file extension, and a proxy URL ends in an encoded source segment that has none, so the inference cannot work here — and a `rel=preload` carrying no `as` has no fetch destination, which browsers decline to act on. Pass `html: { as: … }` if you need something else; a blank one (`nil`, `false`, `""`) raises rather than quietly producing that inert tag.
307
+
308
+ Three things worth knowing before reaching for it:
309
+
310
+ - **It fetches the whole variant.** On a cache miss the proxy answers chunked with no `Accept-Ranges`, so there is no partial preload to be had. This is a hint for the track that is about to play, not for a list of forty.
311
+ - **`crossorigin` must match the element.** Neither helper sets one, so by default they agree. If you add `crossorigin` to the `<audio>` tag, add the same value here, or the browser treats them as two different requests and downloads the variant twice. Write it as a string: Rails renders `crossorigin: true` as `anonymous` on a preload link and as `true` on an `<audio>` tag, so the boolean would produce exactly that mismatch — this helper raises on it rather than letting it through.
312
+ - **Rails also emits a `Link` header** for every preload when `config.action_view.preload_links_header` is on — another reason to preload one track rather than a page of them.
313
+
314
+ ### ActiveStorage
315
+
316
+ Anywhere a source string is accepted, an ActiveStorage blob, an attachment, or a `has_one_attached` association works instead:
317
+
318
+ ```erb
319
+ <%= audioproxy_audio_tag @recording.audio, format: "opus", bitrate: 96 %>
320
+ ```
321
+
322
+ ```ruby
323
+ Audioproxy.url_for(recording.audio) # has_one_attached
324
+ Audioproxy.url_for(recording.audio.blob) # the blob itself
325
+ ```
326
+
327
+ The blob is turned into a source string by looking at the storage service it lives on — the service class, not the name you gave it in `storage.yml`, since `:local` and `:amazon` are labels an app is free to hang on anything.
328
+
329
+ | Service | Source string | Status |
330
+ | --- | --- | --- |
331
+ | `S3Service` | `s3://{bucket}/{key}`, bucket read off the service | Supported |
332
+ | `DiskService` | `local://{key[0..1]}/{key[2..3]}/{key}` | Supported, with the deployment coupling below |
333
+ | Anything else — GCS, Azure, Mirror | — | Raises `Audioproxy::UnsupportedServiceError` |
334
+
335
+ An app's own subclass of a supported service resolves the way its parent does. A Mirror service is *not* unwrapped to its primary, even when that primary is S3: which copy the proxy should read is a deployment decision, so the error names the mirror and leaves the choice to you rather than guessing. Point the blob at the primary service directly if that is what you meant.
336
+
337
+ Asking for a URL for an empty attachment raises `Audioproxy::UnattachedError` naming the attachment, rather than emitting a URL that would 404 at the proxy.
338
+
339
+ #### Disk storage couples `AP_LOCAL_ROOT` to your storage root
340
+
341
+ For `local://` sources, the proxy resolves the path against its own `AP_LOCAL_ROOT`. So **the proxy's `AP_LOCAL_ROOT` must be the same directory as the Disk service's `root`** in `config/storage.yml` — the same volume mounted into both, in a container deployment:
342
+
343
+ ```yaml
344
+ # config/storage.yml
345
+ local:
346
+ service: Disk
347
+ root: /var/audio
348
+ ```
349
+
350
+ ```bash
351
+ # the proxy
352
+ AP_LOCAL_ROOT=/var/audio
353
+ ```
354
+
355
+ If they disagree, URLs generate fine and the proxy answers 404. The gem cannot check this and does not try: it has no way to see the proxy's environment.
356
+
357
+ This coupling is not avoidable by pointing the proxy at your app instead. ActiveStorage's ordinary disk URLs are Rails routes that redirect to the file, and the proxy's HTTPS source backend refuses redirects by design.
358
+
359
+ The two hashed subdirectories in the path are ActiveStorage's own layout for disk storage (`DiskService#path_for`), reproduced here because the proxy needs a path rather than an ActiveStorage lookup. It is the one piece of near-private Rails API this gem leans on, so it lives alone in `Audioproxy::Rails::BlobResolver::DiskLayout`, with a test that pins it against the real `DiskService` in both directions: identical paths for every key the service accepts, and an error for every key it rejects. A Rails upgrade that changed the layout fails the suite rather than silently generating 404s.
360
+
361
+ Blob keys carrying a `.` or `..` path segment, or a null byte, raise. `DiskService` refuses them as path-traversal defense, and this side has more reason to: it never touches a filesystem, so nothing downstream would catch a key that walks out of the storage root. Keys ActiveStorage generates itself never look like this; explicitly-set keys can. Note that the S3 path does *not* apply the same rule, deliberately — an S3 key is an opaque string in which `..` means nothing, and `S3Service` does not reject it either.
362
+
363
+ #### Other services: the third rung
364
+
365
+ GCS, Azure and the rest have no proxy-side counterpart today. The general-purpose answer is ActiveStorage's `rails_storage_proxy` mode — proxied URLs stream the file back with a `200` instead of redirecting — served to the proxy through an `https://` source. That source backend is parked upstream on demand, and this gem is the demand signal for it: if you hit `Audioproxy::UnsupportedServiceError`, say so on the proxy's issue tracker, because you are the person that slice is waiting for.
366
+
367
+ ## Development
368
+
369
+ Run the test suite with:
370
+
371
+ ```bash
372
+ bin/test
373
+ ```
374
+
375
+ It boots the dummy Rails app in `test/dummy` for the integration tests. Style checks:
376
+
377
+ ```bash
378
+ bin/rubocop
379
+ ```
380
+
381
+ ## License
382
+
383
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,155 @@
1
+ require "uri"
2
+ require "active_support/core_ext/hash/keys"
3
+ require "audioproxy/options"
4
+
5
+ module Audioproxy
6
+ # Raised when a URL is requested but the configuration cannot produce one the
7
+ # proxy would accept (missing endpoint, or missing signing material outside of
8
+ # unsigned mode).
9
+ class ConfigurationError < StandardError; end
10
+
11
+ # Process-global settings for URL generation. Assign through
12
+ # +Audioproxy.configure+; every attribute validates at assignment so a typo
13
+ # fails at boot rather than in a mailer.
14
+ class Config
15
+ HEX = /\A(?:\h\h)+\z/
16
+
17
+ # Option keys defaults may carry: the proxy's typed short keys and their
18
+ # spelled-out aliases, plus the pre-rendered +raw:+ escape hatch. An
19
+ # unrecognized key is a typo, and a typo that is silently dropped emits a
20
+ # valid URL for the wrong variant.
21
+ OPTION_KEYS = ([ :raw ] + Options::KEYS + Options::ALIASES.values).uniq.freeze
22
+
23
+ attr_reader :endpoint, :key, :salt, :default_options
24
+ attr_accessor :unsigned
25
+
26
+ def initialize
27
+ @endpoint = nil
28
+ @key = nil
29
+ @salt = nil
30
+ @unsigned = false
31
+ @default_options = {}
32
+ end
33
+
34
+ # Full base URL of the proxy: scheme + host, optionally with a path prefix
35
+ # (a CDN routing +/audio+ to the proxy, say). One trailing slash is dropped
36
+ # so joining is a plain concatenation.
37
+ def endpoint=(value)
38
+ @endpoint = value.nil? ? nil : normalize_endpoint(value)
39
+ end
40
+
41
+ # Options applied to every URL unless overridden per call. Keys may be given
42
+ # as strings or symbols, canonical or spelled-out; they are normalized to
43
+ # canonical symbols here, which is what makes an aliased default and a
44
+ # canonical per-call key one key in the merge rather than two segments (D3).
45
+ def default_options=(value)
46
+ @default_options = normalize_default_options(value)
47
+ end
48
+
49
+ # Hex signing key, decoded to binary at assignment.
50
+ def key=(value)
51
+ @key = decode_hex(value, :key)
52
+ end
53
+
54
+ # Hex salt, decoded to binary at assignment.
55
+ def salt=(value)
56
+ @salt = decode_hex(value, :salt)
57
+ end
58
+
59
+ private
60
+ def decode_hex(value, attribute)
61
+ return nil if value.nil?
62
+
63
+ hex = String.try_convert(value)
64
+ if hex.nil?
65
+ raise ArgumentError, "Audioproxy config #{attribute} must be a hex String, got #{value.class}"
66
+ end
67
+
68
+ unless hex.match?(HEX)
69
+ raise ArgumentError, "Audioproxy config #{attribute} must be a non-empty, even-length hex string, got #{value.inspect}"
70
+ end
71
+
72
+ [ hex ].pack("H*")
73
+ end
74
+
75
+ def normalize_endpoint(value)
76
+ endpoint = String.try_convert(value)
77
+ if endpoint.nil?
78
+ raise ArgumentError, "Audioproxy config endpoint must be a String, got #{value.class}"
79
+ end
80
+
81
+ uri = begin
82
+ URI.parse(endpoint)
83
+ rescue URI::InvalidURIError
84
+ nil
85
+ end
86
+
87
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
88
+ raise ArgumentError, "Audioproxy config endpoint must be an absolute http(s) URL, got #{value.inspect}"
89
+ end
90
+
91
+ # A base URL is scheme + host + optional path prefix and nothing else.
92
+ # Userinfo would put credentials into every generated URL (and so into
93
+ # HTML, logs and CDN access logs); a query or fragment would swallow the
94
+ # path we append after it.
95
+ if uri.userinfo
96
+ raise ArgumentError, "Audioproxy config endpoint must not carry userinfo (credentials would leak into every URL)"
97
+ end
98
+ if uri.query || uri.fragment
99
+ raise ArgumentError, "Audioproxy config endpoint must not carry a query or fragment, got #{value.inspect}"
100
+ end
101
+
102
+ # delete_suffix removes exactly one occurrence, which is what D5 says.
103
+ endpoint.delete_suffix("/")
104
+ end
105
+
106
+ def normalize_default_options(value)
107
+ return {} if value.nil?
108
+
109
+ unless value.is_a?(Hash)
110
+ raise ArgumentError, "Audioproxy config default_options must be a Hash, got #{value.class}"
111
+ end
112
+
113
+ value.each_key do |key|
114
+ unless key.is_a?(String) || key.is_a?(Symbol)
115
+ raise ArgumentError, "Audioproxy config default_options keys must be Strings or Symbols, got #{key.class}"
116
+ end
117
+ end
118
+
119
+ # Before symbolize_keys, which collapses "br" and :br into one entry and
120
+ # silently discards a value — the typo that OPTION_KEYS exists to catch.
121
+ duplicate = value.keys.group_by(&:to_sym).find { |_, spellings| spellings.size > 1 }
122
+ if duplicate
123
+ key, spellings = duplicate
124
+ raise ArgumentError,
125
+ "Audioproxy config default_options gives #{key} twice, as " \
126
+ "#{spellings.map(&:inspect).join(" and ")}; each option takes one spelling"
127
+ end
128
+
129
+ normalized = value.symbolize_keys
130
+
131
+ # Not assert_valid_keys: its message lists the aliases among the valid
132
+ # keys without saying they are aliases, so a caller who guessed
133
+ # bit_rate: sees :bitrate in a flat list and cannot tell the two
134
+ # vocabularies apart.
135
+ unless (unknown = normalized.keys - OPTION_KEYS).empty?
136
+ raise ArgumentError,
137
+ "unknown Audioproxy option #{unknown.first.inspect} in default_options; known keys are " \
138
+ "#{([ :raw ] + Options::KEYS).join(", ")}, each also accepted as its spelled-out alias " \
139
+ "(#{Options::ALIASES[:br]}, #{Options::ALIASES[:sr]}, #{Options::ALIASES[:pk_fmt]}, …)"
140
+ end
141
+
142
+ # Two sources of truth for one segment string is ambiguity, not
143
+ # composition — the same rule as per call (D4), applied at boot.
144
+ if normalized.key?(:raw) && normalized.keys.size > 1
145
+ raise ArgumentError,
146
+ "Audioproxy config default_options takes either raw: or typed option keys, not both " \
147
+ "(got raw: and #{(normalized.keys - [ :raw ]).join(", ")})"
148
+ end
149
+
150
+ # Both spellings of one option is the same ambiguity, and assignment
151
+ # time is where it should fail: at boot, not in a mailer.
152
+ Options.resolve(normalized)
153
+ end
154
+ end
155
+ end