audioproxy-rails 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +156 -3
- data/lib/audioproxy/config.rb +33 -4
- data/lib/audioproxy/expiry.rb +160 -0
- data/lib/audioproxy/options.rb +57 -5
- data/lib/audioproxy/rails/helpers.rb +12 -0
- data/lib/audioproxy/url_builder.rb +149 -7
- data/lib/audioproxy/version.rb +1 -1
- data/lib/audioproxy.rb +13 -2
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4de1340b2094f8154a8c5f02b45c5409fc7b0468967cbe9bf0bda9d836264f4d
|
|
4
|
+
data.tar.gz: 6a69838fa47da950bdd6a80880b31e2f2a75dad76991cfa52599dc01ea92925a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c86091fd6f663dab0785223d5cc9a19d4396d04243363aa8d2ec9ddb94d631eb6c626319c8cd7ca40f0927c14abfdf436e498a9eeecaf5fb00e63e79b424a07
|
|
7
|
+
data.tar.gz: 966e160dfe9a51c1e3e47c0c6bcad259a7a4a829242917fbe2428d9f7f1c03f00132c5f753a1b1bceac06e1933689d3a9934f4e7cebdf4e4a7689fe9ebffc777
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
* Probe metadata URLs. `Audioproxy.info_url(source)` and the `audioproxy_info_url` view helper
|
|
6
|
+
build the proxy's `/info` URL, which returns duration, sample rate, channels and tags as JSON. The
|
|
7
|
+
proxy's `/info` has no options segment, so `info_url` raises for any option or `raw:`, and ignores
|
|
8
|
+
`config.default_options` and `config.expires_in`. Info URLs therefore never expire, even when
|
|
9
|
+
every other URL your app builds does.
|
|
10
|
+
|
|
11
|
+
* Waveform peaks URLs. `Audioproxy.peaks_url(source, **options)` and the `audioproxy_peaks_url`
|
|
12
|
+
view helper build a variant URL with `f:peaks` fixed. They accept only the options that change a
|
|
13
|
+
waveform (`pts`, `pk_fmt`, `pk_bits`, `ch`, `t`, `fade`, `gain`, `norm`, `dl`, `cb`) and raise on
|
|
14
|
+
the rest, since the proxy refuses encoding options on peaks. Of the configured defaults, only
|
|
15
|
+
`t`, `fade`, `gain`, `norm` and `cb` carry over, so the waveform follows normalized audio while an
|
|
16
|
+
audio default such as `br: 96` or `ch: 2` never reaches a peaks request.
|
|
17
|
+
|
|
18
|
+
* `pk_bits` option, aliased as `peak_bits`: the width of each peaks value, 8 or 16. peaks.js reads
|
|
19
|
+
only 8-bit data, so a peaks.js client asks for `pk_bits: 8`. Requires audioproxy 0.8.0 or newer;
|
|
20
|
+
older proxies answer `pk_bits:` with a `422`.
|
|
21
|
+
|
|
22
|
+
* `url_for` output is unchanged, byte for byte.
|
|
23
|
+
|
|
24
|
+
## 0.2.0
|
|
25
|
+
|
|
26
|
+
* Expiring URLs. `url_for` and every view helper accept `expires_in:` (a duration or Integer
|
|
27
|
+
seconds from now) and `expires_at:` (the instant itself), mutually exclusive, rendering the
|
|
28
|
+
proxy's `exp:` option. `config.expires_in` sets a global default; a per-call `expires_in: nil`
|
|
29
|
+
opts one URL out of it.
|
|
30
|
+
|
|
31
|
+
Because `exp` is a request option on the proxy rather than a variant option, it is signed but
|
|
32
|
+
excluded from the cache key: minting a fresh short-lived URL on every render costs no extra
|
|
33
|
+
render and no extra cached variant at the origin.
|
|
34
|
+
|
|
35
|
+
Every input that would produce a valid-looking URL the proxy refuses raises at the call site
|
|
36
|
+
instead: both keywords together, a non-positive window, an `expires_at` at or before now, a
|
|
37
|
+
fractional duration, a millisecond timestamp, a `Date`, and `exp:` written as a plain option key
|
|
38
|
+
or in `default_options`.
|
|
39
|
+
|
|
40
|
+
Requires audioproxy 0.6.0 or newer, the release that added the `exp` option. Older proxies
|
|
41
|
+
answer `exp:` with a `422`. Every other feature in this gem still works against 0.5.0.
|
|
42
|
+
|
|
43
|
+
* `Audioproxy::Signer` is unchanged, and so is the isolation test that pins its extraction seam:
|
|
44
|
+
`exp` is ordinary path bytes to the signer.
|
|
45
|
+
|
|
3
46
|
## 0.1.0
|
|
4
47
|
|
|
5
48
|
First release.
|
data/README.md
CHANGED
|
@@ -10,7 +10,9 @@ Full Rails is a development dependency only. `require "audioproxy"` works in a p
|
|
|
10
10
|
|
|
11
11
|
## Status
|
|
12
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.
|
|
13
|
+
Core signing and typed options work, in both the proxy's short spellings and their aliases, as do [`info` and peaks URLs](#metadata-and-waveforms), 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
|
+
[Expiring URLs](#expiring-urls) work too, against [audioproxy 0.6.0 or newer](#minimum-proxy-version), and `pk_bits` needs 0.8.0. Everything here is verified against the proxy's published signature vectors and its source, and CI also asks a running proxy to accept signed, unsigned, typed-option and expiring URLs. `info` and peaks URLs are not in that round-trip yet.
|
|
14
16
|
|
|
15
17
|
## Installation
|
|
16
18
|
|
|
@@ -63,17 +65,24 @@ Where to go from here:
|
|
|
63
65
|
```ruby
|
|
64
66
|
Audioproxy.configure do |config|
|
|
65
67
|
config.endpoint = "https://audio.example.com" # absolute http(s) URL, path prefix allowed
|
|
66
|
-
config.key = ENV["
|
|
67
|
-
config.salt = ENV["
|
|
68
|
+
config.key = ENV["AP_KEY"] # hex string, decoded at assignment
|
|
69
|
+
config.salt = ENV["AP_SALT"] # hex string, decoded at assignment
|
|
68
70
|
end
|
|
69
71
|
```
|
|
70
72
|
|
|
73
|
+
Writing this out is optional. Under Rails the railtie already reads `audioproxy:` from credentials
|
|
74
|
+
and `AP_ENDPOINT`, `AP_KEY`, `AP_SALT` and `AP_ALLOW_INSECURE` from the environment, so an app that
|
|
75
|
+
uses either needs no initializer at all; see [ENV parity with the proxy](#env-parity-with-the-proxy)
|
|
76
|
+
and [Rails](#rails). Configure by hand when you want to override those, or when there is no Rails.
|
|
77
|
+
|
|
71
78
|
`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
79
|
|
|
73
80
|
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
81
|
|
|
75
82
|
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
83
|
|
|
84
|
+
`config.default_options` sets options applied to every URL, and `config.expires_in` sets a global expiry; both are covered in [Options](#options) and [Expiring URLs](#expiring-urls).
|
|
85
|
+
|
|
77
86
|
## Generating URLs
|
|
78
87
|
|
|
79
88
|
```ruby
|
|
@@ -83,6 +92,24 @@ Audioproxy.url_for("s3://masters/2026/piece-final.wav", raw: "f:opus/br:96")
|
|
|
83
92
|
|
|
84
93
|
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
94
|
|
|
95
|
+
### Metadata and waveforms
|
|
96
|
+
|
|
97
|
+
The proxy answers two more things a player needs around playback, and each has its own entry point:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
Audioproxy.info_url("s3://masters/piece.wav")
|
|
101
|
+
# => "https://audio.example.com/…/info/enc/czM6Ly9tYXN0ZXJz…"
|
|
102
|
+
|
|
103
|
+
Audioproxy.peaks_url("s3://masters/piece.wav", pts: 800, pk_bits: 8)
|
|
104
|
+
# => "https://audio.example.com/…/f:peaks/pts:800/pk_bits:8/enc/czM6Ly9tYXN0ZXJz…"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`info_url` builds the proxy's probe-metadata URL: duration, sample rate, channels and tags as JSON, which is how a player learns the duration it needs before it can write a sensible `t:`. It takes **no options**, because the proxy answers any options segment alongside `info` with a `422`. So it raises for a typed key or `raw:`, and it ignores `config.default_options`, the one entry point that does. It also ignores `config.expires_in` and raises for a per-call `expires_in:` or `expires_at:`, since `/info` has nowhere to carry `exp`: **info URLs never expire, even when every other URL your app builds does.** The proxy treats them as operator-facing rather than something handed to listeners, so keep them that way. Info responses are cached for an hour and are deliberately not `immutable`, because a re-upload changes the answer; do not memoize a response forever.
|
|
108
|
+
|
|
109
|
+
`peaks_url` is a variant URL with the format fixed to `f:peaks`. It accepts only the options that change a waveform: `pts`, `pk_fmt`, `pk_bits`, `ch`, `t`, `fade`, `gain`, `norm`, `dl` and `cb`, in either spelling. Anything else raises, naming that list. The proxy refuses `br`, `q`, `sr` and `bd` on a peaks request, and an option it merely ignored would still enter its cache key and buy a second render of identical peaks. `raw:` is refused too, because a pre-rendered string cannot be checked; `url_for(source, raw: "f:peaks/…")` stays the escape hatch.
|
|
110
|
+
|
|
111
|
+
Only some configured defaults carry over to peaks: `t`, `fade`, `gain` and `norm`, which change the samples, so the waveform matches the audio drawn above it, and the cache buster `cb`. The rest are skipped. `f:opus` or `br:96` would render into a `422`, and `ch` and `dl` mean something else as audio defaults: `ch: 2` is stereo output for audio but per-channel pairs for peaks, and an audio filename is the wrong name for peaks JSON. Pass either per call when you want it on the peaks. A `raw:` default is skipped entirely. Expiry works exactly as it does for `url_for`. The gem does not add `ch:1` for you even though peaks default to mono; the proxy fills in its own defaults.
|
|
112
|
+
|
|
86
113
|
## Options
|
|
87
114
|
|
|
88
115
|
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:
|
|
@@ -106,9 +133,12 @@ Audioproxy.url_for("s3://masters/piece.wav", f: :opus, br: 96, t: [12.5, 30])
|
|
|
106
133
|
| `norm` | `normalize` | `norm: [:ebu, -16, -1.5, 11]` | loudness normalization: mode, then I, TP, LRA |
|
|
107
134
|
| `pts` | `peak_count` | `pts: 800` | peak points, for waveform output |
|
|
108
135
|
| `pk_fmt` | `peak_format` | `pk_fmt: :json` | peaks format |
|
|
136
|
+
| `pk_bits` | `peak_bits` | `pk_bits: 8` | peaks value width, 8 or 16 |
|
|
109
137
|
| `dl` | `download` | `dl: "piece.mp3"` | download filename |
|
|
110
138
|
| `cb` | `cache_buster` | `cb: "v2"` | cache buster |
|
|
111
139
|
|
|
140
|
+
If the peaks are for [peaks.js](https://github.com/bbc/peaks.js), ask for `pk_bits: 8`: peaks.js only loads 8-bit waveform data, and the proxy's default width is 16. The alias is `peak_bits` rather than `bit_depth`, because `bit_depth` already means `bd`, the sample format of encoded audio.
|
|
141
|
+
|
|
112
142
|
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
143
|
|
|
114
144
|
`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.
|
|
@@ -207,6 +237,70 @@ Audioproxy.url_for("local://a.wav", unsigned: true)
|
|
|
207
237
|
|
|
208
238
|
`Audioproxy.url_for` is Rails-free: it works in jobs, mailers, serializers, and plain Ruby scripts.
|
|
209
239
|
|
|
240
|
+
## Expiring URLs
|
|
241
|
+
|
|
242
|
+
A signed URL is otherwise an eternal bearer capability: the signature covers the path and nothing else, so a leaked URL works forever and the only revocation is rotating the key, which kills every URL you ever issued. `expires_in:` and `expires_at:` time-box one URL without touching any other.
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
Audioproxy.url_for(source, format: "opus", expires_in: 1.hour)
|
|
246
|
+
# => ".../f:opus/exp:1767229200/enc/..."
|
|
247
|
+
|
|
248
|
+
Audioproxy.url_for(source, format: "opus", expires_at: 1.day.from_now)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`expires_in:` takes an ActiveSupport duration or a positive Integer of seconds, added to the current time when the URL is built. `expires_at:` takes a `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, or an Integer unix timestamp, used as the instant itself. They are mutually exclusive. Past the expiry the proxy answers `410 Gone`, checked after the signature and before it touches your storage, so an expired URL costs a render of nothing.
|
|
252
|
+
|
|
253
|
+
Every view helper forwards both keywords:
|
|
254
|
+
|
|
255
|
+
```erb
|
|
256
|
+
<%= audioproxy_audio_tag @recording.audio, format: "opus", expires_in: 30.minutes, html: { controls: true } %>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Rotation is free at the origin
|
|
260
|
+
|
|
261
|
+
On the proxy's side `exp` is a *request* option: it is signed as part of the path, but excluded from the cache key and from the ffmpeg arguments. Two URLs that differ only in their expiry are one variant and one render, so minting a fresh short-lived URL on every page render costs nothing at the origin. Each user can carry their own URL while all of them share one cached variant.
|
|
262
|
+
|
|
263
|
+
That guarantee is origin-side only. Two URLs differing in `exp` are still two distinct URLs to a CDN, which stores them separately, so a short expiry on a heavily shared page trades edge cache entries for revocability. That is the trade whichever syntax carries the expiry, not a cost of this spelling.
|
|
264
|
+
|
|
265
|
+
### A global default
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
Audioproxy.configure { |c| c.expires_in = 1.hour }
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`config.expires_in` is `nil` by default, and `nil` means URLs carry no expiry at all. When it is set, every URL this process builds carries one, measured from each call rather than from boot. A per-call `expires_in:`/`expires_at:` overrides it, and a per-call `expires_in: nil` opts a single URL out:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
Audioproxy.url_for(source, expires_in: 5.minutes) # overrides the default
|
|
275
|
+
Audioproxy.url_for(source, expires_in: nil) # no expiry on this one
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
It is a duration rather than an instant on purpose: an absolute timestamp applied process-globally would expire every URL the app will ever mint at the same second. There is no `config.expires_at`, and `exp:` is refused in `default_options` for the same reason.
|
|
279
|
+
|
|
280
|
+
### What raises, and why
|
|
281
|
+
|
|
282
|
+
An expiry the proxy will not accept produces a URL that looks perfectly valid and fails at request time, far from the call that built it. So every one of these raises `ArgumentError` at the call site instead:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
Audioproxy.url_for(source, expires_in: 1.hour, expires_at: 1.day.from_now) # both keywords
|
|
286
|
+
Audioproxy.url_for(source, expires_in: 0) # non-positive window
|
|
287
|
+
Audioproxy.url_for(source, expires_at: 1.hour.ago) # already dead
|
|
288
|
+
Audioproxy.url_for(source, expires_in: 1.5.seconds) # not whole seconds
|
|
289
|
+
Audioproxy.url_for(source, expires_at: 1.hour.from_now.to_i * 1000) # milliseconds
|
|
290
|
+
Audioproxy.url_for(source, expires_at: Date.tomorrow) # local midnight, not an instant
|
|
291
|
+
Audioproxy.url_for(source, exp: 1767229200) # use the keywords
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
An expiry composes with `raw:` rather than replacing it, since `exp` is not a variant option: `url_for(source, raw: "f:opus/br:96", expires_in: 1.hour)` renders `f:opus/br:96/exp:…`. A `raw:` string that already spells out its own `exp:` raises when an expiry is also in force, because the proxy rejects a duplicated option and, under a global default, that is a failure you never wrote.
|
|
295
|
+
|
|
296
|
+
### Minimum proxy version
|
|
297
|
+
|
|
298
|
+
Expiring URLs need **audioproxy 0.6.0 or newer**, the release that added the `exp` option. `0.5.0` and earlier answer `exp:` with a `422 invalid option`. That failure is loud and server-side, so pointing this gem at an older proxy breaks visibly rather than silently ignoring the expiry.
|
|
299
|
+
|
|
300
|
+
`pk_bits` needs **audioproxy 0.8.0 or newer**, the release that added it. Older proxies answer `pk_bits:` with a `422` for an unknown key.
|
|
301
|
+
|
|
302
|
+
Every other feature here works against `0.5.0` unchanged. This gem does not version-sniff.
|
|
303
|
+
|
|
210
304
|
## Rails
|
|
211
305
|
|
|
212
306
|
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.
|
|
@@ -282,6 +376,8 @@ Nothing is validated at boot. An app with no credentials and no ENV boots fine
|
|
|
282
376
|
|
|
283
377
|
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
378
|
|
|
379
|
+
`audioproxy_info_url` and `audioproxy_peaks_url` are [`info_url` and `peaks_url`](#metadata-and-waveforms) under view names, as `audioproxy_url` is `url_for`. There are no tag helpers for either: both return JSON (or peaks' binary `.dat`) for a script to fetch, and there is no HTML element to hand them to.
|
|
380
|
+
|
|
285
381
|
#### Preloading a variant
|
|
286
382
|
|
|
287
383
|
`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:
|
|
@@ -378,6 +474,63 @@ It boots the dummy Rails app in `test/dummy` for the integration tests. Style ch
|
|
|
378
474
|
bin/rubocop
|
|
379
475
|
```
|
|
380
476
|
|
|
477
|
+
### Round-trip tests against a real proxy
|
|
478
|
+
|
|
479
|
+
`bin/test` checks the bytes this gem produces. It cannot check that a real proxy *accepts* them, and
|
|
480
|
+
some things are not expressible as a byte comparison at all: whether an expired URL is refused with
|
|
481
|
+
`410`, and whether the second `exp` names is still served, are questions only a running proxy
|
|
482
|
+
answers.
|
|
483
|
+
|
|
484
|
+
Those tests live in `test/server/`, and they are skipped unless you tell them where a proxy is. The
|
|
485
|
+
gem does not start one for you — start it yourself:
|
|
486
|
+
|
|
487
|
+
```bash
|
|
488
|
+
mkdir -p /tmp/audioproxy-fixtures
|
|
489
|
+
|
|
490
|
+
docker run --rm -d --name audioproxy-test \
|
|
491
|
+
--platform linux/amd64 \
|
|
492
|
+
-p 4000:4000 \
|
|
493
|
+
-e AP_KEY=00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF \
|
|
494
|
+
-e AP_SALT=FFEEDDCCBBAA99887766554433221100 \
|
|
495
|
+
-e AP_ALLOW_INSECURE=true \
|
|
496
|
+
-e AP_LOCAL_ROOT=/srv/audio \
|
|
497
|
+
-v /tmp/audioproxy-fixtures:/srv/audio \
|
|
498
|
+
ghcr.io/audioproxy/audioproxy:0.6.0
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Then point the suite at it:
|
|
502
|
+
|
|
503
|
+
```bash
|
|
504
|
+
AUDIOPROXY_PROXY_URL=http://127.0.0.1:4000 bin/test test/server
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Drop the path to run everything, unit tests included. Stop it with
|
|
508
|
+
`docker rm -f audioproxy-test` when you are done.
|
|
509
|
+
|
|
510
|
+
The key and salt above are the published known-answer test vectors — not secrets, and the same ones
|
|
511
|
+
in `test/fixtures/signature_vectors.rb`, so the round-trips exercise the bytes those vectors pin.
|
|
512
|
+
The suite writes a generated WAV into `/tmp/audioproxy-fixtures` (override with
|
|
513
|
+
`AUDIOPROXY_FIXTURE_ROOT`), so there is nothing to fetch and you need no ffmpeg on your side.
|
|
514
|
+
`--platform linux/amd64` is there because the published image is amd64-only; on Apple Silicon it
|
|
515
|
+
runs under emulation, and on an amd64 host the flag does nothing.
|
|
516
|
+
|
|
517
|
+
A few things worth knowing:
|
|
518
|
+
|
|
519
|
+
- **`AUDIOPROXY_PROXY_URL` unset means skip; set means fail if nothing answers.** Setting it is a
|
|
520
|
+
claim that a proxy is there, so an unreachable one is an error rather than a quiet pass. That
|
|
521
|
+
keeps `bin/test` a fast, Docker-free unit run for anyone who cannot pull an image, without
|
|
522
|
+
letting a broken round-trip environment look like a green one.
|
|
523
|
+
- **The proxy version is checked, not assumed.** The suite asks `/health` what it is talking to and
|
|
524
|
+
refuses to run against anything but the pinned release, so a mismatch is a named failure rather
|
|
525
|
+
than a confusing one.
|
|
526
|
+
- **The pin is advanced by hand, as part of the proxy's release checklist.** A pinned tag nobody
|
|
527
|
+
advances stops testing anything interesting, and it fails silently — by passing. When the proxy
|
|
528
|
+
releases, bump `PROXY_VERSION` in `test/support/server_roundtrip.rb` and the image in
|
|
529
|
+
`.github/workflows/ci.yml`. Forgetting one of the two is caught by the version check above.
|
|
530
|
+
- **In CI the proxy is a service container**, and the round-trip job is **visible but not required**:
|
|
531
|
+
it goes red on failure, but putting it in branch protection would make every merge depend on ghcr
|
|
532
|
+
being up, and the unit suite is the real safety net.
|
|
533
|
+
|
|
381
534
|
## License
|
|
382
535
|
|
|
383
536
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/lib/audioproxy/config.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "uri"
|
|
2
2
|
require "active_support/core_ext/hash/keys"
|
|
3
3
|
require "audioproxy/options"
|
|
4
|
+
require "audioproxy/expiry"
|
|
4
5
|
|
|
5
6
|
module Audioproxy
|
|
6
7
|
# Raised when a URL is requested but the configuration cannot produce one the
|
|
@@ -18,9 +19,16 @@ module Audioproxy
|
|
|
18
19
|
# spelled-out aliases, plus the pre-rendered +raw:+ escape hatch. An
|
|
19
20
|
# unrecognized key is a typo, and a typo that is silently dropped emits a
|
|
20
21
|
# valid URL for the wrong variant.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
#
|
|
23
|
+
# exp is subtracted deliberately (D1). It is an absolute instant, so a
|
|
24
|
+
# default would expire every URL the process ever mints at one second, and
|
|
25
|
+
# it would skip the validation the keywords run. +expires_in+ below is the
|
|
26
|
+
# default that makes sense.
|
|
27
|
+
EXPIRY_KEYS = (Options::REQUEST_KEYS + Options::REQUEST_KEYS.filter_map { |key| Options::ALIASES[key] })
|
|
28
|
+
.uniq.freeze
|
|
29
|
+
OPTION_KEYS = (([ :raw ] + Options::KEYS + Options::ALIASES.values).uniq - EXPIRY_KEYS).freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :endpoint, :key, :salt, :default_options, :expires_in
|
|
24
32
|
attr_accessor :unsigned
|
|
25
33
|
|
|
26
34
|
def initialize
|
|
@@ -29,6 +37,18 @@ module Audioproxy
|
|
|
29
37
|
@salt = nil
|
|
30
38
|
@unsigned = false
|
|
31
39
|
@default_options = {}
|
|
40
|
+
@expires_in = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# How long a URL stays valid, applied to every URL this process builds.
|
|
44
|
+
# nil — the default — means URLs carry no expiry and remain the eternal
|
|
45
|
+
# bearer capabilities they have always been.
|
|
46
|
+
#
|
|
47
|
+
# A duration rather than an instant, and validated here so a typo fails at
|
|
48
|
+
# boot rather than in a mailer. A call site overrides it with its own
|
|
49
|
+
# +expires_in:+/+expires_at:+, or opts one URL out with +expires_in: nil+.
|
|
50
|
+
def expires_in=(value)
|
|
51
|
+
@expires_in = value.nil? ? nil : Expiry.seconds(value, source: "config expires_in")
|
|
32
52
|
end
|
|
33
53
|
|
|
34
54
|
# Full base URL of the proxy: scheme + host, optionally with a path prefix
|
|
@@ -128,6 +148,15 @@ module Audioproxy
|
|
|
128
148
|
|
|
129
149
|
normalized = value.symbolize_keys
|
|
130
150
|
|
|
151
|
+
# Before the generic unknown-key message, which would report exp as a
|
|
152
|
+
# typo when it is a real option reached a different way (D1).
|
|
153
|
+
unless (expiry = normalized.keys & EXPIRY_KEYS).empty?
|
|
154
|
+
raise ArgumentError,
|
|
155
|
+
"Audioproxy config default_options must not carry #{expiry.first.inspect}: it is an " \
|
|
156
|
+
"absolute instant, so every URL this process builds would expire at the same second. " \
|
|
157
|
+
"Set config.expires_in to a duration instead, or pass expires_in:/expires_at: per call"
|
|
158
|
+
end
|
|
159
|
+
|
|
131
160
|
# Not assert_valid_keys: its message lists the aliases among the valid
|
|
132
161
|
# keys without saying they are aliases, so a caller who guessed
|
|
133
162
|
# bit_rate: sees :bitrate in a flat list and cannot tell the two
|
|
@@ -135,7 +164,7 @@ module Audioproxy
|
|
|
135
164
|
unless (unknown = normalized.keys - OPTION_KEYS).empty?
|
|
136
165
|
raise ArgumentError,
|
|
137
166
|
"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 " \
|
|
167
|
+
"#{([ :raw ] + Options::KEYS - EXPIRY_KEYS).join(", ")}, each also accepted as its spelled-out alias " \
|
|
139
168
|
"(#{Options::ALIASES[:br]}, #{Options::ALIASES[:sr]}, #{Options::ALIASES[:pk_fmt]}, …)"
|
|
140
169
|
end
|
|
141
170
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
require "active_support/duration"
|
|
3
|
+
require "audioproxy/options"
|
|
4
|
+
|
|
5
|
+
module Audioproxy
|
|
6
|
+
# Turns the two Rails-shaped expiry spellings — +expires_in: 1.hour+ and
|
|
7
|
+
# +expires_at: some_time+ — into the single Integer of unix seconds the
|
|
8
|
+
# proxy's +exp+ option takes.
|
|
9
|
+
#
|
|
10
|
+
# Every method here raises rather than coercing. The proxy answers a URL whose
|
|
11
|
+
# +exp+ has passed with 410 and one out of bounds with 422, both at request
|
|
12
|
+
# time and neither anywhere near the call that built it, so an input this
|
|
13
|
+
# module cannot read exactly is an error at the call site (D5).
|
|
14
|
+
module Expiry
|
|
15
|
+
# Sentinel for "this keyword was not passed". nil cannot do the job: it is
|
|
16
|
+
# the documented per-call opt-out from a configured default (D6).
|
|
17
|
+
UNSET = Object.new.freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# The +exp+ value for one URL, or nil for no expiry at all. +now+ is
|
|
21
|
+
# supplied by the caller and read once per URL, so the arithmetic below
|
|
22
|
+
# and the past-check cannot straddle a second boundary (D4).
|
|
23
|
+
def timestamp(expires_in:, expires_at:, default_expires_in:, now:)
|
|
24
|
+
if given?(expires_in) && given?(expires_at)
|
|
25
|
+
raise ArgumentError,
|
|
26
|
+
"Audioproxy url_for takes either expires_in: or expires_at:, not both; " \
|
|
27
|
+
"expires_in: is a window from now, expires_at: is the instant itself"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
return at(expires_at, now: now) if given?(expires_at)
|
|
31
|
+
return within(expires_in, now: now, source: "url_for expires_in:") if given?(expires_in)
|
|
32
|
+
return nil if default_expires_in.nil?
|
|
33
|
+
|
|
34
|
+
# Already validated at assignment, so this is arithmetic and nothing
|
|
35
|
+
# else — but it still goes through the bound check that `within` runs.
|
|
36
|
+
within(default_expires_in, now: now, source: "config expires_in")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A window of whole positive seconds, as an Integer. Shared by the keyword
|
|
40
|
+
# and by Config#expires_in= so that a value accepted at boot is exactly a
|
|
41
|
+
# value accepted per call.
|
|
42
|
+
def seconds(value, source:)
|
|
43
|
+
case value
|
|
44
|
+
# #value is the exact number of seconds the Duration stands for (3600
|
|
45
|
+
# for 1.hour), and it stays an Integer where the caller wrote one, so a
|
|
46
|
+
# far-future window does not lose digits to a double on the way past.
|
|
47
|
+
when ActiveSupport::Duration then whole_seconds(value, value.value, source: source)
|
|
48
|
+
when Integer then value
|
|
49
|
+
else
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"Audioproxy #{source} must be an ActiveSupport::Duration or an Integer of seconds, " \
|
|
52
|
+
"got #{value.inspect}"
|
|
53
|
+
end.tap do |seconds|
|
|
54
|
+
unless seconds.positive?
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"Audioproxy #{source} must be a positive number of seconds, got #{value.inspect}; " \
|
|
57
|
+
"a window of #{seconds} mints a URL that is already expired"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
def given?(value)
|
|
64
|
+
!value.equal?(UNSET)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def within(value, now:, source:)
|
|
68
|
+
return nil if value.nil?
|
|
69
|
+
|
|
70
|
+
window = seconds(value, source: source)
|
|
71
|
+
|
|
72
|
+
bounded(now + window, source: source, window: window)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def at(value, now:)
|
|
76
|
+
return nil if value.nil?
|
|
77
|
+
|
|
78
|
+
seconds = bounded(unix_seconds(value), source: "url_for expires_at:")
|
|
79
|
+
|
|
80
|
+
# The proxy's own check is `now > exp`, so the second exp names is
|
|
81
|
+
# still served and only a strictly-past value is dead on arrival.
|
|
82
|
+
# Refusing equality too costs one second and makes the rule statable
|
|
83
|
+
# as "expires_at: must be in the future" (D5).
|
|
84
|
+
unless seconds > now
|
|
85
|
+
raise ArgumentError,
|
|
86
|
+
"Audioproxy url_for expires_at: must be in the future, got #{value.inspect} " \
|
|
87
|
+
"(#{seconds}, and it is now #{now}); the proxy would answer that URL with 410"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
seconds
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Time, DateTime and ActiveSupport::TimeWithZone, or an Integer already
|
|
94
|
+
# in unix seconds.
|
|
95
|
+
#
|
|
96
|
+
# is_a? rather than case/when, and the reason is narrower than the one
|
|
97
|
+
# Options gives for Duration. TimeWithZone overrides is_a? to answer
|
|
98
|
+
# truthfully about what it stands for, and Module#=== ignores that
|
|
99
|
+
# override — but ActiveSupport patches Time.=== to cover the gap
|
|
100
|
+
# (core_ext/time/calculations.rb), so under full Rails a case/when Time
|
|
101
|
+
# matches a TimeWithZone after all.
|
|
102
|
+
#
|
|
103
|
+
# It matches only where that core_ext is loaded, and this file does not
|
|
104
|
+
# require it: after a bare `require "audioproxy"`, Time.=== is still
|
|
105
|
+
# Module's. Since url_for is documented as usable with no Rails loaded,
|
|
106
|
+
# case/when here would accept a TimeWithZone in an app and reject it in
|
|
107
|
+
# a plain Ruby script. is_a? is TimeWithZone's own override and needs
|
|
108
|
+
# nothing else loaded, so it answers the same in both.
|
|
109
|
+
#
|
|
110
|
+
# Date is not on the list. Date#to_time is local midnight, so the same
|
|
111
|
+
# `expires_at: Date.tomorrow` means a different instant on every machine
|
|
112
|
+
# that renders it.
|
|
113
|
+
def unix_seconds(value)
|
|
114
|
+
return value if value.is_a?(Integer)
|
|
115
|
+
|
|
116
|
+
unless value.is_a?(Time) || value.is_a?(DateTime)
|
|
117
|
+
raise ArgumentError,
|
|
118
|
+
"Audioproxy url_for expires_at: must be a Time, DateTime, " \
|
|
119
|
+
"ActiveSupport::TimeWithZone or an Integer of unix seconds, got #{value.class}" \
|
|
120
|
+
"#{" (a Date is local midnight, which is a different instant per machine; pass a Time)" if value.is_a?(Date)}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Truncates sub-second, which moves the expiry at most one second
|
|
124
|
+
# earlier — the fail-safe direction.
|
|
125
|
+
value.to_time.to_i
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The proxy caps exp and 422s past it, which is where the millisecond
|
|
129
|
+
# typo (`some_time.to_i * 1000`) lands: a clean-looking URL that never
|
|
130
|
+
# works.
|
|
131
|
+
#
|
|
132
|
+
# +window+ is set only on the expires_in: path, where the caller wrote a
|
|
133
|
+
# duration rather than a timestamp — advising them about milliseconds
|
|
134
|
+
# there would name a mistake they did not make.
|
|
135
|
+
def bounded(seconds, source:, window: nil)
|
|
136
|
+
if seconds > Options::MAX_EXPIRES_AT
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"Audioproxy #{source} produced #{seconds}, past the proxy's limit of " \
|
|
139
|
+
"#{Options::MAX_EXPIRES_AT} (9999-12-31T23:59:59Z); " \
|
|
140
|
+
"#{window ? "a window of #{window} seconds reaches past the year 9999" : "unix seconds, not milliseconds"}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
seconds
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A Duration whose total is not a whole number of seconds has no exact
|
|
147
|
+
# rendering here, and truncating one silently is the class of thing this
|
|
148
|
+
# gem raises about everywhere else — see Options.format_decimal.
|
|
149
|
+
def whole_seconds(value, total, source:)
|
|
150
|
+
unless total == total.to_i
|
|
151
|
+
raise ArgumentError,
|
|
152
|
+
"Audioproxy #{source} must be a whole number of seconds, got #{value.inspect} " \
|
|
153
|
+
"(#{total}); round explicitly at the call site if that is what you mean"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
total.to_i
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/audioproxy/options.rb
CHANGED
|
@@ -11,15 +11,24 @@ module Audioproxy
|
|
|
11
11
|
# carrying a separator — because a mangled segment is a valid-looking URL for
|
|
12
12
|
# the wrong variant, and it fails at request time, far from here.
|
|
13
13
|
module Options
|
|
14
|
-
# The proxy's
|
|
15
|
-
KEYS = %i[bd br cb ch dl f fade gain norm pk_fmt pts q sr t].freeze
|
|
14
|
+
# The proxy's sixteen option keys, canonical short spellings.
|
|
15
|
+
KEYS = %i[bd br cb ch dl exp f fade gain norm pk_bits pk_fmt pts q sr t].freeze
|
|
16
|
+
|
|
17
|
+
# The one *request* option in the grammar: signed as path bytes, but
|
|
18
|
+
# excluded from the proxy's canonical options string, its cache key and its
|
|
19
|
+
# ffmpeg args, so two URLs differing only here are one variant and one
|
|
20
|
+
# render. Nothing in this module treats it specially beyond D1b below; the
|
|
21
|
+
# distinction is the proxy's, and it is recorded here because it is the
|
|
22
|
+
# reason exp may be appended to a raw: string that variant options may not.
|
|
23
|
+
REQUEST_KEYS = %i[exp].freeze
|
|
16
24
|
|
|
17
25
|
# A spelled-out spelling for each canonical key, for call sites that would
|
|
18
26
|
# rather read than decode. Total over KEYS, so "does this key have an alias"
|
|
19
27
|
# never has two answers: +fade+ and +gain+ are already words and alias to
|
|
20
28
|
# themselves. The names are the proxy's own where it has one — its Options
|
|
21
|
-
# struct calls pts +peak_count
|
|
22
|
-
# vocabulary spelled twice, not a second
|
|
29
|
+
# struct calls pts +peak_count+, pk_fmt +peak_format+ and pk_bits
|
|
30
|
+
# +peak_bits+ — so this is one vocabulary spelled twice, not a second
|
|
31
|
+
# vocabulary (D2). peak_bits is deliberately not bit_depth, which bd owns.
|
|
23
32
|
ALIASES = {
|
|
24
33
|
f: :format,
|
|
25
34
|
br: :bitrate,
|
|
@@ -33,8 +42,10 @@ module Audioproxy
|
|
|
33
42
|
norm: :normalize,
|
|
34
43
|
pts: :peak_count,
|
|
35
44
|
pk_fmt: :peak_format,
|
|
45
|
+
pk_bits: :peak_bits,
|
|
36
46
|
dl: :download,
|
|
37
|
-
cb: :cache_buster
|
|
47
|
+
cb: :cache_buster,
|
|
48
|
+
exp: :expires_at
|
|
38
49
|
}.freeze
|
|
39
50
|
|
|
40
51
|
# Every accepted spelling to the canonical key it renders as. Canonical
|
|
@@ -44,6 +55,22 @@ module Audioproxy
|
|
|
44
55
|
.merge(KEYS.to_h { |key| [ key, key ] })
|
|
45
56
|
.freeze
|
|
46
57
|
|
|
58
|
+
# The keys that reach the proxy's peaks renderer (API v1 §3.3): the peaks
|
|
59
|
+
# keys themselves, and the ones peaks follow so a waveform matches the
|
|
60
|
+
# audio drawn under it. A positive list on purpose (D4): an ignored option
|
|
61
|
+
# still enters the proxy's cache key, so one that slips through buys a
|
|
62
|
+
# second render of byte-identical peaks, and a key added to the proxy later
|
|
63
|
+
# is refused here until someone checks it belongs.
|
|
64
|
+
PEAKS_KEYS = %i[pts pk_fmt pk_bits ch t fade gain norm dl cb].freeze
|
|
65
|
+
|
|
66
|
+
# The configured defaults that carry over to peaks (D10): the ones that
|
|
67
|
+
# change the samples, so a waveform matches the audio drawn above it, and
|
|
68
|
+
# the cache buster. Narrower than PEAKS_KEYS because a default was written
|
|
69
|
+
# for audio: ch:2 there means stereo output, but on peaks it means
|
|
70
|
+
# per-channel pairs instead of the mono downmix, and a dl: filename for the
|
|
71
|
+
# audio is never the right name for peaks JSON.
|
|
72
|
+
PEAKS_DEFAULT_KEYS = %i[t fade gain norm cb].freeze
|
|
73
|
+
|
|
47
74
|
# Keys whose grammar takes colon-separated parts: +t:START[:DURATION]+,
|
|
48
75
|
# +fade:IN[:OUT]+, +norm:ebu[:I[:TP[:LRA]]]+.
|
|
49
76
|
MULTI_PART_KEYS = %i[t fade norm].freeze
|
|
@@ -60,6 +87,13 @@ module Audioproxy
|
|
|
60
87
|
# normalized options string into its cache key.
|
|
61
88
|
MAX_DECIMALS = 3
|
|
62
89
|
|
|
90
|
+
# The proxy's own bound on exp (@max_expires_at in its Options module):
|
|
91
|
+
# 9999-12-31T23:59:59Z. Past it, the proxy answers 422 invalid-option. The
|
|
92
|
+
# grammar fact lives here; UrlBuilder is what checks a caller's value
|
|
93
|
+
# against it, because that is where an out-of-range timestamp can still be
|
|
94
|
+
# reported against the keyword the caller actually wrote.
|
|
95
|
+
MAX_EXPIRES_AT = 253_402_300_799
|
|
96
|
+
|
|
63
97
|
# Characters a rendered value may not carry. The builder supplies '/' and
|
|
64
98
|
# ':', so a value containing either silently invents a segment or a part.
|
|
65
99
|
# '?' and '#' end the path as far as a browser is concerned, which truncates
|
|
@@ -186,6 +220,7 @@ module Audioproxy
|
|
|
186
220
|
# Without this, t: 30.seconds is rejected as "not a number" by
|
|
187
221
|
# something that says it is one.
|
|
188
222
|
return render_duration(key, value) if value.is_a?(ActiveSupport::Duration)
|
|
223
|
+
return render_expiry(value) if key == :exp
|
|
189
224
|
return format_number(value) unless OPAQUE_KEYS.include?(key)
|
|
190
225
|
|
|
191
226
|
case value
|
|
@@ -196,6 +231,23 @@ module Audioproxy
|
|
|
196
231
|
end
|
|
197
232
|
end
|
|
198
233
|
|
|
234
|
+
# exp is unix seconds, and the proxy's grammar has no decimal there:
|
|
235
|
+
# exp:1.5 parses as an invalid option and 422s. So this is one of the
|
|
236
|
+
# values the module cannot render faithfully, and it raises rather than
|
|
237
|
+
# emitting a segment that looks like a timestamp and is not one (D1b).
|
|
238
|
+
# Integer only — not a numeric String, because the two inputs that reach
|
|
239
|
+
# this key in practice are Time#to_i and an arithmetic result, and both
|
|
240
|
+
# are Integers.
|
|
241
|
+
def render_expiry(value)
|
|
242
|
+
unless value.is_a?(Integer)
|
|
243
|
+
raise ArgumentError,
|
|
244
|
+
"Audioproxy option exp: must be an Integer of unix seconds, got #{value.inspect}; " \
|
|
245
|
+
"pass expires_in: or expires_at: to url_for and let it do the arithmetic"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
value.to_s
|
|
249
|
+
end
|
|
250
|
+
|
|
199
251
|
# A Duration is the Rails spelling of a number of seconds, so it is
|
|
200
252
|
# accepted where the value *is* seconds and refused everywhere else:
|
|
201
253
|
# br: 3.seconds rendering br:3 would be a valid-looking URL for the
|
|
@@ -11,6 +11,18 @@ module Audioproxy
|
|
|
11
11
|
Audioproxy.url_for(source, **options)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# URL helpers only, with no tag counterparts on purpose (D7): info is
|
|
15
|
+
# JSON and peaks are JSON or a binary .dat, both fetched by a script, so
|
|
16
|
+
# there is no element to hand them to. A <div data-peaks-url> convention
|
|
17
|
+
# would make this gem the keeper of some JavaScript library's markup.
|
|
18
|
+
def audioproxy_info_url(source, **options)
|
|
19
|
+
Audioproxy.info_url(source, **options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def audioproxy_peaks_url(source, **options)
|
|
23
|
+
Audioproxy.peaks_url(source, **options)
|
|
24
|
+
end
|
|
25
|
+
|
|
14
26
|
# The html: bucket is the seam between proxy options and tag attributes
|
|
15
27
|
# (D4). Without it, proxy option keys and HTML attribute names share one
|
|
16
28
|
# namespace, and a typoed option lands silently on the <audio> element
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "base64"
|
|
2
2
|
require "active_support/core_ext/object/blank"
|
|
3
|
+
require "audioproxy/expiry"
|
|
3
4
|
|
|
4
5
|
module Audioproxy
|
|
5
6
|
# Assembles +{endpoint}/{signature}/{options}/{source}+ URLs, byte-compatible
|
|
@@ -18,6 +19,11 @@ module Audioproxy
|
|
|
18
19
|
# always-valid options string is its default format spelled out.
|
|
19
20
|
FALLBACK_OPTIONS = "f:mp3".freeze
|
|
20
21
|
|
|
22
|
+
INFO_SEGMENT = "info".freeze
|
|
23
|
+
|
|
24
|
+
# Builder keywords that carry an expiry, which /info cannot hold (D9).
|
|
25
|
+
EXPIRY_KEYWORDS = %i[expires_in expires_at].freeze
|
|
26
|
+
|
|
21
27
|
attr_reader :config
|
|
22
28
|
|
|
23
29
|
def initialize(config = Audioproxy.config)
|
|
@@ -29,14 +35,38 @@ module Audioproxy
|
|
|
29
35
|
# spelled-out alias (+format:+, +bitrate:+, +trim:+ …), resolved to the
|
|
30
36
|
# canonical key before anything is rendered. A keyword that is neither a
|
|
31
37
|
# builder option nor a proxy option key raises rather than being ignored.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
#
|
|
39
|
+
# +expires_in:+ (a duration or Integer seconds from now) and +expires_at:+
|
|
40
|
+
# (the instant itself) are mutually exclusive and time-box this one URL,
|
|
41
|
+
# overriding +config.expires_in+; either given as nil opts out of it (D6).
|
|
42
|
+
def url_for(source, raw: nil, endpoint: nil, unsigned: nil,
|
|
43
|
+
expires_in: Expiry::UNSET, expires_at: Expiry::UNSET, **typed)
|
|
44
|
+
assemble(source, endpoint: endpoint, unsigned: unsigned) do
|
|
45
|
+
expiry = expiry_for(expires_in, expires_at)
|
|
46
|
+
with_expiry(variant_segment(raw, typed), expiry)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The proxy's probe-metadata endpoint, +/{sig}/info/{source}+. API v1 §4:
|
|
51
|
+
# any options segment alongside +info+ is a 422, and the grammar therefore
|
|
52
|
+
# cannot carry +exp+ either. So neither +config.default_options+ (D2) nor
|
|
53
|
+
# +config.expires_in+ (D9) is consulted here — the one entry point that
|
|
54
|
+
# ignores them, because honouring them would break every info request.
|
|
55
|
+
def info_url(source, endpoint: nil, unsigned: nil, **options)
|
|
56
|
+
reject_info_options!(options)
|
|
35
57
|
|
|
36
|
-
|
|
37
|
-
|
|
58
|
+
assemble(source, endpoint: endpoint, unsigned: unsigned) { INFO_SEGMENT }
|
|
59
|
+
end
|
|
38
60
|
|
|
39
|
-
|
|
61
|
+
# A variant URL with the format fixed to +f:peaks+. Peaks are a format, not
|
|
62
|
+
# an endpoint, so this is url_for's grammar with a screened vocabulary: only
|
|
63
|
+
# Options::PEAKS_KEYS pass, per call and from the defaults (D4, D10).
|
|
64
|
+
def peaks_url(source, raw: nil, endpoint: nil, unsigned: nil,
|
|
65
|
+
expires_in: Expiry::UNSET, expires_at: Expiry::UNSET, **typed)
|
|
66
|
+
assemble(source, endpoint: endpoint, unsigned: unsigned) do
|
|
67
|
+
expiry = expiry_for(expires_in, expires_at)
|
|
68
|
+
with_expiry(peaks_segment(raw, typed), expiry)
|
|
69
|
+
end
|
|
40
70
|
end
|
|
41
71
|
|
|
42
72
|
# Signs via Audioproxy::Signer. Whether the config *can* sign is this
|
|
@@ -51,11 +81,32 @@ module Audioproxy
|
|
|
51
81
|
end
|
|
52
82
|
|
|
53
83
|
private
|
|
84
|
+
# The block yields the segment between signature and source, and runs
|
|
85
|
+
# after the endpoint check, so a missing endpoint is reported first.
|
|
86
|
+
def assemble(source, endpoint:, unsigned:)
|
|
87
|
+
base = endpoint.nil? ? config.endpoint : Config.new.tap { |c| c.endpoint = endpoint }.endpoint
|
|
88
|
+
raise ConfigurationError, "Audioproxy has no endpoint configured" if base.nil?
|
|
89
|
+
|
|
90
|
+
rest_of_path = "/#{yield}/#{source_segment(source)}"
|
|
91
|
+
insecure = unsigned.nil? ? config.unsigned : unsigned
|
|
92
|
+
|
|
93
|
+
"#{base}/#{insecure ? INSECURE_SEGMENT : sign(rest_of_path)}#{rest_of_path}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Once per URL, so the window arithmetic and the past-check cannot
|
|
97
|
+
# straddle a second boundary (D4).
|
|
98
|
+
def expiry_for(expires_in, expires_at)
|
|
99
|
+
Expiry.timestamp(
|
|
100
|
+
expires_in: expires_in, expires_at: expires_at,
|
|
101
|
+
default_expires_in: config.expires_in, now: Time.now.to_i
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
54
105
|
# Precedence, per D4: an explicit per-call source of options replaces the
|
|
55
106
|
# configured defaults entirely; typed per-call keys merge over typed
|
|
56
107
|
# defaults key-by-key, keeping the defaults' position and appending the
|
|
57
108
|
# rest in caller order.
|
|
58
|
-
def
|
|
109
|
+
def variant_segment(raw, typed)
|
|
59
110
|
unless raw.nil? || typed.empty?
|
|
60
111
|
raise ArgumentError,
|
|
61
112
|
"Audioproxy url_for takes either raw: or typed option keys, not both " \
|
|
@@ -69,6 +120,7 @@ module Audioproxy
|
|
|
69
120
|
# overrides rather than two segments that both render (D3). Defaults
|
|
70
121
|
# were resolved at assignment.
|
|
71
122
|
typed = Options.resolve(typed)
|
|
123
|
+
reject_expiry_option!(typed)
|
|
72
124
|
|
|
73
125
|
defaults = config.default_options
|
|
74
126
|
typed_defaults = defaults.except(:raw)
|
|
@@ -80,6 +132,96 @@ module Audioproxy
|
|
|
80
132
|
FALLBACK_OPTIONS
|
|
81
133
|
end
|
|
82
134
|
|
|
135
|
+
# Screened on the keys as written, so an error echoes the spelling the
|
|
136
|
+
# caller typed rather than a canonical key they never saw.
|
|
137
|
+
def peaks_segment(raw, typed)
|
|
138
|
+
unless raw.nil?
|
|
139
|
+
raise ArgumentError,
|
|
140
|
+
"Audioproxy peaks_url does not take raw:, because a pre-rendered string cannot be checked " \
|
|
141
|
+
"against the options peaks accept; use url_for(source, raw: \"f:peaks/…\") to write it whole"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
written = typed.keys
|
|
145
|
+
typed = Options.resolve(typed)
|
|
146
|
+
reject_expiry_option!(typed, "peaks_url")
|
|
147
|
+
|
|
148
|
+
if typed.key?(:f)
|
|
149
|
+
format = typed.delete(:f)
|
|
150
|
+
unless format == :peaks || format == "peaks"
|
|
151
|
+
key = written.find { |spelling| Options::CANONICAL[spelling.to_sym] == :f }
|
|
152
|
+
raise ArgumentError,
|
|
153
|
+
"Audioproxy peaks_url fixes the format to peaks, but was also given #{key}: #{format.inspect}; " \
|
|
154
|
+
"use url_for for any other format"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
refused = written.reject { |key| [ :f, *Options::PEAKS_KEYS ].include?(Options::CANONICAL[key.to_sym]) }
|
|
159
|
+
unless refused.empty?
|
|
160
|
+
raise ArgumentError,
|
|
161
|
+
"Audioproxy peaks_url does not take #{refused.join(", ")}; peaks accept only " \
|
|
162
|
+
"#{Options::PEAKS_KEYS.join(", ")}, each also accepted spelled out. Any other option still " \
|
|
163
|
+
"enters the proxy's cache key, so it would buy a second render of identical peaks, or a 422"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Defaults were written for audio variants, so only the ones that mean
|
|
167
|
+
# the same thing for peaks carry over; f:opus or br:96 beside f:peaks is
|
|
168
|
+
# a 422 (D10). f:peaks leads regardless, so a redundant format: cannot
|
|
169
|
+
# move it (D3). select, not slice: slice reorders by its arguments, and
|
|
170
|
+
# defaults keep the order they were written in, as they do for url_for.
|
|
171
|
+
defaults = config.default_options.select { |key, _| Options::PEAKS_DEFAULT_KEYS.include?(key) }
|
|
172
|
+
Options.render({ f: :peaks }.merge(defaults).merge(typed))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def reject_info_options!(options)
|
|
176
|
+
expiry = options.slice(*EXPIRY_KEYWORDS).compact
|
|
177
|
+
unless expiry.empty?
|
|
178
|
+
raise ArgumentError,
|
|
179
|
+
"Audioproxy info_url cannot carry an expiry (got #{expiry.keys.join(" and ")}:): the proxy's " \
|
|
180
|
+
"/info has no options segment, so there is nowhere to put exp. Info URLs do not expire"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
rest = options.except(*EXPIRY_KEYWORDS)
|
|
184
|
+
return if rest.empty?
|
|
185
|
+
|
|
186
|
+
raise ArgumentError,
|
|
187
|
+
"Audioproxy info_url takes no proxy options (got #{rest.keys.join(", ")}): the proxy answers " \
|
|
188
|
+
"any options segment alongside /info with a 422. Use url_for or peaks_url to describe a variant"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# exp is a *request* option: signed as path bytes, but excluded from the
|
|
192
|
+
# proxy's cache key, so it is orthogonal to the variant options that
|
|
193
|
+
# raw: and the typed keys describe and composes with either (D3). It goes
|
|
194
|
+
# last, which leaves the variant prefix byte-identical to the same call
|
|
195
|
+
# without an expiry.
|
|
196
|
+
def with_expiry(segment, expiry)
|
|
197
|
+
return segment if expiry.nil?
|
|
198
|
+
|
|
199
|
+
# A raw: string that already spells out exp: would give the proxy two of
|
|
200
|
+
# them and a duplicate-option 422 — a failure the caller never wrote if
|
|
201
|
+
# the second one came from config.expires_in.
|
|
202
|
+
prefixes = Options::REQUEST_KEYS.map { |key| "#{key}:" }
|
|
203
|
+
if segment.split("/").any? { |part| prefixes.any? { |prefix| part.start_with?(prefix) } }
|
|
204
|
+
raise ArgumentError,
|
|
205
|
+
"Audioproxy url_for was given raw options that already carry an exp: segment " \
|
|
206
|
+
"(#{segment.inspect}) and an expiry to apply; the proxy rejects a duplicated option. " \
|
|
207
|
+
"Drop the exp: from raw:, or pass expires_in: nil to opt this URL out"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
"#{segment}/#{Options.segment(:exp, expiry)}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# exp: written as an ordinary option skips every check in Expiry, and the
|
|
214
|
+
# two mistakes it invites — a timestamp already past, a millisecond
|
|
215
|
+
# timestamp — both render a URL that looks right and never works (D1).
|
|
216
|
+
def reject_expiry_option!(typed, entry = "url_for")
|
|
217
|
+
return unless typed.key?(:exp)
|
|
218
|
+
|
|
219
|
+
raise ArgumentError,
|
|
220
|
+
"Audioproxy #{entry} does not take exp: as an option key; " \
|
|
221
|
+
"pass expires_in: (a duration from now) or expires_at: (the instant), " \
|
|
222
|
+
"which validate the value and do the arithmetic"
|
|
223
|
+
end
|
|
224
|
+
|
|
83
225
|
def raw_segment(raw)
|
|
84
226
|
segment = String.try_convert(raw)
|
|
85
227
|
if segment.nil?
|
data/lib/audioproxy/version.rb
CHANGED
data/lib/audioproxy.rb
CHANGED
|
@@ -19,12 +19,23 @@ module Audioproxy
|
|
|
19
19
|
config
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
#
|
|
23
|
-
#
|
|
22
|
+
# Usable from jobs, mailers and serializers of any Ruby program, Rails or
|
|
23
|
+
# not, as are the two below.
|
|
24
24
|
def url_for(source, **options)
|
|
25
25
|
UrlBuilder.new(config).url_for(source, **options)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Probe metadata as JSON. Takes no proxy options and ignores the configured
|
|
29
|
+
# defaults and expiry, because /info can carry neither.
|
|
30
|
+
def info_url(source, **options)
|
|
31
|
+
UrlBuilder.new(config).info_url(source, **options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Waveform peaks: f:peaks fixed, and only the options peaks read.
|
|
35
|
+
def peaks_url(source, **options)
|
|
36
|
+
UrlBuilder.new(config).peaks_url(source, **options)
|
|
37
|
+
end
|
|
38
|
+
|
|
28
39
|
# Anything that is not already a source String is handed to this resolver.
|
|
29
40
|
# The Rails layer registers one for ActiveStorage objects; the core stays
|
|
30
41
|
# ignorant of what a blob is, which is what keeps +url_for+ usable with no
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: audioproxy-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Julian Rubisch
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-09-28 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: base64
|
|
@@ -70,6 +70,7 @@ files:
|
|
|
70
70
|
- README.md
|
|
71
71
|
- lib/audioproxy.rb
|
|
72
72
|
- lib/audioproxy/config.rb
|
|
73
|
+
- lib/audioproxy/expiry.rb
|
|
73
74
|
- lib/audioproxy/options.rb
|
|
74
75
|
- lib/audioproxy/rails.rb
|
|
75
76
|
- lib/audioproxy/rails/blob_resolver.rb
|