From 32e5fec6f3e492f63d30972a4f846f81de4ecba5 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 19:46:06 -0400 Subject: [PATCH] fix(widgets): room widgets render again (bypass broken isValidUrl) matrix-widget-api 1.17.0's WidgetParser rejects every URL (it compares URL.protocol "https:" to "https"), so the widgets panel was always empty. Build Widget objects from the raw state events with a correct scheme check plus the existing origin check. Unit-tested against a real state event. Fixes #15 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../room/widgets/useRoomWidgets.test.ts | 72 +++++++++++++++++++ .../features/room/widgets/useRoomWidgets.ts | 70 ++++++++++++++---- 2 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 src/app/features/room/widgets/useRoomWidgets.test.ts diff --git a/src/app/features/room/widgets/useRoomWidgets.test.ts b/src/app/features/room/widgets/useRoomWidgets.test.ts new file mode 100644 index 000000000..55eba3a0b --- /dev/null +++ b/src/app/features/room/widgets/useRoomWidgets.test.ts @@ -0,0 +1,72 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { MatrixEvent } from 'matrix-js-sdk'; +import { widgetsFromStateEvents } from './useRoomWidgets'; + +const APP = 'https://chat.lotusguild.org'; + +// Minimal fake MatrixEvent, just enough of the surface widgetsFromStateEvents reads. +const fakeEvent = (stateKey: string, sender: string, content: Record) => + ({ + getStateKey: () => stateKey, + getSender: () => sender, + getContent: () => content, + }) as unknown as MatrixEvent; + +test('returns a Widget for a valid im.vector.modular.widgets state event', () => { + // Regression test for matrix-widget-api 1.17.0's broken isValidUrl, which + // compares URL.protocol ("https:") to "https" and rejects every URL, + // making WidgetParser.parseWidgetsFromRoomState always return []. + const events = new Map([ + [ + 'w1', + fakeEvent('w1', '@a:example.org', { + id: 'w1', + type: 'custom', + url: 'https://example.com/widget', + name: 'My Widget', + creatorUserId: '@a:example.org', + }), + ], + ]); + + const widgets = widgetsFromStateEvents(events, APP); + assert.equal(widgets.length, 1); + assert.equal(widgets[0].id, 'w1'); + assert.equal(widgets[0].templateUrl, 'https://example.com/widget'); + assert.equal(widgets[0].creatorUserId, '@a:example.org'); + assert.equal(widgets[0].name, 'My Widget'); +}); + +test('skips removed widgets (empty content)', () => { + const events = new Map([['w1', fakeEvent('w1', '@a:example.org', {})]]); + assert.deepEqual(widgetsFromStateEvents(events, APP), []); +}); + +test('skips non-https and same-origin urls', () => { + const events = new Map([ + [ + 'w1', + fakeEvent('w1', '@a:example.org', { + id: 'w1', + type: 'custom', + url: 'http://example.com/widget', + creatorUserId: '@a:example.org', + }), + ], + [ + 'w2', + fakeEvent('w2', '@a:example.org', { + id: 'w2', + type: 'custom', + url: `${APP}/evil`, + creatorUserId: '@a:example.org', + }), + ], + ]); + assert.deepEqual(widgetsFromStateEvents(events, APP), []); +}); + +test('undefined state map yields no widgets', () => { + assert.deepEqual(widgetsFromStateEvents(undefined, APP), []); +}); diff --git a/src/app/features/room/widgets/useRoomWidgets.ts b/src/app/features/room/widgets/useRoomWidgets.ts index 5541ec5ed..62c5db305 100644 --- a/src/app/features/room/widgets/useRoomWidgets.ts +++ b/src/app/features/room/widgets/useRoomWidgets.ts @@ -1,21 +1,67 @@ -import { Room } from 'matrix-js-sdk'; +import { Room, MatrixEvent } from 'matrix-js-sdk'; import { useMemo } from 'react'; -import { Widget, WidgetParser, IStateEvent } from 'matrix-widget-api'; +import { Widget } from 'matrix-widget-api'; import { StateEvent } from '../../../../types/matrix/room'; -import { useRoomState } from '../../../hooks/useRoomState'; +import { StateKeyToEvents, useRoomState } from '../../../hooks/useRoomState'; +import { isWidgetUrlSafe } from './widgetUtils'; + +/** + * Builds the `Widget` list from raw `im.vector.modular.widgets` state events. + * + * NOTE: we do NOT use `WidgetParser.parseWidgetsFromRoomState` here. In + * matrix-widget-api 1.17.0 its `isValidUrl` compares `URL.protocol` (which is + * always colon-suffixed, e.g. "https:") against the bare strings "http"/ + * "https", so it rejects every URL and the parser always returns []. We build + * the `Widget`s ourselves with a correct scheme check plus the existing + * `isWidgetUrlSafe` origin check. + */ +export const widgetsFromStateEvents = ( + widgetEvents: StateKeyToEvents | undefined, + appOrigin: string, +): Widget[] => { + if (!widgetEvents) return []; + const widgets: Widget[] = []; + Array.from(widgetEvents.values()).forEach((event: MatrixEvent) => { + const content = event.getContent(); + // Removed widgets are represented as an empty content state event. + if (!content || Object.keys(content).length === 0) return; + + const id = event.getStateKey(); + const { type, url, name, data, waitForIframeLoad } = content; + const creatorUserId = content.creatorUserId || event.getSender(); + if (!id || !type || !url || !creatorUserId) return; + + let scheme: string; + try { + scheme = new URL(url).protocol; + } catch { + return; + } + if (scheme !== 'https:') return; + if (!isWidgetUrlSafe(url, appOrigin)) return; + + widgets.push( + new Widget({ + id, + creatorUserId, + type, + url, + name, + data, + waitForIframeLoad, + }), + ); + }); + return widgets; +}; /** * All valid `im.vector.modular.widgets` room widgets, reactive on room state. - * `WidgetParser` drops empty/removed (`{}`) and malformed entries. */ export const useRoomWidgets = (room: Room): Widget[] => { const state = useRoomState(room); - return useMemo(() => { - const widgetEvents = state.get(StateEvent.Widget); - if (!widgetEvents) return []; - const stateEvents = Array.from(widgetEvents.values()).map( - (event) => event.getEffectiveEvent() as unknown as IStateEvent, - ); - return WidgetParser.parseWidgetsFromRoomState(stateEvents); - }, [state]); + return useMemo( + () => widgetsFromStateEvents(state.get(StateEvent.Widget), window.location.origin), + [state], + ); };