mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) updates from grist-core
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* dispose.js provides tools to components that needs to dispose of resources, such as
|
||||
* destroy DOM, and unsubscribe from events. The motivation with examples is presented here:
|
||||
*
|
||||
* https://phab.getgrist.com/w/disposal/
|
||||
* /documentation/disposal/disposal.md
|
||||
*/
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ Object.assign(Disposable.prototype, {
|
||||
}
|
||||
|
||||
// Finish by wiping out the object, since nothing should use it after dispose().
|
||||
// See https://phab.getgrist.com/w/disposal/ for more motivation.
|
||||
// See /documentation/disposal.md for more motivation.
|
||||
wipeOutObject(this);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -17,7 +17,7 @@ import {TableData} from 'app/client/models/TableData';
|
||||
import {ColumnFilterCalendarView} from 'app/client/ui/ColumnFilterCalendarView';
|
||||
import {relativeDatesControl} from 'app/client/ui/ColumnFilterMenuUtils';
|
||||
import {cssInput} from 'app/client/ui/cssInput';
|
||||
import {DateRangeOptions, IDateRangeOption} from 'app/client/ui/DateRangeOptions';
|
||||
import {getDateRangeOptions, IDateRangeOption} from 'app/client/ui/DateRangeOptions';
|
||||
import {cssPinButton} from 'app/client/ui/RightPanelStyles';
|
||||
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
||||
import {cssLabel as cssCheckboxLabel, cssCheckboxSquare,
|
||||
@@ -176,16 +176,16 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
cssLinkRow(
|
||||
testId('presets-links'),
|
||||
cssLink(
|
||||
DateRangeOptions[0].label,
|
||||
dom.on('click', () => action(DateRangeOptions[0]))
|
||||
getDateRangeOptions()[0].label,
|
||||
dom.on('click', () => action(getDateRangeOptions()[0]))
|
||||
),
|
||||
cssLink(
|
||||
DateRangeOptions[1].label,
|
||||
dom.on('click', () => action(DateRangeOptions[1]))
|
||||
getDateRangeOptions()[1].label,
|
||||
dom.on('click', () => action(getDateRangeOptions()[1]))
|
||||
),
|
||||
cssLink(
|
||||
'More ', icon('Dropdown'),
|
||||
menu(() => DateRangeOptions.map(
|
||||
menu(() => getDateRangeOptions().map(
|
||||
(option) => menuItem(() => action(option), option.label)
|
||||
), {attach: '.' + cssMenu.className})
|
||||
),
|
||||
|
||||
@@ -1,41 +1,55 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import { CURRENT_DATE, IRelativeDateSpec } from "app/common/RelativeDates";
|
||||
|
||||
const t = makeT('DateRangeOptions');
|
||||
|
||||
export interface IDateRangeOption {
|
||||
label: string;
|
||||
min: IRelativeDateSpec;
|
||||
max: IRelativeDateSpec;
|
||||
}
|
||||
|
||||
export const DateRangeOptions: IDateRangeOption[] = [{
|
||||
label: 'Today',
|
||||
min: CURRENT_DATE,
|
||||
max: CURRENT_DATE,
|
||||
}, {
|
||||
label: 'Last 7 days',
|
||||
min: [{quantity: -7, unit: 'day'}],
|
||||
max: [{quantity: -1, unit: 'day'}],
|
||||
}, {
|
||||
label: 'Next 7 days',
|
||||
min: [{quantity: 1, unit: 'day'}],
|
||||
max: [{quantity: 7, unit: 'day'}],
|
||||
}, {
|
||||
label: 'Last Week',
|
||||
min: [{quantity: -1, unit: 'week'}],
|
||||
max: [{quantity: -1, unit: 'week', endOf: true}],
|
||||
}, {
|
||||
label: 'Last 30 days',
|
||||
min: [{quantity: -30, unit: 'day'}],
|
||||
max: [{quantity: -1, unit: 'day'}],
|
||||
}, {
|
||||
label: 'This week',
|
||||
min: [{quantity: 0, unit: 'week'}],
|
||||
max: [{quantity: 0, unit: 'week', endOf: true}],
|
||||
}, {
|
||||
label: 'This month',
|
||||
min: [{quantity: 0, unit: 'month'}],
|
||||
max: [{quantity: 0, unit: 'month', endOf: true}],
|
||||
}, {
|
||||
label: 'This year',
|
||||
min: [{quantity: 0, unit: 'year'}],
|
||||
max: [{quantity: 0, unit: 'year', endOf: true}],
|
||||
}];
|
||||
export function getDateRangeOptions(): IDateRangeOption[] {
|
||||
return [
|
||||
{
|
||||
label: t('Today'),
|
||||
min: CURRENT_DATE,
|
||||
max: CURRENT_DATE,
|
||||
},
|
||||
{
|
||||
label: t('Last 7 days'),
|
||||
min: [{quantity: -7, unit: 'day'}],
|
||||
max: [{quantity: -1, unit: 'day'}],
|
||||
},
|
||||
{
|
||||
label: t('Next 7 days'),
|
||||
min: [{quantity: 1, unit: 'day'}],
|
||||
max: [{quantity: 7, unit: 'day'}],
|
||||
},
|
||||
{
|
||||
label: t('Last Week'),
|
||||
min: [{quantity: -1, unit: 'week'}],
|
||||
max: [{quantity: -1, unit: 'week', endOf: true}],
|
||||
},
|
||||
{
|
||||
label: t('Last 30 days'),
|
||||
min: [{quantity: -30, unit: 'day'}],
|
||||
max: [{quantity: -1, unit: 'day'}],
|
||||
},
|
||||
{
|
||||
label: t('This week'),
|
||||
min: [{quantity: 0, unit: 'week'}],
|
||||
max: [{quantity: 0, unit: 'week', endOf: true}],
|
||||
},
|
||||
{
|
||||
label: t('This month'),
|
||||
min: [{quantity: 0, unit: 'month'}],
|
||||
max: [{quantity: 0, unit: 'month', endOf: true}],
|
||||
},
|
||||
{
|
||||
label: t('This year'),
|
||||
min: [{quantity: 0, unit: 'year'}],
|
||||
max: [{quantity: 0, unit: 'year', endOf: true}],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export const GristTooltips: Record<Tooltip, TooltipContentFunc> = {
|
||||
t('Try out changes in a copy, then decide whether to replace the original with your edits.')
|
||||
),
|
||||
dom('div',
|
||||
cssLink({href: commonUrls.helpTryingOutChanges, target: '_blank'}, 'Learn more.'),
|
||||
cssLink({href: commonUrls.helpTryingOutChanges, target: '_blank'}, t('Learn more.')),
|
||||
),
|
||||
...args,
|
||||
),
|
||||
|
||||
@@ -303,7 +303,7 @@ export function downloadDocModal(doc: Document, pageModel: DocPageModel) {
|
||||
const selected = Observable.create<DownloadOption>(owner, 'full');
|
||||
|
||||
return [
|
||||
cssModalTitle(`Download document`),
|
||||
cssModalTitle(t(`Download document`)),
|
||||
cssRadioCheckboxOptions(
|
||||
radioCheckboxOption(selected, 'full', t("Download full document and history")),
|
||||
radioCheckboxOption(selected, 'nohistory', t("Remove document history (can significantly reduce file size)")),
|
||||
@@ -311,7 +311,7 @@ export function downloadDocModal(doc: Document, pageModel: DocPageModel) {
|
||||
),
|
||||
cssModalButtons(
|
||||
dom.domComputed(use =>
|
||||
bigPrimaryButtonLink(`Download`, hooks.maybeModifyLinkAttrs({
|
||||
bigPrimaryButtonLink(t(`Download`), hooks.maybeModifyLinkAttrs({
|
||||
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadUrl({
|
||||
template: use(selected) === "template",
|
||||
removeHistory: use(selected) === "nohistory" || use(selected) === "template",
|
||||
@@ -325,7 +325,7 @@ export function downloadDocModal(doc: Document, pageModel: DocPageModel) {
|
||||
testId('download-button-link'),
|
||||
),
|
||||
),
|
||||
bigBasicButton('Cancel', dom.on('click', () => {
|
||||
bigBasicButton(t('Cancel'), dom.on('click', () => {
|
||||
ctl.close();
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {cardPopup, cssPopupBody, cssPopupButtons, cssPopupCloseButton,
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {dom, styled} from 'grainjs';
|
||||
import { commonUrls } from 'app/common/gristUrls';
|
||||
|
||||
const t = makeT('WelcomeCoachingCall');
|
||||
|
||||
@@ -103,7 +104,7 @@ We can show you the Grist basics, or start working with your data right away to
|
||||
logTelemetryEvent('clickedScheduleCoachingCall');
|
||||
}),
|
||||
{
|
||||
href: getGristConfig().freeCoachingCallUrl,
|
||||
href: commonUrls.freeCoachingCall,
|
||||
target: '_blank',
|
||||
},
|
||||
testId('popup-primary-button'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {pagePanels} from 'app/client/ui/PagePanels';
|
||||
import {createTopBarHome} from 'app/client/ui/TopBar';
|
||||
import {bigBasicButtonLink, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
|
||||
import {theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {getPageTitleSuffix} from 'app/common/gristUrls';
|
||||
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {dom, DomElementArg, makeTestId, observable, styled} from 'grainjs';
|
||||
|
||||
@@ -94,7 +94,7 @@ export function createNotFoundPage(appModel: AppModel, message?: string) {
|
||||
})),
|
||||
cssButtonWrap(bigPrimaryButtonLink(t("Go to main page"), testId('error-primary-btn'),
|
||||
urlState().setLinkUrl({}))),
|
||||
cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: 'https://getgrist.com/contact'})),
|
||||
cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: commonUrls.contactSupport})),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export function createOtherErrorPage(appModel: AppModel, message?: string) {
|
||||
t('There was an unknown error.')),
|
||||
cssButtonWrap(bigPrimaryButtonLink(t("Go to main page"), testId('error-primary-btn'),
|
||||
urlState().setLinkUrl({}))),
|
||||
cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: 'https://getgrist.com/contact'})),
|
||||
cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: commonUrls.contactSupport})),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ export const commonUrls = {
|
||||
helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited",
|
||||
helpCalendarWidget: "https://support.getgrist.com/widget-calendar",
|
||||
helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys",
|
||||
freeCoachingCall: getFreeCoachingCallUrl(),
|
||||
contactSupport: getContactSupportUrl(),
|
||||
plans: "https://www.getgrist.com/pricing",
|
||||
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
||||
contact: "https://www.getgrist.com/contact",
|
||||
@@ -670,6 +672,9 @@ export interface GristLoadConfig {
|
||||
// Url for free coaching call scheduling for the browser client to use.
|
||||
freeCoachingCallUrl?: string;
|
||||
|
||||
// Url for "contact support" button on Grist's "not found" error page
|
||||
contactSupportUrl?: string;
|
||||
|
||||
// When set, this directs the client to encode org information in path, not in domain.
|
||||
pathOnly?: boolean;
|
||||
|
||||
@@ -865,21 +870,33 @@ export function getKnownOrg(): string|null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getHelpCenterUrl(): string|null {
|
||||
export function getHelpCenterUrl(): string {
|
||||
const defaultUrl = "https://support.getgrist.com";
|
||||
if(isClient()) {
|
||||
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
||||
return gristConfig && gristConfig.helpCenterUrl || null;
|
||||
return gristConfig && gristConfig.helpCenterUrl || defaultUrl;
|
||||
} else {
|
||||
return process.env.GRIST_HELP_CENTER || null;
|
||||
return process.env.GRIST_HELP_CENTER || defaultUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFreeCoachingCallUrl(): string|null {
|
||||
export function getFreeCoachingCallUrl(): string {
|
||||
const defaultUrl = "https://calendly.com/grist-team/grist-free-coaching-call";
|
||||
if(isClient()) {
|
||||
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
||||
return gristConfig && gristConfig.freeCoachingCallUrl || null;
|
||||
return gristConfig && gristConfig.freeCoachingCallUrl || defaultUrl;
|
||||
} else {
|
||||
return process.env.FREE_COACHING_CALL_URL || null;
|
||||
return process.env.FREE_COACHING_CALL_URL || defaultUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function getContactSupportUrl(): string {
|
||||
const defaultUrl = "https://www.getgrist.com/contact/";
|
||||
if(isClient()) {
|
||||
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
||||
return gristConfig && gristConfig.contactSupportUrl || defaultUrl;
|
||||
} else {
|
||||
return process.env.GRIST_CONTACT_SUPPORT_URL || defaultUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,7 +955,7 @@ export function extractOrgParts(reqHost: string|undefined, reqPath: string): Org
|
||||
orgFromHost = getOrgFromHost(reqHost);
|
||||
if (orgFromHost) {
|
||||
// Some subdomains are shared, and do not reflect the name of an organization.
|
||||
// See https://phab.getgrist.com/w/hosting/v1/urls/ for a list.
|
||||
// See /documentation/urls.md for a list.
|
||||
if (/^(api|v1-.*|doc-worker-.*)$/.test(orgFromHost)) {
|
||||
orgFromHost = null;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const BLACKLISTED_SUBDOMAINS = new Set([
|
||||
/**
|
||||
*
|
||||
* Checks whether the subdomain is on the list of forbidden subdomains.
|
||||
* See https://phab.getgrist.com/w/hosting/v1/urls/#organization-subdomains
|
||||
* See /documentation/urls.md#organization-subdomains
|
||||
*
|
||||
* Also enforces various sanity checks.
|
||||
*
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface GristTable {
|
||||
// This is documenting what is currently returned by the core plugins. Capitalization
|
||||
// is python-style.
|
||||
//
|
||||
// TODO: could be worth reconciling with: https://phab.getgrist.com/w/grist_data_format/.
|
||||
// TODO: could be worth reconciling with: /documentation/grist-data-format.md.
|
||||
table_name: string | null; // currently allow names to be null
|
||||
column_metadata: GristColumn[];
|
||||
table_data: any[][];
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -93,6 +93,7 @@ import * as path from 'path';
|
||||
import * as t from "ts-interface-checker";
|
||||
import {Checker} from "ts-interface-checker";
|
||||
import uuidv4 from "uuid/v4";
|
||||
import { Document } from "app/gen-server/entity/Document";
|
||||
|
||||
// Cap on the number of requests that can be outstanding on a single document via the
|
||||
// rest doc api. When this limit is exceeded, incoming requests receive an immediate
|
||||
@@ -646,6 +647,9 @@ export class DocWorkerApi {
|
||||
// full document.
|
||||
const dryRun = isAffirmative(req.query.dryrun || req.query.dryRun);
|
||||
const dryRunSuccess = () => res.status(200).json({dryRun: 'allowed'});
|
||||
|
||||
const filename = await this._getDownloadFilename(req);
|
||||
|
||||
// We want to be have a way download broken docs that ActiveDoc may not be able
|
||||
// to load. So, if the user owns the document, we unconditionally let them
|
||||
// download.
|
||||
@@ -655,13 +659,13 @@ export class DocWorkerApi {
|
||||
// We carefully avoid creating an ActiveDoc for the document being downloaded,
|
||||
// in case it is broken in some way. It is convenient to be able to download
|
||||
// broken files for diagnosis/recovery.
|
||||
return await this._docWorker.downloadDoc(req, res, this._docManager.storageManager);
|
||||
return await this._docWorker.downloadDoc(req, res, this._docManager.storageManager, filename);
|
||||
} catch (e) {
|
||||
if (e.message && e.message.match(/does not exist yet/)) {
|
||||
// The document has never been seen on file system / s3. It may be new, so
|
||||
// we try again after having created an ActiveDoc for the document.
|
||||
await this._getActiveDoc(req);
|
||||
return this._docWorker.downloadDoc(req, res, this._docManager.storageManager);
|
||||
return this._docWorker.downloadDoc(req, res, this._docManager.storageManager, filename);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
@@ -674,7 +678,7 @@ export class DocWorkerApi {
|
||||
throw new ApiError('not authorized to download this document', 403);
|
||||
}
|
||||
if (dryRun) { dryRunSuccess(); return; }
|
||||
return this._docWorker.downloadDoc(req, res, this._docManager.storageManager);
|
||||
return this._docWorker.downloadDoc(req, res, this._docManager.storageManager, filename);
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -1222,7 +1226,7 @@ export class DocWorkerApi {
|
||||
|
||||
this._app.get('/api/docs/:docId/download/table-schema', canView, withDoc(async (activeDoc, req, res) => {
|
||||
const doc = await this._dbManager.getDoc(req);
|
||||
const options = this._getDownloadOptions(req, doc.name);
|
||||
const options = await this._getDownloadOptions(req, doc);
|
||||
const tableSchema = await collectTableSchemaInFrictionlessFormat(activeDoc, req, options);
|
||||
const apiPath = await this._grist.getResourceUrl(doc, 'api');
|
||||
const query = new URLSearchParams(req.query as {[key: string]: string});
|
||||
@@ -1241,18 +1245,16 @@ export class DocWorkerApi {
|
||||
}));
|
||||
|
||||
this._app.get('/api/docs/:docId/download/csv', canView, withDoc(async (activeDoc, req, res) => {
|
||||
// Query DB for doc metadata to get the doc title.
|
||||
const {name: docTitle} = await this._dbManager.getDoc(req);
|
||||
const options = this._getDownloadOptions(req, docTitle);
|
||||
const options = await this._getDownloadOptions(req);
|
||||
|
||||
await downloadCSV(activeDoc, req, res, options);
|
||||
}));
|
||||
|
||||
this._app.get('/api/docs/:docId/download/xlsx', canView, withDoc(async (activeDoc, req, res) => {
|
||||
// Query DB for doc metadata to get the doc title (to use as the filename).
|
||||
const {name: docTitle} = await this._dbManager.getDoc(req);
|
||||
const options: DownloadOptions = !_.isEmpty(req.query) ? this._getDownloadOptions(req, docTitle) : {
|
||||
filename: docTitle,
|
||||
const options: DownloadOptions = (!_.isEmpty(req.query) && !_.isEqual(Object.keys(req.query), ["title"]))
|
||||
? await this._getDownloadOptions(req)
|
||||
: {
|
||||
filename: await this._getDownloadFilename(req),
|
||||
tableId: '',
|
||||
viewSectionId: undefined,
|
||||
filters: [],
|
||||
@@ -1734,11 +1736,23 @@ export class DocWorkerApi {
|
||||
return docAuth.docId!;
|
||||
}
|
||||
|
||||
private _getDownloadOptions(req: Request, name: string): DownloadOptions {
|
||||
private async _getDownloadFilename(req: Request, tableId?: string, optDoc?: Document): Promise<string> {
|
||||
let filename = optStringParam(req.query.title, 'title');
|
||||
if (!filename) {
|
||||
// Query DB for doc metadata to get the doc data.
|
||||
const doc = optDoc || await this._dbManager.getDoc(req);
|
||||
const docTitle = doc.name;
|
||||
const suffix = tableId ? (tableId === docTitle ? '' : `-${tableId}`) : '';
|
||||
filename = docTitle + suffix || 'document';
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
private async _getDownloadOptions(req: Request, doc?: Document): Promise<DownloadOptions> {
|
||||
const params = parseExportParameters(req);
|
||||
return {
|
||||
...params,
|
||||
filename: name + (params.tableId === name ? '' : '-' + params.tableId),
|
||||
filename: await this._getDownloadFilename(req, params.tableId, doc),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,14 +68,10 @@ export class DocWorker {
|
||||
}
|
||||
|
||||
public async downloadDoc(req: express.Request, res: express.Response,
|
||||
storageManager: IDocStorageManager): Promise<void> {
|
||||
storageManager: IDocStorageManager, filename: string): Promise<void> {
|
||||
const mreq = req as RequestWithLogin;
|
||||
const docId = getDocId(mreq);
|
||||
|
||||
// Query DB for doc metadata to get the doc title.
|
||||
const doc = await this._dbManager.getDoc(req);
|
||||
const docTitle = doc.name;
|
||||
|
||||
// Get a copy of document for downloading.
|
||||
const tmpPath = await storageManager.getCopy(docId);
|
||||
if (isAffirmative(req.query.template)) {
|
||||
@@ -90,7 +86,7 @@ export class DocWorker {
|
||||
return res.type('application/x-sqlite3')
|
||||
.download(
|
||||
tmpPath,
|
||||
(optStringParam(req.query.title, 'title') || docTitle || 'document') + ".grist",
|
||||
filename + ".grist",
|
||||
async (err: any) => {
|
||||
if (err) {
|
||||
if (err.message && /Request aborted/.test(err.message)) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {ObjMetadata, ObjSnapshot, ObjSnapshotWithMetadata} from 'app/common/DocSnapshot';
|
||||
import {isAffirmative} from 'app/common/gutil';
|
||||
import log from 'app/server/lib/log';
|
||||
import {createTmpDir} from 'app/server/lib/uploads';
|
||||
|
||||
import {delay} from 'bluebird';
|
||||
import * as fse from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
@@ -226,13 +228,27 @@ export class ChecksummedExternalStorage implements ExternalStorage {
|
||||
const expectedChecksum = await this._options.sharedHash.load(fromKey);
|
||||
// Let null docMD5s pass. Otherwise we get stuck if redis is cleared.
|
||||
// Otherwise, make sure what we've got matches what we expect to get.
|
||||
// S3 is eventually consistent - if you overwrite an object in it, and then read from it,
|
||||
// you may get an old version for some time.
|
||||
// AWS S3 was eventually consistent, but now has stronger guarantees:
|
||||
// https://aws.amazon.com/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/
|
||||
//
|
||||
// Previous to this change, if you overwrote an object in it,
|
||||
// and then read from it, you may have got an old version for some time.
|
||||
// We are confident this should not be the case anymore, though this has to be studied carefully.
|
||||
// If a snapshotId was specified, we can skip this check.
|
||||
if (expectedChecksum && expectedChecksum !== checksum) {
|
||||
log.error("ext %s download: data for %s has wrong checksum: %s (expected %s)",
|
||||
this.label, fromKey, checksum, expectedChecksum);
|
||||
return undefined;
|
||||
const message = `ext ${this.label} download: data for ${fromKey} has wrong checksum:` +
|
||||
` ${checksum} (expected ${expectedChecksum})`;
|
||||
|
||||
// If GRIST_SKIP_REDIS_CHECKSUM_MISMATCH is set, issue a warning only and continue,
|
||||
// rather than issuing an error and failing.
|
||||
// This flag is experimental and should be removed once we are
|
||||
// confident that the checksums verification is useless.
|
||||
if (isAffirmative(process.env.GRIST_SKIP_REDIS_CHECKSUM_MISMATCH)) {
|
||||
log.warn(message);
|
||||
} else {
|
||||
log.error(message);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import {Features, getPageTitleSuffix, GristLoadConfig, IFeature} from 'app/common/gristUrls';
|
||||
import {
|
||||
Features,
|
||||
getContactSupportUrl,
|
||||
getFreeCoachingCallUrl,
|
||||
getHelpCenterUrl,
|
||||
getPageTitleSuffix,
|
||||
GristLoadConfig,
|
||||
IFeature
|
||||
} from 'app/common/gristUrls';
|
||||
import {isAffirmative} from 'app/common/gutil';
|
||||
import {getTagManagerSnippet} from 'app/common/tagManager';
|
||||
import {Document} from 'app/common/UserAPI';
|
||||
@@ -53,8 +61,9 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi
|
||||
org: process.env.GRIST_SINGLE_ORG || (mreq && mreq.org),
|
||||
baseDomain,
|
||||
singleOrg: process.env.GRIST_SINGLE_ORG,
|
||||
helpCenterUrl: process.env.GRIST_HELP_CENTER || "https://support.getgrist.com",
|
||||
freeCoachingCallUrl: process.env.FREE_COACHING_CALL_URL || "https://calendly.com/grist-team/grist-free-coaching-call",
|
||||
helpCenterUrl: getHelpCenterUrl(),
|
||||
freeCoachingCallUrl: getFreeCoachingCallUrl(),
|
||||
contactSupportUrl: getContactSupportUrl(),
|
||||
pathOnly,
|
||||
supportAnon: shouldSupportAnon(),
|
||||
enableAnonPlayground: isAffirmative(process.env.GRIST_ANON_PLAYGROUND ?? true),
|
||||
@@ -139,8 +148,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) {
|
||||
|
||||
Reference in New Issue
Block a user