mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	make a /boot/GRIST_BOOT_KEY page for diagnosing configuration problems (#850)
This is a start at a page for diagnosing problems while setting up Grist. Starting to add some diagnostics based on feedback in github issues. We should make Grist installation easier! But when there is a problem it should be easier to diagnose than it is now, and this may help. The page is ugly and doesn't have many diagnostics yet, but we can iterate. Visit `/boot` on a Grist server for tips on how to use this feature.
This commit is contained in:
		
							parent
							
								
									2693e01d08
								
							
						
					
					
						commit
						95b734149e
					
				
							
								
								
									
										259
									
								
								app/client/boot.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										259
									
								
								app/client/boot.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,259 @@ | ||||
| import { AppModel } from 'app/client/models/AppModel'; | ||||
| import { createAppPage } from 'app/client/ui/createAppPage'; | ||||
| import { pagePanels } from 'app/client/ui/PagePanels'; | ||||
| import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe'; | ||||
| import { removeTrailingSlash } from 'app/common/gutil'; | ||||
| import { getGristConfig } from 'app/common/urlUtils'; | ||||
| import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs'; | ||||
| 
 | ||||
| const cssBody = styled('div', ` | ||||
|   padding: 20px; | ||||
|   overflow: auto; | ||||
| `);
 | ||||
| 
 | ||||
| const cssHeader = styled('div', ` | ||||
|   padding: 20px; | ||||
| `);
 | ||||
| 
 | ||||
| const cssResult = styled('div', ` | ||||
|   max-width: 500px; | ||||
| `);
 | ||||
| 
 | ||||
| /** | ||||
|  * | ||||
|  * A "boot" page for inspecting the state of the Grist installation. | ||||
|  * | ||||
|  * TODO: deferring using any localization machinery so as not | ||||
|  * to have to worry about its failure modes yet, but it should be | ||||
|  * fine as long as assets served locally are used. | ||||
|  * | ||||
|  */ | ||||
| export class Boot extends Disposable { | ||||
| 
 | ||||
|   // The back end will offer a set of probes (diagnostics) we
 | ||||
|   // can use. Probes have unique IDs.
 | ||||
|   public probes: Observable<BootProbeInfo[]>; | ||||
| 
 | ||||
|   // Keep track of probe results we have received, by probe ID.
 | ||||
|   public results: Map<string, Observable<BootProbeResult>>; | ||||
| 
 | ||||
|   // Keep track of probe requests we are making, by probe ID.
 | ||||
|   public requests: Map<string, BootProbe>; | ||||
| 
 | ||||
|   constructor(_appModel: AppModel) { | ||||
|     super(); | ||||
|     // Setting title in constructor seems to be how we are doing this,
 | ||||
|     // based on other similar pages.
 | ||||
|     document.title = 'Booting Grist'; | ||||
|     this.probes = Observable.create(this, []); | ||||
|     this.results = new Map(); | ||||
|     this.requests = new Map(); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Set up the page. Uses the generic Grist layout with an empty | ||||
|    * side panel, just for convenience. Could be made a lot prettier. | ||||
|    */ | ||||
|   public buildDom() { | ||||
|     const config = getGristConfig(); | ||||
|     const errMessage = config.errMessage; | ||||
|     if (!errMessage) { | ||||
|       // Probe tool URLs are relative to the current URL. Don't trust configuration,
 | ||||
|       // because it may be buggy if the user is here looking at the boot page
 | ||||
|       // to figure out some problem.
 | ||||
|       const url = new URL(removeTrailingSlash(document.location.href)); | ||||
|       url.pathname += '/probe'; | ||||
|       fetch(url.href).then(async resp => { | ||||
|         const _probes = await resp.json(); | ||||
|         this.probes.set(_probes.probes); | ||||
|       }).catch(e => reportError(e)); | ||||
|     } | ||||
| 
 | ||||
|     const rootNode = dom('div', | ||||
|       dom.domComputed( | ||||
|         use => { | ||||
|           return pagePanels({ | ||||
|             leftPanel: { | ||||
|               panelWidth: Observable.create(this, 240), | ||||
|               panelOpen: Observable.create(this, false), | ||||
|               hideOpener: true, | ||||
|               header: null, | ||||
|               content: null, | ||||
|             }, | ||||
|             headerMain: cssHeader(dom('h1', 'Grist Boot')), | ||||
|             contentMain: this.buildBody(use, {errMessage}), | ||||
|           }); | ||||
|         } | ||||
|       ), | ||||
|       ); | ||||
|     return rootNode; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * The body of the page is very simple right now, basically a | ||||
|    * placeholder.  Make a section for each probe, and kick them off in | ||||
|    * parallel, showing results as they come in. | ||||
|    */ | ||||
|   public buildBody(use: UseCBOwner, options: {errMessage?: string}) { | ||||
|     if (options.errMessage) { | ||||
|       return cssBody(cssResult(this.buildError())); | ||||
|     } | ||||
|     return cssBody([ | ||||
|       ...use(this.probes).map(probe => { | ||||
|         const {id} = probe; | ||||
|         let result = this.results.get(id); | ||||
|         if (!result) { | ||||
|           result = Observable.create(this, {}); | ||||
|           this.results.set(id, result); | ||||
|         } | ||||
|         let request = this.requests.get(id); | ||||
|         if (!request) { | ||||
|           request = new BootProbe(id, this); | ||||
|           this.requests.set(id, request); | ||||
|         } | ||||
|         request.start(); | ||||
|         return cssResult( | ||||
|           this.buildResult(probe, use(result), probeDetails[id])); | ||||
|       }), | ||||
|     ]); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * This is used when there is an attempt to access the boot page | ||||
|    * but something isn't right - either the page isn't enabled, or | ||||
|    * the key in the URL is wrong. Give the user some information about | ||||
|    * how to set things up. | ||||
|    */ | ||||
|   public buildError() { | ||||
|     return dom( | ||||
|       'div', | ||||
|       dom('p', | ||||
|           'A diagnostics page can be made available at:', | ||||
|           dom('blockquote', '/boot/GRIST_BOOT_KEY'), | ||||
|           'GRIST_BOOT_KEY is an environment variable ', | ||||
|           ' set before Grist starts. It should only', | ||||
|           ' contain characters that are valid in a URL.', | ||||
|           ' It should be a secret, since no authentication is needed', | ||||
|           ' to visit the diagnostics page.'), | ||||
|       dom('p', | ||||
|           'You are seeing this page because either the key is not set,', | ||||
|           ' 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', | ||||
|           key, | ||||
|           dom('input', dom.prop('value', JSON.stringify(val))))); | ||||
|       } | ||||
|     } | ||||
|     return out; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Represents a single diagnostic. | ||||
|  */ | ||||
| export class BootProbe { | ||||
|   constructor(public id: string, public boot: Boot) { | ||||
|     const url = new URL(removeTrailingSlash(document.location.href)); | ||||
|     url.pathname = url.pathname + '/probe/' + id; | ||||
|     fetch(url.href).then(async resp => { | ||||
|       const _probes: BootProbeResult = await resp.json(); | ||||
|       const ob = boot.results.get(id); | ||||
|       if (ob) { | ||||
|         ob.set(_probes); | ||||
|       } | ||||
|     }).catch(e => console.error(e)); | ||||
|   } | ||||
| 
 | ||||
|   public start() { | ||||
|     let result = this.boot.results.get(this.id); | ||||
|     if (!result) { | ||||
|       result = Observable.create(this.boot, {}); | ||||
|       this.boot.results.set(this.id, result); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Create a stripped down page to show boot information. | ||||
|  * Make sure the API isn't used since it may well be unreachable | ||||
|  * due to a misconfiguration, especially in multi-server setups. | ||||
|  */ | ||||
| createAppPage(appModel => { | ||||
|   return dom.create(Boot, appModel); | ||||
| }, { | ||||
|   useApi: false, | ||||
| }); | ||||
| 
 | ||||
| /** | ||||
|  * Basic information about diagnostics is kept on the server, | ||||
|  * but it can be useful to show extra details and tips in the | ||||
|  * client. | ||||
|  */ | ||||
| const probeDetails: Record<string, ProbeDetails> = { | ||||
|   'boot-page': { | ||||
|     info: ` | ||||
| This boot page should not be too easy to access. Either turn | ||||
| it off when configuration is ok (by unsetting GRIST_BOOT_KEY) | ||||
| or make GRIST_BOOT_KEY long and cryptographically secure. | ||||
| `,
 | ||||
|   }, | ||||
| 
 | ||||
|   'health-check': { | ||||
|     info: ` | ||||
| Grist has a small built-in health check often used when running | ||||
| it as a container. | ||||
| `,
 | ||||
|   }, | ||||
| 
 | ||||
|   'host-header': { | ||||
|     info: ` | ||||
| Requests arriving to Grist should have an accurate Host | ||||
| header. This is essential when GRIST_SERVE_SAME_ORIGIN | ||||
| is set. | ||||
| `,
 | ||||
|   }, | ||||
| 
 | ||||
|   'system-user': { | ||||
|     info: ` | ||||
| It is good practice not to run Grist as the root user. | ||||
| `,
 | ||||
|   }, | ||||
| 
 | ||||
|   'reachable': { | ||||
|     info: ` | ||||
| The main page of Grist should be available. | ||||
| ` | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Information about the probe. | ||||
|  */ | ||||
| interface ProbeDetails { | ||||
|   info: string; | ||||
| } | ||||
| 
 | ||||
| @ -146,6 +146,11 @@ export interface AppModel { | ||||
|   switchUser(user: FullUser, org?: string): Promise<void>; | ||||
| } | ||||
| 
 | ||||
| export interface TopAppModelOptions { | ||||
|   /** Defaults to true. */ | ||||
|   useApi?: boolean; | ||||
| } | ||||
| 
 | ||||
| export class TopAppModelImpl extends Disposable implements TopAppModel { | ||||
|   public readonly isSingleOrg: boolean; | ||||
|   public readonly productFlavor: ProductFlavor; | ||||
| @ -163,14 +168,16 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { | ||||
|   // up new widgets - that seems ok.
 | ||||
|   private readonly _widgets: AsyncCreate<ICustomWidget[]>; | ||||
| 
 | ||||
|   constructor(window: {gristConfig?: GristLoadConfig}, public readonly api: UserAPI = newUserAPIImpl()) { | ||||
|   constructor(window: {gristConfig?: GristLoadConfig}, | ||||
|               public readonly api: UserAPI = newUserAPIImpl(), | ||||
|               public readonly options: TopAppModelOptions = {}) { | ||||
|     super(); | ||||
|     setErrorNotifier(this.notifier); | ||||
|     this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg); | ||||
|     this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org); | ||||
|     this._gristConfig = window.gristConfig; | ||||
|     this._widgets = new AsyncCreate<ICustomWidget[]>(async () => { | ||||
|       const widgets = await this.api.getWidgets(); | ||||
|       const widgets = this.options.useApi === false ? [] : await this.api.getWidgets(); | ||||
|       this.customWidgets.set(widgets); | ||||
|       return widgets; | ||||
|     }); | ||||
| @ -180,7 +187,9 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { | ||||
|     this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize())); | ||||
|     this.plugins = this._gristConfig?.plugins || []; | ||||
| 
 | ||||
|     this.fetchUsersAndOrgs().catch(reportError); | ||||
|     if (this.options.useApi !== false) { | ||||
|       this.fetchUsersAndOrgs().catch(reportError); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public initialize(): void { | ||||
| @ -237,6 +246,10 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { | ||||
| 
 | ||||
|   private async _doInitialize() { | ||||
|     this.appObs.set(null); | ||||
|     if (this.options.useApi === false) { | ||||
|       AppModelImpl.create(this.appObs, this, null, null, {error: 'no-api', status: 500}); | ||||
|       return; | ||||
|     } | ||||
|     try { | ||||
|       const {user, org, orgError} = await this.api.getSessionActive(); | ||||
|       if (this.isDisposed()) { return; } | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; | ||||
| import {setupLocale} from 'app/client/lib/localization'; | ||||
| import {AppModel, TopAppModelImpl} from 'app/client/models/AppModel'; | ||||
| import {AppModel, TopAppModelImpl, TopAppModelOptions} from 'app/client/models/AppModel'; | ||||
| import {reportError, setUpErrorHandling} from 'app/client/models/errors'; | ||||
| import {buildSnackbarDom} from 'app/client/ui/NotifyUI'; | ||||
| import {addViewportTag} from 'app/client/ui/viewport'; | ||||
| @ -14,10 +14,12 @@ const G = getBrowserGlobals('document', 'window'); | ||||
|  * Sets up the application model, error handling, and global styles, and replaces | ||||
|  * the DOM body with the result of calling `buildAppPage`. | ||||
|  */ | ||||
| export function createAppPage(buildAppPage: (appModel: AppModel) => DomContents) { | ||||
| export function createAppPage( | ||||
|   buildAppPage: (appModel: AppModel) => DomContents, | ||||
|   modelOptions: TopAppModelOptions = {}) { | ||||
|   setUpErrorHandling(); | ||||
| 
 | ||||
|   const topAppModel = TopAppModelImpl.create(null, {}); | ||||
|   const topAppModel = TopAppModelImpl.create(null, {}, undefined, modelOptions); | ||||
| 
 | ||||
|   addViewportTag(); | ||||
|   attachCssRootVars(topAppModel.productFlavor); | ||||
|  | ||||
							
								
								
									
										22
									
								
								app/common/BootProbe.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/common/BootProbe.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | ||||
| 
 | ||||
| export type BootProbeIds = | ||||
|     'boot-page' | | ||||
|     'health-check' | | ||||
|     'reachable' | | ||||
|     'host-header' | | ||||
|     'system-user' | ||||
| ; | ||||
| 
 | ||||
| export interface BootProbeResult { | ||||
|   verdict?: string; | ||||
|   success?: boolean; | ||||
|   done?: boolean; | ||||
|   severity?: 'fault' | 'warning' | 'hmm'; | ||||
|   details?: Record<string, any>; | ||||
| } | ||||
| 
 | ||||
| export interface BootProbeInfo { | ||||
|   id: BootProbeIds; | ||||
|   name: string; | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										185
									
								
								app/server/lib/BootProbes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								app/server/lib/BootProbes.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,185 @@ | ||||
| import { ApiError } from 'app/common/ApiError'; | ||||
| import { BootProbeIds, BootProbeResult } from 'app/common/BootProbe'; | ||||
| import { removeTrailingSlash } from 'app/common/gutil'; | ||||
| import { expressWrap, jsonErrorHandler } from 'app/server/lib/expressWrap'; | ||||
| import { GristServer } from 'app/server/lib/GristServer'; | ||||
| import * as express from 'express'; | ||||
| import fetch from 'node-fetch'; | ||||
| 
 | ||||
| /** | ||||
|  * Self-diagnostics useful when installing Grist. | ||||
|  */ | ||||
| export class BootProbes { | ||||
|   // List of probes.
 | ||||
|   public _probes = new Array<Probe>(); | ||||
| 
 | ||||
|   // Probes indexed by id.
 | ||||
|   public _probeById = new Map<string, Probe>(); | ||||
| 
 | ||||
|   public constructor(private _app: express.Application, | ||||
|                      private _server: GristServer, | ||||
|                      private _base: string) { | ||||
|     this._addProbes(); | ||||
|   } | ||||
| 
 | ||||
|   public addEndpoints() { | ||||
|     // Return a list of available probes.
 | ||||
|     this._app.use(`${this._base}/probe$`, expressWrap(async (_, res) => { | ||||
|       res.json({ | ||||
|         'probes': this._probes.map(probe => { | ||||
|           return { id: probe.id, name: probe.name }; | ||||
|         }), | ||||
|       }); | ||||
|     })); | ||||
| 
 | ||||
|     // Return result of running an individual probe.
 | ||||
|     this._app.use(`${this._base}/probe/:probeId`, expressWrap(async (req, res) => { | ||||
|       const probe = this._probeById.get(req.params.probeId); | ||||
|       if (!probe) { | ||||
|         throw new ApiError('unknown probe', 400); | ||||
|       } | ||||
|       const result = await probe.apply(this._server, req); | ||||
|       res.json(result); | ||||
|     })); | ||||
| 
 | ||||
|     // Fall-back for errors.
 | ||||
|     this._app.use(`${this._base}/probe`, jsonErrorHandler); | ||||
|   } | ||||
| 
 | ||||
|   private _addProbes() { | ||||
|     this._probes.push(_homeUrlReachableProbe); | ||||
|     this._probes.push(_statusCheckProbe); | ||||
|     this._probes.push(_userProbe); | ||||
|     this._probes.push(_bootProbe); | ||||
|     this._probes.push(_hostHeaderProbe); | ||||
|     this._probeById = new Map(this._probes.map(p => [p.id, p])); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * An individual probe has an id, a name, an optional description, | ||||
|  * and a method that returns a probe result. | ||||
|  */ | ||||
| export interface Probe { | ||||
|   id: BootProbeIds; | ||||
|   name: string; | ||||
|   description?: string; | ||||
|   apply: (server: GristServer, req: express.Request) => Promise<BootProbeResult>; | ||||
| } | ||||
| 
 | ||||
| const _homeUrlReachableProbe: Probe = { | ||||
|   id: 'reachable', | ||||
|   name: 'Grist is reachable', | ||||
|   apply: async (server, req) => { | ||||
|     const url = server.getHomeUrl(req); | ||||
|     try { | ||||
|       const resp = await fetch(url); | ||||
|       if (resp.status !== 200) { | ||||
|         throw new ApiError(await resp.text(), resp.status); | ||||
|       } | ||||
|       return { | ||||
|         success: true, | ||||
|       }; | ||||
|     } catch (e) { | ||||
|       return { | ||||
|         success: false, | ||||
|         details: { | ||||
|           error: String(e), | ||||
|         }, | ||||
|         severity: 'fault', | ||||
|       }; | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const _statusCheckProbe: Probe = { | ||||
|   id: 'health-check', | ||||
|   name: 'Built-in Health check', | ||||
|   apply: async (server, req) => { | ||||
|     const baseUrl = server.getHomeUrl(req); | ||||
|     const url = new URL(baseUrl); | ||||
|     url.pathname = removeTrailingSlash(url.pathname) + '/status'; | ||||
|     try { | ||||
|       const resp = await fetch(url); | ||||
|       if (resp.status !== 200) { | ||||
|         throw new Error(`Failed with status ${resp.status}`); | ||||
|       } | ||||
|       const txt = await resp.text(); | ||||
|       if (!txt.includes('is alive')) { | ||||
|         throw new Error(`Failed, page has unexpected content`); | ||||
|       } | ||||
|       return { | ||||
|         success: true, | ||||
|       }; | ||||
|     } catch (e) { | ||||
|       return { | ||||
|         success: false, | ||||
|         error: String(e), | ||||
|         severity: 'fault', | ||||
|       }; | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| const _userProbe: Probe = { | ||||
|   id: 'system-user', | ||||
|   name: 'System user is sane', | ||||
|   apply: async () => { | ||||
|     if (process.getuid && process.getuid() === 0) { | ||||
|       return { | ||||
|         success: false, | ||||
|         verdict: 'User appears to be root (UID 0)', | ||||
|         severity: 'warning', | ||||
|       }; | ||||
|     } else { | ||||
|       return { | ||||
|         success: true, | ||||
|       }; | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| const _bootProbe: Probe = { | ||||
|   id: 'boot-page', | ||||
|   name: 'Boot page exposure', | ||||
|   apply: async (server) => { | ||||
|     if (!server.hasBoot) { | ||||
|       return { success: true }; | ||||
|     } | ||||
|     const maybeSecureEnough = String(process.env.GRIST_BOOT_KEY).length > 10; | ||||
|     return { | ||||
|       success: maybeSecureEnough, | ||||
|       severity: 'hmm', | ||||
|     }; | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Based on: | ||||
|  * https://github.com/gristlabs/grist-core/issues/228#issuecomment-1803304438
 | ||||
|  * | ||||
|  * When GRIST_SERVE_SAME_ORIGIN is set, requests arriving to Grist need | ||||
|  * to have an accurate Host header. | ||||
|  */ | ||||
| const _hostHeaderProbe: Probe = { | ||||
|   id: 'host-header', | ||||
|   name: 'Host header is sane', | ||||
|   apply: async (server, req) => { | ||||
|     const host = req.header('host'); | ||||
|     const url = new URL(server.getHomeUrl(req)); | ||||
|     if (url.hostname === 'localhost') { | ||||
|       return { | ||||
|         done: true, | ||||
|       }; | ||||
|     } | ||||
|     if (String(url.hostname).toLowerCase() !== String(host).toLowerCase()) { | ||||
|       return { | ||||
|         success: false, | ||||
|         severity: 'hmm', | ||||
|       }; | ||||
|     } | ||||
|     return { | ||||
|       done: true, | ||||
|     }; | ||||
|   }, | ||||
| }; | ||||
| @ -28,6 +28,7 @@ import {appSettings} from 'app/server/lib/AppSettings'; | ||||
| import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser, | ||||
|         isSingleUserMode, redirectToLoginUnconditionally} from 'app/server/lib/Authorizer'; | ||||
| import {redirectToLogin, RequestWithLogin, signInStatusMiddleware} from 'app/server/lib/Authorizer'; | ||||
| import {BootProbes} from 'app/server/lib/BootProbes'; | ||||
| import {forceSessionChange} from 'app/server/lib/BrowserSession'; | ||||
| import {Comm} from 'app/server/lib/Comm'; | ||||
| import {create} from 'app/server/lib/create'; | ||||
| @ -175,6 +176,7 @@ export class FlexServer implements GristServer { | ||||
|   private _getLoginSystem?: () => Promise<GristLoginSystem>; | ||||
|   // Set once ready() is called
 | ||||
|   private _isReady: boolean = false; | ||||
|   private _probes: BootProbes; | ||||
| 
 | ||||
|   constructor(public port: number, public name: string = 'flexServer', | ||||
|               public readonly options: FlexServerOptions = {}) { | ||||
| @ -481,6 +483,57 @@ export class FlexServer implements GristServer { | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * | ||||
|    * Adds a /boot/$GRIST_BOOT_KEY page that shows diagnostics. | ||||
|    * Accepts any /boot/... URL in order to let the front end | ||||
|    * give some guidance if the user is stumbling around trying | ||||
|    * to find the boot page, but won't actually provide diagnostics | ||||
|    * unless GRIST_BOOT_KEY is set in the environment, and is present | ||||
|    * in the URL. | ||||
|    * | ||||
|    * We take some steps to make the boot page available even when | ||||
|    * things are going wrong, and should take more in future. | ||||
|    * | ||||
|    * When rendering the page a hardcoded 'boot' tag is used, which | ||||
|    * is used to ensure that static assets are served locally and | ||||
|    * we aren't relying on APP_STATIC_URL being set correctly. | ||||
|    * | ||||
|    * We use a boot key so that it is more acceptable to have this | ||||
|    * boot page living outside of the authentication system, which | ||||
|    * could be broken. | ||||
|    * | ||||
|    * TODO: there are some configuration problems that currently | ||||
|    * result in Grist not running at all. ideally they would result in | ||||
|    * Grist running in a limited mode that is enough to bring up the boot | ||||
|    * page. | ||||
|    * | ||||
|    */ | ||||
|   public addBootPage() { | ||||
|     if (this._check('boot')) { return; } | ||||
|     const bootKey = appSettings.section('boot').flag('key').readString({ | ||||
|       envVar: 'GRIST_BOOT_KEY' | ||||
|     }); | ||||
|     const base = `/boot/${bootKey}`; | ||||
|     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) => { | ||||
|       const goodKey = bootKey && req.params.bootKey === bootKey; | ||||
|       return this._sendAppPage(req, res, { | ||||
|         path: 'boot.html', status: 200, config: goodKey ? { | ||||
|         } : { | ||||
|           errMessage: 'not-the-key', | ||||
|         }, tag: 'boot', | ||||
|       }); | ||||
|     }); | ||||
|     this._probes.addEndpoints(); | ||||
|   } | ||||
| 
 | ||||
|   public hasBoot(): boolean { | ||||
|     return Boolean(this._probes); | ||||
|   } | ||||
| 
 | ||||
|   public denyRequestsIfNotReady() { | ||||
|     this.app.use((_req, res, next) => { | ||||
|       if (!this._isReady) { | ||||
|  | ||||
| @ -60,6 +60,7 @@ export interface GristServer { | ||||
|   getPlugins(): LocalPlugin[]; | ||||
|   servesPlugins(): boolean; | ||||
|   getBundledWidgets(): ICustomWidget[]; | ||||
|   hasBoot(): boolean; | ||||
| } | ||||
| 
 | ||||
| export interface GristLoginSystem { | ||||
| @ -147,6 +148,7 @@ export function createDummyGristServer(): GristServer { | ||||
|     servesPlugins() { return false; }, | ||||
|     getPlugins() { return []; }, | ||||
|     getBundledWidgets() { return []; }, | ||||
|     hasBoot() { return false; }, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -139,8 +139,11 @@ export function makeSendAppPage(opts: { | ||||
|     const needTagManager = (options.googleTagManager === 'anon' && isAnonymousUser(req)) || | ||||
|       options.googleTagManager === true; | ||||
|     const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : ''; | ||||
|     const staticOrigin = process.env.APP_STATIC_URL || ""; | ||||
|     const staticBaseUrl = `${staticOrigin}/v/${options.tag || tag}/`; | ||||
|     const staticTag = options.tag || tag; | ||||
|     // If boot tag is used, serve assets locally, otherwise respect
 | ||||
|     // APP_STATIC_URL.
 | ||||
|     const staticOrigin = staticTag === 'boot' ? '' : (process.env.APP_STATIC_URL || ''); | ||||
|     const staticBaseUrl = `${staticOrigin}/v/${staticTag}/`; | ||||
|     const customHeadHtmlSnippet = server.create.getExtraHeadHtml?.() ?? ""; | ||||
|     const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : ""; | ||||
|     // Preload all languages that will be used or are requested by client.
 | ||||
|  | ||||
| @ -104,6 +104,9 @@ export async function main(port: number, serverTypes: ServerType[], | ||||
|   } | ||||
| 
 | ||||
|   server.addHealthCheck(); | ||||
|   if (includeHome || includeApp) { | ||||
|     server.addBootPage(); | ||||
|   } | ||||
|   server.denyRequestsIfNotReady(); | ||||
| 
 | ||||
|   if (includeHome || includeStatic || includeApp) { | ||||
|  | ||||
| @ -14,6 +14,7 @@ module.exports = { | ||||
|     main: "app/client/app", | ||||
|     errorPages: "app/client/errorMain", | ||||
|     apiconsole: "app/client/apiconsole", | ||||
|     boot: "app/client/boot", | ||||
|     billing: "app/client/billingMain", | ||||
|     form: "app/client/formMain", | ||||
|     // Include client test harness if it is present (it won't be in
 | ||||
|  | ||||
							
								
								
									
										15
									
								
								static/boot.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								static/boot.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | ||||
| <!doctype html> | ||||
| <html> | ||||
| <head> | ||||
|   <meta charset="utf8"> | ||||
|   <!-- INSERT BASE --> | ||||
|   <link rel="icon" type="image/x-icon" href="icons/favicon.png" /> | ||||
|   <link rel="stylesheet" href="icons/icons.css"> | ||||
|   <!-- INSERT LOCALE --> | ||||
|   <!-- INSERT CONFIG --> | ||||
|   <title>Loading...<!-- INSERT TITLE SUFFIX --></title> | ||||
| </head> | ||||
| <body> | ||||
|   <script crossorigin="anonymous" src="boot.bundle.js"></script> | ||||
| </body> | ||||
| </html> | ||||
							
								
								
									
										52
									
								
								test/nbrowser/Boot.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								test/nbrowser/Boot.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,52 @@ | ||||
| import {assert, driver} from 'mocha-webdriver'; | ||||
| import * as gu from 'test/nbrowser/gristUtils'; | ||||
| import {server, setupTestSuite} from 'test/nbrowser/testUtils'; | ||||
| import * as testUtils from 'test/server/testUtils'; | ||||
| 
 | ||||
| describe('Boot', function() { | ||||
|   this.timeout(30000); | ||||
|   setupTestSuite(); | ||||
| 
 | ||||
|   let oldEnv: testUtils.EnvironmentSnapshot; | ||||
| 
 | ||||
|   afterEach(() => gu.checkForErrors()); | ||||
| 
 | ||||
|   async function hasPrompt() { | ||||
|     assert.include( | ||||
|       await driver.findContentWait('p', /diagnostics page/, 2000).getText(), | ||||
|       'A diagnostics page can be made available'); | ||||
|   } | ||||
| 
 | ||||
|   it('gives prompt about how to enable boot page', async function() { | ||||
|     await driver.get(`${server.getHost()}/boot`); | ||||
|     await hasPrompt(); | ||||
|   }); | ||||
| 
 | ||||
|   describe('with a GRIST_BOOT_KEY', function() { | ||||
|     before(async function() { | ||||
|       oldEnv = new testUtils.EnvironmentSnapshot(); | ||||
|       process.env.GRIST_BOOT_KEY = 'lala'; | ||||
|       await server.restart(); | ||||
|     }); | ||||
| 
 | ||||
|     after(async function() { | ||||
|       oldEnv.restore(); | ||||
|       await server.restart(); | ||||
|     }); | ||||
| 
 | ||||
|     it('gives prompt when key is missing', async function() { | ||||
|       await driver.get(`${server.getHost()}/boot`); | ||||
|       await hasPrompt(); | ||||
|     }); | ||||
| 
 | ||||
|     it('gives prompt when key is wrong', async function() { | ||||
|       await driver.get(`${server.getHost()}/boot/bilbo`); | ||||
|       await hasPrompt(); | ||||
|     }); | ||||
| 
 | ||||
|     it('gives page when key is right', async function() { | ||||
|       await driver.get(`${server.getHost()}/boot/lala`); | ||||
|       await driver.findContentWait('h2', /Grist is reachable/, 2000); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user