11.4 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.IRC.ChanServ do
  @moduledoc """
  Pseudo-service for channel registration and ops-by-fiat.

  Lives as a Horde-supervised cluster-wide singleton registered in
  `HordeRegistry` under `{:user, downcased_chanserv_nick}`. PRIVMSGs to
  ChanServ from anywhere in the cluster route to this process; replies
  are sent back as NOTICEs to the requester.

  Commands (case-insensitive):

      HELP
      REGISTER <#channel>      — register a channel; you become founder
      DROP <#channel>          — drop a channel registration (founder only)
      INFO <#channel>          — show registration info
      OP/DEOP <#channel> [nick]      — founder-only grants
      HOP/DEHOP <#channel> [nick]
      VOICE/DEVOICE <#channel> [nick]
      TRANSFER <#channel> <email>    — transfer founder to another user

  All commands that touch registered state require the requester to be
  authenticated to a Phoenix account (SASL/PASS). We discover the
  account by cross-node `GenServer.call(user_pid, :whois_info)` on the
  Horde-registered user pid.
  """

  use GenServer, restart: :transient
  require Logger

  alias OhMyExOhIrcd.Cluster.{HordeRegistry, HordeSupervisor}
  alias OhMyExOhIrcd.{Accounts, Channels}
  alias OhMyExOhIrcd.IRC.Channel

  ## Public API

  @doc "Configured ChanServ nick (default `ChanServ`)."
  def nick do
    Application.get_env(:oh_my_ex_oh_ircd, :services, [])
    |> Keyword.get(:chanserv_nick, "ChanServ")
  end

  def host do
    Application.get_env(:oh_my_ex_oh_ircd, :services, [])
    |> Keyword.get(:service_host, "services.")
  end

  @doc "Ensure ChanServ is running somewhere in the cluster."
  def ensure_started do
    key = {:user, String.downcase(nick())}

    case HordeRegistry.whereis(key) do
      pid when is_pid(pid) ->
        {:ok, pid}

      nil ->
        spec = %{
          id: __MODULE__,
          start: {__MODULE__, :start_link, []},
          restart: :transient
        }

        case Horde.DynamicSupervisor.start_child(HordeSupervisor, spec) do
          {:ok, pid} -> {:ok, pid}
          {:error, {:already_started, pid}} -> {:ok, pid}
          other -> other
        end
    end
  end

  def start_link(_opts \\ []) do
    GenServer.start_link(__MODULE__, [],
      name: HordeRegistry.via({:user, String.downcase(nick())})
    )
  end

  ## Server

  @impl true
  def init(_) do
    Logger.info("ChanServ online as #{nick()}")
    {:ok, %{}}
  end

  # PRIVMSGs routed via the user-nick path arrive as :irc_privmsg.
  @impl true
  def handle_info({:irc_privmsg, %{sender: sender, text: text}}, state) do
    sender_nick = extract_nick(sender)
    Logger.debug("ChanServ <- #{sender_nick}: #{text}")
    handle_command(text, sender_nick)
    {:noreply, state}
  end

  def handle_info(_, state), do: {:noreply, state}

  # Let WHOIS work on ChanServ.
  @impl true
  def handle_call(:whois_info, _from, state) do
    info = %{
      nick: nick(),
      user: "service",
      host: host(),
      realname: "Channel Services",
      channels: [],
      idle: 0,
      signon: 0,
      away: nil,
      account: nil
    }

    {:reply, info, state}
  end

  ## Command dispatch

  defp handle_command(text, sender_nick) do
    case String.split(text, ~r/\s+/, trim: true) do
      [] ->
        :ok

      [cmd | args] ->
        do_command(String.upcase(cmd), args, sender_nick)
    end
  end

  defp do_command("HELP", _args, who) do
    reply(who, [
      "ChanServ helps you register and manage IRC channels.",
      "Commands:",
      "  REGISTER <#channel>     Register a channel; you become founder.",
      "  DROP <#channel>         Drop a channel registration (founder only).",
      "  INFO <#channel>         Show registration info.",
      "  OP/DEOP <#chan> [nick]  Grant/revoke channel operator (founder only).",
      "  HOP/DEHOP <#chan> [nick]   Grant/revoke half-op.",
      "  VOICE/DEVOICE <#chan> [nick]  Grant/revoke voice.",
      "  TRANSFER <#chan> <email>   Transfer founder to another user.",
      "All commands except HELP and INFO require you to be logged in."
    ])
  end

  defp do_command("REGISTER", [channel], who) do
    with {:ok, user} <- require_account(who),
         :ok <- require_channel_in_session(channel, who) do
      case Channels.register(channel, user) do
        {:ok, _rec} ->
          # If the channel singleton is already running, tell it about
          # the new founder so it starts persisting state and we can
          # promote the requester if they're already in.
          try do
            Channel.notify_registered(channel, user.id)
          catch
            _, _ -> :ok
          end

          promote_if_on_channel(channel, who, :founder)
          reply(who, "Channel #{channel} is now registered to you.")

        {:error, :taken} ->
          reply(who, "Channel #{channel} is already registered.")

        {:error, %Ecto.Changeset{} = cs} ->
          reply(who, "Cannot register: #{format_changeset(cs)}")
      end
    end
  end

  defp do_command("REGISTER", _, who),
    do: reply(who, "Usage: REGISTER <#channel>")

  defp do_command("DROP", [channel], who) do
    with {:ok, user} <- require_account(who) do
      case Channels.unregister(channel, user) do
        {:ok, _} -> reply(who, "Channel #{channel} unregistered.")
        {:error, :not_registered} -> reply(who, "Channel #{channel} is not registered.")
        {:error, :not_founder} -> reply(who, "Only the founder may DROP a channel.")
      end
    end
  end

  defp do_command("DROP", _, who),
    do: reply(who, "Usage: DROP <#channel>")

  defp do_command("INFO", [channel], who) do
    case Channels.get(channel) do
      nil ->
        reply(who, "Channel #{channel} is not registered.")

      rec ->
        rec = OhMyExOhIrcd.Repo.preload(rec, :founder)
        founder_email = (rec.founder && rec.founder.email) || "(unknown)"

        registered_at =
          case rec.inserted_at do
            %DateTime{} = dt -> DateTime.to_iso8601(dt)
            _ -> "unknown"
          end

        reply(who, [
          "#{channel} is registered.",
          "  Founder: #{founder_email}",
          "  Registered: #{registered_at}",
          "  Topic: #{rec.topic || ""}"
        ])
    end
  end

  defp do_command("INFO", _, who),
    do: reply(who, "Usage: INFO <#channel>")

  for {cmd, access, op} <- [
        {"OP", :op, :grant},
        {"DEOP", :op, :revoke},
        {"HOP", :halfop, :grant},
        {"DEHOP", :halfop, :revoke},
        {"VOICE", :voice, :grant},
        {"DEVOICE", :voice, :revoke}
      ] do
    defp do_command(unquote(cmd), [channel], who),
      do: do_command(unquote(cmd), [channel, who], who)

    defp do_command(unquote(cmd), [channel, target], who) do
      with {:ok, _user} <- require_founder(channel, who) do
        case unquote(op) do
          :grant ->
            case Channel.service_set_access(channel, target, unquote(access), nick()) do
              :ok ->
                reply(who, "#{target} is now #{unquote(access)} on #{channel}.")

              {:error, :no_such_member} ->
                reply(who, "#{target} is not on #{channel}.")
            end

          :revoke ->
            case Channel.service_set_access(channel, target, :member, nick()) do
              :ok ->
                reply(who, "#{target} no longer holds #{unquote(access)} on #{channel}.")

              {:error, :no_such_member} ->
                reply(who, "#{target} is not on #{channel}.")
            end
        end
      end
    end
  end

  defp do_command("TRANSFER", [channel, email], who) do
    with {:ok, founder} <- require_founder(channel, who) do
      case Accounts.get_user_by_email(email) do
        nil ->
          reply(who, "No account with that email.")

        new_owner ->
          rec = Channels.get(channel)

          rec
          |> OhMyExOhIrcd.Channels.ChannelRecord.registration_changeset(%{
            founder_user_id: new_owner.id
          })
          |> OhMyExOhIrcd.Repo.update()
          |> case do
            {:ok, _} ->
              reply(who, "Channel #{channel} transferred to #{email}.")
              # Demote the previous founder if currently on the channel.
              promote_if_on_channel(channel, founder_nick_for(founder), :member)
              promote_if_on_channel_email(channel, email, :founder)

            {:error, cs} ->
              reply(who, "Transfer failed: #{format_changeset(cs)}")
          end
      end
    end
  end

  defp do_command(other, _, who),
    do: reply(who, "Unknown command: #{other}. Try HELP.")

  ## Auth helpers

  defp require_account(who) do
    case account_for(who) do
      {:ok, user} ->
        {:ok, user}

      :error ->
        reply(who, "You must be logged in. Connect with SASL or PASS using your account.")
        :stop
    end
  end

  defp require_founder(channel, who) do
    with {:ok, user} <- require_account(who) do
      case Channels.get(channel) do
        nil ->
          reply(who, "Channel #{channel} is not registered.")
          :stop

        %{founder_user_id: id} when id == user.id ->
          {:ok, user}

        _ ->
          reply(who, "Only the founder may do that.")
          :stop
      end
    end
  end

  defp require_channel_in_session(_channel, _who), do: :ok

  defp account_for(who) do
    case HordeRegistry.whereis({:user, String.downcase(who)}) do
      pid when is_pid(pid) ->
        try do
          info = GenServer.call(pid, :whois_info, 2_000)

          case info[:account] && Accounts.get_user_by_email(info.account) do
            %_{} = user -> {:ok, user}
            _ -> :error
          end
        catch
          _, _ -> :error
        end

      nil ->
        :error
    end
  end

  ## Reply helpers

  defp reply(target_nick, lines) when is_list(lines),
    do: Enum.each(lines, &reply(target_nick, &1))

  defp reply(target_nick, line) when is_binary(line) do
    case HordeRegistry.whereis({:user, String.downcase(target_nick)}) do
      pid when is_pid(pid) ->
        send(
          pid,
          {:irc_privmsg,
           %{
             sender: nick(),
             target: target_nick,
             text: line,
             kind: :notice
           }}
        )

      nil ->
        :ok
    end
  end

  # When a require_* helper sends an error and returns :stop, our `with`
  # propagates it; treat that as a successful no-op here.
  defp reply(:stop, _), do: :ok

  ## Misc

  defp extract_nick(prefix) when is_binary(prefix) do
    case String.split(prefix, "!", parts: 2) do
      [n | _] -> n
    end
  end

  defp promote_if_on_channel(channel, nick, access) do
    case Channel.member_info(channel, nick) do
      %{access: _} -> Channel.service_set_access(channel, nick, access, nick())
      _ -> :ok
    end
  catch
    _, _ -> :ok
  end

  defp promote_if_on_channel_email(channel, email, access) do
    case Accounts.get_user_by_email(email) do
      nil ->
        :ok

      user ->
        # Find the nick the user is currently using by scanning the registry.
        # This is O(n_users); fine for a transfer.
        nick =
          Horde.Registry.select(HordeRegistry, [
            {{{:user, :"$1"}, :"$2", :"$3"}, [], [{{:"$1", :"$3"}}]}
          ])
          |> Enum.find_value(fn
            {nick, %{user_id: id}} when id == user.id -> nick
            _ -> nil
          end)

        if nick, do: promote_if_on_channel(channel, nick, access), else: :ok
    end
  end

  defp founder_nick_for(_user), do: ""

  defp format_changeset(%Ecto.Changeset{errors: errs}) do
    Enum.map_join(errs, ", ", fn {field, {msg, _}} -> "#{field} #{msg}" end)
  end
end