Ctrl K
ComunidadGuías…

lib/audio_proxy/source.ex

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
defmodule AudioProxy.Source do
  @moduledoc """
  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.
  """

  alias AudioProxy.Source.Type

  # Source types, in dispatch order. Each slice that adds one appends here;
  # nothing loads a type at runtime.
  @types [AudioProxy.Source.Local, AudioProxy.Source.S3, AudioProxy.Source.Https]

  # A `%` that is not the start of a well-formed escape.
  @malformed_escape_re ~r/%(?![0-9A-Fa-f]{2})/

  # Cc = control, Cf = format (bidi overrides, zero-width joiners), Zl/Zp =
  # line and paragraph separators. See the moduledoc for why not `\x00-\x1f`.
  @control_char_re ~r/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u

  @typedoc """
  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.
  """
  @type t :: tuple()

  @typedoc """
  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 error_reason ::
          :unknown_encoding
          | :invalid_encoding
          | :malformed_escape
          | :empty_source
          | :control_character
          | :unknown_scheme

  @doc """
  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 parse(String.t() | [String.t()], [module()]) ::
          {:ok, t()} | {:error, error_reason() | atom()}
  def parse(source, types \\ types())

  def parse(source, types) when is_list(source), do: source |> Enum.join("/") |> parse(types)

  def parse(source, types) when is_binary(source) do
    with {:ok, decoded} <- decode(source),
         :ok <- vet(decoded),
         {:ok, type, body} <- dispatch(decoded, types) do
      type.parse(body)
    end
  end

  @doc """
  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.
  """
  @spec canonical(t(), [module()]) :: String.t()
  def canonical(source, types \\ types()),
    do: source |> type_for!(types) |> apply(:canonical, [source])

  @doc """
  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.
  """
  @spec authorize(t(), [module()]) :: :ok | {:error, :not_allowed}
  def authorize(source, types \\ types()),
    do: source |> type_for!(types) |> apply(:authorize, [source])

  @doc """
  Reports size and ETag material for `source`, via its own type.
  """
  @spec stat(t(), [module()]) :: {:ok, Type.stat()} | {:error, atom()}
  def stat(source, types \\ types()), do: source |> type_for!(types) |> apply(:stat, [source])

  @doc """
  Renders what ffmpeg should be given as input for `source`, via its own type.
  """
  @spec ffmpeg_input(t(), [module()]) :: {:ok, String.t()} | {:error, atom()}
  def ffmpeg_input(source, types \\ types()),
    do: source |> type_for!(types) |> apply(:ffmpeg_input, [source])

  @doc """
  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.
  """
  @spec types() :: [module()]
  def types, do: @types

  @doc """
  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 message(error_reason()) :: String.t()
  def message(:unknown_encoding), do: "source must start with \"plain/\" or \"enc/\""
  def message(:invalid_encoding), do: "encoded source is not valid base64url"
  def message(:malformed_escape), do: "source contains a malformed percent-escape"
  def message(:empty_source), do: "source is empty"
  def message(:control_character), do: "source contains a control character"
  def message(:unknown_scheme), do: "source scheme is not one this proxy serves"

  ## Decoding

  defp decode("plain/" <> rest) do
    if Regex.match?(@malformed_escape_re, rest) do
      {:error, :malformed_escape}
    else
      {:ok, URI.decode(rest)}
    end
  end

  defp decode("enc/" <> rest) do
    # Padded and unpadded spellings decode to the same bytes, so both reach
    # the same source and the same cache key; there is nothing to gain by
    # refusing one, unlike a signature (which must not be malleable).
    case Base.url_decode64(rest, padding: false) do
      {:ok, decoded} -> {:ok, decoded}
      :error -> padded_decode(rest)
    end
  end

  defp decode(_source), do: {:error, :unknown_encoding}

  defp padded_decode(rest) do
    case Base.url_decode64(rest) do
      {:ok, decoded} -> {:ok, decoded}
      :error -> {:error, :invalid_encoding}
    end
  end

  ## Vetting and dispatch

  defp vet(""), do: {:error, :empty_source}

  defp vet(decoded) do
    cond do
      not String.valid?(decoded) -> {:error, :invalid_encoding}
      Regex.match?(@control_char_re, decoded) -> {:error, :control_character}
      true -> :ok
    end
  end

  defp dispatch(decoded, types) do
    with [scheme, body] <- String.split(decoded, "://", parts: 2),
         scheme = String.downcase(scheme),
         type when not is_nil(type) <- Enum.find(types, &(&1.scheme() == scheme)) do
      {:ok, type, body}
    else
      _ -> {:error, :unknown_scheme}
    end
  end

  defp type_for!(source, types) when is_tuple(source) and tuple_size(source) > 0 do
    tag = elem(source, 0)

    Enum.find(types, &(&1.tag() == tag)) ||
      raise ArgumentError, "no source type registered for tag #{inspect(tag)}"
  end
end