diff --git a/Dockerfile b/Dockerfile index cdd584f7ea..52a798185c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,13 +4,13 @@ ## docker buildx build -t ... --build-context=ext= . ## The code in will then be built along with the rest of Grist. ################################################################################ -FROM scratch as ext +FROM scratch AS ext ################################################################################ ## Javascript build stage ################################################################################ -FROM node:18-buster as builder +FROM node:18-buster AS builder # Install all node dependencies. WORKDIR /grist @@ -46,7 +46,7 @@ RUN \ ################################################################################ # Fetch python3.11 and python2.7 -FROM python:3.11-slim-buster as collector +FROM python:3.11-slim-buster AS collector # Install all python dependencies. ADD sandbox/requirements.txt requirements.txt @@ -66,7 +66,7 @@ RUN \ # Fetch gvisor-based sandbox. Note, to enable it to run within default # unprivileged docker, layers of protection that require privilege have # been stripped away, see https://github.com/google/gvisor/issues/4371 -FROM docker.io/gristlabs/gvisor-unprivileged:buster as sandbox +FROM docker.io/gristlabs/gvisor-unprivileged:buster AS sandbox ################################################################################ ## Run-time stage diff --git a/app/client/lib/markdown.ts b/app/client/lib/markdown.ts new file mode 100644 index 0000000000..49365b08a6 --- /dev/null +++ b/app/client/lib/markdown.ts @@ -0,0 +1,11 @@ +import { sanitizeHTML } from 'app/client/ui/sanitizeHTML'; +import { BindableValue, DomElementMethod, subscribeElem } from 'grainjs'; +import { marked } from 'marked'; + +export function markdown(markdownObs: BindableValue): DomElementMethod { + return elem => subscribeElem(elem, markdownObs, value => setMarkdownValue(elem, value)); +} + +function setMarkdownValue(elem: Element, markdownValue: string): void { + elem.innerHTML = sanitizeHTML(marked(markdownValue)); +} diff --git a/app/client/models/ToggleEnterpriseModel.ts b/app/client/models/ToggleEnterpriseModel.ts new file mode 100644 index 0000000000..3c23d24899 --- /dev/null +++ b/app/client/models/ToggleEnterpriseModel.ts @@ -0,0 +1,44 @@ +import {getHomeUrl} from 'app/client/models/AppModel'; +import {Disposable, Observable} from "grainjs"; +import {ConfigAPI} from 'app/common/ConfigAPI'; +import {delay} from 'app/common/delay'; + +export class ToggleEnterpriseModel extends Disposable { + public readonly edition: Observable = Observable.create(this, null); + private readonly _configAPI: ConfigAPI = new ConfigAPI(getHomeUrl()); + + public async fetchEnterpriseToggle(): Promise { + const edition = await this._configAPI.getValue('edition'); + this.edition.set(edition); + } + + public async updateEnterpriseToggle(edition: string): Promise { + // We may be restarting the server, so these requests may well + // fail if done in quick succession. + await retryOnNetworkError(() => this._configAPI.setValue({edition})); + this.edition.set(edition); + await retryOnNetworkError(() => this._configAPI.restartServer()); + } +} + +// Copied from DocPageModel.ts +const reconnectIntervals = [1000, 1000, 2000, 5000, 10000]; +async function retryOnNetworkError(func: () => Promise): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await func(); + } catch (err) { + // fetch() promises that network errors are reported as TypeError. We'll accept NetworkError too. + if (err.name !== "TypeError" && err.name !== "NetworkError") { + throw err; + } + // We really can't reach the server. Make it known. + if (attempt >= reconnectIntervals.length) { + throw err; + } + const reconnectTimeout = reconnectIntervals[attempt]; + console.warn(`Call to ${func.name} failed, will retry in ${reconnectTimeout} ms`, err); + await delay(reconnectTimeout); + } + } +} diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts index 91f6802b49..c0bb068292 100644 --- a/app/client/ui/AdminPanel.ts +++ b/app/client/ui/AdminPanel.ts @@ -9,6 +9,7 @@ import {AppHeader} from 'app/client/ui/AppHeader'; import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; import {pagePanels} from 'app/client/ui/PagePanels'; import {SupportGristPage} from 'app/client/ui/SupportGristPage'; +import {ToggleEnterpriseWidget} from 'app/client/ui/ToggleEnterpriseWidget'; import {createTopBarHome} from 'app/client/ui/TopBar'; import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs'; import {basicButton} from 'app/client/ui2018/buttons'; @@ -25,7 +26,6 @@ import {Computed, Disposable, dom, IDisposable, IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs'; import {AdminSection, AdminSectionItem, HidableToggle} from 'app/client/ui/AdminPanelCss'; - const t = makeT('AdminPanel'); // Translated "Admin Panel" name, made available to other modules. @@ -35,6 +35,7 @@ export function getAdminPanelName() { export class AdminPanel extends Disposable { private _supportGrist = SupportGristPage.create(this, this._appModel); + private _toggleEnterprise = ToggleEnterpriseWidget.create(this); private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl()); private _checks: AdminChecks; @@ -161,6 +162,13 @@ Please log in as an administrator.`)), description: t('Current version of Grist'), value: cssValueLabel(`Version ${version.version}`), }), + dom.create(AdminSectionItem, { + id: 'enterprise', + name: t('Enterprise'), + description: t('Enable Grist Enterprise'), + value: dom.create(HidableToggle, this._toggleEnterprise.getEnterpriseToggleObservable()), + expandedContent: this._toggleEnterprise.buildEnterpriseSection(), + }), this._buildUpdates(owner), ]), dom.create(AdminSection, t('Self Checks'), [ diff --git a/app/client/ui/AdminTogglesCss.ts b/app/client/ui/AdminTogglesCss.ts new file mode 100644 index 0000000000..5419d4289f --- /dev/null +++ b/app/client/ui/AdminTogglesCss.ts @@ -0,0 +1,45 @@ +import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons'; +import {theme} from 'app/client/ui2018/cssVars'; +import {styled} from 'grainjs'; + +export const cssSection = styled('div', ``); + +export const cssParagraph = styled('div', ` + color: ${theme.text}; + font-size: 14px; + line-height: 20px; + margin-bottom: 12px; +`); + +export const cssOptInOutMessage = styled(cssParagraph, ` + line-height: 40px; + font-weight: 600; + margin-top: 24px; + margin-bottom: 0px; +`); + +export const cssOptInButton = styled(bigPrimaryButton, ` + margin-top: 24px; +`); + +export const cssOptOutButton = styled(bigBasicButton, ` + margin-top: 24px; +`); + +export const cssSponsorButton = styled(bigBasicButtonLink, ` + margin-top: 24px; +`); + +export const cssButtonIconAndText = styled('div', ` + display: flex; + align-items: center; +`); + +export const cssButtonText = styled('span', ` + margin-left: 8px; +`); + +export const cssSpinnerBox = styled('div', ` + margin-top: 24px; + text-align: center; +`); diff --git a/app/client/ui/SupportGristPage.ts b/app/client/ui/SupportGristPage.ts index 306ef858a3..13e0ed8fec 100644 --- a/app/client/ui/SupportGristPage.ts +++ b/app/client/ui/SupportGristPage.ts @@ -1,14 +1,24 @@ import {makeT} from 'app/client/lib/localization'; import {AppModel} from 'app/client/models/AppModel'; import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel'; -import {basicButtonLink, bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons'; -import {theme} from 'app/client/ui2018/cssVars'; +import { + cssButtonIconAndText, + cssButtonText, + cssOptInButton, + cssOptInOutMessage, + cssOptOutButton, + cssParagraph, + cssSection, + cssSpinnerBox, + cssSponsorButton, +} from 'app/client/ui/AdminTogglesCss'; +import {basicButtonLink} from 'app/client/ui2018/buttons'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {loadingSpinner} from 'app/client/ui2018/loaders'; import {commonUrls} from 'app/common/gristUrls'; import {TelemetryPrefsWithSources} from 'app/common/InstallAPI'; -import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs'; +import {Computed, Disposable, dom, makeTestId} from 'grainjs'; const testId = makeTestId('test-support-grist-page-'); @@ -164,45 +174,3 @@ function gristCoreLink() { {href: commonUrls.githubGristCore, target: '_blank'}, ); } - -const cssSection = styled('div', ``); - -const cssParagraph = styled('div', ` - color: ${theme.text}; - font-size: 14px; - line-height: 20px; - margin-bottom: 12px; -`); - -const cssOptInOutMessage = styled(cssParagraph, ` - line-height: 40px; - font-weight: 600; - margin-top: 24px; - margin-bottom: 0px; -`); - -const cssOptInButton = styled(bigPrimaryButton, ` - margin-top: 24px; -`); - -const cssOptOutButton = styled(bigBasicButton, ` - margin-top: 24px; -`); - -const cssSponsorButton = styled(bigBasicButtonLink, ` - margin-top: 24px; -`); - -const cssButtonIconAndText = styled('div', ` - display: flex; - align-items: center; -`); - -const cssButtonText = styled('span', ` - margin-left: 8px; -`); - -const cssSpinnerBox = styled('div', ` - margin-top: 24px; - text-align: center; -`); diff --git a/app/client/ui/ToggleEnterpriseWidget.ts b/app/client/ui/ToggleEnterpriseWidget.ts new file mode 100644 index 0000000000..0101990078 --- /dev/null +++ b/app/client/ui/ToggleEnterpriseWidget.ts @@ -0,0 +1,78 @@ +import {makeT} from 'app/client/lib/localization'; +import {markdown} from 'app/client/lib/markdown'; +import {Computed, Disposable, dom, makeTestId} from "grainjs"; +import {commonUrls} from "app/common/gristUrls"; +import {ToggleEnterpriseModel} from 'app/client/models/ToggleEnterpriseModel'; +import { + cssOptInButton, + cssOptOutButton, + cssParagraph, + cssSection, +} from 'app/client/ui/AdminTogglesCss'; + + +const t = makeT('ToggleEnterprsiePage'); +const testId = makeTestId('test-toggle-enterprise-page-'); + +export class ToggleEnterpriseWidget extends Disposable { + private readonly _model: ToggleEnterpriseModel = new ToggleEnterpriseModel(); + private readonly _isEnterprise = Computed.create(this, this._model.edition, (_use, edition) => { + return edition === 'enterprise'; + }).onWrite(async (enabled) => { + await this._model.updateEnterpriseToggle(enabled ? 'enterprise' : 'core'); + }); + + constructor() { + super(); + this._model.fetchEnterpriseToggle().catch(reportError); + } + + public getEnterpriseToggleObservable() { + return this._isEnterprise; + } + + public buildEnterpriseSection() { + return cssSection( + dom.domComputed(this._isEnterprise, (enterpriseEnabled) => { + return [ + enterpriseEnabled ? + cssParagraph( + markdown(t('Grist Enterprise is **enabled**.')), + testId('enterprise-opt-out-message'), + ) : null, + cssParagraph( + markdown(t(`An activation key is used to run Grist Enterprise after a trial period +of 30 days has expired. Get an activation key by [signing up for Grist +Enterprise]({{signupLink}}). You do not need an activation key to run +Grist Core. + +Learn more in our [Help Center]({{helpCenter}}).`, { + signupLink: commonUrls.plans, + helpCenter: commonUrls.helpEnterpriseOptIn + })) + ), + this._buildEnterpriseSectionButtons(), + ]; + }), + testId('enterprise-opt-in-section'), + ); + } + + public _buildEnterpriseSectionButtons() { + return dom.domComputed(this._isEnterprise, (enterpriseEnabled) => { + if (enterpriseEnabled) { + return [ + cssOptOutButton(t('Disable Grist Enterprise'), + dom.on('click', () => this._isEnterprise.set(false)), + ), + ]; + } else { + return [ + cssOptInButton(t('Enable Grist Enterprise'), + dom.on('click', () => this._isEnterprise.set(true)), + ), + ]; + } + }); + } +} diff --git a/app/common/ConfigAPI.ts b/app/common/ConfigAPI.ts new file mode 100644 index 0000000000..a8a12e8307 --- /dev/null +++ b/app/common/ConfigAPI.ts @@ -0,0 +1,31 @@ +import {BaseAPI, IOptions} from "app/common/BaseAPI"; +import {addCurrentOrgToPath} from 'app/common/urlUtils'; + +/** + * An API for accessing the internal Grist configuration, stored in + * config.json. + */ +export class ConfigAPI extends BaseAPI { + constructor(private _homeUrl: string, options: IOptions = {}) { + super(options); + } + + public async getValue(key: string): Promise { + return (await this.requestJson(`${this._url}/api/config/${key}`, {method: 'GET'})).value; + } + + public async setValue(value: any, restart=false): Promise { + await this.request(`${this._url}/api/config`, { + method: 'PATCH', + body: JSON.stringify({config: value, restart}), + }); + } + + public async restartServer(): Promise { + await this.request(`${this._url}/api/admin/restart`, {method: 'POST'}); + } + + private get _url(): string { + return addCurrentOrgToPath(this._homeUrl); + } +} diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 2f81d215f2..6edc6fa859 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -84,6 +84,7 @@ export const commonUrls = { helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes", helpCustomWidgets: "https://support.getgrist.com/widget-custom", helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited", + helpEnterpriseOptIn: "https://support.getgrist.com/self-managed/#how-do-i-activate-grist-enterprise", helpCalendarWidget: "https://support.getgrist.com/widget-calendar", helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys", helpFilteringReferenceChoices: "https://support.getgrist.com/col-refs/#filtering-reference-choices-in-dropdown", diff --git a/app/server/lib/ConfigBackendAPI.ts b/app/server/lib/ConfigBackendAPI.ts new file mode 100644 index 0000000000..afb475faf6 --- /dev/null +++ b/app/server/lib/ConfigBackendAPI.ts @@ -0,0 +1,35 @@ +import * as express from 'express'; +import {expressWrap} from 'app/server/lib/expressWrap'; + +import {getGlobalConfig} from 'app/server/lib/globalConfig'; + +import log from "app/server/lib/log"; + +export class ConfigBackendAPI { + public addEndpoints(app: express.Express, requireInstallAdmin: express.RequestHandler) { + app.get('/api/config/:key', requireInstallAdmin, expressWrap((req, resp) => { + log.debug('config: requesting configuration', req.params); + + // Only one key is valid for now + if (req.params.key === 'edition') { + resp.send({value: getGlobalConfig().edition.get()}); + } else { + resp.status(404).send({ error: 'Configuration key not found.' }); + } + })); + + app.patch('/api/config', requireInstallAdmin, expressWrap(async (req, resp) => { + const config = req.body.config; + log.debug('config: received new configuration item', config); + + // Only one key is valid for now + if(config.edition !== undefined) { + await getGlobalConfig().edition.set(config.edition); + + resp.send({ msg: 'ok' }); + } else { + resp.status(400).send({ error: 'Invalid configuration key' }); + } + })); + } +} diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 06e9b85506..240d819542 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -87,7 +87,8 @@ import {AddressInfo} from 'net'; import fetch from 'node-fetch'; import * as path from 'path'; import * as serveStatic from "serve-static"; -import {IGristCoreConfig} from "./configCore"; +import {ConfigBackendAPI} from "app/server/lib/ConfigBackendAPI"; +import {IGristCoreConfig} from "app/server/lib/configCore"; // Health checks are a little noisy in the logs, so we don't show them all. // We show the first N health checks: @@ -1876,20 +1877,24 @@ export class FlexServer implements GristServer { const probes = new BootProbes(this.app, this, '/api', adminMiddleware); probes.addEndpoints(); - this.app.post('/api/admin/restart', requireInstallAdmin, expressWrap(async (req, resp) => { - const newConfig = req.body.newConfig; + this.app.post('/api/admin/restart', requireInstallAdmin, expressWrap(async (_, resp) => { resp.on('finish', () => { // If we have IPC with parent process (e.g. when running under // Docker) tell the parent that we have a new environment so it // can restart us. if (process.send) { - process.send({ action: 'restart', newConfig }); + process.send({ action: 'restart' }); } }); - // On the topic of http response codes, thus spake MDN: - // "409: This response is sent when a request conflicts with the current state of the server." - const status = process.send ? 200 : 409; - return resp.status(status).send(); + + if(!process.env.GRIST_RUNNING_UNDER_SUPERVISOR) { + // On the topic of http response codes, thus spake MDN: + // "409: This response is sent when a request conflicts with the current state of the server." + return resp.status(409).send({ + error: "Cannot automatically restart the Grist server to enact changes. Please restart server manually." + }); + } + return resp.status(200).send({ msg: 'ok' }); })); // Restrict this endpoint to install admins @@ -1948,6 +1953,14 @@ export class FlexServer implements GristServer { })); } + public addConfigEndpoints() { + // Need to be an admin to change the Grist config + const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin(); + + const configBackendAPI = new ConfigBackendAPI(); + configBackendAPI.addEndpoints(this.app, requireInstallAdmin); + } + // Get the HTML template sent for document pages. public async getDocTemplate(): Promise { const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'), diff --git a/app/server/lib/config.ts b/app/server/lib/config.ts index 490cddcf0b..f668154649 100644 --- a/app/server/lib/config.ts +++ b/app/server/lib/config.ts @@ -98,7 +98,7 @@ export class FileConfig { } public async persistToDisk() { - await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2)); + await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2) + "\n"); } } diff --git a/app/server/lib/configCore.ts b/app/server/lib/configCore.ts index 58ddf627c7..99c3770bcd 100644 --- a/app/server/lib/configCore.ts +++ b/app/server/lib/configCore.ts @@ -4,7 +4,8 @@ import { fileConfigAccessorFactory, IWritableConfigValue } from "./config"; -import { convertToCoreFileContents, IGristCoreConfigFileLatest } from "./configCoreFileFormats"; +import {convertToCoreFileContents, IGristCoreConfigFileLatest} from "./configCoreFileFormats"; +import {isAffirmative} from 'app/common/gutil'; export type Edition = "core" | "enterprise"; @@ -23,6 +24,9 @@ export function loadGristCoreConfigFile(configPath?: string): IGristCoreConfig { export function loadGristCoreConfig(fileConfig?: FileConfig): IGristCoreConfig { const fileConfigValue = fileConfigAccessorFactory(fileConfig); return { - edition: createConfigValue("core", fileConfigValue("edition")) + edition: createConfigValue( + isAffirmative(process.env.TEST_ENABLE_ACTIVATION) ? "enterprise" : "core", + fileConfigValue("edition") + ) }; } diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts index 466eddafca..1bef819dfc 100644 --- a/app/server/mergedServerMain.ts +++ b/app/server/mergedServerMain.ts @@ -165,6 +165,7 @@ export async function main(port: number, serverTypes: ServerType[], server.addLogEndpoint(); server.addGoogleAuthEndpoint(); server.addInstallEndpoints(); + server.addConfigEndpoints(); } if (includeDocs) { diff --git a/sandbox/supervisor.mjs b/sandbox/supervisor.mjs index 2508cb1705..f7178f3c63 100644 --- a/sandbox/supervisor.mjs +++ b/sandbox/supervisor.mjs @@ -3,14 +3,14 @@ import {spawn} from 'child_process'; let grist; function startGrist(newConfig={}) { - saveNewConfig(newConfig); // H/T https://stackoverflow.com/a/36995148/11352427 grist = spawn('./sandbox/run.sh', { - stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + env: {...process.env, GRIST_RUNNING_UNDER_SUPERVISOR: true} }); grist.on('message', function(data) { if (data.action === 'restart') { - console.log('Restarting Grist with new environment'); + console.log('Restarting Grist with new configuration'); // Note that we only set this event handler here, after we have // a new environment to reload with. Small chance of a race here @@ -26,10 +26,4 @@ function startGrist(newConfig={}) { return grist; } -// Stub function -function saveNewConfig(newConfig) { - // TODO: something here to actually persist the new config before - // restarting Grist. -} - startGrist();