3.8 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.Links do
  @moduledoc """
  Oper-managed URL shortener.

  Short links (`/s/<slug>` → `url`) are created/listed/deleted from the
  admin console. Each hit is counted on the link (denormalized
  `click_count`/`last_clicked_at`) and logged as a `Click` row for
  referrer / user-agent / over-time metrics.
  """

  import Ecto.Query

  alias OhMyExOhIrcd.Repo
  alias OhMyExOhIrcd.Links.{Click, ShortLink}

  ## CRUD

  @doc "All short links, newest first, with the creating account preloaded."
  def list_links do
    Repo.all(from l in ShortLink, order_by: [desc: l.inserted_at], preload: [:created_by])
  end

  def get_link!(id), do: Repo.get!(ShortLink, id)

  @doc "Look up a link by slug (case-insensitive). Returns the struct or nil."
  def get_by_slug(slug) when is_binary(slug) do
    Repo.get_by(ShortLink, slug: String.downcase(String.trim(slug)))
  end

  @doc "Create a short link. `attrs` should include slug, url, and optionally title/created_by_user_id."
  def create_link(attrs) do
    %ShortLink{}
    |> ShortLink.changeset(attrs)
    |> Repo.insert()
  end

  def change_link(%ShortLink{} = link, attrs \\ %{}), do: ShortLink.changeset(link, attrs)

  def delete_link(id) do
    Repo.get(ShortLink, id)
    |> case do
      nil -> {:error, :not_found}
      %ShortLink{} = link -> Repo.delete(link)
    end
  end

  ## Clicks

  @doc """
  Record a clickthrough: log the event and bump the link's counters. Always
  returns `:ok` — a metrics write must never break the redirect itself.
  """
  def record_click(%ShortLink{} = link, meta) do
    now = DateTime.utc_now(:second)

    %Click{}
    |> Click.changeset(%{
      short_link_id: link.id,
      referer: clip(meta[:referer]),
      user_agent: clip(meta[:user_agent])
    })
    |> Repo.insert()

    from(l in ShortLink, where: l.id == ^link.id)
    |> Repo.update_all(inc: [click_count: 1], set: [last_clicked_at: now])

    :ok
  rescue
    _ -> :ok
  end

  @doc """
  Aggregate metrics for one link: windowed counts, top referrers, top
  user-agents, a daily series for the last 30 days, and recent clicks.
  """
  def metrics(%ShortLink{} = link) do
    now = DateTime.utc_now()
    base = from(c in Click, where: c.short_link_id == ^link.id)

    %{
      total: link.click_count,
      last_24h: count_since(base, DateTime.add(now, -1, :day)),
      last_7d: count_since(base, DateTime.add(now, -7, :day)),
      last_30d: count_since(base, DateTime.add(now, -30, :day)),
      top_referrers: top(base, :referer, "(direct)"),
      top_user_agents: top(base, :user_agent, "(unknown)"),
      daily: daily_series(base, DateTime.add(now, -30, :day)),
      recent: recent(base)
    }
  end

  defp count_since(base, since) do
    Repo.aggregate(from(c in base, where: c.inserted_at >= ^since), :count, :id)
  end

  defp top(base, field, blank_label) do
    from(c in base,
      group_by: field(c, ^field),
      select: {field(c, ^field), count(c.id)},
      order_by: [desc: count(c.id)],
      limit: 10
    )
    |> Repo.all()
    |> Enum.map(fn {value, n} ->
      %{value: if(value in [nil, ""], do: blank_label, else: value), count: n}
    end)
  end

  defp daily_series(base, since) do
    from(c in base,
      where: c.inserted_at >= ^since,
      group_by: fragment("date_trunc('day', ?)", c.inserted_at),
      select: {fragment("date_trunc('day', ?)", c.inserted_at), count(c.id)},
      order_by: [asc: fragment("date_trunc('day', ?)", c.inserted_at)]
    )
    |> Repo.all()
    |> Enum.map(fn {day, n} -> %{day: day, count: n} end)
  end

  defp recent(base) do
    from(c in base, order_by: [desc: c.inserted_at], limit: 25)
    |> Repo.all()
  end

  # Trim header values so a hostile/huge header can't bloat a row.
  defp clip(nil), do: nil
  defp clip(s) when is_binary(s), do: s |> String.slice(0, 1024) |> String.trim()
  defp clip(_), do: nil
end