import {ApiError} from 'app/common/ApiError'; import {ICustomWidget} from 'app/common/CustomWidget'; import {delay} from 'app/common/delay'; import {DocCreationInfo} from 'app/common/DocListAPI'; import {commonUrls, encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes, GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls'; import {getOrgUrlInfo} from 'app/common/gristUrls'; import {isAffirmative, safeJsonParse} from 'app/common/gutil'; import {InstallProperties} from 'app/common/InstallAPI'; import {UserProfile} from 'app/common/LoginSessionAPI'; import {SandboxInfo} from 'app/common/SandboxInfo'; import {tbind} from 'app/common/tbind'; import * as version from 'app/common/version'; import {ApiServer, getOrgFromRequest} from 'app/gen-server/ApiServer'; import {Document} from "app/gen-server/entity/Document"; import {Organization} from "app/gen-server/entity/Organization"; import {Workspace} from 'app/gen-server/entity/Workspace'; import {Activations} from 'app/gen-server/lib/Activations'; import {DocApiForwarder} from 'app/gen-server/lib/DocApiForwarder'; import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap'; import {Doom} from 'app/gen-server/lib/Doom'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager'; import {Housekeeper} from 'app/gen-server/lib/Housekeeper'; import {Usage} from 'app/gen-server/lib/Usage'; import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens'; import {createSandbox} from 'app/server/lib/ActiveDoc'; import {attachAppEndpoint} from 'app/server/lib/AppEndpoint'; 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'; import {addDiscourseConnectEndpoints} from 'app/server/lib/DiscourseConnect'; import {addDocApiRoutes} from 'app/server/lib/DocApi'; import {DocManager} from 'app/server/lib/DocManager'; import {DocStorageManager} from 'app/server/lib/DocStorageManager'; import {DocWorker} from 'app/server/lib/DocWorker'; import {DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap'; import {expressWrap, jsonErrorHandler, secureJsonErrorHandler} from 'app/server/lib/expressWrap'; import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg'; import {addGoogleAuthEndpoint} from "app/server/lib/GoogleAuth"; import {DocTemplate, GristLoginMiddleware, GristLoginSystem, GristServer, RequestWithGrist} from 'app/server/lib/GristServer'; import {initGristSessions, SessionStore} from 'app/server/lib/gristSessions'; import {HostedStorageManager} from 'app/server/lib/HostedStorageManager'; import {IBilling} from 'app/server/lib/IBilling'; import {IDocStorageManager} from 'app/server/lib/IDocStorageManager'; import {EmptyNotifier, INotifier} from 'app/server/lib/INotifier'; import {InstallAdmin} from 'app/server/lib/InstallAdmin'; import log from 'app/server/lib/log'; import {getLoginSystem} from 'app/server/lib/logins'; import {IPermitStore} from 'app/server/lib/Permit'; import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places'; import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint'; import {PluginManager} from 'app/server/lib/PluginManager'; import * as ProcessMonitor from 'app/server/lib/ProcessMonitor'; import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, integerParam, isParameterOn, optIntegerParam, optStringParam, RequestWithGristInfo, sendOkReply, stringArrayParam, stringParam, TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils'; import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage'; import {getDatabaseUrl, listenPromise, timeoutReached} from 'app/server/lib/serverUtils'; import {Sessions} from 'app/server/lib/Sessions'; import * as shutdown from 'app/server/lib/shutdown'; import {TagChecker} from 'app/server/lib/TagChecker'; import {getTelemetryPrefs, ITelemetry} from 'app/server/lib/Telemetry'; import {startTestingHooks} from 'app/server/lib/TestingHooks'; import {getTestLoginSystem} from 'app/server/lib/TestLogin'; import {UpdateManager} from 'app/server/lib/UpdateManager'; import {addUploadRoute} from 'app/server/lib/uploads'; import {buildWidgetRepository, getWidgetsInPlugins, IWidgetRepository} from 'app/server/lib/WidgetRepository'; import {setupLocale} from 'app/server/localization'; import axios from 'axios'; import * as cookie from 'cookie'; import express from 'express'; import * as fse from 'fs-extra'; import * as http from 'http'; import * as https from 'https'; import {i18n} from 'i18next'; import i18Middleware from "i18next-http-middleware"; import mapValues = require('lodash/mapValues'); import morganLogger from 'morgan'; import {AddressInfo} from 'net'; import fetch from 'node-fetch'; import * as path from 'path'; import * as serveStatic from "serve-static"; // Health checks are a little noisy in the logs, so we don't show them all. // We show the first N health checks: const HEALTH_CHECK_LOG_SHOW_FIRST_N = 10; // And we show every Nth health check: const HEALTH_CHECK_LOG_SHOW_EVERY_N = 100; // DocID of Grist doc to collect the Welcome questionnaire responses, such // as "GristNewUserInfo". const DOC_ID_NEW_USER_INFO = process.env.DOC_ID_NEW_USER_INFO; export interface FlexServerOptions { dataDir?: string; // Base domain for org hostnames, starting with ".". Defaults to the base domain of APP_HOME_URL. baseDomain?: string; // Base URL for plugins, if permitted. Defaults to APP_UNTRUSTED_URL. pluginUrl?: string; } const noop: express.RequestHandler = (req, res, next) => next(); export class FlexServer implements GristServer { public readonly create = create; public tagChecker: TagChecker; public app: express.Express; public deps: Set = new Set(); public appRoot: string; public host: string; public tag: string; public info = new Array<[string, any]>(); public usage: Usage; public housekeeper: Housekeeper; public server: http.Server; public httpsServer?: https.Server; public settings?: Readonly>; public worker: DocWorkerInfo; public electronServerMethods: ElectronServerMethods; public readonly docsRoot: string; public readonly i18Instance: i18n; private _activations: Activations; private _comm: Comm; private _deploymentType: GristDeploymentType; private _dbManager: HomeDBManager; private _defaultBaseDomain: string|undefined; private _pluginUrl: string|undefined; private _pluginUrlReady: boolean = false; private _servesPlugins?: boolean; private _bundledWidgets?: ICustomWidget[]; private _billing: IBilling; private _installAdmin: InstallAdmin; private _instanceRoot: string; private _docManager: DocManager; private _docWorker: DocWorker; private _hosts: Hosts; private _pluginManager: PluginManager; private _sessions: Sessions; private _sessionStore: SessionStore; private _storageManager: IDocStorageManager; private _telemetry: ITelemetry; private _processMonitorStop?: () => void; // Callback to stop the ProcessMonitor private _docWorkerMap: IDocWorkerMap; private _widgetRepository: IWidgetRepository; private _notifier: INotifier; private _accessTokens: IAccessTokens; private _internalPermitStore: IPermitStore; // store for permits that stay within our servers private _externalPermitStore: IPermitStore; // store for permits that pass through outside servers private _disabled: boolean = false; private _disableExternalStorage: boolean = false; private _healthy: boolean = true; // becomes false if a serious error has occurred and // server cannot do its work. private _healthCheckCounter: number = 0; private _hasTestingHooks: boolean = false; private _loginMiddleware: GristLoginMiddleware; private _userIdMiddleware: express.RequestHandler; private _trustOriginsMiddleware: express.RequestHandler; private _docPermissionsMiddleware: express.RequestHandler; // This middleware redirects to signin/signup for anon, except on merged org or for // a team site that allows anon access. private _redirectToLoginWithExceptionsMiddleware: express.RequestHandler; // This unconditionally redirects to signin/signup for anon, for pages where anon access // is never desired. private _redirectToLoginWithoutExceptionsMiddleware: express.RequestHandler; // This can be called to do a redirect to signin/signup in a nuanced situation. private _redirectToLoginUnconditionally: express.RequestHandler | null; private _redirectToOrgMiddleware: express.RequestHandler; private _redirectToHostMiddleware: express.RequestHandler; private _getLoginRedirectUrl: (req: express.Request, target: URL) => Promise; private _getSignUpRedirectUrl: (req: express.Request, target: URL) => Promise; private _getLogoutRedirectUrl: (req: express.Request, nextUrl: URL) => Promise; private _sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise; private _getLoginSystem?: () => Promise; // Set once ready() is called private _isReady: boolean = false; private _updateManager: UpdateManager; private _sandboxInfo: SandboxInfo; constructor(public port: number, public name: string = 'flexServer', public readonly options: FlexServerOptions = {}) { this.app = express(); this.app.set('port', port); this.appRoot = getAppRoot(); this.host = process.env.GRIST_HOST || "localhost"; log.info(`== Grist version is ${version.version} (commit ${version.gitcommit})`); this.info.push(['appRoot', this.appRoot]); // Initialize locales files. this.i18Instance = setupLocale(this.appRoot); if (Array.isArray(this.i18Instance.options.preload)) { this.info.push(['i18:locale', this.i18Instance.options.preload.join(",")]); } if (Array.isArray(this.i18Instance.options.ns)) { this.info.push(['i18:namespace', this.i18Instance.options.ns.join(",")]); } // Add language detection middleware. this.app.use(i18Middleware.handle(this.i18Instance)); // This directory hold Grist documents. let docsRoot = path.resolve((this.options && this.options.dataDir) || process.env.GRIST_DATA_DIR || getAppPathTo(this.appRoot, 'samples')); // In testing, it can be useful to separate out document roots used // by distinct FlexServers. if (process.env.GRIST_TEST_ADD_PORT_TO_DOCS_ROOT === 'true') { docsRoot = path.resolve(docsRoot, String(port)); } // Create directory if it doesn't exist. // TODO: track down all dependencies on 'samples' existing in tests and // in dev environment, and remove them. Then it would probably be best // to simply fail if the docs root directory does not exist. fse.mkdirpSync(docsRoot); this.docsRoot = fse.realpathSync(docsRoot); this.info.push(['docsRoot', this.docsRoot]); this._deploymentType = this.create.deploymentType(); if (process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE) { this._deploymentType = GristDeploymentTypes.check(process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE); } const homeUrl = process.env.APP_HOME_URL; // The "base domain" is only a thing if orgs are encoded as a subdomain. if (process.env.GRIST_ORG_IN_PATH === 'true' || process.env.GRIST_SINGLE_ORG) { this._defaultBaseDomain = options.baseDomain || (homeUrl && new URL(homeUrl).hostname); } else { this._defaultBaseDomain = options.baseDomain || (homeUrl && parseSubdomain(new URL(homeUrl).hostname).base); } this.info.push(['defaultBaseDomain', this._defaultBaseDomain]); this._pluginUrl = options.pluginUrl || process.env.APP_UNTRUSTED_URL; // The electron build is not supported at this time, but this stub // implementation of electronServerMethods is present to allow kicking // its tires. let userConfig: any = { recentItems: [], }; this.electronServerMethods = { async importDoc() { throw new Error('not implemented'); }, onDocOpen(cb) { // currently only a stub. cb(''); }, async getUserConfig() { return userConfig; }, async updateUserConfig(obj: any) { userConfig = obj; }, onBackupMade() { log.info('backup skipped'); } }; this.app.use((req, res, next) => { (req as RequestWithGrist).gristServer = this; next(); }); } // Allow overridding the login system. public setLoginSystem(loginSystem: () => Promise) { this._getLoginSystem = loginSystem; } public getHost(): string { return `${this.host}:${this.getOwnPort()}`; } // Get a url for this server, based on the protocol it speaks (http), the host it // runs on, and the port it listens on. The url the client uses to communicate with // the server may be different if there are intermediaries (such as a load-balancer // terminating TLS). public getOwnUrl(): string { const port = this.getOwnPort(); return `http://${this.host}:${port}`; } /** * Get a url for the home server api. Called without knowledge of a specific * request, so will default to a generic url. Use of this method can render * code incompatible with custom base domains (currently, sendgrid notifications * via Notifier are incompatible for this reason). */ public getDefaultHomeUrl(): string { const homeUrl = process.env.APP_HOME_URL || (this._has('api') && this.getOwnUrl()); if (!homeUrl) { throw new Error("need APP_HOME_URL"); } return homeUrl; } /** * Same as getDefaultHomeUrl, but for internal use. */ public getDefaultHomeInternalUrl(): string { return process.env.APP_HOME_INTERNAL_URL || this.getDefaultHomeUrl(); } /** * Get a url for the home server api, adapting it to match the base domain in the * requested url. This adaptation is important for cookie-based authentication. * * If relPath is given, returns that path relative to homeUrl. If omitted, note that * getHomeUrl() will still return a URL ending in "/". */ public getHomeUrl(req: express.Request, relPath: string = ''): string { // Get the default home url. const homeUrl = new URL(relPath, this.getDefaultHomeUrl()); adaptServerUrl(homeUrl, req as RequestWithOrg); return homeUrl.href; } /** * Same as getHomeUrl, but for requesting internally. */ public getHomeInternalUrl(relPath: string = ''): string { const homeUrl = new URL(relPath, this.getDefaultHomeInternalUrl()); return homeUrl.href; } /** * Get a home url that is appropriate for the given document. For now, this * returns a default that works for all documents. That could change in future, * specifically with custom domains (perhaps we might limit which docs can be accessed * based on domain). */ public async getHomeUrlByDocId(docId: string, relPath: string = ''): Promise { return new URL(relPath, this.getDefaultHomeInternalUrl()).href; } // Get the port number the server listens on. This may be different from the port // number the client expects when communicating with the server if there are intermediaries. public getOwnPort(): number { // Get the port from the server in case it was started with port 0. return this.server ? (this.server.address() as AddressInfo).port : this.port; } /** * Get a url to an org that should be accessible by all signed-in users. For now, this * returns the base URL of the personal org (typically docs[-s]). */ public getMergedOrgUrl(req: RequestWithLogin, pathname: string = '/'): string { return this._getOrgRedirectUrl(req, this._dbManager.mergedOrgDomain(), pathname); } public getPermitStore(): IPermitStore { if (!this._internalPermitStore) { throw new Error('no permit store available'); } return this._internalPermitStore; } public getExternalPermitStore(): IPermitStore { if (!this._externalPermitStore) { throw new Error('no permit store available'); } return this._externalPermitStore; } public getSessions(): Sessions { if (!this._sessions) { throw new Error('no sessions available'); } return this._sessions; } public getComm(): Comm { if (!this._comm) { throw new Error('no Comm available'); } return this._comm; } public getDeploymentType(): GristDeploymentType { return this._deploymentType; } public getHosts(): Hosts { if (!this._hosts) { throw new Error('no hosts available'); } return this._hosts; } public getActivations(): Activations { if (!this._activations) { throw new Error('no activations available'); } return this._activations; } public getHomeDBManager(): HomeDBManager { if (!this._dbManager) { throw new Error('no home db available'); } return this._dbManager; } public getStorageManager(): IDocStorageManager { if (!this._storageManager) { throw new Error('no storage manager available'); } return this._storageManager; } public getTelemetry(): ITelemetry { if (!this._telemetry) { throw new Error('no telemetry available'); } return this._telemetry; } public getWidgetRepository(): IWidgetRepository { if (!this._widgetRepository) { throw new Error('no widget repository available'); } return this._widgetRepository; } public hasNotifier(): boolean { return Boolean(this._notifier) && this._notifier !== EmptyNotifier; } public getNotifier(): INotifier { if (!this._notifier) { throw new Error('no notifier available'); } return this._notifier; } public getInstallAdmin(): InstallAdmin { if (!this._installAdmin) { throw new Error('no InstallAdmin available'); } return this._installAdmin; } public getAccessTokens() { if (this._accessTokens) { return this._accessTokens; } this.addDocWorkerMap(); const cli = this._docWorkerMap.getRedisClient(); this._accessTokens = new AccessTokens(cli); return this._accessTokens; } public getUpdateManager() { if (!this._updateManager) { throw new Error('no UpdateManager available'); } return this._updateManager; } public sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise { if (!this._sendAppPage) { throw new Error('no _sendAppPage method available'); } return this._sendAppPage(req, resp, options); } public addLogging() { if (this._check('logging')) { return; } if (process.env.GRIST_LOG_SKIP_HTTP) { return; } // Add a timestamp token that matches exactly the formatting of non-morgan logs. morganLogger.token('logTime', (req: Request) => log.timestamp()); // Add an optional gristInfo token that can replace the url, if the url is sensitive. morganLogger.token('gristInfo', (req: RequestWithGristInfo) => req.gristInfo || req.originalUrl || req.url); morganLogger.token('host', (req: express.Request) => req.get('host')); const msg = ':logTime :host :method :gristInfo :status :response-time ms - :res[content-length]'; // In hosted Grist, render json so logs retain more organization. function outputJson(tokens: any, req: any, res: any) { return JSON.stringify({ timestamp: tokens.logTime(req, res), method: tokens.method(req, res), path: tokens.gristInfo(req, res), status: tokens.status(req, res), timeMs: parseFloat(tokens['response-time'](req, res)) || undefined, contentLength: parseInt(tokens.res(req, res, 'content-length'), 10) || undefined, host: tokens.host(req, res), altSessionId: req.altSessionId, }); } this.app.use(morganLogger(process.env.GRIST_HOSTED_VERSION ? outputJson : msg, { skip: this._shouldSkipRequestLogging.bind(this) })); } public addHealthCheck() { if (this._check('health')) { return; } // Health check endpoint. if called with /hooks, testing hooks are required in order to be // considered healthy. Testing hooks are used only in server started for tests, and // /status/hooks allows the tests to wait for them to be ready. // If db=1 query parameter is included, status will include the status of DB connection. // If redis=1 query parameter is included, status will include the status of the Redis connection. // If docWorkerRegistered=1 query parameter is included, status will include the status of the // doc worker registration in Redis. this.app.get('/status(/hooks)?', async (req, res) => { const checks = new Map|boolean>(); const timeout = optIntegerParam(req.query.timeout, 'timeout') || 10_000; // Check that the given promise resolves with no error within our timeout. const asyncCheck = async (promise: Promise|undefined) => { if (!promise || await timeoutReached(timeout, promise) === true) { return false; } return promise.then(() => true, () => false); // Success => true, rejection => false }; if (req.path.endsWith('/hooks')) { checks.set('hooks', this._hasTestingHooks); } if (isParameterOn(req.query.db)) { checks.set('db', asyncCheck(this._dbManager.connection.query('SELECT 1'))); } if (isParameterOn(req.query.redis)) { checks.set('redis', asyncCheck(this._docWorkerMap.getRedisClient()?.pingAsync())); } if (isParameterOn(req.query.docWorkerRegistered) && this.worker) { // Only check whether the doc worker is registered if we have a worker. // The Redis client may not be connected, but in this case this has to // be checked with the 'redis' parameter (the user may want to avoid // removing workers when connection is unstable). if (this._docWorkerMap.getRedisClient()?.connected) { checks.set('docWorkerRegistered', asyncCheck( this._docWorkerMap.isWorkerRegistered(this.worker).then(isRegistered => { if (!isRegistered) { throw new Error('doc worker not registered'); } return isRegistered; }) )); } } if (isParameterOn(req.query.ready)) { checks.set('ready', this._isReady); } let extra = ''; let ok = true; // If we had any extra check, collect their status to report them. if (checks.size > 0) { const results = await Promise.all(checks.values()); ok = ok && results.every(r => r === true); const notes = Array.from(checks.keys(), (key, i) => `${key} ${results[i] ? 'ok' : 'not ok'}`); extra = ` (${notes.join(", ")})`; } if (this._healthy && ok) { this._healthCheckCounter++; res.status(200).send(`Grist ${this.name} is alive${extra}.`); } else { this._healthCheckCounter = 0; // reset counter if we ever go internally unhealthy. res.status(500).send(`Grist ${this.name} is unhealthy${extra}.`); } }); } /** * * 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; } this.app.get('/boot(/(:bootKey/?)?)?$', async (req, res) => { // Doing a good redirect is actually pretty subtle and we might // get it wrong, so just say /boot got moved. res.send('The /boot/KEY page is now /admin?boot-key=KEY'); }); } public getBootKey(): string|undefined { return appSettings.section('boot').flag('key').readString({ envVar: 'GRIST_BOOT_KEY' }); } public denyRequestsIfNotReady() { this.app.use((_req, res, next) => { if (!this._isReady) { // If ready() hasn't been called yet, don't continue, and // give a clear error. This is to avoid exposing the service // in a partially configured form. return res.status(503).json({error: 'Service unavailable during start up'}); } next(); }); } public testAddRouter() { if (this._check('router')) { return; } this.app.get('/test/router', (req, res) => { const act = optStringParam(req.query.act, 'act') || 'none'; const port = stringParam(req.query.port, 'port'); // port is trusted in mock; in prod it is not. if (act === 'add' || act === 'remove') { const host = `localhost:${port}`; return res.status(200).json({ act, host, url: `http://${host}`, message: 'ok', }); } return res.status(500).json({error: 'unrecognized action'}); }); } public addCleanup() { if (this._check('cleanup')) { return; } // Set up signal handlers. Note that nodemon sends SIGUSR2 to restart node. shutdown.cleanupOnSignals('SIGINT', 'SIGTERM', 'SIGHUP', 'SIGUSR2'); // We listen for uncaughtExceptions / unhandledRejections, but do exit when they happen. It is // a strong recommendation, which seems best to follow // (https://nodejs.org/docs/latest-v18.x/api/process.html#warning-using-uncaughtexception-correctly). // We do try to shutdown cleanly (i.e. do any planned cleanup), which goes somewhat against // the recommendation to do only synchronous work. let counter = 0; // Note that this event catches also 'unhandledRejection' (origin should be either // 'uncaughtException' or 'unhandledRejection'). process.on('uncaughtException', (err, origin) => { log.error(`UNHANDLED ERROR ${origin} (${counter}):`, err); if (counter === 0) { // Only call shutdown once. It's async and could in theory fail, in which case it would be // another unhandledRejection, and would get caught and reported by this same handler. void(shutdown.exit(1)); } counter++; }); } public addTagChecker() { if (this._check('tag', '!org')) { return; } // Handle requests that start with /v/TAG/ and set .tag property on them. this.tag = version.gitcommit; this.info.push(['tag', this.tag]); this.tagChecker = new TagChecker(this.tag); this.app.use(this.tagChecker.inspectTag); } /** * To allow routing to doc workers via the path, doc workers remove any * path prefix of the form /dw/...../ if present. The prefix is not checked, * just removed unconditionally. * TODO: determine what the prefix should be, and check it, to catch bugs. */ public stripDocWorkerIdPathPrefixIfPresent() { if (this._check('strip_dw', '!tag', '!org')) { return; } this.app.use((req, resp, next) => { const match = req.url.match(/^\/dw\/([-a-zA-Z0-9]+)([/?].*)?$/); if (match) { req.url = sanitizePathTail(match[2]); } next(); }); } public addOrg() { if (this._check('org', 'homedb', 'hosts')) { return; } this.app.use(this._hosts.extractOrg); } public setDirectory() { if (this._check('dir')) { return; } process.chdir(getUnpackedAppRoot(this.appRoot)); } public get instanceRoot() { if (!this._instanceRoot) { this._instanceRoot = path.resolve(process.env.GRIST_INST_DIR || this.appRoot); this.info.push(['instanceRoot', this._instanceRoot]); } return this._instanceRoot; } public addStaticAndBowerDirectories() { if (this._check('static_and_bower', 'dir')) { return; } this.addTagChecker(); // Grist has static help files, which may be useful for standalone app, // but for hosted grist the latest help is at support.getgrist.com. Redirect // to this page for the benefit of crawlers which currently rank the static help // page link highly for historic reasons. this.app.use(/^\/help\//, expressWrap(async (req, res) => { res.redirect('https://support.getgrist.com'); })); // If there is a directory called "static_ext", serve material from there // as well. This isn't used in grist-core but is handy for extensions such // as an Electron app. const staticExtDir = getAppPathTo(this.appRoot, 'static') + '_ext'; const staticExtApp = fse.existsSync(staticExtDir) ? express.static(staticExtDir, serveAnyOrigin) : null; const staticApp = express.static(getAppPathTo(this.appRoot, 'static'), serveAnyOrigin); const bowerApp = express.static(getAppPathTo(this.appRoot, 'bower_components'), serveAnyOrigin); if (process.env.GRIST_LOCALES_DIR) { const locales = express.static(process.env.GRIST_LOCALES_DIR, serveAnyOrigin); this.app.use("/locales", this.tagChecker.withTag(locales)); } if (staticExtApp) { this.app.use(this.tagChecker.withTag(staticExtApp)); } this.app.use(this.tagChecker.withTag(staticApp)); this.app.use(this.tagChecker.withTag(bowerApp)); } // Some tests rely on testFOO.html files being served. public addAssetsForTests() { if (this._check('testAssets', 'dir')) { return; } // Serve test[a-z]*.html for test purposes. this.app.use(/^\/(test[a-z]*.html)$/i, expressWrap(async (req, res) => res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')}))); } // Plugin operation relies currently on grist-plugin-api.js being available, // and with Grist's static assets to be also available on the untrusted // host. The assets should be available without version tags, but not // at the root level - we nest them in /plugins/assets. public async addAssetsForPlugins() { if (this._check('pluginUntaggedAssets', 'dir')) { return; } this.app.use(/^\/(grist-plugin-api.js)$/, expressWrap(async (req, res) => res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')}))); // Plugins get access to static resources without a tag this.app.use( '/plugins/assets', limitToPlugins(this, express.static(getAppPathTo(this.appRoot, 'static')))); this.app.use( '/plugins/assets', limitToPlugins(this, express.static(getAppPathTo(this.appRoot, 'bower_components')))); // Serve custom-widget.html message for anyone. this.app.use(/^\/(custom-widget.html)$/, expressWrap(async (req, res) => res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')}))); this.addOrg(); addPluginEndpoints(this, await this._addPluginManager()); // Serve bundled custom widgets on the plugin endpoint. const places = getWidgetsInPlugins(this, ''); if (places.length > 0) { // For all widgets served in place, replace any copies of // grist-plugin-api.js with this app's version of it. // This is perhaps a bit rude, but beats the alternative // of either using inconsistent bundled versions, or // requiring network access. this.app.use(/^\/widgets\/.*\/(grist-plugin-api.js)$/, expressWrap(async (req, res) => res.sendFile(req.params[0], {root: getAppPathTo(this.appRoot, 'static')}))); } for (const place of places) { this.app.use( '/widgets/' + place.pluginId, this.tagChecker.withTag( limitToPlugins(this, express.static(place.dir, serveAnyOrigin)) ) ); } } // Prepare cache for managing org-to-host relationship. public addHosts() { if (this._check('hosts', 'homedb')) { return; } this._hosts = new Hosts(this._defaultBaseDomain, this._dbManager, this); } public async initHomeDBManager() { if (this._check('homedb')) { return; } this._dbManager = new HomeDBManager(); this._dbManager.setPrefix(process.env.GRIST_ID_PREFIX || ""); await this._dbManager.connect(); await this._dbManager.initializeSpecialIds(); // Report which database we are using, without sensitive credentials. this.info.push(['database', getDatabaseUrl(this._dbManager.connection.options, false)]); // If the installation appears to be new, give it an id and a creation date. this._activations = new Activations(this._dbManager); await this._activations.current(); this._installAdmin = await this.create.createInstallAdmin(this._dbManager); } public addDocWorkerMap() { if (this._check('map')) { return; } this._docWorkerMap = getDocWorkerMap(); this._internalPermitStore = this._docWorkerMap.getPermitStore('internal'); this._externalPermitStore = this._docWorkerMap.getPermitStore('external'); } // Set up the main express middleware used. For a single user setup, without logins, // all this middleware is currently a no-op. public addAccessMiddleware() { if (this._check('middleware', 'map', 'config', isSingleUserMode() ? null : 'hosts')) { return; } if (!isSingleUserMode()) { const skipSession = appSettings.section('login').flag('skipSession').readBool({ envVar: 'GRIST_IGNORE_SESSION', }); // Middleware to redirect landing pages to preferred host this._redirectToHostMiddleware = this._hosts.redirectHost; // Middleware to add the userId to the express request object. this._userIdMiddleware = expressWrap(addRequestUser.bind( null, this._dbManager, this._internalPermitStore, { overrideProfile: this._loginMiddleware.overrideProfile?.bind(this._loginMiddleware), // Set this to false to stop Grist using a cookie for authentication purposes. skipSession, gristServer: this, } )); this._trustOriginsMiddleware = expressWrap(trustOriginHandler); // middleware to authorize doc access to the app. Note that this requires the userId // to be set on the request by _userIdMiddleware. this._docPermissionsMiddleware = expressWrap((...args) => this._docWorker.assertDocAccess(...args)); this._redirectToLoginWithExceptionsMiddleware = redirectToLogin(true, this._getLoginRedirectUrl, this._getSignUpRedirectUrl, this._dbManager); this._redirectToLoginWithoutExceptionsMiddleware = redirectToLogin(false, this._getLoginRedirectUrl, this._getSignUpRedirectUrl, this._dbManager); this._redirectToLoginUnconditionally = redirectToLoginUnconditionally(this._getLoginRedirectUrl, this._getSignUpRedirectUrl); this._redirectToOrgMiddleware = tbind(this._redirectToOrg, this); } else { this._userIdMiddleware = noop; this._trustOriginsMiddleware = noop; // For standalone single-user Grist, documents are stored on-disk // with their filename equal to the document title, no document // aliases are possible, and there is no access control. // The _docPermissionsMiddleware is a no-op. // TODO We might no longer have any tests for isSingleUserMode, or modes of operation. this._docPermissionsMiddleware = noop; this._redirectToLoginWithExceptionsMiddleware = noop; this._redirectToLoginWithoutExceptionsMiddleware = noop; this._redirectToLoginUnconditionally = null; // there is no way to log in. this._redirectToOrgMiddleware = noop; this._redirectToHostMiddleware = noop; } } /** * Add middleware common to all API endpoints (including forwarding ones). */ public addApiMiddleware() { if (this._check('api-mw', 'middleware')) { return; } // API endpoints need req.userId and need to support requests from different subdomains. this.app.use("/api", this._userIdMiddleware); this.app.use("/api", this._trustOriginsMiddleware); this.app.use("/api", noCaching); } /** * Add error-handling middleware common to all API endpoints. */ public addApiErrorHandlers() { if (this._check('api-error', 'api-mw')) { return; } // add a final not-found handler for api this.app.use("/api", (req, res) => { res.status(404).send({error: `not found: ${req.originalUrl}`}); }); // Add a final error handler for /api endpoints that reports errors as JSON. this.app.use('/api/auth', secureJsonErrorHandler); this.app.use('/api', jsonErrorHandler); } public addHomeApi() { if (this._check('api', 'homedb', 'json', 'api-mw')) { return; } // ApiServer's constructor adds endpoints to the app. // tslint:disable-next-line:no-unused-expression new ApiServer(this, this.app, this._dbManager, this._widgetRepository = buildWidgetRepository(this)); } public addBillingApi() { if (this._check('billing-api', 'homedb', 'json', 'api-mw')) { return; } this._getBilling(); this._billing.addEndpoints(this.app); this._billing.addEventHandlers(); } public async addBillingMiddleware() { if (this._check('activation', 'homedb')) { return; } this._getBilling(); await this._billing.addMiddleware?.(this.app); } /** * Add a /api/log endpoint that simply outputs client errors to our * logs. This is a minimal placeholder for a special-purpose * service for dealing with client errors. */ public addLogEndpoint() { if (this._check('log-endpoint', 'json', 'api-mw')) { return; } this.app.post('/api/log', async (req, resp) => { const mreq = req as RequestWithLogin; log.rawWarn('client error', { event: req.body.event, docId: req.body.docId, page: req.body.page, browser: req.body.browser, org: mreq.org, email: mreq.user && mreq.user.loginEmail, userId: mreq.userId, altSessionId: mreq.altSessionId, }); return resp.status(200).send(); }); } public async addTelemetry() { if (this._check('telemetry', 'homedb', 'json', 'api-mw')) { return; } this._telemetry = this.create.Telemetry(this._dbManager, this); this._telemetry.addEndpoints(this.app); await this._telemetry.start(); // Start up a monitor for memory and cpu usage. this._processMonitorStop = ProcessMonitor.start(this._telemetry); } public async close() { this._processMonitorStop?.(); await this._updateManager?.clear(); if (this.usage) { await this.usage.close(); } if (this._hosts) { this._hosts.close(); } if (this._dbManager) { this._dbManager.removeAllListeners(); this._dbManager.flushDocAuthCache(); } if (this.server) { this.server.close(); } if (this.httpsServer) { this.httpsServer.close(); } if (this.housekeeper) { await this.housekeeper.stop(); } await this._shutdown(); if (this._accessTokens) { await this._accessTokens.close(); } // Do this after _shutdown, since DocWorkerMap is used during shutdown. if (this._docWorkerMap) { await this._docWorkerMap.close(); } if (this._sessionStore) { await this._sessionStore.close(); } } public addDocApiForwarder() { if (this._check('doc_api_forwarder', '!json', 'homedb', 'api-mw', 'map')) { return; } const docApiForwarder = new DocApiForwarder(this._docWorkerMap, this._dbManager, this); docApiForwarder.addEndpoints(this.app); } public addJsonSupport() { if (this._check('json')) { return; } this.app.use(express.json({limit: '1mb'})); // Increase from the default 100kb } public addSessions() { if (this._check('sessions', 'config')) { return; } this.addTagChecker(); this.addOrg(); // Create the sessionStore and related objects. const {sessions, sessionMiddleware, sessionStore} = initGristSessions(getUnpackedAppRoot(this.instanceRoot), this); this.app.use(sessionMiddleware); this.app.use(signInStatusMiddleware); // Create an endpoint for making cookies during testing. this.app.get('/test/session', async (req, res) => { const mreq = req as RequestWithLogin; forceSessionChange(mreq.session); res.status(200).send(`Grist ${this.name} is alive and is interested in you.`); }); this._sessions = sessions; this._sessionStore = sessionStore; } // Close connections and stop accepting new connections. Remove server from any lists // it may be in. public async stopListening(mode: 'crash'|'clean' = 'clean') { if (!this._disabled) { if (mode === 'clean') { await this._shutdown(); this._disabled = true; } else { this._disabled = true; if (this._comm) { this._comm.setServerActivation(false); this._comm.destroyAllClients(); } } this.server.close(); if (this.httpsServer) { this.httpsServer.close(); } } } public async createWorkerUrl(): Promise<{url: string, host: string}> { if (!process.env.GRIST_ROUTER_URL) { throw new Error('No service available to create worker url'); } const w = await axios.get(process.env.GRIST_ROUTER_URL, {params: {act: 'add', port: this.getOwnPort()}}); log.info(`DocWorker registered itself via ${process.env.GRIST_ROUTER_URL} as ${w.data.url}`); const statusUrl = `${w.data.url}/status`; // We now wait for the worker to be available from the url that clients will // use to connect to it. This may take some time. The main delay is the // new target group and load balancer rule taking effect - typically 10-20 seconds. // If we don't wait, the worker will end up registered for work and clients // could end up trying to reach it to open documents - but the url they have // won't work. for (let tries = 0; tries < 600; tries++) { await delay(1000); try { await axios.get(statusUrl); return w.data; } catch (err) { log.debug(`While waiting for ${statusUrl} got error ${(err as Error).message}`); } } throw new Error(`Cannot connect to ${statusUrl}`); } // Accept new connections again. Add server to any lists it needs to be in to get work. public async restartListening() { if (!this._docWorkerMap) { throw new Error('expected to have DocWorkerMap'); } await this.stopListening('clean'); if (this._disabled) { if (this._storageManager) { this._storageManager.testReopenStorage(); } this._comm.setServerActivation(true); if (this.worker) { await this._startServers(this.server, this.httpsServer, this.name, this.port, false); await this._addSelfAsWorker(this._docWorkerMap); } this._disabled = false; } } public async addLandingPages() { // TODO: check if isSingleUserMode() path can be removed from this method if (this._check('landing', 'map', isSingleUserMode() ? null : 'homedb')) { return; } this.addSessions(); // Initialize _sendAppPage helper. this._sendAppPage = makeSendAppPage({ server: this, staticDir: getAppPathTo(this.appRoot, 'static'), tag: this.tag, testLogin: allowTestLogin(), baseDomain: this._defaultBaseDomain, }); const forceLogin = appSettings.section('login').flag('forced').readBool({ envVar: 'GRIST_FORCE_LOGIN', }); const forcedLoginMiddleware = forceLogin ? this._redirectToLoginWithoutExceptionsMiddleware : noop; const welcomeNewUser: express.RequestHandler = isSingleUserMode() ? (req, res, next) => next() : expressWrap(async (req, res, next) => { const mreq = req as RequestWithLogin; const user = getUser(req); if (user && user.isFirstTimeUser) { log.debug(`welcoming user: ${user.name}`); // Reset isFirstTimeUser flag. await this._dbManager.updateUser(user.id, {isFirstTimeUser: false}); // This is a good time to set some other flags, for showing a popup with welcome question(s) // to this new user and recording their sign-up with Google Tag Manager. These flags are also // scoped to the user, but isFirstTimeUser has a dedicated DB field because it predates userPrefs. // Note that the updateOrg() method handles all levels of prefs (for user, user+org, or org). await this._dbManager.updateOrg(getScope(req), 0, {userPrefs: { showNewUserQuestions: true, recordSignUpEvent: true }}); // Give a chance to the login system to react to the first visit after signup. this._loginMiddleware.onFirstVisit?.(req); // If we need to copy an unsaved document or template as part of sign-up, do so now // and redirect to it. const docId = await this._maybeCopyDocToHomeWorkspace(mreq, res); if (docId) { return res.redirect(this.getMergedOrgUrl(mreq, `/doc/${docId}`)); } const domain = mreq.org ?? null; if (!process.env.GRIST_SINGLE_ORG && this._dbManager.isMergedOrg(domain)) { // We're logging in for the first time on the merged org; if the user has // access to other team sites, forward the user to a page that lists all // the teams they have access to. const result = await this._dbManager.getMergedOrgs(user.id, user.id, domain); const orgs = this._dbManager.unwrapQueryResult(result); if (orgs.length > 1 && mreq.path === '/') { // Only forward if the request is for the home page. return res.redirect(this.getMergedOrgUrl(mreq, '/welcome/teams')); } } } if (mreq.org && mreq.org.startsWith('o-')) { // We are on a team site without a custom subdomain. const orgInfo = this._dbManager.unwrapQueryResult(await this._dbManager.getOrg({userId: user.id}, mreq.org)); // If the user is a billing manager for the org, and the org // is supposed to have a custom subdomain, forward the user // to a page to set it. // TODO: this is more or less a hack for AppSumo signup flow, // and could be removed if/when signup flow is revamped. // If "welcomeNewUser" is ever added to billing pages, we'd need // to avoid a redirect loop. if (orgInfo.billingAccount.isManager && orgInfo.billingAccount.getFeatures().vanityDomain) { const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : ''; return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`); } } next(); }); attachAppEndpoint({ app: this.app, middleware: [ this._redirectToHostMiddleware, this._userIdMiddleware, forcedLoginMiddleware, this._redirectToLoginWithExceptionsMiddleware, this._redirectToOrgMiddleware, welcomeNewUser ], docMiddleware: [ // Same as middleware, except without login redirect middleware. this._redirectToHostMiddleware, this._userIdMiddleware, forcedLoginMiddleware, this._redirectToOrgMiddleware, welcomeNewUser ], formMiddleware: [ this._userIdMiddleware, forcedLoginMiddleware, ], forceLogin: this._redirectToLoginUnconditionally, docWorkerMap: isSingleUserMode() ? null : this._docWorkerMap, sendAppPage: this._sendAppPage, dbManager: this._dbManager, plugins : (await this._addPluginManager()).getPlugins(), gristServer: this, }); } /** * Load user config file from standard location (if present). * * Note that the user config file doesn't do anything today, but may be useful in * the future for configuring things that don't fit well into environment variables. * * TODO: Revisit this, and update `GristServer.settings` type to match the expected shape * of config.json. (ts-interface-checker could be useful here for runtime validation.) */ public async loadConfig() { if (this._check('config')) { return; } const settingsPath = path.join(this.instanceRoot, 'config.json'); if (await fse.pathExists(settingsPath)) { log.info(`Loading config from ${settingsPath}`); this.settings = JSON.parse(await fse.readFile(settingsPath, 'utf8')); } else { log.info(`Loading empty config because ${settingsPath} missing`); this.settings = {}; } // TODO: We could include a third mock provider of login/logout URLs for better tests. Or we // could create a mock SAML identity provider for testing this using the SAML flow. const loginSystem = await this.resolveLoginSystem(); this._loginMiddleware = await loginSystem.getMiddleware(this); this._getLoginRedirectUrl = tbind(this._loginMiddleware.getLoginRedirectUrl, this._loginMiddleware); this._getSignUpRedirectUrl = tbind(this._loginMiddleware.getSignUpRedirectUrl, this._loginMiddleware); this._getLogoutRedirectUrl = tbind(this._loginMiddleware.getLogoutRedirectUrl, this._loginMiddleware); const wildcardMiddleware = this._loginMiddleware.getWildcardMiddleware?.(); if (wildcardMiddleware?.length) { this.app.use(wildcardMiddleware); } } public addComm() { if (this._check('comm', 'start', 'homedb', 'config')) { return; } this._comm = new Comm(this.server, { settings: this.settings, sessions: this._sessions, hosts: this._hosts, loginMiddleware: this._loginMiddleware, httpsServer: this.httpsServer, i18Instance: this.i18Instance }); } /** * Add endpoint that servers a javascript file with various api keys that * are used by the client libraries. */ public addClientSecrets() { if (this._check('clientSecret')) { return; } this.app.get('/client-secret.js', expressWrap(async (req, res) => { const config = this.getGristConfig(); // Currently we are exposing only Google keys. // Those keys are eventually visible by the client, but should be usable // only from Grist's domains. const secrets = { googleClientId: config.googleClientId, }; res.set('Content-Type', 'application/javascript'); res.status(200); res.send(` window.gristClientSecret = ${JSON.stringify(secrets)} `); })); } public async addLoginRoutes() { if (this._check('login', 'org', 'sessions', 'homedb', 'hosts')) { return; } // TODO: We do NOT want Comm here at all, it's only being used for handling sessions, which // should be factored out of it. this.addComm(); const signinMiddleware = this._loginMiddleware.getLoginOrSignUpMiddleware ? this._loginMiddleware.getLoginOrSignUpMiddleware() : []; this.app.get('/login', ...signinMiddleware, expressWrap(this._redirectToLoginOrSignup.bind(this, { signUp: false, }))); this.app.get('/signup', ...signinMiddleware, expressWrap(this._redirectToLoginOrSignup.bind(this, { signUp: true, }))); this.app.get('/signin', ...signinMiddleware, expressWrap(this._redirectToLoginOrSignup.bind(this, {}))); if (allowTestLogin()) { // This is an endpoint for the dev environment that lets you log in as anyone. // For a standard dev environment, it will be accessible at localhost:8080/test/login // and localhost:8080/o//test/login. Only available when GRIST_TEST_LOGIN is set. // Handy when without network connectivity to reach Cognito. log.warn("Adding a /test/login endpoint because GRIST_TEST_LOGIN is set. " + "Users will be able to login as anyone."); this.app.get('/test/login', expressWrap(async (req, res) => { log.warn("Serving unauthenticated /test/login endpoint, made available because GRIST_TEST_LOGIN is set."); // Query parameter is called "username" for compatibility with Cognito. const email = optStringParam(req.query.username, 'username'); if (email) { const redirect = optStringParam(req.query.next, 'next'); const profile: UserProfile = { email, name: optStringParam(req.query.name, 'name') || email, }; const url = new URL(redirect || getOrgUrl(req)); // Make sure we update session for org we'll be redirecting to. const {org} = await this._hosts.getOrgInfoFromParts(url.hostname, url.pathname); const scopedSession = this._sessions.getOrCreateSessionFromRequest(req, { org }); await scopedSession.updateUserProfile(req, profile); this._sessions.clearCacheIfNeeded({email, org}); if (redirect) { return res.redirect(redirect); } } res.send(` `); })); } this.app.get('/logout', ...this._logoutMiddleware(), expressWrap(async (req, resp) => { const signedOutUrl = new URL(getOrgUrl(req) + 'signed-out'); const redirectUrl = await this._getLogoutRedirectUrl(req, signedOutUrl); resp.redirect(redirectUrl); })); // Add a static "signed-out" page. This is where logout typically lands (e.g. after redirecting // through SAML). this.app.get('/signed-out', expressWrap((req, resp) => this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'signed-out'}}))); const comment = await this._loginMiddleware.addEndpoints(this.app); this.info.push(['loginMiddlewareComment', comment]); addDiscourseConnectEndpoints(this.app, { userIdMiddleware: this._userIdMiddleware, redirectToLogin: this._redirectToLoginWithoutExceptionsMiddleware, }); } public async addTestingHooks(workerServers?: FlexServer[]) { if (process.env.GRIST_TESTING_SOCKET) { await startTestingHooks(process.env.GRIST_TESTING_SOCKET, this.port, this._comm, this, workerServers || []); this._hasTestingHooks = true; } } // Returns a Map from docId to number of connected clients for each doc. public async getDocClientCounts(): Promise> { return this._docManager ? this._docManager.getDocClientCounts() : new Map(); } // allow the document manager to be specified externally, for convenience in testing. public testSetDocManager(docManager: DocManager) { this._docManager = docManager; } // Add document-related endpoints and related support. public async addDoc() { this._check('doc', 'start', 'tag', 'json', isSingleUserMode() ? null : 'homedb', 'api-mw', 'map', 'telemetry'); // add handlers for cleanup, if we are in charge of the doc manager. if (!this._docManager) { this.addCleanup(); } await this.loadConfig(); this.addComm(); await this.create.configure?.(); if (!isSingleUserMode()) { const externalStorage = appSettings.section('externalStorage'); const haveExternalStorage = Object.values(externalStorage.nested) .some(storage => storage.flag('active').getAsBool()); const disabled = externalStorage.flag('disable') .read({ envVar: 'GRIST_DISABLE_S3' }).getAsBool(); if (disabled || !haveExternalStorage) { this._disableExternalStorage = true; externalStorage.flag('active').set(false); } await this.create.checkBackend?.(); const workers = this._docWorkerMap; const docWorkerId = await this._addSelfAsWorker(workers); const storageManager = new HostedStorageManager(this.docsRoot, docWorkerId, this._disableExternalStorage, workers, this._dbManager, this.create); this._storageManager = storageManager; } else { const samples = getAppPathTo(this.appRoot, 'public_samples'); const storageManager = new DocStorageManager(this.docsRoot, samples, this._comm, this); this._storageManager = storageManager; } const pluginManager = await this._addPluginManager(); this._docManager = this._docManager || new DocManager(this._storageManager, pluginManager, this._dbManager, this); const docManager = this._docManager; shutdown.addCleanupHandler(null, this._shutdown.bind(this), 25000, 'FlexServer._shutdown'); if (!isSingleUserMode()) { this._comm.registerMethods({ openDoc: docManager.openDoc.bind(docManager), }); this._serveDocPage(); } // Attach docWorker endpoints and Comm methods. const docWorker = new DocWorker(this._dbManager, {comm: this._comm, gristServer: this}); this._docWorker = docWorker; // Register the websocket comm functions associated with the docworker. docWorker.registerCommCore(); docWorker.registerCommPlugin(); // Doc-specific endpoints require authorization; collect the relevant middleware in one list. const docAccessMiddleware = [ this._userIdMiddleware, this._docPermissionsMiddleware, this.tagChecker.requireTag ]; this._addSupportPaths(docAccessMiddleware); if (!isSingleUserMode()) { addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this); } } public async checkSandbox() { if (this._check('sandbox', 'doc')) { return; } const flavor = process.env.GRIST_SANDBOX_FLAVOR || 'unknown'; const info = this._sandboxInfo = { flavor, configured: flavor !== 'unsandboxed', functional: false, effective: false, sandboxed: false, lastSuccessfulStep: 'none', } as SandboxInfo; try { const sandbox = createSandbox({ server: this, docId: 'test', // The id is just used in logging - no // document is created or read at this level. // In olden times, and in SaaS, Python 2 is supported. In modern // times Python 2 is long since deprecated and defunct. preferredPythonVersion: '3', }); info.flavor = sandbox.getFlavor(); info.configured = info.flavor !== 'unsandboxed'; info.lastSuccessfulStep = 'create'; const result = await sandbox.pyCall('get_version'); if (typeof result !== 'number') { throw new Error(`Expected a number: ${result}`); } info.lastSuccessfulStep = 'use'; await sandbox.shutdown(); info.lastSuccessfulStep = 'all'; info.functional = true; info.effective = ![ 'skip', 'unsandboxed' ].includes(info.flavor); } catch (e) { info.error = String(e); } } public getSandboxInfo(): SandboxInfo|undefined { return this._sandboxInfo; } public getInfo(key: string): any { const infoPair = this.info.find(([keyToCheck]) => key === keyToCheck); return infoPair?.[1]; } public disableExternalStorage() { if (this.deps.has('doc')) { throw new Error('disableExternalStorage called too late'); } this._disableExternalStorage = true; } public addAccountPage() { const middleware = [ this._redirectToHostMiddleware, this._userIdMiddleware, this._redirectToLoginWithoutExceptionsMiddleware ]; this.app.get('/account', ...middleware, expressWrap(async (req, resp) => { return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}}); })); const createDoom = async () => { const dbManager = this.getHomeDBManager(); const permitStore = this.getPermitStore(); const notifier = this.getNotifier(); const loginSystem = await this.resolveLoginSystem(); const homeUrl = this.getHomeInternalUrl().replace(/\/$/, ''); return new Doom(dbManager, permitStore, notifier, loginSystem, homeUrl); }; if (isAffirmative(process.env.GRIST_ACCOUNT_CLOSE)) { this.app.delete('/api/doom/account', expressWrap(async (req, resp) => { // Make sure we have a valid user authenticated user here. const userId = getUserId(req); // Make sure we are deleting the correct user account (and not the anonymous user) const requestedUser = integerParam(req.query.userid, 'userid'); if (requestedUser !== userId || isAnonymousUser(req)) { // This probably shouldn't happen, but if user has already deleted the account and tries to do it // once again in a second tab, we might end up here. In that case we are returning false to indicate // that account wasn't deleted. return resp.status(200).json(false); } // We are a valid user, we can proceed with the deletion. Note that we will // delete user as an admin, as we need to remove other resources that user // might not have access to. // Reuse Doom cli tool for account deletion. It won't allow to delete account if it has access // to other (not public) team sites. const doom = await createDoom(); await doom.deleteUser(userId); this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedAccount'); return resp.status(200).json(true); })); this.app.get('/account-deleted', ...this._logoutMiddleware(), expressWrap((req, resp) => { return this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'account-deleted'}}); })); this.app.delete('/api/doom/org', expressWrap(async (req, resp) => { const mreq = req as RequestWithLogin; const orgDomain = getOrgFromRequest(req); if (!orgDomain) { throw new ApiError("Cannot determine organization", 400); } if (this._dbManager.isMergedOrg(orgDomain)) { throw new ApiError("Cannot delete a personal site", 400); } // Get org from the server. const query = await this._dbManager.getOrg(getScope(mreq), orgDomain); const org = this._dbManager.unwrapQueryResult(query); if (!org || org.ownerId) { // This shouldn't happen, but just in case test it. throw new ApiError("Cannot delete an org with an owner", 400); } if (!org.billingAccount.isManager) { throw new ApiError("Only billing manager can delete a team site", 403); } // Reuse Doom cli tool for org deletion. Note, this removes everything as a super user. const doom = await createDoom(); await doom.deleteOrg(org.id); this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedSite', { full: { siteId: org.id, userId: mreq.userId, }, }); return resp.status(200).send(); })); } } public addBillingPages() { const middleware = [ this._redirectToHostMiddleware, this._userIdMiddleware, this._redirectToLoginWithoutExceptionsMiddleware ]; this._getBilling(); this._billing.addPages(this.app, middleware); } /** * Add billing webhooks. Strip signatures sign the raw body of the message, so * we need to get these webhooks in before the bodyParser is added to parse json. */ public addEarlyWebhooks() { if (this._check('webhooks', 'homedb')) { return; } if (this.deps.has('json')) { throw new Error('addEarlyWebhooks called too late'); } this._getBilling(); this._billing.addWebhooks(this.app); } public addWelcomePaths() { const middleware = [ this._redirectToHostMiddleware, this._userIdMiddleware, this._redirectToLoginWithoutExceptionsMiddleware, ]; // These are some special-purpose welcome pages, with no middleware. this.app.get(/\/welcome\/(signup|verify|teams|select-account)/, expressWrap(async (req, resp, next) => { return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}, googleTagManager: 'anon'}); })); /** * A nuanced redirecting endpoint. For example, on docs.getgrist.com it does: * 1) If logged in and no team site -> https://docs.getgrist.com/ * 2) If logged in and has team sites -> https://docs.getgrist.com/welcome/teams * 3) If logged out but has a cookie -> /login, then 1 or 2 * 4) If entirely unknown -> /signup */ this.app.get('/welcome/start', [ this._redirectToHostMiddleware, this._userIdMiddleware, ], expressWrap(async (req, resp, next) => { if (isAnonymousUser(req)) { return this._redirectToLoginOrSignup({ nextUrl: new URL(getOrgUrl(req, '/welcome/start')), }, req, resp); } await this._redirectToHomeOrWelcomePage(req as RequestWithLogin, resp); })); /** * Like /welcome/start, but doesn't redirect anonymous users to sign in. * * Used by the client when the last site the user visited is unknown, and * a suitable site is needed for the home page. * * For example, on templates.getgrist.com it does: * 1) If logged in and no team site -> https://docs.getgrist.com/ * 2) If logged in and has team sites -> https://docs.getgrist.com/welcome/teams * 3) If logged out -> https://docs.getgrist.com/ */ this.app.get('/welcome/home', [ this._redirectToHostMiddleware, this._userIdMiddleware, ], expressWrap(async (req, resp) => { const mreq = req as RequestWithLogin; if (isAnonymousUser(req)) { return resp.redirect(this.getMergedOrgUrl(mreq)); } await this._redirectToHomeOrWelcomePage(mreq, resp, {redirectToMergedOrg: true}); })); this.app.post('/welcome/info', ...middleware, expressWrap(async (req, resp, next) => { const userId = getUserId(req); const user = getUser(req); const useCases = stringArrayParam(req.body.use_cases, 'use_cases'); const useOther = stringParam(req.body.use_other, 'use_other'); const row = { UserID: userId, Name: user.name, Email: user.loginEmail, use_cases: ['L', ...useCases], use_other: useOther, }; this._recordNewUserInfo(row) .catch(e => { // If we failed to record, at least log the data, so we could potentially recover it. log.rawWarn(`Failed to record new user info: ${e.message}`, {newUserQuestions: row}); }); const nonOtherUseCases = useCases.filter(useCase => useCase !== 'Other'); for (const useCase of [...nonOtherUseCases, ...(useOther ? [`Other - ${useOther}`] : [])]) { this.getTelemetry().logEvent(req as RequestWithLogin, 'answeredUseCaseQuestion', { full: { userId, useCase, }, }); } resp.status(200).send(); }), jsonErrorHandler); // Add a final error handler that reports errors as JSON. } public finalizeEndpoints() { this.addApiErrorHandlers(); // add a final non-found handler for other content. this.app.use("/", expressWrap((req, resp) => { if (this._sendAppPage) { return this._sendAppPage(req, resp, {path: 'error.html', status: 404, config: {errPage: 'not-found'}}); } else { return resp.status(404).json({error: 'not found'}); } })); // add a final error handler this.app.use(async (err: any, req: express.Request, resp: express.Response, next: express.NextFunction) => { // Delegate to default error handler when headers have already been sent, as express advises // at https://expressjs.com/en/guide/error-handling.html#the-default-error-handler. // Also delegates if no _sendAppPage method has been configured. if (resp.headersSent || !this._sendAppPage) { return next(err); } try { const errPage = ( err.status === 403 ? 'access-denied' : err.status === 404 ? 'not-found' : 'other-error' ); const config = {errPage, errMessage: err.message || err}; await this._sendAppPage(req, resp, {path: 'error.html', status: err.status || 400, config}); } catch (error) { return next(error); } }); } /** * Check whether there's a local plugin port. */ public servesPlugins() { if (this._servesPlugins === undefined) { throw new Error('do not know if server will serve plugins'); } return this._servesPlugins; } /** * Declare that there will be a local plugin port. */ public setServesPlugins(flag: boolean) { this._servesPlugins = flag; } /** * Get the base URL for plugins. Throws an error if the URL is not * yet available. */ public getPluginUrl() { if (!this._pluginUrlReady) { throw new Error('looked at plugin url too early'); } return this._pluginUrl; } public getPlugins() { if (!this._pluginManager) { throw new Error('plugin manager not available'); } return this._pluginManager.getPlugins(); } public async finalizePlugins(userPort: number|null) { if (isAffirmative(process.env.GRIST_TRUST_PLUGINS)) { this._pluginUrl = this.getDefaultHomeUrl(); } else if (userPort !== null) { // If plugin content is served from same host but on different port, // run webserver on that port const ports = await this.startCopy('pluginServer', userPort); // If Grist is running on a desktop, directly on the host, it // can be convenient to leave the user port free for the OS to // allocate by using GRIST_UNTRUSTED_PORT=0. But we do need to // remember how to contact it. if (process.env.APP_UNTRUSTED_URL === undefined) { const url = new URL(this.getOwnUrl()); url.port = String(userPort || ports.serverPort); this._pluginUrl = url.href; } } this.info.push(['pluginUrl', this._pluginUrl]); this.info.push(['willServePlugins', this._servesPlugins]); this._pluginUrlReady = true; const repo = buildWidgetRepository(this, { localOnly: true }); this._bundledWidgets = await repo.getWidgets(); } public getBundledWidgets(): ICustomWidget[] { if (!this._bundledWidgets) { throw new Error('bundled widgets accessed too early'); } return this._bundledWidgets; } public summary() { for (const [label, value] of this.info) { log.info("== %s: %s", label, value); } for (const item of appSettings.describeAll()) { const txt = ((item.value !== undefined) ? String(item.value) : '-') + (item.foundInEnvVar ? ` [${item.foundInEnvVar}]` : '') + (item.usedDefault ? ' [default]' : '') + ((item.wouldFindInEnvVar && !item.foundInEnvVar) ? ` [${item.wouldFindInEnvVar}]` : ''); log.info("== %s: %s", item.name, txt); } } public ready() { this._isReady = true; } public checkOptionCombinations() { // Check for some bad combinations we should warn about. const allowedWebhookDomains = appSettings.section('integrations').flag('allowedWebhookDomains').readString({ envVar: 'ALLOWED_WEBHOOK_DOMAINS', }); const proxy = appSettings.section('integrations').flag('proxy').readString({ envVar: 'GRIST_HTTPS_PROXY', }); // If all webhook targets are accepted, and no proxy is defined, issue // a warning. This warning can be removed by explicitly setting the proxy // to the empty string. if (allowedWebhookDomains === '*' && proxy === undefined) { log.warn("Setting an ALLOWED_WEBHOOK_DOMAINS wildcard without a GRIST_HTTPS_PROXY exposes your internal network"); } } public async start() { if (this._check('start')) { return; } const servers = this._createServers(); this.server = servers.server; this.httpsServer = servers.httpsServer; await this._startServers(this.server, this.httpsServer, this.name, this.port, true); } public addNotifier() { if (this._check('notifier', 'start', 'homedb')) { return; } // TODO: make Notifier aware of base domains, rather than sending emails with default // base domain. // Most notifications are ultimately triggered by requests with a base domain in them, // and all that is needed is a refactor to pass that info along. But there is also the // case of notification(s) from stripe. May need to associate a preferred base domain // with org/user and persist that? this._notifier = this.create.Notifier(this._dbManager, this); } public getGristConfig(): GristLoadConfig { return makeGristConfig({ homeUrl: this.getDefaultHomeUrl(), extra: {}, baseDomain: this._defaultBaseDomain, }); } /** * Get a url for a team site. */ public async getOrgUrl(orgKey: string|number): Promise { const org = await this.getOrg(orgKey); return this.getResourceUrl(org); } public async getOrg(orgKey: string|number) { if (!this._dbManager) { throw new Error('database missing'); } const org = await this._dbManager.getOrg({ userId: this._dbManager.getPreviewerUserId(), showAll: true }, orgKey); return this._dbManager.unwrapQueryResult(org); } /** * Get a url for an organization, workspace, or document. */ public async getResourceUrl(resource: Organization|Workspace|Document, purpose?: 'api'|'html'): Promise { if (!this._dbManager) { throw new Error('database missing'); } const gristConfig = this.getGristConfig(); const state: IGristUrlState = {}; let org: Organization; if (resource instanceof Organization) { org = resource; } else if (resource instanceof Workspace) { org = resource.org; state.ws = resource.id; } else { org = resource.workspace.org; state.doc = resource.urlId || resource.id; state.slug = getSlugIfNeeded(resource); } state.org = this._dbManager.normalizeOrgDomain(org.id, org.domain, org.ownerId); state.api = purpose === 'api'; if (!gristConfig.homeUrl) { throw new Error('Computing a resource URL requires a home URL'); } return encodeUrl(gristConfig, state, new URL(gristConfig.homeUrl)); } public addUsage() { if (this._check('usage', 'start', 'homedb')) { return; } this.usage = new Usage(this._dbManager); } public async addHousekeeper() { if (this._check('housekeeper', 'start', 'homedb', 'map', 'json', 'api-mw')) { return; } const store = this._docWorkerMap; this.housekeeper = new Housekeeper(this._dbManager, this, this._internalPermitStore, store); this.housekeeper.addEndpoints(this.app); await this.housekeeper.start(); } public async startCopy(name2: string, port2: number): Promise<{ serverPort: number, httpsServerPort?: number, }>{ const servers = this._createServers(); return this._startServers(servers.server, servers.httpsServer, name2, port2, true); } /** * Close all documents currently held open. */ public async closeDocs(): Promise { if (this._docManager) { return this._docManager.shutdownAll(); } } public addGoogleAuthEndpoint() { if (this._check('google-auth')) { return; } const messagePage = makeMessagePage(getAppPathTo(this.appRoot, 'static')); addGoogleAuthEndpoint(this.app, messagePage); } public addInstallEndpoints() { if (this._check('install')) { return; } const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin(); // Admin endpoint needs to have very little middleware since each // piece of middleware creates a new way to fail and leave the admin // panel inaccessible. Generally the admin panel should report problems // rather than failing entirely. this.app.get('/admin', this._userIdMiddleware, expressWrap(async (req, resp) => { return this.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}}); })); const adminMiddleware = [ this._userIdMiddleware, requireInstallAdmin, ]; const probes = new BootProbes(this.app, this, '/api', adminMiddleware); probes.addEndpoints(); this.app.post('/api/admin/restart', requireInstallAdmin, expressWrap(async (req, resp) => { const newConfig = req.body.newConfig; resp.on('finish', () => { // If we have IPC with parent process (e.g. when running under // Docker) tell the parent that we have a new environment so it // can restart us. if (process.send) { process.send({ action: 'restart', newConfig }); } }); // On the topic of http response codes, thus spake MDN: // "409: This response is sent when a request conflicts with the current state of the server." const status = process.send ? 200 : 409; return resp.status(status).send(); })); // Restrict this endpoint to install admins this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => { const activation = await this._activations.current(); return sendOkReply(null, resp, { telemetry: await getTelemetryPrefs(this._dbManager, activation), }); })); this.app.patch('/api/install/prefs', requireInstallAdmin, expressWrap(async (req, resp) => { const props = {prefs: req.body}; const activation = await this._activations.current(); activation.checkProperties(props); activation.updateFromProperties(props); await activation.save(); if ((props as Partial).prefs?.telemetry) { // Make sure the Telemetry singleton picks up the changes to telemetry preferences. // TODO: if there are multiple home server instances, notify them all of changes to // preferences (via Redis Pub/Sub). await this._telemetry.fetchTelemetryPrefs(); } return resp.status(200).send(); })); // GET api/checkUpdates // Retrieves the latest version of the client from Grist SAAS endpoint. this.app.get('/api/install/updates', adminMiddleware, expressWrap(async (req, res) => { // Prepare data for the telemetry that endpoint might expect. const installationId = (await this.getActivations().current()).id; const deploymentType = this.getDeploymentType(); const currentVersion = version.version; const response = await fetch(process.env.GRIST_TEST_VERSION_CHECK_URL || commonUrls.versionCheck, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ installationId, deploymentType, currentVersion, }), }); if (!response.ok) { res.status(response.status); if (response.headers.get('content-type')?.includes('application/json')) { const data = await response.json(); res.json(data); } else { res.send(await response.text()); } } else { res.json(await response.json()); } })); } // Get the HTML template sent for document pages. public async getDocTemplate(): Promise { const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'), 'app.html'), 'utf8'); return { page, tag: this.tag }; } public getTag(): string { if (!this.tag) { throw new Error('getTag called too early'); } return this.tag; } /** * Make sure external storage of all docs is up to date. */ public async testFlushDocs() { const assignments = await this._docWorkerMap.getAssignments(this.worker.id); for (const assignment of assignments) { await this._storageManager.flushDoc(assignment); } } public resolveLoginSystem() { return process.env.GRIST_TEST_LOGIN ? getTestLoginSystem() : (this._getLoginSystem?.() || getLoginSystem()); } public addUpdatesCheck() { if (this._check('update', 'json')) { return; } // For now we only are active for sass deployments. if (this._deploymentType !== 'saas') { return; } this._updateManager = new UpdateManager(this.app, this); this._updateManager.addEndpoints(); } // Adds endpoints that support imports and exports. private _addSupportPaths(docAccessMiddleware: express.RequestHandler[]) { if (!this._docWorker) { throw new Error("need DocWorker"); } const basicMiddleware = [this._userIdMiddleware, this.tagChecker.requireTag]; // Add the handling for the /upload route. Most uploads are meant for a DocWorker: they are put // in temporary files, and the DocWorker needs to be on the same machine to have access to them. // This doesn't check for doc access permissions because the request isn't tied to a document. addUploadRoute(this, this.app, this._docWorkerMap, this._trustOriginsMiddleware, ...basicMiddleware); this.app.get('/attachment', ...docAccessMiddleware, expressWrap(async (req, res) => this._docWorker.getAttachment(req, res))); } private _check(part: string, ...precedents: Array) { if (this.deps.has(part)) { return true; } for (const precedent of precedents) { if (!precedent) { continue; } if (precedent[0] === '!') { const antecedent = precedent.slice(1); if (this._has(antecedent)) { throw new Error(`${part} is needed before ${antecedent}`); } } else if (!this._has(precedent)) { throw new Error(`${precedent} is needed before ${part}`); } } this.deps.add(part); return false; } private _has(part: string) { return this.deps.has(part); } private async _addSelfAsWorker(workers: IDocWorkerMap): Promise { try { this._healthy = true; // Check if this is the first time calling this method. In production, // it always will be. In testing, we may disconnect and reconnect the // worker. We only need to determine docWorkerId and this.worker once. if (!this.worker) { if (process.env.GRIST_ROUTER_URL) { // register ourselves with the load balancer first. const w = await this.createWorkerUrl(); const url = `${w.url}/v/${this.tag}/`; // TODO: we could compute a distinct internal url here. this.worker = { id: w.host, publicUrl: url, internalUrl: url, }; } else { const url = (process.env.APP_DOC_URL || this.getOwnUrl()) + `/v/${this.tag}/`; this.worker = { // The worker id should be unique to this worker. id: process.env.GRIST_DOC_WORKER_ID || `testDocWorkerId_${this.port}`, publicUrl: url, internalUrl: process.env.APP_DOC_INTERNAL_URL || url, }; } this.info.push(['docWorkerId', this.worker.id]); if (process.env.GRIST_WORKER_GROUP) { this.worker.group = process.env.GRIST_WORKER_GROUP; } } else { if (process.env.GRIST_ROUTER_URL) { await this.createWorkerUrl(); } } await workers.addWorker(this.worker); await workers.setWorkerAvailability(this.worker.id, true); } catch (err) { this._healthy = false; throw err; } return this.worker.id; } private async _removeSelfAsWorker(workers: IDocWorkerMap, docWorkerId: string) { this._healthy = false; await workers.removeWorker(docWorkerId); if (process.env.GRIST_ROUTER_URL) { await axios.get(process.env.GRIST_ROUTER_URL, {params: {act: 'remove', port: this.getOwnPort()}}); log.info(`DocWorker unregistered itself via ${process.env.GRIST_ROUTER_URL}`); } } // Called when server is shutting down. Save any state that needs saving, and // disentangle ourselves from outside world. private async _shutdown(): Promise { if (!this.worker) { return; } if (!this._storageManager) { return; } if (!this._docWorkerMap) { return; } // but this should never happen. const workers = this._docWorkerMap; // Pick up the pace on saving documents. this._storageManager.prepareToCloseStorage(); // We urgently want to disable any new assignments. await workers.setWorkerAvailability(this.worker.id, false); // Enumerate the documents we are responsible for. let assignments = await workers.getAssignments(this.worker.id); let retries: number = 0; while (assignments.length > 0 && retries < 3) { await Promise.all(assignments.map(async assignment => { log.info("FlexServer shutdown assignment", assignment); try { // Start sending the doc to S3 if needed. const flushOp = this._storageManager.closeDocument(assignment); // Get access to the clients of this document. This has the side // effect of waiting for the ActiveDoc to finish initialization. // This could include loading it from S3, an operation we could // potentially abort as an optimization. // TODO: abort any s3 loading as an optimization. const docPromise = this._docManager.getActiveDoc(assignment); const doc = docPromise && await docPromise; await flushOp; // At this instant, S3 and local document should be the same. // We'd now like to make sure (synchronously) that: // - we never output anything new to S3 about this document. // - we never output anything new to user about this document. // There could be asynchronous operations going on related to // these documents, but if we can make sure that their effects // do not reach the outside world then we can ignore them. if (doc) { doc.docClients.interruptAllClients(); doc.setMuted(); } // Release this document for other workers to pick up. // There is a small window of time here in which a client // could reconnect to us. The muted ActiveDoc will result // in them being dropped again. await workers.releaseAssignment(this.worker.id, assignment); } catch (err) { log.info("problem dealing with assignment", assignment, err); } })); // Check for any assignments that slipped through at the last minute. assignments = await workers.getAssignments(this.worker.id); retries++; } if (assignments.length > 0) { log.error("FlexServer shutdown failed to release assignments:", assignments); } await this._removeSelfAsWorker(workers, this.worker.id); try { await this._docManager.shutdownAll(); } catch (err) { log.error("FlexServer shutdown problem", err); } if (this._comm) { this._comm.destroyAllClients(); } log.info("FlexServer shutdown is complete"); } /** * Middleware that redirects a request with a userId but without an org to an org-specific URL, * after looking up the first org for this userId in DB. */ private async _redirectToOrg(req: express.Request, resp: express.Response, next: express.NextFunction) { const mreq = req as RequestWithLogin; if (mreq.org || !mreq.userId) { return next(); } // Redirect anonymous users to the merged org. if (!mreq.userIsAuthorized) { const redirectUrl = this.getMergedOrgUrl(mreq); log.debug(`Redirecting anonymous user to: ${redirectUrl}`); return resp.redirect(redirectUrl); } // We have a userId, but the request is for an unknown org. Redirect to an org that's // available to the user. This matters in dev, and in prod when visiting a generic URL, which // will here redirect to e.g. the user's personal org. const result = await this._dbManager.getMergedOrgs(mreq.userId, mreq.userId, null); const orgs = (result.status === 200) ? result.data : null; const subdomain = orgs && orgs.length > 0 ? orgs[0].domain : null; const redirectUrl = subdomain && this._getOrgRedirectUrl(mreq, subdomain); if (redirectUrl) { log.debug(`Redirecting userId ${mreq.userId} to: ${redirectUrl}`); return resp.redirect(redirectUrl); } next(); } /** * Given a Request and a desired subdomain, returns a URL for a similar request that specifies that * subdomain either in the hostname or in the path. Optionally passing pathname overrides url's * path. */ private _getOrgRedirectUrl(req: RequestWithLogin, subdomain: string, pathname: string = req.originalUrl): string { const config = this.getGristConfig(); const {hostname, orgInPath} = getOrgUrlInfo(subdomain, req.get('host')!, config); const redirectUrl = new URL(pathname, getOriginUrl(req)); if (hostname) { redirectUrl.hostname = hostname; } if (orgInPath) { redirectUrl.pathname = `/o/${orgInPath}` + redirectUrl.pathname; } return redirectUrl.href; } // Create and initialize the plugin manager private async _addPluginManager() { if (this._pluginManager) { return this._pluginManager; } // Only used as {userRoot}/plugins as a place for plugins in addition to {appRoot}/plugins const userRoot = path.resolve(process.env.GRIST_USER_ROOT || getAppPathTo(this.appRoot, '.grist')); this.info.push(['userRoot', userRoot]); // Some custom widgets may be included as an npm package called @gristlabs/grist-widget. // The package doesn't actually contain node code, but should be in the same vicinity // as other packages that do, so we can use require.resolve on one of them to find it. // This seems a little overcomplicated, but works well when grist-core is bundled within // a larger project like grist-electron. // TODO: maybe add a little node code to @gristlabs/grist-widget so it can be resolved // directly? const gristLabsModules = path.dirname(path.dirname(require.resolve('@gristlabs/express-session'))); const bundledRoot = isAffirmative(process.env.GRIST_SKIP_BUNDLED_WIDGETS) ? undefined : path.join( gristLabsModules, 'grist-widget', 'dist' ); this.info.push(['bundledRoot', bundledRoot]); const pluginManager = new PluginManager(this.appRoot, userRoot, bundledRoot); // `initialize()` is asynchronous and reads plugins manifests; if PluginManager is used before it // finishes, it will act as if there are no plugins. // ^ I think this comment was here to justify calling initialize without waiting for // the result. I'm just going to wait, for determinism. await pluginManager.initialize(); this._pluginManager = pluginManager; return pluginManager; } // Serve the static app.html proxied for a document. private _serveDocPage() { // Serve the static app.html file. // TODO: We should be the ones to fill in the base href here to ensure that the browser fetches // the correct version of static files for this app.html. this.app.get('/:docId/app.html', this._userIdMiddleware, expressWrap(async (req, res) => { res.json(await this.getDocTemplate()); })); } private _getBilling(): IBilling { if (!this._billing) { if (!this._dbManager) { throw new Error("need dbManager"); } this._billing = this.create.Billing(this._dbManager, this); } return this._billing; } // Check whether logger should skip a line. Careful, req and res are morgan-specific // types, not Express. private _shouldSkipRequestLogging(req: {url: string}, res: {statusCode: number}) { if (req.url === '/status' && [200, 304].includes(res.statusCode) && this._healthCheckCounter > HEALTH_CHECK_LOG_SHOW_FIRST_N && this._healthCheckCounter % HEALTH_CHECK_LOG_SHOW_EVERY_N !== 1) { return true; } return false; } private _createServers() { // Start the app. const server = configServer(http.createServer(this.app)); let httpsServer; if (TEST_HTTPS_OFFSET) { const certFile = process.env.GRIST_TEST_SSL_CERT; const privateKeyFile = process.env.GRIST_TEST_SSL_KEY; if (!certFile) { throw new Error('Set GRIST_TEST_SSL_CERT to location of certificate file'); } if (!privateKeyFile) { throw new Error('Set GRIST_TEST_SSL_KEY to location of private key file'); } log.debug(`https support: reading cert from ${certFile}`); log.debug(`https support: reading private key from ${privateKeyFile}`); httpsServer = configServer(https.createServer({ key: fse.readFileSync(privateKeyFile, 'utf8'), cert: fse.readFileSync(certFile, 'utf8'), }, this.app)); } return {server, httpsServer}; } private async _startServers(server: http.Server, httpsServer: https.Server|undefined, name: string, port: number, verbose: boolean) { await listenPromise(server.listen(port, this.host)); const serverPort = (server.address() as AddressInfo).port; if (verbose) { log.info(`${name} available at ${this.host}:${serverPort}`); } let httpsServerPort: number|undefined; if (TEST_HTTPS_OFFSET && httpsServer) { if (port === 0) { throw new Error('cannot use https with OS-assigned port'); } httpsServerPort = port + TEST_HTTPS_OFFSET; await listenPromise(httpsServer.listen(httpsServerPort, this.host)); if (verbose) { log.info(`${name} available at https://${this.host}:${httpsServerPort}`); } } return { serverPort, httpsServerPort, }; } private async _recordNewUserInfo(row: object) { const urlId = DOC_ID_NEW_USER_INFO; // If nowhere to record data, return immediately. if (!urlId) { return; } let body: string|undefined; let permitKey: string|undefined; try { body = JSON.stringify(mapValues(row, value => [value])); // Take an extra step to translate the special urlId to a docId. This is helpful to // allow the same urlId to be used in production and in test. We need the docId for the // specialPermit below, which we need to be able to write to this doc. // // TODO With proper forms support, we could give an origin-based permission to submit a // form to this doc, and do it from the client directly. const previewerUserId = this._dbManager.getPreviewerUserId(); const docAuth = await this._dbManager.getDocAuthCached({urlId, userId: previewerUserId}); const docId = docAuth.docId; if (!docId) { throw new Error(`Can't resolve ${urlId}: ${docAuth.error}`); } permitKey = await this._internalPermitStore.setPermit({docId}); const res = await fetch(await this.getHomeUrlByDocId(docId, `/api/docs/${docId}/tables/Responses/data`), { method: 'POST', headers: {'Permit': permitKey, 'Content-Type': 'application/json'}, body, }); if (res.status !== 200) { throw new Error(`API call failed with ${res.status}`); } } finally { if (permitKey) { await this._internalPermitStore.removePermit(permitKey); } } } /** * If signUp is true, redirect to signUp. * If signUp is false, redirect to login. * If signUp is not set, redirect to signUp if no cookie found, else login. * * If nextUrl is not supplied, it will be constructed from a path in * the "next" query parameter. */ private async _redirectToLoginOrSignup( options: { signUp?: boolean, nextUrl?: URL, }, req: express.Request, resp: express.Response, ) { let {nextUrl, signUp} = options; const mreq = req as RequestWithLogin; // This will ensure that express-session will set our cookie if it hasn't already - // we'll need it when we redirect back. forceSessionChange(mreq.session); // Redirect to the requested URL after successful login. if (!nextUrl) { const nextPath = optStringParam(req.query.next, 'next'); nextUrl = new URL(getOrgUrl(req, nextPath)); } if (signUp === undefined) { // Like redirectToLogin in Authorizer, redirect to sign up if it doesn't look like the // user has ever logged in on this browser. signUp = (mreq.session.users === undefined); } const getRedirectUrl = signUp ? this._getSignUpRedirectUrl : this._getLoginRedirectUrl; resp.redirect(await getRedirectUrl(req, nextUrl)); } private async _redirectToHomeOrWelcomePage( mreq: RequestWithLogin, resp: express.Response, options: {redirectToMergedOrg?: boolean} = {} ) { const {redirectToMergedOrg} = options; const userId = getUserId(mreq); const domain = getOrgFromRequest(mreq); const orgs = this._dbManager.unwrapQueryResult( await this._dbManager.getOrgs(userId, domain, { ignoreEveryoneShares: true, }) ); if (orgs.length > 1) { resp.redirect(getOrgUrl(mreq, '/welcome/teams')); } else { resp.redirect(redirectToMergedOrg ? this.getMergedOrgUrl(mreq) : getOrgUrl(mreq)); } } /** * If a valid cookie was set during sign-up to copy a document to the * user's Home workspace, copy it and return the id of the new document. * * If a valid cookie wasn't set or copying failed, return `null`. */ private async _maybeCopyDocToHomeWorkspace( req: RequestWithLogin, resp: express.Response ): Promise { const cookies = cookie.parse(req.headers.cookie || ''); if (!cookies) { return null; } const stateCookie = cookies['gr_signup_state']; if (!stateCookie) { return null; } const state = safeJsonParse(stateCookie, {}); const {srcDocId} = state; if (!srcDocId) { return null; } let newDocId: string | null = null; try { newDocId = await this._copyDocToHomeWorkspace(req, srcDocId); } catch (e) { log.error(`FlexServer failed to copy doc ${srcDocId} to Home workspace`, e); } finally { resp.clearCookie('gr_signup_state'); } return newDocId; } private async _copyDocToHomeWorkspace( req: express.Request, docId: string, ): Promise { const userId = getUserId(req); const doc = await this._dbManager.getDoc({userId, urlId: docId}); if (!doc) { throw new Error(`Doc ${docId} not found`); } const workspacesQueryResult = await this._dbManager.getOrgWorkspaces(getScope(req), 0); const workspaces = this._dbManager.unwrapQueryResult(workspacesQueryResult); const workspace = workspaces.find(w => w.name === 'Home'); if (!workspace) { throw new Error('Home workspace not found'); } const copyDocUrl = this.getHomeInternalUrl('/api/docs'); const response = await fetch(copyDocUrl, { headers: { ...getTransitiveHeaders(req, { includeOrigin: false }), 'Content-Type': 'application/json', }, method: 'POST', body: JSON.stringify({ sourceDocumentId: doc.id, workspaceId: workspace.id, documentName: doc.name, }), }); return await response.json(); } /** * Creates set of middleware for handling logout requests and clears session. Used in any endpoint * or a page that needs to log out the user and clear the session. */ private _logoutMiddleware() { const sessionClearMiddleware = expressWrap(async (req, resp, next) => { const scopedSession = this._sessions.getOrCreateSessionFromRequest(req); // Clear session so that user needs to log in again at the next request. // SAML logout in theory uses userSession, so clear it AFTER we compute the URL. // Express-session will save these changes. const expressSession = (req as RequestWithLogin).session; if (expressSession) { expressSession.users = []; expressSession.orgToUser = {}; } await scopedSession.clearScopedSession(req); // TODO: limit cache clearing to specific user. this._sessions.clearCacheIfNeeded(); next(); }); const pluggedMiddleware = this._loginMiddleware.getLogoutMiddleware ? this._loginMiddleware.getLogoutMiddleware() : []; return [...pluggedMiddleware, sessionClearMiddleware]; } } /** * Returns the passed-in server, with some options adjusted. Specifically, removes the default * socket timeout. */ function configServer(server: T): T { // Remove the socket timeout, which causes node to close socket for long-running requests // (like imports), triggering browser retry. (The default is 2 min; removed starting node v13.) // See also https://nodejs.org/docs/latest-v10.x/api/http.html#http_server_settimeout_msecs_callback.) server.setTimeout(0); // The server's keepAlive timeout should be longer than the load-balancer's. Otherwise LB will // produce occasional 502 errors when it sends a request to node just as node closes a // connection. See https://adamcrowder.net/posts/node-express-api-and-aws-alb-502/. const lbTimeoutSec = 300; // Ensure all inactive connections are terminated by the ALB, by setting this a few seconds // higher than the ALB idle timeout server.keepAliveTimeout = (lbTimeoutSec + 5) * 1000; // Ensure the headersTimeout is set higher than the keepAliveTimeout due to this nodejs // regression bug: https://github.com/nodejs/node/issues/27363 server.headersTimeout = (lbTimeoutSec + 6) * 1000; log.info("Server timeouts: keepAliveTimeout %s headersTimeout %s", server.keepAliveTimeout, server.headersTimeout); return server; } // Returns true if environment is configured to allow unauthenticated test logins. function allowTestLogin() { return Boolean(process.env.GRIST_TEST_LOGIN); } // Check OPTIONS requests for allowed origins, and return heads to allow the browser to proceed // with a POST (or other method) request. function trustOriginHandler(req: express.Request, res: express.Response, next: express.NextFunction) { res.header("Access-Control-Allow-Methods", "GET, PATCH, PUT, POST, DELETE, OPTIONS"); if (trustOrigin(req, res)) { res.header("Access-Control-Allow-Credentials", "true"); res.header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With"); } else { // Any origin is allowed, but if it isn't trusted, then we don't allow credentials, // i.e. no Cookie or Authorization header. res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Headers", "Content-Type, X-Requested-With"); if (req.get("Cookie") || req.get("Authorization")) { // In practice we don't expect to actually reach this point, // as the browser should not include credentials in preflight (OPTIONS) requests, // and should block real requests with credentials based on the preflight response. // But having this means not having to rely on our understanding of browsers and CORS too much. throw new ApiError("Credentials not supported for cross-origin requests", 403); } } if ('OPTIONS' === req.method) { res.sendStatus(200); } else { next(); } } // Set Cache-Control header to "no-cache" function noCaching(req: express.Request, res: express.Response, next: express.NextFunction) { res.header("Cache-Control", "no-cache"); next(); } // Methods that Electron app relies on. export interface ElectronServerMethods { importDoc(filepath: string): Promise; onDocOpen(cb: (filePath: string) => void): void; getUserConfig(): Promise; updateUserConfig(obj: any): Promise; onBackupMade(cb: () => void): void; } // Allow static files to be requested from any origin. const serveAnyOrigin: serveStatic.ServeStaticOptions = { setHeaders: (res, filepath, stat) => { res.setHeader("Access-Control-Allow-Origin", "*"); } };