defmodule OhMyExOhIrcd.IRC.Connection do
@moduledoc """
Per-client ThousandIsland handler. Owns:
* the connection's nick/user state (pre-registration → registered);
* the set of channels it has joined;
* a Horde.Registry entry under `{:user, nick}` so DMs from elsewhere
can find this connection.
Receives `{:irc_privmsg, entry}` and `{:irc_presence, …}` messages via
Phoenix.PubSub once the connection has joined channels. Anything that
needs to be delivered to the *client* on the wire flows through here.
"""
use ThousandIsland.Handler
require Logger
alias OhMyExOhIrcd.Cluster.HordeRegistry
alias OhMyExOhIrcd.IRC.{Auth, Channel, Listener, Message, MessageLog}
@pubsub OhMyExOhIrcd.PubSub
defmodule State do
@moduledoc false
defstruct nick: nil,
user: nil,
realname: nil,
# Display host shown in the `nick!user@host` prefix and WHOIS:
# a hashed-IP cloak, or an oper-assigned vhost when set.
host: nil,
# Real client IP. Kept separate from `host` so cloaking is a
# display concern only — K-line / O-line matching and the
# oper-only WHOIS 338 use this, never the cloak.
real_host: nil,
# Cloak base domain (from listener `:cloak_host`); nil disables
# cloaking and `host` falls back to the real IP.
cloak_host: nil,
registered?: false,
channels: MapSet.new(),
# Set by the user with USER; preserved across nick changes.
server_name: nil,
network_name: nil,
buffer: "",
# Auth state — populated by SASL/PASS, checked at NICK/USER completion.
account: nil,
# SASL handshake state: nil | :awaiting_payload (after AUTHENTICATE PLAIN, before payload).
sasl: nil,
# Set true once the client has sent `CAP LS/REQ`; we then wait for `CAP END`
# before completing registration even if NICK+USER arrived first.
cap_negotiating?: false,
# If true, NICK+USER alone is enough to complete registration; SASL/PASS
# is still honoured if provided. Read from Listener config at accept time.
allow_anonymous: false,
# Welcome MOTD lines from config.
motd: [],
# AWAY state — nil if present, else the away reason.
away: nil,
# Unix-second timestamp of last message from the client; used for WHOIS idle.
last_activity: 0,
connected_at: 0,
# Keepalive: we send `PING :<token>` after `ping_interval_ms` of inactivity
# and expect a PONG within `ping_timeout_ms`. While `ping_token` is non-nil
# we are awaiting that PONG.
ping_interval_ms: 120_000,
ping_timeout_ms: 60_000,
ping_token: nil,
ping_timer: nil,
# Flood control — leaky bucket measured in "seconds of penalty".
# Each parsed command adds `penalty_for(command)`; the bucket
# drains at 1 sec / sec wall time. When the bucket exceeds
# `flood_threshold_seconds` we drop with ERROR :Excess Flood.
flood_threshold_seconds: 20.0,
flood_penalty_default: 1.0,
flood_penalty: 0.0,
flood_last_at_ms: 0,
# Channels (downcased) where this user is exempt from flood
# control on PRIVMSG/NOTICE (channel mode `+f`, founder-set).
# Refreshed from `Channel.flags/1` on JOIN and updated on
# presence MODE broadcasts.
flood_exempt_channels: MapSet.new(),
# IRC operator status — set by successful OPER, cleared when
# the user-mode `-o` is set (not implemented yet).
operator?: false,
# `+w` user-mode: receive WALLOPS broadcasts.
wallops?: false,
# The client's QUIT reason, kept for the disconnect sighting.
quit_reason: nil
end
alias OhMyExOhIrcd.IRC.Stats
@impl ThousandIsland.Handler
def handle_connection(socket, opts) do
{:ok, {ip, _port}} = ThousandIsland.Socket.peername(socket)
peer_host = ip |> :inet.ntoa() |> List.to_string()
cloak_host = Map.get(opts, :cloak_host)
now = System.system_time(:second)
state = %State{
host: OhMyExOhIrcd.IRC.Cloak.host(peer_host, cloak_host),
real_host: peer_host,
cloak_host: cloak_host,
server_name: opts.server_name,
network_name: opts.network_name,
allow_anonymous: Map.get(opts, :allow_anonymous, false),
motd: Map.get(opts, :motd, []),
last_activity: now,
connected_at: now,
ping_interval_ms: Map.get(opts, :ping_interval_ms, 120_000),
ping_timeout_ms: Map.get(opts, :ping_timeout_ms, 60_000),
flood_threshold_seconds: Map.get(opts, :flood_threshold_seconds, 20) * 1.0,
flood_penalty_default: Map.get(opts, :flood_penalty_default, 1.0) * 1.0,
flood_last_at_ms: System.monotonic_time(:millisecond)
}
Phoenix.PubSub.subscribe(@pubsub, OhMyExOhIrcd.Cluster.NodeListener.topic())
# If `proxy_protocol: true` is set on the listener, we expect Traefik /
# HAProxy / etc. to inject a PROXY v1/v2 header as the first bytes of
# every connection. Replace `state.host` with the real client IP and
# stash any extra bytes back into the data buffer so handle_data sees
# them on its first invocation.
if Map.get(opts, :proxy_protocol, false) do
handle_proxy_header(socket, state)
else
{:continue, state}
end
end
defp handle_proxy_header(socket, state) do
require Logger
case OhMyExOhIrcd.IRC.Proxy.read_header(socket, 5_000) do
{:ok, real_ip, leftover} ->
{:continue,
%{
state
| real_host: real_ip,
host: OhMyExOhIrcd.IRC.Cloak.host(real_ip, state.cloak_host),
buffer: leftover
}}
:no_header ->
# Direct TCP connect with no PROXY header — likely a port scan
# or a k8s probe that bypassed the proxy. Drop quietly.
{:close, state}
{:error, :closed} ->
# Peer closed before sending anything; expected churn from
# health checks and load-balancer probes.
{:close, state}
{:error, :timeout} ->
# Peer connected but sent nothing within the deadline. Same as :closed —
# uninteresting, just drop.
{:close, state}
{:error, reason} ->
# Truncated / malformed header — worth surfacing because it
# usually means our proxy and listener disagree on the version.
Logger.warning("PROXY header parse failed: #{inspect(reason)}; dropping")
{:close, state}
end
end
@impl ThousandIsland.Handler
def handle_data(data, socket, state) do
{lines, rest} = split_lines(state.buffer <> data)
state = %{state | buffer: rest}
{state, dropped?} =
Enum.reduce_while(lines, {state, false}, fn line, {st, _} ->
case process_line(line, socket, st) do
{:cont, st2} -> {:cont, {st2, false}}
{:drop, st2} -> {:halt, {st2, true}}
end
end)
if dropped?, do: {:close, state}, else: {:continue, state}
end
@impl GenServer
def handle_info({:irc_privmsg, entry}, {socket, state}) do
cmd = if Map.get(entry, :kind, :privmsg) == :notice, do: "NOTICE", else: "PRIVMSG"
cond do
# PRIVMSG into a channel we're subscribed to
channel_target?(entry.target) and MapSet.member?(state.channels, downcase(entry.target)) ->
# Don't echo our own outbound channel messages back to ourselves
# (clients add them locally). We match on the bare nick prefix because
# the sender field is the full "nick!user@host" form.
unless owns_sender?(state, entry.sender) do
send_msg(socket, Message.build(cmd, [entry.target, entry.text], entry.sender))
end
# DM to us
not channel_target?(entry.target) and downcase(entry.target) == downcase(state.nick) ->
send_msg(socket, Message.build(cmd, [entry.target, entry.text], entry.sender))
true ->
:ok
end
{:noreply, {socket, state}}
end
# Presence senders arrive as the full `nick!user@host` prefix and are
# relayed verbatim — clients (irssi in particular) need the host part to
# maintain their nicklists. Self-comparisons strip the nick back out.
def handle_info({:irc_presence, {:join, sender, channel}}, {socket, state}) do
if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(sender) != state.nick do
send_msg(socket, Message.build("JOIN", [channel], sender))
end
{:noreply, {socket, state}}
end
def handle_info({:irc_presence, {:part, sender, channel, reason}}, {socket, state}) do
if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(sender) != state.nick do
params = if reason == "", do: [channel], else: [channel, reason]
send_msg(socket, Message.build("PART", params, sender))
end
{:noreply, {socket, state}}
end
def handle_info({:irc_presence, {:topic, set_by, channel, text}}, {socket, state}) do
if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(set_by) != state.nick do
send_msg(socket, Message.build("TOPIC", [channel, text], set_by))
end
{:noreply, {socket, state}}
end
def handle_info({:irc_presence, {:mode, granter, channel, flags, targets}}, {socket, state}) do
if MapSet.member?(state.channels, downcase(channel)) do
send_msg(socket, Message.build("MODE", [channel, flags | targets], granter))
end
{:noreply, {socket, update_flood_exempt(state, channel, flags)}}
end
def handle_info({:irc_presence, {:kick, kicker, channel, target, reason}}, {socket, state}) do
if MapSet.member?(state.channels, downcase(channel)) do
send_msg(socket, Message.build("KICK", [channel, target, reason], kicker))
end
{:noreply, {socket, state}}
end
# The kicked user's connection forgets the channel (so future PRIVMSGs
# aren't accepted) but stays on the socket.
def handle_info({:irc_kicked, channel}, {socket, state}) do
Phoenix.PubSub.unsubscribe(@pubsub, MessageLog.channel_topic(channel))
state = %{
state
| channels: MapSet.delete(state.channels, downcase(channel)),
flood_exempt_channels: MapSet.delete(state.flood_exempt_channels, downcase(channel))
}
{:noreply, {socket, state}}
end
def handle_info({:irc_invite, inviter, channel}, {socket, state}) do
send_msg(socket, Message.build("INVITE", [state.nick, channel], inviter))
{:noreply, {socket, state}}
end
# An oper set (or cleared, with nil) our vhost via CHGHOST/SETHOST or the
# web admin. Update the display host immediately, persist it to our
# account if we're logged in, and tell us about it. The new host applies
# to every prefix we emit from here on; existing channel nicklists refresh
# on the next JOIN (we don't advertise the IRCv3 `chghost` cap).
def handle_info({:irc_sethost, new_host, by}, {socket, state}) do
display = new_host || OhMyExOhIrcd.IRC.Cloak.host(state.real_host, state.cloak_host)
state =
case state.account do
%{} = account ->
case OhMyExOhIrcd.Accounts.set_vhost(account, new_host) do
{:ok, updated} -> %{state | account: updated}
_ -> state
end
_ ->
state
end
state = %{state | host: display}
send_msg(
socket,
Message.build(
"NOTICE",
[state.nick || "*", "Your host is now #{display} (set by #{by})"],
state.server_name
)
)
{:noreply, {socket, state}}
end
# WALLOPS: visible to opers and to `+w` users. We dedup at receive
# time — if the same conn is on both `opers` and `wallops` PubSub
# topics, we'd otherwise see it twice.
def handle_info({:irc_wallops, sender, text}, {socket, state}) do
send_msg(socket, Message.build("WALLOPS", [text], sender))
{:noreply, {socket, state}}
end
# GLOBOPS: opers only. Conventionally rendered by clients as a NOTICE
# from the sending oper into the server-window.
def handle_info({:irc_globops, sender, text}, {socket, state}) do
if state.operator? do
send_msg(socket, Message.build("NOTICE", [state.nick, "[GLOBOPS] " <> text], sender))
end
{:noreply, {socket, state}}
end
# Global staff notice. Rendered as a regular NOTICE to the user's nick
# so clients place it in the server window (irssi-style
# "[Global Notice] …" labelling is up to the sender to include).
def handle_info({:irc_global_notice, sender, text}, {socket, state}) do
send_msg(socket, Message.build("NOTICE", [state.nick || "*", text], sender))
{:noreply, {socket, state}}
end
# IRC operator's KILL hit us. Conventional behaviour: send the
# `ERROR :Killed by ...` line and shut down the write half so the
# client sees a clean close.
def handle_info({:irc_killed, killer, reason}, {socket, state}) do
send_msg(
socket,
Message.build("ERROR", ["Closing link: Killed by #{killer}: #{reason}"])
)
ThousandIsland.Socket.shutdown(socket, :write)
{:noreply, {socket, state}}
end
def handle_info({:irc_presence, {:quit, sender, channel, reason}}, {socket, state}) do
if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(sender) != state.nick do
send_msg(socket, Message.build("QUIT", [reason], sender))
end
{:noreply, {socket, state}}
end
# Cluster topology changed — Channel singletons may have moved. Re-JOIN every
# channel we're in so the (possibly new) Channel pid has us in its members
# list. Channel.join is idempotent, so this is a safe no-op when nothing moved.
def handle_info({:node_down, _node}, {socket, state}) do
if state.registered? do
user_id = state.account && state.account.id
pfx = prefix(state)
Enum.each(state.channels, &Channel.join(&1, state.nick, self(), user_id, pfx))
end
{:noreply, {socket, state}}
end
def handle_info({:node_up, _node}, {socket, state}) do
if state.registered? do
user_id = state.account && state.account.id
pfx = prefix(state)
Enum.each(state.channels, &Channel.join(&1, state.nick, self(), user_id, pfx))
end
{:noreply, {socket, state}}
end
# Idle check — fires `ping_interval_ms` after the last reschedule. If the
# client has been quiet that whole time, send a PING and arm the timeout
# check. Otherwise reschedule for the remaining gap.
def handle_info(:check_idle, {socket, state}) do
idle_ms = (System.system_time(:second) - state.last_activity) * 1000
if idle_ms >= state.ping_interval_ms do
token = Integer.to_string(System.unique_integer([:positive]))
send_msg(socket, Message.build("PING", [token], state.server_name))
ref = Process.send_after(self(), :check_pong, state.ping_timeout_ms)
{:noreply, {socket, %{state | ping_token: token, ping_timer: ref}}}
else
remaining = state.ping_interval_ms - idle_ms
ref = Process.send_after(self(), :check_idle, max(remaining, 1_000))
{:noreply, {socket, %{state | ping_timer: ref}}}
end
end
# Pong deadline. If the client replied (or sent anything else) since we
# armed this check, ping_token has been cleared — go back to idle checking.
# Otherwise close the connection with ERROR Ping timeout.
def handle_info(:check_pong, {socket, state}) do
if state.ping_token == nil do
{:noreply, {socket, schedule_idle_check(state)}}
else
send_msg(
socket,
Message.build("ERROR", ["Closing link: Ping timeout (#{state.host})"])
)
ThousandIsland.Socket.shutdown(socket, :write)
{:noreply, {socket, state}}
end
end
def handle_info(_, {socket, state}), do: {:noreply, {socket, state}}
# Cross-node WHOIS query. The Horde-registered Connection on the
# target's node receives this call and returns a snapshot.
@impl GenServer
def handle_call(:whois_info, _from, {socket, state}) do
{:reply, local_whois_info(state), {socket, state}}
end
defp local_whois_info(state) do
%{
nick: state.nick,
user: state.user || state.nick,
host: state.host,
real_host: state.real_host,
realname: state.realname || state.nick,
channels: MapSet.to_list(state.channels),
idle: max(System.system_time(:second) - state.last_activity, 0),
signon: state.connected_at,
away: state.away,
account: state.account && state.account.email,
operator?: state.operator?
}
end
@impl ThousandIsland.Handler
def handle_close(_socket, state) do
if state.ping_timer, do: Process.cancel_timer(state.ping_timer)
if state.registered? do
record_sighting(state, %{quit_reason: state.quit_reason || "Connection closed"})
# Leave every joined channel cleanly.
Enum.each(state.channels, fn ch -> Channel.part(ch, state.nick, "Connection closed") end)
case HordeRegistry.whereis({:user, downcase(state.nick)}) do
pid when pid == self() ->
Horde.Registry.unregister(HordeRegistry, {:user, downcase(state.nick)})
_ ->
:ok
end
end
:ok
end
## Line parsing
defp split_lines(buf), do: do_split(buf, [])
defp do_split(buf, acc) do
case :binary.split(buf, ["\r\n", "\n"]) do
[line, rest] -> do_split(rest, [line | acc])
[_only] -> {Enum.reverse(acc), buf}
end
end
defp process_line(line, socket, state) do
case Message.parse(line) do
{:ok, msg} ->
# Any received line is proof-of-life: bump activity and clear any
# pending PING token so :check_pong won't drop us.
state = %{state | last_activity: System.system_time(:second), ping_token: nil}
case charge_flood(state, msg) do
{:ok, state} ->
{:cont, dispatch(msg, socket, state)}
{:flood, state} ->
send_msg(socket, Message.build("ERROR", ["Closing link: Excess Flood"]))
ThousandIsland.Socket.shutdown(socket, :write)
{:drop, state}
end
{:error, :empty} ->
{:cont, state}
end
end
# Leaky-bucket flood control. Drains `(now - last) / 1000` seconds of
# accumulated penalty, then adds the per-command penalty. Returns
# `{:flood, state}` if the bucket overflows.
#
# Skips charging entirely when the message is a PRIVMSG/NOTICE to a
# channel the user has cached as `+f` (flood-exempt). This lets a
# founder dedicate a channel as a message broker without their
# publishers getting throttled.
defp charge_flood(state, %Message{} = msg) do
if flood_exempt_message?(state, msg) do
{:ok, state}
else
do_charge(state, msg.command)
end
end
defp flood_exempt_message?(state, %Message{command: cmd, params: [target | _]})
when cmd in ["PRIVMSG", "NOTICE"] do
channel_target?(target) and MapSet.member?(state.flood_exempt_channels, downcase(target))
end
defp flood_exempt_message?(_state, _msg), do: false
defp do_charge(state, command) do
now_ms = System.monotonic_time(:millisecond)
drained_sec = (now_ms - state.flood_last_at_ms) / 1000.0
base = max(state.flood_penalty - drained_sec, 0.0)
penalty = base + penalty_for(command, state.flood_penalty_default)
state = %{state | flood_penalty: penalty, flood_last_at_ms: now_ms}
if penalty > state.flood_threshold_seconds do
{:flood, state}
else
{:ok, state}
end
end
# Per-command flood penalties in seconds. "Cheap" handshake / keepalive
# commands get a discount; "expensive" state-changing commands cost more.
# Unlisted commands fall back to the configured default.
defp penalty_for("PING", _default), do: 0.5
defp penalty_for("PONG", _default), do: 0.5
defp penalty_for("CAP", _default), do: 0.5
defp penalty_for("AUTHENTICATE", _default), do: 0.5
defp penalty_for("QUIT", _default), do: 0.5
defp penalty_for("JOIN", _default), do: 1.5
defp penalty_for("PART", _default), do: 1.5
defp penalty_for("NICK", _default), do: 1.5
defp penalty_for("KICK", _default), do: 1.5
defp penalty_for("MODE", _default), do: 1.5
defp penalty_for("LIST", _default), do: 3.0
defp penalty_for("WHOIS", _default), do: 1.5
defp penalty_for("INVITE", _default), do: 1.5
defp penalty_for("TOPIC", _default), do: 1.5
defp penalty_for(_command, default), do: default
## Command dispatch
defp dispatch(%Message{command: "PING", params: params}, socket, state) do
token = List.first(params) || state.server_name
send_msg(socket, Message.build("PONG", [state.server_name, token], state.server_name))
state
end
# Any PONG (matching token or not) is proof of life; clear the
# pending-token flag so :check_pong won't kill us.
defp dispatch(%Message{command: "PONG"}, _socket, state), do: %{state | ping_token: nil}
# IRCv3 capability negotiation. We only advertise `sasl` so clients
# like irssi/weechat will run their SASL handshake before completing
# registration.
defp dispatch(%Message{command: "CAP", params: ["LS" | _]}, socket, state) do
send_msg(socket, Message.build("CAP", [state.nick || "*", "LS", "sasl"], state.server_name))
%{state | cap_negotiating?: true}
end
defp dispatch(%Message{command: "CAP", params: ["LIST" | _]}, socket, state) do
ack = if state.account, do: "sasl", else: ""
send_msg(socket, Message.build("CAP", [state.nick || "*", "LIST", ack], state.server_name))
state
end
defp dispatch(%Message{command: "CAP", params: ["REQ", reqs | _]}, socket, state) do
requested = String.split(reqs, " ", trim: true)
{ack, nak} =
Enum.split_with(requested, fn cap -> cap == "sasl" or cap == "-sasl" end)
cond do
nak != [] ->
send_msg(
socket,
Message.build("CAP", [state.nick || "*", "NAK", reqs], state.server_name)
)
ack != [] ->
send_msg(
socket,
Message.build("CAP", [state.nick || "*", "ACK", Enum.join(ack, " ")], state.server_name)
)
true ->
:ok
end
%{state | cap_negotiating?: true}
end
defp dispatch(%Message{command: "CAP", params: ["END" | _]}, socket, state) do
state = %{state | cap_negotiating?: false}
maybe_complete_registration(state, socket)
end
defp dispatch(%Message{command: "CAP"}, _socket, state), do: state
# SASL PLAIN. Two-step: `AUTHENTICATE PLAIN` then a base64 payload
# (or `*` to abort).
defp dispatch(%Message{command: "AUTHENTICATE", params: ["PLAIN" | _]}, socket, state) do
send_msg(socket, Message.build("AUTHENTICATE", ["+"]))
%{state | sasl: :awaiting_payload}
end
defp dispatch(%Message{command: "AUTHENTICATE", params: ["*" | _]}, socket, state) do
send_numeric(socket, state, "906", [state.nick || "*", "SASL authentication aborted"])
%{state | sasl: nil}
end
defp dispatch(
%Message{command: "AUTHENTICATE", params: [payload | _]},
socket,
%State{sasl: :awaiting_payload} = state
) do
case Auth.verify_sasl_plain(payload) do
{:ok, user} ->
send_numeric(socket, state, "900", [
state.nick || "*",
"#{state.nick || "*"}!#{user.email}@#{state.host}",
user.email,
"You are now logged in as #{user.email}"
])
send_numeric(socket, state, "903", [state.nick || "*", "SASL authentication successful"])
%{state | account: user, sasl: nil}
{:error, error} ->
Auth.log_failure("SASL PLAIN", error, state.real_host || state.host)
send_numeric(socket, state, "904", [state.nick || "*", "SASL authentication failed"])
%{state | sasl: nil}
end
end
# An unsupported mechanism (AUTHENTICATE EXTERNAL, SCRAM-…), or a payload
# with no AUTHENTICATE PLAIN before it. Only a mechanism-shaped first
# parameter is logged; anything else could be a credential.
defp dispatch(%Message{command: "AUTHENTICATE", params: params}, socket, state) do
reason =
case params do
[mech | _] when byte_size(mech) <= 20 ->
if mech =~ ~r/^[A-Z0-9_-]+$/,
do: "unsupported mechanism #{mech}",
else: "payload without AUTHENTICATE PLAIN"
_ ->
"payload without AUTHENTICATE PLAIN"
end
Auth.log_failure("SASL", reason, state.real_host || state.host)
send_numeric(socket, state, "904", [state.nick || "*", "SASL authentication failed"])
%{state | sasl: nil}
end
defp dispatch(%Message{command: "PASS", params: [arg | _]}, socket, state) do
case Auth.verify_pass(arg) do
{:ok, user} ->
%{state | account: user}
{:error, error} ->
Auth.log_failure("PASS", error, state.real_host || state.host)
send_numeric(socket, state, "464", [state.nick || "*", "Password incorrect"])
state
end
end
defp dispatch(%Message{command: "NICK", params: [nick | _]}, socket, state) do
cond do
not valid_nick?(nick) ->
send_numeric(socket, state, "432", [nick || "*", "Erroneous nickname"])
state
Horde.Registry.lookup(HordeRegistry, {:user, downcase(nick)}) != [] ->
send_numeric(socket, state, "433", [state.nick || "*", nick, "Nickname is already in use"])
state
state.registered? ->
# NICK change after registration. The prefix is the *old* identity
# (`old!user@host`); clients use it to update their nicklists.
old = state.nick
old_prefix = prefix(state)
:ok = swap_user_registration(old, nick)
for ch <- state.channels do
Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(ch), {
:irc_presence,
{:nick_change, old_prefix, nick, ch}
})
end
send_msg(socket, Message.build("NICK", [nick], old_prefix))
# An anonymous user's history is per nick: the old one was seen
# until now too.
if is_nil(state.account), do: record_sighting(state)
state = %{state | nick: nick}
record_sighting(state)
state
true ->
state = %{state | nick: nick}
maybe_complete_registration(state, socket)
end
end
defp dispatch(%Message{command: "USER", params: [user, _, _, realname | _]}, socket, state) do
state = %{state | user: user, realname: realname}
maybe_complete_registration(state, socket)
end
defp dispatch(%Message{command: "QUIT", params: params}, socket, state) do
reason = List.first(params) || "Quit"
for ch <- state.channels do
Channel.part(ch, state.nick, reason)
end
send_msg(socket, Message.build("ERROR", ["Closing link: #{state.host} (#{reason})"]))
ThousandIsland.Socket.shutdown(socket, :write)
%{state | quit_reason: reason}
end
defp dispatch(%Message{command: "JOIN", params: [chans | rest]}, socket, state) do
# Optional second param is a comma-separated list of keys, lined up
# by position with the channel list.
keys =
case rest do
[keys_str | _] -> String.split(keys_str, ",", trim: false)
[] -> []
end
channels = String.split(chans, ",", trim: true)
channels
|> Enum.with_index()
|> Enum.reduce(state, fn {ch, idx}, st ->
key = Enum.at(keys, idx)
key = if key in [nil, ""], do: nil, else: key
do_join(ch, key, socket, st)
end)
end
defp dispatch(%Message{command: "PART", params: [chans | rest]}, socket, state) do
reason = List.first(rest) || ""
chans
|> String.split(",", trim: true)
|> Enum.reduce(state, fn ch, st -> do_part(ch, reason, socket, st) end)
end
defp dispatch(%Message{command: "PRIVMSG", params: [target, text | _]}, socket, state) do
cond do
not state.registered? ->
send_numeric(socket, state, "451", ["*", "You have not registered"])
state
channel_target?(target) ->
privmsg_channel(target, text, socket, state)
state
true ->
privmsg_user(target, text, socket, state)
state
end
end
defp dispatch(%Message{command: "NAMES", params: [chans | _]}, socket, state) do
for ch <- String.split(chans, ",", trim: true), do: send_names(ch, socket, state)
state
end
defp dispatch(%Message{command: "MOTD"}, socket, state) do
send_motd(socket, state)
state
end
defp dispatch(%Message{command: "LUSERS"}, socket, state) do
send_lusers(socket, state)
state
end
defp dispatch(%Message{command: "LIST", params: params}, socket, state) do
filter =
case params do
[list | _] -> String.split(list, ",", trim: true) |> Enum.map(&downcase/1)
[] -> nil
end
send_numeric(socket, state, "321", [state.nick || "*", "Channel", "Users Name"])
Stats.channel_names()
|> Enum.filter(fn ch -> filter == nil or downcase(ch) in filter end)
|> Enum.each(fn ch ->
members = safe_members(ch)
topic = safe_topic(ch)
send_numeric(socket, state, "322", [
state.nick || "*",
ch,
Integer.to_string(length(members)),
topic
])
end)
send_numeric(socket, state, "323", [state.nick || "*", "End of /LIST"])
state
end
defp dispatch(%Message{command: "TOPIC", params: [channel]}, socket, state) do
send_topic(channel, socket, state)
state
end
defp dispatch(%Message{command: "TOPIC", params: [channel, new_topic | _]}, socket, state) do
cond do
not state.registered? ->
send_numeric(socket, state, "451", ["*", "You have not registered"])
state
not MapSet.member?(state.channels, downcase(channel)) ->
send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])
state
true ->
Channel.set_topic(channel, state.nick, new_topic)
# Channel broadcasts the topic; our own connection will see it via PubSub,
# but we sent the SET so echo back to ourselves with our own prefix.
send_msg(socket, Message.build("TOPIC", [channel, new_topic], prefix(state)))
state
end
end
defp dispatch(%Message{command: "WHOIS", params: [target | _]}, socket, state) do
target = target |> String.split(",", trim: true) |> List.first()
send_whois(target, socket, state)
state
end
defp dispatch(%Message{command: "WHO", params: [target | _]}, socket, state) do
send_who(target, socket, state)
state
end
defp dispatch(%Message{command: "MODE", params: [target | rest]}, socket, state) do
cond do
channel_target?(target) ->
handle_channel_mode(target, rest, socket, state)
state
downcase(target) == downcase(state.nick) ->
handle_user_mode(rest, socket, state)
true ->
send_numeric(socket, state, "502", [state.nick, "Can't change mode for other users"])
state
end
end
defp dispatch(%Message{command: "KICK", params: [channel, target | rest]}, socket, state) do
reason = List.first(rest) || state.nick || "Kicked"
cond do
not state.registered? ->
send_numeric(socket, state, "451", ["*", "You have not registered"])
not MapSet.member?(state.channels, downcase(channel)) ->
send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])
true ->
kick(channel, target, reason, socket, state)
end
state
end
defp dispatch(%Message{command: "INVITE", params: [nick, channel | _]}, socket, state) do
cond do
not state.registered? ->
send_numeric(socket, state, "451", ["*", "You have not registered"])
not MapSet.member?(state.channels, downcase(channel)) ->
send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])
true ->
invite(nick, channel, socket, state)
end
state
end
defp dispatch(%Message{command: "NOTICE", params: [target, text | _]}, _socket, state) do
# NOTICE is identical to PRIVMSG except clients must never auto-reply to it.
# We deliver it through the same paths; receivers render it as a NOTICE
# based on `entry.kind`. Per RFC, errors generated by NOTICE are silently
# dropped (no numeric replies sent to the originator).
cond do
not state.registered? ->
state
channel_target?(target) ->
if MapSet.member?(state.channels, downcase(target)) do
# Errors from NOTICE are silently dropped per RFC.
_ = Channel.notice(target, prefix(state), text)
end
state
true ->
case HordeRegistry.whereis({:user, downcase(target)}) do
pid when is_pid(pid) ->
send(
pid,
{:irc_privmsg, %{sender: prefix(state), target: target, text: text, kind: :notice}}
)
nil ->
:ok
end
state
end
end
defp dispatch(%Message{command: "AWAY", params: []}, socket, state) do
send_numeric(socket, state, "305", [
state.nick || "*",
"You are no longer marked as being away"
])
%{state | away: nil}
end
defp dispatch(%Message{command: "AWAY", params: [reason | _]}, socket, state) do
if reason == "" do
send_numeric(socket, state, "305", [
state.nick || "*",
"You are no longer marked as being away"
])
%{state | away: nil}
else
send_numeric(socket, state, "306", [state.nick || "*", "You have been marked as being away"])
%{state | away: reason}
end
end
defp dispatch(%Message{command: "USERHOST", params: nicks}, socket, state) do
replies =
nicks
|> Enum.flat_map(&String.split(&1, " ", trim: true))
|> Enum.take(5)
|> Enum.flat_map(fn nick ->
case HordeRegistry.whereis({:user, downcase(nick)}) do
pid when is_pid(pid) ->
try do
info =
if pid == self(),
do: local_whois_info(state),
else: GenServer.call(pid, :whois_info, 1_000)
away_flag = if info.away, do: "-", else: "+"
["#{info.nick}=#{away_flag}#{info.user}@#{info.host}"]
catch
_, _ -> []
end
nil ->
[]
end
end)
send_numeric(socket, state, "302", [state.nick || "*", Enum.join(replies, " ")])
state
end
defp dispatch(%Message{command: "ISON", params: nicks}, socket, state) do
online =
nicks
|> Enum.flat_map(&String.split(&1, " ", trim: true))
|> Enum.filter(fn nick ->
HordeRegistry.whereis({:user, downcase(nick)}) != nil
end)
send_numeric(socket, state, "303", [state.nick || "*", Enum.join(online, " ")])
state
end
# OPER bareword: promote the existing SASL/PASS-authenticated session
# if the linked account has its `is_oper` flag set. This is the
# ergonomic path — managing opers is then just toggling a column on a
# Phoenix user, no bcrypt hashes hand-copied into config.
defp dispatch(%Message{command: "OPER", params: []}, socket, state) do
cond do
not state.registered? ->
send_numeric(socket, state, "451", ["*", "You have not registered"])
state
account_is_oper?(state.account) ->
grant_oper(socket, state)
true ->
send_numeric(socket, state, "464", [
state.nick,
"Password incorrect (or your account isn't flagged as an oper)"
])
state
end
end
# OPER name password. Two ways to succeed:
#
# 1. DB account flag — if you've already SASL/PASS-authenticated as
# an `is_oper` account, the typed password is irrelevant (the
# login is the proof of identity). This is what makes the common
# client `/oper` work: irssi/weechat/etc. *always* send a name +
# password and never a bare OPER, so without this fallback the
# DB-flag path would be unreachable from a normal client.
# 2. Static `:opers` config map — the classic O-line flow. No DB
# round-trip, so it doubles as bootstrap / disaster recovery.
defp dispatch(%Message{command: "OPER", params: [name, password | _]}, socket, state) do
cond do
not state.registered? ->
send_numeric(socket, state, "451", ["*", "You have not registered"])
state
account_is_oper?(state.account) ->
grant_oper(socket, state)
true ->
user_at_host = "#{state.user || state.nick}@#{state.real_host}"
case OhMyExOhIrcd.IRC.Opers.verify(name, password, user_at_host) do
:ok ->
grant_oper(socket, state)
{:error, :bad_host} ->
send_numeric(socket, state, "491", [state.nick, "No O-lines for your host"])
state
_ ->
send_numeric(socket, state, "464", [state.nick, "Password incorrect"])
state
end
end
end
defp dispatch(%Message{command: "OPER"}, socket, state) do
send_numeric(socket, state, "461", [state.nick || "*", "OPER", "Not enough parameters"])
state
end
defp dispatch(%Message{command: "KILL", params: [target, reason | _]}, socket, state) do
cond do
not state.registered? ->
send_numeric(socket, state, "451", ["*", "You have not registered"])
state
not state.operator? ->
send_numeric(socket, state, "481", [
state.nick || "*",
"Permission Denied - You're not an IRC operator"
])
state
true ->
case HordeRegistry.whereis({:user, downcase(target)}) do
pid when is_pid(pid) ->
send(pid, {:irc_killed, state.nick, reason})
state
nil ->
send_numeric(socket, state, "401", [state.nick, target, "No such nick/channel"])
state
end
end
end
defp dispatch(%Message{command: "KILL"}, socket, state) do
send_numeric(socket, state, "461", [state.nick || "*", "KILL", "Not enough parameters"])
state
end
defp dispatch(%Message{command: "WALLOPS", params: [text | _]}, socket, state) do
if state.operator? do
OhMyExOhIrcd.IRC.Broadcast.wallops(state.nick, text)
state
else
send_numeric(socket, state, "481", [
state.nick || "*",
"Permission Denied - You're not an IRC operator"
])
state
end
end
defp dispatch(%Message{command: "WALLOPS"}, socket, state) do
send_numeric(socket, state, "461", [state.nick || "*", "WALLOPS", "Not enough parameters"])
state
end
defp dispatch(%Message{command: "GLOBOPS", params: [text | _]}, socket, state) do
if state.operator? do
OhMyExOhIrcd.IRC.Broadcast.globops(state.nick, text)
state
else
send_numeric(socket, state, "481", [
state.nick || "*",
"Permission Denied - You're not an IRC operator"
])
state
end
end
defp dispatch(%Message{command: "GLOBOPS"}, socket, state) do
send_numeric(socket, state, "461", [state.nick || "*", "GLOBOPS", "Not enough parameters"])
state
end
# Non-standard but common: `GNOTICE :<text>` from an oper broadcasts
# to every connected user (the irssi-style staff "Global Notice").
# Receivers see a NOTICE from the sender to themselves; what surfaces
# as `[Global Notice]` in IRC clients is just convention — many opers
# prefix their message with that string by hand.
defp dispatch(%Message{command: "GNOTICE", params: [text | _]}, socket, state) do
if state.operator? do
OhMyExOhIrcd.IRC.Broadcast.global_notice(state.nick, "[Global Notice] " <> text)
state
else
send_numeric(socket, state, "481", [
state.nick || "*",
"Permission Denied - You're not an IRC operator"
])
state
end
end
defp dispatch(%Message{command: "GNOTICE"}, socket, state) do
send_numeric(socket, state, "461", [state.nick || "*", "GNOTICE", "Not enough parameters"])
state
end
# KLINE / GLINE — same backing store in this implementation (everything
# is cluster-wide via Postgres). Syntax: `KLINE [duration] <mask> :reason`
# where `duration` is an optional integer in minutes.
defp dispatch(%Message{command: cmd, params: params}, socket, state)
when cmd in ["KLINE", "GLINE"] do
cond do
not state.operator? ->
send_numeric(socket, state, "481", [
state.nick || "*",
"Permission Denied - You're not an IRC operator"
])
params == [] ->
send_numeric(socket, state, "461", [state.nick || "*", cmd, "Not enough parameters"])
true ->
{duration, mask, reason} = parse_kline_args(params)
opts = [
set_by: state.nick,
reason: reason,
duration_seconds: if(duration, do: duration * 60)
]
case OhMyExOhIrcd.IRC.Klines.add(mask, state.nick, opts) do
{:ok, _} ->
OhMyExOhIrcd.IRC.Broadcast.globops(
state.nick,
"#{cmd} added: #{mask} (#{reason})"
)
disconnect_matching(mask, state.nick, reason)
{:error, %Ecto.Changeset{} = cs} ->
send_numeric(socket, state, "501", [
state.nick,
"Bad K-line: " <> Enum.map_join(cs.errors, "; ", fn {f, {m, _}} -> "#{f} #{m}" end)
])
end
end
state
end
defp dispatch(%Message{command: cmd, params: [mask | _]}, socket, state)
when cmd in ["UNKLINE", "UNGLINE"] do
if state.operator? do
case OhMyExOhIrcd.IRC.Klines.remove(mask) do
0 ->
send_numeric(socket, state, "501", [state.nick, "No K-line matching #{mask}"])
n ->
OhMyExOhIrcd.IRC.Broadcast.globops(
state.nick,
"#{cmd} removed: #{mask} (#{n} row#{if n == 1, do: "", else: "s"})"
)
end
else
send_numeric(socket, state, "481", [
state.nick || "*",
"Permission Denied - You're not an IRC operator"
])
end
state
end
defp dispatch(%Message{command: cmd}, socket, state)
when cmd in ["UNKLINE", "UNGLINE"] do
send_numeric(socket, state, "461", [state.nick || "*", cmd, "Not enough parameters"])
state
end
# Oper-only: mint a single-use registration invite token in-band, so
# staff don't need shell access to `mix invites.create` /
# `OhMyExOhIrcd.Release.create_invite`. Syntax:
#
# MKINVITE — never-expiring, no note
# MKINVITE <days> — expires in <days> days
# MKINVITE <days> :<note> — with a note
#
# A note requires a leading day count (the IRC grammar can't otherwise
# tell a bare note from a day count). Replies are server NOTICEs to the
# oper carrying the token + signup URL.
defp dispatch(%Message{command: "MKINVITE", params: params}, socket, state) do
if state.operator? do
create_invite_command(params, socket, state)
else
send_numeric(socket, state, "481", [
state.nick || "*",
"Permission Denied - You're not an IRC operator"
])
end
state
end
# Oper-only: list active (unconsumed) invite tokens.
defp dispatch(%Message{command: "LSINVITE"}, socket, state) do
if state.operator? do
list_invites_command(socket, state)
else
send_numeric(socket, state, "481", [
state.nick || "*",
"Permission Denied - You're not an IRC operator"
])
end
state
end
# Oper-only vhost control.
#
# CHGHOST <nick> <host> — set someone's host
# CHGHOST <nick> * — clear it (revert to cloak)
# SETHOST <host> — shorthand for CHGHOST <self> <host>
# SETHOST * — clear your own
#
# For logged-in targets the host is also persisted to their account, so
# it survives reconnects. The change is delivered to the target's session
# via `{:irc_sethost, ...}`, so it works cross-node.
defp dispatch(%Message{command: "CHGHOST", params: [nick, host | _]}, socket, state) do
set_vhost_command(nick, host, socket, state)
state
end
defp dispatch(%Message{command: "CHGHOST"}, socket, state) do
send_numeric(socket, state, "461", [state.nick || "*", "CHGHOST", "Not enough parameters"])
state
end
defp dispatch(%Message{command: "SETHOST", params: [host | _]}, socket, state) do
set_vhost_command(state.nick, host, socket, state)
state
end
defp dispatch(%Message{command: "SETHOST"}, socket, state) do
send_numeric(socket, state, "461", [state.nick || "*", "SETHOST", "Not enough parameters"])
state
end
defp dispatch(%Message{command: cmd}, socket, state) do
send_numeric(socket, state, "421", [state.nick || "*", cmd, "Unknown command"])
state
end
## Helpers
defp create_invite_command(params, socket, state) do
case parse_invite_days(params) do
:error ->
send_server_notice(
socket,
state,
"Usage: MKINVITE [days] [:note] — days must be a positive integer"
)
{:ok, days} ->
opts = [note: invite_note(params)]
opts = if days, do: Keyword.put(opts, :expires_in_seconds, days * 24 * 3600), else: opts
opts = if state.account, do: Keyword.put(opts, :created_by, state.account), else: opts
# Guard the whole DB + URL path: an exception here would otherwise
# crash the ThousandIsland handler and drop the oper's connection.
# We'd much rather surface the error as a NOTICE and log it.
guard_invite_command(socket, state, "MKINVITE", fn ->
do_create_invite(opts, socket, state)
end)
end
end
defp do_create_invite(opts, socket, state) do
case OhMyExOhIrcd.Accounts.create_invite(opts) do
{:ok, invite} ->
url = signup_base_url() <> "/users/register?invite=#{invite.token}"
send_server_notice(socket, state, "Invite token: #{invite.token}")
send_server_notice(socket, state, "Signup URL: #{url}")
if invite.expires_at,
do: send_server_notice(socket, state, "Expires: #{invite.expires_at}")
{:error, cs} ->
send_server_notice(socket, state, "Could not create invite: #{inspect(cs.errors)}")
end
end
defp list_invites_command(socket, state) do
guard_invite_command(socket, state, "LSINVITE", fn ->
case OhMyExOhIrcd.Accounts.list_active_invites() do
[] ->
send_server_notice(socket, state, "No active invites.")
invites ->
send_server_notice(socket, state, "Active invites (#{length(invites)}):")
Enum.each(invites, &send_invite_line(&1, socket, state))
end
end)
end
defp send_invite_line(invite, socket, state) do
note = if invite.note in [nil, ""], do: "", else: " — #{invite.note}"
send_server_notice(
socket,
state,
" #{invite.token} expires=#{invite.expires_at || "never"}#{note}"
)
end
# Run an invite command body, converting any crash into a logged error
# plus an error NOTICE — never a dropped connection.
defp guard_invite_command(socket, state, label, fun) do
fun.()
rescue
e ->
Logger.error("#{label} failed: #{Exception.format(:error, e, __STACKTRACE__)}")
send_server_notice(
socket,
state,
"#{label} failed: #{Exception.message(e)} (see server log)"
)
catch
kind, reason ->
Logger.error("#{label} failed: #{inspect(kind)} #{inspect(reason)}")
send_server_notice(socket, state, "#{label} failed (see server log)")
end
# Public signup base URL, derived from the Endpoint's `:url` config
# (host = PHX_HOST in prod) rather than the live `Endpoint.url/0`, so it
# doesn't depend on endpoint runtime state and matches the behaviour of
# `OhMyExOhIrcd.Release.create_invite/1`.
defp signup_base_url do
url =
Application.get_env(:oh_my_ex_oh_ircd, OhMyExOhIrcdWeb.Endpoint, [])
|> Keyword.get(:url, [])
scheme = Keyword.get(url, :scheme, "http")
host = Keyword.get(url, :host, "localhost")
default = if scheme == "https", do: 443, else: 80
port = Keyword.get(url, :port, default)
if port == default, do: "#{scheme}://#{host}", else: "#{scheme}://#{host}:#{port}"
end
# `MKINVITE` argument parsing. No args → no expiry. A leading token, if
# present, must be a positive integer day count; anything else is a
# usage error.
defp parse_invite_days([]), do: {:ok, nil}
defp parse_invite_days([days | _]) do
case Integer.parse(days) do
{n, ""} when n > 0 -> {:ok, n}
_ -> :error
end
end
# The note is the trailing param after the day count, if any.
defp invite_note([_days, note | _]), do: note
defp invite_note(_), do: ""
# Shared body for CHGHOST/SETHOST. Oper-gated; validates the host (unless
# clearing with `*`/`-`), then pushes it onto the target's live session.
defp set_vhost_command(nick, host, socket, state) do
new_host = if host in ["*", "-", ""], do: nil, else: host
cond do
not state.operator? ->
send_numeric(socket, state, "481", [
state.nick || "*",
"Permission Denied - You're not an IRC operator"
])
new_host && not OhMyExOhIrcd.IRC.Vhost.valid_host?(new_host) ->
send_server_notice(socket, state, "Invalid host: #{host}")
true ->
set_live_vhost(nick, new_host, socket, state)
end
end
defp set_live_vhost(nick, new_host, socket, state) do
case OhMyExOhIrcd.IRC.Vhost.set_live(nick, new_host, state.nick) do
:ok ->
send_server_notice(
socket,
state,
"Set host for #{nick} to #{new_host || "(cloak)"}"
)
{:error, :no_such_nick} ->
send_numeric(socket, state, "401", [state.nick || "*", nick, "No such nick/channel"])
end
end
# A NOTICE from the server pseudo-host to the requesting oper.
defp send_server_notice(socket, state, text) do
server = state.server_name || Listener.server_name()
send_msg(socket, Message.build("NOTICE", [state.nick || "*", text], server))
end
# Force-join the freshly-registered user to every `+A` (auto-join)
# channel. Reuses do_join, so a channel that's also +i/+k/+l/banned
# will refuse the join just as a manual JOIN would — auto-join is for
# otherwise-open network channels (e.g. a welcome channel). DB hiccups
# during connect must never block registration, hence the rescue.
defp auto_join_channels(socket, state) do
OhMyExOhIrcd.Channels.auto_join_names()
|> Enum.reduce(state, fn ch, st -> do_join(ch, nil, socket, st) end)
rescue
_ -> state
end
defp do_join(channel, key, socket, state) do
cond do
not state.registered? ->
send_numeric(socket, state, "451", ["*", "You have not registered"])
state
not Channel.valid_name?(channel) ->
send_numeric(socket, state, "403", [state.nick || "*", channel, "Invalid channel name"])
state
true ->
{:ok, _pid} = Channel.ensure_started(channel)
user_id = state.account && state.account.id
# Subscribe *before* joining so any presence broadcasts the join
# triggers (synthetic +q for founders, JOIN line for others) land
# in our mailbox.
Phoenix.PubSub.subscribe(@pubsub, MessageLog.channel_topic(channel))
case Channel.join(channel, state.nick, self(), user_id, prefix(state), key) do
:ok ->
state = %{state | channels: MapSet.put(state.channels, downcase(channel))}
state = refresh_flood_exempt(state, channel)
send_msg(socket, Message.build("JOIN", [channel], prefix(state)))
send_join_topic(channel, socket, state)
send_names(channel, socket, state)
replay_backlog(channel, socket)
state
{:error, reason} ->
Phoenix.PubSub.unsubscribe(@pubsub, MessageLog.channel_topic(channel))
send_join_refusal(reason, channel, socket, state)
state
end
end
end
defp replay_backlog(channel, socket) do
MessageLog.entries(channel)
|> Enum.each(fn entry ->
cmd = if Map.get(entry, :kind, :privmsg) == :notice, do: "NOTICE", else: "PRIVMSG"
send_msg(socket, Message.build(cmd, [channel, entry.text], entry.sender))
end)
end
# Look up the channel's current flags and (re)cache the `+f` exemption
# locally. Called on JOIN so a returning user re-acquires the bit.
defp refresh_flood_exempt(state, channel) do
exempt? =
try do
Channel.flags(channel) |> MapSet.member?(:flood_exempt)
catch
_, _ -> false
end
key = downcase(channel)
set =
if exempt? do
MapSet.put(state.flood_exempt_channels, key)
else
MapSet.delete(state.flood_exempt_channels, key)
end
%{state | flood_exempt_channels: set}
end
# Parse a presence-mode flag string (e.g. "+ntf", "-f", "+i-f") and
# toggle our cached flood-exempt bit accordingly. Anything other than
# `+f`/`-f` is ignored.
defp update_flood_exempt(state, channel, flag_string) do
{action, _} =
flag_string
|> String.to_charlist()
|> Enum.reduce({:noop, :grant}, fn
?+, {acc, _} -> {acc, :grant}
?-, {acc, _} -> {acc, :revoke}
?f, {_, :grant} -> {:add, :grant}
?f, {_, :revoke} -> {:remove, :revoke}
_, acc -> acc
end)
key = downcase(channel)
case action do
:add ->
%{state | flood_exempt_channels: MapSet.put(state.flood_exempt_channels, key)}
:remove ->
%{state | flood_exempt_channels: MapSet.delete(state.flood_exempt_channels, key)}
:noop ->
state
end
end
defp send_join_refusal(:banned, channel, socket, state),
do: send_numeric(socket, state, "474", [state.nick, channel, "Cannot join channel (+b)"])
defp send_join_refusal(:invite_only, channel, socket, state),
do: send_numeric(socket, state, "473", [state.nick, channel, "Cannot join channel (+i)"])
defp send_join_refusal(:bad_key, channel, socket, state),
do: send_numeric(socket, state, "475", [state.nick, channel, "Cannot join channel (+k)"])
defp send_join_refusal(:channel_full, channel, socket, state),
do: send_numeric(socket, state, "471", [state.nick, channel, "Cannot join channel (+l)"])
defp do_part(channel, reason, socket, state) do
if MapSet.member?(state.channels, downcase(channel)) do
:ok = Channel.part(channel, state.nick, reason)
Phoenix.PubSub.unsubscribe(@pubsub, MessageLog.channel_topic(channel))
params = if reason == "", do: [channel], else: [channel, reason]
send_msg(socket, Message.build("PART", params, prefix(state)))
%{
state
| channels: MapSet.delete(state.channels, downcase(channel)),
flood_exempt_channels: MapSet.delete(state.flood_exempt_channels, downcase(channel))
}
else
send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])
state
end
end
defp kick(channel, target, reason, socket, state) do
case Channel.kick(channel, state.nick, target, reason) do
:ok ->
:ok
{:error, :not_authorized} ->
send_numeric(socket, state, "482", [
state.nick,
channel,
"You're not channel operator"
])
{:error, :no_such_member} ->
send_numeric(socket, state, "441", [
state.nick,
target,
channel,
"They aren't on that channel"
])
{:error, :not_on_channel} ->
send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])
end
end
defp invite(nick, channel, socket, state) do
case Channel.member_info(channel, state.nick) do
%{access: a} ->
if Channel.rank(a) >= Channel.rank(:halfop) do
case HordeRegistry.whereis({:user, downcase(nick)}) do
pid when is_pid(pid) ->
Channel.record_invite(channel, nick)
send(pid, {:irc_invite, state.nick, channel})
send_numeric(socket, state, "341", [state.nick, nick, channel])
nil ->
send_numeric(socket, state, "401", [state.nick, nick, "No such nick/channel"])
end
else
send_numeric(socket, state, "482", [
state.nick,
channel,
"You're not channel operator"
])
end
nil ->
send_numeric(socket, state, "442", [state.nick, channel, "You're not on that channel"])
end
end
defp privmsg_channel(target, text, socket, state) do
if MapSet.member?(state.channels, downcase(target)) do
case Channel.privmsg(target, prefix(state), text) do
:ok ->
:ok
{:error, :moderated} ->
send_numeric(socket, state, "404", [
state.nick,
target,
"Cannot send to channel (+m)"
])
{:error, :no_external} ->
send_numeric(socket, state, "404", [
state.nick,
target,
"Cannot send to channel (+n)"
])
end
else
send_numeric(socket, state, "404", [state.nick, target, "Cannot send to channel"])
end
end
# PRIVMSG to user — find their connection and deliver via Phoenix.PubSub
# of a per-user topic. Simpler: route through MessageLog with target=nick.
defp privmsg_user(target, text, socket, state) do
case HordeRegistry.whereis({:user, downcase(target)}) do
pid when is_pid(pid) ->
send(
pid,
{:irc_privmsg, %{sender: prefix(state), target: target, text: text, kind: :privmsg}}
)
nil ->
send_numeric(socket, state, "401", [state.nick, target, "No such nick/channel"])
end
end
defp send_names(channel, socket, state) do
members =
try do
Channel.members(channel)
catch
_, _ -> []
end
rendered =
Enum.map_join(members, " ", fn
%{nick: nick, access: access} -> Channel.prefix(access) <> nick
nick when is_binary(nick) -> nick
end)
send_numeric(socket, state, "353", [state.nick || "*", "=", channel, rendered])
send_numeric(socket, state, "366", [state.nick || "*", channel, "End of /NAMES list"])
end
defp maybe_complete_registration(%State{registered?: true} = state, _socket), do: state
# Block on CAP negotiation: per IRCv3, registration cannot complete until
# the client sends CAP END (or never sent CAP at all).
defp maybe_complete_registration(%State{cap_negotiating?: true} = state, _socket), do: state
defp maybe_complete_registration(%State{nick: nick, user: user} = state, socket)
when is_binary(nick) and is_binary(user) do
if is_nil(state.account) and not state.allow_anonymous do
send_numeric(socket, state, "464", [
state.nick || "*",
"Password required - authenticate via SASL or PASS"
])
state
else
case OhMyExOhIrcd.IRC.Klines.match("#{state.user}@#{state.real_host}") do
%{reason: reason} = _kline ->
send_msg(
socket,
Message.build("ERROR", ["Closing link: K-Lined: " <> (reason || "")])
)
ThousandIsland.Socket.shutdown(socket, :write)
state
_ ->
register_user(socket, state, nick)
end
end
end
defp maybe_complete_registration(state, _socket), do: state
defp register_user(socket, state, nick) do
value = %{node: node(), user_id: state.account && state.account.id}
case Horde.Registry.register(HordeRegistry, {:user, downcase(nick)}, value) do
{:ok, _} ->
state = %{state | registered?: true}
state = apply_account_vhost(state)
record_sighting(state)
send_welcome(socket, state)
OhMyExOhIrcd.IRC.Broadcast.subscribe_all()
state = schedule_idle_check(state)
auto_join_channels(socket, state)
{:error, {:already_registered, _}} ->
send_numeric(socket, state, "433", [state.nick, nick, "Nickname is already in use"])
%{state | nick: nil}
end
end
# A logged-in user with a persistent vhost wears it from the moment they
# register, overriding the hashed-IP cloak. Anonymous users (no account)
# and accounts without a vhost keep their cloak.
defp apply_account_vhost(%State{account: %{vhost: vhost}} = state)
when is_binary(vhost) and vhost != "" do
%{state | host: vhost}
end
defp apply_account_vhost(state), do: state
# "Seen on this network" history. Handed to Sightings.Writer, so the
# database is never on this connection's path.
defp record_sighting(state, extra \\ %{}) do
%{
nick: state.nick,
user_id: state.account && state.account.id,
host: state.host,
real_ip: state.real_host
}
|> Map.merge(extra)
|> OhMyExOhIrcd.Sightings.record_async()
end
defp swap_user_registration(old, new) do
Horde.Registry.unregister(HordeRegistry, {:user, downcase(old)})
case Horde.Registry.register(HordeRegistry, {:user, downcase(new)}, %{node: node()}) do
{:ok, _} -> :ok
err -> err
end
end
defp send_welcome(socket, state) do
send_numeric(socket, state, "001", [
state.nick,
"Welcome to the #{state.network_name} IRC Network #{state.nick}"
])
send_numeric(socket, state, "002", [
state.nick,
"Your host is #{state.server_name}, running oh_my_ex_oh_ircd"
])
send_numeric(socket, state, "003", [state.nick, "This server was created right now"])
send_numeric(socket, state, "004", [
state.nick,
state.server_name,
"oh_my_ex_oh_ircd-0.1",
"o",
"o"
])
send_numeric(socket, state, "005", [
state.nick,
"NETWORK=#{state.network_name}",
"CHANTYPES=#&",
"CASEMAPPING=ascii",
"PREFIX=(qaohv)~&@%+",
# CHANMODES=A,B,C,D where:
# A = list modes (always take a parameter) — b/e/I
# B = setting modes that take a parameter on both set and unset — k
# C = setting modes that take a parameter only when set — l
# D = setting modes that take no parameter — A/f/i/m/n/t
# (`A` = auto-join, oper-set; force-joins every connecting user)
"CHANMODES=beI,k,l,Afimnt",
"STATUSMSG=~&@%+",
"are supported by this server"
])
send_lusers(socket, state)
send_motd(socket, state)
end
defp send_motd(socket, state) do
case state.motd do
[] ->
send_numeric(socket, state, "422", [state.nick || "*", "MOTD File is missing"])
lines ->
send_numeric(socket, state, "375", [
state.nick || "*",
"- #{state.server_name} Message of the Day -"
])
Enum.each(lines, fn line ->
send_numeric(socket, state, "372", [state.nick || "*", "- #{line}"])
end)
send_numeric(socket, state, "376", [state.nick || "*", "End of MOTD command"])
end
end
defp send_lusers(socket, state) do
users = Stats.user_count()
channels = Stats.channel_count()
nodes = 1 + length(Node.list())
send_numeric(socket, state, "251", [
state.nick || "*",
"There are #{users} users on #{nodes} servers"
])
send_numeric(socket, state, "254", [
state.nick || "*",
Integer.to_string(channels),
"channels formed"
])
send_numeric(socket, state, "255", [
state.nick || "*",
"I have #{users} clients and #{nodes} servers"
])
send_numeric(socket, state, "265", [
state.nick || "*",
Integer.to_string(users),
Integer.to_string(users),
"Current local users #{users}, max #{users}"
])
send_numeric(socket, state, "266", [
state.nick || "*",
Integer.to_string(users),
Integer.to_string(users),
"Current global users #{users}, max #{users}"
])
end
defp send_topic(channel, socket, state) do
info = safe_topic_info(channel)
cond do
info == nil ->
send_numeric(socket, state, "403", [state.nick || "*", channel, "No such channel"])
info.text == "" ->
send_numeric(socket, state, "331", [state.nick || "*", channel, "No topic is set"])
true ->
send_numeric(socket, state, "332", [state.nick || "*", channel, info.text])
if info.set_by != "" do
send_numeric(socket, state, "333", [
state.nick || "*",
channel,
info.set_by,
Integer.to_string(info.set_at)
])
end
end
end
defp send_whois(target, socket, state) do
case HordeRegistry.whereis({:user, downcase(target)}) do
pid when is_pid(pid) ->
try do
info =
if pid == self() do
local_whois_info(state)
else
GenServer.call(pid, :whois_info, 2_000)
end
send_whois_reply(info, socket, state)
catch
_, _ ->
send_numeric(socket, state, "401", [state.nick || "*", target, "No such nick/channel"])
end
nil ->
send_numeric(socket, state, "401", [state.nick || "*", target, "No such nick/channel"])
end
end
defp send_whois_reply(info, socket, state) do
me = state.nick || "*"
send_numeric(socket, state, "311", [
me,
info.nick,
info.user,
info.host,
"*",
info.realname
])
send_numeric(socket, state, "312", [me, info.nick, state.server_name, "oh_my_ex_oh_ircd"])
send_whois_actually(info, socket, state)
send_whois_extras(info, socket, state)
send_numeric(socket, state, "317", [
me,
info.nick,
Integer.to_string(info.idle),
Integer.to_string(info.signon),
"seconds idle, signon time"
])
send_numeric(socket, state, "318", [me, info.nick, "End of /WHOIS list"])
end
# RPL_WHOISACTUALLY — reveal the real IP behind a cloak/vhost, but
# only to operators. Skipped when the displayed host already is the
# real host (cloaking off and no vhost).
defp send_whois_actually(info, socket, state) do
real_host = Map.get(info, :real_host)
if state.operator? and is_binary(real_host) and real_host != info.host do
send_numeric(socket, state, "338", [
state.nick || "*",
info.nick,
real_host,
"Is actually using host"
])
end
end
defp send_whois_extras(info, socket, state) do
me = state.nick || "*"
if info.channels != [] do
send_numeric(socket, state, "319", [me, info.nick, Enum.join(info.channels, " ")])
end
if info.away do
send_numeric(socket, state, "301", [me, info.nick, info.away])
end
if info.account do
send_numeric(socket, state, "330", [me, info.nick, info.account, "is logged in as"])
end
if Map.get(info, :operator?) do
send_numeric(socket, state, "313", [me, info.nick, "is an IRC operator"])
end
end
defp send_who(target, socket, state) do
if channel_target?(target) do
target
|> safe_members()
|> Enum.each(&send_who_member(&1, target, socket, state))
else
send_who_nick(target, socket, state)
end
send_numeric(socket, state, "315", [state.nick || "*", target, "End of /WHO list"])
end
defp send_who_member(member, target, socket, state) do
{nick, access, user, host} =
case member do
%{nick: n, access: a, user: u, host: h} -> {n, a, u || n, h || "unknown"}
%{nick: n, access: a} -> {n, a, n, "unknown"}
n when is_binary(n) -> {n, :member, n, "unknown"}
end
# Per RFC 2812, flags is `H|G[*][@%+]` — we append the highest-rank prefix.
flag = "H" <> Channel.prefix(access)
# Format: <channel> <user> <host> <server> <nick> <flags> :<hopcount> <realname>
send_numeric(socket, state, "352", [
state.nick || "*",
target,
user,
host,
state.server_name,
nick,
flag,
"0 #{nick}"
])
end
# WHO on a nick — just one row if we can find them.
defp send_who_nick(target, socket, state) do
case HordeRegistry.whereis({:user, downcase(target)}) do
pid when is_pid(pid) ->
try do
info =
if pid == self(),
do: local_whois_info(state),
else: GenServer.call(pid, :whois_info, 1_000)
flag = if info.away, do: "G", else: "H"
send_numeric(socket, state, "352", [
state.nick || "*",
"*",
info.user,
info.host,
state.server_name,
info.nick,
flag,
"0 #{info.realname}"
])
catch
_, _ -> :ok
end
nil ->
:ok
end
end
defp safe_members(channel) do
try do
case Channel.members(channel) do
list when is_list(list) -> list
_ -> []
end
catch
_, _ -> []
end
end
defp safe_topic(channel) do
case safe_topic_info(channel) do
%{text: t} -> t
_ -> ""
end
end
# On JOIN, surface the (possibly just-restored-from-DB) topic to the
# client via RPL_TOPIC/RPL_TOPICWHOTIME. Sent only when a topic is set —
# no RPL_331 noise for topicless channels.
defp send_join_topic(channel, socket, state) do
case safe_topic_info(channel) do
%{text: text} = info when is_binary(text) and text != "" ->
send_numeric(socket, state, "332", [state.nick || "*", channel, text])
if info.set_by not in [nil, ""] do
send_numeric(socket, state, "333", [
state.nick || "*",
channel,
info.set_by,
Integer.to_string(info.set_at || 0)
])
end
_ ->
:ok
end
end
defp safe_topic_info(channel) do
try do
Channel.topic_info(channel)
catch
_, _ -> nil
end
end
# Bare nick from a `nick!user@host` prefix (or a bare nick, unchanged).
defp prefix_nick(prefix) when is_binary(prefix),
do: prefix |> String.split("!", parts: 2) |> List.first()
defp prefix_nick(_), do: nil
defp owns_sender?(%State{nick: nil}, _sender), do: false
defp owns_sender?(%State{nick: nick}, sender) when is_binary(sender) do
case String.split(sender, "!", parts: 2) do
[^nick | _] -> true
_ -> false
end
end
defp owns_sender?(_, _), do: false
# Self-mode (`MODE <yournick> ...`). Only a tiny subset matters:
# `+w/-w` toggles WALLOPS reception. Everything else just reports the
# current user-mode string.
defp handle_user_mode([], socket, state) do
send_numeric(socket, state, "221", [state.nick, user_mode_string(state)])
state
end
defp handle_user_mode([mode_string | _], socket, state) do
state =
mode_string
|> String.to_charlist()
|> Enum.reduce({:grant, state}, fn
?+, {_, st} ->
{:grant, st}
?-, {_, st} ->
{:revoke, st}
?w, {:grant, st} when st.wallops? == false ->
OhMyExOhIrcd.IRC.Broadcast.subscribe_wallops()
{:grant, %{st | wallops?: true}}
?w, {:revoke, st} when st.wallops? == true ->
OhMyExOhIrcd.IRC.Broadcast.unsubscribe_wallops()
{:revoke, %{st | wallops?: false}}
_, acc ->
acc
end)
|> elem(1)
# Echo back the resolved user-mode string (RFC 221 RPL_UMODEIS).
send_numeric(socket, state, "221", [state.nick, user_mode_string(state)])
state
end
defp user_mode_string(state) do
"+" <>
if(state.operator?, do: "o", else: "") <>
if(state.wallops?, do: "w", else: "")
end
defp handle_channel_mode(target, [], socket, state) do
{flag_str, args} = current_modes(target)
send_numeric(socket, state, "324", [state.nick || "*", target, flag_str | args])
end
defp handle_channel_mode(target, [mode_string | params], socket, state) do
if state.registered? do
mode_string
|> parse_channel_modes(params)
|> apply_parsed_channel_modes(target, socket, state)
else
send_numeric(socket, state, "451", ["*", "You have not registered"])
end
end
defp apply_parsed_channel_modes({:ok, {[], list_views}}, target, socket, state) do
Enum.each(list_views, &render_mask_list(&1, target, socket, state))
end
defp apply_parsed_channel_modes({:ok, {changes, list_views}}, target, socket, state) do
if MapSet.member?(state.channels, downcase(target)) do
apply_channel_mode_changes(target, changes, socket, state)
Enum.each(list_views, &render_mask_list(&1, target, socket, state))
else
send_numeric(socket, state, "442", [
state.nick,
target,
"You're not on that channel"
])
end
end
defp apply_parsed_channel_modes({:error, {:unknown_mode, letter}}, _target, socket, state) do
send_numeric(socket, state, "472", [
state.nick || "*",
<<letter::utf8>>,
"is unknown mode"
])
end
defp apply_channel_mode_changes(target, changes, socket, state) do
case Channel.apply_mode_changes(target, state.nick, changes, operator?: state.operator?) do
{:ok, _applied} ->
:ok
{:error, :not_authorized} ->
send_numeric(socket, state, "482", [
state.nick,
target,
"You're not channel operator"
])
{:error, :no_such_member} ->
send_numeric(socket, state, "401", [state.nick, "*", "No such nick/channel"])
{:error, :not_on_channel} ->
send_numeric(socket, state, "442", [
state.nick,
target,
"You're not on that channel"
])
end
end
# Parse a mode string like "+b-b+ov-q" plus its parameter list. Returns
# `{:ok, {changes, list_views}}` where:
# * `changes` is the list of `{:grant|:revoke, access, nick}`,
# `{:add_mask|:remove_mask, type, mask}`, or `{:set_flag|:clear_flag, flag}`
# * `list_views` is the list of mask types the user wants to list
# (e.g. `MODE #ch +b` with no arg = "show me the bans").
defp parse_channel_modes(mode_string, params) do
do_parse_modes(String.to_charlist(mode_string), :grant, params, [], [])
end
defp do_parse_modes([], _op, _params, changes, views),
do: {:ok, {Enum.reverse(changes), Enum.reverse(views)}}
defp do_parse_modes([?+ | rest], _op, params, changes, views),
do: do_parse_modes(rest, :grant, params, changes, views)
defp do_parse_modes([?- | rest], _op, params, changes, views),
do: do_parse_modes(rest, :revoke, params, changes, views)
defp do_parse_modes([letter | rest], op, params, changes, views) do
cond do
Map.has_key?(Channel.mode_letters(), letter) ->
parse_access_mode(letter, rest, op, params, changes, views)
letter in [?b, ?e, ?I] ->
parse_mask_mode(letter, rest, op, params, changes, views)
letter == ?k ->
parse_key_mode(rest, op, params, changes, views)
letter == ?l ->
parse_limit_mode(rest, op, params, changes, views)
Channel.flag_from_letter(letter) ->
flag = Channel.flag_from_letter(letter)
change = if op == :grant, do: {:set_flag, flag}, else: {:clear_flag, flag}
do_parse_modes(rest, op, params, [change | changes], views)
true ->
{:error, {:unknown_mode, letter}}
end
end
defp parse_access_mode(letter, rest, op, params, changes, views) do
access = Map.fetch!(Channel.mode_letters(), letter)
case params do
[target | tail] ->
do_parse_modes(rest, op, tail, [{op, access, target} | changes], views)
[] ->
{:error, {:unknown_mode, letter}}
end
end
defp parse_mask_mode(letter, rest, op, params, changes, views) do
type = mask_letter_to_type(letter)
case params do
[mask | tail] ->
change = if op == :grant, do: {:add_mask, type, mask}, else: {:remove_mask, type, mask}
do_parse_modes(rest, op, tail, [change | changes], views)
[] ->
do_parse_modes(rest, op, params, changes, [type | views])
end
end
# `+k <key>` always consumes an argument; `-k <arg>` also consumes
# one per RFC. Missing arg = silently ignore (some clients send
# `MODE #ch k` to inspect; we surface it via 324).
defp parse_key_mode(rest, op, [arg | tail], changes, views) do
change = if op == :grant, do: {:set_key, arg}, else: {:clear_key, arg}
do_parse_modes(rest, op, tail, [change | changes], views)
end
defp parse_key_mode(rest, op, [], changes, views),
do: do_parse_modes(rest, op, [], changes, views)
# `+l N` consumes an int. `-l` doesn't consume.
defp parse_limit_mode(rest, :grant, [arg | tail], changes, views) do
case Integer.parse(arg) do
{n, ""} when n > 0 ->
do_parse_modes(rest, :grant, tail, [{:set_limit, n} | changes], views)
_ ->
do_parse_modes(rest, :grant, tail, changes, views)
end
end
defp parse_limit_mode(rest, :grant, [], changes, views),
do: do_parse_modes(rest, :grant, [], changes, views)
defp parse_limit_mode(rest, :revoke, params, changes, views),
do: do_parse_modes(rest, :revoke, params, [{:clear_limit} | changes], views)
defp mask_letter_to_type(?b), do: :ban
defp mask_letter_to_type(?e), do: :except
defp mask_letter_to_type(?I), do: :invite
# ── RPL_BANLIST/RPL_EXCEPTLIST/RPL_INVITELIST renderers ──────────────
defp render_mask_list(:ban, target, socket, state),
do:
render_mask_list_with(:ban, "367", "368", "End of channel ban list", target, socket, state)
defp render_mask_list(:except, target, socket, state),
do:
render_mask_list_with(
:except,
"348",
"349",
"End of channel exception list",
target,
socket,
state
)
defp render_mask_list(:invite, target, socket, state),
do:
render_mask_list_with(
:invite,
"346",
"347",
"End of channel invite list",
target,
socket,
state
)
defp render_mask_list_with(type, line_code, end_code, end_text, target, socket, state) do
try do
Channel.list_masks(target, type)
|> Enum.each(fn entry ->
send_numeric(socket, state, line_code, [
state.nick || "*",
target,
entry.mask,
entry.set_by,
Integer.to_string(entry.set_at)
])
end)
catch
_, _ -> :ok
end
send_numeric(socket, state, end_code, [state.nick || "*", target, end_text])
end
# Returns `{mode_string, [arg, ...]}` for use as the params of a 324
# numeric. Mode-with-arg ordering matches the letter ordering in the
# mode string.
defp current_modes(target) do
try do
%{flags: flags, key: key, limit: limit} = Channel.mode_snapshot(target)
flag_letters =
["i", "m", "n", "f", "A"]
|> Enum.filter(fn l ->
MapSet.member?(flags, Channel.flag_from_letter(:binary.first(l)))
end)
|> Enum.join()
mode_string =
"+nt" <>
flag_letters <>
if(key, do: "k", else: "") <>
if(limit, do: "l", else: "")
args =
[if(key, do: key, else: nil), if(limit, do: Integer.to_string(limit), else: nil)]
|> Enum.reject(&is_nil/1)
{mode_string, args}
catch
_, _ -> {"+nt", []}
end
end
# `KLINE` syntax mirrors charybdis/oftc: optional leading integer
# (minutes) duration, then mask, then trailing reason. Examples:
#
# KLINE *!*@evil.example :spam
# KLINE 60 *!*@1.2.3.* :try again in an hour
defp parse_kline_args(params) do
case params do
[maybe_dur | rest] ->
case Integer.parse(maybe_dur) do
{n, ""} when n > 0 ->
{mask, reason} = take_mask_and_reason(rest)
{n, mask, reason}
_ ->
{mask, reason} = take_mask_and_reason(params)
{nil, mask, reason}
end
[] ->
{nil, "", ""}
end
end
defp take_mask_and_reason([mask]), do: {mask, ""}
defp take_mask_and_reason([mask | rest]), do: {mask, Enum.join(rest, " ")}
defp take_mask_and_reason([]), do: {"", ""}
# Walk every Horde-registered user; ask each for its `user@host` and
# disconnect any matching the new K-line mask.
defp disconnect_matching(mask, killer, reason) do
Horde.Registry.select(HordeRegistry, [{{{:user, :"$1"}, :"$2", :_}, [], [{{:"$1", :"$2"}}]}])
|> Enum.each(fn {_nick, pid} ->
try do
info = GenServer.call(pid, :whois_info, 1_000)
# Match the K-line against the real IP as well as the displayed
# host, so a cloak or vhost can't be used to dodge a ban placed on
# the underlying address.
real_host = Map.get(info, :real_host) || info.host
targets = ["#{info.user}@#{real_host}", "#{info.user}@#{info.host}"]
if Enum.any?(targets, &match_glob?(&1, mask)) do
send(pid, {:irc_killed, killer, "K-Lined: " <> reason})
end
catch
_, _ -> :ok
end
end)
end
defp match_glob?(string, mask) do
pattern =
mask
|> Regex.escape()
|> String.replace("\\*", ".*")
|> String.replace("\\?", ".")
Regex.match?(~r/\A#{pattern}\z/i, string)
end
defp grant_oper(socket, state) do
send_numeric(socket, state, "381", [state.nick, "You are now an IRC operator"])
send_msg(socket, Message.build("MODE", [state.nick, "+o"], state.nick))
OhMyExOhIrcd.IRC.Broadcast.subscribe_opers()
%{state | operator?: true}
end
# Re-read the `is_oper` flag from the DB rather than trusting the
# `User` struct cached at SASL/PASS time. Otherwise a `grant_oper`
# issued *after* the client authenticated wouldn't take effect until
# the client reconnected (the cached struct would still say false).
defp account_is_oper?(nil), do: false
defp account_is_oper?(%{id: id}) do
OhMyExOhIrcd.Accounts.oper?(OhMyExOhIrcd.Accounts.get_user!(id))
rescue
Ecto.NoResultsError -> false
end
defp account_is_oper?(_), do: false
defp schedule_idle_check(state) do
if state.ping_timer, do: Process.cancel_timer(state.ping_timer)
ref = Process.send_after(self(), :check_idle, state.ping_interval_ms)
%{state | ping_timer: ref, ping_token: nil}
end
defp send_numeric(socket, state, code, params) do
msg = Message.build(code, params, state.server_name || Listener.server_name())
send_msg(socket, msg)
end
defp send_msg(socket, %Message{} = msg) do
ThousandIsland.Socket.send(socket, Message.to_iodata(msg))
end
defp prefix(%State{nick: nick, user: user, host: host}),
do: "#{nick}!#{user || nick}@#{host}"
defp channel_target?("#" <> _), do: true
defp channel_target?("&" <> _), do: true
defp channel_target?(_), do: false
defp downcase(nil), do: nil
defp downcase(s) when is_binary(s), do: String.downcase(s)
defp valid_nick?(nick) when is_binary(nick) and byte_size(nick) in 1..30 do
String.match?(nick, ~r/^[A-Za-z\[\]\\`_^{|}][A-Za-z0-9\[\]\\`_^{|}\-]*$/)
end
defp valid_nick?(_), do: false
end
neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
81.7 KiB · text
History
4b6fe3b