AudioProxy.Source (audio_proxy v0.7.0)

Copy Markdown View Source

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.

t()

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

error_reason()

@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.

t()

@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

authorize(source, types \\ types())

@spec authorize(t(), [module()]) :: :ok | {:error, :not_allowed}

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.

canonical(source, types \\ types())

@spec canonical(t(), [module()]) :: String.t()

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.

ffmpeg_input(source, types \\ types())

@spec ffmpeg_input(t(), [module()]) :: {:ok, String.t()} | {:error, atom()}

Renders what ffmpeg should be given as input for source, via its own type.

message(atom)

@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.

parse(source, types \\ types())

@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.

stat(source, types \\ types())

@spec stat(t(), [module()]) :: {:ok, AudioProxy.Source.Type.stat()} | {:error, atom()}

Reports size and ETag material for source, via its own type.

types()

@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.