Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 adf94d7bc1 fix(acl): a missing allow_ip_literals means allowed (#261)
CI / Build & Quality Checks (pull_request) Successful in 1m54s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m45s
The Server ACL editor treated an absent allow_ip_literals as false, so the
first save in a room with no ACL (or one omitting the field) silently banned
IP-literal servers. The spec default is true. normalizeServerAcl() fills the
spec defaults once and feeds the initial state, the dirty check and the save.
It also stops showing an existing ACL with no allow list as allow-all: per
spec that allows no servers, and the /acl command already keeps it as-is.

The checkbox is driven by onClick (folds has no onChange), now readOnly so
React stops warning about a checked input without onChange.

Fixes #261

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:07:03 -04:00
6 changed files with 102 additions and 158 deletions
@@ -52,7 +52,8 @@ import { settingsAtom } from '../../../state/settings';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { buildTopicContent } from '../../../utils/topicMarkdown';
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
const start = textarea.selectionStart;
@@ -70,6 +71,30 @@ function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholde
textarea.setSelectionRange(cursorStart, cursorEnd);
}
function topicMarkdownToHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/_(.+?)_/g, '<em>$1</em>')
.replace(/~~(.+?)~~/g, '<del>$1</del>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
.replace(/\n/g, '<br>');
}
function buildTopicContent(topic: string): Record<string, string> {
if (!MARKDOWN_PATTERN.test(topic)) return { topic };
const formattedBody = topicMarkdownToHtml(topic);
// Use HTML-stripped text as the plain topic so the header shows clean text, not raw markdown syntax
const plainTopic = formattedBody.replace(/<br>/g, '\n').replace(/<[^>]+>/g, '');
return { topic: plainTopic, format: 'org.matrix.custom.html', formatted_body: formattedBody };
}
type RoomProfileEditProps = {
canEditAvatar: boolean;
canEditName: boolean;
@@ -32,24 +32,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
import { stopPropagation } from '../../utils/keyboard';
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
import { isValidServerPattern, matchesAnyGlob, normalizeServerAcl } from '../../utils/serverAcl';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { useModalStyle } from '../../hooks/useModalStyle';
// ── Types ─────────────────────────────────────────────────────────────────────
type ServerAclContent = {
allow: string[];
deny: string[];
allow_ip_literals: boolean;
};
const DEFAULT_ACL: ServerAclContent = {
allow: ['*'],
deny: [],
allow_ip_literals: false,
};
// ── Server list sub-component ─────────────────────────────────────────────────
type ServerListProps = {
@@ -185,20 +173,20 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
// Read current ACL from room state
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
const currentAcl = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
const currentAcl = normalizeServerAcl(aclEvent?.getContent());
// Local draft state — initialised from current ACL
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow);
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny);
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
() => currentAcl.allow_ip_literals ?? false,
() => currentAcl.allow_ip_literals,
);
// Track whether there are unsaved changes
const isDirty =
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow) ||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny) ||
allowIpLiterals !== currentAcl.allow_ip_literals;
// Save handler
const [saveState, save] = useAsyncCallback(
@@ -336,7 +324,10 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
id="allow-ip-literals"
checked={allowIpLiterals}
disabled={!canEdit}
onClick={() => setAllowIpLiterals(!allowIpLiterals)}
// folds' Checkbox has no onChange; readOnly tells React the
// click handler controls it (no "checked without onChange").
readOnly
onClick={(e) => setAllowIpLiterals(e.currentTarget.checked)}
size="300"
variant="Primary"
/>
+38 -1
View File
@@ -1,6 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isValidServerPattern, globToRegExp, matchesAnyGlob } from './serverAcl';
import {
isValidServerPattern,
globToRegExp,
matchesAnyGlob,
normalizeServerAcl,
} from './serverAcl';
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
for (const v of [
@@ -42,3 +47,35 @@ test('matchesAnyGlob: true if any glob matches (self-ban detection)', () => {
assert.equal(matchesAnyGlob('lotusguild.org', ['*.evil.com']), false);
assert.equal(matchesAnyGlob('lotusguild.org', []), false);
});
test('no ACL event: every server allowed, IP literals allowed (#261)', () => {
assert.deepEqual(normalizeServerAcl(undefined), {
allow: ['*'],
deny: [],
allow_ip_literals: true,
});
});
test('a missing allow_ip_literals defaults to true, per the spec (#261)', () => {
assert.equal(normalizeServerAcl({ allow: ['*'], deny: ['bad.example'] }).allow_ip_literals, true);
});
test('an explicit allow_ip_literals is kept', () => {
assert.equal(
normalizeServerAcl({ allow: ['*'], allow_ip_literals: false }).allow_ip_literals,
false,
);
assert.equal(
normalizeServerAcl({ allow: ['*'], allow_ip_literals: true }).allow_ip_literals,
true,
);
});
test('missing or malformed lists become empty, non-strings are dropped', () => {
assert.deepEqual(normalizeServerAcl({ allow: ['a.example', 3, null] }), {
allow: ['a.example'],
deny: [],
allow_ip_literals: true,
});
assert.deepEqual(normalizeServerAcl({ allow: 'a.example', deny: {} }).allow, []);
});
+26
View File
@@ -50,3 +50,29 @@ export function matchesAnyGlob(domain: string, globs: string[]): boolean {
}
});
}
/**
* `m.room.server_acl` content with the spec's defaults filled in (#261).
* `allow_ip_literals` defaults to **true** when absent; `allow` and `deny`
* default to empty lists, but with no ACL event at all every server is allowed,
* so the editor starts from `allow: ['*']` in that case.
* https://spec.matrix.org/v1.12/client-server-api/#mroomserver_acl
*/
export type ServerAclContent = {
allow: string[];
deny: string[];
allow_ip_literals: boolean;
};
const stringList = (value: unknown): string[] | undefined =>
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : undefined;
export function normalizeServerAcl(content: Record<string, unknown> | undefined): ServerAclContent {
if (!content) return { allow: ['*'], deny: [], allow_ip_literals: true };
return {
allow: stringList(content.allow) ?? [],
deny: stringList(content.deny) ?? [],
allow_ip_literals:
typeof content.allow_ip_literals === 'boolean' ? content.allow_ip_literals : true,
};
}
-72
View File
@@ -1,72 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildTopicContent } from './topicMarkdown';
test('a URL with underscores is sent verbatim (#262)', () => {
const t = 'Rules: https://wiki.lotusguild.org/en/server_rules_and_faq';
assert.deepEqual(buildTopicContent(t), { topic: t });
});
test('a markdown link keeps its underscored URL in both fields (#262)', () => {
const c = buildTopicContent('[Our wiki](https://wiki.example.org/my_cool_page)');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /href="https:\/\/wiki\.example\.org\/my_cool_page"/);
assert.equal(c.topic, 'Our wiki (https://wiki.example.org/my_cool_page)');
});
test('room aliases and user IDs with underscores are untouched (#262)', () => {
for (const t of [
'Join #my_room_name:matrix.org',
'Ask @some_user_x:matrix.org',
'see snake_case_word',
]) {
assert.deepEqual(buildTopicContent(t), { topic: t });
}
});
test('emphasis still works at word boundaries', () => {
const c = buildTopicContent('this is _really_ *great* and **bold**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<i data-md="_">really<\/i>/);
assert.match(c.formatted_body, /<i data-md="\*">great<\/i>/);
assert.match(c.formatted_body, /<strong data-md="\*\*">bold<\/strong>/);
assert.equal(c.topic, 'this is really great and bold');
});
test('emphasis around an alias keeps the alias intact', () => {
const c = buildTopicContent('**Join #my_room:matrix.org**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<strong data-md="\*\*">Join #my_room:matrix\.org<\/strong>/);
assert.equal(c.topic, 'Join #my_room:matrix.org');
});
test('inline code keeps its underscores and no backslashes appear', () => {
const c = buildTopicContent('run `a_b_c` now');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<code data-md="`">a_b_c<\/code>/);
assert.equal(c.topic, 'run a_b_c now');
});
test('a double quote in a link target cannot break out of the attribute (#262)', () => {
const c = buildTopicContent('[x](https://a" onclick="alert(1))');
assert.ok('formatted_body' in c);
assert.doesNotMatch(c.formatted_body, /" onclick="/);
});
test('HTML in the topic is escaped, and the plain topic shows it as typed', () => {
const c = buildTopicContent('**<b>hi</b> & bye**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /&lt;b&gt;hi&lt;\/b&gt; &amp; bye/);
assert.equal(c.topic, '<b>hi</b> & bye');
});
test('line breaks become <br> and survive in the plain topic', () => {
const c = buildTopicContent('**Rules**\nBe nice');
assert.ok('formatted_body' in c);
assert.equal(c.formatted_body, '<strong data-md="**">Rules</strong><br>Be nice');
assert.equal(c.topic, 'Rules\nBe nice');
});
test('plain text with no markdown is sent as typed', () => {
assert.deepEqual(buildTopicContent("It's <fun> & games"), { topic: "It's <fun> & games" });
});
-63
View File
@@ -1,63 +0,0 @@
import { parseInlineMD } from '../plugins/markdown';
import { sanitizeText } from './sanitize';
/**
* Room topic markdown → `m.room.topic` content (#262).
*
* Uses the composer's inline markdown parser (the same rules as messages: links,
* bare URLs and `"` are handled), with one topic-specific guard: an `_` between
* two letters/digits is literal, as in CommonMark. Topics are full of
* `#room_alias:server`, `@user_name:server` and snake_case paths, which the
* shared parser would otherwise turn into italics and, worse, drop from the
* plain `topic` that every other client shows.
*/
// Spans the guard must not touch: inline code, link targets, bare URLs.
const PROTECTED = /(`[^`\n]*`|\]\([^)\n]*\)|https?:\/\/[^\s<>"]+)/;
const INTRAWORD_UNDERSCORES = /(?<=[\p{L}\p{N}])_+(?=[\p{L}\p{N}])/gu;
const escapeIntrawordUnderscores = (line: string): string =>
line
.split(PROTECTED)
.map((part, i) =>
// split() with one capture group puts the protected spans at odd indexes.
i % 2 === 1 ? part : part.replace(INTRAWORD_UNDERSCORES, (m) => m.replace(/_/g, '\\_')),
)
.join('');
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
};
const decodeEntities = (s: string): string =>
s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => ENTITIES[e]);
/** Readable plain text for `topic`: markers dropped, links kept as "text (url)". */
const htmlToPlain = (html: string): string =>
decodeEntities(
html
.replace(/<a\b[^>]*\bhref="([^"]*)"[^>]*>(.*?)<\/a>/g, (_m, href: string, label: string) => {
const text = label.replace(/<[^>]+>/g, '');
return text === href ? href : `${text} (${href})`;
})
.replace(/<br>/g, '\n')
.replace(/<[^>]+>/g, ''),
);
export type TopicContent =
| { topic: string }
| { topic: string; format: 'org.matrix.custom.html'; formatted_body: string };
export function buildTopicContent(text: string): TopicContent {
const lines = text.split('\n');
const html = lines
.map((line) => parseInlineMD(sanitizeText(escapeIntrawordUnderscores(line))))
.join('<br>');
const plainHtml = lines.map(sanitizeText).join('<br>');
// No markdown at all: send the text as typed, without a formatted body.
if (html === plainHtml) return { topic: text };
return { topic: htmlToPlain(html), format: 'org.matrix.custom.html', formatted_body: html };
}