6.0 KiB · text History e1821ef
defmodule OhMyExOhIrcd.Sightings do
  @moduledoc """
  Persisted "seen on this network" history, for the operator console.

  `Connection` reports three events — registration complete, nick change
  and disconnect — through `record_async/1`, which hands the write to
  `Sightings.Writer` so the connection never waits on (or crashes from)
  the database. Each event upserts one row:

    * authenticated to an account: keyed by `user_id`, so an account is
      one row whatever nick it last used;
    * anonymous: keyed by the downcased nick, where `user_id` is NULL.

  `first_seen_at` is kept from the first sighting; `last_seen_at` only
  moves forward. Unregistered rows not seen for
  `unregistered_retention_days` (default 90) are deleted by `prune/0`,
  which `Sightings.Pruner` runs on every node — a plain DELETE, so
  concurrent runs are harmless.
  """

  import Ecto.Query, warn: false

  alias OhMyExOhIrcd.Repo
  alias OhMyExOhIrcd.Sightings.{Sighting, Writer}

  @default_retention_days 90
  @default_list_limit 500

  @type event :: %{
          required(:nick) => String.t(),
          optional(:user_id) => integer() | nil,
          optional(:host) => String.t() | nil,
          optional(:real_ip) => String.t() | nil,
          optional(:quit_reason) => String.t() | nil,
          optional(:at) => DateTime.t()
        }

  ## Config

  defp env, do: Application.get_env(:oh_my_ex_oh_ircd, :sightings, [])

  @doc "Whether connections report sightings (off in the test env)."
  def enabled?, do: Keyword.get(env(), :enabled, true)

  @doc "Whether `Sightings.Pruner` sweeps (off in the test env)."
  def prune_enabled?, do: Keyword.get(env(), :prune_enabled, true)

  def prune_interval_ms, do: Keyword.get(env(), :prune_interval_ms, 6 * 3_600_000)

  def unregistered_retention_days,
    do: Keyword.get(env(), :unregistered_retention_days, @default_retention_days)

  def list_limit, do: @default_list_limit

  ## Recording

  @doc """
  Queue a sighting for `Sightings.Writer`. Never raises and never blocks
  the caller; a no-op when disabled or for service nicks.
  """
  @spec record_async(event()) :: :ok
  def record_async(%{nick: nick} = event) when is_binary(nick) do
    if enabled?() and not service_nick?(nick) do
      Writer.record(Map.put_new(event, :at, now()))
    end

    :ok
  rescue
    _ -> :ok
  end

  def record_async(_), do: :ok

  @doc """
  Upsert a sighting now. Returns `{:ok, %Sighting{}}`, `:skipped` for a
  service nick, or `{:error, reason}`.
  """
  @spec record(event()) :: {:ok, Sighting.t()} | :skipped | {:error, term()}
  def record(%{nick: nick} = event) when is_binary(nick) do
    if service_nick?(nick) do
      :skipped
    else
      at = event |> Map.get(:at, now()) |> DateTime.truncate(:second)
      user_id = Map.get(event, :user_id)

      row = %Sighting{
        nick_key: String.downcase(nick),
        nick: nick,
        user_id: user_id,
        host: Map.get(event, :host),
        real_ip: Map.get(event, :real_ip),
        first_seen_at: at,
        last_seen_at: at,
        last_quit_reason: truncate(Map.get(event, :quit_reason)),
        inserted_at: at,
        updated_at: at
      }

      Repo.insert(row,
        on_conflict: upsert_query(),
        conflict_target: conflict_target(user_id),
        returning: true
      )
    end
  end

  # Keep first_seen_at; never move last_seen_at backwards (writes from two
  # nodes can land out of order); keep a known IP / quit reason when the
  # new event doesn't carry one.
  defp upsert_query do
    from s in Sighting,
      update: [
        set: [
          nick: fragment("EXCLUDED.nick"),
          nick_key: fragment("EXCLUDED.nick_key"),
          host: fragment("COALESCE(EXCLUDED.host, ?)", s.host),
          real_ip: fragment("COALESCE(EXCLUDED.real_ip, ?)", s.real_ip),
          last_seen_at: fragment("GREATEST(?, EXCLUDED.last_seen_at)", s.last_seen_at),
          last_quit_reason:
            fragment("COALESCE(EXCLUDED.last_quit_reason, ?)", s.last_quit_reason),
          updated_at: fragment("EXCLUDED.updated_at")
        ]
      ]
  end

  defp conflict_target(nil), do: {:unsafe_fragment, "(nick_key) WHERE user_id IS NULL"}
  defp conflict_target(_user_id), do: [:user_id]

  defp truncate(nil), do: nil
  defp truncate(reason) when is_binary(reason), do: String.slice(reason, 0, 255)

  @doc "ChanServ, ScrobbleServ, ImgServ — never recorded."
  def service_nick?(nick) when is_binary(nick) do
    String.downcase(nick) in [
      String.downcase(OhMyExOhIrcd.IRC.ChanServ.nick()),
      String.downcase(OhMyExOhIrcd.IRC.ScrobbleServ.nick()),
      String.downcase(OhMyExOhIrcd.IRC.ImgServ.nick())
    ]
  end

  ## Reading

  @doc """
  The most recently seen rows, newest first, with `:user` preloaded.
  `filter` is `:all`, `:registered` or `:unregistered`.
  """
  @spec list_recent(:all | :registered | :unregistered, pos_integer()) :: [Sighting.t()]
  def list_recent(filter \\ :all, limit \\ @default_list_limit) do
    Sighting
    |> filter_kind(filter)
    |> order_by([s], desc: s.last_seen_at, desc: s.id)
    |> limit(^limit)
    |> preload(:user)
    |> Repo.all()
  end

  @doc "How many rows match `filter` (to say when the list is capped)."
  @spec count(:all | :registered | :unregistered) :: non_neg_integer()
  def count(filter \\ :all), do: Sighting |> filter_kind(filter) |> Repo.aggregate(:count)

  defp filter_kind(query, :registered), do: where(query, [s], not is_nil(s.user_id))
  defp filter_kind(query, :unregistered), do: where(query, [s], is_nil(s.user_id))
  defp filter_kind(query, _all), do: query

  ## Expiry

  @doc """
  Delete unregistered (user_id NULL) rows last seen more than
  `unregistered_retention_days` ago. Account rows are kept. Returns the
  number deleted.
  """
  @spec prune(DateTime.t()) :: non_neg_integer()
  def prune(now \\ now()) do
    cutoff = DateTime.add(now, -unregistered_retention_days() * 86_400, :second)

    {n, _} =
      Repo.delete_all(from s in Sighting, where: is_nil(s.user_id) and s.last_seen_at < ^cutoff)

    n
  end

  defp now, do: DateTime.utc_now() |> DateTime.truncate(:second)
end