Files
cinny/src/app/features/room/widgets/widgetUtils.ts
T

172 lines
5.9 KiB
TypeScript
Raw Normal View History

import {
Capability,
EventDirection,
EventKind,
MatrixCapabilities,
WidgetEventCapability,
} from 'matrix-widget-api';
// Capability policy. Benign display capabilities are granted silently. Reading
// or sending events/state in the widget's OWN room can be granted by the user
// through the consent prompt (see classifyWidgetCapabilities). Everything else
// (other rooms' timelines, to-device, account data, uploads, user directory,
// delayed events, TURN servers) is always denied.
export const ALLOWED_WIDGET_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
MatrixCapabilities.AlwaysOnScreen,
MatrixCapabilities.RequiresClient,
MatrixCapabilities.Screenshots,
]);
export const filterWidgetCapabilities = (requested: Set<Capability>): Set<Capability> =>
new Set([...requested].filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap)));
export type WidgetUrlError = 'empty' | 'invalid' | 'not-https' | 'same-origin';
// A widget URL to ADD must be https and NOT our own origin: a same-origin frame
// with allow-same-origin + allow-scripts can break out of the sandbox against us.
export const validateWidgetUrl = (raw: string, appOrigin: string): WidgetUrlError | undefined => {
const trimmed = raw.trim();
if (!trimmed) return 'empty';
let url: URL;
try {
url = new URL(trimmed);
} catch {
return 'invalid';
}
if (url.protocol !== 'https:') return 'not-https';
if (url.origin === appOrigin) return 'same-origin';
return undefined;
};
// Is an already-resolved (complete) widget URL safe to render in a sandboxed
// iframe that carries allow-same-origin? Rejects same-origin URLs (breakout).
export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean => {
try {
return new URL(completeUrl).origin !== appOrigin;
} catch {
return false;
}
};
export const generateWidgetId = (): string =>
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
// State a widget may never write with the user's authority, even if approved:
// these change who can do what in the room, or its encryption/identity.
export const PROTECTED_STATE_TYPES: ReadonlySet<string> = new Set([
'm.room.create',
'm.room.power_levels',
'm.room.join_rules',
'm.room.history_visibility',
'm.room.guest_access',
'm.room.encryption',
'm.room.member',
'm.room.server_acl',
'm.room.tombstone',
'm.room.canonical_alias',
'm.room.third_party_invite',
'im.vector.modular.widgets',
'm.widget',
]);
export type WidgetPermissionRequest = {
capability: Capability;
/** Plain-language description, e.g. "Send messages". */
label: string;
/** Sending as you (vs only reading). Shown with a warning tone. */
sends: boolean;
};
export type ClassifiedCapabilities = {
/** Granted without asking. */
auto: Set<Capability>;
/** Needs the user's OK. */
ask: WidgetPermissionRequest[];
};
const FRIENDLY_EVENT_NAMES: Record<string, string> = {
'm.room.message': 'messages',
'm.reaction': 'reactions',
'm.sticker': 'stickers',
'm.room.redaction': 'message deletions',
'm.room.name': 'the room name',
'm.room.topic': 'the room topic',
'm.room.avatar': 'the room avatar',
'm.room.pinned_events': 'pinned messages',
'm.room.member': 'the member list',
'm.room.power_levels': 'room permissions',
};
const describeEventCapability = (cap: WidgetEventCapability): string => {
const verb = cap.direction === EventDirection.Send ? 'Send' : 'See';
const friendly = FRIENDLY_EVENT_NAMES[cap.eventType];
const what =
friendly ?? `“${cap.eventType}” ${cap.kind === EventKind.State ? 'state' : 'events'}`;
let detail = '';
if (cap.keyStr) {
detail = cap.kind === EventKind.State ? ` (key “${cap.keyStr}”)` : ` of type “${cap.keyStr}”`;
}
if (cap.kind === EventKind.State && cap.direction === EventDirection.Send && friendly) {
return `Change ${friendly}${detail}`;
}
return `${verb} ${what}${detail} in this room`;
};
/**
* Split a widget's requested capabilities into those granted silently and
* those the user may approve. Anything in neither list is denied outright.
*/
export const classifyWidgetCapabilities = (
requested: Iterable<Capability>,
): ClassifiedCapabilities => {
const list = [...requested];
const auto = new Set(list.filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap)));
const ask: WidgetPermissionRequest[] = [];
WidgetEventCapability.findEventCapabilities(list).forEach((cap) => {
if (cap.kind !== EventKind.Event && cap.kind !== EventKind.State) return;
const sends = cap.direction === EventDirection.Send;
if (sends && cap.kind === EventKind.State && PROTECTED_STATE_TYPES.has(cap.eventType)) return;
ask.push({ capability: cap.raw, label: describeEventCapability(cap), sends });
});
return { auto, ask };
};
/**
* Remembered approvals, per viewer (localStorage), keyed by room + widget id and
* tied to the widget's URL: if the URL changes, the widget is asked again.
*/
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
`lotus.widgetConsent.${roomId}.${widgetId}`;
export const loadWidgetConsent = (
roomId: string,
widgetId: string,
url: string,
): StoredWidgetConsent | undefined => {
try {
const raw = localStorage.getItem(widgetConsentKey(roomId, widgetId));
if (!raw) return undefined;
const parsed = JSON.parse(raw) as StoredWidgetConsent;
if (parsed.url !== url || !Array.isArray(parsed.allowed) || !Array.isArray(parsed.denied)) {
return undefined;
}
return parsed;
} catch {
return undefined;
}
};
export const saveWidgetConsent = (
roomId: string,
widgetId: string,
consent: StoredWidgetConsent,
) => {
try {
localStorage.setItem(widgetConsentKey(roomId, widgetId), JSON.stringify(consent));
} catch {
// Storage unavailable: the widget just asks again next time.
}
};