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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
9.0 KiB · text
History
b868907