import BaseView from 'app/client/components/BaseView'; import {GristDoc} from 'app/client/components/GristDoc'; import {hooks} from 'app/client/Hooks'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {makeTestId} from 'app/client/lib/domUtils'; import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; import {AccessLevel, isSatisfied} from 'app/common/CustomWidget'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions'; import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes'; import {Theme} from 'app/common/ThemePrefs'; import {AccessTokenOptions, CursorPos, CustomSectionAPI, GristDocAPI, GristView, InteractionOptionsRequest, WidgetAPI, WidgetColumnMap} from 'app/plugin/grist-plugin-api'; import {MsgType, Rpc} from 'grain-rpc'; import {Computed, Disposable, dom, Observable} from 'grainjs'; import noop = require('lodash/noop'); import debounce = require('lodash/debounce'); import isEqual = require('lodash/isEqual'); import flatMap = require('lodash/flatMap'); const testId = makeTestId('test-custom-widget-'); /** * This file contains a WidgetFrame and all its components. * * WidgetFrame embeds an external Custom Widget (external webpage) in an iframe. It is used on a CustomView, * to display widget content, and on the configuration screen to display widget's configuration screen. * * Beside exposing widget content, it also exposes some of the API's that Grist offers via grist-rpc. * API are defined in the core/app/plugin/grist-plugin-api.ts. */ const G = getBrowserGlobals('window'); /** * Options for WidgetFrame */ export interface WidgetFrameOptions { /** * Url of external page. Iframe is rebuild each time the URL changes. */ url: string; /** * Assigned access level. Iframe is rebuild each time access level is changed. */ access: AccessLevel; /** * If document is in readonly mode. */ readonly: boolean; /** * If set, show the iframe after `grist.ready()`. * * Currently, this is only used to defer showing a widget until it has had * a chance to apply the Grist theme. */ showAfterReady?: boolean; /** * Handler for the settings initialized message. * * Currently, this is only used to defer showing a widget until it has had * a chance to apply the Grist theme. */ onSettingsInitialized: () => void; /** * Optional callback to configure exposed API. */ configure?: (frame: WidgetFrame) => void; /** * Optional handler to modify the iframe. */ onElem?: (iframe: HTMLIFrameElement) => void; } /** * Iframe that embeds Custom Widget page and exposes Grist API. */ export class WidgetFrame extends DisposableWithEvents { // A grist-rpc object, encapsulated to prevent direct access. private _rpc: Rpc; // Created iframe element, used to receive and post messages via Rpc private _iframe: HTMLIFrameElement | null; // If widget called ready() method, this will be set to true. private _readyCalled = Observable.create(this, false); // Whether the iframe is visible. private _visible = Observable.create(this, !this._options.showAfterReady); constructor(private _options: WidgetFrameOptions) { super(); _options.access = _options.access || AccessLevel.none; // Build RPC object and connect it to iframe. this._rpc = new Rpc({}); // queue until iframe's content emit ready() message this._rpc.queueOutgoingUntilReadyMessage(); // Register outgoing message handler. this._rpc.setSendMessage(msg => this._iframe?.contentWindow!.postMessage(msg, '*')); // Register incoming message handler. const listener = this._onMessage.bind(this); // 'message' is an event's name used by Rpc in window to iframe communication. G.window.addEventListener('message', listener); this.onDispose(() => { // Stop listening for events from the iframe. G.window.removeEventListener('message', listener); // Stop sending messages to the iframe. this._rpc.setSendMessage(noop); }); // Call custom configuration handler. _options.configure?.(this); } /** * Attach an EventSource with desired access level. */ public useEvents(source: IEventSource, access: AccessChecker) { // Wrap event handler with access check. const handler = async (data: any) => { if (access.check(this._options.access)) { await this._rpc.postMessage(data); } }; this.listenTo(source, 'event', handler); // Give EventSource a chance to attach to WidgetFrame events. source.attach(this); } /** * Exposes API for Custom Widget. * TODO: add ts-interface support. Currently all APIs are written in typescript, * so those checks are not that needed. */ public exposeAPI(name: string, api: any, access: AccessChecker) { this._rpc.registerImpl(name, wrapObject(api, access, this._options.access)); this.onDispose(() => this._rpc.unregisterImpl(name)); } /** * Expose a method for Custom Widget. */ public exposeMethod(name: string, handler: (...args: any[]) => any, access: AccessChecker) { this._rpc.registerFunc(name, (...args: any[]) => { if (access.check(this._options.access, 'invoke')) { return handler(...args); } else { throwError(this._options.access); } }); } /** * Make configure call to the widget. Widget should open some configuration screen or ignore it. */ public editOptions() { return this.callRemote('editOptions'); } /** * Call remote function that is exposed by the widget. */ public callRemote(name: string, ...args: any[]) { return this._rpc.callRemoteFunc(name, ...args); } public buildDom() { // Append access level to query string. const urlWithAccess = (url: string) => { if (!url) { return url; } const urlObj = new URL(url); urlObj.searchParams.append('access', this._options.access); urlObj.searchParams.append('readonly', String(this._options.readonly)); return urlObj.href; }; const fullUrl = urlWithAccess(this._options.url); const onElem = this._options.onElem ?? ((el: HTMLIFrameElement) => el); return onElem( (this._iframe = dom('iframe', dom.style('visibility', use => use(this._visible) ? 'visible' : 'hidden'), dom.cls('clipboard_focus'), dom.cls('custom_view'), { src: fullUrl, ...hooks.iframeAttributes, }, testId('ready', this._readyCalled), )) ); } private _onMessage(event: MessageEvent) { if (this._iframe && event.source === this._iframe.contentWindow && !this.isDisposed()) { // Previously, we forwarded messages targeted at "grist" to the back-end. // Now, we process them immediately in the context of the client for access // control purposes. To do that, any message that comes in with mdest of // "grist" will have that destination wiped, and we provide a local // implementation of the interface. // It feels like it should be possible to deal with the mdest more cleanly, // with a rpc.registerForwarder('grist', { ... }), but it seems somehow hard // to call a locally registered interface of an rpc object? if (event.data.mdest === 'grist') { event.data.mdest = ''; } if (event.data.mtype === MsgType.Ready) { this.trigger('ready', this); this._readyCalled.set(true); } if (event.data.data?.settings?.status === 'initialized') { this._visible.set(true); this._options.onSettingsInitialized(); } this._rpc.receiveMessage(event.data); } } } const throwError = (access: AccessLevel) => { throw new Error('Access not granted. Current access level ' + access); }; /** * Wraps an object to check access level before it is called. * TODO: grain-rpc exposes callWrapper which could be used for this purpose, * but currently it doesn't have access to the incoming message. */ function wrapObject(impl: T, accessChecker: AccessChecker, access: AccessLevel): T { return new Proxy(impl, { // This proxies all the calls to methods on the API. get(target: any, methodName: string) { return function () { if (methodName === 'then') { // Making a proxy for then invocation is not a good idea. return undefined; } if (accessChecker.check(access, methodName)) { return target[methodName](...arguments); } else { throwError(access); } }; }, }); } /** * Interface for custom access rules. */ export interface AccessChecker { /** * Checks if the incoming call can be served on current access level. * @param access Current access level * @param method Method called on the interface, can use * or undefined to match all methods. */ check(access: AccessLevel, method?: string): boolean; } /** * Checks if current access level is enough. */ export class MinimumLevel implements AccessChecker { constructor(private _minimum: AccessLevel) {} public check(access: AccessLevel): boolean { return isSatisfied(access, this._minimum); } } type MethodMatcher = keyof T | '*'; /** * Helper object that allows assigning access level to a particular method in the interface. * * Example: * * 1. Expose two methods, all other will be denied (even in full access mode) * new ApiGranularAccess() * .require("read_table", "method1") // for method1 we need at least read_table * .require("none", "method2") // for method2 no access level is needed * * 2. Expose two methods, all other will require full access (effectively the same as ex. 1) * new ApiGranularAccess() * .require("read_table", "method1") // for method1 we need at least read_table * .require("none", "method2") // for method2 no access level is needed * .require("full", "*") // for any other, require full * * 3. Expose all methods on read_table access, but one can have none * new ApiGranularAccess() * .require("none", "method2") // for method2 we are ok with none access * .require("read_table", "*") // for any other, require read_table */ export class MethodAccess implements AccessChecker { private _accessMap: Map, AccessLevel> = new Map(); constructor() {} public require(level: AccessLevel, method: MethodMatcher = '*') { this._accessMap.set(method, level); return this; } public check(access: AccessLevel, method?: string): boolean { if (!method) { throw new Error('Method name is required for MethodAccess check'); } // Check if the iface was registered. if (this._accessMap.has(method as MethodMatcher)) { // If it was, check that minimum access level is granted. const minimum = this._accessMap.get(method as MethodMatcher)!; return isSatisfied(access, minimum); } else if (this._accessMap.has('*')) { // If there is a default rule, check if it permits the access. const minimum = this._accessMap.get('*')!; return isSatisfied(access, minimum); } else { // By default, don't allow anything on this interface. return false; } } } /*********************** * Exposed APIs for Custom Widgets. * * Currently we expose 3 APIs * - GristDocAPI - full access to document. * - ViewAPI - access to current table. * - WidgetAPI - access to widget configuration. ***********************/ /** * GristDocApi implemented over active GristDoc. */ export class GristDocAPIImpl implements GristDocAPI { public static readonly defaultAccess = new MethodAccess() .require(AccessLevel.read_table, 'getDocName') .require(AccessLevel.full); // for any other, require full Access. constructor(private _doc: GristDoc) {} public async getDocName() { return this._doc.docId(); } public async listTables(): Promise { // Could perhaps read tableIds from this.gristDoc.docModel.visibleTableIds.all()? const {tableData} = await this._doc.docComm.fetchTable('_grist_Tables'); // Tables the user doesn't have access to are just blanked out. return tableData[3].tableId.filter(tableId => tableId !== '') as string[]; } public async fetchTable(tableId: string) { return fromTableDataAction(await this._doc.docComm.fetchTable(tableId)); } public async applyUserActions(actions: any[][], options?: any) { return this._doc.docComm.applyUserActions(actions, {desc: undefined, ...options}); } // Get a token for out-of-band access to the document. // Currently will require the custom widget to have full access to the // document. // It would be great to support this with read_table rights. This could be // possible to do by adding a tableId setting to AccessTokenOptions, // encoding that limitation in the access token, and ensuring the back-end // respects it. But the current motivating use for adding access tokens is // showing attachments, and they aren't currently something that logically // lives within a specific table. public async getAccessToken(options: AccessTokenOptions) { return this._doc.docComm.getAccessToken({ readOnly: options.readOnly, }); } } /** * GristViewAPI implemented over BaseView. */ export class GristViewImpl implements GristView { constructor(private _baseView: BaseView) {} public async fetchSelectedTable(): Promise { // If widget has a custom columns mapping, we will ignore hidden columns section. // Hidden/Visible columns will eventually reflect what is available, but this operation // is not instant - and widget can receive rows with fields that are not in the mapping. const columns: ColumnRec[] = this._visibleColumns(); const rowIds = this._baseView.sortedRows.getKoArray().peek().filter(id => id != 'new'); const data: BulkColValues = {}; for (const column of columns) { // Use the colId of the displayCol, which may be different in case of Reference columns. const colId: string = column.displayColModel.peek().colId.peek(); const getter = this._baseView.tableModel.tableData.getRowPropFunc(colId)!; const typeInfo = extractInfoFromColType(column.type.peek()); data[column.colId.peek()] = rowIds.map(r => reencodeAsAny(getter(r)!, typeInfo)); } data.id = rowIds; return data; } public async fetchSelectedRecord(rowId: number): Promise { // Prepare an object containing the fields available to the view // for the specified row. A RECORD()-generated rendering would be // more useful. but the data engine needs to know what information // the custom view depends on, so we shouldn't volunteer any untracked // information here. const columns: ColumnRec[] = this._visibleColumns(); const data: RowRecord = {id: rowId}; for (const column of columns) { const colId: string = column.displayColModel.peek().colId.peek(); const typeInfo = extractInfoFromColType(column.type.peek()); data[column.colId.peek()] = reencodeAsAny( this._baseView.tableModel.tableData.getValue(rowId, colId)!, typeInfo ); } return data; } /** * This is deprecated method to turn on cursor linking. Previously it was used * to create a custom row id filter. Now widgets can be treated as normal source of linking. * Now allowSelectBy should be set using the ready event. */ public async allowSelectBy(): Promise { this._baseView.viewSection.allowSelectBy(true); // This is to preserve a legacy behavior, where when allowSelectBy is called widget expected // that the filter was already applied to clear all rows. this._baseView.viewSection.selectedRows([]); } public async setSelectedRows(rowIds: number[]|null): Promise { this._baseView.viewSection.selectedRows(rowIds); } public setCursorPos(cursorPos: CursorPos): Promise { this._baseView.setCursorPos(cursorPos); return Promise.resolve(); } private _visibleColumns() { const columns: ColumnRec[] = this._baseView.viewSection.columns.peek(); const hiddenCols = this._baseView.viewSection.hiddenColumns.peek().map(c => c.id.peek()); const mappings = this._baseView.viewSection.mappedColumns.peek(); const mappedColumns = new Set(flatMap(Object.values(mappings || {}))); const notHidden = (col: ColumnRec) => !hiddenCols.includes(col.id.peek()); const mapped = (col: ColumnRec) => mappings && mappedColumns.has(col.colId.peek()); // If columns are mapped, return only those that are mapped. // Otherwise return all not hidden columns; return mappings ? columns.filter(mapped) : columns.filter(notHidden); } } /** * WidgetAPI implemented over active section. */ export class WidgetAPIImpl implements WidgetAPI { constructor(private _section: ViewSectionRec) {} /** * Stores options in viewSection.customDef.widgetDef json field. * This way whenever widget is changed, options are removed and not shared * between widgets by design. */ public async setOptions(options: object): Promise { if (options === null || options === undefined || typeof options !== 'object') { throw new Error('options must be a valid JSON object'); } this._section.activeCustomOptions(options); } public async getOptions(): Promise | null> { return this._section.activeCustomOptions.peek() ?? null; } public async clearOptions(): Promise { this._section.activeCustomOptions(null); } public async setOption(key: string, value: any): Promise { const options = {...this._section.activeCustomOptions.peek()}; options[key] = value; this._section.activeCustomOptions(options); } public getOption(key: string): Promise { const options = this._section.activeCustomOptions.peek(); return options?.[key]; } } /************************ * Events that are sent to the CustomWidget. * * Currently: * - onRecord, implemented by RecordNotifier, sends a message each time active row is changed. * - onRecords, implemented by TableNotifier, sends a message each time table is changed * - onOptions, implemented by ConfigNotifier, sends a message each time configuration is changed * * All of those events are also sent when CustomWidget sends its ready message. ************************/ /** * EventSource should trigger event called "event" that will be send to the Custom Widget. */ export interface IEventSource extends DisposableWithEvents { /** * Called by WidgetFrame, allowing EventSource to attach to its ready event. */ attach(frame: WidgetFrame): void; } export class BaseEventSource extends DisposableWithEvents implements IEventSource { // Attaches to WidgetFrame ready event. public attach(frame: WidgetFrame): void { this.listenTo(frame, 'ready', this._ready.bind(this)); } protected _ready() { // To override if needed to react on the ready event. } protected _notify(data: any) { if (this.isDisposed()) { return; } this.trigger('event', data); } } /** * Notifies about cursor position change. Exposed in the API as a onRecord handler. */ export class RecordNotifier extends BaseEventSource { private _debounced: () => void; // debounced call to let the view know linked cursor changed. constructor(private _baseView: BaseView) { super(); this._debounced = debounce(() => this._update(), 0); this.autoDispose(_baseView.cursor.rowIndex.subscribe(this._debounced)); } private _update() { if (this.isDisposed()) { return; } const state = { tableId: this._baseView.viewSection.table().tableId(), rowId: this._baseView.cursor.getCursorPos().rowId || undefined, dataChange: false, }; this._notify(state); } } export interface ConfigNotifierOptions { access: AccessLevel; theme: Computed; } /** * Notifies about options change. Exposed in the API as a onOptions handler. */ export class ConfigNotifier extends BaseEventSource { private _accessLevel = this._options.access; private _theme = this._options.theme; private _currentConfig: Computed; // Debounced call to let the view know linked cursor changed. private _debounced: (fromReady?: boolean) => void; constructor(private _section: ViewSectionRec, private _options: ConfigNotifierOptions) { super(); this._currentConfig = Computed.create(this, use => { const options = use(this._section.activeCustomOptions); return options; }); this._debounced = debounce((fromReady?: boolean) => this._update(fromReady), 0); const subscribe = (...observables: Observable[]) => { for (const obs of observables) { this.autoDispose( obs.addListener((cur, prev) => { if (isEqual(prev, cur)) { return; } this._debounced(); }) ); } }; subscribe(this._currentConfig, this._theme); } protected _ready() { // On ready, send initial configuration. this._debounced(true); } private _update(fromReady = false) { if (this.isDisposed()) { return; } this._notify({ options: this._currentConfig.get(), settings: { accessLevel: this._accessLevel, theme: this._theme.get(), }, fromReady, }); } } /** * Notifies about cursor table data or structure change. * Exposed in the API as a onRecords handler. * This Notifier sends an initial event when subscribed */ export class TableNotifier extends BaseEventSource { private _debounced: () => void; private _updateMapping = true; constructor(private _baseView: BaseView) { super(); this._debounced = debounce(() => this._update(), 0); this.autoDispose(_baseView.viewSection.viewFields().subscribe(this._debounced.bind(this))); this.listenTo(_baseView.sortedRows, 'rowNotify', this._debounced.bind(this)); this.autoDispose(_baseView.sortedRows.getKoArray().subscribe(this._debounced.bind(this))); this.autoDispose(_baseView.viewSection.mappedColumns .subscribe(() => { this._updateMapping = true; this._debounced(); }) ); } protected _ready() { // On ready, send initial table information. this._debounced(); } private _update() { if (this.isDisposed()) { return; } const state = { tableId: this._baseView.viewSection.table().tableId(), rowId: this._baseView.cursor.getCursorPos().rowId || undefined, dataChange: true, mappingsChange: this._updateMapping }; this._updateMapping = false; this._notify(state); } } export class CustomSectionAPIImpl extends Disposable implements CustomSectionAPI { constructor( private _section: ViewSectionRec, private _currentAccess: AccessLevel, private _promptCallback: (access: AccessLevel) => void ) { super(); } public async mappings(): Promise { return this._section.mappedColumns.peek(); } /** * Method called as part of ready message. Allows widget to request for particular features or inform about * capabilities. */ public async configure(settings: InteractionOptionsRequest): Promise { if (settings.hasCustomOptions !== undefined) { this._section.hasCustomOptions(settings.hasCustomOptions); } if (settings.requiredAccess && settings.requiredAccess !== this._currentAccess) { this._promptCallback(settings.requiredAccess as AccessLevel); } if (settings.columns !== undefined) { this._section.columnsToMap(settings.columns); } else { this._section.columnsToMap(null); } if (settings.allowSelectBy !== undefined) { this._section.allowSelectBy(settings.allowSelectBy); } } }