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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
module Audioproxy
|
|
5
|
+
# Signature building — the one piece that must stay liftable into a standalone
|
|
6
|
+
# gem. It therefore depends on stdlib and base64 only: no ActiveSupport, no
|
|
7
|
+
# Rails, and nothing else in this gem. Everything it needs arrives through the
|
|
8
|
+
# constructor, so extracting it is a `git mv` plus a gemspec, not a rewrite.
|
|
9
|
+
#
|
|
10
|
+
# Byte contract, mirroring the proxy's reference signer:
|
|
11
|
+
#
|
|
12
|
+
# base64url(HMAC-SHA256(key, salt ‖ rest_of_path)) — unpadded
|
|
13
|
+
#
|
|
14
|
+
# where key and salt are the decoded binary values and +rest_of_path+ is the
|
|
15
|
+
# exact byte sequence after the signature segment, leading "/" included.
|
|
16
|
+
class Signer
|
|
17
|
+
DIGEST = "SHA256".freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :key, :salt
|
|
20
|
+
|
|
21
|
+
def initialize(key:, salt:)
|
|
22
|
+
@key = key
|
|
23
|
+
@salt = salt
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def sign(rest_of_path)
|
|
27
|
+
unless rest_of_path.start_with?("/")
|
|
28
|
+
raise ArgumentError, "rest_of_path must begin with '/' to be verifiable at the proxy, got #{rest_of_path.inspect}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# .b so a non-ASCII path cannot raise Encoding::CompatibilityError against
|
|
32
|
+
# the binary salt.
|
|
33
|
+
digest = OpenSSL::HMAC.digest(DIGEST, key, salt + rest_of_path.b)
|
|
34
|
+
|
|
35
|
+
# Unpadded: the proxy accepts both spellings, but emitting exactly one
|
|
36
|
+
# keeps URLs and CDN cache keys stable.
|
|
37
|
+
Base64.urlsafe_encode64(digest, padding: false)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "active_support/core_ext/object/blank"
|
|
3
|
+
|
|
4
|
+
module Audioproxy
|
|
5
|
+
# Assembles +{endpoint}/{signature}/{options}/{source}+ URLs, byte-compatible
|
|
6
|
+
# with the proxy's reference signer.
|
|
7
|
+
#
|
|
8
|
+
# The signature covers everything after itself (leading +/+ included), so an
|
|
9
|
+
# endpoint path prefix cannot disturb it.
|
|
10
|
+
#
|
|
11
|
+
# This is the Rails-facing half and may use ActiveSupport freely. The HMAC
|
|
12
|
+
# itself lives in Audioproxy::Signer, which may not — see D1.
|
|
13
|
+
class UrlBuilder
|
|
14
|
+
# Literal signature segment the proxy accepts under AP_ALLOW_INSECURE.
|
|
15
|
+
INSECURE_SEGMENT = "insecure".freeze
|
|
16
|
+
|
|
17
|
+
# The proxy's path grammar has no optionless form, so the minimal
|
|
18
|
+
# always-valid options string is its default format spelled out.
|
|
19
|
+
FALLBACK_OPTIONS = "f:mp3".freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :config
|
|
22
|
+
|
|
23
|
+
def initialize(config = Audioproxy.config)
|
|
24
|
+
@config = config
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Typed option keys (+f:+, +br:+, +t:+ …) arrive as keyword arguments and
|
|
28
|
+
# render in the order they were written. Each is also accepted as its
|
|
29
|
+
# spelled-out alias (+format:+, +bitrate:+, +trim:+ …), resolved to the
|
|
30
|
+
# canonical key before anything is rendered. A keyword that is neither a
|
|
31
|
+
# 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
|
+
base = endpoint.nil? ? config.endpoint : Config.new.tap { |c| c.endpoint = endpoint }.endpoint
|
|
34
|
+
raise ConfigurationError, "Audioproxy has no endpoint configured" if base.nil?
|
|
35
|
+
|
|
36
|
+
rest_of_path = "/#{options_segment(raw, typed)}/#{source_segment(source)}"
|
|
37
|
+
insecure = unsigned.nil? ? config.unsigned : unsigned
|
|
38
|
+
|
|
39
|
+
"#{base}/#{insecure ? INSECURE_SEGMENT : sign(rest_of_path)}#{rest_of_path}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Signs via Audioproxy::Signer. Whether the config *can* sign is this
|
|
43
|
+
# class's problem; how the bytes are produced is the signer's.
|
|
44
|
+
def sign(rest_of_path)
|
|
45
|
+
missing = [ (:key unless config.key), (:salt unless config.salt) ].compact
|
|
46
|
+
unless missing.empty?
|
|
47
|
+
raise ConfigurationError, "Audioproxy cannot sign a URL: #{missing.join(" and ")} not configured (set them, or use unsigned: true)"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Signer.new(key: config.key, salt: config.salt).sign(rest_of_path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
# Precedence, per D4: an explicit per-call source of options replaces the
|
|
55
|
+
# configured defaults entirely; typed per-call keys merge over typed
|
|
56
|
+
# defaults key-by-key, keeping the defaults' position and appending the
|
|
57
|
+
# rest in caller order.
|
|
58
|
+
def options_segment(raw, typed)
|
|
59
|
+
unless raw.nil? || typed.empty?
|
|
60
|
+
raise ArgumentError,
|
|
61
|
+
"Audioproxy url_for takes either raw: or typed option keys, not both " \
|
|
62
|
+
"(got raw: and #{typed.keys.join(", ")})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return raw_segment(raw) unless raw.nil?
|
|
66
|
+
|
|
67
|
+
# Onto the canonical keys before anything else, so that a default
|
|
68
|
+
# written as bitrate: and a per-call br: are one key the merge
|
|
69
|
+
# overrides rather than two segments that both render (D3). Defaults
|
|
70
|
+
# were resolved at assignment.
|
|
71
|
+
typed = Options.resolve(typed)
|
|
72
|
+
|
|
73
|
+
defaults = config.default_options
|
|
74
|
+
typed_defaults = defaults.except(:raw)
|
|
75
|
+
|
|
76
|
+
return Options.render(typed_defaults.merge(typed)) unless typed.empty?
|
|
77
|
+
return raw_segment(defaults[:raw]) unless defaults[:raw].nil?
|
|
78
|
+
return Options.render(typed_defaults) unless typed_defaults.empty?
|
|
79
|
+
|
|
80
|
+
FALLBACK_OPTIONS
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def raw_segment(raw)
|
|
84
|
+
segment = String.try_convert(raw)
|
|
85
|
+
if segment.nil?
|
|
86
|
+
raise ArgumentError, "raw options must be a String, got #{raw.class}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
return FALLBACK_OPTIONS if segment.blank?
|
|
90
|
+
segment = segment.strip
|
|
91
|
+
|
|
92
|
+
# The builder supplies the separators. A bracketing slash would sign a
|
|
93
|
+
# path with an empty segment, which the proxy rejects — and it would do
|
|
94
|
+
# so at request time, nowhere near this call.
|
|
95
|
+
if segment.start_with?("/") || segment.end_with?("/")
|
|
96
|
+
raise ArgumentError, "raw options must not begin or end with '/', got #{segment.inspect}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
segment
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The proxy accepts padded and unpadded enc/ payloads; emitting exactly
|
|
103
|
+
# one spelling keeps URLs (and CDN cache keys) stable.
|
|
104
|
+
def source_segment(source)
|
|
105
|
+
string = String.try_convert(source) || resolved_source(source)
|
|
106
|
+
if string.empty?
|
|
107
|
+
raise ArgumentError, "source must not be empty"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
"enc/#{base64url(string)}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Non-String sources go through the registered resolver, or nowhere. The
|
|
114
|
+
# core names no ActiveStorage constant; it only knows that something else
|
|
115
|
+
# may have claimed the job of turning objects into source strings.
|
|
116
|
+
def resolved_source(source)
|
|
117
|
+
resolver = Audioproxy.source_resolver
|
|
118
|
+
if resolver.nil?
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"source must be a String, got #{source.class} (ActiveStorage blobs " \
|
|
121
|
+
"and attachments resolve only where the Rails integration is loaded)"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
resolved = resolver.call(source)
|
|
125
|
+
string = String.try_convert(resolved)
|
|
126
|
+
if string.nil?
|
|
127
|
+
raise ArgumentError,
|
|
128
|
+
"the registered Audioproxy source resolver returned #{resolved.class} " \
|
|
129
|
+
"for #{source.class}, not a source String"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
string
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Unpadded, per D3/D4: the proxy accepts both spellings, but emitting one
|
|
136
|
+
# keeps URLs and CDN cache keys stable.
|
|
137
|
+
def base64url(bytes)
|
|
138
|
+
Base64.urlsafe_encode64(bytes, padding: false)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
data/lib/audioproxy.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "audioproxy/version"
|
|
2
|
+
require "audioproxy/signer"
|
|
3
|
+
require "audioproxy/options"
|
|
4
|
+
require "audioproxy/config"
|
|
5
|
+
require "audioproxy/url_builder"
|
|
6
|
+
|
|
7
|
+
# Nothing in this namespace may reference Rails constants; ActiveSupport is
|
|
8
|
+
# fair game except inside Audioproxy::Signer, which stays liftable. See D1.
|
|
9
|
+
module Audioproxy
|
|
10
|
+
class << self
|
|
11
|
+
attr_writer :config
|
|
12
|
+
|
|
13
|
+
def config
|
|
14
|
+
@config ||= Config.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configure
|
|
18
|
+
yield config
|
|
19
|
+
config
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Single public entry point: usable from jobs, mailers and serializers of
|
|
23
|
+
# any Ruby program, Rails or not.
|
|
24
|
+
def url_for(source, **options)
|
|
25
|
+
UrlBuilder.new(config).url_for(source, **options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Anything that is not already a source String is handed to this resolver.
|
|
29
|
+
# The Rails layer registers one for ActiveStorage objects; the core stays
|
|
30
|
+
# ignorant of what a blob is, which is what keeps +url_for+ usable with no
|
|
31
|
+
# Rails loaded at all (D5).
|
|
32
|
+
attr_reader :source_resolver
|
|
33
|
+
|
|
34
|
+
def register_source_resolver(resolver = nil, &block)
|
|
35
|
+
resolver ||= block
|
|
36
|
+
|
|
37
|
+
unless resolver.respond_to?(:call)
|
|
38
|
+
raise ArgumentError, "an Audioproxy source resolver must respond to #call, got #{resolver.class}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@source_resolver = resolver
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset_source_resolver
|
|
45
|
+
@source_resolver = nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
require "audioproxy/rails/railtie" if defined?(::Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: audioproxy-rails
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Julian Rubisch
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-08-11 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rails
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '8.1'
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: 8.1.3.1
|
|
50
|
+
type: :development
|
|
51
|
+
prerelease: false
|
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - "~>"
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '8.1'
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: 8.1.3.1
|
|
60
|
+
description: Builds signed, option-carrying URLs for the audioproxy media server,
|
|
61
|
+
with a Rails integration layer on top of a Rails-free core.
|
|
62
|
+
email:
|
|
63
|
+
- julian@julianrubisch.at
|
|
64
|
+
executables: []
|
|
65
|
+
extensions: []
|
|
66
|
+
extra_rdoc_files: []
|
|
67
|
+
files:
|
|
68
|
+
- CHANGELOG.md
|
|
69
|
+
- MIT-LICENSE
|
|
70
|
+
- README.md
|
|
71
|
+
- lib/audioproxy.rb
|
|
72
|
+
- lib/audioproxy/config.rb
|
|
73
|
+
- lib/audioproxy/options.rb
|
|
74
|
+
- lib/audioproxy/rails.rb
|
|
75
|
+
- lib/audioproxy/rails/blob_resolver.rb
|
|
76
|
+
- lib/audioproxy/rails/helpers.rb
|
|
77
|
+
- lib/audioproxy/rails/railtie.rb
|
|
78
|
+
- lib/audioproxy/signer.rb
|
|
79
|
+
- lib/audioproxy/url_builder.rb
|
|
80
|
+
- lib/audioproxy/version.rb
|
|
81
|
+
- lib/tasks/audioproxy/rails_tasks.rake
|
|
82
|
+
homepage: https://github.com/audioproxy/audioproxy-rails
|
|
83
|
+
licenses:
|
|
84
|
+
- MIT
|
|
85
|
+
metadata:
|
|
86
|
+
homepage_uri: https://github.com/audioproxy/audioproxy-rails
|
|
87
|
+
bug_tracker_uri: https://github.com/audioproxy/audioproxy-rails/issues
|
|
88
|
+
changelog_uri: https://github.com/audioproxy/audioproxy-rails/blob/main/CHANGELOG.md
|
|
89
|
+
rubygems_mfa_required: 'true'
|
|
90
|
+
rdoc_options: []
|
|
91
|
+
require_paths:
|
|
92
|
+
- lib
|
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: 3.2.0
|
|
98
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
requirements: []
|
|
104
|
+
rubygems_version: 3.6.2
|
|
105
|
+
specification_version: 4
|
|
106
|
+
summary: Signed variant URLs for the audioproxy server.
|
|
107
|
+
test_files: []
|