audioproxy-rails 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7ae5b13fb6a5adc46eb6e975cdc682cf27244c316624891072a31f2447a1257
4
- data.tar.gz: fe648c6c0cc0552d30dfd4cdcdf7878ebf68fdb7961c7363b6bf2b62725c50bf
3
+ metadata.gz: e2c006e53f916a943195a3887a184f3fa141e086227ecc03919199a335f32df7
4
+ data.tar.gz: 110cc6e5eb81d7fb34d3688b2c56953d822792157a27e9669d1f25c17b86d1e1
5
5
  SHA512:
6
- metadata.gz: 2c46a5ef917dca8990e4d565cd14b283a0c0592b36ca95e4cd6e81cb4fe06d2bd3ac823353e22efb5486a0d41749f0631c32faf45f4fef18b3866dba182fedfa
7
- data.tar.gz: 878b41e27ab71a8b4e69cc8d6d0f27c1a5d79e011307508f32d8a589e99397710a2bbca8fa71df979d42c09560690914fd5f8f188332ebea096a1c03c7402097
6
+ metadata.gz: 8c506590e9344488d0cc4222aec06ae92315628e685526fd8a7b2c1ab7692997dadba3b84294d7bcf1f1c4d653c5b6b8320e695c88ce86d571a83d0e5561ab4b
7
+ data.tar.gz: a1a54ccb83749af4b65339ba42d29c11ae76d1cc914376461388dcf70707febf0771159df327b54b26ad00692371b5980829e8b7031f1077235ab31546168dff
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ * Expiring URLs. `url_for` and every view helper accept `expires_in:` (a duration or Integer
6
+ seconds from now) and `expires_at:` (the instant itself), mutually exclusive, rendering the
7
+ proxy's `exp:` option. `config.expires_in` sets a global default; a per-call `expires_in: nil`
8
+ opts one URL out of it.
9
+
10
+ Because `exp` is a request option on the proxy rather than a variant option, it is signed but
11
+ excluded from the cache key: minting a fresh short-lived URL on every render costs no extra
12
+ render and no extra cached variant at the origin.
13
+
14
+ Every input that would produce a valid-looking URL the proxy refuses raises at the call site
15
+ instead: both keywords together, a non-positive window, an `expires_at` at or before now, a
16
+ fractional duration, a millisecond timestamp, a `Date`, and `exp:` written as a plain option key
17
+ or in `default_options`.
18
+
19
+ Requires audioproxy 0.6.0 or newer, the release that added the `exp` option. Older proxies
20
+ answer `exp:` with a `422`. Every other feature in this gem still works against 0.5.0.
21
+
22
+ * `Audioproxy::Signer` is unchanged, and so is the isolation test that pins its extraction seam:
23
+ `exp` is ordinary path bytes to the signer.
24
+
3
25
  ## 0.1.0
4
26
 
5
27
  First release.
data/README.md CHANGED
@@ -12,6 +12,8 @@ Full Rails is a development dependency only. `require "audioproxy"` works in a p
12
12
 
13
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
14
 
