neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Avatars you can upload, for users, orgs and repositories
4b2ca85 · Gabriel Morell · 2026-09-12 19:11
Message
{commit_body(@commit)}
Files changed
added
lib/git_gud/avatars.ex
+225
−0
@@ -0,0 +1,225 @@
| 1 | +defmodule GitGud.Avatars do | |
| 2 | + @moduledoc """ | |
| 3 | + Avatars for users, organizations and repositories. | |
| 4 | + | |
| 5 | + Until now the avatar *was* the identicon — deterministic, themed, and | |
| 6 | + nobody's to choose. This adds an uploaded one on top, without taking | |
| 7 | + the identicon away: `render/1` answers with the upload if there is | |
| 8 | + one and the generated PNG otherwise, so nothing downstream has to | |
| 9 | + branch on whether a given owner has bothered. | |
| 10 | + | |
| 11 | + That is what makes the F-Droid repository icon follow an org's | |
| 12 | + uploaded avatar for free — the adapter asks for bytes, not for a file. | |
| 13 | + | |
| 14 | + Bytes live in `GitGud.Storage` alongside LFS objects and CI | |
| 15 | + artifacts. The row keeps the pointer plus what a response needs | |
| 16 | + without fetching them. | |
| 17 | + | |
| 18 | + ## Cache busting | |
| 19 | + | |
| 20 | + The serving URL carries the content digest, so replacing an avatar | |
| 21 | + changes the URL. No cache anywhere has to be asked to forget the old | |
| 22 | + one, which is the failure mode every avatar feature has. | |
| 23 | + """ | |
| 24 | + | |
| 25 | + import Ecto.Query, warn: false | |
| 26 | + | |
| 27 | + alias GitGud.Accounts.User | |
| 28 | + alias GitGud.Avatars.Avatar | |
| 29 | + alias GitGud.Avatars.Identicon | |
| 30 | + alias GitGud.Organizations.Organization | |
| 31 | + alias GitGud.Repo | |
| 32 | + alias GitGud.Repositories.Repository | |
| 33 | + alias GitGud.Repositories.Storage, as: RepoStorage | |
| 34 | + alias GitGud.Storage | |
| 35 | + | |
| 36 | + @bucket "gitgud-avatars" | |
| 37 | + | |
| 38 | + # Generous for a picture that renders at 96px, tight enough that a | |
| 39 | + # misdirected upload fails fast rather than filling a bucket. | |
| 40 | + @max_bytes 2 * 1024 * 1024 | |
| 41 | + | |
| 42 | + @type owner :: %User{} | %Organization{} | %Repository{} | |
| 43 | + | |
| 44 | + @doc "The avatar row for `owner`, or `nil`." | |
| 45 | + @spec get(owner()) :: Avatar.t() | nil | |
| 46 | + def get(owner) do | |
| 47 | + {type, id} = owner_key(owner) | |
| 48 | + Repo.get_by(Avatar, owner_type: type, owner_id: id) | |
| 49 | + end | |
| 50 | + | |
| 51 | + @doc """ | |
| 52 | + Store `body` as `owner`'s avatar, replacing any existing one. | |
| 53 | + | |
| 54 | + The content type is taken from the bytes, not from what the client | |
| 55 | + claimed: a browser will render an image by sniffing regardless of the | |
| 56 | + header we were handed, so the header is not a safety property. | |
| 57 | + """ | |
| 58 | + @spec put(owner(), binary()) :: {:ok, Avatar.t()} | {:error, term()} | |
| 59 | + def put(_owner, body) when byte_size(body) > @max_bytes, | |
| 60 | + do: {:error, :too_large} | |
| 61 | + | |
| 62 | + def put(_owner, <<>>), do: {:error, :empty} | |
| 63 | + | |
| 64 | + def put(owner, body) when is_binary(body) do | |
| 65 | + with {:ok, content_type} <- detect_type(body) do | |
| 66 | + {type, id} = owner_key(owner) | |
| 67 | + sha256 = :sha256 |> :crypto.hash(body) |> Base.encode16(case: :lower) | |
| 68 | + key = "#{type}/#{id}/#{sha256}" | |
| 69 | + | |
| 70 | + with :ok <- Storage.put(@bucket, key, body, content_type: content_type) do | |
| 71 | + previous = get(owner) | |
| 72 | + | |
| 73 | + attrs = %{ | |
| 74 | + owner_type: type, | |
| 75 | + owner_id: id, | |
| 76 | + storage_bucket: @bucket, | |
| 77 | + storage_key: key, | |
| 78 | + content_type: content_type, | |
| 79 | + size: byte_size(body), | |
| 80 | + sha256: sha256 | |
| 81 | + } | |
| 82 | + | |
| 83 | + result = | |
| 84 | + (previous || %Avatar{}) | |
| 85 | + |> Avatar.changeset(attrs) | |
| 86 | + |> Repo.insert_or_update() | |
| 87 | + | |
| 88 | + # The old object goes only after the row points at the new one. | |
| 89 | + # The other order risks a window where the row names an object | |
| 90 | + # that is already gone. | |
| 91 | + with {:ok, avatar} <- result do | |
| 92 | + discard(previous, avatar) | |
| 93 | + {:ok, avatar} | |
| 94 | + end | |
| 95 | + end | |
| 96 | + end | |
| 97 | + end | |
| 98 | + | |
| 99 | + @doc "Remove `owner`'s uploaded avatar. The identicon takes over again." | |
| 100 | + @spec delete(owner()) :: :ok | |
| 101 | + def delete(owner) do | |
| 102 | + case get(owner) do | |
| 103 | + nil -> | |
| 104 | + :ok | |
| 105 | + | |
| 106 | + avatar -> | |
| 107 | + Repo.delete(avatar) | |
| 108 | + _ = Storage.delete(avatar.storage_bucket, avatar.storage_key) | |
| 109 | + :ok | |
| 110 | + end | |
| 111 | + end | |
| 112 | + | |
| 113 | + @doc """ | |
| 114 | + The bytes to serve for `owner`: the uploaded avatar, or the generated | |
| 115 | + identicon. | |
| 116 | + | |
| 117 | + Every caller gets an image. An owner with no upload is not an error | |
| 118 | + and not a missing file, it is an owner whose avatar is generated — | |
| 119 | + which is what it has always been. | |
| 120 | + """ | |
| 121 | + @spec render(owner()) :: {:ok, String.t(), binary()} | |
| 122 | + def render(owner) do | |
| 123 | + case get(owner) do | |
| 124 | + nil -> | |
| 125 | + {:ok, Identicon.content_type(), Identicon.png(seed(owner))} | |
| 126 | + | |
| 127 | + avatar -> | |
| 128 | + case Storage.get(avatar.storage_bucket, avatar.storage_key) do | |
| 129 | + {:ok, body} -> | |
| 130 | + {:ok, avatar.content_type, body} | |
| 131 | + | |
| 132 | + # The row says there is an upload and the store disagrees. | |
| 133 | + # Falling back beats a broken image: the identicon is still | |
| 134 | + # a correct answer to "what does this owner look like". | |
| 135 | + {:error, _} -> | |
| 136 | + {:ok, Identicon.content_type(), Identicon.png(seed(owner))} | |
| 137 | + end | |
| 138 | + end | |
| 139 | + end | |
| 140 | + | |
| 141 | + @doc """ | |
| 142 | + The URL an `<img>` points at, digest included so a replacement is a | |
| 143 | + different URL. `nil` for an owner we can't name. | |
| 144 | + """ | |
| 145 | + @spec url(owner()) :: String.t() | nil | |
| 146 | + def url(owner) do | |
| 147 | + case path(owner) do | |
| 148 | + nil -> nil | |
| 149 | + base -> base <> "?v=" <> version(owner) | |
| 150 | + end | |
| 151 | + end | |
| 152 | + | |
| 153 | + defp path(%User{handle: h}) when is_binary(h), do: "/u/#{h}/avatar" | |
| 154 | + defp path(%Organization{handle: h}) when is_binary(h), do: "/orgs/#{h}/avatar" | |
| 155 | + | |
| 156 | + defp path(%Repository{} = repo) do | |
| 157 | + repo = Repo.preload(repo, [:owner, :organization]) | |
| 158 | + "/r/#{RepoStorage.repo_handle(repo)}/#{repo.name}/avatar" | |
| 159 | + end | |
| 160 | + | |
| 161 | + defp path(_), do: nil | |
| 162 | + | |
| 163 | + # The upload's digest, or a short digest of the seed so the identicon | |
| 164 | + # gets a stable, cacheable URL too. | |
| 165 | + defp version(owner) do | |
| 166 | + case get(owner) do | |
| 167 | + nil -> | |
| 168 | + :sha256 |> :crypto.hash(seed(owner)) |> Base.encode16(case: :lower) |> binary_part(0, 12) | |
| 169 | + | |
| 170 | + avatar -> | |
| 171 | + binary_part(avatar.sha256, 0, 12) | |
| 172 | + end | |
| 173 | + end | |
| 174 | + | |
| 175 | + @doc """ | |
| 176 | + The string an owner's identicon is generated from. Stable for the | |
| 177 | + life of the name, which is the point — a regenerated identicon is a | |
| 178 | + different face. | |
| 179 | + """ | |
| 180 | + @spec seed(owner()) :: String.t() | |
| 181 | + def seed(%User{handle: h}) when is_binary(h), do: h | |
| 182 | + def seed(%User{email: email}) when is_binary(email), do: email | |
| 183 | + def seed(%Organization{handle: h}), do: h | |
| 184 | + | |
| 185 | + def seed(%Repository{} = repo) do | |
| 186 | + repo = Repo.preload(repo, [:owner, :organization]) | |
| 187 | + "#{RepoStorage.repo_handle(repo)}/#{repo.name}" | |
| 188 | + end | |
| 189 | + | |
| 190 | + @doc "Largest upload accepted, in bytes." | |
| 191 | + def max_bytes, do: @max_bytes | |
| 192 | + | |
| 193 | + @doc false | |
| 194 | + def bucket, do: @bucket | |
| 195 | + | |
| 196 | + # ── internals ──────────────────────────────────────────────────────── | |
| 197 | + | |
| 198 | + defp owner_key(%User{id: id}), do: {"user", id} | |
| 199 | + defp owner_key(%Organization{id: id}), do: {"organization", id} | |
| 200 | + defp owner_key(%Repository{id: id}), do: {"repository", id} | |
| 201 | + | |
| 202 | + # Magic bytes, not the `Content-Type` header. A browser sniffs the | |
| 203 | + # body to decide how to render it, so what the client claimed is not | |
| 204 | + # a safety property — and "it said PNG" is exactly how an HTML file | |
| 205 | + # ends up being served from your own origin. | |
| 206 | + defp detect_type(<<137, 80, 78, 71, 13, 10, 26, 10, _::binary>>), do: {:ok, "image/png"} | |
| 207 | + defp detect_type(<<0xFF, 0xD8, 0xFF, _::binary>>), do: {:ok, "image/jpeg"} | |
| 208 | + defp detect_type(<<"GIF87a", _::binary>>), do: {:ok, "image/gif"} | |
| 209 | + defp detect_type(<<"GIF89a", _::binary>>), do: {:ok, "image/gif"} | |
| 210 | + | |
| 211 | + defp detect_type(<<"RIFF", _size::binary-size(4), "WEBP", _::binary>>), | |
| 212 | + do: {:ok, "image/webp"} | |
| 213 | + | |
| 214 | + defp detect_type(_), do: {:error, :unsupported_type} | |
| 215 | + | |
| 216 | + # Only when the key actually changed — re-uploading identical bytes | |
| 217 | + # produces the same digest, and deleting then would remove the object | |
| 218 | + # the new row points at. | |
| 219 | + defp discard(nil, _new), do: :ok | |
| 220 | + | |
| 221 | + defp discard(%Avatar{storage_key: key}, %Avatar{storage_key: key}), do: :ok | |
| 222 | + | |
| 223 | + defp discard(%Avatar{} = previous, _new), | |
| 224 | + do: Storage.delete(previous.storage_bucket, previous.storage_key) && :ok | |
| 225 | +end |
added
lib/git_gud/avatars/avatar.ex
+70
−0
@@ -0,0 +1,70 @@
| 1 | +defmodule GitGud.Avatars.Avatar do | |
| 2 | + @moduledoc """ | |
| 3 | + An uploaded avatar for a user, organization or repository. | |
| 4 | + | |
| 5 | + Polymorphic on `{owner_type, owner_id}`, the same shape `Labels` | |
| 6 | + uses — the three owners want identical fields, and one table means | |
| 7 | + the serving path and the settings form exist once rather than three | |
| 8 | + times. | |
| 9 | + | |
| 10 | + The bytes are in `GitGud.Storage`; this row is the pointer plus what | |
| 11 | + a response needs without fetching them. | |
| 12 | + """ | |
| 13 | + use Ecto.Schema | |
| 14 | + | |
| 15 | + import Ecto.Changeset | |
| 16 | + | |
| 17 | + @owner_types ~w(user organization repository) | |
| 18 | + | |
| 19 | + # What a browser will actually render, and nothing that can carry | |
| 20 | + # script. SVG is deliberately absent: it is a document format with | |
| 21 | + # script and external references in it, and serving one from the | |
| 22 | + # forge's own origin would be a stored XSS with extra steps. | |
| 23 | + @content_types ~w(image/png image/jpeg image/gif image/webp) | |
| 24 | + | |
| 25 | + schema "avatars" do | |
| 26 | + field :owner_type, :string | |
| 27 | + field :owner_id, :integer | |
| 28 | + | |
| 29 | + field :storage_bucket, :string | |
| 30 | + field :storage_key, :string | |
| 31 | + field :content_type, :string | |
| 32 | + field :size, :integer, default: 0 | |
| 33 | + field :sha256, :string | |
| 34 | + | |
| 35 | + timestamps(type: :utc_datetime) | |
| 36 | + end | |
| 37 | + | |
| 38 | + @doc false | |
| 39 | + def changeset(avatar, attrs) do | |
| 40 | + avatar | |
| 41 | + |> cast(attrs, [ | |
| 42 | + :owner_type, | |
| 43 | + :owner_id, | |
| 44 | + :storage_bucket, | |
| 45 | + :storage_key, | |
| 46 | + :content_type, | |
| 47 | + :size, | |
| 48 | + :sha256 | |
| 49 | + ]) | |
| 50 | + |> validate_required([ | |
| 51 | + :owner_type, | |
| 52 | + :owner_id, | |
| 53 | + :storage_bucket, | |
| 54 | + :storage_key, | |
| 55 | + :content_type, | |
| 56 | + :sha256 | |
| 57 | + ]) | |
| 58 | + |> validate_inclusion(:owner_type, @owner_types) | |
| 59 | + |> validate_inclusion(:content_type, @content_types, | |
| 60 | + message: "must be a PNG, JPEG, GIF or WebP image" | |
| 61 | + ) | |
| 62 | + |> unique_constraint([:owner_type, :owner_id]) | |
| 63 | + end | |
| 64 | + | |
| 65 | + @doc "Owner kinds an avatar can belong to." | |
| 66 | + def owner_types, do: @owner_types | |
| 67 | + | |
| 68 | + @doc "Image types accepted on upload." | |
| 69 | + def content_types, do: @content_types | |
| 70 | +end |
renamed
lib/git_gud/packages/formats/fdroid/icon.ex → lib/git_gud/avatars/identicon.ex
+6
−10
@@ -1,7 +1,7 @@
| 1 | −defmodule GitGud.Packages.Formats.Fdroid.Icon do | |
| 1 | +defmodule GitGud.Avatars.Identicon do | |
| 2 | 2 | @moduledoc """ |
| 3 | − Renders a namespace's identicon as a PNG, for the F-Droid repository | |
| 4 | − icon. | |
| 3 | + Renders an identicon as a PNG — the fallback avatar for anyone who | |
| 4 | + hasn't uploaded one, and the F-Droid repository icon. | |
| 5 | 5 | |
| 6 | 6 | The web avatar (`GitGudWeb.AvatarComponent`) is an inline SVG whose |
| 7 | 7 | fills are `var(--color-primary)` and friends, so it recolors when the |
@@ -11,8 +11,8 @@ defmodule GitGud.Packages.Formats.Fdroid.Icon do
| 11 | 11 | raster with concrete colors. |
| 12 | 12 | |
| 13 | 13 | It is the *same* grid — `AvatarComponent.render_spec/1` is the single |
| 14 | − source of both, so the icon in F-Droid is the avatar on the profile | |
| 15 | − page and not merely something similar. | |
| 14 | + source of both, so the PNG is the avatar on the profile page and not | |
| 15 | + merely something similar. | |
| 16 | 16 | |
| 17 | 17 | ## Why a hand-written PNG |
| 18 | 18 |
@@ -70,14 +70,10 @@ defmodule GitGud.Packages.Formats.Fdroid.Icon do
| 70 | 70 | @signature <> ihdr() <> idat(filled, foreground) <> iend() |
| 71 | 71 | end |
| 72 | 72 | |
| 73 | − @doc "Content type, for the response and the index entry." | |
| 73 | + @doc "Content type of what `png/1` returns." | |
| 74 | 74 | @spec content_type() :: String.t() |
| 75 | 75 | def content_type, do: "image/png" |
| 76 | 76 | |
| 77 | − @doc "The filename the index points at." | |
| 78 | − @spec filename() :: String.t() | |
| 79 | − def filename, do: "icon.png" | |
| 80 | − | |
| 81 | 77 | # ── chunks ─────────────────────────────────────────────────────────── |
| 82 | 78 | |
| 83 | 79 | defp ihdr do |
modified
lib/git_gud/packages/formats/fdroid.ex
+32
−19
@@ -46,8 +46,11 @@ defmodule GitGud.Packages.Formats.Fdroid do
| 46 | 46 | nothing else. The index falls back to the package name, and the |
| 47 | 47 | repository description comes from the git repository's own. |
| 48 | 48 | |
| 49 | − The *repository* icon is the namespace's identicon — the same grid | |
| 50 | − the profile page draws — rendered to a PNG by `Fdroid.Icon`. | |
| 49 | + The *repository* icon is the namespace's avatar, via `GitGud.Avatars` | |
| 50 | + — an uploaded one if the org has set one, and the identicon the | |
| 51 | + profile page draws otherwise. Seeded on the namespace rather than the | |
| 52 | + repository: a repo under `neiam` shows neiam, which is what `_all` | |
| 53 | + shows anyway. | |
| 51 | 54 | |
| 52 | 55 | ## signer, and why it is the load-bearing field |
| 53 | 56 |
@@ -67,7 +70,7 @@ defmodule GitGud.Packages.Formats.Fdroid do
| 67 | 70 | |
| 68 | 71 | alias GitGud.Packages |
| 69 | 72 | alias GitGud.Packages.Formats.Fdroid.Apk |
| 70 | − alias GitGud.Packages.Formats.Fdroid.Icon | |
| 73 | + alias GitGud.Avatars | |
| 71 | 74 | alias GitGud.Packages.Formats.Fdroid.Jar |
| 72 | 75 | alias GitGud.Packages.PackageFile |
| 73 | 76 | alias GitGud.Repositories.Repository |
@@ -174,8 +177,10 @@ defmodule GitGud.Packages.Formats.Fdroid do
| 174 | 177 | # profile page draws, baked into a PNG because a client has no |
| 175 | 178 | # stylesheet to resolve the web avatar's CSS variables against. |
| 176 | 179 | defp serve(_repos, _label, scope, %{path: ["icons", filename]}) do |
| 177 | − if filename == Icon.filename() do | |
| 178 | − {:ok, Icon.content_type(), Icon.png(icon_seed(scope))} | |
| 180 | + {content_type, body} = icon(scope) | |
| 181 | + | |
| 182 | + if filename == icon_filename(content_type) do | |
| 183 | + {:ok, content_type, body} | |
| 179 | 184 | else |
| 180 | 185 | {:error, {404, "no such icon"}} |
| 181 | 186 | end |
@@ -225,15 +230,32 @@ defmodule GitGud.Packages.Formats.Fdroid do
| 225 | 230 | # a client can tell a changed icon from an unchanged one without |
| 226 | 231 | # fetching it. Deterministic rendering is what makes that work. |
| 227 | 232 | defp icon_file(scope) do |
| 228 | − png = Icon.png(icon_seed(scope)) | |
| 233 | + {content_type, body} = icon(scope) | |
| 229 | 234 | |
| 230 | 235 | %{ |
| 231 | − "name" => "/icons/" <> Icon.filename(), | |
| 232 | − "sha256" => :sha256 |> :crypto.hash(png) |> Base.encode16(case: :lower), | |
| 233 | − "size" => byte_size(png) | |
| 236 | + "name" => "/icons/" <> icon_filename(content_type), | |
| 237 | + "sha256" => :sha256 |> :crypto.hash(body) |> Base.encode16(case: :lower), | |
| 238 | + "size" => byte_size(body) | |
| 234 | 239 | } |
| 235 | 240 | end |
| 236 | 241 | |
| 242 | + # Whatever the namespace's avatar is — an upload if there is one, | |
| 243 | + # the identicon otherwise. `Avatars.render/1` makes that one call | |
| 244 | + # rather than a branch here. | |
| 245 | + defp icon(scope), | |
| 246 | + do: scope |> icon_owner() |> Avatars.render() |> then(fn {:ok, t, b} -> {t, b} end) | |
| 247 | + | |
| 248 | + defp icon_owner(nil), do: %GitGud.Accounts.User{handle: "gitgud"} | |
| 249 | + defp icon_owner(scope), do: GitGud.Packages.Signing.namespace(scope) | |
| 250 | + | |
| 251 | + # The index names the icon by filename, so the extension has to match | |
| 252 | + # what is actually served — a JPEG at `icon.png` is a file every | |
| 253 | + # client will fetch and some will refuse to draw. | |
| 254 | + defp icon_filename("image/jpeg"), do: "icon.jpg" | |
| 255 | + defp icon_filename("image/gif"), do: "icon.gif" | |
| 256 | + defp icon_filename("image/webp"), do: "icon.webp" | |
| 257 | + defp icon_filename(_png), do: "icon.png" | |
| 258 | + | |
| 237 | 259 | defp package_entry(name, group) do |
| 238 | 260 | added = group |> Enum.map(& &1.added) |> Enum.min() |
| 239 | 261 | updated = group |> Enum.map(& &1.added) |> Enum.max() |
@@ -294,7 +316,7 @@ defmodule GitGud.Packages.Formats.Fdroid do
| 294 | 316 | "timestamp" => repo_timestamp(apks), |
| 295 | 317 | "version" => @index_v1_version, |
| 296 | 318 | "name" => label, |
| 297 | − "icon" => Icon.filename(), | |
| 319 | + "icon" => icon_filename(elem(icon(scope), 0)), | |
| 298 | 320 | "address" => repo_url(ctx.base_url), |
| 299 | 321 | "description" => description(repos, label) |
| 300 | 322 | }, |
@@ -497,15 +519,6 @@ defmodule GitGud.Packages.Formats.Fdroid do
| 497 | 519 | # An empty repository reports 0 rather than the current time: it has |
| 498 | 520 | # never been updated, and saying "just now" every time anyone asks is |
| 499 | 521 | # both false and a rollback the next real publish has to climb out of. |
| 500 | − # The *namespace* identicon, not the repository's. A repository under | |
| 501 | − # `neiam` shows neiam's avatar, the same one the profile page draws — | |
| 502 | − # which is the point of the icon, and is what `_all` would show | |
| 503 | − # anyway. | |
| 504 | − defp icon_seed(nil), do: "gitgud" | |
| 505 | − | |
| 506 | − defp icon_seed(scope), | |
| 507 | − do: scope |> GitGud.Packages.Signing.namespace() |> Storage.owner_handle() | |
| 508 | − | |
| 509 | 522 | defp repo_timestamp([]), do: 0 |
| 510 | 523 | defp repo_timestamp(apks), do: apks |> Enum.map(& &1.added) |> Enum.max() |
| 511 | 524 |
added
lib/git_gud_web/controllers/avatar_controller.ex
+70
−0
@@ -0,0 +1,70 @@
| 1 | +defmodule GitGudWeb.AvatarController do | |
| 2 | + @moduledoc """ | |
| 3 | + Serves avatars for users, organizations and repositories. | |
| 4 | + | |
| 5 | + GET /u/:handle/avatar | |
| 6 | + GET /orgs/:handle/avatar | |
| 7 | + GET /r/:owner/:name/avatar | |
| 8 | + | |
| 9 | + Always an image. An owner with no upload gets the generated | |
| 10 | + identicon, which is what the profile page has always drawn — so a | |
| 11 | + caller never has to handle "no avatar", and an `<img>` never has a | |
| 12 | + broken state. | |
| 13 | + | |
| 14 | + Unauthenticated on purpose. An avatar is decoration attached to a | |
| 15 | + handle that is already public, and requiring a session would break | |
| 16 | + every `<img>` in a federated context or an email. | |
| 17 | + """ | |
| 18 | + use GitGudWeb, :controller | |
| 19 | + | |
| 20 | + alias GitGud.Avatars | |
| 21 | + alias GitGud.Repositories | |
| 22 | + | |
| 23 | + # A year. Safe because the URL carries the content digest — a | |
| 24 | + # replaced avatar is a different URL, so nothing has to expire for | |
| 25 | + # the change to be seen. | |
| 26 | + @immutable_max_age 31_536_000 | |
| 27 | + | |
| 28 | + def user(conn, %{"handle" => handle}) do | |
| 29 | + case Repositories.resolve_namespace(handle) do | |
| 30 | + {:ok, owner} -> send_avatar(conn, owner) | |
| 31 | + :error -> send_resp(conn, 404, "") | |
| 32 | + end | |
| 33 | + end | |
| 34 | + | |
| 35 | + def organization(conn, params), do: user(conn, params) | |
| 36 | + | |
| 37 | + def repository(conn, %{"owner" => owner, "name" => name}) do | |
| 38 | + case Repositories.find_by_path(owner, name) do | |
| 39 | + nil -> send_resp(conn, 404, "") | |
| 40 | + repo -> send_avatar(conn, repo) | |
| 41 | + end | |
| 42 | + end | |
| 43 | + | |
| 44 | + defp send_avatar(conn, owner) do | |
| 45 | + {:ok, content_type, body} = Avatars.render(owner) | |
| 46 | + | |
| 47 | + conn | |
| 48 | + |> put_resp_content_type(content_type) | |
| 49 | + |> put_resp_header("cache-control", cache_control(conn)) | |
| 50 | + # Belt and braces on a path that serves user-supplied bytes: the | |
| 51 | + # type is sniffed from the body on upload, but a browser that | |
| 52 | + # decides to sniff again shouldn't be able to reach a different | |
| 53 | + # conclusion. | |
| 54 | + |> put_resp_header("x-content-type-options", "nosniff") | |
| 55 | + |> send_resp(200, body) | |
| 56 | + end | |
| 57 | + | |
| 58 | + # Only immutable when the request carried a version. A bare URL has | |
| 59 | + # to stay revalidatable, or a visitor who followed a link without one | |
| 60 | + # is stuck with a stale face for a year. | |
| 61 | + defp cache_control(conn) do | |
| 62 | + case conn.query_params do | |
| 63 | + %{"v" => v} when is_binary(v) and v != "" -> | |
| 64 | + "public, max-age=#{@immutable_max_age}, immutable" | |
| 65 | + | |
| 66 | + _ -> | |
| 67 | + "public, max-age=300" | |
| 68 | + end | |
| 69 | + end | |
| 70 | +end |
modified
lib/git_gud_web/router.ex
+8
−0
@@ -234,6 +234,14 @@ defmodule GitGudWeb.Router do
| 234 | 234 | scope "/", GitGudWeb do |
| 235 | 235 | pipe_through :browser |
| 236 | 236 | |
| 237 | + # Avatars. Controller routes rather than LiveView: these serve image | |
| 238 | + # bytes, they're referenced from <img> and from federated actors, | |
| 239 | + # and they're deliberately unauthenticated — an avatar decorates a | |
| 240 | + # handle that is already public. | |
| 241 | + get "/u/:handle/avatar", AvatarController, :user | |
| 242 | + get "/orgs/:handle/avatar", AvatarController, :organization | |
| 243 | + get "/r/:owner/:name/avatar", AvatarController, :repository | |
| 244 | + | |
| 237 | 245 | live_session :public_repo, |
| 238 | 246 | on_mount: [{GitGudWeb.UserAuth, :mount_current_scope}] do |
| 239 | 247 | # Public read-only browsing. Visibility is enforced inside the |
added
priv/repo/migrations/20260912190507_create_avatars.exs
+42
−0
@@ -0,0 +1,42 @@
| 1 | +defmodule GitGud.Repo.Migrations.CreateAvatars do | |
| 2 | + use Ecto.Migration | |
| 3 | + | |
| 4 | + @moduledoc """ | |
| 5 | + Uploaded avatars for users, organizations and repositories. | |
| 6 | + | |
| 7 | + One table rather than three sets of columns: the three owners want | |
| 8 | + exactly the same fields, and `Labels` already established the | |
| 9 | + `{target_type, id}` shape in this schema. It also means the serving | |
| 10 | + path and the settings form are written once. | |
| 11 | + | |
| 12 | + The bytes live in `GitGud.Storage`, like LFS objects and CI | |
| 13 | + artifacts. Only the pointer and the metadata needed to serve a | |
| 14 | + response — content type, size, digest — are here. | |
| 15 | + | |
| 16 | + No foreign key, for the same reason `labels` has none: a polymorphic | |
| 17 | + owner can't have one. Deletion is handled by the contexts that own | |
| 18 | + the rows. | |
| 19 | + """ | |
| 20 | + | |
| 21 | + def change do | |
| 22 | + create table(:avatars) do | |
| 23 | + add :owner_type, :string, null: false | |
| 24 | + add :owner_id, :bigint, null: false | |
| 25 | + | |
| 26 | + add :storage_bucket, :string, null: false | |
| 27 | + add :storage_key, :string, null: false | |
| 28 | + add :content_type, :string, null: false | |
| 29 | + add :size, :integer, null: false, default: 0 | |
| 30 | + | |
| 31 | + # Content digest, which doubles as the cache buster: the serving | |
| 32 | + # URL carries it, so a replaced avatar is a different URL and no | |
| 33 | + # proxy or browser has to be asked politely to forget the old one. | |
| 34 | + add :sha256, :string, null: false | |
| 35 | + | |
| 36 | + timestamps(type: :utc_datetime) | |
| 37 | + end | |
| 38 | + | |
| 39 | + # One avatar per owner. The upsert on replace depends on this. | |
| 40 | + create unique_index(:avatars, [:owner_type, :owner_id]) | |
| 41 | + end | |
| 42 | +end |
added
test/git_gud/avatars_test.exs
+155
−0
@@ -0,0 +1,155 @@
| 1 | +defmodule GitGud.AvatarsTest do | |
| 2 | + @moduledoc """ | |
| 3 | + Uploaded avatars, and the identicon that stands in when there isn't | |
| 4 | + one. | |
| 5 | + | |
| 6 | + The property worth defending is that `render/1` always answers with an | |
| 7 | + image. Every call site — the profile page, an `<img>`, the F-Droid | |
| 8 | + repository icon — is written against that, so a caller that has to | |
| 9 | + handle "no avatar" is a caller that will get it wrong somewhere. | |
| 10 | + """ | |
| 11 | + use GitGud.DataCase, async: false | |
| 12 | + | |
| 13 | + import GitGud.ForgeFixtures | |
| 14 | + | |
| 15 | + alias GitGud.Avatars | |
| 16 | + | |
| 17 | + # Minimal valid images, by magic bytes. The context sniffs the body | |
| 18 | + # rather than trusting a header, so these have to be real signatures. | |
| 19 | + @png <<137, 80, 78, 71, 13, 10, 26, 10>> <> :binary.copy(<<0>>, 32) | |
| 20 | + @jpeg <<0xFF, 0xD8, 0xFF, 0xE0>> <> :binary.copy(<<0>>, 32) | |
| 21 | + @gif "GIF89a" <> :binary.copy(<<0>>, 32) | |
| 22 | + @webp "RIFF" <> <<40::little-32>> <> "WEBP" <> :binary.copy(<<0>>, 32) | |
| 23 | + | |
| 24 | + setup do | |
| 25 | + {user, repo} = repository_fixture() | |
| 26 | + %{user: user, repo: repo} | |
| 27 | + end | |
| 28 | + | |
| 29 | + describe "render/1 without an upload" do | |
| 30 | + test "returns the identicon rather than nothing", %{user: user} do | |
| 31 | + assert {:ok, "image/png", png} = Avatars.render(user) | |
| 32 | + assert <<137, 80, 78, 71, 13, 10, 26, 10, _::binary>> = png | |
| 33 | + end | |
| 34 | + | |
| 35 | + test "is the same grid the profile page draws", %{user: user} do | |
| 36 | + {:ok, _, png} = Avatars.render(user) | |
| 37 | + | |
| 38 | + assert png == GitGud.Avatars.Identicon.png(Avatars.seed(user)) | |
| 39 | + end | |
| 40 | + | |
| 41 | + test "repositories and users get different faces", %{user: user, repo: repo} do | |
| 42 | + {:ok, _, for_user} = Avatars.render(user) | |
| 43 | + {:ok, _, for_repo} = Avatars.render(repo) | |
| 44 | + | |
| 45 | + refute for_user == for_repo | |
| 46 | + end | |
| 47 | + end | |
| 48 | + | |
| 49 | + describe "put/2" do | |
| 50 | + test "stores an upload and render/1 serves it back", %{user: user} do | |
| 51 | + assert {:ok, avatar} = Avatars.put(user, @png) | |
| 52 | + assert avatar.content_type == "image/png" | |
| 53 | + assert avatar.size == byte_size(@png) | |
| 54 | + | |
| 55 | + assert {:ok, "image/png", @png} = Avatars.render(user) | |
| 56 | + end | |
| 57 | + | |
| 58 | + test "detects the type from the bytes, not from a claim", %{user: user} do | |
| 59 | + for {body, expected} <- [ | |
| 60 | + {@png, "image/png"}, | |
| 61 | + {@jpeg, "image/jpeg"}, | |
| 62 | + {@gif, "image/gif"}, | |
| 63 | + {@webp, "image/webp"} | |
| 64 | + ] do | |
| 65 | + {:ok, avatar} = Avatars.put(user, body) | |
| 66 | + assert avatar.content_type == expected | |
| 67 | + end | |
| 68 | + end | |
| 69 | + | |
| 70 | + test "refuses something that isn't an image", %{user: user} do | |
| 71 | + # Including the case that matters: a browser sniffs the body, so | |
| 72 | + # "it said PNG" is how an HTML file gets served from our origin. | |
| 73 | + assert {:error, :unsupported_type} = Avatars.put(user, "<html><script>alert(1)</script>") | |
| 74 | + assert {:error, :unsupported_type} = Avatars.put(user, "<svg xmlns='...'></svg>") | |
| 75 | + end | |
| 76 | + | |
| 77 | + test "refuses an empty upload", %{user: user} do | |
| 78 | + assert {:error, :empty} = Avatars.put(user, "") | |
| 79 | + end | |
| 80 | + | |
| 81 | + test "refuses one that's too large", %{user: user} do | |
| 82 | + oversized = :binary.copy(<<0>>, Avatars.max_bytes() + 1) | |
| 83 | + | |
| 84 | + assert {:error, :too_large} = Avatars.put(user, oversized) | |
| 85 | + end | |
| 86 | + | |
| 87 | + test "replacing keeps one row and one object", %{user: user} do | |
| 88 | + {:ok, first} = Avatars.put(user, @png) | |
| 89 | + {:ok, second} = Avatars.put(user, @jpeg) | |
| 90 | + | |
| 91 | + assert first.id == second.id | |
| 92 | + assert second.content_type == "image/jpeg" | |
| 93 | + | |
| 94 | + # The old object goes only after the row points at the new one. | |
| 95 | + assert {:error, _} = GitGud.Storage.get(first.storage_bucket, first.storage_key) | |
| 96 | + assert {:ok, @jpeg} = GitGud.Storage.get(second.storage_bucket, second.storage_key) | |
| 97 | + end | |
| 98 | + | |
| 99 | + test "re-uploading identical bytes doesn't delete the object", %{user: user} do | |
| 100 | + {:ok, first} = Avatars.put(user, @png) | |
| 101 | + {:ok, second} = Avatars.put(user, @png) | |
| 102 | + | |
| 103 | + # Same digest means the same key — discarding "the old one" here | |
| 104 | + # would remove the object the new row points at. | |
| 105 | + assert first.storage_key == second.storage_key | |
| 106 | + assert {:ok, "image/png", @png} = Avatars.render(user) | |
| 107 | + end | |
| 108 | + | |
| 109 | + test "works for repositories too", %{repo: repo} do | |
| 110 | + assert {:ok, _} = Avatars.put(repo, @png) | |
| 111 | + assert {:ok, "image/png", @png} = Avatars.render(repo) | |
| 112 | + end | |
| 113 | + end | |
| 114 | + | |
| 115 | + describe "delete/1" do | |
| 116 | + test "hands the identicon back", %{user: user} do | |
| 117 | + {:ok, avatar} = Avatars.put(user, @png) | |
| 118 | + assert :ok = Avatars.delete(user) | |
| 119 | + | |
| 120 | + assert {:ok, "image/png", png} = Avatars.render(user) | |
| 121 | + assert png == GitGud.Avatars.Identicon.png(Avatars.seed(user)) | |
| 122 | + assert {:error, _} = GitGud.Storage.get(avatar.storage_bucket, avatar.storage_key) | |
| 123 | + end | |
| 124 | + | |
| 125 | + test "is fine with no avatar", %{user: user} do | |
| 126 | + assert :ok = Avatars.delete(user) | |
| 127 | + end | |
| 128 | + end | |
| 129 | + | |
| 130 | + describe "url/1" do | |
| 131 | + test "carries a version so a replacement is a different URL", %{user: user} do | |
| 132 | + before = Avatars.url(user) | |
| 133 | + {:ok, _} = Avatars.put(user, @png) | |
| 134 | + after_upload = Avatars.url(user) | |
| 135 | + | |
| 136 | + assert before =~ "/u/#{user.handle}/avatar?v=" | |
| 137 | + refute before == after_upload | |
| 138 | + end | |
| 139 | + | |
| 140 | + test "a repository's URL is under the repository", %{repo: repo, user: user} do | |
| 141 | + assert Avatars.url(repo) =~ "/r/#{user.handle}/#{repo.name}/avatar?v=" | |
| 142 | + end | |
| 143 | + end | |
| 144 | + | |
| 145 | + describe "a missing object" do | |
| 146 | + test "falls back rather than serving a broken image", %{user: user} do | |
| 147 | + {:ok, avatar} = Avatars.put(user, @png) | |
| 148 | + # The row says there is an upload; the store disagrees. | |
| 149 | + :ok = GitGud.Storage.delete(avatar.storage_bucket, avatar.storage_key) | |
| 150 | + | |
| 151 | + assert {:ok, "image/png", png} = Avatars.render(user) | |
| 152 | + assert png == GitGud.Avatars.Identicon.png(Avatars.seed(user)) | |
| 153 | + end | |
| 154 | + end | |
| 155 | +end |
modified
test/git_gud/packages/fdroid_format_test.exs
+22
−6
@@ -323,14 +323,30 @@ defmodule GitGud.Packages.Formats.FdroidTest do
| 323 | 323 | assert <<_::binary-size(16), 480::32, 480::32, _::binary>> = png |
| 324 | 324 | end |
| 325 | 325 | |
| 326 | − test "is the same identicon the profile page draws", %{repo: repo, user: user} do | |
| 326 | + test "is the namespace's avatar", %{repo: repo, user: user} do | |
| 327 | 327 | {:ok, _, png} = Fdroid.index(repo, ctx(%{path: ["icons", "icon.png"]})) |
| 328 | 328 | |
| 329 | − # Both come from AvatarComponent.render_spec/1, so the icon in | |
| 330 | − # F-Droid is the avatar on the profile rather than something that | |
| 331 | − # merely resembles it. | |
| 332 | − label = GitGud.Repositories.Storage.owner_handle(user) | |
| 333 | − assert IO.iodata_to_binary(png) == GitGud.Packages.Formats.Fdroid.Icon.png(label) | |
| 329 | + # Through GitGud.Avatars, so it is whatever the profile shows — | |
| 330 | + # the identicon here, and an upload the moment there is one. | |
| 331 | + {:ok, _type, expected} = GitGud.Avatars.render(user) | |
| 332 | + assert IO.iodata_to_binary(png) == expected | |
| 333 | + end | |
| 334 | + | |
| 335 | + test "follows an uploaded avatar, extension and all", %{repo: repo, user: user} do | |
| 336 | + # A 1x1 GIF. The index names the icon by filename, so the | |
| 337 | + # extension has to follow the bytes — a JPEG served at icon.png | |
| 338 | + # is a file some clients fetch and refuse to draw. | |
| 339 | + gif = | |
| 340 | + <<"GIF89a", 1::little-16, 1::little-16, 0x80, 0, 0, 0, 0, 0, 255, 255, 255, 0x21, 0xF9, 4, | |
| 341 | + 1, 0, 0, 0, 0, 0x2C, 0, 0, 0, 0, 1::little-16, 1::little-16, 0, 2, 2, 0x44, 1, 0, 0x3B>> | |
| 342 | + | |
| 343 | + {:ok, _} = GitGud.Avatars.put(user, gif) | |
| 344 | + | |
| 345 | + assert {:ok, "image/gif", ^gif} = Fdroid.index(repo, ctx(%{path: ["icons", "icon.gif"]})) | |
| 346 | + assert {:error, {404, _}} = Fdroid.index(repo, ctx(%{path: ["icons", "icon.png"]})) | |
| 347 | + | |
| 348 | + index = decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))) | |
| 349 | + assert index["repo"]["icon"]["en-US"]["name"] == "/icons/icon.gif" | |
| 334 | 350 | end |
| 335 | 351 | |
| 336 | 352 | test "index-v2 pins it by hash and size", %{repo: repo} do |
Parents: 40af765