Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 d223f847c6 fix(schedule): limit to 24 hours ahead with a clear message (#275)
CI / Build & Quality Checks (pull_request) Successful in 1m43s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m4s
Production Synapse caps delayed events at 24h, but the dialog let any
later time be picked and then showed the raw server error. The date
input now has min/max (today .. now+24h), a too-late time shows "You can
schedule up to 24 hours ahead." as soon as it is picked (and blocks
Schedule), and ORG.MATRIX.MSC4140_DELAY_TOO_LARGE maps to the same text.

Fixes #275

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:20:08 -04:00
18 changed files with 60 additions and 156 deletions
@@ -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>
+22 -4
View File
@@ -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, []);
});
+1 -14
View File
@@ -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' : ''
-6
View File
@@ -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.
-5
View File
@@ -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
+1 -18
View File
@@ -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');
});
-26
View File
@@ -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);
};
+12
View File
@@ -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.');
});
+16
View File
@@ -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
-7
View File
@@ -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',
);
});
+1 -3
View File
@@ -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'],
-4
View File
@@ -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 -6
View File
@@ -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
View File
@@ -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');
});
-21
View File
@@ -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();