15
+ [Expiring URLs](#expiring-urls) work too, against [audioproxy 0.6.0 or newer](#minimum-proxy-version). One caveat worth knowing either way: everything in this gem is verified against the proxy's published signature vectors and its source, but no test here has yet asked a running proxy whether a generated URL is accepted. That round-trip is the next change.
16
+
15
17
  ## Installation
16
18
 
17
19
  ```ruby
@@ -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["AUDIOPROXY_KEY"] # hex string, decoded at assignment
67
- config.salt = ENV["AUDIOPROXY_SALT"] # hex string, decoded at assignment
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
@@ -207,6 +216,66 @@ Audioproxy.url_for("local://a.wav", unsigned: true)
207
216
 
208
217
  `Audioproxy.url_for` is Rails-free: it works in jobs, mailers, serializers, and plain Ruby scripts.
209
218
 
219
+ ## Expiring URLs
220
+
221
+ 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.
222
+
223
+ ```ruby
224
+ Audioproxy.url_for(source, format: "opus", expires_in: 1.hour)
225
+ # => ".../f:opus/exp:1767229200/enc/..."
226
+
227
+ Audioproxy.url_for(source, format: "opus", expires_at: 1.day.from_now)
228
+ ```
229
+
230
+ `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.
231
+
232
+ Every view helper forwards both keywords:
233
+
234
+ ```erb
235
+ <%= audioproxy_audio_tag @recording.audio, format: "opus", expires_in: 30.minutes, html: { controls: true } %>
236
+ ```
237
+
238
+ ### Rotation is free at the origin
239
+
240
+ 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.
241
+
242
+ 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.
243
+
244
+ ### A global default
245
+
246
+ ```ruby
247
+ Audioproxy.configure { |c| c.expires_in = 1.hour }
248
+ ```
249
+
250
+ `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:
251
+
252
+ ```ruby
253
+ Audioproxy.url_for(source, expires_in: 5.minutes) # overrides the default
254
+ Audioproxy.url_for(source, expires_in: nil) # no expiry on this one
255
+ ```
256
+
257
+ 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.
258
+
259
+ ### What raises, and why
260
+
261
+ 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:
262
+
263
+ ```ruby
264
+ Audioproxy.url_for(source, expires_in: 1.hour, expires_at: 1.day.from_now) # both keywords
265
+ Audioproxy.url_for(source, expires_in: 0) # non-positive window
266
+ Audioproxy.url_for(source, expires_at: 1.hour.ago) # already dead
267
+ Audioproxy.url_for(source, expires_in: 1.5.seconds) # not whole seconds
268
+ Audioproxy.url_for(source, expires_at: 1.hour.from_now.to_i * 1000) # milliseconds
269
+ Audioproxy.url_for(source, expires_at: Date.tomorrow) # local midnight, not an instant
270
+ Audioproxy.url_for(source, exp: 1767229200) # use the keywords
271
+ ```
272
+
273
+ 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.
274
+
275
+ ### Minimum proxy version
276
+
277
+ 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, and every other feature here works against `0.5.0` unchanged. This gem does not version-sniff.
278
+
210
279
  ## Rails
211
280
 
212
281
  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.
@@ -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
- OPTION_KEYS = ([ :raw ] + Options::KEYS + Options::ALIASES.values).uniq.freeze
22
-
23
- attr_reader :endpoint, :key, :salt, :default_options
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
@@ -11,8 +11,16 @@ 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 fourteen option keys, canonical short spellings.
15
- KEYS = %i[bd br cb ch dl f fade gain norm pk_fmt pts q sr t].freeze
14
+ # The proxy's fifteen option keys, canonical short spellings.
15
+ KEYS = %i[bd br cb ch dl exp f fade gain norm 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"
@@ -34,7 +42,8 @@ module Audioproxy
34
42
  pts: :peak_count,
35
43
  pk_fmt: :peak_format,
36
44
  dl: :download,
37
- cb: :cache_buster
45
+ cb: :cache_buster,
46
+ exp: :expires_at
38
47
  }.freeze
39
48
 
40
49
  # Every accepted spelling to the canonical key it renders as. Canonical
@@ -60,6 +69,13 @@ module Audioproxy
60
69
  # normalized options string into its cache key.
61
70
  MAX_DECIMALS = 3
62
71
 
72
+ # The proxy's own bound on exp (@max_expires_at in its Options module):
73
+ # 9999-12-31T23:59:59Z. Past it, the proxy answers 422 invalid-option. The
74
+ # grammar fact lives here; UrlBuilder is what checks a caller's value
75
+ # against it, because that is where an out-of-range timestamp can still be
76
+ # reported against the keyword the caller actually wrote.
77
+ MAX_EXPIRES_AT = 253_402_300_799
78
+
63
79
  # Characters a rendered value may not carry. The builder supplies '/' and
64
80
  # ':', so a value containing either silently invents a segment or a part.
65
81
  # '?' and '#' end the path as far as a browser is concerned, which truncates
@@ -186,6 +202,7 @@ module Audioproxy
186
202
  # Without this, t: 30.seconds is rejected as "not a number" by
187
203
  # something that says it is one.
188
204
  return render_duration(key, value) if value.is_a?(ActiveSupport::Duration)
205
+ return render_expiry(value) if key == :exp
189
206
  return format_number(value) unless OPAQUE_KEYS.include?(key)
190
207
 
191
208
  case value
@@ -196,6 +213,23 @@ module Audioproxy
196
213
  end
197
214
  end
198
215
 
216
+ # exp is unix seconds, and the proxy's grammar has no decimal there:
217
+ # exp:1.5 parses as an invalid option and 422s. So this is one of the
218
+ # values the module cannot render faithfully, and it raises rather than
219
+ # emitting a segment that looks like a timestamp and is not one (D1b).
220
+ # Integer only — not a numeric String, because the two inputs that reach
221
+ # this key in practice are Time#to_i and an arithmetic result, and both
222
+ # are Integers.
223
+ def render_expiry(value)
224
+ unless value.is_a?(Integer)
225
+ raise ArgumentError,
226
+ "Audioproxy option exp: must be an Integer of unix seconds, got #{value.inspect}; " \
227
+ "pass expires_in: or expires_at: to url_for and let it do the arithmetic"
228
+ end
229
+
230
+ value.to_s
231
+ end
232
+
199
233
  # A Duration is the Rails spelling of a number of seconds, so it is
200
234
  # accepted where the value *is* seconds and refused everywhere else:
201
235
  # br: 3.seconds rendering br:3 would be a valid-looking URL for the
@@ -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
@@ -29,11 +30,23 @@ module Audioproxy
29
30
  # spelled-out alias (+format:+, +bitrate:+, +trim:+ …), resolved to the
30
31
  # canonical key before anything is rendered. A keyword that is neither a
31
32
  # builder option nor a proxy option key raises rather than being ignored.
32
- def url_for(source, raw: nil, endpoint: nil, unsigned: nil, **typed)
33
+ #
34
+ # +expires_in:+ (a duration or Integer seconds from now) and +expires_at:+
35
+ # (the instant itself) are mutually exclusive and time-box this one URL,
36
+ # overriding +config.expires_in+; either given as nil opts out of it (D6).
37
+ def url_for(source, raw: nil, endpoint: nil, unsigned: nil,
38
+ expires_in: Expiry::UNSET, expires_at: Expiry::UNSET, **typed)
33
39
  base = endpoint.nil? ? config.endpoint : Config.new.tap { |c| c.endpoint = endpoint }.endpoint
34
40
  raise ConfigurationError, "Audioproxy has no endpoint configured" if base.nil?
35
41
 
36
- rest_of_path = "/#{options_segment(raw, typed)}/#{source_segment(source)}"
42
+ # Once per URL, so the window arithmetic and the past-check cannot
43
+ # straddle a second boundary (D4).
44
+ expiry = Expiry.timestamp(
45
+ expires_in: expires_in, expires_at: expires_at,
46
+ default_expires_in: config.expires_in, now: Time.now.to_i
47
+ )
48
+
49
+ rest_of_path = "/#{options_segment(raw, typed, expiry)}/#{source_segment(source)}"
37
50
  insecure = unsigned.nil? ? config.unsigned : unsigned
38
51
 
39
52
  "#{base}/#{insecure ? INSECURE_SEGMENT : sign(rest_of_path)}#{rest_of_path}"
@@ -55,7 +68,11 @@ module Audioproxy
55
68
  # configured defaults entirely; typed per-call keys merge over typed
56
69
  # defaults key-by-key, keeping the defaults' position and appending the
57
70
  # rest in caller order.
58
- def options_segment(raw, typed)
71
+ def options_segment(raw, typed, expiry)
72
+ with_expiry(variant_segment(raw, typed), expiry)
73
+ end
74
+
75
+ def variant_segment(raw, typed)
59
76
  unless raw.nil? || typed.empty?
60
77
  raise ArgumentError,
61
78
  "Audioproxy url_for takes either raw: or typed option keys, not both " \
@@ -69,6 +86,7 @@ module Audioproxy
69
86
  # overrides rather than two segments that both render (D3). Defaults
70
87
  # were resolved at assignment.
71
88
  typed = Options.resolve(typed)
89
+ reject_expiry_option!(typed)
72
90
 
73
91
  defaults = config.default_options
74
92
  typed_defaults = defaults.except(:raw)
@@ -80,6 +98,40 @@ module Audioproxy
80
98
  FALLBACK_OPTIONS
81
99
  end
82
100
 
101
+ # exp is a *request* option: signed as path bytes, but excluded from the
102
+ # proxy's cache key, so it is orthogonal to the variant options that
103
+ # raw: and the typed keys describe and composes with either (D3). It goes
104
+ # last, which leaves the variant prefix byte-identical to the same call
105
+ # without an expiry.
106
+ def with_expiry(segment, expiry)
107
+ return segment if expiry.nil?
108
+
109
+ # A raw: string that already spells out exp: would give the proxy two of
110
+ # them and a duplicate-option 422 — a failure the caller never wrote if
111
+ # the second one came from config.expires_in.
112
+ prefixes = Options::REQUEST_KEYS.map { |key| "#{key}:" }
113
+ if segment.split("/").any? { |part| prefixes.any? { |prefix| part.start_with?(prefix) } }
114
+ raise ArgumentError,
115
+ "Audioproxy url_for was given raw options that already carry an exp: segment " \
116
+ "(#{segment.inspect}) and an expiry to apply; the proxy rejects a duplicated option. " \
117
+ "Drop the exp: from raw:, or pass expires_in: nil to opt this URL out"
118
+ end
119
+
120
+ "#{segment}/#{Options.segment(:exp, expiry)}"
121
+ end
122
+
123
+ # exp: written as an ordinary option skips every check in Expiry, and the
124
+ # two mistakes it invites — a timestamp already past, a millisecond
125
+ # timestamp — both render a URL that looks right and never works (D1).
126
+ def reject_expiry_option!(typed)
127
+ return unless typed.key?(:exp)
128
+
129
+ raise ArgumentError,
130
+ "Audioproxy url_for does not take exp: as an option key; " \
131
+ "pass expires_in: (a duration from now) or expires_at: (the instant), " \
132
+ "which validate the value and do the arithmetic"
133
+ end
134
+
83
135
  def raw_segment(raw)
84
136
  segment = String.try_convert(raw)
85
137
  if segment.nil?
@@ -1,3 +1,3 @@
1
1
  module Audioproxy
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Rubisch
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-08-11 00:00:00.000000000 Z
10
+ date: 2026-08-13 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