From 20e57ce27cbe437393568c03be8317be46775870 Mon Sep 17 00:00:00 2001 From: Richard Drake Date: Fri, 6 Nov 2026 01:22:07 +0000 Subject: [PATCH 01/39] Search: send soju's one parameter to nefarious2 too nefarious2 read the spec's `SEARCH in=#chan;text=foo` as one long `in=`, so its profile spelled the selectors out as separate parameters, the text last. evilnet/nefarious2#152 makes m_search read soju's form, as soju itself does, so the profile's searchParams and the hook it filled are gone: every server gets specSearchParams. A line break no longer has to be refused before sending: the one parameter escapes it, as it always did for other servers. The scenario checks the wire in the new spelling. An older nefarious2 answers the new form with FAIL SEARCH INVALID_PARAMS in, so this waits on #152. --- client/js/irc/historysearch.ts | 4 ++-- client/js/irc/profiles/nefarious/index.ts | 16 ---------------- client/js/irc/profiles/types.ts | 3 --- docs/resources/nefarious2-websocket.md | 2 +- shared/types/search.ts | 3 +-- test/irc/historysearch.ts | 19 ++++--------------- tools/scenarios/history-search.mjs | 10 +++++----- 7 files changed, 13 insertions(+), 44 deletions(-) diff --git a/client/js/irc/historysearch.ts b/client/js/irc/historysearch.ts index 2c1c2473b..9f976734f 100644 --- a/client/js/irc/historysearch.ts +++ b/client/js/irc/historysearch.ts @@ -94,7 +94,7 @@ export function pendingSearches(client: IrcClient): readonly PendingSearch[] { /** * The spec's single parameter: `key=value` joined by `;`, values escaped like - * message tags. nefarious2 spells it differently (its profile's `searchParams`). + * message tags, as soju reads it. nefarious2 reads it too (evilnet/nefarious2#152). */ export function specSearchParams(attrs: SearchAttrs): string[] { const escape = (value: string) => @@ -261,7 +261,7 @@ export function requestSearch(client: IrcClient, requestId: number, attrs: Searc {kind: "results", requestId}, client.caps.hasCapability(SEARCH_CAP) && client.caps.hasCapability("batch"), "SEARCH", - client.profile.searchParams?.(attrs) ?? specSearchParams(attrs) + specSearchParams(attrs) ); } diff --git a/client/js/irc/profiles/nefarious/index.ts b/client/js/irc/profiles/nefarious/index.ts index 7413851f8..a3926082c 100644 --- a/client/js/irc/profiles/nefarious/index.ts +++ b/client/js/irc/profiles/nefarious/index.ts @@ -3,7 +3,6 @@ * docs/resources/nefarious2-oper.md. */ -import type {SearchAttrs} from "../../../../../shared/types/search"; import type {ServerProfile} from "../types"; import {ALERT_LINES} from "./alerts"; import {checkReport} from "./check"; @@ -11,24 +10,9 @@ import {classifyNotice, NOTICE_KINDS} from "./notices"; import {NEFARIOUS_SNOMASK} from "./snomask"; import {STATS_ENTRIES, statsReport} from "./stats"; -/** - * `m_search` takes `key=value` selectors as separate parameters, not the spec's - * one `;`-joined parameter, which it would read as one long `in=`. Only the - * last may hold a space, so `text` goes last. - */ -function searchParams(attrs: SearchAttrs): string[] { - const {text, ...rest} = attrs; - const params = Object.entries(rest) - .filter(([, value]) => value !== undefined && value !== "") - .map(([key, value]) => `${key}=${String(value)}`); - - return text ? [...params, `text=${text}`] : params; -} - export const nefarious: ServerProfile = { id: "nefarious2", name: "nefarious2", - searchParams, snomask: NEFARIOUS_SNOMASK, noticeKinds: NOTICE_KINDS, alertLines: ALERT_LINES, diff --git a/client/js/irc/profiles/types.ts b/client/js/irc/profiles/types.ts index 4d34c55c1..eb48631f5 100644 --- a/client/js/irc/profiles/types.ts +++ b/client/js/irc/profiles/types.ts @@ -10,7 +10,6 @@ */ import type {OperReport, Route} from "../../../../shared/types/oper"; -import type {SearchAttrs} from "../../../../shared/types/search"; import type {IrcMessage} from "../message"; /** One bit of a numeric server notice mask (ircu family). */ @@ -100,8 +99,6 @@ export interface ServerProfile { id: string; /** Display name (`nefarious2`). */ name: string; - /** `SEARCH`'s parameters where they differ from the spec's single one. */ - searchParams?: (attrs: SearchAttrs) => string[]; snomask?: SnomaskModel; /** Every notice kind the classifier can produce, with its default route. */ noticeKinds: NoticeKind[]; diff --git a/docs/resources/nefarious2-websocket.md b/docs/resources/nefarious2-websocket.md index 8137c1111..4a98befdd 100644 --- a/docs/resources/nefarious2-websocket.md +++ b/docs/resources/nefarious2-websocket.md @@ -129,7 +129,7 @@ Every cap in the plan's list (`server-time`, `message-tags`, `account-tag`, `ech ### `SEARCH` differs from soju's spec -Verified against a build of `ircv3.2-upgrade` at `965ca63`. The [spec](https://codeberg.org/emersion/soju/src/branch/master/doc/ext/search.md) sends one parameter, `SEARCH text=foo;in=#chan`, with tag-style escapes, and is itself inconsistent about the batch type (`search` in the prose, `soju.im/search` in the introduction and examples). `m_search` takes separate `key=value` parameters, of which only the last may hold a space: `SEARCH in=#chan limit=100 :text=foo bar`. The spec's form would be read as one long `in=`. The profile's `searchParams` holds the difference; both batch names are accepted. The reply is `@label=x BATCH +id search`, rows ascending in time, `BATCH -id`, and then a labeled `ACK` as well. `text` is one case-insensitive substring and `from` an exact nick; there is no total and no sign that the 50000-row scan budget ran out, so an empty page does not prove that nothing older exists. Without `in=` only the channels the user is in are searched, on this server's store alone; private messages need `in=`. An unknown target answers `FAIL SEARCH INVALID_PARAMS in :No access to target`. +Verified against a build of `ircv3.2-upgrade` at `965ca63`, and the one-parameter form against evilnet/nefarious2#152. The [spec](https://codeberg.org/emersion/soju/src/branch/master/doc/ext/search.md) sends one parameter, `SEARCH text=foo;in=#chan`, with tag-style escapes, and soju answers with a `soju.im/search` batch. Seance sends that form to every server. Before #152, `m_search` took separate `key=value` parameters (`SEARCH in=#chan limit=100 :text=foo bar`), read the spec's form as one long `in=` and answered `FAIL SEARCH INVALID_PARAMS in :No access to target`, so search on an older nefarious2 fails; with #152 it reads both forms. Both batch names are accepted (#152 sends `soju.im/search`; before it, `search`, followed by a labeled `ACK` as well). Rows come ascending in time. `text` is one case-insensitive substring and `from` an exact nick; a value holding CR or LF is refused. There is no total and no sign that the 50000-row scan budget ran out, so an empty page does not prove that nothing older exists. Without `in=` only the channels the user is in are searched, on this server's store alone; private messages need `in=`. An unknown target answers `FAIL SEARCH INVALID_PARAMS in :No access to target`. ## nefarious2 quirks the client must tolerate diff --git a/shared/types/search.ts b/shared/types/search.ts index 386b5532f..90c0d1627 100644 --- a/shared/types/search.ts +++ b/shared/types/search.ts @@ -2,8 +2,7 @@ import type {SharedMsg} from "./msg"; /** * What a `SEARCH` may ask (soju.im/search). `in` absent means every channel - * the user is in. The order here is not the order on the wire: that is the - * server profile's (`ServerProfile.searchParams`). + * the user is in. On the wire: one `;`-joined parameter (`specSearchParams`). */ export interface SearchAttrs { in?: string; diff --git a/test/irc/historysearch.ts b/test/irc/historysearch.ts index e3adcc8e1..bde29d2ee 100644 --- a/test/irc/historysearch.ts +++ b/test/irc/historysearch.ts @@ -13,7 +13,6 @@ import { specSearchParams, } from "../../client/js/irc/historysearch"; import {pendingHistory} from "../../client/js/irc/history"; -import {nefarious} from "../../client/js/irc/profiles/nefarious"; import {IRCU_FAKE_LAG, NO_FAKE_LAG, type FakeLagModel} from "../../client/js/irc/fakelag"; import type {Transport} from "../../client/js/irc/types"; import type {TransportEvent, TransportState} from "../../client/js/irc/transport"; @@ -222,14 +221,13 @@ describe("historysearch", function () { expect(specSearchParams({text: "a;b\\c"})).to.deep.equal(["text=a\\:b\\\\c"]); }); - it("uses separate selectors with text last on nefarious2", function () { + it("uses the same one parameter on nefarious2", function () { const {client, transport} = setup({nefarious: true}); requestSearch(client, 1, attrs); expect(transport.sent[0]).to.match( - /^@label=q\d+ SEARCH in=#seance from=bob limit=100 :text=fake lag$/ + /^@label=q\d+ SEARCH in=#seance;text=fake\\slag;from=bob;limit=100$/ ); - expect(nefarious.searchParams?.({from: "bob"})).to.deep.equal(["from=bob"]); }); it("refuses what would not fit, without sending it", function () { @@ -240,21 +238,12 @@ describe("historysearch", function () { expect(payloads("search:results")[0].status).to.equal("invalid"); }); - it("answers invalid for a line break, on either spelling, and sends nothing", function () { + it("escapes a line break, so it is only text", function () { for (const onNefarious of [false, true]) { const {client, transport} = setup({nefarious: onNefarious}); requestSearch(client, 1, {text: "x\r\nQUIT :bye"}); - - if (onNefarious) { - expect(transport.sent).to.deep.equal([]); - expect(payloads("search:results").pop()?.status).to.equal( - "invalid" - ); - } else { - // The spec's form escapes it, so it is simply text there. - expect(transport.sent[0]).to.contain("x\\r\\nQUIT\\s:bye"); - } + expect(transport.sent[0]).to.contain("x\\r\\nQUIT\\s:bye"); } }); diff --git a/tools/scenarios/history-search.mjs b/tools/scenarios/history-search.mjs index 72e244b4b..b3993cbeb 100644 --- a/tools/scenarios/history-search.mjs +++ b/tools/scenarios/history-search.mjs @@ -178,8 +178,8 @@ export default async function run(page) { const asked = sentCommands(page, start, /\bSEARCH\b/); page.check("1. SEARCH goes to the server", asked.length === 1); page.check( - "1. in nefarious2's spelling, scoped to the channel", - asked[0] && new RegExp(`SEARCH in=${HOME} limit=\\d+ text=fake`).test(asked[0].payloadData) + "1. in the spec's one parameter, scoped to the channel", + asked[0] && new RegExp(`SEARCH in=${HOME};text=fake;limit=\\d+$`).test(asked[0].payloadData) ); page.check( "1. four hits, ours among them, no other channel's", @@ -187,12 +187,12 @@ export default async function run(page) { ); await page.screenshot("1-results"); - // A phrase is one substring, and only the last selector may hold a space. + // A phrase is one substring, its space escaped. const phraseFrom = page.wsFrames.length; await search(page, '"fake lag"'); page.check( - "1. a quoted phrase goes as one trailing parameter", - sentCommands(page, phraseFrom, /limit=\d+ :text=fake lag/).length === 1 + "1. a quoted phrase goes as one escaped value", + sentCommands(page, phraseFrom, /;text=fake\\slag;/).length === 1 ); // The picture says fake-lag.png: both words, not the phrase. page.check("1. and leaves out what only has the words", (await count(page, ROWS)) === 3); From 5813ac5a46e1953c180d18169336fae5ea858781 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Fri, 6 Nov 2026 20:22:36 -0800 Subject: [PATCH 02/39] Channel bans: ban from a nick or a user@host, and a Bans window Ban... on a nick's menu, and a new menu on the user@host in join, part and quit lines, open a dialog in the oper tools' style: the mask with suggested shapes, who it catches as you type, warnings when it would catch you, is already covered or replaces narrower bans, and a kick to go with it. The Bans window replaces the old table, in the channel browser's look: search by word or by a pasted address (which finds the ban catching it), filters, sorts, Unban with an undo, "Unban these N", add and edit, and other ops' changes arrive live. "List banned users", /banlist and a typed MODE #chan b all open it. Changes go out one line at a time, each waiting for its ACK, so the throttle is respected and a ban the server silently ignores is reported rather than shown as done. Extban wording lives in the nefarious profile. Kick no longer hides itself on networks without half-ops. The new strings are English in the other languages until translated. Co-Authored-By: Claude Opus 5.5 --- client/components/App.vue | 3 + client/components/BanDialog.vue | 670 ++++++++++++++++ client/components/Chat.vue | 3 - client/components/ContextMenu.vue | 4 +- client/components/HostAddress.vue | 44 ++ client/components/MessageTypes/join.vue | 14 +- client/components/MessageTypes/part.vue | 12 +- client/components/MessageTypes/quit.vue | 12 +- client/components/Oper/ActionDialog.vue | 34 +- client/components/Special/ListBans.vue | 49 -- client/components/Username.vue | 3 + client/components/Windows/BanList.vue | 765 +++++++++++++++++++ client/components/Windows/ChannelBrowser.vue | 8 +- client/css/style.css | 22 +- client/js/helpers/banMenu.ts | 142 ++++ client/js/helpers/channelBans.ts | 310 ++++++++ client/js/helpers/contextMenu.ts | 21 +- client/js/i18n/call-sites.ts | 90 ++- client/js/irc/banList.ts | 312 ++++++++ client/js/irc/bus.ts | 20 + client/js/irc/client.ts | 35 +- client/js/irc/commands/ban.ts | 6 +- client/js/irc/handlers/lists.ts | 42 +- client/js/irc/handlers/mode.ts | 13 +- client/js/irc/isupport.ts | 19 + client/js/irc/profiles/nefarious/extbans.ts | 38 + client/js/irc/profiles/nefarious/index.ts | 2 + client/js/irc/profiles/types.ts | 10 + client/js/router.ts | 8 + client/js/socket-events/channel_bans.ts | 13 + client/js/socket-events/index.ts | 1 + client/js/store.ts | 13 + client/locales/ar.json | 4 - client/locales/ar.po | 588 +++++++++++++- client/locales/cs.json | 4 - client/locales/cs.po | 564 +++++++++++++- client/locales/da.json | 4 - client/locales/da.po | 556 +++++++++++++- client/locales/de.json | 4 - client/locales/de.po | 556 +++++++++++++- client/locales/en.json | 114 ++- client/locales/es.json | 4 - client/locales/es.po | 556 +++++++++++++- client/locales/fil.json | 4 - client/locales/fil.po | 556 +++++++++++++- client/locales/fr.json | 4 - client/locales/fr.po | 556 +++++++++++++- client/locales/hu.json | 4 - client/locales/hu.po | 556 +++++++++++++- client/locales/it.json | 4 - client/locales/it.po | 556 +++++++++++++- client/locales/ja.json | 4 - client/locales/ja.po | 548 ++++++++++++- client/locales/ko.json | 4 - client/locales/ko.po | 548 ++++++++++++- client/locales/messages.pot | 556 +++++++++++++- client/locales/nl.json | 4 - client/locales/nl.po | 556 +++++++++++++- client/locales/pl.json | 4 - client/locales/pl.po | 564 +++++++++++++- client/locales/pt.json | 4 - client/locales/pt.po | 556 +++++++++++++- client/locales/qqx.json | 114 ++- client/locales/ro.json | 4 - client/locales/ro.po | 564 +++++++++++++- client/locales/ru.json | 4 - client/locales/ru.po | 564 +++++++++++++- client/locales/sk.json | 4 - client/locales/sk.po | 564 +++++++++++++- client/locales/sv.json | 4 - client/locales/sv.po | 556 +++++++++++++- client/locales/th.json | 4 - client/locales/th.po | 548 ++++++++++++- client/locales/tr.json | 4 - client/locales/tr.po | 556 +++++++++++++- client/locales/uk.json | 4 - client/locales/uk.po | 564 +++++++++++++- client/locales/vi.json | 4 - client/locales/vi.po | 548 ++++++++++++- client/locales/zh.json | 4 - client/locales/zh.po | 548 ++++++++++++- docs/resources/bus-contract.md | 64 +- docs/resources/client-features.md | 12 + shared/types/ban-list.ts | 57 ++ shared/types/chan.ts | 1 - shared/types/socket-events.d.ts | 10 + test/helpers/channelBans.ts | 286 +++++++ test/irc/banList.ts | 437 +++++++++++ test/irc/lists.ts | 182 +---- test/irc/special-windows.ts | 24 +- tools/scenarios/channel-bans.mjs | 390 ++++++++++ tools/scenarios/channel-browser.mjs | 11 +- 92 files changed, 16896 insertions(+), 1020 deletions(-) create mode 100644 client/components/BanDialog.vue create mode 100644 client/components/HostAddress.vue delete mode 100644 client/components/Special/ListBans.vue create mode 100644 client/components/Windows/BanList.vue create mode 100644 client/js/helpers/banMenu.ts create mode 100644 client/js/helpers/channelBans.ts create mode 100644 client/js/irc/banList.ts create mode 100644 client/js/irc/profiles/nefarious/extbans.ts create mode 100644 client/js/socket-events/channel_bans.ts create mode 100644 shared/types/ban-list.ts create mode 100644 test/helpers/channelBans.ts create mode 100644 test/irc/banList.ts create mode 100644 tools/scenarios/channel-bans.mjs diff --git a/client/components/App.vue b/client/components/App.vue index 67bbea21e..f16f1e4bc 100644 --- a/client/components/App.vue +++ b/client/components/App.vue @@ -27,6 +27,7 @@ +
@@ -47,6 +48,7 @@ import ImageViewer from "./ImageViewer.vue"; import ContextMenu from "./ContextMenu.vue"; import ConfirmDialog from "./ConfirmDialog.vue"; import UploadPreview from "./UploadPreview.vue"; +import BanDialog from "./BanDialog.vue"; import OperActionDialog from "./Oper/ActionDialog.vue"; import InvestigateCard from "./Oper/InvestigateCard.vue"; import AlertBuilder from "./Oper/AlertBuilder.vue"; @@ -81,6 +83,7 @@ export default defineComponent({ ConfirmDialog, UploadPreview, OperActionDialog, + BanDialog, InvestigateCard, AlertBuilder, Mentions, diff --git a/client/components/BanDialog.vue b/client/components/BanDialog.vue new file mode 100644 index 000000000..0939ef2a8 --- /dev/null +++ b/client/components/BanDialog.vue @@ -0,0 +1,670 @@ + + + + + diff --git a/client/components/Chat.vue b/client/components/Chat.vue index 8b0a40b0f..96fb51ca9 100644 --- a/client/components/Chat.vue +++ b/client/components/Chat.vue @@ -176,7 +176,6 @@ import ChatInput from "./ChatInput.vue"; import ChatUserList from "./ChatUserList.vue"; import SidebarToggle from "./SidebarToggle.vue"; import MessageSearchForm from "./MessageSearchForm.vue"; -import ListBans from "./Special/ListBans.vue"; import ListInvites from "./Special/ListInvites.vue"; import ListExcepts from "./Special/ListExcepts.vue"; import ListIgnored from "./Special/ListIgnored.vue"; @@ -331,8 +330,6 @@ export default defineComponent({ const specialComponent = computed(() => { switch (props.channel.special) { - case SpecialChanType.BANLIST: - return ListBans as Component; case SpecialChanType.INVITELIST: return ListInvites as Component; case SpecialChanType.EXCEPTLIST: diff --git a/client/components/ContextMenu.vue b/client/components/ContextMenu.vue index 8f894fc2d..a7919a1a3 100644 --- a/client/components/ContextMenu.vue +++ b/client/components/ContextMenu.vue @@ -260,6 +260,7 @@ export default defineComponent({ event: MouseEvent; network?: ClientNetwork; channel?: ClientChan; + address?: string; }) => { // The network the nick was shown for (an oper card, a notice), not // the active one: a card can be open for another network, and its @@ -283,7 +284,8 @@ export default defineComponent({ channel.users.find((u) => u.nick === data.user.nick) || { nick: data.user.nick, modes: [], - } + }, + data.address ); open(data.event, newItems); }; diff --git a/client/components/HostAddress.vue b/client/components/HostAddress.vue new file mode 100644 index 000000000..a8c86b045 --- /dev/null +++ b/client/components/HostAddress.vue @@ -0,0 +1,44 @@ + + + diff --git a/client/components/MessageTypes/join.vue b/client/components/MessageTypes/join.vue index 18e022c40..608bcdd82 100644 --- a/client/components/MessageTypes/join.vue +++ b/client/components/MessageTypes/join.vue @@ -1,7 +1,13 @@