9.0 KiB · text History 709545e
defmodule GitGud.Avatars do
  @moduledoc """
  Avatars for users, organizations and repositories.

  Until now the avatar *was* the identicon — deterministic, themed, and
  nobody's to choose. This adds an uploaded one on top, without taking
  the identicon away: `render/1` answers with the upload if there is
  one and the generated PNG otherwise, so nothing downstream has to
  branch on whether a given owner has bothered.

  That is what makes the F-Droid repository icon follow an org's
  uploaded avatar for free — the adapter asks for bytes, not for a file.

  Bytes live in `GitGud.Storage` alongside LFS objects and CI
  artifacts. The row keeps the pointer plus what a response needs
  without fetching them.

  ## Cache busting

  The serving URL carries the content digest, so replacing an avatar
  changes the URL. No cache anywhere has to be asked to forget the old
  one, which is the failure mode every avatar feature has.
  """

  import Ecto.Query, warn: false

  alias GitGud.Accounts.User
  alias GitGud.Cache
  alias GitGud.Avatars.Avatar
  alias GitGud.Avatars.Identicon
  alias GitGud.Organizations.Organization
  alias GitGud.Repo
  alias GitGud.Repositories.Repository
  alias GitGud.Repositories.Storage, as: RepoStorage
  alias GitGud.Storage

  @bucket "gitgud-avatars"

  @handles_key {:avatars, :uploaded_handles}

  # Generous for a picture that renders at 96px, tight enough that a
  # misdirected upload fails fast rather than filling a bucket.
  @max_bytes 2 * 1024 * 1024

  @type owner :: %User{} | %Organization{} | %Repository{}

  @doc "The avatar row for `owner`, or `nil`."
  @spec get(owner()) :: Avatar.t() | nil
  def get(owner) do
    {type, id} = owner_key(owner)
    Repo.get_by(Avatar, owner_type: type, owner_id: id)
  end

  @doc """
  Store `body` as `owner`'s avatar, replacing any existing one.

  The content type is taken from the bytes, not from what the client
  claimed: a browser will render an image by sniffing regardless of the
  header we were handed, so the header is not a safety property.
  """
  @spec put(owner(), binary()) :: {:ok, Avatar.t()} | {:error, term()}
  def put(_owner, body) when byte_size(body) > @max_bytes,
    do: {:error, :too_large}

  def put(_owner, <<>>), do: {:error, :empty}

  def put(owner, body) when is_binary(body) do
    with {:ok, content_type} <- detect_type(body) do
      {type, id} = owner_key(owner)
      sha256 = :sha256 |> :crypto.hash(body) |> Base.encode16(case: :lower)
      key = "#{type}/#{id}/#{sha256}"

      with :ok <- Storage.put(@bucket, key, body, content_type: content_type) do
        previous = get(owner)

        attrs = %{
          owner_type: type,
          owner_id: id,
          storage_bucket: @bucket,
          storage_key: key,
          content_type: content_type,
          size: byte_size(body),
          sha256: sha256
        }

        result =
          (previous || %Avatar{})
          |> Avatar.changeset(attrs)
          |> Repo.insert_or_update()

        # The old object goes only after the row points at the new one.
        # The other order risks a window where the row names an object
        # that is already gone.
        with {:ok, avatar} <- result do
          discard(previous, avatar)
          invalidate_handles()
          {:ok, avatar}
        end
      end
    end
  end

  @doc "Remove `owner`'s uploaded avatar. The identicon takes over again."
  @spec delete(owner()) :: :ok
  def delete(owner) do
    case get(owner) do
      nil ->
        :ok

      avatar ->
        Repo.delete(avatar)
        _ = Storage.delete(avatar.storage_bucket, avatar.storage_key)
        invalidate_handles()
        :ok
    end
  end

  @doc """
  The bytes to serve for `owner`: the uploaded avatar, or the generated
  identicon.

  Every caller gets an image. An owner with no upload is not an error
  and not a missing file, it is an owner whose avatar is generated —
  which is what it has always been.
  """
  @spec render(owner()) :: {:ok, String.t(), binary()}
  def render(owner) do
    case get(owner) do
      nil ->
        {:ok, Identicon.content_type(), Identicon.png(seed(owner))}

      avatar ->
        case Storage.get(avatar.storage_bucket, avatar.storage_key) do
          {:ok, body} ->
            {:ok, avatar.content_type, body}

          # The row says there is an upload and the store disagrees.
          # Falling back beats a broken image: the identicon is still
          # a correct answer to "what does this owner look like".
          {:error, _} ->
            {:ok, Identicon.content_type(), Identicon.png(seed(owner))}
        end
    end
  end

  @doc """
  The URL an `<img>` points at, digest included so a replacement is a
  different URL. `nil` for an owner we can't name.
  """
  @spec url(owner()) :: String.t() | nil
  def url(owner) do
    case path(owner) do
      nil -> nil
      base -> base <> "?v=" <> version(owner)
    end
  end

  defp path(%User{handle: h}) when is_binary(h), do: "/u/#{h}/avatar"
  defp path(%Organization{handle: h}) when is_binary(h), do: "/orgs/#{h}/avatar"

  defp path(%Repository{} = repo) do
    repo = Repo.preload(repo, [:owner, :organization])
    "/r/#{RepoStorage.repo_handle(repo)}/#{repo.name}/avatar"
  end

  defp path(_), do: nil

  # The upload's digest, or a short digest of the seed so the identicon
  # gets a stable, cacheable URL too.
  defp version(owner) do
    case get(owner) do
      nil ->
        :sha256 |> :crypto.hash(seed(owner)) |> Base.encode16(case: :lower) |> binary_part(0, 12)

      avatar ->
        binary_part(avatar.sha256, 0, 12)
    end
  end

  @doc """
  The string an owner's identicon is generated from. Stable for the
  life of the name, which is the point — a regenerated identicon is a
  different face.
  """
  @spec seed(owner()) :: String.t()
  def seed(%User{handle: h}) when is_binary(h), do: h
  def seed(%User{email: email}) when is_binary(email), do: email
  def seed(%Organization{handle: h}), do: h

  def seed(%Repository{} = repo) do
    repo = Repo.preload(repo, [:owner, :organization])
    "#{RepoStorage.repo_handle(repo)}/#{repo.name}"
  end

  @doc """
  The handles that actually have an uploaded avatar.

  `AvatarComponent` renders an `<img>` for these and its themed SVG
  identicon for everyone else, and it is asked once per avatar on a
  page — a members list is fifty of them. So this is one cached query
  returning a set of strings rather than a lookup per render, and the
  set is small by nature: it holds only the people who have bothered.

  Cached rather than computed per request, and invalidated on write, so
  an upload shows up immediately rather than within a TTL. Getting that
  backwards is how an avatar feature earns "I changed it and nothing
  happened".
  """
  @spec uploaded_handles() :: MapSet.t(String.t())
  def uploaded_handles do
    case Cache.get(@handles_key) do
      nil ->
        handles = load_uploaded_handles()
        Cache.put(@handles_key, handles, ttl: :timer.hours(1))
        handles

      handles ->
        handles
    end
  end

  defp load_uploaded_handles do
    users =
      from(a in Avatar,
        join: u in User,
        on: u.id == a.owner_id,
        where: a.owner_type == "user" and not is_nil(u.handle),
        select: u.handle
      )
      |> Repo.all()

    orgs =
      from(a in Avatar,
        join: o in Organization,
        on: o.id == a.owner_id,
        where: a.owner_type == "organization",
        select: o.handle
      )
      |> Repo.all()

    MapSet.new(users ++ orgs)
  end

  defp invalidate_handles, do: Cache.delete(@handles_key)

  @doc "Largest upload accepted, in bytes."
  def max_bytes, do: @max_bytes

  @doc false
  def bucket, do: @bucket

  # ── internals ────────────────────────────────────────────────────────

  defp owner_key(%User{id: id}), do: {"user", id}
  defp owner_key(%Organization{id: id}), do: {"organization", id}
  defp owner_key(%Repository{id: id}), do: {"repository", id}

  # Magic bytes, not the `Content-Type` header. A browser sniffs the
  # body to decide how to render it, so what the client claimed is not
  # a safety property — and "it said PNG" is exactly how an HTML file
  # ends up being served from your own origin.
  defp detect_type(<<137, 80, 78, 71, 13, 10, 26, 10, _::binary>>), do: {:ok, "image/png"}
  defp detect_type(<<0xFF, 0xD8, 0xFF, _::binary>>), do: {:ok, "image/jpeg"}
  defp detect_type(<<"GIF87a", _::binary>>), do: {:ok, "image/gif"}
  defp detect_type(<<"GIF89a", _::binary>>), do: {:ok, "image/gif"}

  defp detect_type(<<"RIFF", _size::binary-size(4), "WEBP", _::binary>>),
    do: {:ok, "image/webp"}

  defp detect_type(_), do: {:error, :unsupported_type}

  # Only when the key actually changed — re-uploading identical bytes
  # produces the same digest, and deleting then would remove the object
  # the new row points at.
  defp discard(nil, _new), do: :ok

  defp discard(%Avatar{storage_key: key}, %Avatar{storage_key: key}), do: :ok

  defp discard(%Avatar{} = previous, _new),
    do: Storage.delete(previous.storage_bucket, previous.storage_key) && :ok
end