betterops

62c2d06 · gmorell · 2026-06-10 21:24

11 files +247 -0

Files changed

modified README.md
+16 −0
@@ -137,6 +137,22 @@ bin/oh_my_ex_oh_ircd eval 'OhMyExOhIrcd.Release.list_invites()'
137 137 go to the server's console, not yours. The signup URL is built from the
138 138 Endpoint's `:url` host, i.e. `PHX_HOST`.)
139 139
140 +**From IRC, as an operator:** if you'd rather not touch a shell at all,
141 +an IRC operator (see below) can mint and list invites in-band. The
142 +server replies with NOTICEs carrying the token and signup URL:
143 +
144 +```sh
145 +/quote MKINVITE # never-expiring, no note
146 +/quote MKINVITE 7 # expires in 7 days
147 +/quote MKINVITE 7 :for alice # 7-day expiry, with a note
148 +/quote LSINVITE # list active (unconsumed) invites
149 +```
150 +
151 +A note must follow a day count (the IRC grammar can't otherwise tell a
152 +bare note from a day count). Both commands require operator status and
153 +reply `481` otherwise. (`/quote` sends a raw command; some clients use
154 +`/raw` or accept `/mkinvite` directly.)
155 +
140 156 Registration mode itself is set from the `REGISTRATION_MODE` env var in
141 157 `runtime.exs` (`open` for free signup, anything else → `invite_only`).
142 158 With `:open`, the gate is off and invite tokens are unnecessary.
modified config/test.exs
+9 −0
@@ -8,6 +8,15 @@ config :bcrypt_elixir, :log_rounds, 1
8 8 # Tests that exercise the invite gate can `Application.put_env/3` per-test.
9 9 config :oh_my_ex_oh_ircd, :registration_mode, :open
10 10
11 +# A static O-line so oper-command tests can `OPER testoper testpass`
12 +# without provisioning a DB-flagged account. Hash is `testpass`.
13 +config :oh_my_ex_oh_ircd, :opers, %{
14 + "testoper" => %{
15 + password_hash: "$2b$12$A1stkT9cNf.e0cOnjc74MuZggbXRhhYj7/iEf.z6DcYv3GTNipIMK",
16 + host_mask: "*@*"
17 + }
18 +}
19 +
11 20 # Bind the app-level IRC listener to an ephemeral port during tests so
12 21 # `mix test` never fights with whatever's on 6667 (or a previous test run
13 22 # that leaked the socket). Real assertions all spin up their own listener
modified lib/oh_my_ex_oh_ircd/irc/connection.ex
+104 −0
@@ -1174,6 +1174,44 @@ defmodule OhMyExOhIrcd.IRC.Connection do
1174 1174 state
1175 1175 end
1176 1176
1177 + # Oper-only: mint a single-use registration invite token in-band, so
1178 + # staff don't need shell access to `mix invites.create` /
1179 + # `OhMyExOhIrcd.Release.create_invite`. Syntax:
1180 + #
1181 + # MKINVITE — never-expiring, no note
1182 + # MKINVITE <days> — expires in <days> days
1183 + # MKINVITE <days> :<note> — with a note
1184 + #
1185 + # A note requires a leading day count (the IRC grammar can't otherwise
1186 + # tell a bare note from a day count). Replies are server NOTICEs to the
1187 + # oper carrying the token + signup URL.
1188 + defp dispatch(%Message{command: "MKINVITE", params: params}, socket, state) do
1189 + if state.operator? do
1190 + create_invite_command(params, socket, state)
1191 + else
1192 + send_numeric(socket, state, "481", [
1193 + state.nick || "*",
1194 + "Permission Denied - You're not an IRC operator"
1195 + ])
1196 + end
1197 +
1198 + state
1199 + end
1200 +
1201 + # Oper-only: list active (unconsumed) invite tokens.
1202 + defp dispatch(%Message{command: "LSINVITE"}, socket, state) do
1203 + if state.operator? do
1204 + list_invites_command(socket, state)
1205 + else
1206 + send_numeric(socket, state, "481", [
1207 + state.nick || "*",
1208 + "Permission Denied - You're not an IRC operator"
1209 + ])
1210 + end
1211 +
1212 + state
1213 + end
1214 +
1177 1215 defp dispatch(%Message{command: cmd}, socket, state) do
1178 1216 send_numeric(socket, state, "421", [state.nick || "*", cmd, "Unknown command"])
1179 1217 state
@@ -1181,6 +1219,72 @@ defmodule OhMyExOhIrcd.IRC.Connection do
1181 1219
1182 1220 ## Helpers
1183 1221
1222 + defp create_invite_command(params, socket, state) do
1223 + case parse_invite_days(params) do
1224 + :error ->
1225 + send_server_notice(
1226 + socket,
1227 + state,
1228 + "Usage: MKINVITE [days] [:note] — days must be a positive integer"
1229 + )
1230 +
1231 + {:ok, days} ->
1232 + opts = [note: invite_note(params)]
1233 + opts = if days, do: Keyword.put(opts, :expires_in_seconds, days * 24 * 3600), else: opts
1234 + opts = if state.account, do: Keyword.put(opts, :created_by, state.account), else: opts
1235 +
1236 + case OhMyExOhIrcd.Accounts.create_invite(opts) do
1237 + {:ok, invite} ->
1238 + url = OhMyExOhIrcdWeb.Endpoint.url() <> "/users/register?invite=#{invite.token}"
1239 + send_server_notice(socket, state, "Invite token: #{invite.token}")
1240 + send_server_notice(socket, state, "Signup URL: #{url}")
1241 +
1242 + if invite.expires_at,
1243 + do: send_server_notice(socket, state, "Expires: #{invite.expires_at}")
1244 +
1245 + {:error, cs} ->
1246 + send_server_notice(socket, state, "Could not create invite: #{inspect(cs.errors)}")
1247 + end
1248 + end
1249 + end
1250 +
1251 + defp list_invites_command(socket, state) do
1252 + case OhMyExOhIrcd.Accounts.list_active_invites() do
1253 + [] ->
1254 + send_server_notice(socket, state, "No active invites.")
1255 +
1256 + invites ->
1257 + send_server_notice(socket, state, "Active invites (#{length(invites)}):")
1258 +
1259 + Enum.each(invites, fn i ->
1260 + note = if i.note in [nil, ""], do: "", else: " — #{i.note}"
1261 + send_server_notice(socket, state, " #{i.token} expires=#{i.expires_at || "never"}#{note}")
1262 + end)
1263 + end
1264 + end
1265 +
1266 + # `MKINVITE` argument parsing. No args → no expiry. A leading token, if
1267 + # present, must be a positive integer day count; anything else is a
1268 + # usage error.
1269 + defp parse_invite_days([]), do: {:ok, nil}
1270 +
1271 + defp parse_invite_days([days | _]) do
1272 + case Integer.parse(days) do
1273 + {n, ""} when n > 0 -> {:ok, n}
1274 + _ -> :error
1275 + end
1276 + end
1277 +
1278 + # The note is the trailing param after the day count, if any.
1279 + defp invite_note([_days, note | _]), do: note
1280 + defp invite_note(_), do: ""
1281 +
1282 + # A NOTICE from the server pseudo-host to the requesting oper.
1283 + defp send_server_notice(socket, state, text) do
1284 + server = state.server_name || Listener.server_name()
1285 + send_msg(socket, Message.build("NOTICE", [state.nick || "*", text], server))
1286 + end
1287 +
1184 1288 defp do_join(channel, key, socket, state) do
1185 1289 cond do
1186 1290 not state.registered? ->
added test/oh_my_ex_oh_ircd/irc/oper_invite_test.exs
+118 −0
@@ -0,0 +1,118 @@
1 +defmodule OhMyExOhIrcd.IRC.OperInviteTest do
2 + @moduledoc """
3 + Integration tests for the oper-only in-band invite commands
4 + (`MKINVITE` / `LSINVITE`). These hit the DB via the Accounts context
5 + from the Connection process, so they run `async: false` to use the
6 + Sandbox in shared mode.
7 +
8 + Oper status is granted via the static O-line configured in
9 + `config/test.exs` (`OPER testoper testpass`).
10 + """
11 +
12 + use OhMyExOhIrcd.DataCase, async: false
13 +
14 + import OhMyExOhIrcd.IRCTestHelper
15 +
16 + alias OhMyExOhIrcd.Accounts
17 +
18 + # Register, then claim oper via the static O-line from config/test.exs.
19 + defp register_oper(port) do
20 + sock = register(port, unique_nick("oper"))
21 + send_line(sock, "OPER testoper testpass")
22 + # Drain the 381 + MODE +o.
23 + assert Enum.any?(drain(sock, 300), &String.contains?(&1, " 381 "))
24 + sock
25 + end
26 +
27 + defp notice_text(lines) do
28 + lines
29 + |> Enum.filter(&String.contains?(&1, "NOTICE"))
30 + |> Enum.map_join("\n", & &1)
31 + end
32 +
33 + describe "MKINVITE" do
34 + test "an oper mints an invite and gets the token + signup URL back" do
35 + port = start_listener()
36 + sock = register_oper(port)
37 +
38 + send_line(sock, "MKINVITE")
39 + lines = drain(sock, 400)
40 + text = notice_text(lines)
41 +
42 + assert text =~ "Invite token:"
43 + assert text =~ "/users/register?invite="
44 +
45 + # The token is real and valid against the Accounts context.
46 + [_, token] = Regex.run(~r/Invite token: (\S+)/, text)
47 + assert Accounts.valid_invite(token)
48 + end
49 +
50 + test "MKINVITE <days> sets an expiry" do
51 + port = start_listener()
52 + sock = register_oper(port)
53 +
54 + send_line(sock, "MKINVITE 7")
55 + text = notice_text(drain(sock, 400))
56 +
57 + assert text =~ "Invite token:"
58 + assert text =~ "Expires:"
59 + end
60 +
61 + test "MKINVITE <days> :<note> records the note" do
62 + port = start_listener()
63 + sock = register_oper(port)
64 +
65 + send_line(sock, "MKINVITE 7 :for alice")
66 + text = notice_text(drain(sock, 400))
67 +
68 + [_, token] = Regex.run(~r/Invite token: (\S+)/, text)
69 + assert %{note: "for alice"} = Accounts.valid_invite(token)
70 + end
71 +
72 + test "a non-integer day count is a usage error, not an invite" do
73 + port = start_listener()
74 + sock = register_oper(port)
75 +
76 + send_line(sock, "MKINVITE notanumber")
77 + text = notice_text(drain(sock, 400))
78 +
79 + assert text =~ "Usage: MKINVITE"
80 + refute text =~ "Invite token:"
81 + end
82 +
83 + test "a non-oper gets 481 and no invite is created" do
84 + port = start_listener()
85 + before = length(Accounts.list_active_invites())
86 +
87 + sock = register(port, unique_nick("plain"))
88 + send_line(sock, "MKINVITE")
89 + lines = drain(sock, 400)
90 +
91 + assert Enum.any?(lines, &String.contains?(&1, " 481 "))
92 + assert length(Accounts.list_active_invites()) == before
93 + end
94 + end
95 +
96 + describe "LSINVITE" do
97 + test "lists active invites for an oper" do
98 + port = start_listener()
99 + sock = register_oper(port)
100 +
101 + {:ok, invite} = Accounts.create_invite(note: "listed-#{System.unique_integer([:positive])}")
102 +
103 + send_line(sock, "LSINVITE")
104 + text = notice_text(drain(sock, 400))
105 +
106 + assert text =~ "Active invites"
107 + assert text =~ invite.token
108 + end
109 +
110 + test "a non-oper gets 481" do
111 + port = start_listener()
112 + sock = register(port, unique_nick("plain"))
113 +
114 + send_line(sock, "LSINVITE")
115 + assert Enum.any?(drain(sock, 400), &String.contains?(&1, " 481 "))
116 + end
117 + end
118 +end

Parents: 6de4808