From 4f3d0d41a0a29daef80212256bd48e2128aaaca3 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Fri, 6 Oct 2023 21:38:37 -0400 Subject: [PATCH] some cleanup --- app/client/components/CustomCalendarView.ts | 43 +---- app/client/components/CustomView.ts | 65 +++----- app/client/components/WidgetFrame.ts | 58 +++---- app/client/models/AppModel.ts | 7 + app/client/models/entities/ViewSectionRec.ts | 8 +- app/client/ui/CustomSectionConfig.ts | 64 ++----- app/client/ui2018/menus.ts | 1 - app/common/CustomWidget.ts | 68 ++------ app/plugin/PluginManifest.ts | 10 ++ app/server/lib/FlexServer.ts | 148 +++++++++------- app/server/lib/GristServer.ts | 4 +- app/server/lib/PluginEndpoint.ts | 2 +- app/server/lib/PluginManager.ts | 1 - app/server/lib/WidgetRepository.ts | 167 +++++++++---------- app/server/lib/sendAppPage.ts | 2 +- app/server/mergedServerMain.ts | 25 +-- package.json | 4 +- yarn.lock | 66 +++----- 18 files changed, 305 insertions(+), 438 deletions(-) diff --git a/app/client/components/CustomCalendarView.ts b/app/client/components/CustomCalendarView.ts index f7e1f20c..b0e934b7 100644 --- a/app/client/components/CustomCalendarView.ts +++ b/app/client/components/CustomCalendarView.ts @@ -1,49 +1,8 @@ -// import {AccessLevel} from "app/common/CustomWidget"; -// import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec"; import { CustomView, CustomViewSettings } from "app/client/components/CustomView"; import { AccessLevel } from "app/common/CustomWidget"; -// import {GristDoc} from "app/client/components/GristDoc"; -// import {reportError} from 'app/client/models/errors'; - -//Abstract class for more future inheritances -// abstract class CustomAttachedView extends CustomView { - /* - public override create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { - super.create(gristDoc, viewSectionModel); - if (viewSectionModel.customDef.access.peek() !== AccessLevel.full) { - void viewSectionModel.customDef.access.setAndSave(AccessLevel.full).catch((err)=>{ - if (err?.code === "ACL_DENY") { - // do nothing, we might be in a readonly mode. - return; - } - reportError(err); - }); - } - - const widgetsApi = this.gristDoc.app.topAppModel; - 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 CustomView { - protected getInitialSettings(): CustomViewSettings { + protected getBuiltInSettings(): CustomViewSettings { return { widgetId: '@gristlabs/widget-calendar', accessLevel: AccessLevel.full, diff --git a/app/client/components/CustomView.ts b/app/client/components/CustomView.ts index 55ac3714..bf43e4fe 100644 --- a/app/client/components/CustomView.ts +++ b/app/client/components/CustomView.ts @@ -32,6 +32,13 @@ import {dom as grains} from 'grainjs'; import * as ko from 'knockout'; import defaults = require('lodash/defaults'); +/** + * + * Built in settings for a custom widget. Used when the custom + * widget is the implementation of a native-looking widget, + * for example the calendar widget. + * + */ export interface CustomViewSettings { widgetId?: string; accessLevel?: AccessLevel; @@ -106,42 +113,6 @@ export class CustomView extends Disposable { this.viewPane = this.autoDispose(this._buildDom()); this._updatePluginInstance(); - - this.dealWithBundledWidgets(gristDoc, viewSectionModel); - } - - public dealWithBundledWidgets(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { - const settings = this.getInitialSettings(); - console.log("dealWith!", {settings}); - if (!settings.widgetId) { return; } - if (viewSectionModel.customDef.access.peek() !== AccessLevel.full) { - void viewSectionModel.customDef.access.setAndSave(AccessLevel.full).catch((err)=>{ - if (err?.code === "ACL_DENY") { - // do nothing, we might be in a readonly mode. - return; - } - reportError(err); - }); - } - - const widgetsApi = this.gristDoc.app.topAppModel; - widgetsApi.getWidgets().then(async result=>{ - const widget = result.find(w => w.widgetId === settings.widgetId); - console.log("FOUND", {widget}); - if (widget && this.customDef.widgetId.peek() !== widget.widgetId) { - console.log("SET!!"); - await this.customDef.widgetId.setAndSave(widget.widgetId); - await this.customDef.pluginId.setAndSave(widget.fromPlugin||''); - } - }).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. - } - }); } public async triggerPrint() { @@ -150,10 +121,10 @@ export class CustomView extends Disposable { } } - protected getInitialSettings(): CustomViewSettings { + protected getBuiltInSettings(): CustomViewSettings { return {}; } - + protected getEmptyWidgetPage(): string { return new URL("custom-widget.html", getGristConfig().homeUrl!).href; } @@ -201,10 +172,7 @@ export class CustomView extends Disposable { const showPlugin = ko.pureComputed(() => this.customDef.mode() === "plugin"); const showAfterReady = () => { // The empty widget page calls `grist.ready()`. - // Pending: URLs set now only when user actually enters a URL, - // so this could be breaking pages without grist.ready() call - // added to manifests. - if (!url()) { return true; } + if (!url() && !widgetId()) { return true; } return renderAfterReady(); }; @@ -216,19 +184,25 @@ export class CustomView extends Disposable { // For the view to update when switching from one section to another one, the computed // observable must always notify. .extend({notify: 'always'}); + // Some widgets have built-in settings that should override anything + // that is in the rest of the view options. Ideally, everything would + // be consistent. We could fix inconsistencies if we find them, but + // we are not guaranteed to have write privileges at this point. + const builtInSettings = this.getBuiltInSettings(); return dom('div.flexauto.flexvbox.custom_view_container', dom.autoDispose(showPlugin), dom.autoDispose(showPluginNotification), dom.autoDispose(showSectionNotification), dom.autoDispose(showPluginContent), // todo: should display content in webview when running electron - kd.scope(() => [mode(), url(), access(), widgetId(), pluginId()], ([_mode, _url, _access, _widgetId, _pluginId]: string[]) => + kd.scope(() => [mode(), url(), access(), widgetId(), pluginId()], + ([_mode, _url, _access, _widgetId, _pluginId]: string[]) => _mode === "url" ? this._buildIFrame({ baseUrl: _url, - access: (_access as AccessLevel || AccessLevel.none), + access: builtInSettings.accessLevel || (_access as AccessLevel || AccessLevel.none), showAfterReady: showAfterReady(), - widgetId: _widgetId, + widgetId: builtInSettings.widgetId || _widgetId, pluginId: _pluginId, }) : null @@ -267,7 +241,6 @@ export class CustomView extends Disposable { url: baseUrl || this.getEmptyWidgetPage(), widgetId, pluginId, - emptyUrl: this.getEmptyWidgetPage(), access, readonly: this.gristDoc.isReadonly.get(), showAfterReady, diff --git a/app/client/components/WidgetFrame.ts b/app/client/components/WidgetFrame.ts index 575e1177..b8e1a949 100644 --- a/app/client/components/WidgetFrame.ts +++ b/app/client/components/WidgetFrame.ts @@ -6,7 +6,8 @@ import {hooks} from 'app/client/Hooks'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {makeTestId} from 'app/client/lib/domUtils'; import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; -import {AccessLevel, ICustomWidget, isSatisfied, matchWidget } from 'app/common/CustomWidget'; +import {reportError} from 'app/client/models/errors'; +import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions'; import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes'; @@ -19,7 +20,6 @@ import noop = require('lodash/noop'); import debounce = require('lodash/debounce'); import isEqual = require('lodash/isEqual'); import flatMap = require('lodash/flatMap'); -import { reportError } from '../models/errors'; const testId = makeTestId('test-custom-widget-'); @@ -44,9 +44,15 @@ export interface WidgetFrameOptions { * Url of external page. Iframe is rebuild each time the URL changes. */ url: string; + /** + * ID of widget, if known. When set, the url for the specified widget + * in the WidgetRepository, if found, will take precedence. + */ widgetId?: string|null; + /** + * ID of the plugin that provided the widget (if it came from a plugin). + */ pluginId?: string; - emptyUrl: string; /** * Assigned access level. Iframe is rebuild each time access level is changed. */ @@ -77,7 +83,9 @@ export interface WidgetFrameOptions { * Optional handler to modify the iframe. */ onElem?: (iframe: HTMLIFrameElement) => void; - + /** + * The containing document. + */ gristDoc: GristDoc; } @@ -93,7 +101,8 @@ export class WidgetFrame extends DisposableWithEvents { private _readyCalled = Observable.create(this, false); // Whether the iframe is visible. private _visible = Observable.create(this, !this._options.showAfterReady); - public readonly _widgets = Observable.create(this, []); + // An entry for this widget in the WidgetRepository, if available. + public readonly _widget = Observable.create(this, null); constructor(private _options: WidgetFrameOptions) { super(); @@ -121,7 +130,7 @@ export class WidgetFrame extends DisposableWithEvents { // Call custom configuration handler. _options.configure?.(this); - this._fetchWidgets().catch(reportError); + this._checkWidgetRepository().catch(reportError); } /** @@ -184,16 +193,14 @@ export class WidgetFrame extends DisposableWithEvents { 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._widgets))), - { - ...hooks.iframeAttributes, - }, + dom.attr('src', use => this._getUrl(use(this._widget))), + hooks.iframeAttributes, testId('ready', this._readyCalled), )) ); } - private _getUrl(widgets: ICustomWidget[]): string { + private _getUrl(widget: ICustomWidget|null): string { // Append access level to query string. const urlWithAccess = (url: string) => { if (!url) { @@ -204,20 +211,8 @@ export class WidgetFrame extends DisposableWithEvents { urlObj.searchParams.append('readonly', String(this._options.readonly)); return urlObj.href; }; - const {widgetId, pluginId} = this._options; - let url = this._options.url; - if (widgetId) { - console.log("Iframe match starting"); - const widget = matchWidget(widgets, {widgetId, pluginId}); - console.log("Iframe match done"); - if (widget) { - url = widget.url; - } else { - return 'about:blank'; - } - } - const fullUrl = urlWithAccess(url); - return fullUrl; + const url = widget?.url || this._options.url || 'about:blank'; + return urlWithAccess(url); } private _onMessage(event: MessageEvent) { @@ -245,12 +240,17 @@ export class WidgetFrame extends DisposableWithEvents { } } - private async _fetchWidgets() { - if (this.isDisposed()) { return; } + /** + * 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; } - this._widgets.set(widgets); - console.log("SAVED", {widgets}); + const widget = matchWidget(widgets, {widgetId, pluginId}); + this._widget.set(widget || null); } } diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 9b2d1c42..14f37176 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -78,6 +78,10 @@ export interface TopAppModel { */ fetchUsersAndOrgs(): Promise; + /** + * Enumerate the widgets in the WidgetRepository for this installation + * of Grist. + */ getWidgets(): Promise; } @@ -147,6 +151,9 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { public readonly users = Observable.create(this, []); public readonly plugins: LocalPlugin[] = []; 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; constructor( diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 57d60ce7..8208671f 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -254,11 +254,7 @@ export interface CustomViewSectionDef { * widgets available. */ widgetId: modelUtil.KoSaveableObservable; - /** - * Custom widget information. - */ - // widgetDef: modelUtil.KoSaveableObservable; - /** + /** * Custom widget options. */ widgetOptions: modelUtil.KoSaveableObservable|null>; @@ -333,7 +329,6 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): const customViewDefaults = { mode: 'url', url: null, - // widgetDef: null, access: '', pluginId: '', sectionId: '', @@ -346,7 +341,6 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): mode: customDefObj.prop('mode'), url: customDefObj.prop('url'), widgetId: customDefObj.prop('widgetId'), - // widgetDef: customDefObj.prop('widgetDef'), widgetOptions: customDefObj.prop('widgetOptions'), columnsMapping: customDefObj.prop('columnsMapping'), access: customDefObj.prop('access'), diff --git a/app/client/ui/CustomSectionConfig.ts b/app/client/ui/CustomSectionConfig.ts index 2acb4f69..982f7cad 100644 --- a/app/client/ui/CustomSectionConfig.ts +++ b/app/client/ui/CustomSectionConfig.ts @@ -15,7 +15,7 @@ import {IconName} from 'app/client/ui2018/IconList'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus'; -import { AccessLevel, ICustomWidget, isSatisfied, matchWidget } from 'app/common/CustomWidget'; +import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget'; import {GristLoadConfig} from 'app/common/gristUrls'; import {unwrap} from 'app/common/gutil'; import { @@ -322,8 +322,7 @@ export class CustomSectionConfig extends Disposable { // Test if we can offer widget list. const gristConfig: GristLoadConfig = (window as any).gristConfig || {}; - console.log("Ignoring gristConfig now", {gristConfig}); - this._canSelect = true; // gristConfig.enableWidgetRepository ?? true; + this._canSelect = gristConfig.enableWidgetRepository ?? true; // Array of available widgets - will be updated asynchronously. this._widgets = Observable.create(this, []); @@ -335,13 +334,12 @@ export class CustomSectionConfig extends Disposable { const widgetId = use(_section.customDef.widgetId); const pluginId = use(_section.customDef.pluginId); if (widgetId) { - console.log("_selectedId", {widgetId, pluginId}); - return (pluginId||'') + ':' + widgetId; + // selection id is "pluginId:widgetId" + return (pluginId || '') + ':' + widgetId; } return CUSTOM_ID; }); this._selectedId.onWrite(async value => { - console.log("_selectedId onWrite", {value}); if (value === CUSTOM_ID) { // Select Custom URL bundleChanges(() => { @@ -351,9 +349,8 @@ export class CustomSectionConfig extends Disposable { _section.customDef.url(null); // Clear widgetId _section.customDef.widgetId(null); + // Clear pluginId _section.customDef.pluginId(''); - // Clear widget definition. - // _section.customDef.widgetDef(null); // Reset access level to none. _section.customDef.access(AccessLevel.none); // Clear all saved options. @@ -369,13 +366,10 @@ export class CustomSectionConfig extends Disposable { } else { const [pluginId, widgetId] = value?.split(':') || []; // Select Widget - console.log("Start match"); const selectedWidget = matchWidget(this._widgets.get(), { widgetId, pluginId, }); - console.log("Started match"); - console.log("SETTING", {pluginId, widgetId, selectedWidget}); if (!selectedWidget) { // should not happen throw new Error('Error accessing widget from the list'); @@ -383,12 +377,6 @@ export class CustomSectionConfig extends Disposable { // If user selected the same one, do nothing. if (_section.customDef.widgetId.peek() === widgetId && _section.customDef.pluginId.peek() === pluginId) { - console.log("DO NOTHING", { - widgetId, - pluginId, - owidgetId: _section.customDef.widgetId.peek(), - opluginId: _section.customDef.pluginId.peek(), - }); return; } bundleChanges(() => { @@ -398,18 +386,11 @@ export class CustomSectionConfig extends Disposable { _section.customDef.access(AccessLevel.none); // When widget wants some access, set desired access level. this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none); - // Update widget definition. - // _section.customDef.widgetDef(selectedWidget); // Update widgetId. _section.customDef.widgetId(selectedWidget.widgetId); - _section.customDef.pluginId(selectedWidget.fromPlugin || ''); - console.log({ - setty: 1, - widgetId: selectedWidget.widgetId, - pluginId: selectedWidget.fromPlugin || '', - selectedWidget - }); - // Update widget URL. + // Update pluginId. + _section.customDef.pluginId(selectedWidget.source?.pluginId || ''); + // Update widget URL. Leave blank when widgetId is set. _section.customDef.url(null); // Clear options. _section.customDef.widgetOptions(null); @@ -420,7 +401,6 @@ export class CustomSectionConfig extends Disposable { _section.columnsToMap(null); }); await _section.saveCustomDef(); - console.log("CustomSectionConfig saved"); } }); @@ -431,11 +411,12 @@ export class CustomSectionConfig extends Disposable { bundleChanges(() => { _section.customDef.renderAfterReady(false); if (newUrl) { - console.log("ZAP widgetId and pluginId"); + // When a URL is set explicitly, make sure widgetId/pluginId + // is empty. _section.customDef.widgetId(null); _section.customDef.pluginId(''); } - //_section.customDef.url(newUrl); + _section.customDef.url(newUrl); }); await _section.saveCustomDef(); }); @@ -460,11 +441,6 @@ export class CustomSectionConfig extends Disposable { const holder = new MultiHolder(); // Show prompt, when desired access level is different from actual one. - function makeLabel(widget: ICustomWidget) { - if (!widget.fromPlugin) { return widget.name; } - const group = widget.fromPlugin.replace('builtIn/', ''); - return `${widget.name} (${group})`; - } const prompt = Computed.create(holder, use => use(this._desiredAccess) && !isSatisfied(use(this._currentAccess), use(this._desiredAccess)!)); @@ -476,7 +452,8 @@ export class CustomSectionConfig extends Disposable { const options = Computed.create(holder, use => [ {label: 'Custom URL', value: 'custom'}, ...use(this._widgets).map(w => ({ - label: makeLabel(w), value: ((w.fromPlugin||'') + ':' + w.widgetId) + label: w.source?.name ? `${w.name} (${w.source.name})` : w.name, + value: (w.source?.pluginId || '') + ':' + w.widgetId, })), ]); function buildPrompt(level: AccessLevel|null) { @@ -583,21 +560,6 @@ export class CustomSectionConfig extends Disposable { protected async _getWidgets() { const widgets = await this._gristDoc.app.topAppModel.getWidgets(); - /* - const widgets = filterWidgets(widgets1, { - keepWidgetIdUnique: true, - preferPlugin: false, - }); - */ - // const wigets = await api.getWidgets(); - // 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()) { - // wigets.push(this._section.customDef.widgetDef.peek()!); - // } - } this._widgets.set(widgets); } diff --git a/app/client/ui2018/menus.ts b/app/client/ui2018/menus.ts index 4eafce6d..88d6f0c1 100644 --- a/app/client/ui2018/menus.ts +++ b/app/client/ui2018/menus.ts @@ -220,7 +220,6 @@ export function multiSelect(selectedOptions: MutableObsArray, }, dom.domComputed(selectedOptionsSet, selectedOpts => { return dom.forEach(availableOptions, option => { - console.log(">>> option", {availableOptions}); const fullOption = weasel.getOptionFull(option); return cssCheckboxLabel( cssCheckboxSquare( diff --git a/app/common/CustomWidget.ts b/app/common/CustomWidget.ts index 9b1a9b4f..ce70700e 100644 --- a/app/common/CustomWidget.ts +++ b/app/common/CustomWidget.ts @@ -31,7 +31,13 @@ export interface ICustomWidget { */ renderAfterReady?: boolean; - fromPlugin?: string; + /** + * If the widget came from a plugin, we track that here. + */ + source?: { + pluginId: string; + name: string; + }; } /** @@ -64,64 +70,20 @@ export function isSatisfied(current: AccessLevel, minimum: AccessLevel) { return ordered(current) >= ordered(minimum); } +/** + * Find the best match for a widgetId/pluginId combination among the + * given widgets. An exact widgetId match is required. A pluginId match + * is preferred but not required. + */ export function matchWidget(widgets: ICustomWidget[], options: { - widgetId?: string, + widgetId: string, pluginId?: string, }): ICustomWidget|undefined { - console.log("MATCHING", { - widgets, - options, - }); const prefs = sortBy(widgets, (w) => { return [w.widgetId !== options.widgetId, - (w.fromPlugin||'') !== options.pluginId] + (w.source?.pluginId||'') !== options.pluginId] }); if (prefs.length === 0) { return; } - if (options.widgetId && prefs[0].widgetId !== options.widgetId) { - return; - } - console.log("ORDERED", prefs); - console.log("MATCHED", prefs[0]); + if (prefs[0].widgetId !== options.widgetId) { return }; return prefs[0]; } - -export function filterWidgets(widgets: ICustomWidget[], options: { - preferPlugin?: boolean, - keepWidgetIdUnique?: boolean, -}) { - const folders = new Map(); - for (const widget of widgets) { - const widgetId = widget.widgetId; - if (!folders.has(widgetId)) { folders.set(widgetId, []); } - const widgetFolder = folders.get(widgetId)!; - widgetFolder.push(widget); - } - let finalResults: ICustomWidget[] = widgets; - if (options.preferPlugin !== undefined) { - const results = []; - const seen = new Set(); - for (const widget of widgets) { - const folder = folders.get(widget.widgetId)!; - if (folder.length === 1) { - results.push(widget); - continue; - } - if (seen.has(widget.widgetId)) { continue; } - seen.add(widget.widgetId); - const folderSorted = sortBy(folder, (w) => Boolean(w.fromPlugin) !== options.preferPlugin); - results.push(folderSorted[0]!); - } - finalResults = results; - } - if (options.keepWidgetIdUnique) { - const results = []; - const seen = new Set(); - for (const widget of widgets) { - if (seen.has(widget.widgetId)) { continue; } - seen.add(widget.widgetId); - results.push(widget); - } - finalResults = results; - } - return finalResults; -} diff --git a/app/plugin/PluginManifest.ts b/app/plugin/PluginManifest.ts index fd6a94a7..c5451c76 100644 --- a/app/plugin/PluginManifest.ts +++ b/app/plugin/PluginManifest.ts @@ -35,6 +35,11 @@ export interface PublishedPlugin extends BarePlugin { * as those being developed). */ export interface BarePlugin { + /** + * An optional human-readable name. + */ + name?: string; + /** * Components describe how the plugin runs. A plugin may provide UI and behavior that runs in * the browser, Python code that runs in a secure sandbox, and arbitrary code that runs in Node. @@ -82,6 +87,11 @@ export interface BarePlugin { */ unsafeNode?: string; + /** + * Relative path to a specialized manifest of custom widgets. + * I'm unsure how this fits into components and contributions, + * this seemed the least-worst spot for it. + */ widgets?: string; /** diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 940d7dda..e025a7aa 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1,5 +1,5 @@ import {ApiError} from 'app/common/ApiError'; -import { ICustomWidget } from 'app/common/CustomWidget'; +import {ICustomWidget} from 'app/common/CustomWidget'; import {delay} from 'app/common/delay'; import {DocCreationInfo} from 'app/common/DocListAPI'; import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes, @@ -66,7 +66,7 @@ import {getTelemetryPrefs, ITelemetry} from 'app/server/lib/Telemetry'; import {startTestingHooks} from 'app/server/lib/TestingHooks'; import {getTestLoginSystem} from 'app/server/lib/TestLogin'; import {addUploadRoute} from 'app/server/lib/uploads'; -import { buildWidgetRepository, getWidgetPlaces, IWidgetRepository} from 'app/server/lib/WidgetRepository'; +import {buildWidgetRepository, getWidgetsInPlugins, IWidgetRepository} from 'app/server/lib/WidgetRepository'; import {setupLocale} from 'app/server/localization'; import axios from 'axios'; import * as bodyParser from 'body-parser'; @@ -129,8 +129,8 @@ export class FlexServer implements GristServer { private _dbManager: HomeDBManager; private _defaultBaseDomain: string|undefined; private _pluginUrl: string|undefined; - private _pluginUrlSet: boolean = false; - private _willServePlugins?: boolean; + private _pluginUrlReady: boolean = false; + private _servesPlugins?: boolean; private _bundledWidgets?: ICustomWidget[]; private _billing: IBilling; private _instanceRoot: string; @@ -174,11 +174,30 @@ export class FlexServer implements GristServer { private _getLogoutRedirectUrl: (req: express.Request, nextUrl: URL) => Promise; private _sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise; private _getLoginSystem?: () => Promise; + // Called by ready() to allow requests to be served. + private _ready: () => void; + // Set once ready() is called + private _isReady: boolean = false; constructor(public port: number, public name: string = 'flexServer', public readonly options: FlexServerOptions = {}) { this.app = express(); this.app.set('port', port); + + // Before doing anything, we pause any request handling to wait + // for the server being entirely ready. The specific reason to do + // so is because, if we are serving plugins, and using an + // OS-assigned port to do so, we won't know the URL to use for + // plugins until quite late. But it seems a nice thing to + // guarantee in general. + const readyPromise = new Promise(resolve => { + this._ready = () => resolve(undefined); + }); + this.app.use(async (_req, _res, next) => { + await readyPromise; + next(); + }); + this.appRoot = getAppRoot(); this.host = process.env.GRIST_HOST || "localhost"; log.info(`== Grist version is ${version.version} (commit ${version.gitcommit})`); @@ -508,12 +527,6 @@ export class FlexServer implements GristServer { public addStaticAndBowerDirectories() { if (this._check('static_and_bower', 'dir')) { return; } this.addTagChecker(); - // Allow static files to be requested from any origin. - const options: serveStatic.ServeStaticOptions = { - setHeaders: (res, filepath, stat) => { - res.setHeader("Access-Control-Allow-Origin", "*"); - } - }; // Grist has static help files, which may be useful for standalone app, // but for hosted grist the latest help is at support.getgrist.com. Redirect // to this page for the benefit of crawlers which currently rank the static help @@ -526,11 +539,11 @@ export class FlexServer implements GristServer { // as an Electron app. const staticExtDir = getAppPathTo(this.appRoot, 'static') + '_ext'; const staticExtApp = fse.existsSync(staticExtDir) ? - express.static(staticExtDir, options) : null; - const staticApp = express.static(getAppPathTo(this.appRoot, 'static'), options); - const bowerApp = express.static(getAppPathTo(this.appRoot, 'bower_components'), options); + express.static(staticExtDir, serveAnyOrigin) : null; + const staticApp = express.static(getAppPathTo(this.appRoot, 'static'), serveAnyOrigin); + const bowerApp = express.static(getAppPathTo(this.appRoot, 'bower_components'), serveAnyOrigin); if (process.env.GRIST_LOCALES_DIR) { - const locales = express.static(process.env.GRIST_LOCALES_DIR, options); + const locales = express.static(process.env.GRIST_LOCALES_DIR, serveAnyOrigin); this.app.use("/locales", this.tagChecker.withTag(locales)); } if (staticExtApp) { this.app.use(this.tagChecker.withTag(staticExtApp)); } @@ -561,20 +574,13 @@ export class FlexServer implements GristServer { res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')}))); this.addOrg(); addPluginEndpoints(this, await this._addPluginManager()); - const places = getWidgetPlaces(this, 'wotnot'); - // Allow static files to be requested from any origin. - const options: serveStatic.ServeStaticOptions = { - setHeaders: (res, filepath, stat) => { - res.setHeader("Access-Control-Allow-Origin", "*"); - } - }; - for (const place of places) { - this.app.use( - '/widgets/' + place.name, - this.tagChecker.withTag( - limitToPlugins(this, - express.static(place.fileDir, options) - ) + + // Serve bundled custom widgets on the plugin endpoint. + const places = getWidgetsInPlugins(this, ''); + for (const place of places) { + this.app.use( + '/widgets/' + place.pluginId, this.tagChecker.withTag( + limitToPlugins(this, express.static(place.dir, serveAnyOrigin)) ) ); } @@ -1445,25 +1451,29 @@ export class FlexServer implements GristServer { }); } - public setPluginPort(port: number) { - const url = new URL(this.getOwnUrl()); - url.port = String(port); - this._pluginUrl = url.href; - } - - public willServePlugins() { - if (this._willServePlugins === undefined) { - throw new Error('do not know if will serve plugins'); + /** + * 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._willServePlugins; - } - - public setWillServePlugins(flag: boolean) { - this._willServePlugins = flag; + 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._pluginUrlSet) { + if (!this._pluginUrlReady) { throw new Error('looked at plugin url too early'); } return this._pluginUrl; @@ -1476,14 +1486,24 @@ export class FlexServer implements GristServer { return this._pluginManager.getPlugins(); } - public async prepareSummary() { - // Add some information that isn't guaranteed set until the end. - + public async finishPluginSetup(userPort: number|null) { + // If plugin content is served from same host but on different port, + // run webserver on that port + if (userPort !== null) { + 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]); - // plugin url should be finalized by now. - this._pluginUrlSet = true; - this.info.push(['willServePlugins', this._willServePlugins]); - + this._pluginUrlReady = true; + this.info.push(['willServePlugins', this._servesPlugins]); const repo = buildWidgetRepository(this, { localOnly: true }); this._bundledWidgets = await repo.getWidgets(); } @@ -1509,6 +1529,12 @@ export class FlexServer implements GristServer { } } + public ready() { + if (this._isReady) { return; } + this._isReady = true; + this._ready(); + } + public checkOptionCombinations() { // Check for some bad combinations we should warn about. const allowedWebhookDomains = appSettings.section('integrations').flag('allowedWebhookDomains').readString({ @@ -1983,15 +2009,18 @@ export class FlexServer implements GristServer { private async _startServers(server: http.Server, httpsServer: https.Server|undefined, name: string, port: number, verbose: boolean) { await listenPromise(server.listen(port, this.host)); - if (verbose) { log.info(`${name} available at ${this.host}:${port}`); } + const serverPort = (server.address() as AddressInfo).port; + if (verbose) { log.info(`${name} available at ${this.host}:${serverPort}`); } + let httpsServerPort: number|undefined; if (TEST_HTTPS_OFFSET && httpsServer) { - const httpsPort = port + TEST_HTTPS_OFFSET; - await listenPromise(httpsServer.listen(httpsPort, this.host)); - if (verbose) { log.info(`${name} available at https://${this.host}:${httpsPort}`); } + if (port === 0) { throw new Error('cannot use https with OS-assigned port'); } + httpsServerPort = port + TEST_HTTPS_OFFSET; + await listenPromise(httpsServer.listen(httpsServerPort, this.host)); + if (verbose) { log.info(`${name} available at https://${this.host}:${httpsServerPort}`); } } return { - serverPort: (server.address() as AddressInfo).port, - httpsServerPort: (server.address() as AddressInfo)?.port, + serverPort, + httpsServerPort, }; } @@ -2247,3 +2276,10 @@ export interface ElectronServerMethods { updateUserConfig(obj: any): Promise; 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", "*"); + } +}; diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index 7629d61b..39781e8e 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -57,7 +57,7 @@ export interface GristServer { resolveLoginSystem(): Promise; getPluginUrl(): string|undefined; getPlugins(): LocalPlugin[]; - willServePlugins(): boolean; + servesPlugins(): boolean; getBundledWidgets(): ICustomWidget[]; } @@ -142,7 +142,7 @@ export function createDummyGristServer(): GristServer { getAccessTokens() { throw new Error('no access tokens'); }, resolveLoginSystem() { throw new Error('no login system'); }, getPluginUrl() { return undefined; }, - willServePlugins() { return false; }, + servesPlugins() { return false; }, getPlugins() { return []; }, getBundledWidgets() { return []; }, }; diff --git a/app/server/lib/PluginEndpoint.ts b/app/server/lib/PluginEndpoint.ts index fb427fff..fa04db82 100644 --- a/app/server/lib/PluginEndpoint.ts +++ b/app/server/lib/PluginEndpoint.ts @@ -14,7 +14,7 @@ export function getUntrustedContentHost(origin: string|undefined): string|undefi // Add plugin endpoints to be served on untrusted host export function addPluginEndpoints(server: FlexServer, pluginManager: PluginManager) { - if (server.willServePlugins()) { + if (server.servesPlugins()) { server.app.get(/^\/plugins\/(installed|builtIn)\/([^/]+)\/(.+)/, (req, res) => servePluginContent(req, res, pluginManager, server)); } diff --git a/app/server/lib/PluginManager.ts b/app/server/lib/PluginManager.ts index 0939e127..712838b9 100644 --- a/app/server/lib/PluginManager.ts +++ b/app/server/lib/PluginManager.ts @@ -131,7 +131,6 @@ export class PluginManager { async function scanDirectory(dir: string, kind: "installed"|"builtIn"): Promise { - console.log("SCAN", {dir, kind}); const plugins: DirectoryScanEntry[] = []; let listDir; diff --git a/app/server/lib/WidgetRepository.ts b/app/server/lib/WidgetRepository.ts index ed482051..ad61cd38 100644 --- a/app/server/lib/WidgetRepository.ts +++ b/app/server/lib/WidgetRepository.ts @@ -4,11 +4,14 @@ import * as fse from 'fs-extra'; import fetch from 'node-fetch'; import * as path from 'path'; import {ApiError} from 'app/common/ApiError'; +import {removeTrailingSlash} from 'app/common/gutil'; +import {GristServer} from 'app/server/lib/GristServer'; import LRUCache from 'lru-cache'; import * as url from 'url'; -import { removeTrailingSlash } from 'app/common/gutil'; -import { GristServer } from './GristServer'; -// import { LocalPlugin } from 'app/common/plugin'; +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. @@ -17,67 +20,66 @@ export interface IWidgetRepository { getWidgets(): Promise; } -// Static url for StaticWidgetRepository -const STATIC_URL = process.env.GRIST_WIDGET_LIST_URL; - -export class FileWidgetRepository implements IWidgetRepository { - constructor(private _widgetFileName: string, +/** + * + * 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 _pluginId?: string) {} + private _source?: any) {} public async getWidgets(): Promise { - const txt = await fse.readFile(this._widgetFileName, { - encoding: 'utf8', - }); + const txt = await fse.readFile(this._widgetFile, { encoding: 'utf8' }); const widgets: ICustomWidget[] = JSON.parse(txt); fixUrls(widgets, this._widgetBaseUrl); - if (this._pluginId) { + if (this._source) { for (const widget of widgets) { - widget.fromPlugin = this._pluginId; + widget.source = this._source; } } - console.log("FileWidget", {widgets}); return widgets; } } -/* -export class NestedWidgetRepository implements IWidgetRepository { - constructor(private _widgetDir: string, - private _widgetBaseUrl: string) {} - - public async getWidgets(): Promise { - const listDir = await fse.readdir(this._widgetDir, - { withFileTypes: true }); - const fileName = 'manifest.json'; - const allWidgets: ICustomWidget[] = []; - for (const dir of listDir) { - if (!dir.isDirectory()) { continue; } - const fullPath = path.join(this._widgetDir, dir.name, fileName); - if (!await fse.pathExists(fullPath)) { continue; } - const txt = await fse.readFile(fullPath, 'utf8'); - const widgets = JSON.parse(txt); - fixUrls( - widgets, - removeTrailingSlash(this._widgetBaseUrl) + '/' + dir.name + '/' - ); - allWidgets.push(...widgets); - } - return allWidgets; - } -} -*/ - +/** + * + * A wrapper around a widget repository that delays creating it + * until the first call to getWidgets(). + * + */ export class DelayedWidgetRepository implements IWidgetRepository { - constructor(private _makeRepo: () => Promise) {} + private _repo: AsyncCreate; + + constructor(_makeRepo: () => Promise) { + this._repo = new AsyncCreate(_makeRepo); + } public async getWidgets(): Promise { - const repo = await this._makeRepo(); + 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[]) {} @@ -86,13 +88,12 @@ export class CombinedWidgetRepository implements IWidgetRepository { for (const repo of this._repos) { allWidgets.push(...await repo.getWidgets()); } - console.log("COMBINED", {allWidgets}); return allWidgets; } } /** - * Repository that gets list of available widgets from a static URL. + * Repository that gets a list of widgets from a URL. */ export class UrlWidgetRepository implements IWidgetRepository { constructor(private _staticUrl = STATIC_URL) {} @@ -134,13 +135,14 @@ export class UrlWidgetRepository implements IWidgetRepository { } /** - * Default repository that gets list of available widgets from a static URL. + * 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; - private _dirWidgets?: IWidgetRepository; constructor(_options: { staticUrl?: string, @@ -148,12 +150,16 @@ export class WidgetRepositoryImpl implements IWidgetRepository { }) { const {staticUrl, gristServer} = _options; if (gristServer) { - this._dirWidgets = new DelayedWidgetRepository(async () => { - const places = getWidgetPlaces(gristServer); - console.log("PLACES!", places); - const files = places.map(place => new FileWidgetRepository(place.fileBase, - place.urlBase, - place.pluginId)); + 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); }); } @@ -174,7 +180,7 @@ export class WidgetRepositoryImpl implements IWidgetRepository { this._urlWidgets = new UrlWidgetRepository(this._staticUrl); repos.push(this._urlWidgets); } - if (this._dirWidgets) { repos.push(this._dirWidgets); } + if (this._diskWidgets) { repos.push(this._diskWidgets); } this._combinedWidgets = new CombinedWidgetRepository(repos); } @@ -200,7 +206,6 @@ class CachedWidgetRepository extends WidgetRepositoryImpl { const list = await super.getWidgets(); // Cache only if there are some widgets. if (list.length) { this._cache.set(1, list); } - console.log("CACHABLE RESULT", {list}); return list; } @@ -217,20 +222,15 @@ export function buildWidgetRepository(gristServer: GristServer, options?: { localOnly: boolean }) { - if (options?.localOnly) { - return new WidgetRepositoryImpl({ - gristServer, - staticUrl: '' - }); - } 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 manifest file. + // relative to the supplied base. for (const widget of widgets) { if (!(url.parse(widget.url).protocol)) { widget.url = new URL(widget.url, baseUrl).href; @@ -238,41 +238,40 @@ function fixUrls(widgets: ICustomWidget[], baseUrl: string) { } } -export interface CustomWidgetPlace { - urlBase: string, - fileBase: string, - fileDir: string, - name: string, +/** + * 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, } -export function getWidgetPlaces(gristServer: GristServer, - pluginUrl?: string) { - const places: CustomWidgetPlace[] = []; +/** + * Get a list of widgets available locally via plugins. + */ +export function getWidgetsInPlugins(gristServer: GristServer, + pluginUrl?: string) { + const places: CustomWidgetsInPlugin[] = []; const plugins = gristServer.getPlugins(); - console.log("PLUGINS", plugins); - pluginUrl = pluginUrl || gristServer.getPluginUrl(); - if (!pluginUrl) { return []; } + pluginUrl = pluginUrl ?? gristServer.getPluginUrl(); + if (pluginUrl === undefined) { return []; } for (const plugin of plugins) { - console.log("PLUGIN", plugin); const components = plugin.manifest.components; if (!components.widgets) { continue; } - console.log("GOT SOMETHING", { - name: plugin.id, - path: plugin.path, - widgets: components.widgets - }); const urlBase = removeTrailingSlash(pluginUrl) + '/v/' + gristServer.getTag() + '/widgets/' + plugin.id + '/'; places.push({ urlBase, - fileBase: path.join(plugin.path, components.widgets), - fileDir: plugin.path, - name: plugin.id, + dir: plugin.path, + file: path.join(plugin.path, components.widgets), + name: plugin.manifest.name || plugin.id, pluginId: plugin.id, }); } - console.log("PLACES", places); return places; } diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 81c3c301..54bef317 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -68,7 +68,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi maxUploadSizeImport: (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || undefined, maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined, timestampMs: Date.now(), - enableWidgetRepository: Boolean(process.env.GRIST_WIDGET_LIST_URL), + enableWidgetRepository: Boolean(process.env.GRIST_WIDGET_LIST_URL) || ((server?.getBundledWidgets().length || 0) > 0), survey: Boolean(process.env.DOC_ID_NEW_USER_INFO), tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID, activation: getActivation(req as RequestWithLogin | undefined), diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts index 3cf7e749..2cd2d5a6 100644 --- a/app/server/mergedServerMain.ts +++ b/app/server/mergedServerMain.ts @@ -72,11 +72,12 @@ export async function main(port: number, serverTypes: ServerType[], const server = new FlexServer(port, `server(${serverTypes.join(",")})`, options); + // We need to know early on whether we will be serving plugins or not. if (includeHome) { const userPort = checkUserContentPort(); - server.setWillServePlugins(userPort !== undefined); + server.setServesPlugins(userPort !== undefined); } else { - server.setWillServePlugins(false); + server.setServesPlugins(false); } if (options.loginSystem) { @@ -171,26 +172,14 @@ export async function main(port: number, serverTypes: ServerType[], server.finalize(); if (includeHome) { - // If plugin content is served from same host but on different port, - // run webserver on that port - const userPort = checkUserContentPort(); - if (userPort !== null) { - const ports = await server.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 (userPort === 0) { - server.setPluginPort(ports.serverPort); - } else if (process.env.APP_UNTRUSTED_URL === undefined) { - server.setPluginPort(userPort); - } - } + await server.finishPluginSetup(checkUserContentPort()); + } else { + await server.finishPluginSetup(null); } server.checkOptionCombinations(); - await server.prepareSummary(); server.summary(); + server.ready(); return server; } catch(e) { await server.close(); diff --git a/package.json b/package.json index aeced43c..ae4c7f75 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@types/double-ended-queue": "2.1.0", "@types/express": "4.16.0", "@types/form-data": "2.2.1", - "@types/fs-extra": "11.0.2", + "@types/fs-extra": "5.0.4", "@types/http-proxy": "1.17.9", "@types/i18next-fs-backend": "1.1.2", "@types/image-size": "0.0.29", @@ -139,7 +139,7 @@ "exceljs": "4.2.1", "express": "4.16.4", "file-type": "16.5.4", - "fs-extra": "11.1.1", + "fs-extra": "7.0.0", "grain-rpc": "0.1.7", "grainjs": "1.0.2", "handlebars": "4.7.7", diff --git a/yarn.lock b/yarn.lock index 3a6867c1..3f82821d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -721,12 +721,11 @@ dependencies: "@types/node" "*" -"@types/fs-extra@11.0.2": - version "11.0.2" - resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-11.0.2.tgz#23dc1ed7b2eba8ccd75568ac34e7a4e48aa2d087" - integrity sha512-c0hrgAOVYr21EX8J0jBMXGLMgJqVf/v6yxi0dLaJboW9aQPh16Id+z6w2Tx1hm+piJOLv8xPfVKZCLfjPw/IMQ== +"@types/fs-extra@5.0.4": + version "5.0.4" + resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-5.0.4.tgz" + integrity sha512-DsknoBvD8s+RFfSGjmERJ7ZOP1HI0UZRA3FSI+Zakhrc/Gy26YQsLI+m5V5DHxroHRJqCDLKJp7Hixn8zyaF7g== dependencies: - "@types/jsonfile" "*" "@types/node" "*" "@types/http-cache-semantics@*": @@ -786,13 +785,6 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== -"@types/jsonfile@*": - version "6.1.2" - resolved "https://registry.yarnpkg.com/@types/jsonfile/-/jsonfile-6.1.2.tgz#d3b8a3536c5bb272ebee0f784180e456b7691c8f" - integrity sha512-8t92P+oeW4d/CRQfJaSqEwXujrhH4OEeHRjGU3v1Q8mUS8GPF3yiX26sw4svv6faL2HfBtGTe2xWIoVgN3dy9w== - dependencies: - "@types/node" "*" - "@types/jsonwebtoken@7.2.8": version "7.2.8" resolved "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-7.2.8.tgz" @@ -865,9 +857,9 @@ form-data "^3.0.0" "@types/node@*": - version "20.7.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.7.2.tgz#0bdc211f8c2438cfadad26dc8c040a874d478aed" - integrity sha512-RcdC3hOBOauLP+r/kRt27NrByYtDjsXyAuSbR87O6xpsvi763WI+5fbSIvYJrXnt9w4RuxhV6eAXfIs7aaf/FQ== + version "14.0.1" + resolved "https://registry.npmjs.org/@types/node/-/node-14.0.1.tgz" + integrity sha512-FAYBGwC+W6F9+huFIDtn43cpy7+SzG+atzRiTfdp3inUKL2hXnd4rG8hylJLIh4+hqrQy1P17kvJByE/z825hA== "@types/node@^14": version "14.18.21" @@ -3917,14 +3909,14 @@ fs-constants@^1.0.0: resolved "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-extra@11.1.1: - version "11.1.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d" - integrity sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ== +fs-extra@7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.0.tgz" + integrity sha512-EglNDLRpmaTWiD/qraZn6HREAEAHJcJOmxNEYwq6xeMKnVMAy3GUcFB+wXt2C6k4CNvB/mP1y/U3dzvKKj5OtQ== dependencies: - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" fs-extra@^4.0.2, fs-extra@^4.0.3: version "4.0.3" @@ -4331,12 +4323,12 @@ graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6, resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== -graceful-fs@^4.1.2, graceful-fs@^4.1.6: - version "4.2.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" - integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== +graceful-fs@^4.1.2: + version "4.2.4" + resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz" + integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== -graceful-fs@^4.2.0, graceful-fs@^4.2.2: +graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2: version "4.2.6" resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz" integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== @@ -5255,17 +5247,8 @@ json5@^2.2.1: jsonfile@^4.0.0: version "4.0.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" - integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== - optionalDependencies: - graceful-fs "^4.1.6" - -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== - dependencies: - universalify "^2.0.0" + resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz" + integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= optionalDependencies: graceful-fs "^4.1.6" @@ -8401,7 +8384,7 @@ unique-string@^2.0.0: universalify@^0.1.0: version "0.1.2" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== universalify@^0.2.0: @@ -8409,11 +8392,6 @@ universalify@^0.2.0: resolved "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz" integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== -universalify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" - integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== - unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"