defmodule BunnyCDNSigner do
@moduledoc """
BunnyCDN signed urls using token authentication.
See https://support.bunny.net/hc/en-us/articles/360016055099-How-to-sign-URLs-for-BunnyCDN-Token-Authentication for more information.
"""
@doc """
Signs a URL with the given parameters.
## Parameters
* `url` - The URL to sign
* `opts` - A keyword list of options
* `:user_ip` - The user's IP address
* `:expiration` - The number of seconds until the token expires, defaults to 3,600 (1 hour)
* `:directory` - Whether the URL is a directory (defaults to false)
* `:path_allowed` - The path to allow
* `:countries_allowed` - A comma-separated list of countries to allow
* `:countries_blocked` - A comma-separated list of countries to block
## Examples
sign_url("https://example.com/test.mp4")
"https://...?token=...&expires=..."
sign_url("https://example.com/test.mp4", directory: true)
"https://.../bcdn_token=...&expires=.../test.mp4"
"""
def sign_url(url, opts \\ []) do
authentication_key =
Application.get_env(:bunny_cdn_signer, :authentication_key) ||
raise "Bunny CDN authentication_key not set in Application config"
user_ip = Keyword.get(opts, :user_ip, "")
expiration_time = Keyword.get(opts, :expiration, 3600)
is_directory = Keyword.get(opts, :directory, false)
path_allowed = Keyword.get(opts, :path_allowed, "")
countries_allowed = Keyword.get(opts, :countries_allowed, "")
countries_blocked = Keyword.get(opts, :countries_blocked, "")
expires = :os.system_time(:second) + expiration_time
# Parse the url into a URI and add the countries to the query params if they are provided
uri =
url
|> URI.parse()
|> put_countries(countries_allowed, countries_blocked)
# Process the parameters to get the signature path and parameter data
{signature_path, parameter_data} = process_parameters(uri, path_allowed)
# Generate the token
token = generate_token(authentication_key, signature_path, expires, user_ip, parameter_data)
uri =
if is_directory do
# Add the token and expires to the path
%{uri | path: "/bcdn_token=#{token}&expires=#{expires}#{uri.path}"}
else
# Add the token and expires to the query string
uri
|> put_query_param("token", token)
|> put_query_param("expires", expires)
end
URI.to_string(uri)
end
defp generate_token(authentication_key, signature_path, expires, user_ip, parameter_data) do
hashable_base = "#{authentication_key}#{signature_path}#{expires}#{user_ip}#{parameter_data}"
:crypto.hash(:sha256, hashable_base)
|> Base.encode64()
|> String.replace("\n", "")
|> String.replace("+", "-")
|> String.replace("/", "_")
|> String.replace("=", "")
end
defp put_countries(%URI{} = uri, countries_allowed, countries_blocked) do
maybe_put_countries_allowed = fn uri ->
if countries_allowed != "",
do: put_query_param(uri, "token_countries", countries_allowed),
else: uri
end
maybe_put_countries_blocked = fn uri ->
if countries_blocked != "",
do: put_query_param(uri, "token_countries_blocked", countries_blocked),
else: uri
end
uri
|> maybe_put_countries_allowed.()
|> maybe_put_countries_blocked.()
end
defp process_parameters(%URI{} = uri, path_allowed) do
parameters = URI.decode_query(uri.query || "")
signature_path =
if path_allowed != "",
do: path_allowed,
else: URI.decode(uri.path)
parameter_data =
Enum.sort(parameters) |> Enum.map_join("&", fn {k, v} -> "#{k}=#{URI.encode(v)}" end)
{signature_path, parameter_data}
end
defp put_query_param(%URI{query: nil} = uri, key, value) do
%{uri | query: URI.encode_query(%{key => value})}
end
defp put_query_param(%URI{query: query} = uri, key, value) do
query =
query
|> URI.decode_query()
|> Map.merge(%{key => value})
|> URI.encode_query()
%{uri | query: query}
end
end