2023-05-20 23:58:41 +00:00
|
|
|
import {Features, getPageTitleSuffix, GristLoadConfig, IFeature} from 'app/common/gristUrls';
|
2023-03-23 18:22:28 +00:00
|
|
|
import {isAffirmative} from 'app/common/gutil';
|
2022-03-11 20:35:29 +00:00
|
|
|
import {getTagManagerSnippet} from 'app/common/tagManager';
|
2022-07-27 20:20:14 +00:00
|
|
|
import {Document} from 'app/common/UserAPI';
|
2023-08-29 14:50:42 +00:00
|
|
|
import {AttachedCustomWidgets, IAttachedCustomWidget} from "app/common/widgetTypes";
|
2022-12-13 01:06:15 +00:00
|
|
|
import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager';
|
2023-06-06 17:08:50 +00:00
|
|
|
import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer';
|
2020-07-21 13:20:51 +00:00
|
|
|
import {RequestWithOrg} from 'app/server/lib/extractOrg';
|
|
|
|
import {GristServer} from 'app/server/lib/GristServer';
|
2023-07-31 20:10:59 +00:00
|
|
|
import {getTemplateOrg} from 'app/server/lib/gristSettings';
|
2021-09-15 23:35:21 +00:00
|
|
|
import {getSupportedEngineChoices} from 'app/server/lib/serverUtils';
|
2022-09-29 08:01:37 +00:00
|
|
|
import {readLoadedLngs, readLoadedNamespaces} from 'app/server/localization';
|
2020-07-21 13:20:51 +00:00
|
|
|
import * as express from 'express';
|
|
|
|
import * as fse from 'fs-extra';
|
2022-07-27 20:20:14 +00:00
|
|
|
import * as handlebars from 'handlebars';
|
2023-08-29 14:50:42 +00:00
|
|
|
import jsesc from 'jsesc';
|
2020-07-21 13:20:51 +00:00
|
|
|
import * as path from 'path';
|
2023-05-20 23:58:41 +00:00
|
|
|
import difference = require('lodash/difference');
|
2020-07-21 13:20:51 +00:00
|
|
|
|
2022-10-13 10:31:26 +00:00
|
|
|
const translate = (req: express.Request, key: string, args?: any) => req.t(`sendAppPage.${key}`, args);
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
export interface ISendAppPageOptions {
|
|
|
|
path: string; // Ignored if .content is present (set to "" for clarity).
|
|
|
|
content?: string;
|
|
|
|
status: number;
|
|
|
|
config: Partial<GristLoadConfig>;
|
|
|
|
tag?: string; // If present, override version tag.
|
|
|
|
|
|
|
|
// If present, enable Google Tag Manager on this page (if GOOGLE_TAG_MANAGER_ID env var is set).
|
|
|
|
// Used on the welcome page to track sign-ups. We don't intend to use it for in-app analytics.
|
|
|
|
// Set to true to insert tracker unconditionally; false to omit it; "anon" to insert
|
|
|
|
// it only when the user is not logged in.
|
|
|
|
googleTagManager?: true | false | 'anon';
|
|
|
|
}
|
|
|
|
|
2023-10-27 19:34:42 +00:00
|
|
|
export interface MakeGristConfigOptions {
|
2023-06-06 17:08:50 +00:00
|
|
|
homeUrl: string|null;
|
|
|
|
extra: Partial<GristLoadConfig>;
|
|
|
|
baseDomain?: string;
|
|
|
|
req?: express.Request;
|
|
|
|
server?: GristServer|null;
|
|
|
|
}
|
|
|
|
|
2023-10-27 19:34:42 +00:00
|
|
|
export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfig {
|
2023-06-06 17:08:50 +00:00
|
|
|
const {homeUrl, extra, baseDomain, req, server} = options;
|
2020-07-21 13:20:51 +00:00
|
|
|
// .invalid is a TLD the IETF promises will never exist.
|
2021-07-13 07:29:38 +00:00
|
|
|
const pluginUrl = process.env.APP_UNTRUSTED_URL || 'http://plugins.invalid';
|
2020-07-21 13:20:51 +00:00
|
|
|
const pathOnly = (process.env.GRIST_ORG_IN_PATH === "true") ||
|
|
|
|
(homeUrl && new URL(homeUrl).hostname === 'localhost') || false;
|
|
|
|
const mreq = req as RequestWithOrg|undefined;
|
|
|
|
return {
|
|
|
|
homeUrl,
|
|
|
|
org: process.env.GRIST_SINGLE_ORG || (mreq && mreq.org),
|
|
|
|
baseDomain,
|
|
|
|
singleOrg: process.env.GRIST_SINGLE_ORG,
|
2022-11-28 20:19:31 +00:00
|
|
|
helpCenterUrl: process.env.GRIST_HELP_CENTER || "https://support.getgrist.com",
|
2020-07-21 13:20:51 +00:00
|
|
|
pathOnly,
|
|
|
|
supportAnon: shouldSupportAnon(),
|
2023-09-08 13:05:52 +00:00
|
|
|
enableAnonPlayground: isAffirmative(process.env.GRIST_ANON_PLAYGROUND ?? true),
|
2021-09-15 23:35:21 +00:00
|
|
|
supportEngines: getSupportedEngineChoices(),
|
2023-05-20 23:58:41 +00:00
|
|
|
features: getFeatures(),
|
2022-05-27 11:03:56 +00:00
|
|
|
pageTitleSuffix: configuredPageTitleSuffix(),
|
2020-07-21 13:20:51 +00:00
|
|
|
pluginUrl,
|
|
|
|
stripeAPIKey: process.env.STRIPE_PUBLIC_API_KEY,
|
2021-07-13 07:29:38 +00:00
|
|
|
googleClientId: process.env.GOOGLE_CLIENT_ID,
|
2021-09-30 08:19:22 +00:00
|
|
|
googleDriveScope: process.env.GOOGLE_DRIVE_SCOPE,
|
2021-12-07 05:37:37 +00:00
|
|
|
helpScoutBeaconId: process.env.HELP_SCOUT_BEACON_ID_V2,
|
2020-07-21 13:20:51 +00:00
|
|
|
maxUploadSizeImport: (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || undefined,
|
|
|
|
maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined,
|
|
|
|
timestampMs: Date.now(),
|
2023-10-27 19:34:42 +00:00
|
|
|
enableWidgetRepository: Boolean(process.env.GRIST_WIDGET_LIST_URL) ||
|
|
|
|
((server?.getBundledWidgets().length || 0) > 0),
|
2022-01-19 22:16:16 +00:00
|
|
|
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
|
2022-03-11 20:35:29 +00:00
|
|
|
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
|
2022-08-22 19:46:25 +00:00
|
|
|
activation: getActivation(req as RequestWithLogin | undefined),
|
2023-03-23 18:22:28 +00:00
|
|
|
enableCustomCss: isAffirmative(process.env.APP_STATIC_INCLUDE_CUSTOM_CSS),
|
2022-09-29 08:01:37 +00:00
|
|
|
supportedLngs: readLoadedLngs(req?.i18n),
|
|
|
|
namespaces: readLoadedNamespaces(req?.i18n),
|
2023-03-23 18:22:28 +00:00
|
|
|
featureComments: isAffirmative(process.env.COMMENTS),
|
2023-08-18 20:14:42 +00:00
|
|
|
featureFormulaAssistant: Boolean(process.env.OPENAI_API_KEY || process.env.ASSISTANT_CHAT_COMPLETION_ENDPOINT),
|
|
|
|
assistantService: process.env.OPENAI_API_KEY ? 'OpenAI' : undefined,
|
2023-10-27 19:34:42 +00:00
|
|
|
permittedCustomWidgets: getPermittedCustomWidgets(server),
|
2023-10-13 13:01:12 +00:00
|
|
|
gristNewColumnMenu: isAffirmative(process.env.GRIST_NEW_COLUMN_MENU),
|
2022-12-13 01:06:15 +00:00
|
|
|
supportEmail: SUPPORT_EMAIL,
|
2023-01-24 13:13:18 +00:00
|
|
|
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
|
2023-11-01 13:54:19 +00:00
|
|
|
telemetry: server?.getTelemetry().getTelemetryConfig(req as RequestWithLogin | undefined),
|
2023-06-06 17:08:50 +00:00
|
|
|
deploymentType: server?.getDeploymentType(),
|
2023-07-26 22:31:02 +00:00
|
|
|
templateOrg: getTemplateOrg(),
|
2023-09-26 08:09:06 +00:00
|
|
|
canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE),
|
2020-07-21 13:20:51 +00:00
|
|
|
...extra,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-07-21 08:46:03 +00:00
|
|
|
/**
|
|
|
|
* Creates a method that will send html page that will immediately post a message to a parent window.
|
|
|
|
* Primary used for Google Auth Grist's endpoint, but can be used in future in any other server side
|
|
|
|
* authentication flow.
|
|
|
|
*/
|
2021-07-21 16:38:57 +00:00
|
|
|
export function makeMessagePage(staticDir: string) {
|
2021-07-21 08:46:03 +00:00
|
|
|
return async (req: express.Request, resp: express.Response, message: any) => {
|
|
|
|
const fileContent = await fse.readFile(path.join(staticDir, "message.html"), 'utf8');
|
2022-07-27 20:20:14 +00:00
|
|
|
const content = fileContent.replace(
|
|
|
|
"<!-- INSERT MESSAGE -->",
|
|
|
|
`<script>window.message = ${jsesc(message, {isScriptContext: true, json: true})};</script>`
|
|
|
|
);
|
2021-07-21 08:46:03 +00:00
|
|
|
resp.status(200).type('html').send(content);
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
/**
|
|
|
|
* Send a simple template page, read from file at pagePath (relative to static/), with certain
|
|
|
|
* placeholders replaced.
|
|
|
|
*/
|
|
|
|
export function makeSendAppPage(opts: {
|
2023-06-06 17:08:50 +00:00
|
|
|
server: GristServer, staticDir: string, tag: string, testLogin?: boolean,
|
2020-07-21 13:20:51 +00:00
|
|
|
baseDomain?: string
|
|
|
|
}) {
|
|
|
|
const {server, staticDir, tag, testLogin} = opts;
|
|
|
|
return async (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => {
|
2023-06-06 17:08:50 +00:00
|
|
|
const config = makeGristConfig({
|
|
|
|
homeUrl: !isSingleUserMode() ? server.getHomeUrl(req) : null,
|
|
|
|
extra: options.config,
|
|
|
|
baseDomain: opts.baseDomain,
|
|
|
|
req,
|
|
|
|
server,
|
|
|
|
});
|
2020-07-21 13:20:51 +00:00
|
|
|
|
|
|
|
// We could cache file contents in memory, but the filesystem does caching too, and compared
|
|
|
|
// to that, the performance gain is unlikely to be meaningful. So keep it simple here.
|
|
|
|
const fileContent = options.content || await fse.readFile(path.join(staticDir, options.path), 'utf8');
|
|
|
|
|
|
|
|
const needTagManager = (options.googleTagManager === 'anon' && isAnonymousUser(req)) ||
|
|
|
|
options.googleTagManager === true;
|
2022-03-11 20:35:29 +00:00
|
|
|
const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : '';
|
2020-07-21 13:20:51 +00:00
|
|
|
const staticOrigin = process.env.APP_STATIC_URL || "";
|
|
|
|
const staticBaseUrl = `${staticOrigin}/v/${options.tag || tag}/`;
|
2023-06-06 17:08:50 +00:00
|
|
|
const customHeadHtmlSnippet = server.create.getExtraHeadHtml?.() ?? "";
|
2022-06-13 02:48:23 +00:00
|
|
|
const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
|
2022-09-29 08:01:37 +00:00
|
|
|
// Preload all languages that will be used or are requested by client.
|
2023-01-24 13:13:18 +00:00
|
|
|
const preloads = req.languages
|
|
|
|
.filter(lng => (readLoadedLngs(req.i18n)).includes(lng))
|
|
|
|
.map(lng => lng.replace('-', '_'))
|
|
|
|
.map((lng) =>
|
|
|
|
readLoadedNamespaces(req.i18n).map((ns) =>
|
|
|
|
`<link rel="preload" href="locales/${lng}.${ns}.json" as="fetch" type="application/json" crossorigin>`
|
2022-09-29 08:01:37 +00:00
|
|
|
).join("\n")
|
|
|
|
).join('\n');
|
2020-07-21 13:20:51 +00:00
|
|
|
const content = fileContent
|
|
|
|
.replace("<!-- INSERT WARNING -->", warning)
|
2022-09-29 08:01:37 +00:00
|
|
|
.replace("<!-- INSERT TITLE -->", getPageTitle(req, config))
|
2022-07-27 20:20:14 +00:00
|
|
|
.replace("<!-- INSERT META -->", getPageMetadataHtmlSnippet(config))
|
2023-06-06 17:08:50 +00:00
|
|
|
.replace("<!-- INSERT TITLE SUFFIX -->", getPageTitleSuffix(server.getGristConfig()))
|
2020-07-21 13:20:51 +00:00
|
|
|
.replace("<!-- INSERT BASE -->", `<base href="${staticBaseUrl}">` + tagManagerSnippet)
|
2022-09-29 08:01:37 +00:00
|
|
|
.replace("<!-- INSERT LOCALE -->", preloads)
|
2022-05-12 06:08:06 +00:00
|
|
|
.replace("<!-- INSERT CUSTOM -->", customHeadHtmlSnippet)
|
2022-07-27 20:20:14 +00:00
|
|
|
.replace(
|
|
|
|
"<!-- INSERT CONFIG -->",
|
|
|
|
`<script>window.gristConfig = ${jsesc(config, {isScriptContext: true, json: true})};</script>`
|
|
|
|
);
|
2020-07-21 13:20:51 +00:00
|
|
|
resp.status(options.status).type('html').send(content);
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function shouldSupportAnon() {
|
|
|
|
// Enable UI for anonymous access if a flag is explicitly set in the environment
|
|
|
|
return process.env.GRIST_SUPPORT_ANON === "true";
|
|
|
|
}
|
2022-05-27 11:03:56 +00:00
|
|
|
|
2023-05-20 23:58:41 +00:00
|
|
|
function getFeatures(): IFeature[] {
|
|
|
|
const disabledFeatures = process.env.GRIST_HIDE_UI_ELEMENTS?.split(',') ?? [];
|
|
|
|
const enabledFeatures = process.env.GRIST_UI_FEATURES?.split(',') ?? Features.values;
|
|
|
|
return Features.checkAll(difference(enabledFeatures, disabledFeatures));
|
2022-05-27 11:03:56 +00:00
|
|
|
}
|
|
|
|
|
2023-10-27 19:34:42 +00:00
|
|
|
function getPermittedCustomWidgets(gristServer?: GristServer|null): IAttachedCustomWidget[] {
|
|
|
|
if (!process.env.PERMITTED_CUSTOM_WIDGETS && gristServer) {
|
|
|
|
// The PERMITTED_CUSTOM_WIDGETS environment variable is a bit of
|
|
|
|
// a drag. If there are bundled widgets that overlap with widgets
|
|
|
|
// described in the codebase, let's just assume they are permitted.
|
|
|
|
const widgets = gristServer.getBundledWidgets();
|
|
|
|
const names = new Set(AttachedCustomWidgets.values as string[]);
|
|
|
|
const namesFound: IAttachedCustomWidget[] = [];
|
|
|
|
for (const widget of widgets) {
|
|
|
|
// Permitted custom widgets are identified so many ways across the
|
|
|
|
// code! Why? TODO: cut down on identifiers.
|
|
|
|
const name = widget.widgetId.replace('@gristlabs/widget-', 'custom.');
|
|
|
|
if (names.has(name)) {
|
|
|
|
namesFound.push(name as IAttachedCustomWidget);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return AttachedCustomWidgets.checkAll(namesFound);
|
|
|
|
}
|
2023-08-29 14:50:42 +00:00
|
|
|
const widgetsList = process.env.PERMITTED_CUSTOM_WIDGETS?.split(',').map(widgetName=>`custom.${widgetName}`) ?? [];
|
2023-10-27 19:34:42 +00:00
|
|
|
return AttachedCustomWidgets.checkAll(widgetsList);
|
2023-08-29 14:50:42 +00:00
|
|
|
}
|
|
|
|
|
2022-05-27 11:03:56 +00:00
|
|
|
function configuredPageTitleSuffix() {
|
|
|
|
const result = process.env.GRIST_PAGE_TITLE_SUFFIX;
|
|
|
|
return result === "_blank" ? "" : result;
|
|
|
|
}
|
2022-07-27 20:20:14 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns a page title suitable for inserting into an HTML title element.
|
|
|
|
*
|
|
|
|
* Currently returns the document name if the page being requested is for a document, or
|
|
|
|
* a placeholder, "Loading...", that's updated in the client once the page has loaded.
|
|
|
|
*
|
|
|
|
* Note: The string returned is escaped and safe to insert into HTML.
|
|
|
|
*/
|
2022-09-29 08:01:37 +00:00
|
|
|
function getPageTitle(req: express.Request, config: GristLoadConfig): string {
|
2022-07-27 20:20:14 +00:00
|
|
|
const maybeDoc = getDocFromConfig(config);
|
2022-10-27 08:50:51 +00:00
|
|
|
if (!maybeDoc) { return translate(req, 'Loading') + "..."; }
|
2022-07-27 20:20:14 +00:00
|
|
|
|
|
|
|
return handlebars.Utils.escapeExpression(maybeDoc.name);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns a string representation of 0 or more HTML metadata elements.
|
|
|
|
*
|
|
|
|
* Currently includes the document description and thumbnail if the requested page is
|
|
|
|
* for a document and the document has one set.
|
|
|
|
*
|
|
|
|
* Note: The string returned is escaped and safe to insert into HTML.
|
|
|
|
*/
|
|
|
|
function getPageMetadataHtmlSnippet(config: GristLoadConfig): string {
|
|
|
|
const metadataElements: string[] = [];
|
|
|
|
const maybeDoc = getDocFromConfig(config);
|
|
|
|
|
|
|
|
const description = maybeDoc?.options?.description;
|
|
|
|
if (description) {
|
|
|
|
const content = handlebars.Utils.escapeExpression(description);
|
|
|
|
metadataElements.push(`<meta name="description" content="${content}">`);
|
2022-08-16 17:37:55 +00:00
|
|
|
metadataElements.push(`<meta property="og:description" content="${content}">`);
|
|
|
|
metadataElements.push(`<meta name="twitter:description" content="${content}">`);
|
2022-07-27 20:20:14 +00:00
|
|
|
}
|
|
|
|
|
2022-08-16 17:37:55 +00:00
|
|
|
const icon = maybeDoc?.options?.icon;
|
|
|
|
if (icon) {
|
|
|
|
const content = handlebars.Utils.escapeExpression(icon);
|
2022-07-27 20:20:14 +00:00
|
|
|
metadataElements.push(`<meta name="thumbnail" content="${content}">`);
|
2022-08-16 17:37:55 +00:00
|
|
|
metadataElements.push(`<meta property="og:image" content="${content}">`);
|
|
|
|
metadataElements.push(`<meta name="twitter:image" content="${content}">`);
|
2022-07-27 20:20:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return metadataElements.join('\n');
|
|
|
|
}
|
|
|
|
|
|
|
|
function getDocFromConfig(config: GristLoadConfig): Document | null {
|
|
|
|
if (!config.getDoc || !config.assignmentId) { return null; }
|
|
|
|
|
|
|
|
return config.getDoc[config.assignmentId] ?? null;
|
|
|
|
}
|
2022-08-22 19:46:25 +00:00
|
|
|
|
|
|
|
function getActivation(mreq: RequestWithLogin|undefined) {
|
|
|
|
const defaultEmail = process.env.GRIST_DEFAULT_EMAIL;
|
|
|
|
return {
|
|
|
|
...mreq?.activation,
|
|
|
|
isManager: Boolean(defaultEmail && defaultEmail === mreq?.user?.loginEmail),
|
|
|
|
};
|
|
|
|
}
|