4.8 KiB · text History 7accb73
defmodule GitGud.Moderation do
@moduledoc """
Repo- and org-scoped bans on local users.
A repo admin can stop someone acting in their repo; an org admin can
stop them across every repo the org owns. Neither reaches further
than that — suspending an account instance-wide stays an instance
admin's power.
Bans stop *writing*, not reading. Visibility already decides who can
see a repo, and a read block on a public one is unenforceable anyway
— the same person can log out. What a ban does is prevent opening
issues and PRs, commenting, reviewing, and requesting reviews, and it
hides the content they already wrote in that scope.
Expiry needs no sweeper: `banned?/2` filters on `expires_at` in the
query, so a lapsed ban simply stops matching. The row stays as a
record of what happened.
"""
import Ecto.Query, warn: false
alias GitGud.Accounts.User
alias GitGud.Moderation.Ban
alias GitGud.Organizations.Organization
alias GitGud.Repo
alias GitGud.Repositories.Repository
@doc """
Ban `user` from a repository or an organization.
`scope` is a `%Repository{}` or an `%Organization{}`. Options:
`:reason`, `:expires_at`, `:banned_by`.
"""
def ban(scope, %User{} = user, opts \\ []) do
attrs =
scope
|> scope_attrs()
|> Map.merge(%{
banned_user_id: user.id,
banned_by_id: opts[:banned_by] && opts[:banned_by].id,
reason: opts[:reason],
expires_at: opts[:expires_at]
})
%Ban{} |> Ban.changeset(attrs) |> Repo.insert()
end
defp scope_attrs(%Repository{id: id}), do: %{repository_id: id}
defp scope_attrs(%Organization{id: id}), do: %{organization_id: id}
@doc "Lift a ban. Keeps the row, stamped with who lifted it and when."
def lift(%Ban{} = ban, %User{} = actor) do
ban |> Ban.lift_changeset(actor) |> Repo.update()
end
def get_ban(id), do: Repo.get(Ban, id) |> Repo.preload([:banned_user, :banned_by])
@doc """
Whether `user` is currently banned from writing in `repo`.
Checks the repo's own bans and those of the org that owns it — an org
ban covers everything the org owns, so a repo admin can't
accidentally undercut it by not knowing about it.
`nil` (anonymous) is never banned; whether they can act at all is a
separate question that authentication already answers.
"""
def banned?(_repo, nil), do: false
def banned?(%Repository{} = repo, %User{id: uid}) do
Ban
|> where([b], b.banned_user_id == ^uid)
|> in_force()
|> covering(repo)
|> Repo.exists?()
end
# A ban still standing: not lifted, and not lapsed. Expiry is a query
# condition rather than a swept flag, so nothing has to run for a
# ban to end.
defp in_force(query) do
now = DateTime.utc_now()
query
|> where([b], is_nil(b.lifted_at))
|> where([b], is_nil(b.expires_at) or b.expires_at > ^now)
end
# Bans that reach into this repo: its own, plus its owning org's.
# Split on whether there *is* an owning org — a user-owned repo has a
# nil organization_id, and Ecto rightly refuses `== nil` in a query.
defp covering(query, %Repository{id: rid, organization_id: nil}),
do: where(query, [b], b.repository_id == ^rid)
defp covering(query, %Repository{id: rid, organization_id: oid}),
do: where(query, [b], b.repository_id == ^rid or b.organization_id == ^oid)
@doc """
The ban in force for `user` in `repo`, if any — so a page can say
why, and whether it lapses.
An org-wide ban takes precedence over a repo one: it's the broader
statement, and lifting the repo ban wouldn't change anything.
"""
def active_ban(%Repository{} = repo, %User{id: uid}) do
Ban
|> where([b], b.banned_user_id == ^uid)
|> in_force()
|> covering(repo)
# Non-null organization_id sorts first, so an org ban is reported
# ahead of a repo one.
|> order_by([b], desc_nulls_last: b.organization_id)
|> limit(1)
|> preload([:banned_by, :organization, :repository])
|> Repo.one()
end
def active_ban(_repo, nil), do: nil
@doc "Bans in a scope, newest first. Lifted and lapsed ones included."
def list_bans(%Repository{id: id}), do: bans_where(repository_id: id)
def list_bans(%Organization{id: id}), do: bans_where(organization_id: id)
defp bans_where(clause) do
Ban
|> where(^clause)
|> order_by([b], desc: b.id)
|> preload([:banned_user, :banned_by, :lifted_by])
|> Repo.all()
end
@doc "Ids of users currently banned in `repo`, for hiding their content."
def banned_user_ids(%Repository{} = repo) do
Ban
|> in_force()
|> covering(repo)
|> select([b], b.banned_user_id)
|> distinct(true)
|> Repo.all()
end
@doc "Count of bans currently in force in a scope — for a settings badge."
def active_ban_count(scope) do
scope
|> list_bans()
|> Enum.count(&Ban.active?/1)
end
end