From 8e2a0aebbaa5fca9e6253968f023237a16f720cf Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Wed, 1 May 2024 18:17:26 -0400 Subject: [PATCH] reconcile boot and admin pages further This adds some remaining parts of the boot page to the admin panel, and then equalizes their content. The boot page then becomes just a fallback way to access the admin panel if there is a problem serving assets or if auth is messed up. One more step down the road and we can equate them entirely and just speak of a single admin panel with a fallback access method, but that isn't done here yet, some URL work is needed. --- app/client/boot.ts | 48 +++--------- app/client/models/AdminChecks.ts | 18 ++++- app/client/models/AppModel.ts | 22 ++++++ app/client/ui/AdminPanel.ts | 122 ++++++++++++++++++++++++++++++- app/server/lib/BootProbes.ts | 12 +-- app/server/lib/FlexServer.ts | 4 +- 6 files changed, 176 insertions(+), 50 deletions(-) diff --git a/app/client/boot.ts b/app/client/boot.ts index a37fd363..4da8a650 100644 --- a/app/client/boot.ts +++ b/app/client/boot.ts @@ -1,8 +1,8 @@ import { AppModel } from 'app/client/models/AppModel'; -import { AdminChecks, ProbeDetails } from 'app/client/models/AdminChecks'; +import { AdminChecks } from 'app/client/models/AdminChecks'; +import { AdminPanel } from 'app/client/ui/AdminPanel'; import { createAppPage } from 'app/client/ui/createAppPage'; import { pagePanels } from 'app/client/ui/PagePanels'; -import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe'; import { getGristConfig } from 'app/common/urlUtils'; import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs'; @@ -27,12 +27,18 @@ const cssResult = styled('div', ` * to have to worry about its failure modes yet, but it should be * fine as long as assets served locally are used. * + * This page now shows the same content as the Admin Panel, and + * serves as a way to access that panel in difficult situations, + * for example if auth is failing or asset serving is messed up + * because of some APP_HOME_URL problem etc. TODO: reconcile + * still further. + * */ export class Boot extends Disposable { private _checks: AdminChecks; - constructor(_appModel: AppModel) { + constructor(private _appModel: AppModel) { super(); // Setting title in constructor seems to be how we are doing this, // based on other similar pages. @@ -79,11 +85,7 @@ export class Boot extends Disposable { return cssBody(cssResult(this.buildError())); } return cssBody([ - ...use(this._checks.probes).map(probe => { - const req = this._checks.requestCheck(probe); - return cssResult( - this.buildResult(req.probe, use(req.result), req.details)); - }), + dom.create(AdminPanel, this._appModel, true), ]); } @@ -109,36 +111,6 @@ export class Boot extends Disposable { ' or it is not in the URL.'), ); } - - /** - * An ugly rendering of information returned by the probe. - */ - public buildResult(info: BootProbeInfo, result: BootProbeResult, - details: ProbeDetails|undefined) { - const out: (HTMLElement|string|null)[] = []; - out.push(dom('h2', info.name)); - if (details) { - out.push(dom('p', '> ', details.info)); - } - if (result.verdict) { - out.push(dom('pre', result.verdict)); - } - if (result.success !== undefined) { - out.push(result.success ? '✅' : '❌'); - } - if (result.done === true) { - out.push(dom('p', 'no fault detected')); - } - if (result.details) { - for (const [key, val] of Object.entries(result.details)) { - out.push(dom( - 'div', - cssLabel(key), - dom('input', dom.prop('value', JSON.stringify(val))))); - } - } - return out; - } } /** diff --git a/app/client/models/AdminChecks.ts b/app/client/models/AdminChecks.ts index 45de41ed..8959fecb 100644 --- a/app/client/models/AdminChecks.ts +++ b/app/client/models/AdminChecks.ts @@ -42,8 +42,12 @@ export class AdminChecks { url.pathname += '/probe'; const resp = await fetch(url.href); const _probes = await resp.json(); - this.probes.set(_probes.probes); + if (!this._parent.isDisposed()) { + this.probes.set(_probes.probes); + } + return _probes.probes; } + return []; } /** @@ -99,6 +103,7 @@ export class AdminCheckRunner { url.pathname = url.pathname + '/probe/' + id; fetch(url.href).then(async resp => { const _probes: BootProbeResult = await resp.json(); + if (parent.isDisposed()) { return; } const ob = results.get(id); if (ob) { ob.set(_probes); @@ -144,6 +149,17 @@ is set. `, }, + 'sandboxing': { + info: ` +Grist allows for very powerful formulas, using Python. +We recommend setting the environment variable +GRIST_SANDBOX_FLAVOR to gvisor if your hardware +supports it (most will), to run formulas in each document +within a sandbox isolated from other documents and isolated +from the network. +` + }, + 'system-user': { info: ` It is good practice not to run Grist as the root user. diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 30ea4799..82613e48 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -188,6 +188,28 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { if (this.options.useApi !== false) { this.fetchUsersAndOrgs().catch(reportError); + } else { + bundleChanges(() => { + this.users.set([{ + name: 'Boot', + id: 0, + email: '', + }]); + this.orgs.set([{ + id: 0, + owner: { + name: 'Boot', + id: 0, + email: '', + }, + host: '', + access: 'owners', + domain: 'boot', + name: 'Boot', + createdAt: String(Date.now()), + updatedAt: String(Date.now()), + }]); + }); } } diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts index f02d8351..c8d90b65 100644 --- a/app/client/ui/AdminPanel.ts +++ b/app/client/ui/AdminPanel.ts @@ -2,8 +2,8 @@ import {buildHomeBanners} from 'app/client/components/Banners'; import {makeT} from 'app/client/lib/localization'; import {localStorageJsonObs} from 'app/client/lib/localStorageObs'; import {getTimeFromNow} from 'app/client/lib/timeUtils'; +import {AdminChecks, ProbeDetails} from 'app/client/models/AdminChecks'; import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel'; -import {AdminChecks} from 'app/client/models/AdminChecks'; import {urlState} from 'app/client/models/gristUrlState'; import {AppHeader} from 'app/client/ui/AppHeader'; import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; @@ -17,7 +17,7 @@ import {toggle} from 'app/client/ui2018/checkbox'; import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink, makeLinks} from 'app/client/ui2018/links'; -import {SandboxingBootProbeDetails} from 'app/common/BootProbe'; +import {BootProbeInfo, BootProbeResult, SandboxingBootProbeDetails} from 'app/common/BootProbe'; import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls'; import {InstallAPI, InstallAPIImpl, LatestVersion} from 'app/common/InstallAPI'; import {naturalCompare} from 'app/common/SortFunc'; @@ -39,7 +39,7 @@ export class AdminPanel extends Disposable { private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl()); private _checks: AdminChecks; - constructor(private _appModel: AppModel) { + constructor(private _appModel: AppModel, private _fullScreen: boolean = false) { super(); document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig()); this._checks = new AdminChecks(this); @@ -49,6 +49,9 @@ export class AdminPanel extends Disposable { this._checks.fetchAvailableChecks().catch(err => { reportError(err); }); + if (this._fullScreen) { + return dom.create(this._buildMainContent.bind(this)); + } const panelOpen = Observable.create(this, false); return pagePanels({ leftPanel: { @@ -119,6 +122,23 @@ export class AdminPanel extends Disposable { }), this._buildUpdates(owner), ), + cssSection( + cssSectionTitle(t('Self Checks')), + this._buildProbeItems(owner, { + showRedundant: false, + showNovel: true, + }), + this._buildItem(owner, { + id: 'probe-other', + name: 'more...', + description: '', + value: '', + expandedContent: this._buildProbeItems(owner, { + showRedundant: true, + showNovel: false, + }), + }), + ), testId('admin-panel'), ); } @@ -145,6 +165,7 @@ export class AdminPanel extends Disposable { } private _buildSandboxingNotice() { + // TODO: reconcile with AdminChecks text for sandboxing. return [ t('Grist allows for very powerful formulas, using Python. \ We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor \ @@ -420,12 +441,100 @@ isolated from other documents and isolated from the network.'), ) }); } + + /** + * Show the results of various checks. Of the checks, some are considered + * "redundant" (already covered elsewhere in the Admin Panel) and the + * remainder are "novel". + */ + private _buildProbeItems(owner: MultiHolder, options: { + showRedundant: boolean, + showNovel: boolean, + }) { + return dom.domComputed( + use => [ + ...use(this._checks.probes).map(probe => { + const isRedundant = probe.id === 'sandboxing'; + const show = isRedundant ? options.showRedundant : options.showNovel; + if (!show) { return null; } + const req = this._checks.requestCheck(probe); + return this._buildProbeItem(owner, req.probe, use(req.result), req.details); + }), + ] + ); + } + + /** + * Show the result of an individual check. + */ + private _buildProbeItem(owner: MultiHolder, + info: BootProbeInfo, + result: BootProbeResult, + details: ProbeDetails|undefined) { + const out: (HTMLElement|string|null)[] = []; + if (result.verdict) { + out.push(dom('pre', result.verdict)); + } + if (result.success !== undefined) { + out.push(dom('p', + result.success ? 'Check succeeded.' : 'Check failed.')); + } + if (result.details) { + for (const [key, val] of Object.entries(result.details)) { + out.push(dom( + 'div', + cssLabel(key), + dom('input', dom.prop('value', JSON.stringify(val))))); + } + } + const status = (result.success !== undefined) ? + (result.success ? '✅' : '❗') : '―'; + + return this._buildItem(owner, { + id: `probe-${info.id}`, + name: info.id, + description: info.name, + value: cssStatus(status), + expandedContent: [ + result.verdict ? dom('pre', result.verdict) : null, + (result.success === undefined) ? null : + dom('p', + result.success ? 'Check succeeded.' : 'Check failed.'), + (result.done !== true) ? null : + dom('p', 'No fault detected.'), + (details?.info === undefined) ? null : + cssNote(details.info), + (result.details === undefined) ? null : + Object.entries(result.details).map(([key, val]) => { + return dom( + 'div', + cssLabel(key), + dom('input', dom.prop('value', JSON.stringify(val)))) + }), + ], + }); + } } function maybeSwitchToggle(value: Observable): DomContents { return toggle(value, dom.hide((use) => use(value) === null)); } +const cssNote = styled('div', ` + border-left: 2px solid ${theme.lightText}; + padding-left: 5px; + padding-bottom: 5px; + padding-top: 5px; + margin-bottom: 5px; +`); + +const cssStatus = styled('div', ` + display: inline-block; + text-align: center; + width: 40px; + padding: 5px; +`); + const cssPageContainer = styled('div', ` overflow: auto; padding: 40px; @@ -598,3 +707,10 @@ export const cssError = styled('div', ` export const cssHappy = styled('div', ` color: ${theme.controlFg}; `); + +export const cssLabel = styled('div', ` + display: inline-block; + min-width: 100px; + text-align: right; + padding-right: 5px; +`); diff --git a/app/server/lib/BootProbes.ts b/app/server/lib/BootProbes.ts index 7cddb99f..24c7bb1a 100644 --- a/app/server/lib/BootProbes.ts +++ b/app/server/lib/BootProbes.ts @@ -75,7 +75,7 @@ export interface Probe { const _homeUrlReachableProbe: Probe = { id: 'reachable', - name: 'Grist is reachable', + name: 'Is home page available at expected URL', apply: async (server, req) => { const url = server.getHomeUrl(req); try { @@ -100,7 +100,7 @@ const _homeUrlReachableProbe: Probe = { const _statusCheckProbe: Probe = { id: 'health-check', - name: 'Built-in Health check', + name: 'Is an internal health check passing', apply: async (server, req) => { const baseUrl = server.getHomeUrl(req); const url = new URL(baseUrl); @@ -129,7 +129,7 @@ const _statusCheckProbe: Probe = { const _userProbe: Probe = { id: 'system-user', - name: 'System user is sane', + name: 'Is the system user following best practice', apply: async () => { if (process.getuid && process.getuid() === 0) { return { @@ -147,7 +147,7 @@ const _userProbe: Probe = { const _bootProbe: Probe = { id: 'boot-page', - name: 'Boot page exposure', + name: 'Is the boot page adequately protected', apply: async (server) => { if (!server.hasBoot) { return { success: true }; @@ -169,7 +169,7 @@ const _bootProbe: Probe = { */ const _hostHeaderProbe: Probe = { id: 'host-header', - name: 'Host header is sane', + name: 'Does the host header look correct', apply: async (server, req) => { const host = req.header('host'); const url = new URL(server.getHomeUrl(req)); @@ -193,7 +193,7 @@ const _hostHeaderProbe: Probe = { const _sandboxingProbe: Probe = { id: 'sandboxing', - name: 'Sandboxing is working', + name: 'Is document sandboxing effective', apply: async (server, req) => { const details = server.getSandboxInfo(); return { diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 17ac46a3..733c146e 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -550,7 +550,7 @@ export class FlexServer implements GristServer { this._probes = new BootProbes(this.app, this, base); // Respond to /boot, /boot/, /boot/KEY, /boot/KEY/ to give // a helpful message even if user gets KEY wrong or omits it. - this.app.get('/boot(/(:bootKey/?)?)?$', async (req, res) => { + this.app.get('/boot(/(:bootKey/?)?)?$', expressWrap(async (req, res) => { const goodKey = bootKey && req.params.bootKey === bootKey; return this._sendAppPage(req, res, { path: 'boot.html', status: 200, config: goodKey ? { @@ -558,7 +558,7 @@ export class FlexServer implements GristServer { errMessage: 'not-the-key', }, tag: 'boot', }); - }); + })); this._probes.addEndpoints(); }