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
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
|