ML-181: harden assets endpoints
This commit is contained in:
@@ -4,9 +4,10 @@ defmodule MusicLibrary.Assets.Cache do
|
||||
|
||||
## Cache key
|
||||
|
||||
Each entry is keyed by `{payload, format}` where `payload` is a transform
|
||||
parameter string (encoding width and asset hash) and `format` is the output
|
||||
MIME type (e.g. `"image/webp"`).
|
||||
Each entry is keyed by `{key, format}` where `key` is an opaque canonical
|
||||
transform key provided by the caller (currently `"hash:width"` from
|
||||
`Transform.canonical_key/1`) and `format` is the output MIME type
|
||||
(e.g. `"image/webp"`).
|
||||
|
||||
## TTL and pruning
|
||||
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
defmodule MusicLibrary.Assets.Transform do
|
||||
@moduledoc """
|
||||
Represents an image transformation (hash + target width) for asset serving.
|
||||
|
||||
## Width validation
|
||||
|
||||
`decode/1` validates the `width` field: it must be `nil` (serve original size)
|
||||
or a positive integer in `1..2048`. Any other value (string, negative, zero,
|
||||
float, or very large) returns `{:error, :invalid_payload}`.
|
||||
|
||||
## Canonical cache key
|
||||
|
||||
`canonical_key/1` produces a deterministic `"hash:width"` string used as the
|
||||
ETS cache key in `MusicLibraryWeb.AssetController`, collapsing variant JSON
|
||||
payloads that encode the same (hash, width) into a single cache entry.
|
||||
"""
|
||||
|
||||
@derive JSON.Encoder
|
||||
@@ -39,11 +51,19 @@ defmodule MusicLibrary.Assets.Transform do
|
||||
iex> Transform.decode("!!!invalid")
|
||||
{:error, :invalid_payload}
|
||||
"""
|
||||
@max_width 2048
|
||||
|
||||
@spec decode(payload()) :: {:ok, t()} | {:error, :invalid_payload}
|
||||
def decode(payload) do
|
||||
with {:ok, decoded} <- Base.url_decode64(payload, padding: false),
|
||||
{:ok, params} when is_map(params) <- JSON.decode(decoded) do
|
||||
{:ok, struct!(__MODULE__, %{hash: params["hash"], width: params["width"]})}
|
||||
width = params["width"]
|
||||
|
||||
if valid_width?(width) do
|
||||
{:ok, struct!(__MODULE__, %{hash: params["hash"], width: width})}
|
||||
else
|
||||
{:error, :invalid_payload}
|
||||
end
|
||||
else
|
||||
_ -> {:error, :invalid_payload}
|
||||
end
|
||||
@@ -57,16 +77,23 @@ defmodule MusicLibrary.Assets.Transform do
|
||||
"""
|
||||
@spec decode!(payload()) :: t()
|
||||
def decode!(payload) do
|
||||
params =
|
||||
payload
|
||||
|> Base.url_decode64!(padding: false)
|
||||
|> JSON.decode!()
|
||||
|
||||
struct!(__MODULE__, %{
|
||||
hash: params["hash"],
|
||||
width: params["width"]
|
||||
})
|
||||
case decode(payload) do
|
||||
{:ok, transform} -> transform
|
||||
{:error, :invalid_payload} -> raise ArgumentError, "invalid transform payload"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
iex> alias MusicLibrary.Assets.Transform
|
||||
iex> Transform.canonical_key(%Transform{hash: "abc123", width: 96})
|
||||
"abc123:96"
|
||||
"""
|
||||
@spec canonical_key(t()) :: String.t()
|
||||
def canonical_key(%__MODULE__{hash: hash, width: width}), do: "#{hash}:#{width}"
|
||||
|
||||
defp valid_width?(nil), do: true
|
||||
defp valid_width?(width) when is_integer(width) and width > 0 and width <= @max_width, do: true
|
||||
defp valid_width?(_), do: false
|
||||
end
|
||||
|
||||
defimpl Phoenix.Param, for: MusicLibrary.Assets.Transform do
|
||||
|
||||
@@ -17,7 +17,9 @@ defmodule MusicLibraryWeb.AssetController do
|
||||
bad_request(conn)
|
||||
|
||||
{:ok, transform} ->
|
||||
case cached_get(payload, transform, format) do
|
||||
cache_key = Transform.canonical_key(transform)
|
||||
|
||||
case cached_get(cache_key, transform, format) do
|
||||
nil ->
|
||||
not_found(conn)
|
||||
|
||||
@@ -30,12 +32,12 @@ defmodule MusicLibraryWeb.AssetController do
|
||||
end
|
||||
end
|
||||
|
||||
defp cached_get(_payload, transform, _format) when is_nil(transform.hash) do
|
||||
defp cached_get(_cache_key, transform, _format) when is_nil(transform.hash) do
|
||||
nil
|
||||
end
|
||||
|
||||
defp cached_get(payload, transform, format) do
|
||||
case Cache.get(payload, format) do
|
||||
defp cached_get(cache_key, transform, format) do
|
||||
case Cache.get(cache_key, format) do
|
||||
:not_found ->
|
||||
if asset = Assets.get(transform.hash) do
|
||||
result =
|
||||
@@ -47,7 +49,7 @@ defmodule MusicLibraryWeb.AssetController do
|
||||
|
||||
case result do
|
||||
{:ok, image_data} ->
|
||||
Cache.set(payload, format, image_data)
|
||||
Cache.set(cache_key, format, image_data)
|
||||
image_data
|
||||
|
||||
{:error, reason} ->
|
||||
|
||||
Reference in New Issue
Block a user