11.3 KiB · text History 87dde30
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