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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module Audioproxy
2
+ VERSION = "0.1.0"
3
+ 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)
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :audioproxy_rails do
3
+ # # Task goes here
4
+ # end
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: []