Add oper URL shortener with clickthrough metrics

d510a92 · gmorell · 2026-06-27 19:39

24 files +682 -1
Message
{commit_body(@commit)}

Files changed

added lib/oh_my_ex_oh_ircd/links.ex
+129 −0
@@ -0,0 +1,129 @@
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
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,39 @@
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,7 +11,7 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
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,7 +25,9 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
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,10 +138,54 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
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,6 +343,82 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
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/&lt;slug&gt;</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/&lt;slug&gt;</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