defmodule Image.Plug.Cache do
@moduledoc """
HTTP-cache helpers used by `Image.Plug`.
This module is pure. It computes a stable ETag from the source's
`etag_seed` and the normalised pipeline's fingerprint, evaluates
`If-None-Match` for conditional GETs, and produces a default
`Cache-Control` directive.
The ETag is a strong validator computed as:
base64url(sha256(meta.etag_seed <> "|" <> pipeline_fingerprint <> "|" <> chosen_format))
Because the pipeline is normalised first, two URLs that differ
only in option order produce the same ETag — bandwidth-friendly
and cache-friendly.
"""
alias Image.Plug.Pipeline
@typedoc """
The shape returned by `compute/3`. The plug attaches these to the
conn before the body is sent.
"""
@type cache_info :: %{
required(:etag) => String.t(),
required(:cache_control) => String.t(),
optional(:last_modified) => DateTime.t(),
optional(:vary) => [String.t()]
}
@default_cache_control "public, max-age=3600, stale-while-revalidate=86400"
@doc """
Computes cache headers for a given source meta + normalised
pipeline + chosen output format.
### Arguments
* `meta` is the `Image.Plug.SourceResolver.meta` map.
* `pipeline` is the (normalised) `Image.Plug.Pipeline`.
* `chosen_format` is an atom describing the actual output format
selected by the encoder (e.g. `:webp` even when the request was
`format=auto`). Caller passes this so that two requests choosing
different formats get different ETags.
### Returns
* A `t:cache_info/0` map.
"""
@spec compute(map(), Pipeline.t(), atom()) :: cache_info()
def compute(meta, %Pipeline{} = pipeline, chosen_format) do
etag = etag(meta, pipeline, chosen_format)
base = %{etag: etag, cache_control: cache_control(meta), vary: vary(pipeline)}
case Map.get(meta, :last_modified) do
%DateTime{} = ts -> Map.put(base, :last_modified, ts)
_ -> base
end
end
@doc """
Returns true when the request's `If-None-Match` header matches the
computed ETag (i.e. we should respond 304).
Accepts the bare ETag form and the `W/"..."` weak form, since some
intermediaries strip the surrounding quotes.
"""
@spec fresh?(Plug.Conn.t(), String.t()) :: boolean()
def fresh?(%Plug.Conn{} = conn, etag) when is_binary(etag) do
quoted = quoted_etag(etag)
conn
|> Plug.Conn.get_req_header("if-none-match")
|> Enum.any?(fn header ->
header
|> String.split(",", trim: true)
|> Enum.any?(fn entry ->
normalised = entry |> String.trim() |> String.replace_prefix("W/", "")
normalised == etag or normalised == quoted
end)
end)
end
@doc """
Computes a stable fingerprint for a normalised pipeline. Used by
`etag/3` and exposed for tests.
"""
@spec fingerprint(Pipeline.t()) :: binary()
def fingerprint(%Pipeline{ops: ops, output: output}) do
payload = [
Enum.map(ops, &op_fingerprint/1),
"|",
op_fingerprint(output)
]
:crypto.hash(:sha256, IO.iodata_to_binary(payload))
end
defp op_fingerprint(%struct{} = op) do
fields =
op
|> Map.from_struct()
|> Enum.sort_by(fn {k, _} -> Atom.to_string(k) end)
|> Enum.map(fn {k, v} -> [Atom.to_string(k), "=", inspect(v)] end)
|> Enum.intersperse(";")
[inspect(struct), "{", fields, "}"]
end
defp etag(meta, pipeline, chosen_format) do
digest =
:crypto.hash(:sha256, [
meta.etag_seed,
"|",
fingerprint(pipeline),
"|",
Atom.to_string(chosen_format)
])
Base.url_encode64(digest, padding: false)
end
defp quoted_etag(etag), do: ~s("#{etag}")
defp cache_control(meta) do
case Map.get(meta, :cache_control) do
value when is_binary(value) and value != "" -> value
_ -> @default_cache_control
end
end
defp vary(_pipeline), do: ["Accept"]
end