neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Add oper URL shortener with clickthrough metrics
d510a92 · gmorell · 2026-06-27 19:39
Message
{commit_body(@commit)}
Files changed
added
lib/oh_my_ex_oh_ircd/links.ex
+129
−0
| 1 | +defmodule OhMyExOhIrcd.Links do | |
| 2 | + @moduledoc """ | |
| 3 | + Oper-managed URL shortener. | |
| 4 | + | |
| 5 | + Short links (`/s/<slug>` → `url`) are created/listed/deleted from the | |
| 6 | + admin console. Each hit is counted on the link (denormalized | |
| 7 | + `click_count`/`last_clicked_at`) and logged as a `Click` row for | |
| 8 | + referrer / user-agent / over-time metrics. | |
| 9 | + """ | |
| 10 | + | |
| 11 | + import Ecto.Query | |
| 12 | + | |
| 13 | + alias OhMyExOhIrcd.Repo | |
| 14 | + alias OhMyExOhIrcd.Links.{Click, ShortLink} | |
| 15 | + | |
| 16 | + ## CRUD | |
| 17 | + | |
| 18 | + @doc "All short links, newest first, with the creating account preloaded." | |
| 19 | + def list_links do | |
| 20 | + Repo.all(from l in ShortLink, order_by: [desc: l.inserted_at], preload: [:created_by]) | |
| 21 | + end | |
| 22 | + | |
| 23 | + def get_link!(id), do: Repo.get!(ShortLink, id) | |
| 24 | + | |
| 25 | + @doc "Look up a link by slug (case-insensitive). Returns the struct or nil." | |
| 26 | + def get_by_slug(slug) when is_binary(slug) do | |
| 27 | + Repo.get_by(ShortLink, slug: String.downcase(String.trim(slug))) | |
| 28 | + end | |
| 29 | + | |
| 30 | + @doc "Create a short link. `attrs` should include slug, url, and optionally title/created_by_user_id." | |
| 31 | + def create_link(attrs) do | |
| 32 | + %ShortLink{} | |
| 33 | + |> ShortLink.changeset(attrs) | |
| 34 | + |> Repo.insert() | |
| 35 | + end | |
| 36 | + | |
| 37 | + def change_link(%ShortLink{} = link, attrs \\ %{}), do: ShortLink.changeset(link, attrs) | |
| 38 | + | |
| 39 | + def delete_link(id) do | |
| 40 | + Repo.get(ShortLink, id) | |
| 41 | + |> case do | |
| 42 | + nil -> {:error, :not_found} | |
| 43 | + %ShortLink{} = link -> Repo.delete(link) | |
| 44 | + end | |
| 45 | + end | |
| 46 | + | |
| 47 | + ## Clicks | |
| 48 | + | |
| 49 | + @doc """ | |
| 50 | + Record a clickthrough: log the event and bump the link's counters. Always | |
| 51 | + returns `:ok` — a metrics write must never break the redirect itself. | |
| 52 | + """ | |
| 53 | + def record_click(%ShortLink{} = link, meta) do | |
| 54 | + now = DateTime.utc_now(:second) | |
| 55 | + | |
| 56 | + %Click{} | |
| 57 | + |> Click.changeset(%{ | |
| 58 | + short_link_id: link.id, | |
| 59 | + referer: clip(meta[:referer]), | |
| 60 | + user_agent: clip(meta[:user_agent]) | |
| 61 | + }) | |
| 62 | + |> Repo.insert() | |
| 63 | + | |
| 64 | + from(l in ShortLink, where: l.id == ^link.id) | |
| 65 | + |> Repo.update_all(inc: [click_count: 1], set: [last_clicked_at: now]) | |
| 66 | + | |
| 67 | + :ok | |
| 68 | + rescue | |
| 69 | + _ -> :ok | |
| 70 | + end | |
| 71 | + | |
| 72 | + @doc """ | |
| 73 | + Aggregate metrics for one link: windowed counts, top referrers, top | |
| 74 | + user-agents, a daily series for the last 30 days, and recent clicks. | |
| 75 | + """ | |
| 76 | + def metrics(%ShortLink{} = link) do | |
| 77 | + now = DateTime.utc_now() | |
| 78 | + base = from(c in Click, where: c.short_link_id == ^link.id) | |
| 79 | + | |
| 80 | + %{ | |
| 81 | + total: link.click_count, | |
| 82 | + last_24h: count_since(base, DateTime.add(now, -1, :day)), | |
| 83 | + last_7d: count_since(base, DateTime.add(now, -7, :day)), | |
| 84 | + last_30d: count_since(base, DateTime.add(now, -30, :day)), | |
| 85 | + top_referrers: top(base, :referer, "(direct)"), | |
| 86 | + top_user_agents: top(base, :user_agent, "(unknown)"), | |
| 87 | + daily: daily_series(base, DateTime.add(now, -30, :day)), | |
| 88 | + recent: recent(base) | |
| 89 | + } | |
| 90 | + end | |
| 91 | + | |
| 92 | + defp count_since(base, since) do | |
| 93 | + Repo.aggregate(from(c in base, where: c.inserted_at >= ^since), :count, :id) | |
| 94 | + end | |
| 95 | + | |
| 96 | + defp top(base, field, blank_label) do | |
| 97 | + from(c in base, | |
| 98 | + group_by: field(c, ^field), | |
| 99 | + select: {field(c, ^field), count(c.id)}, | |
| 100 | + order_by: [desc: count(c.id)], | |
| 101 | + limit: 10 | |
| 102 | + ) | |
| 103 | + |> Repo.all() | |
| 104 | + |> Enum.map(fn {value, n} -> | |
| 105 | + %{value: if(value in [nil, ""], do: blank_label, else: value), count: n} | |
| 106 | + end) | |
| 107 | + end | |
| 108 | + | |
| 109 | + defp daily_series(base, since) do | |
| 110 | + from(c in base, | |
| 111 | + where: c.inserted_at >= ^since, | |
| 112 | + group_by: fragment("date_trunc('day', ?)", c.inserted_at), | |
| 113 | + select: {fragment("date_trunc('day', ?)", c.inserted_at), count(c.id)}, | |
| 114 | + order_by: [asc: fragment("date_trunc('day', ?)", c.inserted_at)] | |
| 115 | + ) | |
| 116 | + |> Repo.all() | |
| 117 | + |> Enum.map(fn {day, n} -> %{day: day, count: n} end) | |
| 118 | + end | |
| 119 | + | |
| 120 | + defp recent(base) do | |
| 121 | + from(c in base, order_by: [desc: c.inserted_at], limit: 25) | |
| 122 | + |> Repo.all() | |
| 123 | + end | |
| 124 | + | |
| 125 | + # Trim header values so a hostile/huge header can't bloat a row. | |
| 126 | + defp clip(nil), do: nil | |
| 127 | + defp clip(s) when is_binary(s), do: s |> String.slice(0, 1024) |> String.trim() | |
| 128 | + defp clip(_), do: nil | |
| 129 | +end |
added
lib/oh_my_ex_oh_ircd/links/click.ex
+27
−0
| 1 | +defmodule OhMyExOhIrcd.Links.Click do | |
| 2 | + @moduledoc """ | |
| 3 | + One clickthrough on a short link. Immutable event; carries only the | |
| 4 | + request's own Referer / User-Agent headers (no client IP). | |
| 5 | + """ | |
| 6 | + | |
| 7 | + use Ecto.Schema | |
| 8 | + import Ecto.Changeset | |
| 9 | + | |
| 10 | + alias OhMyExOhIrcd.Links.ShortLink | |
| 11 | + | |
| 12 | + @primary_key {:id, :id, autogenerate: true} | |
| 13 | + schema "short_link_clicks" do | |
| 14 | + field :referer, :string | |
| 15 | + field :user_agent, :string | |
| 16 | + | |
| 17 | + belongs_to :short_link, ShortLink | |
| 18 | + | |
| 19 | + timestamps(type: :utc_datetime, updated_at: false) | |
| 20 | + end | |
| 21 | + | |
| 22 | + def changeset(record, attrs) do | |
| 23 | + record | |
| 24 | + |> cast(attrs, [:short_link_id, :referer, :user_agent]) | |
| 25 | + |> validate_required([:short_link_id]) | |
| 26 | + end | |
| 27 | +end |
added
lib/oh_my_ex_oh_ircd/links/short_link.ex
+47
−0
| 1 | +defmodule OhMyExOhIrcd.Links.ShortLink do | |
| 2 | + @moduledoc """ | |
| 3 | + An oper-defined short link: `/s/<slug>` → `url`. | |
| 4 | + | |
| 5 | + `click_count` / `last_clicked_at` are denormalized counters maintained by | |
| 6 | + `OhMyExOhIrcd.Links.record_click/2`; detailed per-click metrics live in | |
| 7 | + `OhMyExOhIrcd.Links.Click`. | |
| 8 | + """ | |
| 9 | + | |
| 10 | + use Ecto.Schema | |
| 11 | + import Ecto.Changeset | |
| 12 | + | |
| 13 | + alias OhMyExOhIrcd.Accounts.User | |
| 14 | + | |
| 15 | + # Lowercase, URL-safe, must start alphanumeric. 1..64 chars. | |
| 16 | + @slug_format ~r/^[a-z0-9][a-z0-9_-]{0,63}$/ | |
| 17 | + | |
| 18 | + schema "short_links" do | |
| 19 | + field :slug, :string | |
| 20 | + field :url, :string | |
| 21 | + field :title, :string | |
| 22 | + field :click_count, :integer, default: 0 | |
| 23 | + field :last_clicked_at, :utc_datetime | |
| 24 | + | |
| 25 | + belongs_to :created_by, User, foreign_key: :created_by_user_id | |
| 26 | + | |
| 27 | + timestamps(type: :utc_datetime) | |
| 28 | + end | |
| 29 | + | |
| 30 | + def changeset(record, attrs) do | |
| 31 | + record | |
| 32 | + |> cast(attrs, [:slug, :url, :title, :created_by_user_id]) | |
| 33 | + |> update_change(:slug, &normalize_slug/1) | |
| 34 | + |> validate_required([:slug, :url]) | |
| 35 | + |> validate_format(:slug, @slug_format, | |
| 36 | + message: "must be lowercase letters, numbers, dashes or underscores" | |
| 37 | + ) | |
| 38 | + |> validate_format(:url, ~r{^https?://}i, message: "must start with http:// or https://") | |
| 39 | + |> validate_length(:url, max: 2048) | |
| 40 | + |> validate_length(:title, max: 200) | |
| 41 | + |> unique_constraint(:slug) | |
| 42 | + |> assoc_constraint(:created_by) | |
| 43 | + end | |
| 44 | + | |
| 45 | + defp normalize_slug(nil), do: nil | |
| 46 | + defp normalize_slug(slug) when is_binary(slug), do: slug |> String.trim() |> String.downcase() | |
| 47 | +end |
added
lib/oh_my_ex_oh_ircd_web/controllers/short_link_controller.ex
+39
−0
| 1 | +defmodule OhMyExOhIrcdWeb.ShortLinkController do | |
| 2 | + @moduledoc """ | |
| 3 | + Public short-link redirector. | |
| 4 | + | |
| 5 | + * `GET /s/:slug` — 302-redirect to the link's target URL, recording a | |
| 6 | + clickthrough (referrer + user-agent) for the admin metrics. Unknown | |
| 7 | + slugs return 404. | |
| 8 | + """ | |
| 9 | + | |
| 10 | + use OhMyExOhIrcdWeb, :controller | |
| 11 | + | |
| 12 | + alias OhMyExOhIrcd.Links | |
| 13 | + | |
| 14 | + def show(conn, %{"slug" => slug}) do | |
| 15 | + case Links.get_by_slug(slug) do | |
| 16 | + nil -> | |
| 17 | + conn | |
| 18 | + |> put_status(:not_found) | |
| 19 | + |> text("Not found") | |
| 20 | + | |
| 21 | + link -> | |
| 22 | + Links.record_click(link, %{ | |
| 23 | + referer: header(conn, "referer"), | |
| 24 | + user_agent: header(conn, "user-agent") | |
| 25 | + }) | |
| 26 | + | |
| 27 | + conn | |
| 28 | + |> put_resp_header("cache-control", "no-store") | |
| 29 | + |> redirect(external: link.url) | |
| 30 | + end | |
| 31 | + end | |
| 32 | + | |
| 33 | + defp header(conn, name) do | |
| 34 | + case get_req_header(conn, name) do | |
| 35 | + [value | _] -> value | |
| 36 | + _ -> nil | |
| 37 | + end | |
| 38 | + end | |
| 39 | +end |
modified
lib/oh_my_ex_oh_ircd_web/live/admin_live.ex
+123
−1
| 11 | 11 | |
| 12 | 12 | use OhMyExOhIrcdWeb, :live_view |
| 13 | 13 | |
| 14 | − alias OhMyExOhIrcd.{Accounts, Channels} | |
| 14 | + alias OhMyExOhIrcd.{Accounts, Channels, Links} | |
| 15 | 15 | alias OhMyExOhIrcd.IRC.{Channel, Stats, Vhost} |
| 16 | 16 | |
| 17 | 17 | @refresh_ms 5_000 |
| 25 | 25 | |> assign(:page_title, "Operator admin") |
| 26 | 26 | |> assign(:form, invite_form()) |
| 27 | 27 | |> assign(:vhost_form, vhost_email_form()) |
| 28 | + |> assign(:link_form, link_form()) | |
| 28 | 29 | |> load_invites() |
| 30 | + |> load_links() | |
| 29 | 31 | |> load_channels() |
| 30 | 32 | |> load_users()} |
| 31 | 33 | end |
| 136 | 138 | end |
| 137 | 139 | end |
| 138 | 140 | |
| 141 | + # Create a short link, attributed to the current oper. | |
| 142 | + def handle_event("create_link", %{"link" => params}, socket) do | |
| 143 | + attrs = %{ | |
| 144 | + "slug" => params["slug"], | |
| 145 | + "url" => params["url"], | |
| 146 | + "title" => params["title"], | |
| 147 | + "created_by_user_id" => socket.assigns.current_scope.user.id | |
| 148 | + } | |
| 149 | + | |
| 150 | + case Links.create_link(attrs) do | |
| 151 | + {:ok, _link} -> | |
| 152 | + {:noreply, | |
| 153 | + socket | |
| 154 | + |> put_flash(:info, "Short link created.") | |
| 155 | + |> assign(:link_form, link_form()) | |
| 156 | + |> load_links()} | |
| 157 | + | |
| 158 | + {:error, changeset} -> | |
| 159 | + {:noreply, | |
| 160 | + socket | |
| 161 | + |> put_flash(:error, "Could not create link: #{changeset_error(changeset)}") | |
| 162 | + |> assign(:link_form, to_form(changeset, as: :link))} | |
| 163 | + end | |
| 164 | + end | |
| 165 | + | |
| 166 | + def handle_event("delete_link", %{"id" => id}, socket) do | |
| 167 | + case Links.delete_link(id) do | |
| 168 | + {:ok, _} -> | |
| 169 | + {:noreply, socket |> put_flash(:info, "Short link deleted.") |> load_links()} | |
| 170 | + | |
| 171 | + _ -> | |
| 172 | + {:noreply, put_flash(socket, :error, "Could not delete link.")} | |
| 173 | + end | |
| 174 | + end | |
| 175 | + | |
| 139 | 176 | defp invite_form, do: to_form(%{"note" => "", "expires_in_days" => ""}, as: :invite) |
| 140 | 177 | |
| 141 | 178 | defp vhost_email_form, do: to_form(%{"email" => "", "host" => ""}, as: :vhost) |
| 142 | 179 | |
| 180 | + defp link_form, do: to_form(%{"slug" => "", "url" => "", "title" => ""}, as: :link) | |
| 181 | + | |
| 182 | + defp load_links(socket), do: assign(socket, :links, Links.list_links()) | |
| 183 | + | |
| 184 | + defp short_link_url(slug), do: url(~p"/s/#{slug}") | |
| 185 | + | |
| 186 | + defp changeset_error(%Ecto.Changeset{errors: [{field, {msg, _}} | _]}), do: "#{field} #{msg}" | |
| 187 | + defp changeset_error(_), do: "invalid" | |
| 188 | + | |
| 143 | 189 | defp parse_days(nil), do: :none |
| 144 | 190 | defp parse_days(""), do: :none |
| 145 | 191 |
| 297 | 343 | <% end %> |
| 298 | 344 | </section> |
| 299 | 345 | |
| 346 | + <section class="space-y-4"> | |
| 347 | + <h2 class="text-lg font-mono font-semibold">Short links</h2> | |
| 348 | + <p class="text-xs text-base-content/60"> | |
| 349 | + Define <code class="font-mono">/s/<slug></code> redirects with clickthrough metrics. | |
| 350 | + </p> | |
| 351 | + | |
| 352 | + <.form for={@link_form} phx-submit="create_link" class="flex flex-wrap items-end gap-3"> | |
| 353 | + <.input field={@link_form[:slug]} type="text" label="Slug" placeholder="scroppleapp" /> | |
| 354 | + <.input | |
| 355 | + field={@link_form[:url]} | |
| 356 | + type="text" | |
| 357 | + label="Target URL" | |
| 358 | + placeholder="https://github.com/.../app.apk" | |
| 359 | + /> | |
| 360 | + <.input field={@link_form[:title]} type="text" label="Title (optional)" /> | |
| 361 | + <button type="submit" class="btn btn-primary">Create link</button> | |
| 362 | + </.form> | |
| 363 | + | |
| 364 | + <%= if @links == [] do %> | |
| 365 | + <div class="rounded-box border border-base-300 bg-base-200 p-6 text-sm text-base-content/70"> | |
| 366 | + No short links yet. Create one above — it'll redirect from | |
| 367 | + <code class="font-mono">/s/<slug></code>. | |
| 368 | + </div> | |
| 369 | + <% else %> | |
| 370 | + <div class="overflow-x-auto rounded-box border border-base-300"> | |
| 371 | + <table class="table table-zebra"> | |
| 372 | + <thead> | |
| 373 | + <tr> | |
| 374 | + <th>Short URL</th> | |
| 375 | + <th>Target</th> | |
| 376 | + <th>Clicks</th> | |
| 377 | + <th>Last click</th> | |
| 378 | + <th>Created by</th> | |
| 379 | + <th></th> | |
| 380 | + </tr> | |
| 381 | + </thead> | |
| 382 | + <tbody> | |
| 383 | + <tr :for={l <- @links}> | |
| 384 | + <td class="font-mono text-xs"> | |
| 385 | + <input | |
| 386 | + type="text" | |
| 387 | + readonly | |
| 388 | + value={short_link_url(l.slug)} | |
| 389 | + class="input input-bordered input-xs w-full max-w-xs" | |
| 390 | + onclick="this.select()" | |
| 391 | + /> | |
| 392 | + </td> | |
| 393 | + <td class="max-w-xs truncate"> | |
| 394 | + <a href={l.url} target="_blank" rel="noopener" class="link link-primary text-xs"> | |
| 395 | + {l.title || l.url} | |
| 396 | + </a> | |
| 397 | + </td> | |
| 398 | + <td class="tabular-nums">{l.click_count}</td> | |
| 399 | + <td class="text-xs tabular-nums">{l.last_clicked_at || "—"}</td> | |
| 400 | + <td class="text-xs">{l.created_by && l.created_by.email}</td> | |
| 401 | + <td class="whitespace-nowrap"> | |
| 402 | + <.link navigate={~p"/admin/links/#{l.id}"} class="btn btn-ghost btn-xs"> | |
| 403 | + Metrics | |
| 404 | + </.link> | |
| 405 | + <button | |
| 406 | + type="button" | |
| 407 | + phx-click="delete_link" | |
| 408 | + phx-value-id={l.id} | |
| 409 | + data-confirm={"Delete /s/#{l.slug}?"} | |
| 410 | + class="btn btn-ghost btn-xs text-error" | |
| 411 | + > | |
| 412 | + Delete | |
| 413 | + </button> | |
| 414 | + </td> | |
| 415 | + </tr> | |
| 416 | + </tbody> | |
| 417 | + </table> | |
| 418 | + </div> | |
| 419 | + <% end %> | |
| 420 | + </section> | |
| 421 | + | |
| 300 | 422 | <section class="space-y-4"> |
| 301 | 423 | <h2 class="text-lg font-mono font-semibold">All registered channels</h2> |
| 302 | 424 |
added
lib/oh_my_ex_oh_ircd_web/live/link_metrics_live.ex
+161
−0
Click to load diff…
modified
lib/oh_my_ex_oh_ircd_web/router.ex
+7
−0
Click to load diff…
added
priv/repo/migrations/20260627020000_create_short_links.exs
+22
−0
Click to load diff…
added
priv/repo/migrations/20260627030000_create_short_link_clicks.exs
+19
−0
Click to load diff…
added
test/oh_my_ex_oh_ircd/links_test.exs
+79
−0
Click to load diff…
added
test/oh_my_ex_oh_ircd_web/controllers/short_link_controller_test.exs
+29
−0
Click to load diff…
Parents: 3a1226c