Avatars you can upload, for users, orgs and repositories

4b2ca85 · Gabriel Morell · 2026-09-12 19:11

23 files +630 -35
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