Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97d2d195c3 | ||
|
|
a5471a0576 | ||
|
|
adf94d7bc1 | ||
|
|
0eccf9bea9 |
@@ -94,10 +94,15 @@ export function OutboxFeature() {
|
||||
OFFLINE.has(mx.getSyncState()) ||
|
||||
(typeof navigator !== 'undefined' && navigator.onLine === false);
|
||||
|
||||
const persist = (next: OutboxEntry[]) => {
|
||||
if (next === entries) return;
|
||||
// [Gitea #268] Read-modify-write: another tab may have queued or cleared
|
||||
// entries since we last looked, and saving our own copy over the key
|
||||
// would drop theirs. Entries are added/removed by txnId, so applying our
|
||||
// change to the stored list merges cleanly.
|
||||
const persist = (update: (list: OutboxEntry[]) => OutboxEntry[]) => {
|
||||
const stored = loadOutbox(userId);
|
||||
const next = update(stored);
|
||||
entries = next;
|
||||
saveOutbox(userId, entries);
|
||||
if (next !== stored) saveOutbox(userId, next);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -123,8 +128,8 @@ export function OutboxFeature() {
|
||||
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
|
||||
live.set(txnId, { event, room });
|
||||
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
|
||||
persist(
|
||||
withEntry(entries, {
|
||||
persist((list) =>
|
||||
withEntry(list, {
|
||||
txnId,
|
||||
roomId: room.roomId,
|
||||
threadId: event.threadRootId ?? null,
|
||||
@@ -138,7 +143,7 @@ export function OutboxFeature() {
|
||||
// SENT, CANCELLED, or the remote echo replaced it (status null).
|
||||
live.delete(txnId);
|
||||
autoRetries.delete(txnId);
|
||||
persist(withoutEntry(entries, txnId));
|
||||
persist((list) => withoutEntry(list, txnId));
|
||||
};
|
||||
|
||||
/** Re-send network failures, oldest first; a room stops at its first failure. */
|
||||
@@ -191,7 +196,7 @@ export function OutboxFeature() {
|
||||
return !!room && isDelivered(room, entry.txnId);
|
||||
},
|
||||
);
|
||||
plan.drop.forEach((entry) => persist(withoutEntry(entries, entry.txnId)));
|
||||
plan.drop.forEach((entry) => persist((list) => withoutEntry(list, entry.txnId)));
|
||||
plan.restore.forEach((entry) => {
|
||||
const room = mx.getRoom(entry.roomId);
|
||||
if (!room || live.has(entry.txnId)) return;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user