import { ApiError } from "app/common/ApiError"; import { ConfigKey, ConfigKeyChecker, ConfigValue, ConfigValueCheckers, } from "app/common/Config"; import { commonUrls } from "app/common/gristUrls"; import { InstallProperties } from "app/common/InstallAPI"; import * as version from "app/common/version"; import { getOrgKey } from "app/gen-server/ApiServer"; import { Config } from "app/gen-server/entity/Config"; import { PreviousAndCurrent, QueryResult, } from "app/gen-server/lib/homedb/Interfaces"; import { BootProbes } from "app/server/lib/BootProbes"; import { expressWrap } from "app/server/lib/expressWrap"; import { GristServer } from "app/server/lib/GristServer"; import log from "app/server/lib/log"; import { getScope, sendOkReply, sendReply, stringParam, } from "app/server/lib/requestUtils"; import { getTelemetryPrefs } from "app/server/lib/Telemetry"; import { Application, json, NextFunction, Request, RequestHandler, Response, } from "express"; import pick from "lodash/pick"; export interface AttachOptions { app: Application; gristServer: GristServer; userIdMiddleware: RequestHandler; } export function attachEarlyEndpoints(options: AttachOptions) { const { app, gristServer, userIdMiddleware } = options; // 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. app.get( "/admin", userIdMiddleware, expressWrap(async (req, res) => { return gristServer.sendAppPage(req, res, { path: "app.html", status: 200, config: {}, }); }) ); const requireInstallAdmin = gristServer .getInstallAdmin() .getMiddlewareRequireAdmin(); const adminMiddleware = [requireInstallAdmin]; app.use("/api/admin", adminMiddleware); app.use("/api/install", adminMiddleware); const probes = new BootProbes(app, gristServer, "/api", adminMiddleware); probes.addEndpoints(); app.post( "/api/admin/restart", expressWrap(async (_, res) => { res.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" }); } }); if (!process.env.GRIST_RUNNING_UNDER_SUPERVISOR) { // 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." return res.status(409).send({ error: "Cannot automatically restart the Grist server to enact changes. Please restart server manually.", }); } return res.status(200).send({ msg: "ok" }); }) ); // Restrict this endpoint to install admins. app.get( "/api/install/prefs", expressWrap(async (_req, res) => { const activation = await gristServer.getActivations().current(); return sendOkReply(null, res, { telemetry: await getTelemetryPrefs( gristServer.getHomeDBManager(), activation ), }); }) ); app.patch( "/api/install/prefs", json({ limit: "1mb" }), expressWrap(async (req, res) => { const props = { prefs: req.body }; const activation = await gristServer.getActivations().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 gristServer.getTelemetry().fetchTelemetryPrefs(); } return res.status(200).send(); }) ); // Retrieves the latest version of the client from Grist SAAS endpoint. app.get( "/api/install/updates", expressWrap(async (_req, res) => { // Prepare data for the telemetry that endpoint might expect. const installationId = (await gristServer.getActivations().current()).id; const deploymentType = gristServer.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()); } }) ); app.get( "/api/install/configs/:key", hasValidConfigKey, expressWrap(async (req, res) => { const key = stringParam(req.params.key, "key") as ConfigKey; const configResult = await gristServer .getHomeDBManager() .getInstallConfig(key); const result = pruneConfigAPIResult(configResult); return sendReply(req, res, result); }) ); app.put( "/api/install/configs/:key", json({ limit: "1mb", strict: false }), hasValidConfig, expressWrap(async (req, res) => { const key = stringParam(req.params.key, "key") as ConfigKey; const value = req.body as ConfigValue; const configResult = await gristServer .getHomeDBManager() .updateInstallConfig(key, value); const result = pruneConfigAPIResult(configResult); return sendReply(req, res, result); }) ); app.delete( "/api/install/configs/:key", hasValidConfigKey, expressWrap(async (req, res) => { const key = stringParam(req.params.key, "key") as ConfigKey; const { status } = await gristServer .getHomeDBManager() .deleteInstallConfig(key); return sendReply(req, res, { status }); }) ); app.get( "/api/orgs/:oid/configs/:key", hasValidConfigKey, expressWrap(async (req, res) => { const org = getOrgKey(req); const key = stringParam(req.params.key, "key") as ConfigKey; const configResult = await gristServer .getHomeDBManager() .getOrgConfig(getScope(req), org, key); const result = pruneConfigAPIResult(configResult); return sendReply(req, res, result); }) ); app.put( "/api/orgs/:oid/configs/:key", json({ limit: "1mb", strict: false }), hasValidConfig, expressWrap(async (req, res) => { const key = stringParam(req.params.key, "key") as ConfigKey; const org = getOrgKey(req); const value = req.body as ConfigValue; const configResult = await gristServer .getHomeDBManager() .updateOrgConfig(getScope(req), org, key, value); const result = pruneConfigAPIResult(configResult); return sendReply(req, res, result); }) ); app.delete( "/api/orgs/:oid/configs/:key", hasValidConfigKey, expressWrap(async (req, res) => { const org = getOrgKey(req); const key = stringParam(req.params.key, "key") as ConfigKey; const { status } = await gristServer .getHomeDBManager() .deleteOrgConfig(getScope(req), org, key); return sendReply(req, res, { status }); }) ); } function pruneConfigAPIResult( result: QueryResult> ) { if (!result.data) { return result as unknown as QueryResult; } const config = "previous" in result.data ? result.data.current : result.data; return { ...result, data: { ...pick(config, "id", "key", "value", "createdAt", "updatedAt"), ...(config.org ? { org: pick(config.org, "id", "name", "domain") } : undefined), }, }; } function hasValidConfig(req: Request, _res: Response, next: NextFunction) { try { assertValidConfig(req); next(); } catch (e) { next(e); } } function hasValidConfigKey(req: Request, _res: Response, next: NextFunction) { try { assertValidConfigKey(req); next(); } catch (e) { next(e); } } function assertValidConfig(req: Request) { assertValidConfigKey(req); const key = stringParam(req.params.key, "key") as ConfigKey; try { ConfigValueCheckers[key].check(req.body); } catch (err) { log.warn( `Error during API call to ${req.path}: invalid config value (${String( err )})` ); throw new ApiError("Invalid config value", 400, { userError: String(err) }); } } function assertValidConfigKey(req: Request) { try { ConfigKeyChecker.check(req.params.key); } catch (err) { log.warn( `Error during API call to ${req.path}: invalid config key (${String( err )})` ); throw new ApiError("Invalid config key", 400, { userError: String(err) }); } }