Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d223f847c6 |
@@ -38,14 +38,10 @@ export function Soundboard({ requestClose }: SoundboardProps) {
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">
|
||||
{room?.isSpaceRoom() ? 'This space (shared)' : 'This room (shared)'}
|
||||
</Text>
|
||||
<Text size="L400">This room / space (shared)</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{/* [Gitea #279] Only a space's clips are inherited by rooms under it. */}
|
||||
{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.'}
|
||||
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.
|
||||
</Text>
|
||||
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
||||
</Box>
|
||||
|
||||
@@ -20,7 +20,12 @@ import { IContent } from 'matrix-js-sdk';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
||||
import {
|
||||
describeScheduleError,
|
||||
MAX_SCHEDULE_AHEAD_MS,
|
||||
scheduleMessage,
|
||||
SCHEDULE_TOO_FAR_TEXT,
|
||||
} from '../../utils/scheduledMessages';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import {
|
||||
toLocalDate,
|
||||
@@ -121,7 +126,13 @@ export function ScheduleMessageModal({
|
||||
return;
|
||||
}
|
||||
const diffMs = sendAt.getTime() - Date.now();
|
||||
if (diffMs < 60_000) {
|
||||
// [Gitea #275] Say so as soon as a too-late time is picked, not on submit.
|
||||
const tooFar = diffMs > MAX_SCHEDULE_AHEAD_MS;
|
||||
setError((e) => {
|
||||
if (tooFar) return SCHEDULE_TOO_FAR_TEXT;
|
||||
return e === SCHEDULE_TOO_FAR_TEXT ? null : e;
|
||||
});
|
||||
if (diffMs < 60_000 || tooFar) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
@@ -149,6 +160,10 @@ export function ScheduleMessageModal({
|
||||
setError('Scheduled time must be at least 1 minute in the future.');
|
||||
return;
|
||||
}
|
||||
if (diffMs > MAX_SCHEDULE_AHEAD_MS) {
|
||||
setError(SCHEDULE_TOO_FAR_TEXT);
|
||||
return;
|
||||
}
|
||||
if (!messageText.trim()) {
|
||||
setError('Please enter a message to schedule.');
|
||||
return;
|
||||
@@ -162,7 +177,7 @@ export function ScheduleMessageModal({
|
||||
onScheduled(delayId, sendAt.getTime(), content);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
|
||||
setError(describeScheduleError(err));
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
@@ -247,6 +262,8 @@ export function ScheduleMessageModal({
|
||||
<input
|
||||
id="schedule-date"
|
||||
type="date"
|
||||
min={toLocalDate(new Date())}
|
||||
max={toLocalDate(new Date(Date.now() + MAX_SCHEDULE_AHEAD_MS))}
|
||||
value={dateValue}
|
||||
onChange={(e) => setDateValue(e.target.value)}
|
||||
style={pickerInputStyle(color, config)}
|
||||
@@ -294,7 +311,8 @@ export function ScheduleMessageModal({
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
(dateValue || timeValue) && (
|
||||
(dateValue || timeValue) &&
|
||||
error !== SCHEDULE_TOO_FAR_TEXT && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Must be at least 1 minute in the future
|
||||
</Text>
|
||||
|
||||
@@ -531,7 +531,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
||||
No Pinned Messages
|
||||
</Text>
|
||||
<Text size="T400" align="Center">
|
||||
Users with sufficient power level can pin a message from its context menu.
|
||||
Users with sufficient power level can pin a messages from its context menu.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -106,19 +106,3 @@ test('classifyWidgetCapabilities: reading protected state is fine, changing unpr
|
||||
test('loadWidgetConsent tolerates missing storage', () => {
|
||||
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,16 +67,6 @@ export const PROTECTED_STATE_TYPES: ReadonlySet<string> = new Set([
|
||||
'm.room.third_party_invite',
|
||||
'im.vector.modular.widgets',
|
||||
'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 = {
|
||||
@@ -147,11 +137,8 @@ export const classifyWidgetCapabilities = (
|
||||
*/
|
||||
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 =>
|
||||
`${WIDGET_CONSENT_PREFIX}${roomId}.${widgetId}`;
|
||||
`lotus.widgetConsent.${roomId}.${widgetId}`;
|
||||
|
||||
export const loadWidgetConsent = (
|
||||
roomId: string,
|
||||
|
||||
@@ -2089,12 +2089,7 @@ function Calls() {
|
||||
description={
|
||||
deafenBind.error ??
|
||||
(deafenHotkey
|
||||
? `${
|
||||
// [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.`
|
||||
}${
|
||||
? `Press ${keyLabel(deafenKey)} during a call to mute your speakers. Press Escape to cancel rebind.${
|
||||
!isSafeGlobalToggleKey(deafenKey)
|
||||
? ` 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' : ''
|
||||
|
||||
@@ -33,8 +33,6 @@ import {
|
||||
} from '../../../client/initMatrix';
|
||||
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
|
||||
import { clearPlaintextCaches } from '../../state/plaintextCaches';
|
||||
import { clearSoundboardClipCache } from '../../utils/soundboardClips';
|
||||
import { clearSharedPayload } from '../../../swShare';
|
||||
import { SplashScreen } from '../../components/splash-screen';
|
||||
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
||||
@@ -174,10 +172,6 @@ const useLogoutListener = (mx?: MatrixClient) => {
|
||||
// (translation, scheduled messages, recent searches/forwards/gifs/
|
||||
// stickers, nav paths) — wipe them on server-forced logout too.
|
||||
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
|
||||
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
||||
// window.localStorage.clear() is reserved for the explicit reset path.
|
||||
|
||||
@@ -145,11 +145,6 @@ export function Share() {
|
||||
<Box direction="Column" gap="400">
|
||||
<Box direction="Column" gap="200">
|
||||
<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) => (
|
||||
<Box key={f.name} alignItems="Center" gap="200">
|
||||
<Icon
|
||||
|
||||
@@ -26,10 +26,7 @@ const api = {
|
||||
: Object.getOwnPropertyDescriptor(target, key),
|
||||
});
|
||||
|
||||
const { clearPlaintextCaches, plaintextCachesWiped, WIDGET_CONSENT_KEY_PREFIX } =
|
||||
await import('./plaintextCaches');
|
||||
const { WIDGET_CONSENT_PREFIX, widgetConsentKey } =
|
||||
await import('../features/room/widgets/widgetUtils');
|
||||
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
|
||||
|
||||
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
||||
store.clear();
|
||||
@@ -107,17 +104,3 @@ 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(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,31 +139,6 @@ 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 => {
|
||||
wiped = true;
|
||||
clearTranslationCache();
|
||||
@@ -178,6 +153,5 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
||||
clearStatusMessage();
|
||||
clearCallVolumesAndSyncMeta(userId);
|
||||
clearAccountScopedFlags(userId);
|
||||
clearWidgetConsents();
|
||||
if (userId) clearNavToActivePathStore(userId);
|
||||
};
|
||||
|
||||
@@ -179,3 +179,15 @@ test('groupScheduledByRoom: groups and sorts by sendAt', () => {
|
||||
);
|
||||
assert.equal(g.get('!2')?.length, 1);
|
||||
});
|
||||
|
||||
test('[#275] the server limit maps to friendly text; other errors keep theirs', async () => {
|
||||
const { describeScheduleError, SCHEDULE_TOO_FAR_TEXT, MAX_SCHEDULE_AHEAD_MS } =
|
||||
await import('./scheduledMessages');
|
||||
assert.equal(MAX_SCHEDULE_AHEAD_MS, 86_400_000);
|
||||
const tooLarge = Object.assign(new Error('The requested delay (172800000ms) exceeds…'), {
|
||||
errcode: 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE',
|
||||
});
|
||||
assert.equal(describeScheduleError(tooLarge), SCHEDULE_TOO_FAR_TEXT);
|
||||
assert.equal(describeScheduleError(new Error('Boom')), 'Boom');
|
||||
assert.equal(describeScheduleError('nope'), 'Failed to schedule message.');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
||||
import type { ScheduledMessage } from '../state/scheduledMessages';
|
||||
|
||||
/**
|
||||
* [Gitea #275] Production Synapse caps delayed events at
|
||||
* `max_event_delay_duration: 24h`; a later time is rejected with
|
||||
* ORG.MATRIX.MSC4140_DELAY_TOO_LARGE. The dialog enforces the same limit and
|
||||
* the server's error maps to the same text, in case the limit changes.
|
||||
*/
|
||||
export const MAX_SCHEDULE_AHEAD_MS = 24 * 60 * 60 * 1000;
|
||||
export const SCHEDULE_TOO_FAR_TEXT = 'You can schedule up to 24 hours ahead.';
|
||||
|
||||
/** A user-facing sentence for a failed schedule. */
|
||||
export const describeScheduleError = (err: unknown): string => {
|
||||
const errcode = (err as { errcode?: unknown } | null)?.errcode;
|
||||
if (errcode === 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE') return SCHEDULE_TOO_FAR_TEXT;
|
||||
return err instanceof Error ? err.message : 'Failed to schedule message.';
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedule a message via MSC4140 (delayed messages).
|
||||
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
|
||||
|
||||
@@ -109,10 +109,3 @@ describe('stripTrackingParamsInHtml', () => {
|
||||
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,9 +103,7 @@ const HOST_PARAMS: Array<{ hosts: string[]; params: string[]; prefixes?: string[
|
||||
},
|
||||
{
|
||||
hosts: ['reddit.com'],
|
||||
// [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'],
|
||||
params: ['share_id', 'rdt', 'context'],
|
||||
},
|
||||
{
|
||||
hosts: ['tiktok.com'],
|
||||
|
||||
@@ -9,7 +9,6 @@ import { pushSessionToSW } from '../sw-session';
|
||||
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
|
||||
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
||||
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
|
||||
import { clearSharedPayload } from '../swShare';
|
||||
|
||||
// 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).
|
||||
@@ -147,9 +146,6 @@ export const logoutClient = async (mx: MatrixClient) => {
|
||||
// [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard
|
||||
// clip bytes stay reachable via live blob: URLs until the reload otherwise.
|
||||
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
|
||||
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
||||
removeFallbackSession();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||
import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
||||
|
||||
export type {};
|
||||
@@ -249,11 +249,6 @@ self.addEventListener('fetch', (event: FetchEvent) => {
|
||||
// share_target). Stash the form and bounce to the in-app /share page.
|
||||
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
|
||||
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(
|
||||
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
|
||||
);
|
||||
|
||||
+1
-12
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isTrustedShareRequest, shareDraftText } from './swShare';
|
||||
import { shareDraftText } from './swShare';
|
||||
|
||||
const base = { files: [], receivedAt: 0 };
|
||||
|
||||
@@ -25,14 +25,3 @@ 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), '');
|
||||
});
|
||||
|
||||
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,27 +20,6 @@ export type SharePayload = {
|
||||
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. */
|
||||
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
|
||||
const form = await request.formData();
|
||||
|
||||
Reference in New Issue
Block a user