Admin: close empty unregistered channels

d434ccb · Gabriel Morell · 2026-10-27 17:37

14 files +360 -12
Message
{commit_body(@commit)}

Files changed

modified lib/oh_my_ex_oh_ircd/channels.ex
+74 −0
@@ -6,6 +6,7 @@ defmodule OhMyExOhIrcd.Channels do
6 6 """
7 7
8 8 import Ecto.Query, warn: false
9 + require Logger
9 10
10 11 alias OhMyExOhIrcd.Repo
11 12 alias OhMyExOhIrcd.Channels.{ChannelMembership, ChannelRecord, ChannelTopic}
@@ -306,4 +307,77 @@ defmodule OhMyExOhIrcd.Channels do
306 307 rescue
307 308 _ -> %{}
308 309 end
310 +
311 + ## Closing unregistered channels (operator console)
312 +
313 + @doc """
314 + Close a live, unregistered channel that has nobody in it: stop its
315 + cluster singleton and delete what it left in the database.
316 +
317 + Refuses a ChanServ-registered channel (`{:error, :registered}`) and an
318 + occupied one (`{:error, :in_use}`) — stopping a channel with members
319 + would silently drop them, since their connections still think they're
320 + joined. Membership is checked inside the channel process at close time
321 + (see `Channel.close_if_empty/1`), so a JOIN racing the close is safe.
322 + `{:error, :not_running}` if there's no live channel by that name.
323 + """
324 + @spec close_unregistered(String.t()) ::
325 + {:ok, :closed} | {:error, :registered | :in_use | :not_running}
326 + def close_unregistered(name) when is_binary(name) do
327 + name = String.downcase(name)
328 +
329 + if get(name) do
330 + {:error, :registered}
331 + else
332 + case OhMyExOhIrcd.IRC.Channel.close_if_empty(name) do
333 + :ok -> {:ok, :closed}
334 + {:error, _} = err -> err
335 + end
336 + end
337 + end
338 +
339 + @doc """
340 + Close every live, unregistered channel with no members. Returns
341 + `%{closed: n, in_use: n}` — `in_use` counts channels left alone because
342 + they have (or just gained) members.
343 + """
344 + @spec close_empty_unregistered() :: %{closed: non_neg_integer(), in_use: non_neg_integer()}
345 + def close_empty_unregistered do
346 + live = OhMyExOhIrcd.IRC.Stats.channel_names() |> Enum.map(&String.downcase/1) |> Enum.uniq()
347 +
348 + registered =
349 + Repo.all(from c in ChannelRecord, where: c.name in ^live, select: c.name) |> MapSet.new()
350 +
351 + live
352 + |> Enum.reject(&MapSet.member?(registered, &1))
353 + |> Enum.reduce(%{closed: 0, in_use: 0}, fn name, acc ->
354 + case close_unregistered(name) do
355 + {:ok, :closed} -> Map.update!(acc, :closed, &(&1 + 1))
356 + {:error, :in_use} -> Map.update!(acc, :in_use, &(&1 + 1))
357 + {:error, _} -> acc
358 + end
359 + end)
360 + end
361 +
362 + @doc """
363 + Delete the persisted rows an unregistered channel leaves behind — only
364 + its `channel_topics` row: modes and per-user access are persisted only
365 + for registered channels (keyed by `channels.id`). Does nothing for a
366 + registered channel. Returns the number of rows deleted.
367 + """
368 + @spec delete_unregistered_leftovers(String.t()) :: non_neg_integer()
369 + def delete_unregistered_leftovers(name) when is_binary(name) do
370 + name = String.downcase(name)
371 +
372 + if get(name) do
373 + 0
374 + else
375 + {n, _} = Repo.delete_all(from t in ChannelTopic, where: t.name == ^name)
376 + n
377 + end
378 + rescue
379 + e ->
380 + Logger.warning("Could not delete leftovers of #{name}: #{inspect(e)}")
381 + 0
382 + end
309 383 end
modified lib/oh_my_ex_oh_ircd/irc/channel.ex
+68 −0
@@ -191,6 +191,48 @@ defmodule OhMyExOhIrcd.IRC.Channel do
191 191
192 192 def topic_topic(name), do: "channel:" <> normalize(name)
193 193
194 + @doc """
195 + `%{topic:, member_count:, flags:, key:, limit:}` for a channel that is
196 + running, or `nil` when it isn't. Unlike the other accessors this never
197 + starts the channel — the admin console lists live channels, and
198 + looking one up must not resurrect a channel that was just closed.
199 + """
200 + @spec live_summary(String.t()) :: map() | nil
201 + def live_summary(name) do
202 + case HordeRegistry.whereis({:channel, normalize(name)}) do
203 + pid when is_pid(pid) -> GenServer.call(pid, :live_summary, 2_000)
204 + nil -> nil
205 + end
206 + catch
207 + :exit, _ -> nil
208 + end
209 +
210 + @doc """
211 + Stop the channel's singleton if, and only if, it has no members and is
212 + not registered — for the operator console's "close" control. The check
213 + and the stop happen inside the channel process, so a JOIN can't slip in
214 + between them: joins are serialized through the same mailbox, and one
215 + queued behind this call finds the process gone and starts a fresh
216 + channel (see `call/3`). Leftover persisted state for the channel (its
217 + `channel_topics` row) is deleted before the process exits.
218 +
219 + Stopping with `:normal` makes Horde drop the child (it's `:transient`)
220 + exactly as `Horde.DynamicSupervisor.terminate_child/2` would.
221 +
222 + Never starts a channel: returns `{:error, :not_running}` when there is
223 + nothing to close. Otherwise `:ok`, `{:error, :in_use}` or
224 + `{:error, :registered}`.
225 + """
226 + @spec close_if_empty(String.t()) :: :ok | {:error, :in_use | :registered | :not_running}
227 + def close_if_empty(name) do
228 + case HordeRegistry.whereis({:channel, normalize(name)}) do
229 + pid when is_pid(pid) -> GenServer.call(pid, :close_if_empty)
230 + nil -> {:error, :not_running}
231 + end
232 + catch
233 + :exit, _ -> {:error, :not_running}
234 + end
235 +
194 236 # Calls the pid ensure_started/1 found or started, not the registry name
195 237 # again: Horde's registry is synced between nodes as a CRDT, so right
196 238 # after a channel starts (or moves) on another node the local copy may
@@ -459,6 +501,18 @@ defmodule OhMyExOhIrcd.IRC.Channel do
459 501 def handle_call(:mode_snapshot, _from, state),
460 502 do: {:reply, %{flags: state.flags, key: state.key, limit: state.limit}, state}
461 503
504 + def handle_call(:live_summary, _from, state) do
505 + summary = %{
506 + topic: state.topic,
507 + member_count: map_size(state.members),
508 + flags: state.flags,
509 + key: state.key,
510 + limit: state.limit
511 + }
512 +
513 + {:reply, summary, state}
514 + end
515 +
462 516 def handle_call({:record_invite, nick}, _from, state) do
463 517 {:reply, :ok, %{state | invited: MapSet.put(state.invited, downcase(nick))}}
464 518 end
@@ -470,6 +524,20 @@ defmodule OhMyExOhIrcd.IRC.Channel do
470 524 {:reply, :ok, state}
471 525 end
472 526
527 + def handle_call(:close_if_empty, _from, state) do
528 + cond do
529 + map_size(state.members) > 0 ->
530 + {:reply, {:error, :in_use}, state}
531 +
532 + state.founder_user_id != nil ->
533 + {:reply, {:error, :registered}, state}
534 +
535 + true ->
536 + OhMyExOhIrcd.Channels.delete_unregistered_leftovers(state.name)
537 + {:stop, :normal, :ok, state}
538 + end
539 + end
540 +
473 541 def handle_call(:members, _from, state) do
474 542 list =
475 543 state.members
modified lib/oh_my_ex_oh_ircd_web/live/admin_live.ex
+84 −12
@@ -173,6 +173,31 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
173 173 end
174 174 end
175 175
176 + # Close one empty, unregistered channel. The context re-checks
177 + # membership inside the channel process, so a late JOIN wins.
178 + def handle_event("close_channel", %{"name" => name}, socket) do
179 + msg =
180 + case Channels.close_unregistered(name) do
181 + {:ok, :closed} -> {:info, "Closed #{name}."}
182 + {:error, :in_use} -> {:error, "#{name} has members now; left it open."}
183 + {:error, :registered} -> {:error, "#{name} is registered; not closing it."}
184 + {:error, :not_running} -> {:info, "#{name} was already closed."}
185 + end
186 +
187 + {kind, text} = msg
188 + {:noreply, socket |> put_flash(kind, text) |> load_channels()}
189 + end
190 +
191 + def handle_event("clear_empty_unregistered", _params, socket) do
192 + %{closed: closed, in_use: in_use} = Channels.close_empty_unregistered()
193 +
194 + text =
195 + "Closed #{closed} empty #{if closed == 1, do: "channel", else: "channels"}." <>
196 + if(in_use > 0, do: " #{in_use} in use, left open.", else: "")
197 +
198 + {:noreply, socket |> put_flash(:info, text) |> load_channels()}
199 + end
200 +
176 201 defp invite_form, do: to_form(%{"note" => "", "expires_in_days" => ""}, as: :invite)
177 202
178 203 defp vhost_email_form, do: to_form(%{"email" => "", "host" => ""}, as: :vhost)
@@ -223,6 +248,16 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
223 248 assign(socket, :users, users)
224 249 end
225 250
251 + defp empty_count(channels), do: Enum.count(channels, &(&1.member_count == 0))
252 +
253 + # A channel name as a DOM-id fragment: "#Foo.Bar" -> "foo-bar".
254 + defp dom_slug(name) do
255 + name
256 + |> String.downcase()
257 + |> String.replace(~r/[^a-z0-9_-]+/u, "-")
258 + |> String.trim("-")
259 + end
260 +
226 261 defp kind_count(users, kind), do: Enum.count(users, &(&1.kind == kind))
227 262
228 263 # Pull live state (members, modes) from each channel's singleton if it's
@@ -246,18 +281,22 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
246 281 end
247 282
248 283 # Live snapshot for a channel that has no DB record — everything comes
249 − # from the running singleton.
284 + # from the running singleton. `nil` (dropped) when it's no longer
285 + # running: the registry can briefly list a channel that was just closed,
286 + # and looking it up must not start it again.
250 287 defp enrich_live(name) do
251 − snap = Channel.mode_snapshot(name)
252 −
253 − %{
254 − name: name,
255 − topic: Channel.topic(name) || "",
256 − member_count: length(Channel.members(name)),
257 − mode_string: render_mode_string(snap.flags, snap.key, snap.limit)
258 − }
259 − catch
260 − _, _ -> nil
288 + case Channel.live_summary(name) do
289 + nil ->
290 + nil
291 +
292 + snap ->
293 + %{
294 + name: name,
295 + topic: snap.topic || "",
296 + member_count: snap.member_count,
297 + mode_string: render_mode_string(snap.flags, snap.key, snap.limit)
298 + }
299 + end
261 300 end
262 301
263 302 defp render_mode_string(flags, key, limit) do
@@ -463,6 +502,22 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
463 502 No unregistered channels are live right now.
464 503 </div>
465 504 <% else %>
505 + <div class="flex items-center gap-3 flex-wrap">
506 + <button
507 + type="button"
508 + id="clear-empty-unregistered"
509 + phx-click="clear_empty_unregistered"
510 + data-confirm="Close every unregistered channel with no members? Their topics are deleted too."
511 + disabled={empty_count(@unregistered_channels) == 0}
512 + class="btn btn-sm btn-outline btn-error"
513 + >
514 + Clear empty channels
515 + </button>
516 + <span class="text-xs text-base-content/60 tabular-nums">
517 + {empty_count(@unregistered_channels)} of {length(@unregistered_channels)} empty.
518 + Channels with members are left alone.
519 + </span>
520 + </div>
466 521 <div class="overflow-x-auto rounded-box border border-base-300">
467 522 <table class="table table-zebra">
468 523 <thead>
@@ -471,14 +526,31 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
471 526 <th>Members</th>
472 527 <th>Modes</th>
473 528 <th>Topic</th>
529 + <th></th>
474 530 </tr>
475 531 </thead>
476 532 <tbody>
477 − <tr :for={c <- @unregistered_channels}>
533 + <tr :for={c <- @unregistered_channels} id={"unregistered-#{dom_slug(c.name)}"}>
478 534 <td class="font-mono">{c.name}</td>
479 535 <td class="tabular-nums">{c.member_count}</td>
480 536 <td class="font-mono text-xs">{c.mode_string}</td>
481 537 <td class="max-w-md truncate">{c.topic}</td>
538 + <td class="whitespace-nowrap text-right">
539 + <%= if c.member_count == 0 do %>
540 + <button
541 + type="button"
542 + id={"close-channel-#{dom_slug(c.name)}"}
543 + phx-click="close_channel"
544 + phx-value-name={c.name}
545 + data-confirm={"Close #{c.name}? Its topic is deleted too."}
546 + class="btn btn-ghost btn-xs text-error"
547 + >
548 + Close
549 + </button>
550 + <% else %>
551 + <span class="text-xs text-base-content/50">in use</span>
552 + <% end %>
553 + </td>
482 554 </tr>
483 555 </tbody>
484 556 </table>
added test/oh_my_ex_oh_ircd/channels_close_test.exs
+91 −0
@@ -0,0 +1,91 @@
1 +defmodule OhMyExOhIrcd.ChannelsCloseTest do
2 + @moduledoc """
3 + `Channels.close_unregistered/1` and `close_empty_unregistered/0` stop
4 + live Channel singletons (which hit the DB in `init/1`), so this needs
5 + the shared sandbox: `async: false`.
6 + """
7 +
8 + use OhMyExOhIrcd.DataCase, async: false
9 +
10 + import OhMyExOhIrcd.AccountsFixtures
11 +
12 + alias OhMyExOhIrcd.Channels
13 + alias OhMyExOhIrcd.Cluster.HordeRegistry
14 + alias OhMyExOhIrcd.IRC.Channel
15 +
16 + defp unique_name(tag), do: "#close#{tag}#{System.unique_integer([:positive])}"
17 +
18 + defp start_channel(name) do
19 + {:ok, pid} = Channel.ensure_started(name)
20 + pid
21 + end
22 +
23 + defp assert_stopped(pid) do
24 + ref = Process.monitor(pid)
25 + assert_receive {:DOWN, ^ref, :process, ^pid, _}
26 + end
27 +
28 + describe "close_unregistered/1" do
29 + test "stops an empty unregistered channel and deletes its topic row" do
30 + name = unique_name("empty")
31 + pid = start_channel(name)
32 + :ok = Channels.put_topic(name, "someone", "old topic", 1)
33 + assert Channels.get_topic(name)
34 +
35 + assert {:ok, :closed} = Channels.close_unregistered(name)
36 + assert_stopped(pid)
37 + refute Channels.get_topic(name)
38 + end
39 +
40 + test "refuses a channel with members" do
41 + name = unique_name("busy")
42 + pid = start_channel(name)
43 + :ok = Channel.join(name, "closer#{System.unique_integer([:positive])}", self())
44 + :ok = Channels.put_topic(name, "someone", "keep me", 1)
45 +
46 + assert {:error, :in_use} = Channels.close_unregistered(name)
47 + assert HordeRegistry.whereis({:channel, name}) == pid
48 + assert Channels.get_topic(name).topic == "keep me"
49 + end
50 +
51 + test "refuses a registered channel and leaves its data alone" do
52 + name = unique_name("reg")
53 + {:ok, _} = Channels.register(name, user_fixture())
54 + pid = start_channel(name)
55 + :ok = Channels.put_topic(name, "someone", "registered topic", 1)
56 +
57 + assert {:error, :registered} = Channels.close_unregistered(name)
58 + assert HordeRegistry.whereis({:channel, name}) == pid
59 + assert Channels.get(name)
60 + assert Channels.get_topic(name)
61 + end
62 +
63 + test "is :not_running for a channel that isn't live, and doesn't start it" do
64 + name = unique_name("gone")
65 + assert {:error, :not_running} = Channels.close_unregistered(name)
66 + assert HordeRegistry.whereis({:channel, name}) == nil
67 + end
68 + end
69 +
70 + describe "close_empty_unregistered/0" do
71 + test "closes the empty ones and counts the occupied ones" do
72 + empty = unique_name("bulkempty")
73 + busy = unique_name("bulkbusy")
74 + reg = unique_name("bulkreg")
75 +
76 + empty_pid = start_channel(empty)
77 + busy_pid = start_channel(busy)
78 + :ok = Channel.join(busy, "bulk#{System.unique_integer([:positive])}", self())
79 + {:ok, _} = Channels.register(reg, user_fixture())
80 + reg_pid = start_channel(reg)
81 +
82 + %{closed: closed, in_use: in_use} = Channels.close_empty_unregistered()
83 + assert closed >= 1
84 + assert in_use >= 1
85 +
86 + assert_stopped(empty_pid)
87 + assert HordeRegistry.whereis({:channel, busy}) == busy_pid
88 + assert HordeRegistry.whereis({:channel, reg}) == reg_pid
89 + end
90 + end
91 +end
modified test/oh_my_ex_oh_ircd_web/live/admin_live_channels_test.exs
+43 −0
@@ -45,4 +45,47 @@ defmodule OhMyExOhIrcdWeb.AdminLiveChannelsTest do
45 45 unregistered = lv |> element("#unregistered-channels") |> render()
46 46 refute unregistered =~ name
47 47 end
48 +
49 + describe "closing unregistered channels" do
50 + defp slug(name), do: name |> String.downcase() |> String.trim_leading("#")
51 +
52 + test "an empty channel gets a Close button that stops it", %{conn: conn} do
53 + name = "#closeme#{System.unique_integer([:positive])}"
54 + {:ok, pid} = Channel.ensure_started(name)
55 + ref = Process.monitor(pid)
56 +
57 + {:ok, lv, _html} = conn |> oper_conn() |> live(~p"/admin")
58 +
59 + assert has_element?(lv, "#clear-empty-unregistered")
60 + assert has_element?(lv, "#close-channel-#{slug(name)}")
61 +
62 + lv |> element("#close-channel-#{slug(name)}") |> render_click()
63 +
64 + assert_receive {:DOWN, ^ref, :process, ^pid, _}
65 + assert render(lv) =~ "Closed #{name}."
66 + refute has_element?(lv, "#unregistered-#{slug(name)}")
67 + end
68 +
69 + test "an occupied channel has no Close button, and the bulk clear skips it", %{conn: conn} do
70 + busy = "#busy#{System.unique_integer([:positive])}"
71 + empty = "#idle#{System.unique_integer([:positive])}"
72 + {:ok, busy_pid} = Channel.ensure_started(busy)
73 + :ok = Channel.join(busy, "sitter#{System.unique_integer([:positive])}", self())
74 + {:ok, empty_pid} = Channel.ensure_started(empty)
75 + ref = Process.monitor(empty_pid)
76 +
77 + {:ok, lv, _html} = conn |> oper_conn() |> live(~p"/admin")
78 +
79 + assert has_element?(lv, "#unregistered-#{slug(busy)}")
80 + refute has_element?(lv, "#close-channel-#{slug(busy)}")
81 +
82 + html = lv |> element("#clear-empty-unregistered") |> render_click()
83 +
84 + assert_receive {:DOWN, ^ref, :process, ^empty_pid, _}
85 + assert html =~ ~r/Closed \d+ empty channels?\./
86 + assert OhMyExOhIrcd.Cluster.HordeRegistry.whereis({:channel, busy}) == busy_pid
87 + assert has_element?(lv, "#unregistered-#{slug(busy)}")
88 + refute has_element?(lv, "#unregistered-#{slug(empty)}")
89 + end
90 + end
48 91 end

Parents: 5e17a59