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
neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
3.8 KiB · text
History
4b6fe3b