neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Upload an avatar from settings
1d76240 · Gabriel Morell · 2026-09-12 19:48
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'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