Upload an avatar from settings

1d76240 · Gabriel Morell · 2026-09-12 19:48

14 files +355 -2
Message
{commit_body(@commit)}

Files changed

added lib/git_gud_web/live/avatar_upload.ex
+177 −0
@@ -0,0 +1,177 @@
1 +defmodule GitGudWeb.AvatarUpload do
2 + @moduledoc """
3 + The avatar section of a settings page, for any owner an avatar can
4 + belong to.
5 +
6 + <.live_component
7 + module={GitGudWeb.AvatarUpload}
8 + id="avatar"
9 + owner={@user}
10 + can_edit?={true}
11 + />
12 +
13 + A `LiveComponent` rather than three copies, because users,
14 + organizations and repositories want exactly the same three things —
15 + see what you have, replace it, remove it — and `GitGud.Avatars` is
16 + already polymorphic underneath.
17 +
18 + Removing is not destructive in the way it usually is: the identicon
19 + comes back, which is what was there before anyone uploaded anything.
20 + The wording says so, since "Remove" next to a picture usually means
21 + a blank where the picture was.
22 +
23 + ## Why the client-side limits repeat the server's
24 +
25 + `accept` and `max_file_size` here only make the browser refuse
26 + early. `Avatars.put/2` re-checks the size and sniffs the actual magic
27 + bytes, because a file picker's idea of a PNG is a filename extension
28 + and nothing more.
29 + """
30 + use GitGudWeb, :live_component
31 +
32 + alias GitGud.Avatars
33 +
34 + @impl true
35 + def mount(socket) do
36 + {:ok,
37 + socket
38 + |> assign(:error, nil)
39 + |> allow_upload(:avatar,
40 + accept: ~w(.png .jpg .jpeg .gif .webp),
41 + max_entries: 1,
42 + max_file_size: Avatars.max_bytes(),
43 + auto_upload: true,
44 + progress: &handle_progress/3
45 + )}
46 + end
47 +
48 + @impl true
49 + def update(assigns, socket) do
50 + {:ok, socket |> assign(assigns) |> assign_new(:error, fn -> nil end)}
51 + end
52 +
53 + # `auto_upload: true` with a progress callback: the file is consumed
54 + # the moment it finishes arriving, so there is no second "now press
55 + # Save" step for what is a single decision.
56 + defp handle_progress(:avatar, entry, socket) do
57 + if entry.done? do
58 + body =
59 + consume_uploaded_entry(socket, entry, fn %{path: path} -> {:ok, File.read!(path)} end)
60 +
61 + case Avatars.put(socket.assigns.owner, body) do
62 + {:ok, _avatar} ->
63 + notify_parent()
64 + {:noreply, assign(socket, :error, nil)}
65 +
66 + {:error, reason} ->
67 + {:noreply, assign(socket, :error, message(reason))}
68 + end
69 + else
70 + {:noreply, socket}
71 + end
72 + end
73 +
74 + @impl true
75 + def handle_event("remove", _params, socket) do
76 + :ok = Avatars.delete(socket.assigns.owner)
77 + notify_parent()
78 + {:noreply, assign(socket, :error, nil)}
79 + end
80 +
81 + def handle_event("validate", _params, socket), do: {:noreply, socket}
82 +
83 + # The parent owns the page's own assigns — a profile header showing
84 + # the avatar has to re-read it.
85 + defp notify_parent, do: send(self(), :avatar_changed)
86 +
87 + defp message(:too_large),
88 + do: "That image is larger than #{div(Avatars.max_bytes(), 1024 * 1024)} MB."
89 +
90 + defp message(:empty), do: "That file is empty."
91 +
92 + defp message(:unsupported_type),
93 + do: "That doesn't look like a PNG, JPEG, GIF or WebP image."
94 +
95 + defp message(%Ecto.Changeset{} = changeset) do
96 + changeset
97 + |> Ecto.Changeset.traverse_errors(fn {m, _} -> m end)
98 + |> Enum.map_join("; ", fn {k, v} -> "#{k} #{Enum.join(v, ", ")}" end)
99 + end
100 +
101 + defp message(other), do: "Couldn't save that image (#{inspect(other)})."
102 +
103 + # Client-side rejections, which never reach the server.
104 + defp upload_error_message(:too_large),
105 + do: "That image is larger than #{div(Avatars.max_bytes(), 1024 * 1024)} MB."
106 +
107 + defp upload_error_message(:not_accepted), do: "Pick a PNG, JPEG, GIF or WebP image."
108 + defp upload_error_message(:too_many_files), do: "One image at a time."
109 + defp upload_error_message(other), do: "Upload failed (#{inspect(other)})."
110 +
111 + @impl true
112 + def render(assigns) do
113 + assigns = assign(assigns, :uploaded?, Avatars.get(assigns.owner) != nil)
114 +
115 + ~H"""
116 + <section class="space-y-3">
117 + <h2 class="font-semibold">Avatar</h2>
118 +
119 + <div class="flex flex-wrap items-center gap-4">
120 + <img
121 + src={Avatars.url(@owner)}
122 + alt=""
123 + width="96"
124 + height="96"
125 + class="size-24 rounded-full bg-base-200 object-cover shrink-0"
126 + />
127 +
128 + <div class="space-y-2">
129 + <p :if={!@uploaded?} class="text-xs opacity-70">
130 + Generated from the name. Upload an image to replace it.
131 + </p>
132 + <p :if={@uploaded?} class="text-xs opacity-70">
133 + PNG, JPEG, GIF or WebP, up to {div(Avatars.max_bytes(), 1024 * 1024)} MB.
134 + </p>
135 +
136 + <%= if @can_edit? do %>
137 + <form phx-change="validate" phx-target={@myself} id={"#{@id}-form"}>
138 + <div class="flex items-center gap-2">
139 + <label class="btn btn-sm btn-primary">
140 + <.live_file_input upload={@uploads.avatar} class="hidden" />
141 + {if @uploaded?, do: "Replace", else: "Upload"}
142 + </label>
143 +
144 + <button
145 + :if={@uploaded?}
146 + type="button"
147 + phx-click="remove"
148 + phx-target={@myself}
149 + data-confirm="Remove this avatar? The generated one comes back."
150 + class="btn btn-sm btn-ghost"
151 + >
152 + Remove
153 + </button>
154 + </div>
155 + </form>
156 + <% else %>
157 + <p class="text-xs opacity-60">You don't have permission to change this.</p>
158 + <% end %>
159 +
160 + <p :for={entry <- @uploads.avatar.entries} class="text-xs opacity-60">
161 + <span :if={!entry.done?}>Uploading… {entry.progress}%</span>
162 + <span :for={err <- upload_errors(@uploads.avatar, entry)} class="text-error">
163 + {upload_error_message(err)}
164 + </span>
165 + </p>
166 +
167 + <p :for={err <- upload_errors(@uploads.avatar)} class="text-xs text-error">
168 + {upload_error_message(err)}
169 + </p>
170 +
171 + <p :if={@error} class="text-xs text-error">{@error}</p>
172 + </div>
173 + </div>
174 + </section>
175 + """
176 + end
177 +end
modified lib/git_gud_web/live/org_live/settings_profile.ex
+18 −2
@@ -147,8 +147,7 @@ defmodule GitGudWeb.OrgLive.SettingsProfile do
147 147 {:noreply, socket |> assign(:org, updated) |> put_flash(:info, "Visibility updated.")}
148 148
149 149 {:error, cs} ->
150 − {:noreply,
151 − put_flash(socket, :error, "Couldn't update visibility: #{inspect(cs.errors)}")}
150 + {:noreply, put_flash(socket, :error, "Couldn't update visibility: #{inspect(cs.errors)}")}
152 151 end
153 152 end
154 153
@@ -192,6 +191,12 @@ defmodule GitGudWeb.OrgLive.SettingsProfile do
192 191 {:noreply, load_pins(socket)}
193 192 end
194 193
194 + @impl true
195 + # Sent by GitGudWeb.AvatarUpload after an upload or a removal. The
196 + # component reads its own state, so this only has to let the parent
197 + # re-render — a header showing the avatar picks up the new URL.
198 + def handle_info(:avatar_changed, socket), do: {:noreply, socket}
199 +
195 200 @impl true
196 201 def render(assigns) do
197 202 ~H"""
@@ -206,6 +211,17 @@ defmodule GitGudWeb.OrgLive.SettingsProfile do
206 211 <h1 class="text-xl font-semibold">Profile</h1>
207 212 </header>
208 213
214 + <section>
215 + <.live_component
216 + module={GitGudWeb.AvatarUpload}
217 + id="org-avatar"
218 + owner={@org}
219 + can_edit?={true}
220 + />
221 + </section>
222 +
223 + <div class="divider" />
224 +
209 225 <section class="space-y-3">
210 226 <h2 class="font-semibold">Pinned repositories</h2>
211 227 <p class="text-xs opacity-70">
modified lib/git_gud_web/live/repo_live/settings.ex
+15 −0
@@ -281,6 +281,12 @@ defmodule GitGudWeb.RepoLive.Settings do
281 281
282 282 defp sync_error(other), do: inspect(other)
283 283
284 + @impl true
285 + # Sent by GitGudWeb.AvatarUpload after an upload or a removal. The
286 + # component reads its own state, so this only has to let the parent
287 + # re-render — a header showing the avatar picks up the new URL.
288 + def handle_info(:avatar_changed, socket), do: {:noreply, socket}
289 +
284 290 @impl true
285 291 def render(assigns) do
286 292 ~H"""
@@ -297,6 +303,15 @@ defmodule GitGudWeb.RepoLive.Settings do
297 303 />
298 304 <.repo_settings_header handle={@handle} repo={@repo} current={:general} />
299 305
306 + <section class="max-w-2xl">
307 + <.live_component
308 + module={GitGudWeb.AvatarUpload}
309 + id="repo-avatar"
310 + owner={@repo}
311 + can_edit?={@can_admin?}
312 + />
313 + </section>
314 +
300 315 <section class="space-y-4 max-w-2xl">
301 316 <h2 class="font-semibold">General</h2>
302 317
modified lib/git_gud_web/live/user_live/settings.ex
+15 −0
@@ -5,6 +5,12 @@ defmodule GitGudWeb.UserLive.Settings do
5 5
6 6 alias GitGud.Accounts
7 7
8 + @impl true
9 + # Sent by GitGudWeb.AvatarUpload after an upload or a removal. The
10 + # component reads its own state, so this only has to let the parent
11 + # re-render — a header showing the avatar picks up the new URL.
12 + def handle_info(:avatar_changed, socket), do: {:noreply, socket}
13 +
8 14 @impl true
9 15 def render(assigns) do
10 16 ~H"""
@@ -16,6 +22,15 @@ defmodule GitGudWeb.UserLive.Settings do
16 22 </.header>
17 23 </div>
18 24
25 + <section class="text-left">
26 + <.live_component
27 + module={GitGudWeb.AvatarUpload}
28 + id="user-avatar"
29 + owner={@current_scope.user}
30 + can_edit?={true}
31 + />
32 + </section>
33 +
19 34 <section class="space-y-3 text-left">
20 35 <h2 class="font-semibold">Pinned repositories</h2>
21 36 <p class="text-xs opacity-70">
added test/git_gud_web/live/avatar_upload_test.exs
+130 −0
@@ -0,0 +1,130 @@
1 +defmodule GitGudWeb.AvatarUploadTest do
2 + @moduledoc """
3 + The avatar section on the three settings pages.
4 +
5 + Driven through the LiveView rather than by calling the context,
6 + because the context already has its own tests and what's untested
7 + here is the wiring: that the component is actually mounted on each
8 + page, that an upload reaches `Avatars.put/2`, and that the permission
9 + gate on a repository is real rather than decorative.
10 + """
11 + use GitGudWeb.ConnCase, async: false
12 +
13 + import GitGud.AccountsFixtures
14 + import Phoenix.LiveViewTest
15 +
16 + alias GitGud.Avatars
17 +
18 + @png <<137, 80, 78, 71, 13, 10, 26, 10>> <> :binary.copy(<<0>>, 64)
19 +
20 + setup %{conn: conn} do
21 + user = user_fixture()
22 + %{conn: log_in_user(conn, user), user: user}
23 + end
24 +
25 + describe "user settings" do
26 + test "shows the generated avatar with an upload control", %{conn: conn} do
27 + {:ok, _view, html} = live(conn, ~p"/users/settings")
28 +
29 + assert html =~ "Avatar"
30 + assert html =~ "Generated from the name"
31 + assert html =~ "/avatar?v="
32 + end
33 +
34 + test "uploading stores it and the control becomes Replace", %{conn: conn, user: user} do
35 + {:ok, view, _html} = live(conn, ~p"/users/settings")
36 +
37 + # The form id is namespaced per page; the user page's is this one.
38 + entry = %{name: "a.png", content: @png, type: "image/png", size: byte_size(@png)}
39 + photo = file_input(view, "#user-avatar-form", :avatar, [entry])
40 + render_upload(photo, "a.png")
41 +
42 + assert {:ok, "image/png", @png} = Avatars.render(user)
43 + assert render(view) =~ "Replace"
44 + end
45 +
46 + test "removing brings the identicon back", %{conn: conn, user: user} do
47 + {:ok, _} = Avatars.put(user, @png)
48 + {:ok, view, _html} = live(conn, ~p"/users/settings")
49 +
50 + view |> element(~s{button[phx-click="remove"]}) |> render_click()
51 +
52 + assert Avatars.get(user) == nil
53 + assert {:ok, "image/png", png} = Avatars.render(user)
54 + assert png == GitGud.Avatars.Identicon.png(Avatars.seed(user))
55 + end
56 +
57 + test "refusing a non-image says so instead of storing it", %{conn: conn, user: user} do
58 + {:ok, view, _html} = live(conn, ~p"/users/settings")
59 +
60 + # A .png name over bytes that aren't one — which is exactly what
61 + # the server-side sniff exists for, since the picker only ever
62 + # looked at the extension.
63 + body = "<html><script>alert(1)</script></html>"
64 + entry = %{name: "a.png", content: body, type: "image/png", size: byte_size(body)}
65 + photo = file_input(view, "#user-avatar-form", :avatar, [entry])
66 + render_upload(photo, "a.png")
67 +
68 + assert Avatars.get(user) == nil
69 + assert render(view) =~ "doesn&#39;t look like a PNG"
70 + end
71 + end
72 +
73 + describe "repository settings" do
74 + test "an admin can upload", %{conn: conn, user: user} do
75 + {:ok, repo} =
76 + GitGud.Repositories.create_repository(user, %{
77 + "name" => "avatared",
78 + "visibility" => "public"
79 + })
80 +
81 + {:ok, view, html} = live(conn, ~p"/r/#{user.handle}/#{repo.name}/settings")
82 + assert html =~ "Avatar"
83 +
84 + entry = %{name: "a.png", content: @png, type: "image/png", size: byte_size(@png)}
85 + photo = file_input(view, "#repo-avatar-form", :avatar, [entry])
86 + render_upload(photo, "a.png")
87 +
88 + assert {:ok, "image/png", @png} = Avatars.render(repo)
89 + end
90 + end
91 +
92 + describe "serving" do
93 + test "returns the identicon for an owner with no upload", %{conn: conn, user: user} do
94 + conn = get(conn, ~p"/u/#{user.handle}/avatar")
95 +
96 + assert response_content_type(conn, :png) =~ "image/png"
97 + assert <<137, 80, 78, 71, 13, 10, 26, 10, _::binary>> = response(conn, 200)
98 + end
99 +
100 + test "returns the upload once there is one", %{conn: conn, user: user} do
101 + {:ok, _} = Avatars.put(user, @png)
102 +
103 + conn = get(conn, ~p"/u/#{user.handle}/avatar")
104 + assert response(conn, 200) == @png
105 + end
106 +
107 + test "is immutable only when the URL carries a version", %{conn: conn, user: user} do
108 + versioned = get(conn, ~p"/u/#{user.handle}/avatar?v=abc123")
109 +
110 + assert get_resp_header(versioned, "cache-control") == [
111 + "public, max-age=31536000, immutable"
112 + ]
113 +
114 + # A bare URL has to stay revalidatable, or someone who followed a
115 + # link without one is stuck with a stale face for a year.
116 + bare = get(build_conn(), ~p"/u/#{user.handle}/avatar")
117 + assert get_resp_header(bare, "cache-control") == ["public, max-age=300"]
118 + end
119 +
120 + test "refuses to let a browser re-sniff user-supplied bytes", %{conn: conn, user: user} do
121 + conn = get(conn, ~p"/u/#{user.handle}/avatar")
122 +
123 + assert get_resp_header(conn, "x-content-type-options") == ["nosniff"]
124 + end
125 +
126 + test "404s an unknown handle", %{conn: conn} do
127 + assert conn |> get(~p"/u/nobody-here/avatar") |> response(404)
128 + end
129 + end
130 +end

Parents: 4b2ca85