neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Pages ask again before they change anything; reports name only what's on the page
e3f46f9 · Gabriel Morell · 2026-10-18 19:30
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/reports.ex
+4
−0
| 36 | 36 | |
| 37 | 37 | def get_report!(id), do: Repo.get!(Report, id) |
| 38 | 38 | |
| 39 | + @doc "The report with this id, or `nil` (also for a `nil` id)." | |
| 40 | + def get_report(nil), do: nil | |
| 41 | + def get_report(id) when is_integer(id), do: Repo.get(Report, id) | |
| 42 | + | |
| 39 | 43 | def list_for_target({type, id}) do |
| 40 | 44 | Report |
| 41 | 45 | |> where([r], r.target_type == ^type and r.target_id == ^id) |
modified
lib/git_gud_web/live/issue_live/show.ex
+50
−21
| 71 | 71 | defp can_edit_comment?(_assigns, _comment), do: false |
| 72 | 72 | |
| 73 | 73 | defp find_comment(socket, id) do |
| 74 | − Enum.find(socket.assigns.issue.comments, &(&1.id == String.to_integer(id))) | |
| 74 | + id = GitGudWeb.RepoAdmin.parse_id(id) | |
| 75 | + Enum.find(socket.assigns.issue.comments, &(&1.id == id)) | |
| 76 | + end | |
| 77 | + | |
| 78 | + # What a report from this page may name: this issue and its comments. | |
| 79 | + # Anything else — another repository's content, a private issue — is | |
| 80 | + # refused the same way whether it exists or not. | |
| 81 | + defp reportable(socket, "issue", id) do | |
| 82 | + issue = socket.assigns.issue | |
| 83 | + if GitGudWeb.RepoAdmin.parse_id(id) == issue.id, do: {"issue", issue.id} | |
| 84 | + end | |
| 85 | + | |
| 86 | + defp reportable(socket, "issue_comment", id) do | |
| 87 | + case find_comment(socket, id) do | |
| 88 | + nil -> nil | |
| 89 | + comment -> {"issue_comment", comment.id} | |
| 90 | + end | |
| 91 | + end | |
| 92 | + | |
| 93 | + defp reportable(_socket, _type, _id), do: nil | |
| 94 | + | |
| 95 | + defp add_comment(socket, user, attrs) do | |
| 96 | + case Issues.add_comment(socket.assigns.issue, user, attrs) do | |
| 97 | + {:error, :banned} -> | |
| 98 | + {:noreply, put_flash(socket, :error, "You're banned from this repository.")} | |
| 99 | + | |
| 100 | + {:ok, comment} -> | |
| 101 | + :ok = | |
| 102 | + Events.record(socket.assigns.issue, "comment_added", user, %{comment_id: comment.id}) | |
| 103 | + | |
| 104 | + {:noreply, | |
| 105 | + socket | |
| 106 | + |> assign_comment_form() | |
| 107 | + |> assign(:comment_body, "") | |
| 108 | + |> assign(:event_count, Events.count_for(socket.assigns.issue)) | |
| 109 | + |> reload()} | |
| 110 | + | |
| 111 | + {:error, cs} -> | |
| 112 | + {:noreply, assign(socket, :comment_form, to_form(cs))} | |
| 113 | + end | |
| 75 | 114 | end |
| 76 | 115 | |
| 77 | 116 | @impl true |
| 248 | 287 | end |
| 249 | 288 | |
| 250 | 289 | def handle_event("quote_reply", %{"id" => id}, socket) do |
| 251 | − case Enum.find(socket.assigns.issue.comments, &(&1.id == String.to_integer(id))) do | |
| 290 | + case find_comment(socket, id) do | |
| 252 | 291 | nil -> |
| 253 | 292 | {:noreply, socket} |
| 254 | 293 |
| 261 | 300 | end |
| 262 | 301 | |
| 263 | 302 | def handle_event("add_comment", %{"issue_comment" => attrs}, socket) do |
| 264 | − user = socket.assigns.current_scope.user | |
| 265 | − | |
| 266 | − case Issues.add_comment(socket.assigns.issue, user, attrs) do | |
| 267 | − {:ok, comment} -> | |
| 268 | − :ok = | |
| 269 | − Events.record(socket.assigns.issue, "comment_added", user, %{comment_id: comment.id}) | |
| 270 | − | |
| 271 | − {:noreply, | |
| 272 | − socket | |
| 273 | − |> assign_comment_form() | |
| 274 | − |> assign(:comment_body, "") | |
| 275 | − |> assign(:event_count, Events.count_for(socket.assigns.issue)) | |
| 276 | − |> reload()} | |
| 277 | − | |
| 278 | − {:error, cs} -> | |
| 279 | − {:noreply, assign(socket, :comment_form, to_form(cs))} | |
| 303 | + case viewer(socket) do | |
| 304 | + nil -> {:noreply, put_flash(socket, :error, "Sign in to comment.")} | |
| 305 | + user -> add_comment(socket, user, attrs) | |
| 280 | 306 | end |
| 281 | 307 | end |
| 282 | 308 |
| 290 | 316 | {:noreply, put_flash(socket, :error, "Sign in to report.")} |
| 291 | 317 | |
| 292 | 318 | user -> |
| 293 | − case GitGud.Reports.open(user, {type, String.to_integer(id)}, reason) do | |
| 294 | − {:ok, _} -> | |
| 295 | − {:noreply, put_flash(socket, :info, "Report submitted. Thanks.")} | |
| 319 | + with target when not is_nil(target) <- reportable(socket, type, id), | |
| 320 | + {:ok, _} <- GitGud.Reports.open(user, target, reason) do | |
| 321 | + {:noreply, put_flash(socket, :info, "Report submitted. Thanks.")} | |
| 322 | + else | |
| 323 | + nil -> | |
| 324 | + {:noreply, put_flash(socket, :error, "That can't be reported from here.")} | |
| 296 | 325 | |
| 297 | 326 | {:error, :already_moderated} -> |
| 298 | 327 | {:noreply, put_flash(socket, :info, "This content has already been moderated.")} |
modified
lib/git_gud_web/live/job_token_live/index.ex
+6
−3
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | cond do |
| 40 | − not socket.assigns.can_admin? -> | |
| 40 | + # Asked again, not read from mount: the socket can outlive the role. | |
| 41 | + not GitGudWeb.RepoAdmin.admin_now?(socket) -> | |
| 41 | 42 | {:noreply, put_flash(socket, :error, "Not allowed.")} |
| 42 | 43 | |
| 43 | 44 | source == "" -> |
| 60 | 61 | end |
| 61 | 62 | |
| 62 | 63 | def handle_event("delete_grant", %{"id" => id}, socket) do |
| 63 | − if socket.assigns.can_admin? do | |
| 64 | − _ = JobTokenGrants.delete(socket.assigns.repo, String.to_integer(id)) | |
| 64 | + if GitGudWeb.RepoAdmin.admin_now?(socket) do | |
| 65 | + if id = GitGudWeb.RepoAdmin.parse_id(id), | |
| 66 | + do: _ = JobTokenGrants.delete(socket.assigns.repo, id) | |
| 67 | + | |
| 65 | 68 | {:noreply, socket |> load() |> put_flash(:info, "Grant removed.")} |
| 66 | 69 | else |
| 67 | 70 | {:noreply, put_flash(socket, :error, "Not allowed.")} |
modified
lib/git_gud_web/live/moderation_actions.ex
+70
−24
| 19 | 19 | |
| 20 | 20 | alias GitGud.Accounts |
| 21 | 21 | alias GitGud.Moderation |
| 22 | + alias GitGud.Organizations | |
| 23 | + alias GitGud.Organizations.Organization | |
| 22 | 24 | alias GitGud.Reports |
| 25 | + alias GitGud.Repositories.Repository | |
| 23 | 26 | |
| 24 | 27 | @doc "Dismiss a report as needing nothing." |
| 25 | − def dismiss_report(socket, id, reload) do | |
| 28 | + def dismiss_report(socket, id, reload), | |
| 29 | + do: as_moderator(socket, fn -> do_dismiss_report(socket, id, reload) end) | |
| 30 | + | |
| 31 | + @doc "Mark a report handled without changing the content." | |
| 32 | + def action_report(socket, id, reload), | |
| 33 | + do: as_moderator(socket, fn -> do_action_report(socket, id, reload) end) | |
| 34 | + | |
| 35 | + @doc """ | |
| 36 | + Replace reported content with a moderation message, and close the | |
| 37 | + report. | |
| 38 | + | |
| 39 | + The original is kept and stays visible to admins and its author — | |
| 40 | + this hides it from everyone else rather than destroying it. | |
| 41 | + """ | |
| 42 | + def moderate_report(socket, id, reload), | |
| 43 | + do: as_moderator(socket, fn -> do_moderate_report(socket, id, reload) end) | |
| 44 | + | |
| 45 | + @doc "Ban the author of a reported item, straight from the queue." | |
| 46 | + def ban_reported_author(socket, id, reload), | |
| 47 | + do: as_moderator(socket, fn -> do_ban_reported_author(socket, id, reload) end) | |
| 48 | + | |
| 49 | + @doc "Ban someone by handle." | |
| 50 | + def ban_user(socket, params, reload), | |
| 51 | + do: as_moderator(socket, fn -> do_ban_user(socket, params, reload) end) | |
| 52 | + | |
| 53 | + @doc "Lift a ban." | |
| 54 | + def lift_ban(socket, id, reload), | |
| 55 | + do: as_moderator(socket, fn -> do_lift_ban(socket, id, reload) end) | |
| 56 | + | |
| 57 | + # Each action asks again whether the socket's user still moderates | |
| 58 | + # this scope, rather than trusting the mount: a page left open can | |
| 59 | + # outlive the admin role it was opened with. | |
| 60 | + defp as_moderator(socket, fun) do | |
| 61 | + if still_moderator?(socket), | |
| 62 | + do: fun.(), | |
| 63 | + else: put_flash(socket, :error, "You don't moderate here any more.") | |
| 64 | + end | |
| 65 | + | |
| 66 | + defp still_moderator?(%{assigns: %{scope: %Repository{id: id}, moderator: moderator}}) do | |
| 67 | + case GitGud.Repo.get(Repository, id) do | |
| 68 | + nil -> false | |
| 69 | + repo -> GitGudWeb.RepoAdmin.admin?(moderator, GitGud.Repo.preload(repo, :organization)) | |
| 70 | + end | |
| 71 | + end | |
| 72 | + | |
| 73 | + defp still_moderator?(%{assigns: %{scope: %Organization{id: id}, moderator: moderator}}) do | |
| 74 | + case GitGud.Repo.get(Organization, id) do | |
| 75 | + nil -> false | |
| 76 | + org -> Organizations.org_admin?(org, moderator) | |
| 77 | + end | |
| 78 | + end | |
| 79 | + | |
| 80 | + defp still_moderator?(_socket), do: false | |
| 81 | + | |
| 82 | + defp do_dismiss_report(socket, id, reload) do | |
| 26 | 83 | with {:ok, report} <- scoped_report(socket, id) do |
| 27 | 84 | {:ok, _} = Reports.dismiss(report, socket.assigns.moderator) |
| 28 | 85 | socket |> reload.() |> put_flash(:info, "Report dismissed.") |
| 31 | 88 | end |
| 32 | 89 | end |
| 33 | 90 | |
| 34 | − @doc "Mark a report handled without changing the content." | |
| 35 | − def action_report(socket, id, reload) do | |
| 91 | + defp do_action_report(socket, id, reload) do | |
| 36 | 92 | with {:ok, report} <- scoped_report(socket, id) do |
| 37 | 93 | {:ok, _} = Reports.action(report, socket.assigns.moderator) |
| 38 | 94 | socket |> reload.() |> put_flash(:info, "Report marked handled.") |
| 41 | 97 | end |
| 42 | 98 | end |
| 43 | 99 | |
| 44 | − @doc """ | |
| 45 | − Replace reported content with a moderation message, and close the | |
| 46 | − report. | |
| 47 | − | |
| 48 | − The original is kept and stays visible to admins and its author — | |
| 49 | − this hides it from everyone else rather than destroying it. | |
| 50 | − """ | |
| 51 | − def moderate_report(socket, id, reload) do | |
| 100 | + defp do_moderate_report(socket, id, reload) do | |
| 52 | 101 | moderator = socket.assigns.moderator |
| 53 | 102 | |
| 54 | 103 | with {:ok, report} <- scoped_report(socket, id), |
| 60 | 109 | end |
| 61 | 110 | end |
| 62 | 111 | |
| 63 | − @doc "Ban the author of a reported item, straight from the queue." | |
| 64 | − def ban_reported_author(socket, id, reload) do | |
| 112 | + defp do_ban_reported_author(socket, id, reload) do | |
| 65 | 113 | with {:ok, report} <- scoped_report(socket, id), |
| 66 | 114 | %{author_id: author_id} when is_integer(author_id) <- Reports.preview(report), |
| 67 | 115 | %{} = user <- GitGud.Repo.get(GitGud.Accounts.User, author_id) do |
| 71 | 119 | end |
| 72 | 120 | end |
| 73 | 121 | |
| 74 | − @doc "Ban someone by handle." | |
| 75 | − def ban_user(socket, params, reload) do | |
| 122 | + defp do_ban_user(socket, params, reload) do | |
| 76 | 123 | handle = String.trim(params["handle"] || "") |
| 77 | 124 | |
| 78 | 125 | case Accounts.get_user_by_handle(handle) do |
| 113 | 160 | end |
| 114 | 161 | end |
| 115 | 162 | |
| 116 | − @doc "Lift a ban." | |
| 117 | − def lift_ban(socket, id, reload) do | |
| 118 | − ban = Moderation.get_ban(id) | |
| 163 | + defp do_lift_ban(socket, id, reload) do | |
| 164 | + ban = (id = GitGudWeb.RepoAdmin.parse_id(id)) && Moderation.get_ban(id) | |
| 119 | 165 | |
| 120 | 166 | if ban && in_scope?(ban, socket.assigns.scope) do |
| 121 | 167 | {:ok, _} = Moderation.lift(ban, socket.assigns.moderator) |
| 128 | 174 | # A moderator may only act on reports in their own scope; the id in |
| 129 | 175 | # the event is not taken on trust. |
| 130 | 176 | defp scoped_report(socket, id) do |
| 131 | − report = Reports.get_report!(String.to_integer(id)) | |
| 177 | + report = Reports.get_report(GitGudWeb.RepoAdmin.parse_id(id)) | |
| 132 | 178 | |
| 133 | − if report_in_scope?(report, socket.assigns.scope), | |
| 179 | + if report && report_in_scope?(report, socket.assigns.scope), | |
| 134 | 180 | do: {:ok, report}, |
| 135 | 181 | else: :error |
| 136 | 182 | end |
| 137 | 183 | |
| 138 | − defp report_in_scope?(report, %GitGud.Repositories.Repository{id: id}), | |
| 184 | + defp report_in_scope?(report, %Repository{id: id}), | |
| 139 | 185 | do: report.repository_id == id |
| 140 | 186 | |
| 141 | − defp report_in_scope?(report, %GitGud.Organizations.Organization{id: id}), | |
| 187 | + defp report_in_scope?(report, %Organization{id: id}), | |
| 142 | 188 | do: report.organization_id == id |
| 143 | 189 | |
| 144 | − defp in_scope?(ban, %GitGud.Repositories.Repository{id: id}), do: ban.repository_id == id | |
| 145 | − defp in_scope?(ban, %GitGud.Organizations.Organization{id: id}), do: ban.organization_id == id | |
| 190 | + defp in_scope?(ban, %Repository{id: id}), do: ban.repository_id == id | |
| 191 | + defp in_scope?(ban, %Organization{id: id}), do: ban.organization_id == id | |
| 146 | 192 | |
| 147 | 193 | defp blank_to_nil(nil), do: nil |
| 148 | 194 |
added
lib/git_gud_web/live/org_admin.ex
+37
−0
| 1 | +defmodule GitGudWeb.OrgAdmin do | |
| 2 | + @moduledoc """ | |
| 3 | + `GitGudWeb.RepoAdmin`'s counterpart for organization settings pages. | |
| 4 | + | |
| 5 | + Each page checks `Organizations.org_admin?/2` in mount; every handler | |
| 6 | + that changes something goes through `with_org_admin/2`, which re-reads | |
| 7 | + the organization and the membership rather than trusting the mount — | |
| 8 | + an admin demoted in another tab keeps no powers in this one. | |
| 9 | + """ | |
| 10 | + | |
| 11 | + import Phoenix.LiveView, only: [put_flash: 3] | |
| 12 | + | |
| 13 | + alias GitGud.Organizations | |
| 14 | + alias GitGud.Organizations.Organization | |
| 15 | + | |
| 16 | + @doc "Whether the socket's user administers its `:org`, asked now." | |
| 17 | + def org_admin_now?(%{assigns: %{org: %Organization{id: id}} = assigns}) do | |
| 18 | + user = assigns[:current_scope] && assigns.current_scope.user | |
| 19 | + | |
| 20 | + case user && GitGud.Repo.get(Organization, id) do | |
| 21 | + %Organization{} = org -> Organizations.org_admin?(org, user) | |
| 22 | + _ -> false | |
| 23 | + end | |
| 24 | + end | |
| 25 | + | |
| 26 | + def org_admin_now?(_socket), do: false | |
| 27 | + | |
| 28 | + @doc """ | |
| 29 | + Runs `fun` (a handler body returning `{:noreply, socket}`) only if the | |
| 30 | + socket's user still administers the organization. | |
| 31 | + """ | |
| 32 | + def with_org_admin(socket, fun) when is_function(fun, 0) do | |
| 33 | + if org_admin_now?(socket), | |
| 34 | + do: fun.(), | |
| 35 | + else: {:noreply, put_flash(socket, :error, "Org admin only.")} | |
| 36 | + end | |
| 37 | +end |
modified
lib/git_gud_web/live/org_live/members.ex
+15
−6
Click to load diff…
modified
lib/git_gud_web/live/org_live/settings_profile.ex
+12
−5
Click to load diff…
modified
lib/git_gud_web/live/org_live/settings_runners.ex
+30
−12
Click to load diff…
modified
lib/git_gud_web/live/pr_live/show.ex
+55
−22
Click to load diff…
modified
lib/git_gud_web/live/registry_token_live/index.ex
+24
−15
Click to load diff…
modified
lib/git_gud_web/live/repo_live/fork.ex
+4
−1
Click to load diff…
modified
lib/git_gud_web/live/repo_live/merge.ex
+5
−1
Click to load diff…
modified
lib/git_gud_web/live/repo_live/new.ex
+4
−2
Click to load diff…
modified
lib/git_gud_web/live/repo_live/settings.ex
+15
−7
Click to load diff…
modified
lib/git_gud_web/live/repo_live/settings_runners.ex
+11
−5
Click to load diff…
modified
lib/git_gud_web/live/workflow_live/show.ex
+7
−7
Click to load diff…
added
test/git_gud_web/live/repo_auth/page_handlers_auth_test.exs
+334
−0
Click to load diff…
Parents: 49fdeb3