neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
betterops
62c2d06 · gmorell · 2026-06-10 21:24
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