8.2 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.Scrobbles do
  @moduledoc """
  Context for ScrobbleServ persistence: per-channel config, per-channel
  API tokens, and the append-only scrobble history shown in the web UI.

  All channel lookups go through `OhMyExOhIrcd.Channels.get/1`, so only
  ChanServ-registered channels can be configured (the FK to `channels`
  enforces this at the DB layer, and `on_delete: :delete_all` means
  dropping a registration cascades away its config, tokens and history).
  """

  import Ecto.Query, warn: false

  alias OhMyExOhIrcd.Repo
  alias OhMyExOhIrcd.Accounts.User
  alias OhMyExOhIrcd.Channels
  alias OhMyExOhIrcd.Channels.ChannelRecord
  alias OhMyExOhIrcd.Scrobbles.{ChannelConfig, RequestLog, Scrobble, Token}

  # How many request-log rows to keep per channel.
  @log_retention 200

  ## Config

  @doc "Fetch the config for a channel by name. Returns nil if absent."
  def get_config(channel_name) when is_binary(channel_name) do
    case Channels.get(channel_name) do
      nil -> nil
      %ChannelRecord{id: id} -> Repo.get_by(ChannelConfig, channel_id: id)
    end
  end

  @doc """
  Fetch the config for a registered channel, creating it (enabled) if it
  doesn't exist yet. Returns `{:ok, config}` or `{:error, :not_registered}`.
  """
  def ensure_config(channel_name, attrs \\ %{}) when is_binary(channel_name) do
    case Channels.get(channel_name) do
      nil ->
        {:error, :not_registered}

      %ChannelRecord{id: id} ->
        case Repo.get_by(ChannelConfig, channel_id: id) do
          nil ->
            %ChannelConfig{}
            |> ChannelConfig.changeset(Map.merge(%{channel_id: id}, attrs))
            |> Repo.insert()

          %ChannelConfig{} = cfg ->
            {:ok, cfg}
        end
    end
  end

  @doc """
  Update config fields for a registered channel, creating the row first if
  needed. Returns `{:ok, config}`, `{:error, changeset}`, or
  `{:error, :not_registered}`.
  """
  def update_config(channel_name, attrs) when is_binary(channel_name) do
    with {:ok, cfg} <- ensure_config(channel_name) do
      cfg
      |> ChannelConfig.changeset(attrs)
      |> Repo.update()
    end
  end

  def set_enabled(channel_name, value), do: update_config(channel_name, %{enabled: !!value})
  def set_joined(channel_name, value), do: update_config(channel_name, %{joined: !!value})
  def set_format(channel_name, format), do: update_config(channel_name, %{format: format})

  def set_restrict(channel_name, value),
    do: update_config(channel_name, %{restrict_to_allowlist: !!value})

  def set_rate_limit(channel_name, seconds),
    do: update_config(channel_name, %{rate_limit_seconds: seconds})

  @doc "Add a `who` display name to the allowlist (stored downcased, deduped)."
  def add_allow(channel_name, who) when is_binary(who) do
    who = normalize_who(who)

    with {:ok, cfg} <- ensure_config(channel_name) do
      list = Enum.uniq([who | cfg.allowlist])
      update_config(channel_name, %{allowlist: list})
    end
  end

  @doc "Remove a `who` display name from the allowlist."
  def remove_allow(channel_name, who) when is_binary(who) do
    who = normalize_who(who)

    with {:ok, cfg} <- ensure_config(channel_name) do
      list = Enum.reject(cfg.allowlist, &(&1 == who))
      update_config(channel_name, %{allowlist: list})
    end
  end

  @doc "Channel names where ScrobbleServ should currently be present."
  def list_joined_channels do
    Repo.all(
      from c in ChannelConfig,
        where: c.joined == true,
        join: ch in ChannelRecord,
        on: ch.id == c.channel_id,
        select: ch.name
    )
  end

  ## Tokens

  @doc """
  Mint a token for a registered channel, bound to `user` and announcing as
  `who`. Returns `{:ok, display_token, token}` where `display_token` is
  shown to the user once.
  """
  def mint_token(channel_name, %User{} = user, who, label \\ nil)
      when is_binary(channel_name) and is_binary(who) do
    who = String.trim(who)

    case Channels.get(channel_name) do
      _ when who == "" ->
        {:error, :blank_who}

      nil ->
        {:error, :not_registered}

      %ChannelRecord{id: id} ->
        {display, struct} = Token.build(id, user.id, who, label)

        case struct |> Token.changeset(%{}) |> Repo.insert() do
          {:ok, token} -> {:ok, display, token}
          {:error, cs} -> {:error, cs}
        end
    end
  end

  @doc "List (non-revoked) tokens for a channel. Never exposes plaintext."
  def list_tokens(channel_name) when is_binary(channel_name) do
    case Channels.get(channel_name) do
      nil ->
        []

      %ChannelRecord{id: id} ->
        Repo.all(
          from t in Token,
            where: t.channel_id == ^id and is_nil(t.revoked_at),
            order_by: [desc: t.inserted_at]
        )
    end
  end

  @doc "Revoke a token by id, scoped to the channel. Idempotent."
  def revoke_token(channel_name, token_id) when is_binary(channel_name) do
    case Channels.get(channel_name) do
      nil ->
        :ok

      %ChannelRecord{id: id} ->
        from(t in Token,
          where: t.id == ^token_id and t.channel_id == ^id and is_nil(t.revoked_at)
        )
        |> Repo.update_all(set: [revoked_at: DateTime.utc_now(:second)])

        :ok
    end
  end

  @doc """
  Verify a presented bearer token. Returns `{:ok, %{channel:, who:,
  user_id:}}` for a live (non-revoked) token — `who` is the bound display
  name, NOT anything the client supplied — else `:error`. Bumps
  `last_used_at` as a side effect on success.
  """
  def verify_token(presented) when is_binary(presented) do
    with {:ok, hash} <- Token.hash_presented(presented),
         %Token{} = token <-
           Repo.one(from t in Token, where: t.token_hash == ^hash and is_nil(t.revoked_at)),
         %ChannelRecord{} = channel <- Repo.get(ChannelRecord, token.channel_id) do
      Repo.update_all(
        from(t in Token, where: t.id == ^token.id),
        set: [last_used_at: DateTime.utc_now(:second)]
      )

      {:ok, %{channel: channel, who: token.who, user_id: token.user_id}}
    else
      _ -> :error
    end
  end

  def verify_token(_), do: :error

  ## History

  @doc "Persist an announced scrobble. `attrs` has who/artist/track/album."
  def record_scrobble(channel_id, attrs) when is_integer(channel_id) do
    %Scrobble{}
    |> Scrobble.changeset(Map.put(attrs, :channel_id, channel_id))
    |> Repo.insert()
  end

  @doc "Most recent `n` scrobbles for a channel, newest first."
  def recent(channel_name, n \\ 25) when is_binary(channel_name) do
    case Channels.get(channel_name) do
      nil ->
        []

      %ChannelRecord{id: id} ->
        Repo.all(
          from s in Scrobble,
            where: s.channel_id == ^id,
            order_by: [desc: s.inserted_at, desc: s.id],
            limit: ^n
        )
    end
  end

  defp normalize_who(who), do: who |> String.trim() |> String.downcase()

  ## Request logs (debugging)

  @doc """
  Record the outcome of an authenticated scrobble request. `attrs` carries
  `who`, `status` (e.g. "ok", "ignored", "not_joined", "invalid_payload",
  "error") and an optional `detail`. Prunes to the most recent
  `#{@log_retention}` rows per channel. Best-effort: never raises.
  """
  def log_request(channel_id, attrs) when is_integer(channel_id) do
    %RequestLog{}
    |> RequestLog.changeset(Map.put(attrs, :channel_id, channel_id))
    |> Repo.insert()
    |> case do
      {:ok, _} = ok ->
        prune_logs(channel_id)
        ok

      other ->
        other
    end
  rescue
    _ -> :error
  end

  @doc "Most recent `n` request-log rows for a channel, newest first."
  def recent_logs(channel_name, n \\ 50) when is_binary(channel_name) do
    case Channels.get(channel_name) do
      nil ->
        []

      %ChannelRecord{id: id} ->
        Repo.all(
          from l in RequestLog,
            where: l.channel_id == ^id,
            order_by: [desc: l.inserted_at, desc: l.id],
            limit: ^n
        )
    end
  end

  defp prune_logs(channel_id) do
    keep_ids =
      from(l in RequestLog,
        where: l.channel_id == ^channel_id,
        order_by: [desc: l.inserted_at, desc: l.id],
        limit: @log_retention,
        select: l.id
      )

    Repo.delete_all(
      from l in RequestLog,
        where: l.channel_id == ^channel_id and l.id not in subquery(keep_ids)
    )
  end
end