The source segment (API doc §1): encodings, decoding, dispatch, identity.
The source is the tail of a signed path — everything after the options — and it arrives in one of two encodings:
plain/{source}— the source string, percent-escaped so it survives being carried as path bytes (plain/s3://masters/a%20track.wav).enc/{base64url(source)}— the same source string, base64url-encoded, which exists so nested URLs never have to be escaped by hand.
This module owns what those encodings imply, and deliberately nothing else.
What a source is — a bucket and key, a URL, a path under a root — belongs
to a source type (AudioProxy.Source.Type), and each type ships in its own
slice. Ask this module to parse a source and it will decode, vet, and hand
the remainder to whichever type claims the scheme.
Three types are registered: local://, files under AP_LOCAL_ROOT
(AudioProxy.Source.Local); s3://, an object in a bucket
(AudioProxy.Source.S3); and https://, a URL at an allowlisted origin
(AudioProxy.Source.Https). Anything else — http:// included, deliberately
— is {:error, :unknown_scheme}.
Decode exactly once
The enc/ form is decoded to the plain source string first, and from there
both encodings share one code path — that is what keeps them from drifting
semantically, and it fixes an ordering every type depends on: decode fully,
then interpret. A path-confinement check or a URL parse is only sound on a
fully-decoded string.
It also fixes what "escaped" means. The plain/ payload is percent-decoded
exactly once, so a literal % is written %25 and a literal space %20.
+ is left alone; it is a literal plus in a path, and the +-means-space
convention belongs to query strings.
Escapes must be well-formed. %zz is rejected rather than passed through,
because passing it through would give one source two spellings (%zz and
%25zz would both decode to %zz) and therefore one variant two cache keys.
What is refused here, once, for everyone
Control, format and line/paragraph-separator code points are refused in any
decoded source, by Unicode category rather than by the ASCII range —
\x00-\x1f alone lets U+0085, U+2028 and U+202E through. Nothing
legitimate needs one, and they would otherwise reach ffmpeg argv, object
keys, Content-Disposition and log lines, where a right-to-left override is
a filename-spoofing tool.
Doing it here rather than per type is the point: a source type cannot forget it, so a NUL byte never reaches a confinement check.
Canonical identity
canonical/1 produces the string AudioProxy.CacheKey hashes as the "what"
half of a variant's identity. Each type renders its own, under one rule this
module enforces by construction: both encodings of one source parse to the
same typed source, so they render the same bytes and share a cache key.
Summary
Types
Why a source was rejected before any source type saw it.
A resolved source: a tagged tuple whose first element identifies its type.
Functions
Decides whether source may be served, via its own type.
Renders the canonical identity string for source, via its own type.
Renders what ffmpeg should be given as input for source, via its own type.
A short human-readable sentence for reason, for error bodies and logs.
Parses a source segment (or its already-split parts) into a typed source.
Reports size and ETag material for source, via its own type.
The registered source types.
Types
@type error_reason() ::
:unknown_encoding
| :invalid_encoding
| :malformed_escape
| :empty_source
| :control_character
| :unknown_scheme
Why a source was rejected before any source type saw it.
A type adds its own reasons on top; these are the ones the shared layer produces.
@type t() :: tuple()
A resolved source: a tagged tuple whose first element identifies its type.
The shape past the tag is the type's own business — {:local, path},
{:s3, bucket, key}, {:http, url} — and consumers pattern-match the shape
their slice documents.
Functions
Decides whether source may be served, via its own type.
See AudioProxy.Source.Type for why the policy is the type's and not this
module's.
Renders the canonical identity string for source, via its own type.
This is the second half of the cache-key input (AudioProxy.CacheKey), and
it is what makes the two encodings one variant.
Renders what ffmpeg should be given as input for source, via its own type.
@spec message(error_reason()) :: String.t()
A short human-readable sentence for reason, for error bodies and logs.
Only the shared layer's reasons; a source type explains its own.
@spec parse(String.t() | [String.t()], [module()]) :: {:ok, t()} | {:error, error_reason() | atom()}
Parses a source segment (or its already-split parts) into a typed source.
Accepts "plain/s3://b/k" or ["plain", "s3://b/k"]; a list is joined with
/ before parsing. The input must be the raw, still-escaped path bytes —
the same bytes the signature was computed over. Never pass conn.path_info,
which Plug has already percent-decoded (see
AudioProxy.Plugs.VerifySignature); passing it would decode twice.
types overrides the registered source types, which is how this module is
tested against a stand-in rather than a real one.
iex> AudioProxy.Source.parse("plain/local://previews/track.wav")
{:ok, {:local, "previews/track.wav"}}
iex> AudioProxy.Source.parse("plain/s3://masters/a.wav")
{:ok, {:s3, "masters", "a.wav"}}
iex> AudioProxy.Source.parse("plain/http://media.example/a.wav")
{:error, :unknown_scheme}
iex> AudioProxy.Source.parse("nonsense/x")
{:error, :unknown_encoding}One consequence of decoding exactly once is worth spelling out: a source that
already carries escapes has to be escaped again for the plain/ form,
since the outer layer is what gets stripped. A URL ending in a%20b.wav is
written plain/https://h/a%2520b.wav. That is precisely the headache enc/
exists to avoid — base64url the source as written and be done.
@spec stat(t(), [module()]) :: {:ok, AudioProxy.Source.Type.stat()} | {:error, atom()}
Reports size and ETag material for source, via its own type.
@spec types() :: [module()]
The registered source types.
parse/2 and friends take an override, so the contract can be exercised
against a test-only type without registering one.