Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bfb8737d6 | ||
|
|
7ad2a94e2e | ||
|
|
e1168f47bb | ||
|
|
14f05a3d5c | ||
|
|
f5a184e428 | ||
|
|
a5471a0576 | ||
|
|
ce1354ed93 | ||
|
|
adf94d7bc1 |
@@ -52,8 +52,7 @@ import { settingsAtom } from '../../../state/settings';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useAlive } from '../../../hooks/useAlive';
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { buildTopicContent } from '../../../utils/topicMarkdown';
|
||||||
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
|
|
||||||
|
|
||||||
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
|
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
@@ -71,30 +70,6 @@ function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholde
|
|||||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
textarea.setSelectionRange(cursorStart, cursorEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
function topicMarkdownToHtml(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.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 = {
|
type RoomProfileEditProps = {
|
||||||
canEditAvatar: boolean;
|
canEditAvatar: boolean;
|
||||||
canEditName: boolean;
|
canEditName: boolean;
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ export function Soundboard({ requestClose }: SoundboardProps) {
|
|||||||
<PageContent>
|
<PageContent>
|
||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="L400">This room / space (shared)</Text>
|
<Text size="L400">
|
||||||
|
{room?.isSpaceRoom() ? 'This space (shared)' : 'This room (shared)'}
|
||||||
|
</Text>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
Clips here are shared with everyone, and inherited by every room under this space
|
{/* [Gitea #279] Only a space's clips are inherited by rooms under it. */}
|
||||||
— just like emoji/sticker packs. Only members with permission can edit.
|
{room?.isSpaceRoom()
|
||||||
|
? 'Clips here are shared with everyone, and inherited by every room under this space — just like emoji/sticker packs. Only members with permission can edit.'
|
||||||
|
: 'Clips here are shared with everyone in this room — just like emoji/sticker packs. Only members with permission can edit.'}
|
||||||
</Text>
|
</Text>
|
||||||
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -32,24 +32,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
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 { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── 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 ─────────────────────────────────────────────────
|
// ── Server list sub-component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
type ServerListProps = {
|
type ServerListProps = {
|
||||||
@@ -185,20 +173,20 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
|
|
||||||
// Read current ACL from room state
|
// Read current ACL from room state
|
||||||
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
|
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
|
// Local draft state — initialised from current ACL
|
||||||
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
|
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow);
|
||||||
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
|
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny);
|
||||||
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
|
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
|
||||||
() => currentAcl.allow_ip_literals ?? false,
|
() => currentAcl.allow_ip_literals,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track whether there are unsaved changes
|
// Track whether there are unsaved changes
|
||||||
const isDirty =
|
const isDirty =
|
||||||
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
|
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow) ||
|
||||||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
|
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny) ||
|
||||||
allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
|
allowIpLiterals !== currentAcl.allow_ip_literals;
|
||||||
|
|
||||||
// Save handler
|
// Save handler
|
||||||
const [saveState, save] = useAsyncCallback(
|
const [saveState, save] = useAsyncCallback(
|
||||||
@@ -336,7 +324,10 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
id="allow-ip-literals"
|
id="allow-ip-literals"
|
||||||
checked={allowIpLiterals}
|
checked={allowIpLiterals}
|
||||||
disabled={!canEdit}
|
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"
|
size="300"
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
|||||||
No Pinned Messages
|
No Pinned Messages
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T400" align="Center">
|
<Text size="T400" align="Center">
|
||||||
Users with sufficient power level can pin a messages from its context menu.
|
Users with sufficient power level can pin a message from its context menu.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -106,3 +106,19 @@ test('classifyWidgetCapabilities: reading protected state is fine, changing unpr
|
|||||||
test('loadWidgetConsent tolerates missing storage', () => {
|
test('loadWidgetConsent tolerates missing storage', () => {
|
||||||
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
|
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[#266] space structure, call membership and Lotus room policy can never be granted', () => {
|
||||||
|
const { auto, ask } = classifyWidgetCapabilities(
|
||||||
|
[
|
||||||
|
'm.space.child',
|
||||||
|
'm.space.parent',
|
||||||
|
'org.matrix.msc3401.call',
|
||||||
|
'org.matrix.msc3401.call.member',
|
||||||
|
'm.call.member',
|
||||||
|
'io.lotus.voice_limit',
|
||||||
|
'io.lotus.room_quality',
|
||||||
|
].map((t) => `org.matrix.msc2762.send.state_event:${t}`),
|
||||||
|
);
|
||||||
|
assert.equal(auto.size, 0);
|
||||||
|
assert.deepEqual(ask, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ export const PROTECTED_STATE_TYPES: ReadonlySet<string> = new Set([
|
|||||||
'm.room.third_party_invite',
|
'm.room.third_party_invite',
|
||||||
'im.vector.modular.widgets',
|
'im.vector.modular.widgets',
|
||||||
'm.widget',
|
'm.widget',
|
||||||
|
// [Gitea #266] Space structure, call membership and Lotus room policy: a
|
||||||
|
// widget could restructure a space, fake call membership or lift the
|
||||||
|
// voice-channel limit, so these can't be granted even with consent.
|
||||||
|
'm.space.child',
|
||||||
|
'm.space.parent',
|
||||||
|
'org.matrix.msc3401.call',
|
||||||
|
'org.matrix.msc3401.call.member',
|
||||||
|
'm.call.member',
|
||||||
|
'io.lotus.voice_limit',
|
||||||
|
'io.lotus.room_quality',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type WidgetPermissionRequest = {
|
export type WidgetPermissionRequest = {
|
||||||
@@ -137,8 +147,11 @@ export const classifyWidgetCapabilities = (
|
|||||||
*/
|
*/
|
||||||
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
|
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
|
||||||
|
|
||||||
|
// [Gitea #266] Swept on logout by clearPlaintextCaches, so one account's
|
||||||
|
// approvals never apply to the next account on the same device.
|
||||||
|
export const WIDGET_CONSENT_PREFIX = 'lotus.widgetConsent.';
|
||||||
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
|
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
|
||||||
`lotus.widgetConsent.${roomId}.${widgetId}`;
|
`${WIDGET_CONSENT_PREFIX}${roomId}.${widgetId}`;
|
||||||
|
|
||||||
export const loadWidgetConsent = (
|
export const loadWidgetConsent = (
|
||||||
roomId: string,
|
roomId: string,
|
||||||
|
|||||||
@@ -2089,7 +2089,12 @@ function Calls() {
|
|||||||
description={
|
description={
|
||||||
deafenBind.error ??
|
deafenBind.error ??
|
||||||
(deafenHotkey
|
(deafenHotkey
|
||||||
? `Press ${keyLabel(deafenKey)} during a call to mute your speakers. Press Escape to cancel rebind.${
|
? `${
|
||||||
|
// [Gitea #279] The Escape hint only applies while rebinding.
|
||||||
|
deafenBind.listening
|
||||||
|
? 'Press a key to bind it, or Escape to cancel.'
|
||||||
|
: `Press ${keyLabel(deafenKey)} during a call to mute your speakers.`
|
||||||
|
}${
|
||||||
!isSafeGlobalToggleKey(deafenKey)
|
!isSafeGlobalToggleKey(deafenKey)
|
||||||
? ` A letter, digit or Space only works in the call view — on a chat screen typing takes priority${
|
? ` A letter, digit or Space only works in the call view — on a chat screen typing takes priority${
|
||||||
isTauriEnv() && globalCallHotkeys ? ' and other apps are never watched' : ''
|
isTauriEnv() && globalCallHotkeys ? ' and other apps are never watched' : ''
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
} from '../../../client/initMatrix';
|
} from '../../../client/initMatrix';
|
||||||
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
|
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
|
||||||
import { clearPlaintextCaches } from '../../state/plaintextCaches';
|
import { clearPlaintextCaches } from '../../state/plaintextCaches';
|
||||||
|
import { clearSoundboardClipCache } from '../../utils/soundboardClips';
|
||||||
|
import { clearSharedPayload } from '../../../swShare';
|
||||||
import { SplashScreen } from '../../components/splash-screen';
|
import { SplashScreen } from '../../components/splash-screen';
|
||||||
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
||||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
||||||
@@ -172,6 +174,10 @@ const useLogoutListener = (mx?: MatrixClient) => {
|
|||||||
// (translation, scheduled messages, recent searches/forwards/gifs/
|
// (translation, scheduled messages, recent searches/forwards/gifs/
|
||||||
// stickers, nav paths) — wipe them on server-forced logout too.
|
// stickers, nav paths) — wipe them on server-forced logout too.
|
||||||
clearPlaintextCaches(mx?.getUserId() ?? undefined);
|
clearPlaintextCaches(mx?.getUserId() ?? undefined);
|
||||||
|
// [Gitea #57] As on manual logout: no decrypted clip bytes via blob: URLs.
|
||||||
|
clearSoundboardClipCache();
|
||||||
|
// [Gitea #267] As on manual logout: drop files stashed by the share target.
|
||||||
|
await clearSharedPayload().catch(() => undefined);
|
||||||
// Remove only the session credential keys — NOT settings, drafts, and
|
// Remove only the session credential keys — NOT settings, drafts, and
|
||||||
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
||||||
// window.localStorage.clear() is reserved for the explicit reset path.
|
// window.localStorage.clear() is reserved for the explicit reset path.
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ export function Share() {
|
|||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="L400">Shared</Text>
|
<Text size="L400">Shared</Text>
|
||||||
|
{/* [Gitea #267] Not everything that arrives here came from the
|
||||||
|
user's share sheet; say so before they pick a room. */}
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
This came from another app or website. Check it before you send it.
|
||||||
|
</Text>
|
||||||
{shared.files.map((f) => (
|
{shared.files.map((f) => (
|
||||||
<Box key={f.name} alignItems="Center" gap="200">
|
<Box key={f.name} alignItems="Center" gap="200">
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ const api = {
|
|||||||
: Object.getOwnPropertyDescriptor(target, key),
|
: Object.getOwnPropertyDescriptor(target, key),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
|
const { clearPlaintextCaches, plaintextCachesWiped, WIDGET_CONSENT_KEY_PREFIX } =
|
||||||
|
await import('./plaintextCaches');
|
||||||
|
const { WIDGET_CONSENT_PREFIX, widgetConsentKey } =
|
||||||
|
await import('../features/room/widgets/widgetUtils');
|
||||||
|
|
||||||
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
||||||
store.clear();
|
store.clear();
|
||||||
@@ -104,3 +107,17 @@ test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe
|
|||||||
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
|
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
|
||||||
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
|
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[#266] clearPlaintextCaches sweeps remembered widget approvals', () => {
|
||||||
|
assert.equal(WIDGET_CONSENT_KEY_PREFIX, WIDGET_CONSENT_PREFIX, 'prefix kept in sync');
|
||||||
|
store.clear();
|
||||||
|
const consent = widgetConsentKey('!room:server', 'widget-1');
|
||||||
|
store.set(consent, '{"url":"https://pad.example","allowed":[],"denied":[]}');
|
||||||
|
store.set('lotus.widgetConsentNotAKey', 'kept');
|
||||||
|
removed.length = 0;
|
||||||
|
|
||||||
|
clearPlaintextCaches('@me:server');
|
||||||
|
|
||||||
|
assert.ok(removed.includes(consent), 'consent cleared');
|
||||||
|
assert.ok(store.has('lotus.widgetConsentNotAKey'), 'unrelated key kept');
|
||||||
|
});
|
||||||
|
|||||||
@@ -139,6 +139,31 @@ const clearAccountScopedFlags = (userId?: string): void => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #266] Remembered widget approvals (`lotus.widgetConsent.<room>.<widget>`,
|
||||||
|
* widgetUtils.ts) would otherwise apply silently to the next account on this
|
||||||
|
* device, and their keys list the previous account's rooms. Kept as a string
|
||||||
|
* here so logout doesn't pull the widget code into the entry chunk; a test
|
||||||
|
* checks it matches WIDGET_CONSENT_PREFIX.
|
||||||
|
*/
|
||||||
|
export const WIDGET_CONSENT_KEY_PREFIX = 'lotus.widgetConsent.';
|
||||||
|
const clearWidgetConsents = (): void => {
|
||||||
|
let keys: string[];
|
||||||
|
try {
|
||||||
|
keys = Object.keys(localStorage);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (!key.startsWith(WIDGET_CONSENT_KEY_PREFIX)) return;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const clearPlaintextCaches = (userId?: string): void => {
|
export const clearPlaintextCaches = (userId?: string): void => {
|
||||||
wiped = true;
|
wiped = true;
|
||||||
clearTranslationCache();
|
clearTranslationCache();
|
||||||
@@ -153,5 +178,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
|||||||
clearStatusMessage();
|
clearStatusMessage();
|
||||||
clearCallVolumesAndSyncMeta(userId);
|
clearCallVolumesAndSyncMeta(userId);
|
||||||
clearAccountScopedFlags(userId);
|
clearAccountScopedFlags(userId);
|
||||||
|
clearWidgetConsents();
|
||||||
if (userId) clearNavToActivePathStore(userId);
|
if (userId) clearNavToActivePathStore(userId);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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', () => {
|
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
|
||||||
for (const v of [
|
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', ['*.evil.com']), false);
|
||||||
assert.equal(matchesAnyGlob('lotusguild.org', []), 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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, /<b>hi<\/b> & 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" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -109,3 +109,10 @@ describe('stripTrackingParamsInHtml', () => {
|
|||||||
assert.equal(stripTrackingParamsInHtml(html), html);
|
assert.equal(stripTrackingParamsInHtml(html), html);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('[#280] Reddit keeps context= (it shows the parent comments) but drops share_id', () => {
|
||||||
|
assert.equal(
|
||||||
|
stripTrackingParams('https://www.reddit.com/r/x/comments/abc/def/xyz/?context=3&share_id=1'),
|
||||||
|
'https://www.reddit.com/r/x/comments/abc/def/xyz/?context=3',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ const HOST_PARAMS: Array<{ hosts: string[]; params: string[]; prefixes?: string[
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
hosts: ['reddit.com'],
|
hosts: ['reddit.com'],
|
||||||
params: ['share_id', 'rdt', 'context'],
|
// [Gitea #280] Not `context`: on a comment permalink, ?context=N shows the
|
||||||
|
// N parent comments, i.e. the conversation the sender meant to share.
|
||||||
|
params: ['share_id', 'rdt'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hosts: ['tiktok.com'],
|
hosts: ['tiktok.com'],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { pushSessionToSW } from '../sw-session';
|
|||||||
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
|
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
|
||||||
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
||||||
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
|
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
|
||||||
|
import { clearSharedPayload } from '../swShare';
|
||||||
|
|
||||||
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
|
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
|
||||||
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
|
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
|
||||||
@@ -146,6 +147,9 @@ export const logoutClient = async (mx: MatrixClient) => {
|
|||||||
// [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard
|
// [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard
|
||||||
// clip bytes stay reachable via live blob: URLs until the reload otherwise.
|
// clip bytes stay reachable via live blob: URLs until the reload otherwise.
|
||||||
clearSoundboardClipCache();
|
clearSoundboardClipCache();
|
||||||
|
// [Gitea #267] Files stashed by the share target stay in Cache Storage until
|
||||||
|
// /share is opened; a shared photo must not outlive the logout.
|
||||||
|
await clearSharedPayload().catch(() => undefined);
|
||||||
// Remove only the session credential keys, preserving user preferences and
|
// Remove only the session credential keys, preserving user preferences and
|
||||||
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
||||||
removeFallbackSession();
|
removeFallbackSession();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference lib="WebWorker" />
|
/// <reference lib="WebWorker" />
|
||||||
|
|
||||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||||
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||||
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
||||||
|
|
||||||
export type {};
|
export type {};
|
||||||
@@ -249,6 +249,11 @@ self.addEventListener('fetch', (event: FetchEvent) => {
|
|||||||
// share_target). Stash the form and bounce to the in-app /share page.
|
// share_target). Stash the form and bounce to the in-app /share page.
|
||||||
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
|
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
|
||||||
const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length);
|
const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length);
|
||||||
|
// [Gitea #267] A cross-site form posting here is not a share: drop it.
|
||||||
|
if (!isTrustedShareRequest(event.request.headers, self.location.origin)) {
|
||||||
|
event.respondWith(Response.redirect(`${base}/`, 303));
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
|
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-1
@@ -1,6 +1,6 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { shareDraftText } from './swShare';
|
import { isTrustedShareRequest, shareDraftText } from './swShare';
|
||||||
|
|
||||||
const base = { files: [], receivedAt: 0 };
|
const base = { files: [], receivedAt: 0 };
|
||||||
|
|
||||||
@@ -25,3 +25,14 @@ test('shareDraftText drops a title/url the text already contains', () => {
|
|||||||
assert.equal(shareDraftText({ ...base, url: 'https://x.test' }), 'https://x.test');
|
assert.equal(shareDraftText({ ...base, url: 'https://x.test' }), 'https://x.test');
|
||||||
assert.equal(shareDraftText(base), '');
|
assert.equal(shareDraftText(base), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[#267] a share another website submitted is refused', () => {
|
||||||
|
const app = 'https://chat.lotusguild.org';
|
||||||
|
const h = (origin?: string) => new Headers(origin ? { Origin: origin } : {});
|
||||||
|
assert.equal(isTrustedShareRequest(h(), app), true, 'no Origin (OS share sheet)');
|
||||||
|
assert.equal(isTrustedShareRequest(h('null'), app), true, 'opaque origin (OS share sheet)');
|
||||||
|
assert.equal(isTrustedShareRequest(h(app), app), true, 'the app itself');
|
||||||
|
assert.equal(isTrustedShareRequest(h('https://evil.example'), app), false);
|
||||||
|
assert.equal(isTrustedShareRequest(h('https://chat.lotusguild.org.evil.example'), app), false);
|
||||||
|
assert.equal(isTrustedShareRequest(h('http://chat.lotusguild.org'), app), false, 'other scheme');
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,27 @@ export type SharePayload = {
|
|||||||
receivedAt: number;
|
receivedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #267] Refuse a share that another website submitted: any site could
|
||||||
|
* otherwise auto-submit a cross-site form here and drop attacker-chosen text
|
||||||
|
* and files into the "Share to…" picker as if the user had shared them.
|
||||||
|
*
|
||||||
|
* The worker can't use `Sec-Fetch-Site`: fetch-metadata headers are added
|
||||||
|
* after the service worker and never appear on `event.request` (checked in
|
||||||
|
* Chromium). `Origin` does: a cross-site form carries the site's origin. The
|
||||||
|
* OS share sheet's POST has no web initiator, so its Origin is `null` or
|
||||||
|
* absent, and both stay accepted. A page can also produce `Origin: null`
|
||||||
|
* (sandboxed frame, no-referrer), which is why /share also says the content
|
||||||
|
* came from elsewhere.
|
||||||
|
*/
|
||||||
|
export const isTrustedShareRequest = (
|
||||||
|
headers: Pick<Headers, 'get'>,
|
||||||
|
selfOrigin: string,
|
||||||
|
): boolean => {
|
||||||
|
const origin = headers.get('Origin');
|
||||||
|
return origin === null || origin === 'null' || origin === selfOrigin;
|
||||||
|
};
|
||||||
|
|
||||||
/** Worker side: stash the POSTed form and answer with a redirect to /share. */
|
/** Worker side: stash the POSTed form and answer with a redirect to /share. */
|
||||||
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
|
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
|||||||
Reference in New Issue
Block a user