Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bfb8737d6 | ||
|
|
7ad2a94e2e | ||
|
|
14f05a3d5c |
@@ -38,10 +38,14 @@ export function Soundboard({ requestClose }: SoundboardProps) {
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<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">
|
||||
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.
|
||||
{/* [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.'}
|
||||
</Text>
|
||||
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
||||
</Box>
|
||||
|
||||
@@ -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 messages from its context menu.
|
||||
Users with sufficient power level can pin a message from its context menu.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -106,3 +106,19 @@ 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,6 +67,16 @@ 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 = {
|
||||
@@ -137,8 +147,11 @@ 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 =>
|
||||
`lotus.widgetConsent.${roomId}.${widgetId}`;
|
||||
`${WIDGET_CONSENT_PREFIX}${roomId}.${widgetId}`;
|
||||
|
||||
export const loadWidgetConsent = (
|
||||
roomId: string,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
|
||||
import pkg from '../../../../../package.json';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { tauriInvoke } from '../../../hooks/useTauri';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
|
||||
|
||||
@@ -79,51 +78,6 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
|
||||
type AboutProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
/**
|
||||
* [Gitea #276] Which build someone is on: the web deploy's commit and build
|
||||
* date (stamped in by vite.config.js), plus the desktop app's own version from
|
||||
* Tauri, the number a desktop or updater bug report needs.
|
||||
*/
|
||||
function BuildInfo() {
|
||||
const [desktopVersion, setDesktopVersion] = useState<string>();
|
||||
useEffect(() => {
|
||||
tauriInvoke()?.('plugin:app|version')
|
||||
.then((v) => {
|
||||
if (typeof v === 'string') setDesktopVersion(v);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
const commit = __LOTUS_BUILD_COMMIT__;
|
||||
const built = new Date(__LOTUS_BUILD_DATE__);
|
||||
const builtText = Number.isNaN(built.getTime())
|
||||
? undefined
|
||||
: built.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
return (
|
||||
<Box direction="Column" gap="0">
|
||||
{desktopVersion && (
|
||||
<Text size="T200" priority="300">
|
||||
Desktop {desktopVersion}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="T200" priority="300">
|
||||
Web build{' '}
|
||||
{commit !== 'unknown' ? (
|
||||
<a
|
||||
href={`https://code.lotusguild.org/LotusGuild/cinny/commit/${commit}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{commit}
|
||||
</a>
|
||||
) : (
|
||||
commit
|
||||
)}
|
||||
{builtText && ` · built ${builtText}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function About({ requestClose }: AboutProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { support: serverSupport, loading: supportLoading } = useServerSupport();
|
||||
@@ -163,7 +117,6 @@ export function About({ requestClose }: AboutProps) {
|
||||
<Text size="T200">v{pkg.version}</Text>
|
||||
</Box>
|
||||
<Text>A Matrix client for Lotus Guild.</Text>
|
||||
<BuildInfo />
|
||||
</Box>
|
||||
|
||||
<Box gap="200" wrap="Wrap">
|
||||
|
||||
@@ -2089,7 +2089,12 @@ function Calls() {
|
||||
description={
|
||||
deafenBind.error ??
|
||||
(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)
|
||||
? ` 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,6 +33,8 @@ 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';
|
||||
@@ -172,6 +174,10 @@ 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,6 +145,11 @@ 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,7 +26,10 @@ const api = {
|
||||
: 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', () => {
|
||||
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(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 => {
|
||||
wiped = true;
|
||||
clearTranslationCache();
|
||||
@@ -153,5 +178,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
||||
clearStatusMessage();
|
||||
clearCallVolumesAndSyncMeta(userId);
|
||||
clearAccountScopedFlags(userId);
|
||||
clearWidgetConsents();
|
||||
if (userId) clearNavToActivePathStore(userId);
|
||||
};
|
||||
|
||||
@@ -109,3 +109,10 @@ 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,7 +103,9 @@ const HOST_PARAMS: Array<{ hosts: string[]; params: string[]; prefixes?: string[
|
||||
},
|
||||
{
|
||||
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'],
|
||||
|
||||
@@ -9,6 +9,7 @@ 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).
|
||||
@@ -146,6 +147,9 @@ 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();
|
||||
|
||||
Vendored
-4
@@ -1,9 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// [Gitea #276] Set by vite.config.js `define`.
|
||||
declare const __LOTUS_BUILD_COMMIT__: string;
|
||||
declare const __LOTUS_BUILD_DATE__: string;
|
||||
|
||||
declare module 'browser-encrypt-attachment' {
|
||||
export interface EncryptedAttachmentInfo {
|
||||
v: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
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';
|
||||
|
||||
export type {};
|
||||
@@ -249,6 +249,11 @@ 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)),
|
||||
);
|
||||
|
||||
+12
-1
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shareDraftText } from './swShare';
|
||||
import { isTrustedShareRequest, shareDraftText } from './swShare';
|
||||
|
||||
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), '');
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* [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();
|
||||
|
||||
@@ -6,7 +6,6 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import buildConfig from './build.config';
|
||||
|
||||
@@ -273,26 +272,8 @@ const vendorChunks = (id) => {
|
||||
if (id.includes('node_modules/chroma-js')) return 'chroma';
|
||||
};
|
||||
|
||||
// [Gitea #276] Which build is this? The short commit and build time are shown
|
||||
// in Settings → About (web deploys change many times a day while the package
|
||||
// version stays the same). LOTUS_BUILD_COMMIT overrides; no git → "unknown".
|
||||
const buildCommit = (() => {
|
||||
if (process.env.LOTUS_BUILD_COMMIT) return process.env.LOTUS_BUILD_COMMIT.slice(0, 12);
|
||||
try {
|
||||
return execSync('git rev-parse --short=8 HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
|
||||
export default defineConfig({
|
||||
appType: 'spa',
|
||||
define: {
|
||||
__LOTUS_BUILD_COMMIT__: JSON.stringify(buildCommit),
|
||||
__LOTUS_BUILD_DATE__: JSON.stringify(new Date().toISOString()),
|
||||
},
|
||||
publicDir: false,
|
||||
base: buildConfig.base,
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user