defmodule OhMyExOhIrcd.Channels do
@moduledoc """
Persisted channel registrations. A registered channel has a founder
(a Phoenix user). When that user joins the channel they are auto-
promoted to `:founder` access.
"""
import Ecto.Query, warn: false
require Logger
alias OhMyExOhIrcd.Repo
alias OhMyExOhIrcd.Channels.{ChannelMembership, ChannelRecord, ChannelTopic}
alias OhMyExOhIrcd.Accounts.User
@doc "Fetch a registration by name (case-insensitive). Returns nil if unregistered."
def get(name) when is_binary(name) do
Repo.get_by(ChannelRecord, name: String.downcase(name))
end
def list do
Repo.all(from c in ChannelRecord, order_by: [asc: c.name])
end
@doc """
Every registered channel with its founder preloaded — for the
operator admin view (the per-user `list_for_user/1` only returns
channels the caller has a stake in).
"""
def list_with_founder do
Repo.all(from c in ChannelRecord, order_by: [asc: c.name], preload: [:founder])
end
@doc """
Names of registered channels carrying the `+A` (auto-join) flag.
Every connecting user is force-joined to these right after
registration. Only registered channels are considered — the flag is
persisted via the `modes` map, so an unregistered channel's `+A` does
not survive and is not enumerable here.
"""
def auto_join_names do
Repo.all(
from c in ChannelRecord,
where: fragment("(? -> 'flags') @> '[\"auto_join\"]'::jsonb", c.modes),
order_by: [asc: c.name],
select: c.name
)
rescue
_ -> []
end
@doc """
Channels the given user has a relationship with — either as founder
(via `channels.founder_user_id`) or via a persisted `+a/+o/+h/+v`
grant in `channel_memberships`. Returns
`[%{record: %ChannelRecord{}, role: access_atom}]`, sorted by channel
name. Founder always wins over a per-user membership row.
"""
def list_for_user(%User{id: user_id}) do
founder_recs = Repo.all(from c in ChannelRecord, where: c.founder_user_id == ^user_id)
member_rows =
Repo.all(
from m in ChannelMembership,
where: m.user_id == ^user_id,
join: c in ChannelRecord,
on: c.id == m.channel_id,
select: {c, m.access}
)
founder_ids = MapSet.new(founder_recs, & &1.id)
founder_entries = Enum.map(founder_recs, fn r -> %{record: r, role: :founder} end)
member_entries =
member_rows
|> Enum.reject(fn {c, _} -> MapSet.member?(founder_ids, c.id) end)
|> Enum.map(fn {c, access_str} ->
%{record: c, role: String.to_existing_atom(access_str)}
end)
(founder_entries ++ member_entries)
|> Enum.sort_by(& &1.record.name)
end
@doc """
Register a channel with the given user as founder. Idempotent: if the
channel is already registered to the same user it returns the existing
record; if registered to someone else it returns `{:error, :taken}`.
"""
def register(name, %User{} = user) when is_binary(name) do
name = String.downcase(name)
case get(name) do
nil ->
%ChannelRecord{}
|> ChannelRecord.registration_changeset(%{name: name, founder_user_id: user.id})
|> Repo.insert()
%ChannelRecord{founder_user_id: id} = rec when id == user.id ->
{:ok, rec}
%ChannelRecord{} ->
{:error, :taken}
end
end
@doc "Drop a channel registration. Only the founder can do this."
def unregister(name, %User{} = user) when is_binary(name) do
case get(name) do
nil ->
{:error, :not_registered}
%ChannelRecord{founder_user_id: id} = rec when id == user.id ->
Repo.delete(rec)
%ChannelRecord{} ->
{:error, :not_founder}
end
end
@doc "Fetch the persisted topic row for a channel, or nil."
def get_topic(name) when is_binary(name) do
Repo.get_by(ChannelTopic, name: String.downcase(name))
end
@doc """
Persist a topic change for any channel — registered or not — so it
survives restarts/deploys. Called from `Channel.set_topic/3`.
Writes the canonical `channel_topics` row, and keeps the registered
channel's `channels.topic` in sync as well so the admin/ChanServ views
don't go stale. Best-effort: never lets a DB hiccup crash the caller.
"""
def put_topic(name, set_by, topic, set_at)
when is_binary(name) and is_binary(topic) do
name = String.downcase(name)
(get_topic(name) || %ChannelTopic{})
|> ChannelTopic.changeset(%{name: name, topic: topic, set_by: set_by, set_at: set_at})
|> Repo.insert_or_update()
case get(name) do
%ChannelRecord{} = rec ->
rec |> ChannelRecord.topic_changeset(%{topic: topic}) |> Repo.update()
nil ->
:ok
end
:ok
rescue
_ -> :ok
end
@doc """
Persist channel-level modes (key/limit/flags/masks) for a registered
channel. No-op for unregistered channels.
"""
def persist_modes(name, snapshot) when is_map(snapshot) do
case get(name) do
nil ->
:ok
%ChannelRecord{} = rec ->
rec
|> ChannelRecord.modes_changeset(%{modes: serialize_modes(snapshot)})
|> Repo.update()
:ok
end
rescue
_ -> :ok
end
@doc """
Build a deserialized snapshot (atom keys, MapSet flags) from a record
loaded out of the database. Returns `nil` when the record is missing.
"""
def load_modes(name) do
case get(name) do
nil -> nil
%ChannelRecord{modes: modes} -> deserialize_modes(modes || %{})
end
rescue
_ -> nil
end
## Serialization
defp serialize_modes(snapshot) do
%{
"key" => snapshot.key,
"limit" => snapshot.limit,
"flags" => snapshot.flags |> MapSet.to_list() |> Enum.map(&Atom.to_string/1),
"bans" => Enum.map(snapshot.ban_masks, &mask_to_map/1),
"excepts" => Enum.map(snapshot.except_masks, &mask_to_map/1),
"invites" => Enum.map(snapshot.invite_masks, &mask_to_map/1)
}
end
defp mask_to_map(%{mask: m, set_by: by, set_at: at}),
do: %{"mask" => m, "set_by" => by, "set_at" => at}
defp deserialize_modes(modes) do
flags =
modes
|> Map.get("flags", [])
|> Enum.map(&String.to_existing_atom/1)
|> MapSet.new()
%{
key: Map.get(modes, "key"),
limit: Map.get(modes, "limit"),
flags: flags,
ban_masks: Enum.map(Map.get(modes, "bans", []), &map_to_mask/1),
except_masks: Enum.map(Map.get(modes, "excepts", []), &map_to_mask/1),
invite_masks: Enum.map(Map.get(modes, "invites", []), &map_to_mask/1)
}
rescue
# If any atom strings don't exist (probably a schema drift), bail out
# with a permissive default instead of crashing Channel.init.
_ ->
%{
key: nil,
limit: nil,
flags: MapSet.new(),
ban_masks: [],
except_masks: [],
invite_masks: []
}
end
defp map_to_mask(%{"mask" => m, "set_by" => by, "set_at" => at}),
do: %{mask: m, set_by: by, set_at: at}
## Per-user persistent access (members table)
@doc """
Upsert a user's access level on a registered channel. `:member` clears
the row. No-op when the channel isn't registered or `user_id` is nil.
"""
def set_member_access(_name, nil, _access), do: :ok
def set_member_access(name, user_id, :member),
do: clear_member_access(name, user_id)
def set_member_access(name, user_id, access) when is_atom(access) do
case get(name) do
nil ->
:ok
%ChannelRecord{id: chan_id} ->
existing = Repo.get_by(ChannelMembership, channel_id: chan_id, user_id: user_id)
attrs = %{channel_id: chan_id, user_id: user_id, access: Atom.to_string(access)}
case existing do
nil ->
%ChannelMembership{}
|> ChannelMembership.changeset(attrs)
|> Repo.insert()
rec ->
rec
|> ChannelMembership.changeset(attrs)
|> Repo.update()
end
:ok
end
rescue
_ -> :ok
end
@doc "Drop a user's persisted access. No-op if not registered."
def clear_member_access(name, user_id) when is_integer(user_id) do
case get(name) do
nil ->
:ok
%ChannelRecord{id: chan_id} ->
Repo.delete_all(
from m in ChannelMembership,
where: m.channel_id == ^chan_id and m.user_id == ^user_id
)
:ok
end
rescue
_ -> :ok
end
def clear_member_access(_name, _), do: :ok
@doc """
Returns persisted access for every authenticated member of the channel
as `%{user_id => access_atom}`. Empty map when unregistered.
"""
def load_member_access(name) do
case get(name) do
nil ->
%{}
%ChannelRecord{id: chan_id} ->
Repo.all(from m in ChannelMembership, where: m.channel_id == ^chan_id)
|> Enum.into(%{}, fn m -> {m.user_id, String.to_existing_atom(m.access)} end)
end
rescue
_ -> %{}
end
## Closing unregistered channels (operator console)
@doc """
Close a live, unregistered channel that has nobody in it: stop its
cluster singleton and delete what it left in the database.
Refuses a ChanServ-registered channel (`{:error, :registered}`) and an
occupied one (`{:error, :in_use}`) — stopping a channel with members
would silently drop them, since their connections still think they're
joined. Membership is checked inside the channel process at close time
(see `Channel.close_if_empty/1`), so a JOIN racing the close is safe.
`{:error, :not_running}` if there's no live channel by that name.
"""
@spec close_unregistered(String.t()) ::
{:ok, :closed} | {:error, :registered | :in_use | :not_running}
def close_unregistered(name) when is_binary(name) do
name = String.downcase(name)
if get(name) do
{:error, :registered}
else
case OhMyExOhIrcd.IRC.Channel.close_if_empty(name) do
:ok -> {:ok, :closed}
{:error, _} = err -> err
end
end
end
@doc """
Close every live, unregistered channel with no members. Returns
`%{closed: n, in_use: n}` — `in_use` counts channels left alone because
they have (or just gained) members.
"""
@spec close_empty_unregistered() :: %{closed: non_neg_integer(), in_use: non_neg_integer()}
def close_empty_unregistered do
live = OhMyExOhIrcd.IRC.Stats.channel_names() |> Enum.map(&String.downcase/1) |> Enum.uniq()
registered =
Repo.all(from c in ChannelRecord, where: c.name in ^live, select: c.name) |> MapSet.new()
live
|> Enum.reject(&MapSet.member?(registered, &1))
|> Enum.reduce(%{closed: 0, in_use: 0}, fn name, acc ->
case close_unregistered(name) do
{:ok, :closed} -> Map.update!(acc, :closed, &(&1 + 1))
{:error, :in_use} -> Map.update!(acc, :in_use, &(&1 + 1))
{:error, _} -> acc
end
end)
end
@doc """
Delete the persisted rows an unregistered channel leaves behind — only
its `channel_topics` row: modes and per-user access are persisted only
for registered channels (keyed by `channels.id`). Does nothing for a
registered channel. Returns the number of rows deleted.
"""
@spec delete_unregistered_leftovers(String.t()) :: non_neg_integer()
def delete_unregistered_leftovers(name) when is_binary(name) do
name = String.downcase(name)
if get(name) do
0
else
{n, _} = Repo.delete_all(from t in ChannelTopic, where: t.name == ^name)
n
end
rescue
e ->
Logger.warning("Could not delete leftovers of #{name}: #{inspect(e)}")
0
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.3 KiB · text
History
4b6fe3b