defmodule Image.Plug.Provider.Cloudinary.URL do @moduledoc """ URL-shape recognition for the [Cloudinary delivery URL grammar](https://cloudinary.com/documentation/transformation_reference). Cloudinary URLs have the shape: ////[/]/ with: * `` — the cloud name (e.g. `demo`). * `` — `image`, `video`, `raw`. v0.1 supports `image`. * `` — `upload`, `fetch`, `private`, `authenticated`, `sprite`, `facebook`, etc. v0.1 recognises any value but only `upload` and `fetch` are exercised by tests. * `` — optional `s----` segment for signed URLs. * `` — zero or more comma-separated transform stages, each separated by `/`. v0.1 collapses multi-stage transforms by concatenating with commas; the canonical IR doesn't model chained transforms as v0.1. * `` — the public id (with extension); for `delivery=fetch` it's a percent-encoded HTTPS URL. Unlike imgix and Cloudflare, Cloudinary embeds account + delivery segments in the path, so the recogniser strips them before reaching the source. """ alias Image.Plug.{Error, Source} @signature_segment_re ~r/^s--[A-Za-z0-9_-]+--$/ @typedoc """ The recognised URL shape. * `:options` is the joined transform string (multiple stages comma- flattened) and may be empty. * `:source` is the resolved source. * `:account`, `:resource_type`, `:delivery`, `:signature` are the parsed path segments. Used by the wiring module to verify signatures and to reconstruct the canonical-string for HMAC verification. """ @type recognised :: %{ shape: :cloudinary, options: String.t(), source: Source.t(), account: String.t() | nil, resource_type: String.t(), delivery: String.t(), signature: String.t() | nil } @doc """ Parses the request path of a `Plug.Conn` into a recognised URL shape. ### Arguments * `conn` is a `Plug.Conn` struct. * `options` is a keyword list. The following keys are honoured: ### Options * `:mount` — string path prefix the plug is mounted under. Stripped before treating the rest as the cloudinary path. Defaults to `""`. * `:account` — when set, asserts the URL's account segment matches this value. When `nil` (default), any account is accepted and the parsed value is reported in the recognised shape. ### Returns * `{:ok, recognised}` on a successful match. * `{:error, %Image.Plug.Error{tag: :malformed_url}}` when the path doesn't sit under the mount or has too few segments. ### Examples iex> conn = %Plug.Conn{ ...> path_info: ["demo", "image", "upload", "w_200,c_fill", "sample.jpg"], ...> request_path: "/demo/image/upload/w_200,c_fill/sample.jpg", ...> query_string: "" ...> } iex> {:ok, parsed} = Image.Plug.Provider.Cloudinary.URL.parse(conn, []) iex> parsed.options "w_200,c_fill" iex> parsed.delivery "upload" iex> parsed.source.ref "/sample.jpg" """ @spec parse(Plug.Conn.t(), keyword()) :: {:ok, recognised()} | {:error, Error.t()} def parse(%Plug.Conn{path_info: path_info}, options) when is_list(options) do mount_segments = mount_segments(Keyword.get(options, :mount, "")) expected_account = Keyword.get(options, :account) decoded = Enum.map(path_info, &URI.decode/1) with {:ok, after_mount} <- strip_prefix(decoded, mount_segments), {:ok, parts} <- split_path(after_mount, expected_account) do with {:ok, source} <- build_source(parts.source_segments, parts.delivery) do {:ok, %{ shape: :cloudinary, options: Enum.join(parts.transform_stages, ","), source: source, account: parts.account, resource_type: parts.resource_type, delivery: parts.delivery, signature: parts.signature }} end end end defp mount_segments(""), do: [] defp mount_segments(mount) when is_binary(mount) do mount |> String.trim_leading("/") |> String.trim_trailing("/") |> String.split("/", trim: true) end defp strip_prefix(path_info, []), do: {:ok, path_info} defp strip_prefix(path_info, mount_segments) do if List.starts_with?(path_info, mount_segments) do {:ok, Enum.drop(path_info, length(mount_segments))} else {:error, Error.new(:malformed_url, "request path does not sit under the configured mount")} end end defp split_path([account, resource_type, delivery | rest], expected_account) when rest != [] do cond do expected_account != nil and account != expected_account -> {:error, Error.new(:malformed_url, "cloudinary account segment does not match configured value", details: %{got: account, expected: expected_account} )} true -> {signature, after_signature} = pop_signature(rest) {transform_stages, source_segments} = split_transforms_and_source(after_signature) if source_segments == [] do {:error, Error.new(:malformed_url, "cloudinary URL has no source segment")} else {:ok, %{ account: account, resource_type: resource_type, delivery: delivery, signature: signature, transform_stages: transform_stages, source_segments: source_segments }} end end end defp split_path(_, _) do {:error, Error.new( :malformed_url, "cloudinary URL needs at least ///" )} end defp pop_signature([first | rest] = segments) do if Regex.match?(@signature_segment_re, first) do {first, rest} else {nil, segments} end end # The last segment is always the source. Everything before it is a # transform stage IF it contains a Cloudinary transform marker (an # underscore between letters/digits, like `w_200`); otherwise we # treat it as part of the source path (folders). defp split_transforms_and_source(segments) do {transforms, sources} = Enum.split_while(segments, &looks_like_transform_stage?/1) case sources do [] -> # Edge case: every segment looked like a transform stage. Last # one is actually the source (cloudinary public-id with no # extension, rare). case transforms do [] -> {[], []} _ -> {Enum.drop(transforms, -1), [List.last(transforms)]} end _ -> {transforms, sources} end end defp looks_like_transform_stage?(segment) do # A transform stage is a comma-separated list of _ # pairs. The simplest heuristic: contains a `_`, no `.` (extensions # only appear on the source), and every comma-split chunk has a # `_` in it. cond do String.contains?(segment, ".") -> false not String.contains?(segment, "_") -> false true -> segment |> String.split(",", trim: true) |> Enum.all?(fn chunk -> String.contains?(chunk, "_") end) end end defp build_source(segments, "fetch") do # `delivery=fetch` is a web-proxy: the source is an absolute # http(s) URL. Cloudinary accepts it either fully percent-encoded # (one path segment) or written in the natural form (split into # multiple segments by `/`). Path-split collapses `https://` to # `https:/`, so we restore the missing slash on rejoin. joined = segments |> Enum.join("/") |> case do "http:/" <> rest = original -> if String.starts_with?(rest, "/"), do: original, else: "http://" <> rest "https:/" <> rest = original -> if String.starts_with?(rest, "/"), do: original, else: "https://" <> rest other -> other end cond do String.starts_with?(joined, "http://") or String.starts_with?(joined, "https://") -> Source.url(joined) true -> Source.path("/" <> Enum.join(segments, "/")) end end defp build_source(segments, _delivery) do Source.path("/" <> Enum.join(segments, "/")) end end