(core) support for bundling custom widgets with the Grist app

Summary:
This adds support for bundling custom widgets with the Grist app, as follows:

 * Adds a new `widgets` component to plugins mechanism.
 * When a set of widgets is provided in a plugin, the html/js/css assets for those widgets are served on the existing untrusted user content port.
 * Any bundled `grist-plugin-api.js` will be served with the Grist app's own version of that file. It is important that bundled widgets not refer to https://docs.getgrist.com for the plugin js, since they must be capable of working offline.
 * The logic for configuring that port is updated a bit.
 * I removed the CustomAttachedView class in favor of applying settings of bundled custom widgets more directly, without modification on view.

Any Grist installation via docker will need an extra step now, since there is an extra port that needs exposing for full functionality. I did add a `GRIST_TRUST_PLUGINS` option for anyone who really doesn't want to do this, and would prefer to trust the plugins and have them served on the same port.

Actually making use of bundling will be another step. It'll be important to mesh it with our SaaS's use of APP_STATIC_URL for serving most static assets.

Design sketch: https://grist.quip.com/bJlWACWzr2R9/Bundled-custom-widgets

Test Plan: added a test

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4069
pull/711/head
Paul Fitzpatrick 7 months ago
parent cb0ce9b20f
commit cc9a9ae8c5

