(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
This commit is contained in:
Paul Fitzpatrick 2023-10-27 15:34:42 -04:00
parent cb0ce9b20f
commit cc9a9ae8c5
26 changed files with 961 additions and 227 deletions

View File

@ -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_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_TELEMETRY_LEVEL | the telemetry level. Can be set to: `off` (default), `limited`, or `full`.
GRIST_THROTTLE_CPU | if set, CPU throttling is enabled 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_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 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 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. 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 GRIST_FORWARD_AUTH_HEADER is similar to GRIST_PROXY_AUTH_HEADER, but enables
a login system (assuming you have some forward authentication set up). 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: #### Google Drive integrations:
Variable | Purpose Variable | Purpose

View File

@ -1,46 +1,11 @@
import {AccessLevel} from "app/common/CustomWidget"; import { CustomView, CustomViewSettings } from "app/client/components/CustomView";
import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec"; import { AccessLevel } from "app/common/CustomWidget";
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 export class CustomCalendarView extends CustomView {
abstract class CustomAttachedView extends CustomView { protected getBuiltInSettings(): CustomViewSettings {
public override create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { return {
super.create(gristDoc, viewSectionModel); widgetId: '@gristlabs/widget-calendar',
if (viewSectionModel.customDef.access.peek() !== AccessLevel.full) { accessLevel: 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";
} }
} }

View File

@ -34,6 +34,17 @@ import {dom as grains} from 'grainjs';
import * as ko from 'knockout'; import * as ko from 'knockout';
import defaults = require('lodash/defaults'); 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 * 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) private _frame: WidgetFrame; // plugin frame (holding external page)
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, { 'addNewRow': true }); BaseView.call(this as any, gristDoc, viewSectionModel, { 'addNewRow': true });
this.customDef = this.viewSection.customDef; this.customDef = this.viewSection.customDef;
this.autoDisposeCallback(() => { this.autoDisposeCallback(() => {
if (this._customSection) { if (this._customSection) {
@ -107,16 +117,20 @@ export class CustomView extends Disposable {
this._updatePluginInstance(); this._updatePluginInstance();
} }
public async triggerPrint() { public async triggerPrint() {
if (!this.isDisposed() && this._frame) { if (!this.isDisposed() && this._frame) {
return await this._frame.callRemote('print'); return await this._frame.callRemote('print');
} }
} }
protected getBuiltInSettings(): CustomViewSettings {
return {};
}
protected getEmptyWidgetPage(): string { protected getEmptyWidgetPage(): string {
return new URL("custom-widget.html", getGristConfig().homeUrl!).href; 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 plugin instance that matches the plugin id, update the `found` observables, then tries to
* find a matching section. * find a matching section.
@ -156,13 +170,13 @@ export class CustomView extends Disposable {
} }
private _buildDom() { 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 showPlugin = ko.pureComputed(() => this.customDef.mode() === "plugin");
const showAfterReady = () => { const showAfterReady = () => {
// The empty widget page calls `grist.ready()`. // 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. // 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 // For the view to update when switching from one section to another one, the computed
// observable must always notify. // observable must always notify.
.extend({notify: 'always'}); .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', return dom('div.flexauto.flexvbox.custom_view_container',
dom.autoDispose(showPlugin), dom.autoDispose(showPlugin),
dom.autoDispose(showPluginNotification), dom.autoDispose(showPluginNotification),
dom.autoDispose(showSectionNotification), dom.autoDispose(showSectionNotification),
dom.autoDispose(showPluginContent), dom.autoDispose(showPluginContent),
// todo: should display content in webview when running electron // 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" ? _mode === "url" ?
this._buildIFrame({ this._buildIFrame({
baseUrl: _url, baseUrl: _url,
access: (_access as AccessLevel || AccessLevel.none), access: builtInSettings.accessLevel || (_access as AccessLevel || AccessLevel.none),
showAfterReady: showAfterReady(), showAfterReady: showAfterReady(),
widgetId: builtInSettings.widgetId || _widgetId,
pluginId: _pluginId,
}) })
: null : null
), ),
@ -213,12 +236,16 @@ export class CustomView extends Disposable {
baseUrl: string|null, baseUrl: string|null,
access: AccessLevel, access: AccessLevel,
showAfterReady?: boolean, 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 documentSettings = this.gristDoc.docData.docSettings();
const readonly = this.gristDoc.isReadonly.get(); const readonly = this.gristDoc.isReadonly.get();
return grains.create(WidgetFrame, { return grains.create(WidgetFrame, {
url: baseUrl || this.getEmptyWidgetPage(), url: baseUrl || this.getEmptyWidgetPage(),
widgetId,
pluginId,
access, access,
preferences: preferences:
{ {
@ -273,7 +300,8 @@ export class CustomView extends Disposable {
} }
// allow menus to close if any // allow menus to close if any
closeRegisteredMenu(); closeRegisteredMenu();
}) }),
gristDoc: this.gristDoc,
}); });
} }

View File

@ -6,7 +6,8 @@ import {hooks} from 'app/client/Hooks';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {makeTestId} from 'app/client/lib/domUtils'; import {makeTestId} from 'app/client/lib/domUtils';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; 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 {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions'; import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes'; 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 of external page. Iframe is rebuild each time the URL changes.
*/ */
url: string; 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. * 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. * Optional language to use for the widget.
*/ */
preferences: {language?: string, timeZone?: any, currency?: string, culture?: string}; 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); private _readyCalled = Observable.create(this, false);
// Whether the iframe is visible. // Whether the iframe is visible.
private _visible = Observable.create(this, !this._options.showAfterReady); private _visible = Observable.create(this, !this._options.showAfterReady);
private readonly _widget = Observable.create<ICustomWidget|null>(this, null);
constructor(private _options: WidgetFrameOptions) { constructor(private _options: WidgetFrameOptions) {
super(); super();
@ -112,7 +127,10 @@ export class WidgetFrame extends DisposableWithEvents {
// Call custom configuration handler. // Call custom configuration handler.
_options.configure?.(this); _options.configure?.(this);
this._checkWidgetRepository().catch(reportError);
} }
/** /**
* Attach an EventSource with desired access level. * Attach an EventSource with desired access level.
*/ */
@ -166,6 +184,21 @@ export class WidgetFrame extends DisposableWithEvents {
} }
public buildDom() { 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. // Append access level to query string.
const urlWithAccess = (url: string) => { const urlWithAccess = (url: string) => {
if (!url) { if (!url) {
@ -175,23 +208,12 @@ export class WidgetFrame extends DisposableWithEvents {
urlObj.searchParams.append('access', this._options.access); urlObj.searchParams.append('access', this._options.access);
urlObj.searchParams.append('readonly', String(this._options.readonly)); urlObj.searchParams.append('readonly', String(this._options.readonly));
// Append user and document preferences to query string. // Append user and document preferences to query string.
const settingsParams = new URLSearchParams(this._options.preferences); const settingsParams = new URLSearchParams(this._options.preferences);
settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value)); settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value));
return urlObj.href; return urlObj.href;
}; };
const fullUrl = urlWithAccess(this._options.url); const url = widget?.url || this._options.url || 'about:blank';
const onElem = this._options.onElem ?? ((el: HTMLIFrameElement) => el); return urlWithAccess(url);
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) { private _onMessage(event: MessageEvent) {
@ -217,6 +239,19 @@ export class WidgetFrame extends DisposableWithEvents {
this._rpc.receiveMessage(event.data); 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) => { const throwError = (access: AccessLevel) => {

View File

@ -11,6 +11,8 @@ import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades'; import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge'; import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars'; 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 {OrgUsageSummary} from 'app/common/DocUsage';
import {Features, isLegacyPlan, Product} from 'app/common/Features'; import {Features, isLegacyPlan, Product} from 'app/common/Features';
import {GristLoadConfig, IGristUrlState} from 'app/common/gristUrls'; import {GristLoadConfig, IGristUrlState} from 'app/common/gristUrls';
@ -61,6 +63,8 @@ export interface TopAppModel {
orgs: Observable<Organization[]>; orgs: Observable<Organization[]>;
users: Observable<FullUser[]>; users: Observable<FullUser[]>;
customWidgets: Observable<ICustomWidget[]|null>;
// Reinitialize the app. This is called when org or user changes. // Reinitialize the app. This is called when org or user changes.
initialize(): void; initialize(): void;
@ -75,6 +79,17 @@ export interface TopAppModel {
* Reloads orgs and accounts for current user. * Reloads orgs and accounts for current user.
*/ */
fetchUsersAndOrgs(): Promise<void>; 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 orgs = Observable.create<Organization[]>(this, []);
public readonly users = Observable.create<FullUser[]>(this, []); public readonly users = Observable.create<FullUser[]>(this, []);
public readonly plugins: LocalPlugin[] = []; public readonly plugins: LocalPlugin[] = [];
public readonly customWidgets = Observable.create<ICustomWidget[]|null>(this, null);
private readonly _gristConfig?: GristLoadConfig; 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( constructor(
window: {gristConfig?: GristLoadConfig}, window: {gristConfig?: GristLoadConfig},
@ -153,6 +173,11 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg); this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org); this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
this._gristConfig = window.gristConfig; 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 // 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). // 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() { public getUntrustedContentOrigin() {
if (G.window.isRunningUnderElectron) { if (G.window.isRunningUnderElectron) {
// when loaded within webviews it is safe to serve plugin's content from the same domain // when loaded within webviews it is safe to serve plugin's content from the same domain

View File

@ -272,11 +272,24 @@ export interface CustomViewSectionDef {
* The url. * The url.
*/ */
url: modelUtil.KoSaveableObservable<string|null>; 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. * Custom widget options.
*/ */
widgetOptions: modelUtil.KoSaveableObservable<Record<string, any>|null>; widgetOptions: modelUtil.KoSaveableObservable<Record<string, any>|null>;
@ -363,6 +376,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.customDef = { this.customDef = {
mode: customDefObj.prop('mode'), mode: customDefObj.prop('mode'),
url: customDefObj.prop('url'), url: customDefObj.prop('url'),
widgetId: customDefObj.prop('widgetId'),
widgetDef: customDefObj.prop('widgetDef'), widgetDef: customDefObj.prop('widgetDef'),
widgetOptions: customDefObj.prop('widgetOptions'), widgetOptions: customDefObj.prop('widgetOptions'),
columnsMapping: customDefObj.prop('columnsMapping'), columnsMapping: customDefObj.prop('columnsMapping'),

View File

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

View File

@ -1,3 +1,5 @@
import sortBy = require('lodash/sortBy');
/** /**
* Custom widget manifest definition. * Custom widget manifest definition.
*/ */
@ -8,6 +10,9 @@ export interface ICustomWidget {
name: string; name: string;
/** /**
* Widget unique id, probably in npm package format @gristlabs/custom-widget-name. * 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; widgetId: string;
/** /**
@ -25,6 +30,14 @@ export interface ICustomWidget {
* applying the Grist theme. * applying the Grist theme.
*/ */
renderAfterReady?: boolean; 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); 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];
}

View File

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

View File

@ -35,6 +35,11 @@ export interface PublishedPlugin extends BarePlugin {
* as those being developed). * as those being developed).
*/ */
export interface BarePlugin { 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 * 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. * 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; 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 * 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 * that we may in the future also add options for when to activate the plugin, which is for

View File

@ -34,22 +34,6 @@ function getPort(envVarName: string, fallbackPort: number): number {
return val ? parseInt(val, 10) : fallbackPort; 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() { export async function main() {
log.info("=========================================================================="); log.info("==========================================================================");
log.info("== devServer"); log.info("== devServer");
@ -114,14 +98,6 @@ export async function main() {
} }
const server = await mergedServerMain(port, ["home", "docs", "static"]); const server = await mergedServerMain(port, ["home", "docs", "static"]);
await server.addTestingHooks(); 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; return;
} }
@ -155,15 +131,6 @@ export async function main() {
await home.startCopy('webServer', webServerPort); 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) // Bring up the docWorker(s)
log.info("=========================================================================="); log.info("==========================================================================");
log.info("== docWorker"); log.info("== docWorker");

View File

@ -1,4 +1,5 @@
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {ICustomWidget} from 'app/common/CustomWidget';
import {delay} from 'app/common/delay'; import {delay} from 'app/common/delay';
import {DocCreationInfo} from 'app/common/DocListAPI'; import {DocCreationInfo} from 'app/common/DocListAPI';
import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes, 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 {startTestingHooks} from 'app/server/lib/TestingHooks';
import {getTestLoginSystem} from 'app/server/lib/TestLogin'; import {getTestLoginSystem} from 'app/server/lib/TestLogin';
import {addUploadRoute} from 'app/server/lib/uploads'; 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 {setupLocale} from 'app/server/localization';
import axios from 'axios'; import axios from 'axios';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
@ -127,6 +128,9 @@ export class FlexServer implements GristServer {
private _dbManager: HomeDBManager; private _dbManager: HomeDBManager;
private _defaultBaseDomain: string|undefined; private _defaultBaseDomain: string|undefined;
private _pluginUrl: string|undefined; private _pluginUrl: string|undefined;
private _pluginUrlReady: boolean = false;
private _servesPlugins?: boolean;
private _bundledWidgets?: ICustomWidget[];
private _billing: IBilling; private _billing: IBilling;
private _instanceRoot: string; private _instanceRoot: string;
private _docManager: DocManager; private _docManager: DocManager;
@ -169,11 +173,30 @@ export class FlexServer implements GristServer {
private _getLogoutRedirectUrl: (req: express.Request, nextUrl: URL) => Promise<string>; private _getLogoutRedirectUrl: (req: express.Request, nextUrl: URL) => Promise<string>;
private _sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>; private _sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;
private _getLoginSystem?: () => Promise<GristLoginSystem>; 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', constructor(public port: number, public name: string = 'flexServer',
public readonly options: FlexServerOptions = {}) { public readonly options: FlexServerOptions = {}) {
this.app = express(); this.app = express();
this.app.set('port', port); 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.appRoot = getAppRoot();
this.host = process.env.GRIST_HOST || "localhost"; this.host = process.env.GRIST_HOST || "localhost";
log.info(`== Grist version is ${version.version} (commit ${version.gitcommit})`); 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.info.push(['defaultBaseDomain', this._defaultBaseDomain]);
this._pluginUrl = options.pluginUrl || process.env.APP_UNTRUSTED_URL; 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 // The electron build is not supported at this time, but this stub
// implementation of electronServerMethods is present to allow kicking // implementation of electronServerMethods is present to allow kicking
@ -540,12 +562,6 @@ export class FlexServer implements GristServer {
public addStaticAndBowerDirectories() { public addStaticAndBowerDirectories() {
if (this._check('static_and_bower', 'dir')) { return; } if (this._check('static_and_bower', 'dir')) { return; }
this.addTagChecker(); 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, // 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 // 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 // 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. // as an Electron app.
const staticExtDir = getAppPathTo(this.appRoot, 'static') + '_ext'; const staticExtDir = getAppPathTo(this.appRoot, 'static') + '_ext';
const staticExtApp = fse.existsSync(staticExtDir) ? const staticExtApp = fse.existsSync(staticExtDir) ?
express.static(staticExtDir, options) : null; express.static(staticExtDir, serveAnyOrigin) : null;
const staticApp = express.static(getAppPathTo(this.appRoot, 'static'), options); const staticApp = express.static(getAppPathTo(this.appRoot, 'static'), serveAnyOrigin);
const bowerApp = express.static(getAppPathTo(this.appRoot, 'bower_components'), options); const bowerApp = express.static(getAppPathTo(this.appRoot, 'bower_components'), serveAnyOrigin);
if (process.env.GRIST_LOCALES_DIR) { 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)); this.app.use("/locales", this.tagChecker.withTag(locales));
} }
if (staticExtApp) { this.app.use(this.tagChecker.withTag(staticExtApp)); } 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) => this.app.use(/^\/(grist-plugin-api.js)$/, expressWrap(async (req, res) =>
res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')}))); res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')})));
// Plugins get access to static resources without a tag // Plugins get access to static resources without a tag
this.app.use(limitToPlugins(express.static(getAppPathTo(this.appRoot, 'static')))); this.app.use(limitToPlugins(this, 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, 'bower_components'))));
// Serve custom-widget.html message for anyone. // Serve custom-widget.html message for anyone.
this.app.use(/^\/(custom-widget.html)$/, expressWrap(async (req, res) => this.app.use(/^\/(custom-widget.html)$/, expressWrap(async (req, res) =>
res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')}))); res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')})));
this.addOrg(); this.addOrg();
addPluginEndpoints(this, await this._addPluginManager()); 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. // Prepare cache for managing org-to-host relationship.
public addHosts() { public addHosts() {
if (this._check('hosts', 'homedb')) { return; } 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() { public async initHomeDBManager() {
@ -706,7 +741,7 @@ export class FlexServer implements GristServer {
// ApiServer's constructor adds endpoints to the app. // ApiServer's constructor adds endpoints to the app.
// tslint:disable-next-line:no-unused-expression // 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() { public addBillingApi() {
@ -1420,7 +1455,7 @@ export class FlexServer implements GristServer {
}), jsonErrorHandler); // Add a final error handler that reports errors as JSON. }), jsonErrorHandler); // Add a final error handler that reports errors as JSON.
} }
public finalize() { public finalizeEndpoints() {
this.addApiErrorHandlers(); this.addApiErrorHandlers();
// add a final non-found handler for other content. // 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() { public summary() {
for (const [label, value] of this.info) { for (const [label, value] of this.info) {
log.info("== %s: %s", label, value); 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() { public checkOptionCombinations() {
// Check for some bad combinations we should warn about. // Check for some bad combinations we should warn about.
const allowedWebhookDomains = appSettings.section('integrations').flag('allowedWebhookDomains').readString({ const allowedWebhookDomains = appSettings.section('integrations').flag('allowedWebhookDomains').readString({
@ -1565,9 +1672,12 @@ export class FlexServer implements GristServer {
await this.housekeeper.start(); 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(); 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 { public getTag(): string {
if (!this.tag) {
throw new Error('getTag called too early');
}
return this.tag; return this.tag;
} }
@ -1934,12 +2047,19 @@ export class FlexServer implements GristServer {
private async _startServers(server: http.Server, httpsServer: https.Server|undefined, private async _startServers(server: http.Server, httpsServer: https.Server|undefined,
name: string, port: number, verbose: boolean) { name: string, port: number, verbose: boolean) {
await listenPromise(server.listen(port, this.host)); 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) { if (TEST_HTTPS_OFFSET && httpsServer) {
const httpsPort = port + TEST_HTTPS_OFFSET; if (port === 0) { throw new Error('cannot use https with OS-assigned port'); }
await listenPromise(httpsServer.listen(httpsPort, this.host)); httpsServerPort = port + TEST_HTTPS_OFFSET;
if (verbose) { log.info(`${name} available at https://${this.host}:${httpsPort}`); } 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) { private async _recordNewUserInfo(row: object) {
@ -2194,3 +2314,10 @@ export interface ElectronServerMethods {
updateUserConfig(obj: any): Promise<void>; updateUserConfig(obj: any): Promise<void>;
onBackupMade(cb: () => void): 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", "*");
}
};

View File

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

View File

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

View File

@ -137,8 +137,10 @@ async function scanDirectory(dir: string, kind: "installed"|"builtIn"): Promise<
try { try {
listDir = await fse.readdir(dir); listDir = await fse.readdir(dir);
} catch (e) { } catch (e) {
// non existing dir is treated as an empty dir // Non existing dir is treated as an empty dir.
log.info(`No plugins directory: ${e.message}`); // 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 []; return [];
} }

View File

@ -1,8 +1,17 @@
import {ICustomWidget} from 'app/common/CustomWidget'; import {ICustomWidget} from 'app/common/CustomWidget';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import * as fse from 'fs-extra';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import * as path from 'path';
import {ApiError} from 'app/common/ApiError'; 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 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. * Widget Repository returns list of available Custom Widgets.
@ -11,28 +20,90 @@ export interface IWidgetRepository {
getWidgets(): Promise<ICustomWidget[]>; 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 { export class DelayedWidgetRepository implements IWidgetRepository {
constructor(protected _staticUrl = STATIC_URL) {} private _repo: AsyncCreate<IWidgetRepository|undefined>;
/** constructor(_makeRepo: () => Promise<IWidgetRepository|undefined>) {
* Method exposed for testing, overrides widget url. this._repo = new AsyncCreate(_makeRepo);
*/
public testOverrideUrl(url: string) {
this._staticUrl = url;
} }
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[]> { public async getWidgets(): Promise<ICustomWidget[]> {
if (!this._staticUrl) { if (!this._staticUrl) {
log.warn( 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.' ? ' Missing GRIST_WIDGET_LIST_URL environmental variable.'
: '' : '')
); );
return []; return [];
} }
@ -52,6 +123,7 @@ export class WidgetRepositoryImpl implements IWidgetRepository {
if (!widgets || !Array.isArray(widgets)) { if (!widgets || !Array.isArray(widgets)) {
throw new ApiError('WidgetRepository: Error reading widget list', 500); throw new ApiError('WidgetRepository: Error reading widget list', 500);
} }
fixUrls(widgets, this._staticUrl);
return widgets; return widgets;
} catch (err) { } catch (err) {
if (!(err instanceof ApiError)) { 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. * Version of WidgetRepository that caches successful result for 2 minutes.
*/ */
@ -91,6 +218,60 @@ class CachedWidgetRepository extends WidgetRepositoryImpl {
/** /**
* Returns widget repository implementation. * Returns widget repository implementation.
*/ */
export function buildWidgetRepository() { export function buildWidgetRepository(gristServer: GristServer,
return new CachedWidgetRepository(); 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;
} }

View File

@ -1,8 +1,10 @@
import { ApiError } from 'app/common/ApiError'; import { ApiError } from 'app/common/ApiError';
import { mapGetOrSet, MapWithTTL } from 'app/common/AsyncCreate'; import { mapGetOrSet, MapWithTTL } from 'app/common/AsyncCreate';
import { extractOrgParts, getHostType, getKnownOrg } from 'app/common/gristUrls'; import { extractOrgParts, getHostType, getKnownOrg } from 'app/common/gristUrls';
import { isAffirmative } from 'app/common/gutil';
import { Organization } from 'app/gen-server/entity/Organization'; import { Organization } from 'app/gen-server/entity/Organization';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
import { GristServer } from 'app/server/lib/GristServer';
import { getOriginUrl } from 'app/server/lib/requestUtils'; import { getOriginUrl } from 'app/server/lib/requestUtils';
import { NextFunction, Request, RequestHandler, Response } from 'express'; import { NextFunction, Request, RequestHandler, Response } from 'express';
import { IncomingMessage } from 'http'; 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. // baseDomain should start with ".". It may be undefined for localhost or single-org mode.
constructor(private _baseDomain: string|undefined, private _dbManager: HomeDBManager, 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) { 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});
} }
} }

View File

@ -50,7 +50,11 @@ export class ManifestError extends Error {
*/ */
export async function readManifest(pluginPath: string): Promise<BarePlugin> { export async function readManifest(pluginPath: string): Promise<BarePlugin> {
const notices: string[] = []; 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)) { if (isValidManifest(manifest, notices)) {
return manifest as BarePlugin; 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 _readManifest(pluginPath: string): Promise<object> {
async function readManifestFile(fileExtension: string): Promise<string> {
return await fse.readFile(path.join(pluginPath, "manifest." + fileExtension), "utf8");
}
try { try {
return yaml.safeLoad(await readManifestFile("yml")); return yaml.safeLoad(await readManifestFile("yml"));
} catch (e) { } catch (e) {
@ -73,7 +80,4 @@ async function _readManifest(pluginPath: string): Promise<object> {
} }
throw new Error('cannot read manifest file: ' + e.message); 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");
}
} }

View File

@ -33,7 +33,7 @@ export interface ISendAppPageOptions {
googleTagManager?: true | false | 'anon'; googleTagManager?: true | false | 'anon';
} }
export interface MakeGristConfigOptons { export interface MakeGristConfigOptions {
homeUrl: string|null; homeUrl: string|null;
extra: Partial<GristLoadConfig>; extra: Partial<GristLoadConfig>;
baseDomain?: string; baseDomain?: string;
@ -41,7 +41,7 @@ export interface MakeGristConfigOptons {
server?: GristServer|null; server?: GristServer|null;
} }
export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig { export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfig {
const {homeUrl, extra, baseDomain, req, server} = options; const {homeUrl, extra, baseDomain, req, server} = options;
// .invalid is a TLD the IETF promises will never exist. // .invalid is a TLD the IETF promises will never exist.
const pluginUrl = process.env.APP_UNTRUSTED_URL || 'http://plugins.invalid'; 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, maxUploadSizeImport: (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || undefined,
maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined, maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined,
timestampMs: Date.now(), 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), survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID, tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
activation: getActivation(req as RequestWithLogin | undefined), activation: getActivation(req as RequestWithLogin | undefined),
@ -78,7 +79,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
featureComments: isAffirmative(process.env.COMMENTS), featureComments: isAffirmative(process.env.COMMENTS),
featureFormulaAssistant: Boolean(process.env.OPENAI_API_KEY || process.env.ASSISTANT_CHAT_COMPLETION_ENDPOINT), featureFormulaAssistant: Boolean(process.env.OPENAI_API_KEY || process.env.ASSISTANT_CHAT_COMPLETION_ENDPOINT),
assistantService: process.env.OPENAI_API_KEY ? 'OpenAI' : undefined, assistantService: process.env.OPENAI_API_KEY ? 'OpenAI' : undefined,
permittedCustomWidgets: getPermittedCustomWidgets(), permittedCustomWidgets: getPermittedCustomWidgets(server),
gristNewColumnMenu: isAffirmative(process.env.GRIST_NEW_COLUMN_MENU), gristNewColumnMenu: isAffirmative(process.env.GRIST_NEW_COLUMN_MENU),
supportEmail: SUPPORT_EMAIL, supportEmail: SUPPORT_EMAIL,
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale, userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
@ -171,9 +172,26 @@ function getFeatures(): IFeature[] {
return Features.checkAll(difference(enabledFeatures, disabledFeatures)); 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}`) ?? []; const widgetsList = process.env.PERMITTED_CUSTOM_WIDGETS?.split(',').map(widgetName=>`custom.${widgetName}`) ?? [];
return AttachedCustomWidgets.checkAll(widgetsList); return AttachedCustomWidgets.checkAll(widgetsList);
} }
function configuredPageTitleSuffix() { function configuredPageTitleSuffix() {

View File

@ -31,6 +31,26 @@ export function parseServerTypes(serverTypes: string|undefined): ServerType[] {
return types as 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 { interface ServerOptions extends FlexServerOptions {
logToConsole?: boolean; // If set, messages logged to console (default: false) logToConsole?: boolean; // If set, messages logged to console (default: false)
// (but if options are not given at all in call to main, // (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); 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) { if (options.loginSystem) {
server.setLoginSystem(options.loginSystem); server.setLoginSystem(options.loginSystem);
} }
@ -141,10 +169,11 @@ export async function main(port: number, serverTypes: ServerType[],
server.addClientSecrets(); server.addClientSecrets();
} }
server.finalize(); server.finalizeEndpoints();
await server.finalizePlugins(includeHome ? checkUserContentPort() : null);
server.checkOptionCombinations(); server.checkOptionCombinations();
server.summary(); server.summary();
server.ready();
return server; return server;
} catch(e) { } catch(e) {
await server.close(); await server.close();

View File

@ -107,6 +107,12 @@ export async function main() {
// Set directory for uploaded documents. // Set directory for uploaded documents.
setDefaultEnv('GRIST_DATA_DIR', 'docs'); setDefaultEnv('GRIST_DATA_DIR', 'docs');
setDefaultEnv('GRIST_SERVERS', 'home,docs,static'); 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); const serverTypes = parseServerTypes(process.env.GRIST_SERVERS);
await fse.mkdirp(process.env.GRIST_DATA_DIR!); await fse.mkdirp(process.env.GRIST_DATA_DIR!);

View File

@ -16,13 +16,20 @@ describe('AttachedCustomWidget', function () {
// Valid widget url. // Valid widget url.
const widgetEndpoint = '/widget'; const widgetEndpoint = '/widget';
// Create some widgets: // 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 = ''; let widgetServerUrl = '';
// Holds widgets manifest content. // Holds widgets manifest content.
let widgets: ICustomWidget[] = []; let widgets: ICustomWidget[] = [];
// Switches widget manifest url // Switches widget manifest url
function useManifest(url: string) { async function useManifest(url: string) {
return server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : ''); 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(){ async function buildWidgetServer(){
@ -77,8 +84,8 @@ describe('AttachedCustomWidget', function () {
it('should be able to attach Calendar Widget', async () => { it('should be able to attach Calendar Widget', async () => {
await gu.openAddWidgetToPage(); await gu.openAddWidgetToPage();
const notepadElement = await driver.findContent('.test-wselect-type', /Calendar/); const calendarElement = await driver.findContent('.test-wselect-type', /Calendar/);
assert.exists(notepadElement, 'Calendar widget is not found in the list of widgets'); assert.exists(calendarElement, 'Calendar widget is not found in the list of widgets');
}); });
it('should not ask for permission', async () => { it('should not ask for permission', async () => {

View File

@ -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 {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
import {AccessTokenResult} from 'app/plugin/GristAPI'; import {AccessTokenResult} from 'app/plugin/GristAPI';
import {TableOperations} from 'app/plugin/TableOperations'; import {TableOperations} from 'app/plugin/TableOperations';
import {getAppRoot} from 'app/server/lib/places'; 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 fetch from 'node-fetch';
import * as path from 'path'; 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. // Valid manifest url.
const manifestEndpoint = '/manifest.json'; const manifestEndpoint = '/manifest.json';
@ -45,14 +48,18 @@ function getCustomWidgetFrame() {
describe('CustomWidgets', function () { describe('CustomWidgets', function () {
this.timeout(20000); this.timeout(20000);
gu.bigScreen();
const cleanup = setupTestSuite(); const cleanup = setupTestSuite();
// Holds url for sample widget server. // Holds url for sample widget server.
let widgetServerUrl = ''; let widgetServerUrl = '';
// Switches widget manifest url // Switches widget manifest url
function useManifest(url: string) { async function useManifest(url: string) {
return server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : ''); 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(''); await server.testingHooks.setWidgetRepositoryUrl('');
}); });
after(async function() {
await server.testingHooks.setWidgetRepositoryUrl('');
});
after(async function() {
await server.testingHooks.setWidgetRepositoryUrl('');
});
// Open or close widget menu. // Open or close widget menu.
const toggle = async () => await driver.findWait('.test-config-widget-select .test-select-open', 1000).click(); const toggle = async () => await driver.findWait('.test-config-widget-select .test-select-open', 1000).click();
// Get current value from widget menu. // Get current value from widget menu.
@ -215,16 +214,17 @@ describe('CustomWidgets', function () {
// Rejects new access level. // Rejects new access level.
const reject = () => driver.find(".test-config-widget-access-reject").click(); 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', () => { describe('RightWidgetMenu', () => {
beforeEach(async function () { beforeEach(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();
});
it('should show widgets in dropdown', async () => { it('should show widgets in dropdown', async () => {
await gu.toggleSidePanel('right', 'open'); await gu.toggleSidePanel('right', 'open');
@ -364,7 +364,12 @@ describe('CustomWidgets', function () {
await recreatePanel(); 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. // Select widget1 and then remove it from the list.
await toggle(); await toggle();
await select(widget1.name); 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');
});
});
});
}); });

View File

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

View File

@ -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 * Returns an instance of `LockableClipboard`, making sure to unlock it after
* each test. * each test.

View File

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