6.1 KiB · text History 442a476
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}
  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 """
  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 """
  Persist a topic change if the channel is registered. No-op otherwise.
  Called from `Channel.set_topic/3`.
  """
  def maybe_update_topic(name, topic) do
    case get(name) do
      nil ->
        :ok

      %ChannelRecord{} = rec ->
        rec
        |> ChannelRecord.topic_changeset(%{topic: topic})
        |> Repo.update()

        :ok
    end
  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