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
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, order_by: [asc: c.name])
|> Enum.filter(fn rec ->
(rec.modes || %{}) |> Map.get("flags", []) |> Enum.member?("auto_join")
end)
|> Enum.map(& &1.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
end
neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
8.8 KiB · text
History
3a1226c