Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f897b6d1 |
@@ -11,7 +11,7 @@ import {
|
||||
import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils';
|
||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||
@@ -46,12 +46,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
if (!container) return undefined;
|
||||
const current = widgetRef.current;
|
||||
|
||||
const completeUrl = current.getCompleteUrl({
|
||||
currentUserId: mx.getSafeUserId(),
|
||||
widgetRoomId: room.roomId,
|
||||
deviceId: mx.getDeviceId() ?? undefined,
|
||||
baseUrl: mx.baseUrl,
|
||||
});
|
||||
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).
|
||||
|
||||
@@ -27,7 +27,13 @@ import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
|
||||
import {
|
||||
generateWidgetId,
|
||||
validateWidgetUrl,
|
||||
widgetCompleteUrl,
|
||||
widgetPopoutUrl,
|
||||
WidgetUrlError,
|
||||
} from './widgetUtils';
|
||||
|
||||
const urlErrorMessage = (err: WidgetUrlError): string => {
|
||||
switch (err) {
|
||||
@@ -60,6 +66,14 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const viewing = widgets.find((w) => w.id === viewingId) ?? null;
|
||||
// [Gitea #205] Open a widget in its own window, beside the chat. It runs
|
||||
// standalone there (no widget API), as in Element's popout.
|
||||
const popoutUrl = (widget: (typeof widgets)[number]) =>
|
||||
widgetPopoutUrl(widgetCompleteUrl(widget, mx, room.roomId), window.location.origin);
|
||||
const popout = (widget: (typeof widgets)[number]) => {
|
||||
const url = popoutUrl(widget);
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const handleAdd: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||||
evt.preventDefault();
|
||||
@@ -144,7 +158,13 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
<Box grow="Yes" className={css.WidgetsPanelContent}>
|
||||
{viewing ? (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box shrink="No" style={{ padding: config.space.S200 }}>
|
||||
<Box
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
gap="200"
|
||||
style={{ padding: config.space.S200 }}
|
||||
>
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
@@ -157,6 +177,17 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
{viewing.name || 'Widget'}
|
||||
</Text>
|
||||
</Button>
|
||||
{popoutUrl(viewing) && (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Background"
|
||||
aria-label={`Open ${viewing.name || 'widget'} in a new window`}
|
||||
onClick={() => popout(viewing)}
|
||||
>
|
||||
<Icon size="100" src={Icons.External} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<RoomWidgetView room={room} widget={viewing} />
|
||||
</Box>
|
||||
@@ -184,6 +215,17 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
{widget.name || widget.templateUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
{popoutUrl(widget) && (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Background"
|
||||
aria-label={`Open ${widget.name || 'widget'} in a new window`}
|
||||
onClick={() => popout(widget)}
|
||||
>
|
||||
<Icon size="100" src={Icons.External} />
|
||||
</IconButton>
|
||||
)}
|
||||
{canModify && (
|
||||
<IconButton
|
||||
size="300"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
generateWidgetId,
|
||||
classifyWidgetCapabilities,
|
||||
loadWidgetConsent,
|
||||
widgetPopoutUrl,
|
||||
} from './widgetUtils';
|
||||
|
||||
const APP = 'https://chat.lotusguild.org';
|
||||
@@ -122,3 +123,17 @@ test('[#266] space structure, call membership and Lotus room policy can never be
|
||||
assert.equal(auto.size, 0);
|
||||
assert.deepEqual(ask, []);
|
||||
});
|
||||
|
||||
test('[#205] widgetPopoutUrl: only cross-origin https opens in a new window', () => {
|
||||
// Built at runtime so the no-script-url lint rule doesn't flag the test data.
|
||||
const scriptUrl = ['javascript', 'alert(1)'].join(':');
|
||||
assert.equal(
|
||||
widgetPopoutUrl('https://pad.example.org/p/room', APP),
|
||||
'https://pad.example.org/p/room',
|
||||
);
|
||||
assert.equal(widgetPopoutUrl('https://chat.lotusguild.org/evil', APP), null, 'same origin');
|
||||
assert.equal(widgetPopoutUrl('http://pad.example.org/', APP), null, 'not https');
|
||||
assert.equal(widgetPopoutUrl(scriptUrl, APP), null, 'script URL');
|
||||
assert.equal(widgetPopoutUrl('data:text/html,<b>x</b>', APP), null, 'data:');
|
||||
assert.equal(widgetPopoutUrl('not a url', APP), null);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
import {
|
||||
Capability,
|
||||
EventDirection,
|
||||
EventKind,
|
||||
MatrixCapabilities,
|
||||
type Widget,
|
||||
WidgetEventCapability,
|
||||
} from 'matrix-widget-api';
|
||||
|
||||
@@ -48,6 +50,29 @@ export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean
|
||||
}
|
||||
};
|
||||
|
||||
/** The widget's URL with its template variables filled in for this viewer. */
|
||||
export const widgetCompleteUrl = (widget: Widget, mx: MatrixClient, roomId: string): string =>
|
||||
widget.getCompleteUrl({
|
||||
currentUserId: mx.getSafeUserId(),
|
||||
widgetRoomId: roomId,
|
||||
deviceId: mx.getDeviceId() ?? undefined,
|
||||
baseUrl: mx.baseUrl,
|
||||
});
|
||||
|
||||
/**
|
||||
* [Gitea #205] The URL to open a widget in its own window, or null when it
|
||||
* mustn't be opened: only cross-origin https (room state can carry anything,
|
||||
* e.g. a `javascript:` URL from another client, whose origin is "null").
|
||||
*/
|
||||
export const widgetPopoutUrl = (completeUrl: string, appOrigin: string): string | null => {
|
||||
try {
|
||||
const u = new URL(completeUrl);
|
||||
return u.protocol === 'https:' && u.origin !== appOrigin ? u.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const generateWidgetId = (): string =>
|
||||
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user