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.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"
# 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)
{: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)
: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 "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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
7.5 KiB · text
History
4b2ca85