import React, { useEffect, useRef, useState } from 'react'; import { Box, Icon, Icons, Text, color } from 'folds'; import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, RoomEventHandlerMap, RoomStateEvent, } from 'matrix-js-sdk'; import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { GeneralWidgetDriver } from './GeneralWidgetDriver'; import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils'; import { restrictWidgetMessages } from '../../../plugins/widgetTransport'; import { WidgetPermissionPrompt } from './WidgetPermissionPrompt'; import { isFromFilteredTimelineSet } from '../../../utils/room'; type PendingAsk = { requests: WidgetPermissionRequest[]; origin: string; resolve: (allowed: Set, remember: boolean) => void; }; type RoomWidgetViewProps = { room: Room; widget: Widget; }; // Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets // that wait on the client handshake load). Display-only access is granted // silently; reading/sending in this room is asked for (WidgetPermissionPrompt) // and, once granted, the room's live events are fed to the widget. // Re-mounts only when the widget id or its (template) URL changes — not on every // unrelated room-state update — so viewing a widget doesn't reload constantly. export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) { const mx = useMatrixClient(); const containerRef = useRef(null); const widgetRef = useRef(widget); widgetRef.current = widget; const [blocked, setBlocked] = useState(false); const [pendingAsk, setPendingAsk] = useState(); useEffect(() => { const container = containerRef.current; if (!container) return undefined; const current = widgetRef.current; const completeUrl = widgetCompleteUrl(current, mx, room.roomId); // Security: never render a same-origin widget with allow-same-origin (a // same-origin frame could break out of the sandbox against our own origin). if (!isWidgetUrlSafe(completeUrl, window.location.origin)) { setBlocked(true); return undefined; } setBlocked(false); const iframe = document.createElement('iframe'); iframe.title = current.name || 'Widget'; iframe.sandbox.value = 'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads'; iframe.allow = 'autoplay; clipboard-write;'; iframe.src = completeUrl; iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; container.append(iframe); let origin = completeUrl; try { origin = new URL(completeUrl).host; } catch { // isWidgetUrlSafe already rejected unparsable URLs } let cancelAsk: (() => void) | undefined; const driver = new GeneralWidgetDriver( mx, room.roomId, { id: current.id, url: current.templateUrl }, (requests) => new Promise((resolve) => { let settled = false; const done = (allowed: Set, remember: boolean) => { if (settled) return; settled = true; cancelAsk = undefined; setPendingAsk(undefined); resolve({ allowed, remember }); }; cancelAsk = () => done(new Set(), false); setPendingAsk({ requests, origin, resolve: done }); }), ); const clientApi = new ClientWidgetApi(current, iframe, driver); // Only messages from this widget's own frame and origin are handled, so a // widget can't impersonate another one (or the call). restrictWidgetMessages(clientApi, iframe, current.origin); clientApi.setViewedRoomId(room.roomId); // Live room events for widgets that were allowed to see them // (ClientWidgetApi drops anything the widget has no capability for). const feed = (ev: MatrixEvent) => { if (ev.getRoomId() !== room.roomId) return; if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined); }; const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => { if (toStart || removed || !data?.liveEvent) return; if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed); else feed(ev); }; const onState = (ev: MatrixEvent) => { if (ev.getRoomId() !== room.roomId) return; clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined); }; mx.on(RoomEvent.Timeline, onTimeline); mx.on(RoomStateEvent.Events, onState); return () => { mx.off(RoomEvent.Timeline, onTimeline); mx.off(RoomStateEvent.Events, onState); cancelAsk?.(); clientApi.stop(); iframe.remove(); }; }, [mx, room.roomId, widget.id, widget.templateUrl]); if (blocked) { return ( This widget can't be loaded because its URL is on this app's own origin. ); } const addedBy = widget.creatorUserId ? (room.getMember(widget.creatorUserId)?.rawDisplayName ?? widget.creatorUserId) : undefined; return ( <> {pendingAsk && ( )} ); }