@ -295,8 +295,10 @@ GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown b
GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way.
GRIST_TELEMETRY_LEVEL | the telemetry level. Can be set to: `off` (default), `limited`, or `full`.
GRIST_THROTTLE_CPU | if set, CPU throttling is enabled
GRIST_USER_ROOT | an extra path to look for plugins in.
GRIST_TRUST_PLUGINS | if set, plugins are expect to be served from the same host as the rest of the Grist app, rather than from a distinct host. Ordinarily, plugins are served from a distinct host so that the cookies used by the Grist app are not automatically available to them. Enable this only if you understand the security implications.
GRIST_USER_ROOT | an extra path to look for plugins in - Grist will scan for plugins in `$GRIST_USER_ROOT/plugins`.
GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,multiSite,multiAccounts,sendToDrive,tutorials`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled.
GRIST_UNTRUSTED_PORT | if set, plugins will be served from the given port. This is an alternative to setting APP_UNTRUSTED_URL.
GRIST_WIDGET_LIST_URL | a url pointing to a widget manifest, by default `https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json` is used
COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie
HOME_PORT | port number to listen on for REST API server; if set to "share", add API endpoints to regular grist port.
@ -346,6 +348,41 @@ When using forward authentication, you may wish to also set the following variab
GRIST_FORWARD_AUTH_HEADER is similar to GRIST_PROXY_AUTH_HEADER, but enables
a login system (assuming you have some forward authentication set up).
#### Plugins:
Grist has a plugin system, used internally. One useful thing you can
do with it is include custom widgets in a build of Grist. Custom widgets
are usually made available just by setting `GRIST_WIDGET_LIST_URL`,
but that has the downside of being an external dependency, which can
be awkward for offline use or for archiving. Plugins offer an alternative.
To "bundle" custom widgets as a plugin:
* Add a subdirectory of `plugins`, e.g. `plugins/my-widgets`.
Alternatively, you can set the `GRIST_USER_ROOT` environment
variable to any path you want, and then create `plugins/my-widgets`
within that.
* Add a `manifest.yml` file in that subdirectory that looks like
this:
```
name: My Widgets
components:
widgets: widgets.json
```
* The `widgets.json` file should be in the format produced by
the [grist-widget](https://github.com/gristlabs/grist-widget)
repository, and should be placed in the same directory as
`manifest.yml`. Any material in `plugins/my-widgets`
will be served by Grist, and relative URLs can be used in
`widgets.json`.
* Once all files are in place, restart Grist. Your widgets should
now be available in the custom widgets dropdown, along with
any others from `GRIST_WIDGET_LIST_URL`.
* If you like, you can add multiple plugin subdirectories, with
multiple sets of widgets, and they'll all be made available.
#### Google Drive integrations:
Variable | Purpose

@ -1,46 +1,11 @@
import {AccessLevel} from "app/common/CustomWidget";
import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec";
import {CustomView} from "app/client/components/CustomView";
import {GristDoc} from "app/client/components/GristDoc";
import {reportError} from 'app/client/models/errors';
//Abstract class for more future inheritances
abstract class CustomAttachedView extends CustomView {
public override create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
super.create(gristDoc, viewSectionModel);
if (viewSectionModel.customDef.access.peek() !== AccessLevel.full) {
void viewSectionModel.customDef.access.setAndSave(AccessLevel.full).catch((err)=>{
if (err?.code === "ACL_DENY") {
// do nothing, we might be in a readonly mode.
return;
}
reportError(err);
});
}
const widgetsApi = this.gristDoc.app.topAppModel.api;
widgetsApi.getWidgets().then(async result=>{
const widget = result.find(w=>w.name == this.getWidgetName());
if (widget && this.customDef.url.peek() !== widget.url) {
await this.customDef.url.setAndSave(widget.url);
}
}).catch((err)=>{
if (err?.code !== "ACL_DENY") {
// TODO: revisit it later. getWidgets() is async call, and non of the code
// above is checking if we are still alive.
console.error(err);
} else {
// do nothing, we might be in a readonly mode.
}
});
}
protected abstract getWidgetName(): string;
}
export class CustomCalendarView extends CustomAttachedView {
protected getWidgetName(): string {
return "Calendar";
import { CustomView, CustomViewSettings } from "app/client/components/CustomView";
import { AccessLevel } from "app/common/CustomWidget";
export class CustomCalendarView extends CustomView {
protected getBuiltInSettings(): CustomViewSettings {
return {
widgetId: '@gristlabs/widget-calendar',
accessLevel: AccessLevel.full,
};
}
}

@ -34,6 +34,17 @@ import {dom as grains} from 'grainjs';
import * as ko from 'knockout';
import defaults = require('lodash/defaults');
/**
*
* Built in settings for a custom widget. Used when the custom
* widget is the implementation of a native-looking widget,
* for example the calendar widget.
*
*/
export interface CustomViewSettings {
widgetId?: string;
accessLevel?: AccessLevel;
}
/**
* CustomView components displays arbitrary html. There are two modes available, in the "url" mode
@ -83,11 +94,10 @@ export class CustomView extends Disposable {
private _frame: WidgetFrame; // plugin frame (holding external page)
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, { 'addNewRow': true });
this.customDef = this.viewSection.customDef;
this.customDef = this.viewSection.customDef;
this.autoDisposeCallback(() => {
if (this._customSection) {
@ -107,16 +117,20 @@ export class CustomView extends Disposable {
this._updatePluginInstance();
}
public async triggerPrint() {
if (!this.isDisposed() && this._frame) {
return await this._frame.callRemote('print');
}
}
protected getBuiltInSettings(): CustomViewSettings {
return {};
}
protected getEmptyWidgetPage(): string {
return new URL("custom-widget.html", getGristConfig().homeUrl!).href;
}
/**
* Find a plugin instance that matches the plugin id, update the `found` observables, then tries to
* find a matching section.
@ -156,13 +170,13 @@ export class CustomView extends Disposable {
}
private _buildDom() {
const {mode, url, access, renderAfterReady} = this.customDef;
const {mode, url, access, renderAfterReady, widgetDef, widgetId, pluginId} = this.customDef;
const showPlugin = ko.pureComputed(() => this.customDef.mode() === "plugin");
const showAfterReady = () => {
// The empty widget page calls `grist.ready()`.
if (!url()) { return true; }
if (!url() && !widgetId()) { return true; }
return this.customDef.widgetDef()?.renderAfterReady ?? renderAfterReady();
return renderAfterReady();
};
// When both plugin and section are not found, let's show only plugin notification.
@ -172,18 +186,27 @@ export class CustomView extends Disposable {
// For the view to update when switching from one section to another one, the computed
// observable must always notify.
.extend({notify: 'always'});
// Some widgets have built-in settings that should override anything
// that is in the rest of the view options. Ideally, everything would
// be consistent. We could fix inconsistencies if we find them, but
// we are not guaranteed to have write privileges at this point.
const builtInSettings = this.getBuiltInSettings();
return dom('div.flexauto.flexvbox.custom_view_container',
dom.autoDispose(showPlugin),
dom.autoDispose(showPluginNotification),
dom.autoDispose(showSectionNotification),
dom.autoDispose(showPluginContent),
// todo: should display content in webview when running electron
kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) =>
// prefer widgetId; spelunk in widgetDef for older docs
kd.scope(() => [mode(), url(), access(), widgetId() || widgetDef()?.widgetId || '', pluginId()],
([_mode, _url, _access, _widgetId, _pluginId]: string[]) =>
_mode === "url" ?
this._buildIFrame({
baseUrl: _url,
access: (_access as AccessLevel || AccessLevel.none),
access: builtInSettings.accessLevel || (_access as AccessLevel || AccessLevel.none),
showAfterReady: showAfterReady(),
widgetId: builtInSettings.widgetId || _widgetId,
pluginId: _pluginId,
})
: null
),
@ -213,12 +236,16 @@ export class CustomView extends Disposable {
baseUrl: string|null,
access: AccessLevel,
showAfterReady?: boolean,
widgetId?: string|null,
pluginId?: string
}) {
const {baseUrl, access, showAfterReady} = options;
const {baseUrl, access, showAfterReady, widgetId, pluginId} = options;
const documentSettings = this.gristDoc.docData.docSettings();
const readonly = this.gristDoc.isReadonly.get();
return grains.create(WidgetFrame, {
url: baseUrl || this.getEmptyWidgetPage(),
widgetId,
pluginId,
access,
preferences:
{
@ -273,7 +300,8 @@ export class CustomView extends Disposable {
}
// allow menus to close if any
closeRegisteredMenu();
})
}),
gristDoc: this.gristDoc,
});
}

@ -6,7 +6,8 @@ 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 {reportError} from 'app/client/models/errors';
import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} 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';
@ -45,6 +46,15 @@ export interface WidgetFrameOptions {
* Url of external page. Iframe is rebuild each time the URL changes.
*/
url: string;
/**
* ID of widget, if known. When set, the url for the specified widget
* in the WidgetRepository, if found, will take precedence.
*/
widgetId?: string|null;
/**
* ID of the plugin that provided the widget (if it came from a plugin).
*/
pluginId?: string;
/**
* Assigned access level. Iframe is rebuild each time access level is changed.
*/
@ -72,6 +82,10 @@ export interface WidgetFrameOptions {
* Optional language to use for the widget.
*/
preferences: {language?: string, timeZone?: any, currency?: string, culture?: string};
/**
* The containing document.
*/
gristDoc: GristDoc;
}
/**
@ -86,6 +100,7 @@ export class WidgetFrame extends DisposableWithEvents {
private _readyCalled = Observable.create(this, false);
// Whether the iframe is visible.
private _visible = Observable.create(this, !this._options.showAfterReady);
private readonly _widget = Observable.create<ICustomWidget|null>(this, null);
constructor(private _options: WidgetFrameOptions) {
super();
@ -112,7 +127,10 @@ export class WidgetFrame extends DisposableWithEvents {
// Call custom configuration handler.
_options.configure?.(this);
this._checkWidgetRepository().catch(reportError);
}
/**
* Attach an EventSource with desired access level.
*/
@ -166,6 +184,21 @@ export class WidgetFrame extends DisposableWithEvents {
}
public buildDom() {
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'),
dom.attr('src', use => this._getUrl(use(this._widget))),
hooks.iframeAttributes,
testId('ready', this._readyCalled),
))
);
}
private _getUrl(widget: ICustomWidget|null): string {
// Append access level to query string.
const urlWithAccess = (url: string) => {
if (!url) {
@ -175,23 +208,12 @@ export class WidgetFrame extends DisposableWithEvents {
urlObj.searchParams.append('access', this._options.access);
urlObj.searchParams.append('readonly', String(this._options.readonly));
// Append user and document preferences to query string.
const settingsParams = new URLSearchParams(this._options.preferences);
settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value));
const settingsParams = new URLSearchParams(this._options.preferences);
settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value));
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),
))
);
const url = widget?.url || this._options.url || 'about:blank';
return urlWithAccess(url);
}
private _onMessage(event: MessageEvent) {
@ -217,6 +239,19 @@ export class WidgetFrame extends DisposableWithEvents {
this._rpc.receiveMessage(event.data);
}
}
/**
* If we have a widgetId, look it up in the WidgetRepository and
* get the best URL we can for it.
*/
private async _checkWidgetRepository() {
const {widgetId, pluginId} = this._options;
if (this.isDisposed() || !widgetId) { return; }
const widgets = await this._options.gristDoc.app.topAppModel.getWidgets();
if (this.isDisposed()) { return; }
const widget = matchWidget(widgets, {widgetId, pluginId});
this._widget.set(widget || null);
}
}
const throwError = (access: AccessLevel) => {

@ -11,6 +11,8 @@ import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {AsyncCreate} from 'app/common/AsyncCreate';
import {ICustomWidget} from 'app/common/CustomWidget';
import {OrgUsageSummary} from 'app/common/DocUsage';
import {Features, isLegacyPlan, Product} from 'app/common/Features';
import {GristLoadConfig, IGristUrlState} from 'app/common/gristUrls';
@ -61,6 +63,8 @@ export interface TopAppModel {
orgs: Observable<Organization[]>;
users: Observable<FullUser[]>;
customWidgets: Observable<ICustomWidget[]|null>;
// Reinitialize the app. This is called when org or user changes.
initialize(): void;
@ -75,6 +79,17 @@ export interface TopAppModel {
* Reloads orgs and accounts for current user.
*/
fetchUsersAndOrgs(): Promise<void>;
/**
* Enumerate the widgets in the WidgetRepository for this installation
* of Grist.
*/
getWidgets(): Promise<ICustomWidget[]>;
/**
* Reload cached list of widgets, for testing purposes.
*/
testReloadWidgets(): Promise<void>;
}
/**
@ -142,7 +157,12 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
public readonly orgs = Observable.create<Organization[]>(this, []);
public readonly users = Observable.create<FullUser[]>(this, []);
public readonly plugins: LocalPlugin[] = [];
public readonly customWidgets = Observable.create<ICustomWidget[]|null>(this, null);
private readonly _gristConfig?: GristLoadConfig;
// Keep a list of available widgets, once requested, so we don't have to
// keep reloading it. Downside: browser page will need reloading to pick
// up new widgets - that seems ok.
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
constructor(
window: {gristConfig?: GristLoadConfig},
@ -153,6 +173,11 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
this._gristConfig = window.gristConfig;
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
const widgets = await this.api.getWidgets();
this.customWidgets.set(widgets);
return widgets;
});
// Initially, and on any change to subdomain, call initialize() to get the full Organization
// and the FullUser to use for it (the user may change when switching orgs).
@ -175,6 +200,19 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
}
}
public async getWidgets(): Promise<ICustomWidget[]> {
return this._widgets.get();
}
public async testReloadWidgets() {
console.log("testReloadWidgets");
this._widgets.clear();
this.customWidgets.set(null);
console.log("testReloadWidgets cleared and nulled");
const result = await this.getWidgets();
console.log("testReloadWidgets got", {result});
}
public getUntrustedContentOrigin() {
if (G.window.isRunningUnderElectron) {
// when loaded within webviews it is safe to serve plugin's content from the same domain

@ -272,11 +272,24 @@ export interface CustomViewSectionDef {
* The url.
*/
url: modelUtil.KoSaveableObservable<string|null>;
/**
* Custom widget information.
/**
* A widgetId, if available. Preferred to url.
* For bundled custom widgets, it is important to refer
* to them by something other than url, since url will
* vary with deployment, and it should be possible to move
* documents between deployments if they have compatible
* widgets available.
*/
widgetDef: modelUtil.KoSaveableObservable<ICustomWidget|null>;
widgetId: modelUtil.KoSaveableObservable<string|null>;
/**
* Custom widget information. This is a record of what was
* in a custom widget manifest entry when the widget was
* configured. Its contents should not be relied on too much.
* In particular, any URL contained may come from an entirely
* different installation of Grist.
*/
widgetDef: modelUtil.KoSaveableObservable<ICustomWidget|null>;
/**
* Custom widget options.
*/
widgetOptions: modelUtil.KoSaveableObservable<Record<string, any>|null>;
@ -363,6 +376,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.customDef = {
mode: customDefObj.prop('mode'),
url: customDefObj.prop('url'),
widgetId: customDefObj.prop('widgetId'),
widgetDef: customDefObj.prop('widgetDef'),
widgetOptions: customDefObj.prop('widgetOptions'),
columnsMapping: customDefObj.prop('columnsMapping'),

@ -16,7 +16,7 @@ import {textInput} from 'app/client/ui2018/editableLabel';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
import {AccessLevel, ICustomWidget, isSatisfied} from 'app/common/CustomWidget';
import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
import {GristLoadConfig} from 'app/common/gristUrls';
import {not, unwrap} from 'app/common/gutil';
import {
@ -388,7 +388,7 @@ export class CustomSectionConfig extends Disposable {
protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig;
// Holds all available widget definitions.
private _widgets: Observable<ICustomWidget[]>;
private _widgets: Observable<ICustomWidget[]|null>;
// Holds selected option (either custom string or a widgetId).
private readonly _selectedId: Computed<string | null>;
// Holds custom widget URL.
@ -413,14 +413,20 @@ export class CustomSectionConfig extends Disposable {
this._canSelect = gristConfig.enableWidgetRepository ?? true;
// Array of available widgets - will be updated asynchronously.
this._widgets = Observable.create(this, []);
this._widgets = _gristDoc.app.topAppModel.customWidgets;
this._getWidgets().catch(reportError);
// Request for rest of the widgets.
// Selected value from the dropdown (contains widgetId or "custom" string for Custom URL)
this._selectedId = Computed.create(this, use => {
if (use(_section.customDef.widgetDef)) {
return _section.customDef.widgetDef.peek()!.widgetId;
// widgetId could be stored in one of two places, depending on
// age of document.
const widgetId = use(_section.customDef.widgetId) ||
use(_section.customDef.widgetDef)?.widgetId;
const pluginId = use(_section.customDef.pluginId);
if (widgetId) {
// selection id is "pluginId:widgetId"
return (pluginId || '') + ':' + widgetId;
}
return CUSTOM_ID;
});
@ -432,8 +438,11 @@ export class CustomSectionConfig extends Disposable {
_section.customDef.renderAfterReady(false);
// Clear url.
_section.customDef.url(null);
// Clear widget definition.
// Clear widgetId
_section.customDef.widgetId(null);
_section.customDef.widgetDef(null);
// Clear pluginId
_section.customDef.pluginId('');
// Reset access level to none.
_section.customDef.access(AccessLevel.none);
// Clear all saved options.
@ -447,14 +456,19 @@ export class CustomSectionConfig extends Disposable {
});
await _section.saveCustomDef();
} else {
const [pluginId, widgetId] = value?.split(':') || [];
// Select Widget
const selectedWidget = this._widgets.get().find(w => w.widgetId === value);
const selectedWidget = matchWidget(this._widgets.get()||[], {
widgetId,
pluginId,
});
if (!selectedWidget) {
// should not happen
throw new Error('Error accessing widget from the list');
}
// If user selected the same one, do nothing.
if (_section.customDef.widgetDef.peek()?.widgetId === value) {
if (_section.customDef.widgetId.peek() === widgetId &&
_section.customDef.pluginId.peek() === pluginId) {
return;
}
bundleChanges(() => {
@ -464,10 +478,19 @@ export class CustomSectionConfig extends Disposable {
_section.customDef.access(AccessLevel.none);
// When widget wants some access, set desired access level.
this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none);
// Update widget definition.
// Keep a record of the original widget definition.
// Don't rely on this much, since the document could
// have moved installation since, and widgets could be
// served from elsewhere.
_section.customDef.widgetDef(selectedWidget);
// Update widget URL.
_section.customDef.url(selectedWidget.url);
// Update widgetId.
_section.customDef.widgetId(selectedWidget.widgetId);
// Update pluginId.
_section.customDef.pluginId(selectedWidget.source?.pluginId || '');
// Update widget URL. Leave blank when widgetId is set.
_section.customDef.url(null);
// Clear options.
_section.customDef.widgetOptions(null);
// Clear has custom configuration.
@ -486,6 +509,13 @@ export class CustomSectionConfig extends Disposable {
this._url.onWrite(async newUrl => {
bundleChanges(() => {
_section.customDef.renderAfterReady(false);
if (newUrl) {
// When a URL is set explicitly, make sure widgetId/pluginId/widgetDef
// is empty.
_section.customDef.widgetId(null);
_section.customDef.pluginId('');
_section.customDef.widgetDef(null);
}
_section.customDef.url(newUrl);
});
await _section.saveCustomDef();
@ -521,7 +551,10 @@ export class CustomSectionConfig extends Disposable {
// Options for the select-box (all widgets definitions and Custom URL)
const options = Computed.create(holder, use => [
{label: 'Custom URL', value: 'custom'},
...use(this._widgets).map(w => ({label: w.name, value: w.widgetId})),
...(use(this._widgets) || []).map(w => ({
label: w.source?.name ? `${w.name} (${w.source.name})` : w.name,
value: (w.source?.pluginId || '') + ':' + w.widgetId,
})),
]);
function buildPrompt(level: AccessLevel|null) {
if (!level) {
@ -557,7 +590,7 @@ export class CustomSectionConfig extends Disposable {
testId('select')
)
: null,
dom.maybe(isCustom && this.shouldRenderWidgetSelector(), () => [
dom.maybe((use) => use(isCustom) && this.shouldRenderWidgetSelector(), () => [
cssRow(
cssTextInput(
this._url,
@ -626,19 +659,7 @@ export class CustomSectionConfig extends Disposable {
}
protected async _getWidgets() {
const api = this._gristDoc.app.topAppModel.api;
const widgets = await api.getWidgets();
if (this.isDisposed()) { return; }
// Request for rest of the widgets.
if (this._canSelect) {
// From the start we will provide single widget definition
// that was chosen previously.
if (this._section.customDef.widgetDef.peek()) {
widgets.push(this._section.customDef.widgetDef.peek()!);
}
}
this._widgets.set(widgets);
await this._gristDoc.app.topAppModel.getWidgets();
}
private _accept() {

@ -1,3 +1,5 @@
import sortBy = require('lodash/sortBy');
/**
* Custom widget manifest definition.
*/
@ -8,6 +10,9 @@ export interface ICustomWidget {
name: string;
/**
* Widget unique id, probably in npm package format @gristlabs/custom-widget-name.
*
* There could be multiple versions of the same widget with the
* same id, e.g. a bundled version and an external version.
*/
widgetId: string;
/**
@ -25,6 +30,14 @@ export interface ICustomWidget {
* applying the Grist theme.
*/
renderAfterReady?: boolean;
/**
* If the widget came from a plugin, we track that here.
*/
source?: {
pluginId: string;
name: string;
};
}
/**
@ -56,3 +69,21 @@ export function isSatisfied(current: AccessLevel, minimum: AccessLevel) {
}
return ordered(current) >= ordered(minimum);
}
/**
* Find the best match for a widgetId/pluginId combination among the
* given widgets. An exact widgetId match is required. A pluginId match
* is preferred but not required.
*/
export function matchWidget(widgets: ICustomWidget[], options: {
widgetId: string,
pluginId?: string,
}): ICustomWidget|undefined {
const prefs = sortBy(widgets, (w) => {
return [w.widgetId !== options.widgetId,
(w.source?.pluginId||'') !== options.pluginId];
});
if (prefs.length === 0) { return; }
if (prefs[0].widgetId !== options.widgetId) { return; }
return prefs[0];
}

@ -10,10 +10,12 @@ export const PublishedPlugin = t.iface(["BarePlugin"], {
});
export const BarePlugin = t.iface([], {
"name": t.opt("string"),
"components": t.iface([], {
"safeBrowser": t.opt("string"),
"safePython": t.opt("string"),
"unsafeNode": t.opt("string"),
"widgets": t.opt("string"),
"deactivate": t.opt(t.iface([], {
"inactivitySec": t.opt("number"),
})),

@ -35,6 +35,11 @@ export interface PublishedPlugin extends BarePlugin {
* as those being developed).
*/
export interface BarePlugin {
/**
* An optional human-readable name.
*/
name?: string;
/**
* Components describe how the plugin runs. A plugin may provide UI and behavior that runs in
* the browser, Python code that runs in a secure sandbox, and arbitrary code that runs in Node.
@ -82,6 +87,13 @@ export interface BarePlugin {
*/
unsafeNode?: string;
/**
* Relative path to a specialized manifest of custom widgets.
* I'm unsure how this fits into components and contributions,
* this seemed the least-worst spot for it.
*/
widgets?: string;
/**
* Options for when to deactivate the plugin, i.e. when to stop any plugin processes. (Note
* that we may in the future also add options for when to activate the plugin, which is for

@ -34,22 +34,6 @@ function getPort(envVarName: string, fallbackPort: number): number {
return val ? parseInt(val, 10) : fallbackPort;
}
// Checks whether to serve user content on same domain but on different port
function checkUserContentPort(): number | null {
if (process.env.APP_UNTRUSTED_URL && process.env.APP_HOME_URL) {
const homeUrl = new URL(process.env.APP_HOME_URL);
const pluginUrl = new URL(process.env.APP_UNTRUSTED_URL);
// If the hostname of both home and plugin url are the same,
// but the ports are different
if (homeUrl.hostname === pluginUrl.hostname &&
homeUrl.port !== pluginUrl.port) {
const port = parseInt(pluginUrl.port || '80', 10);
return port;
}
}
return null;
}
export async function main() {
log.info("==========================================================================");
log.info("== devServer");
@ -114,14 +98,6 @@ export async function main() {
}
const server = await mergedServerMain(port, ["home", "docs", "static"]);
await server.addTestingHooks();
// If plugin content is served from same host but on different port,
// run webserver on that port
const userPort = checkUserContentPort();
if (userPort !== null) {
log.info("==========================================================================");
log.info("== userContent");
await server.startCopy('pluginServer', userPort);
}
return;
}
@ -155,15 +131,6 @@ export async function main() {
await home.startCopy('webServer', webServerPort);
}
// If plugin content is served from same host but on different port,
// run webserver on that port
const userPort = checkUserContentPort();
if (userPort !== null) {
log.info("==========================================================================");
log.info("== userContent");
await home.startCopy('pluginServer', userPort);
}
// Bring up the docWorker(s)
log.info("==========================================================================");
log.info("== docWorker");

@ -1,4 +1,5 @@
import {ApiError} from 'app/common/ApiError';
import {ICustomWidget} from 'app/common/CustomWidget';
import {delay} from 'app/common/delay';
import {DocCreationInfo} from 'app/common/DocListAPI';
import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,
@ -65,7 +66,7 @@ import {getTelemetryPrefs, ITelemetry} from 'app/server/lib/Telemetry';
import {startTestingHooks} from 'app/server/lib/TestingHooks';
import {getTestLoginSystem} from 'app/server/lib/TestLogin';
import {addUploadRoute} from 'app/server/lib/uploads';
import {buildWidgetRepository, IWidgetRepository} from 'app/server/lib/WidgetRepository';
import {buildWidgetRepository, getWidgetsInPlugins, IWidgetRepository} from 'app/server/lib/WidgetRepository';
import {setupLocale} from 'app/server/localization';
import axios from 'axios';
import * as cookie from 'cookie';
@ -127,6 +128,9 @@ export class FlexServer implements GristServer {
private _dbManager: HomeDBManager;
private _defaultBaseDomain: string|undefined;
private _pluginUrl: string|undefined;
private _pluginUrlReady: boolean = false;
private _servesPlugins?: boolean;
private _bundledWidgets?: ICustomWidget[];
private _billing: IBilling;
private _instanceRoot: string;
private _docManager: DocManager;
@ -169,11 +173,30 @@ export class FlexServer implements GristServer {
private _getLogoutRedirectUrl: (req: express.Request, nextUrl: URL) => Promise<string>;
private _sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;
private _getLoginSystem?: () => Promise<GristLoginSystem>;
// Called by ready() to allow requests to be served.
private _ready: () => void;
// Set once ready() is called
private _isReady: boolean = false;
constructor(public port: number, public name: string = 'flexServer',
public readonly options: FlexServerOptions = {}) {
this.app = express();
this.app.set('port', port);
// Before doing anything, we pause any request handling to wait
// for the server being entirely ready. The specific reason to do
// so is because, if we are serving plugins, and using an
// OS-assigned port to do so, we won't know the URL to use for
// plugins until quite late. But it seems a nice thing to
// guarantee in general.
const readyPromise = new Promise(resolve => {
this._ready = () => resolve(undefined);
});
this.app.use(async (_req, _res, next) => {
await readyPromise;
next();
});
this.appRoot = getAppRoot();
this.host = process.env.GRIST_HOST || "localhost";
log.info(`== Grist version is ${version.version} (commit ${version.gitcommit})`);
@ -219,7 +242,6 @@ export class FlexServer implements GristServer {
}
this.info.push(['defaultBaseDomain', this._defaultBaseDomain]);
this._pluginUrl = options.pluginUrl || process.env.APP_UNTRUSTED_URL;
this.info.push(['pluginUrl', this._pluginUrl]);
// The electron build is not supported at this time, but this stub
// implementation of electronServerMethods is present to allow kicking
@ -540,12 +562,6 @@ export class FlexServer implements GristServer {
public addStaticAndBowerDirectories() {
if (this._check('static_and_bower', 'dir')) { return; }
this.addTagChecker();
// Allow static files to be requested from any origin.
const options: serveStatic.ServeStaticOptions = {
setHeaders: (res, filepath, stat) => {
res.setHeader("Access-Control-Allow-Origin", "*");
}
};
// Grist has static help files, which may be useful for standalone app,
// but for hosted grist the latest help is at support.getgrist.com. Redirect
// to this page for the benefit of crawlers which currently rank the static help
@ -558,11 +574,11 @@ export class FlexServer implements GristServer {
// as an Electron app.
const staticExtDir = getAppPathTo(this.appRoot, 'static') + '_ext';
const staticExtApp = fse.existsSync(staticExtDir) ?
express.static(staticExtDir, options) : null;
const staticApp = express.static(getAppPathTo(this.appRoot, 'static'), options);
const bowerApp = express.static(getAppPathTo(this.appRoot, 'bower_components'), options);
express.static(staticExtDir, serveAnyOrigin) : null;
const staticApp = express.static(getAppPathTo(this.appRoot, 'static'), serveAnyOrigin);
const bowerApp = express.static(getAppPathTo(this.appRoot, 'bower_components'), serveAnyOrigin);
if (process.env.GRIST_LOCALES_DIR) {
const locales = express.static(process.env.GRIST_LOCALES_DIR, options);
const locales = express.static(process.env.GRIST_LOCALES_DIR, serveAnyOrigin);
this.app.use("/locales", this.tagChecker.withTag(locales));
}
if (staticExtApp) { this.app.use(this.tagChecker.withTag(staticExtApp)); }
@ -586,19 +602,38 @@ export class FlexServer implements GristServer {
this.app.use(/^\/(grist-plugin-api.js)$/, expressWrap(async (req, res) =>
res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')})));
// Plugins get access to static resources without a tag
this.app.use(limitToPlugins(express.static(getAppPathTo(this.appRoot, 'static'))));
this.app.use(limitToPlugins(express.static(getAppPathTo(this.appRoot, 'bower_components'))));
this.app.use(limitToPlugins(this, express.static(getAppPathTo(this.appRoot, 'static'))));
this.app.use(limitToPlugins(this, express.static(getAppPathTo(this.appRoot, 'bower_components'))));
// Serve custom-widget.html message for anyone.
this.app.use(/^\/(custom-widget.html)$/, expressWrap(async (req, res) =>
res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')})));
this.addOrg();
addPluginEndpoints(this, await this._addPluginManager());
// Serve bundled custom widgets on the plugin endpoint.
const places = getWidgetsInPlugins(this, '');
if (places.length > 0) {
// For all widgets served in place, replace any copies of
// grist-plugin-api.js with this app's version of it.
// This is perhaps a bit rude, but beats the alternative
// of either using inconsistent bundled versions, or
// requiring network access.
this.app.use(/^\/widgets\/.*\/(grist-plugin-api.js)$/, expressWrap(async (req, res) =>
res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')})));
}
for (const place of places) {
this.app.use(
'/widgets/' + place.pluginId, this.tagChecker.withTag(
limitToPlugins(this, express.static(place.dir, serveAnyOrigin))
)
);
}
}
// Prepare cache for managing org-to-host relationship.
public addHosts() {
if (this._check('hosts', 'homedb')) { return; }
this._hosts = new Hosts(this._defaultBaseDomain, this._dbManager, this._pluginUrl);
this._hosts = new Hosts(this._defaultBaseDomain, this._dbManager, this);
}
public async initHomeDBManager() {
@ -706,7 +741,7 @@ export class FlexServer implements GristServer {
// ApiServer's constructor adds endpoints to the app.
// tslint:disable-next-line:no-unused-expression
new ApiServer(this, this.app, this._dbManager, this._widgetRepository = buildWidgetRepository());
new ApiServer(this, this.app, this._dbManager, this._widgetRepository = buildWidgetRepository(this));
}
public addBillingApi() {
@ -1420,7 +1455,7 @@ export class FlexServer implements GristServer {
}), jsonErrorHandler); // Add a final error handler that reports errors as JSON.
}
public finalize() {
public finalizeEndpoints() {
this.addApiErrorHandlers();
// add a final non-found handler for other content.
@ -1452,6 +1487,72 @@ export class FlexServer implements GristServer {
});
}
/**
* Check whether there's a local plugin port.
*/
public servesPlugins() {
if (this._servesPlugins === undefined) {
throw new Error('do not know if server will serve plugins');
}
return this._servesPlugins;
}
/**
* Declare that there will be a local plugin port.
*/
public setServesPlugins(flag: boolean) {
this._servesPlugins = flag;
}
/**
* Get the base URL for plugins. Throws an error if the URL is not
* yet available.
*/
public getPluginUrl() {
if (!this._pluginUrlReady) {
throw new Error('looked at plugin url too early');
}
return this._pluginUrl;
}
public getPlugins() {
if (!this._pluginManager) {
throw new Error('plugin manager not available');
}
return this._pluginManager.getPlugins();
}
public async finalizePlugins(userPort: number|null) {
if (isAffirmative(process.env.GRIST_TRUST_PLUGINS)) {
this._pluginUrl = this.getDefaultHomeUrl();
} else if (userPort !== null) {
// If plugin content is served from same host but on different port,
// run webserver on that port
const ports = await this.startCopy('pluginServer', userPort);
// If Grist is running on a desktop, directly on the host, it
// can be convenient to leave the user port free for the OS to
// allocate by using GRIST_UNTRUSTED_PORT=0. But we do need to
// remember how to contact it.
if (process.env.APP_UNTRUSTED_URL === undefined) {
const url = new URL(this.getOwnUrl());
url.port = String(userPort || ports.serverPort);
this._pluginUrl = url.href;
}
}
this.info.push(['pluginUrl', this._pluginUrl]);
this.info.push(['willServePlugins', this._servesPlugins]);
this._pluginUrlReady = true;
const repo = buildWidgetRepository(this, { localOnly: true });
this._bundledWidgets = await repo.getWidgets();
}
public getBundledWidgets(): ICustomWidget[] {
if (!this._bundledWidgets) {
throw new Error('bundled widgets accessed too early');
}
return this._bundledWidgets;
}
public summary() {
for (const [label, value] of this.info) {
log.info("== %s: %s", label, value);
@ -1466,6 +1567,12 @@ export class FlexServer implements GristServer {
}
}
public ready() {
if (this._isReady) { return; }
this._isReady = true;
this._ready();
}
public checkOptionCombinations() {
// Check for some bad combinations we should warn about.
const allowedWebhookDomains = appSettings.section('integrations').flag('allowedWebhookDomains').readString({
@ -1565,9 +1672,12 @@ export class FlexServer implements GristServer {
await this.housekeeper.start();
}
public async startCopy(name2: string, port2: number) {
public async startCopy(name2: string, port2: number): Promise<{
serverPort: number,
httpsServerPort?: number,
}>{
const servers = this._createServers();
await this._startServers(servers.server, servers.httpsServer, name2, port2, true);
return this._startServers(servers.server, servers.httpsServer, name2, port2, true);
}
/**
@ -1633,6 +1743,9 @@ export class FlexServer implements GristServer {
}
public getTag(): string {
if (!this.tag) {
throw new Error('getTag called too early');
}
return this.tag;
}
@ -1934,12 +2047,19 @@ export class FlexServer implements GristServer {
private async _startServers(server: http.Server, httpsServer: https.Server|undefined,
name: string, port: number, verbose: boolean) {
await listenPromise(server.listen(port, this.host));
if (verbose) { log.info(`${name} available at ${this.host}:${port}`); }
const serverPort = (server.address() as AddressInfo).port;
if (verbose) { log.info(`${name} available at ${this.host}:${serverPort}`); }
let httpsServerPort: number|undefined;
if (TEST_HTTPS_OFFSET && httpsServer) {
const httpsPort = port + TEST_HTTPS_OFFSET;
await listenPromise(httpsServer.listen(httpsPort, this.host));
if (verbose) { log.info(`${name} available at https://${this.host}:${httpsPort}`); }
if (port === 0) { throw new Error('cannot use https with OS-assigned port'); }
httpsServerPort = port + TEST_HTTPS_OFFSET;
await listenPromise(httpsServer.listen(httpsServerPort, this.host));
if (verbose) { log.info(`${name} available at https://${this.host}:${httpsServerPort}`); }
}
return {
serverPort,
httpsServerPort,
};
}
private async _recordNewUserInfo(row: object) {
@ -2194,3 +2314,10 @@ export interface ElectronServerMethods {
updateUserConfig(obj: any): Promise<void>;
onBackupMade(cb: () => void): void;
}
// Allow static files to be requested from any origin.
const serveAnyOrigin: serveStatic.ServeStaticOptions = {
setHeaders: (res, filepath, stat) => {
res.setHeader("Access-Control-Allow-Origin", "*");
}
};

@ -1,4 +1,6 @@
import { ICustomWidget } from 'app/common/CustomWidget';
import { GristDeploymentType, GristLoadConfig } from 'app/common/gristUrls';
import { LocalPlugin } from 'app/common/plugin';
import { UserProfile } from 'app/common/UserAPI';
import { Document } from 'app/gen-server/entity/Document';
import { Organization } from 'app/gen-server/entity/Organization';
@ -54,6 +56,10 @@ export interface GristServer {
sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void>;
getAccessTokens(): IAccessTokens;
resolveLoginSystem(): Promise<GristLoginSystem>;
getPluginUrl(): string|undefined;
getPlugins(): LocalPlugin[];
servesPlugins(): boolean;
getBundledWidgets(): ICustomWidget[];
}
export interface GristLoginSystem {
@ -136,6 +142,10 @@ export function createDummyGristServer(): GristServer {
sendAppPage() { return Promise.resolve(); },
getAccessTokens() { throw new Error('no access tokens'); },
resolveLoginSystem() { throw new Error('no login system'); },
getPluginUrl() { return undefined; },
servesPlugins() { return false; },
getPlugins() { return []; },
getBundledWidgets() { return []; },
};
}

@ -1,34 +1,36 @@
import {FlexServer} from 'app/server/lib/FlexServer';
import {GristServer} from 'app/server/lib/GristServer';
import log from 'app/server/lib/log';
import {PluginManager} from 'app/server/lib/PluginManager';
import * as express from 'express';
import * as mimeTypes from 'mime-types';
import * as path from 'path';
// Get the url where plugin material should be served from.
export function getUntrustedContentOrigin(): string|undefined {
return process.env.APP_UNTRUSTED_URL;
}
// Get the host serving plugin material
export function getUntrustedContentHost(): string|undefined {
const origin = getUntrustedContentOrigin();
export function getUntrustedContentHost(origin: string|undefined): string|undefined {
if (!origin) { return; }
return new URL(origin).host;
}
// Add plugin endpoints to be served on untrusted host
export function addPluginEndpoints(server: FlexServer, pluginManager: PluginManager) {
const host = getUntrustedContentHost();
if (host) {
if (server.servesPlugins()) {
server.app.get(/^\/plugins\/(installed|builtIn)\/([^/]+)\/(.+)/, (req, res) =>
servePluginContent(req, res, pluginManager, host));
servePluginContent(req, res, pluginManager, server));
}
}
// Serve content for plugins with various checks that it is being accessed as we expect.
function servePluginContent(req: express.Request, res: express.Response,
pluginManager: PluginManager, untrustedContentHost: string) {
pluginManager: PluginManager,
gristServer: GristServer) {
const pluginUrl = gristServer.getPluginUrl();
const untrustedContentHost = getUntrustedContentHost(pluginUrl);
if (!untrustedContentHost) {
// not expected
throw new Error('plugin host unexpectedly not set');
}
const pluginKind = req.params[0];
const pluginId = req.params[1];
const pluginPath = req.params[2];
@ -56,9 +58,11 @@ function servePluginContent(req: express.Request, res: express.Response,
}
// Middleware to restrict some assets to untrusted host.
export function limitToPlugins(handler: express.RequestHandler) {
const host = getUntrustedContentHost();
export function limitToPlugins(gristServer: GristServer,
handler: express.RequestHandler) {
return function(req: express.Request, resp: express.Response, next: express.NextFunction) {
const pluginUrl = gristServer.getPluginUrl();
const host = getUntrustedContentHost(pluginUrl);
if (!host) { return next(); }
if (matchHost(req.get('host'), host) || req.get('X-From-Plugin-WebView') === "true") {
return handler(req, resp, next);

@ -137,8 +137,10 @@ async function scanDirectory(dir: string, kind: "installed"|"builtIn"): Promise<
try {
listDir = await fse.readdir(dir);
} catch (e) {
// non existing dir is treated as an empty dir
log.info(`No plugins directory: ${e.message}`);
// Non existing dir is treated as an empty dir.
// It is hard for user to avoid Grist checking a dir,
// so phrase the message as information rather than error.
log.info(`No plugins found in directory: ${dir}`);
return [];
}

@ -1,8 +1,17 @@
import {ICustomWidget} from 'app/common/CustomWidget';
import log from 'app/server/lib/log';
import * as fse from 'fs-extra';
import fetch from 'node-fetch';
import * as path from 'path';
import {ApiError} from 'app/common/ApiError';
import {removeTrailingSlash} from 'app/common/gutil';
import {GristServer} from 'app/server/lib/GristServer';
import LRUCache from 'lru-cache';
import * as url from 'url';
import { AsyncCreate } from 'app/common/AsyncCreate';
// Static url for UrlWidgetRepository
const STATIC_URL = process.env.GRIST_WIDGET_LIST_URL;
/**
* Widget Repository returns list of available Custom Widgets.
@ -11,28 +20,90 @@ export interface IWidgetRepository {
getWidgets(): Promise<ICustomWidget[]>;
}
// Static url for StaticWidgetRepository
const STATIC_URL = process.env.GRIST_WIDGET_LIST_URL;
/**
*
* A widget repository that lives on disk.
*
* The _widgetFile should point to a json file containing a
* list of custom widgets, in the format used by the grist-widget
* repo:
* https://github.com/gristlabs/grist-widget
*
* The file can use relative URLs. The URLs will be interpreted
* as relative to the _widgetBaseUrl.
*
* If a _source is provided, it will be passed along in the
* widget listings.
*
*/
export class DiskWidgetRepository implements IWidgetRepository {
constructor(private _widgetFile: string,
private _widgetBaseUrl: string,
private _source?: any) {}
public async getWidgets(): Promise<ICustomWidget[]> {
const txt = await fse.readFile(this._widgetFile, { encoding: 'utf8' });
const widgets: ICustomWidget[] = JSON.parse(txt);
fixUrls(widgets, this._widgetBaseUrl);
if (this._source) {
for (const widget of widgets) {
widget.source = this._source;
}
}
return widgets;
}
}
/**
* Default repository that gets list of available widgets from a static URL.
*
* A wrapper around a widget repository that delays creating it
* until the first call to getWidgets().
*
*/
export class WidgetRepositoryImpl implements IWidgetRepository {
constructor(protected _staticUrl = STATIC_URL) {}
export class DelayedWidgetRepository implements IWidgetRepository {
private _repo: AsyncCreate<IWidgetRepository|undefined>;
/**
* Method exposed for testing, overrides widget url.
*/
public testOverrideUrl(url: string) {
this._staticUrl = url;
constructor(_makeRepo: () => Promise<IWidgetRepository|undefined>) {
this._repo = new AsyncCreate(_makeRepo);
}
public async getWidgets(): Promise<ICustomWidget[]> {
const repo = await this._repo.get();
if (!repo) { return []; }
return repo.getWidgets();
}
}
/**
*
* A wrapper around a list of widget repositories that concatenates
* their results.
*
*/
export class CombinedWidgetRepository implements IWidgetRepository {
constructor(private _repos: IWidgetRepository[]) {}
public async getWidgets(): Promise<ICustomWidget[]> {
const allWidgets: ICustomWidget[] = [];
for (const repo of this._repos) {
allWidgets.push(...await repo.getWidgets());
}
return allWidgets;
}
}
/**
* Repository that gets a list of widgets from a URL.
*/
export class UrlWidgetRepository implements IWidgetRepository {
constructor(private _staticUrl = STATIC_URL) {}
public async getWidgets(): Promise<ICustomWidget[]> {
if (!this._staticUrl) {
log.warn(
'WidgetRepository: Widget repository is not configured.' + !STATIC_URL
'WidgetRepository: Widget repository is not configured.' + (!STATIC_URL
? ' Missing GRIST_WIDGET_LIST_URL environmental variable.'
: ''
: '')
);
return [];
}
@ -52,6 +123,7 @@ export class WidgetRepositoryImpl implements IWidgetRepository {
if (!widgets || !Array.isArray(widgets)) {
throw new ApiError('WidgetRepository: Error reading widget list', 500);
}
fixUrls(widgets, this._staticUrl);
return widgets;
} catch (err) {
if (!(err instanceof ApiError)) {
@ -62,6 +134,61 @@ export class WidgetRepositoryImpl implements IWidgetRepository {
}
}
/**
* Default repository that gets list of available widgets from multiple
* sources.
*/
export class WidgetRepositoryImpl implements IWidgetRepository {
protected _staticUrl: string|undefined;
private _diskWidgets?: IWidgetRepository;
private _urlWidgets: UrlWidgetRepository;
private _combinedWidgets: CombinedWidgetRepository;
constructor(_options: {
staticUrl?: string,
gristServer?: GristServer,
}) {
const {staticUrl, gristServer} = _options;
if (gristServer) {
this._diskWidgets = new DelayedWidgetRepository(async () => {
const places = getWidgetsInPlugins(gristServer);
const files = places.map(
place => new DiskWidgetRepository(
place.file,
place.urlBase,
{
pluginId: place.pluginId,
name: place.name
}));
return new CombinedWidgetRepository(files);
});
}
this.testSetUrl(staticUrl);
}
/**
* Method exposed for testing, overrides widget url.
*/
public testOverrideUrl(overrideUrl: string|undefined) {
this.testSetUrl(overrideUrl);
}
public testSetUrl(overrideUrl: string|undefined) {
const repos: IWidgetRepository[] = [];
this._staticUrl = overrideUrl ?? STATIC_URL;
if (this._staticUrl) {
this._urlWidgets = new UrlWidgetRepository(this._staticUrl);
repos.push(this._urlWidgets);
}
if (this._diskWidgets) { repos.push(this._diskWidgets); }
this._combinedWidgets = new CombinedWidgetRepository(repos);
}
public async getWidgets(): Promise<ICustomWidget[]> {
return this._combinedWidgets.getWidgets();
}
}
/**
* Version of WidgetRepository that caches successful result for 2 minutes.
*/
@ -91,6 +218,60 @@ class CachedWidgetRepository extends WidgetRepositoryImpl {
/**
* Returns widget repository implementation.
*/
export function buildWidgetRepository() {
return new CachedWidgetRepository();
export function buildWidgetRepository(gristServer: GristServer,
options?: {
localOnly: boolean
}) {
return new CachedWidgetRepository({
gristServer,
...(options?.localOnly ? { staticUrl: '' } : undefined)
});
}
function fixUrls(widgets: ICustomWidget[], baseUrl: string) {
// If URLs are relative, make them absolute, interpreting them
// relative to the supplied base.
for (const widget of widgets) {
if (!(url.parse(widget.url).protocol)) {
widget.url = new URL(widget.url, baseUrl).href;
}
}
}
/**
* Information about widgets in a plugin. We need to coordinate
* URLs with location on disk.
*/
export interface CustomWidgetsInPlugin {
pluginId: string,
urlBase: string,
dir: string,
file: string,
name: string,
}
/**
* Get a list of widgets available locally via plugins.
*/
export function getWidgetsInPlugins(gristServer: GristServer,
pluginUrl?: string) {
const places: CustomWidgetsInPlugin[] = [];
const plugins = gristServer.getPlugins();
pluginUrl = pluginUrl ?? gristServer.getPluginUrl();
if (pluginUrl === undefined) { return []; }
for (const plugin of plugins) {
const components = plugin.manifest.components;
if (!components.widgets) { continue; }
const urlBase =
removeTrailingSlash(pluginUrl) + '/v/' +
gristServer.getTag() + '/widgets/' + plugin.id + '/';
places.push({
urlBase,
dir: plugin.path,
file: path.join(plugin.path, components.widgets),
name: plugin.manifest.name || plugin.id,
pluginId: plugin.id,
});
}
return places;
}

@ -1,8 +1,10 @@
import { ApiError } from 'app/common/ApiError';
import { mapGetOrSet, MapWithTTL } from 'app/common/AsyncCreate';
import { extractOrgParts, getHostType, getKnownOrg } from 'app/common/gristUrls';
import { isAffirmative } from 'app/common/gutil';
import { Organization } from 'app/gen-server/entity/Organization';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
import { GristServer } from 'app/server/lib/GristServer';
import { getOriginUrl } from 'app/server/lib/requestUtils';
import { NextFunction, Request, RequestHandler, Response } from 'express';
import { IncomingMessage } from 'http';
@ -41,7 +43,7 @@ export class Hosts {
// baseDomain should start with ".". It may be undefined for localhost or single-org mode.
constructor(private _baseDomain: string|undefined, private _dbManager: HomeDBManager,
private _pluginUrl: string|undefined) {
private _gristServer: GristServer|undefined) {
}
/**
@ -165,6 +167,8 @@ export class Hosts {
}
private _getHostType(host: string) {
return getHostType(host, {baseDomain: this._baseDomain, pluginUrl: this._pluginUrl});
const pluginUrl = isAffirmative(process.env.GRIST_TRUST_PLUGINS) ?
undefined : this._gristServer?.getPluginUrl();
return getHostType(host, {baseDomain: this._baseDomain, pluginUrl});
}
}

@ -50,7 +50,11 @@ export class ManifestError extends Error {
*/
export async function readManifest(pluginPath: string): Promise<BarePlugin> {
const notices: string[] = [];
const manifest = await _readManifest(pluginPath);
const manifest: any = await _readManifest(pluginPath);
// We allow contributions and components to be omitted as shorthand
// for being the empty object.
if (!manifest.contributions) { manifest.contributions = {}; }
if (!manifest.components) { manifest.components = {}; }
if (isValidManifest(manifest, notices)) {
return manifest as BarePlugin;
}
@ -58,6 +62,9 @@ export async function readManifest(pluginPath: string): Promise<BarePlugin> {
}
async function _readManifest(pluginPath: string): Promise<object> {
async function readManifestFile(fileExtension: string): Promise<string> {
return await fse.readFile(path.join(pluginPath, "manifest." + fileExtension), "utf8");
}
try {
return yaml.safeLoad(await readManifestFile("yml"));
} catch (e) {
@ -73,7 +80,4 @@ async function _readManifest(pluginPath: string): Promise<object> {
}
throw new Error('cannot read manifest file: ' + e.message);
}
async function readManifestFile(fileExtension: string): Promise<string> {
return await fse.readFile(path.join(pluginPath, "manifest." + fileExtension), "utf8");
}
}

@ -33,7 +33,7 @@ export interface ISendAppPageOptions {
googleTagManager?: true | false | 'anon';
}
export interface MakeGristConfigOptons {
export interface MakeGristConfigOptions {
homeUrl: string|null;
extra: Partial<GristLoadConfig>;
baseDomain?: string;
@ -41,7 +41,7 @@ export interface MakeGristConfigOptons {
server?: GristServer|null;
}
export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig {
export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfig {
const {homeUrl, extra, baseDomain, req, server} = options;
// .invalid is a TLD the IETF promises will never exist.
const pluginUrl = process.env.APP_UNTRUSTED_URL || 'http://plugins.invalid';
@ -68,7 +68,8 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
maxUploadSizeImport: (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || undefined,
maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined,
timestampMs: Date.now(),
enableWidgetRepository: Boolean(process.env.GRIST_WIDGET_LIST_URL),
enableWidgetRepository: Boolean(process.env.GRIST_WIDGET_LIST_URL) ||
((server?.getBundledWidgets().length || 0) > 0),
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
activation: getActivation(req as RequestWithLogin | undefined),
@ -78,7 +79,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
featureComments: isAffirmative(process.env.COMMENTS),
featureFormulaAssistant: Boolean(process.env.OPENAI_API_KEY || process.env.ASSISTANT_CHAT_COMPLETION_ENDPOINT),
assistantService: process.env.OPENAI_API_KEY ? 'OpenAI' : undefined,
permittedCustomWidgets: getPermittedCustomWidgets(),
permittedCustomWidgets: getPermittedCustomWidgets(server),
gristNewColumnMenu: isAffirmative(process.env.GRIST_NEW_COLUMN_MENU),
supportEmail: SUPPORT_EMAIL,
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
@ -171,9 +172,26 @@ function getFeatures(): IFeature[] {
return Features.checkAll(difference(enabledFeatures, disabledFeatures));
}
function getPermittedCustomWidgets(): IAttachedCustomWidget[] {
function getPermittedCustomWidgets(gristServer?: GristServer|null): IAttachedCustomWidget[] {
if (!process.env.PERMITTED_CUSTOM_WIDGETS && gristServer) {
// The PERMITTED_CUSTOM_WIDGETS environment variable is a bit of
// a drag. If there are bundled widgets that overlap with widgets
// described in the codebase, let's just assume they are permitted.
const widgets = gristServer.getBundledWidgets();
const names = new Set(AttachedCustomWidgets.values as string[]);
const namesFound: IAttachedCustomWidget[] = [];
for (const widget of widgets) {
// Permitted custom widgets are identified so many ways across the
// code! Why? TODO: cut down on identifiers.
const name = widget.widgetId.replace('@gristlabs/widget-', 'custom.');
if (names.has(name)) {
namesFound.push(name as IAttachedCustomWidget);
}
}
return AttachedCustomWidgets.checkAll(namesFound);
}
const widgetsList = process.env.PERMITTED_CUSTOM_WIDGETS?.split(',').map(widgetName=>`custom.${widgetName}`) ?? [];
return AttachedCustomWidgets.checkAll(widgetsList);
return AttachedCustomWidgets.checkAll(widgetsList);
}
function configuredPageTitleSuffix() {

@ -31,6 +31,26 @@ export function parseServerTypes(serverTypes: string|undefined): ServerType[] {
return types as ServerType[];
}
function checkUserContentPort(): number | null {
// Check whether a port is explicitly set for user content.
if (process.env.GRIST_UNTRUSTED_PORT) {
return parseInt(process.env.GRIST_UNTRUSTED_PORT, 10);
}
// Checks whether to serve user content on same domain but on different port
if (process.env.APP_UNTRUSTED_URL && process.env.APP_HOME_URL) {
const homeUrl = new URL(process.env.APP_HOME_URL);
const pluginUrl = new URL(process.env.APP_UNTRUSTED_URL);
// If the hostname of both home and plugin url are the same,
// but the ports are different
if (homeUrl.hostname === pluginUrl.hostname &&
homeUrl.port !== pluginUrl.port) {
const port = parseInt(pluginUrl.port || '80', 10);
return port;
}
}
return null;
}
interface ServerOptions extends FlexServerOptions {
logToConsole?: boolean; // If set, messages logged to console (default: false)
// (but if options are not given at all in call to main,
@ -52,6 +72,14 @@ export async function main(port: number, serverTypes: ServerType[],
const server = new FlexServer(port, `server(${serverTypes.join(",")})`, options);
// We need to know early on whether we will be serving plugins or not.
if (includeHome) {
const userPort = checkUserContentPort();
server.setServesPlugins(userPort !== undefined);
} else {
server.setServesPlugins(false);
}
if (options.loginSystem) {
server.setLoginSystem(options.loginSystem);
}
@ -141,10 +169,11 @@ export async function main(port: number, serverTypes: ServerType[],
server.addClientSecrets();
}
server.finalize();
server.finalizeEndpoints();
await server.finalizePlugins(includeHome ? checkUserContentPort() : null);
server.checkOptionCombinations();
server.summary();
server.ready();
return server;
} catch(e) {
await server.close();

@ -107,6 +107,12 @@ export async function main() {
// Set directory for uploaded documents.
setDefaultEnv('GRIST_DATA_DIR', 'docs');
setDefaultEnv('GRIST_SERVERS', 'home,docs,static');
if (process.env.GRIST_SERVERS?.includes('home')) {
// By default, we will now start an untrusted port alongside a
// home server, for bundled custom widgets.
// Suppress with GRIST_UNTRUSTED_PORT=''
setDefaultEnv('GRIST_UNTRUSTED_PORT', '0');
}
const serverTypes = parseServerTypes(process.env.GRIST_SERVERS);
await fse.mkdirp(process.env.GRIST_DATA_DIR!);

@ -16,13 +16,20 @@ describe('AttachedCustomWidget', function () {
// Valid widget url.
const widgetEndpoint = '/widget';
// Create some widgets:
const widget1: ICustomWidget = {widgetId: '1', name: 'Calendar', url: widgetEndpoint + '?name=Calendar'};
const widget1: ICustomWidget = {
widgetId: '@gristlabs/widget-calendar',
name: 'Calendar',
url: widgetEndpoint + '?name=Calendar',
};
let widgetServerUrl = '';
// Holds widgets manifest content.
let widgets: ICustomWidget[] = [];
// Switches widget manifest url
function useManifest(url: string) {
return server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : '');
async function useManifest(url: string) {
await server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : '');
await driver.executeAsyncScript(
(done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done()
);
}
async function buildWidgetServer(){
@ -77,8 +84,8 @@ describe('AttachedCustomWidget', function () {
it('should be able to attach Calendar Widget', async () => {
await gu.openAddWidgetToPage();
const notepadElement = await driver.findContent('.test-wselect-type', /Calendar/);
assert.exists(notepadElement, 'Calendar widget is not found in the list of widgets');
const calendarElement = await driver.findContent('.test-wselect-type', /Calendar/);
assert.exists(calendarElement, 'Calendar widget is not found in the list of widgets');
});
it('should not ask for permission', async () => {

@ -1,13 +1,16 @@
import {assert, driver, Key} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import {serveSomething} from 'test/server/customUtil';
import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
import {AccessTokenResult} from 'app/plugin/GristAPI';
import {TableOperations} from 'app/plugin/TableOperations';
import {getAppRoot} from 'app/server/lib/places';
import * as fse from 'fs-extra';
import {assert, driver, Key} from 'mocha-webdriver';
import fetch from 'node-fetch';
import * as path from 'path';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import {serveSomething} from 'test/server/customUtil';
import {createTmpDir} from 'test/server/docTools';
import {EnvironmentSnapshot} from 'test/server/testUtils';
// Valid manifest url.
const manifestEndpoint = '/manifest.json';
@ -45,14 +48,18 @@ function getCustomWidgetFrame() {
describe('CustomWidgets', function () {
this.timeout(20000);
gu.bigScreen();
const cleanup = setupTestSuite();
// Holds url for sample widget server.
let widgetServerUrl = '';
// Switches widget manifest url
function useManifest(url: string) {
return server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : '');
async function useManifest(url: string) {
await server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : '');
await driver.executeAsyncScript(
(done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done()
);
}
@ -113,14 +120,6 @@ describe('CustomWidgets', function () {
await server.testingHooks.setWidgetRepositoryUrl('');
});
after(async function() {
await server.testingHooks.setWidgetRepositoryUrl('');
});
after(async function() {
await server.testingHooks.setWidgetRepositoryUrl('');
});
// Open or close widget menu.
const toggle = async () => await driver.findWait('.test-config-widget-select .test-select-open', 1000).click();
// Get current value from widget menu.
@ -215,16 +214,17 @@ describe('CustomWidgets', function () {
// Rejects new access level.
const reject = () => driver.find(".test-config-widget-access-reject").click();
async function enableWidgetsAndShowPanel() {
// Override gristConfig to enable widget list.
await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
// We need to be sure that widget configuration panel is open all the time.
await gu.toggleSidePanel('right', 'open');
await recreatePanel();
await driver.findWait('.test-right-tab-pagewidget', 100).click();
}
describe('RightWidgetMenu', () => {
beforeEach(async function () {
// Override gristConfig to enable widget list.
await driver.executeScript('window.gristConfig.enableWidgetRepository = true;');
// We need to be sure that widget configuration panel is open all the time.
await gu.toggleSidePanel('right', 'open');
await recreatePanel();
await driver.findWait('.test-right-tab-pagewidget', 100).click();
});
beforeEach(enableWidgetsAndShowPanel);
it('should show widgets in dropdown', async () => {
await gu.toggleSidePanel('right', 'open');
@ -364,7 +364,12 @@ describe('CustomWidgets', function () {
await recreatePanel();
});
it('should show widget when it was removed from list', async () => {
/**
* Need to think about whether this is desirable?
* The document could be on a different Grist installation to the
* one where it was created.
*/
it.skip('should show widget when it was removed from list', async () => {
// Select widget1 and then remove it from the list.
await toggle();
await select(widget1.name);
@ -721,4 +726,154 @@ describe('CustomWidgets', function () {
});
});
});
describe('Bundling', function () {
let oldEnv: EnvironmentSnapshot;
before(async function () {
oldEnv = new EnvironmentSnapshot();
});
after(async function() {
oldEnv.restore();
await server.restart();
});
it('can add widgets via plugins', async function () {
// Double-check that using one external widget, we see
// just that widget listed.
widgets = [widget1];
await useManifest(manifestEndpoint);
await enableWidgetsAndShowPanel();
await toggle();
assert.deepEqual(await options(), [
CUSTOM_URL, widget1.name,
]);
// Get a temporary directory that will be cleaned up,
// and populated it as follows:
// plugins/
// my-widgets/
// manifest.yml # a plugin manifest, listing widgets.json
// widgets.json # a widget set manifest, grist-widget style
// p1.html # one of the widgets in widgets.json
// p2.html # another of the widgets in widgets.json
// grist-plugin-api.js # a dummy api file, to check it is overridden
const dir = await createTmpDir();
const pluginDir = path.join(dir, 'plugins', 'my-widgets');
await fse.mkdirp(pluginDir);
// A plugin, with some widgets in it.
await fse.writeFile(path.join(pluginDir, 'manifest.yml'), `
name: My Widgets
components:
widgets: widgets.json
`);
// A list of a pair of custom widgets, with the widget
// source in the same directory.
await fse.writeFile(
path.join(pluginDir, 'widgets.json'),
JSON.stringify([
{
widgetId: 'p1',
name: 'P1',
url: './p1.html',
},
{
widgetId: 'p2',
name: 'P2',
url: './p2.html',
},
]),
);
// The first widget - just contains the text P1.
await fse.writeFile(
path.join(pluginDir, 'p1.html'),
'<html><body>P1</body></html>',
);
// The second widget. This contains the text P2
// if grist is defined after loading grist-plugin-api.js
// (but the js bundled with the widget just throws an
// alert).
await fse.writeFile(
path.join(pluginDir, 'p2.html'),
`
<html>
<script src="./grist-plugin-api.js"></script>
<body>
<div id="readout"></div>
<script>
if (typeof grist !== 'undefined') {
document.getElementById('readout').innerText = 'P2';
}
</script>
</body>
</html>
`
);
// A dummy grist-plugin-api.js - hopefully the actual
// js for the current version of Grist will be served in
// its place.
await fse.writeFile(
path.join(pluginDir, 'grist-plugin-api.js'),
'alert("Error: built in api version used");',
);
// Restart server and reload doc now plugins are in place.
process.env.GRIST_USER_ROOT = dir;
await server.restart();
await gu.reloadDoc();
// Continue using one external widget.
await useManifest(manifestEndpoint);
await enableWidgetsAndShowPanel();
// Check we see one external widget and two bundled ones.
await toggle();
assert.deepEqual(await options(), [
CUSTOM_URL, widget1.name, 'P1 (My Widgets)', 'P2 (My Widgets)',
]);
// Prepare to check content of widgets.
async function getWidgetText(): Promise<string> {
return gu.doInIframe(await getCustomWidgetFrame(), () => {
return driver.executeScript(
() => document.body.innerText
);
});
}
// Check built-in P1 works as expected.
await select(/P1/);
assert.equal(await current(), 'P1 (My Widgets)');
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'P1');
});
// Check external W1 works as expected.
await toggle();
await select(/W1/);
assert.equal(await current(), 'W1');
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'W1');
});
// Check build-in P2 works as expected.
await toggle();
await select(/P2/);
assert.equal(await current(), 'P2 (My Widgets)');
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'P2');
});
// Make sure widget setting is sticky.
await gu.reloadDoc();
await gu.waitToPass(async () => {
assert.equal(await getWidgetText(), 'P2');
});
});
});
});

@ -340,6 +340,10 @@ describe('CustomWidgetsConfig', function () {
});
it('should hide mappings when there is no good column', async () => {
if ((await currentWidget()) !== CUSTOM_URL) {
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
}
await gu.setWidgetUrl(
createConfigUrl({
columns: [{name: 'M2', type: 'Date'}],
@ -392,6 +396,10 @@ describe('CustomWidgetsConfig', function () {
it('should clear optional mapping', async () => {
const revert = await gu.begin();
if ((await currentWidget()) !== CUSTOM_URL) {
await toggleWidgetMenu();
await clickOption(CUSTOM_URL);
}
await gu.setWidgetUrl(
createConfigUrl({
columns: [{name: 'M2', type: 'Date', optional: true}],

@ -3271,6 +3271,27 @@ export async function changeWidgetAccess(access: 'read table'|'full'|'none') {
}
}
/**
* Recently, driver.switchTo().window() has become a little flakey,
* methods may fail if called immediately after switching to a
* window. This method works around the problem by waiting for
* driver.getCurrentUrl to succeed.
* https://github.com/SeleniumHQ/selenium/issues/12277
*/
export async function switchToWindow(target: string) {
await driver.switchTo().window(target);
for (let i = 0; i < 10; i++) {
try {
await driver.getCurrentUrl();
break;
} catch (e) {
console.log("switchToWindow retry after error:", e);
await driver.sleep(250);
}
}
}
/*
* Returns an instance of `LockableClipboard`, making sure to unlock it after
* each test.

@ -37,6 +37,9 @@ async function activateServer(home: FlexServer, docManager: DocManager) {
await home.addTelemetry();
await home.addDoc();
home.addApiErrorHandlers();
home.finalizeEndpoints();
await home.finalizePlugins(null);
home.ready();
serverUrl = home.getOwnUrl();
}
@ -110,7 +113,8 @@ describe('Authorizer', function() {
const resp2 = await axios.get(`${serverUrl}/o/pr/doc/Bananas2`, charon);
assert.equal(resp2.status, 404);
assert.notMatch(resp.data, /sample_6/);
assert.deepEqual(resp.data, resp2.data);
assert.deepEqual(withoutTimestamp(resp.data),
withoutTimestamp(resp2.data));
});
it("viewer can access title", async function() {
@ -304,3 +308,7 @@ describe('Authorizer', function() {
await cli.close();
});
});
function withoutTimestamp(txt: string): string {
return txt.replace(/"timestampMs":[ 0-9]+/, '"timestampMs": NNNN');
}

Loading…
Cancel
Save