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
neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
11.4 KiB · text
History
4b6fe3b