# SPDX-FileCopyrightText: 2024 Ɓukasz Niemier <#@hauleth.dev> # # SPDX-License-Identifier: MPL-2.0 defmodule Aww do alias Aww.Cache require Logger @default_cache Aww.Cache @type host() :: :gravatar | :libravatar | (String.t(), opts() -> String.t() | {String.t(), integer()}) | {module(), atom(), [term()]} @type avatar_opt() :: {:size, integer()} | {:default, String.t()} | {:robohash, String.t()} | {:forcedefault?, boolean()} | {:rating, String.t()} @type service_opt() :: {:host, host()} | {:secure?, boolean()} | {:resolv_opts, [:inet_res.res_option()]} | {:timeout, pos_integer()} | {atom(), term()} @type opts() :: [avatar_opt() | {:service_opts, [service_opt()]}] @doc """ Generate URL for avatar using given service. ## Options Gravatar options supported are: - `:size` - pixel size of the resulting image. As returned avatars are always squares, only one side is needed. - `:default` - fallback image and/or implementation for image generation. Check out your implementation documentation for list of supported values. - `:robohash` - Libravatar and Gravatar support using additional option for `default: :robohash`. - `:forcedefault?` - force using default image (useful for testing) - `:rating` - require image of this rating or lower. Used for explicit image filtering. Ignored by Libravatar (that service requires all images to be `PG` rated). ### Service options It is possible to configure used service via options under `:service_opts` configuration key. Recognised options are: - `:host` - implementation that will be used for generating avatar. Possible options are: + `:gravatar` - [Gravatar](https://gravatar.com) + `:libravatar` - [Libravatar](https://www.libravatar.org) - federated and open source service providing Gravatar-compatible API + 2-ary function passed either as a capture or `{m, f, a}` tuple. In case of MFA tuple the arguments will be prepended to passed list. Arguments appended are: domain of the `email` and `opts` passed to this function. Defaults to `Application.get_env(:aww, :default, :libravatar)`. - `:secure?` - whether to use secure (HTTPS) connection to the service. Defaults to `true`. - `:resolv_opts` - used by `host: :libravatar` - additional opts passed to DNS resolver. See `t::inet_res.res_option/0` for details. - `:timeout` - DNS query timeout in milliseconds. Defaults to `5000` (5 seconds). """ @spec avatar_url(String.t(), keyword()) :: URI.t() def avatar_url(email, opts \\ []) do {service_opts, avatar_opts} = Keyword.pop(opts, :service_opts, []) default_host = Application.fetch_env!(:aww, :default) secure? = Keyword.get(service_opts, :secure?, true) {domain, normalized} = normalize(email) {host, port} = case host(service_opts[:host] || default_host, domain, service_opts) do {host, port} -> {host, port} host when is_binary(host) -> {host, nil} end hash = :crypto.hash(:sha256, normalized) %URI{ scheme: if(secure?, do: "https", else: "http"), host: host, port: port, path: "/avatar/#{Base.encode16(hash, case: :lower)}", query: query(avatar_opts) } end defp host(kind, domain, opts) defp host(:gravatar, _domain, opts) do if Keyword.get(opts, :secure?, true) do "secure.gravatar.com" else "gravatar.com" end end defp host(host, _domain, _opts) when is_binary(host), do: {host, nil} defp host(%URI{host: host, port: port}, _domain, _opts), do: {host, port} defp host(:libravatar, "", opts) do if Keyword.get(opts, :secure?, true) do "seccdn.libravatar.org" else "cdn.libravatar.org" end end defp host(:libravatar, domain, opts) do secure? = Keyword.get(opts, :secure?, true) cache = Keyword.get(opts, :cache, @default_cache) {fallback, query} = if secure? do {"seccdn.libravatar.org", "_avatars-sec._tcp.#{domain}"} else { "cdn.libravatar.org", "_avatars._tcp.#{domain}" } end Cache.get_or_store(cache, query, fn _ -> resolv_opts = opts[:resolv_opts] || [] timeout = opts[:timeout] || 5000 defederated = opts[:defederated] || Application.fetch_env!(:aww, :defederated) case dns_resp(query, resolv_opts, timeout) do {:ok, ttl, {host, _} = result} -> if host in defederated do Logger.debug( %{ kind: :ignore, reason: :defederated, host: host, list: defederated }, report_cb: &__MODULE__.__report_cb__/1 ) {fallback, 60_000} else {result, :timer.seconds(ttl)} end _ -> {fallback, 60_000} end end) end defp host({m, f, a}, domain, opts) when is_atom(m) and is_atom(f) and is_list(a) do apply(m, f, [domain, opts | a]) end defp host(func, domain, opts) when is_function(func, 2) do func.(domain, opts) end defp query(opts) do size = opts[:size] || opts[:s] default = opts[:default] || opts[:d] robohash = opts[:robohash] force = (opts[:forcedefault?] || opts[:f]) && "y" rating = opts[:rating] || opts[:r] [ s: size, d: default, f: force, r: rating, robohash: robohash ] |> Enum.filter(&elem(&1, 1)) |> Map.new() |> URI.encode_query() end defp normalize(input) do trimmed = String.trim(input) case String.downcase(trimmed) do "http://" <> _ -> normalize_oauth(trimmed) "https://" <> _ -> normalize_oauth(trimmed) other -> domain = split_last(other, "@") {domain, other} end end defp normalize_oauth(input) do uri = URI.parse(input) host = String.downcase(uri.host) {host, URI.to_string(%URI{uri | host: host})} end defp split_last(input, pattern) when is_binary(input) do case :string.find(input, pattern, :trailing) do ^pattern <> rest -> rest _ -> "" end end defp dns_resp(req_host, opts, timeout) do start = System.monotonic_time() resp = resolve(req_host, opts, timeout) finish = System.monotonic_time() case resp do {:ok, ttl, {resp_host, port}} -> :telemetry.execute( [:aww, :dns, :resolve, :success], %{duration: finish - start, ttl: ttl}, %{ req_host: req_host, resp_host: resp_host, resp_port: port } ) :error -> :telemetry.execute( [:aww, :dns, :resolve, :failed], %{duration: finish - start}, %{req_host: req_host} ) end resp end defp resolve(host, opts, timeout) do with {:ok, msg} <- :inet_res.resolve( to_charlist(host), :in, :srv, opts, timeout ), [rr | _] <- :inet_dns.msg(msg, :anlist) do ttl = :inet_dns.rr(rr, :ttl) {_, _, port, host} = :inet_dns.rr(rr, :data) {:ok, ttl, {to_string(host), port}} else _ -> :error end catch _, _ -> :error end @doc false def __report_cb__(%{kind: :ignore, reason: :defederated, host: host}) do {"Ignoring avatar from ~p, because it was defederated", [host]} end end