mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
ecff88bd32
Summary: A new set of endpoints for managing installation and site configuration have been added: - GET `/api/install/configs/:key` - get the value of the configuration item with the specified key - PUT `/api/install/configs/:key` - set the value of the configuration item with the specified key - body: the JSON value of the configuration item - DELETE `/api/install/configs/:key` - delete the configuration item with the specified key - GET `/api/orgs/:oid/configs/:key` - get the value of the configuration item with the specified key - PUT `/api/orgs/:oid/configs/:key` - set the value of the configuration item with the specified key - body: the JSON value of the configuration item - DELETE `/api/orgs/:oid/configs/:key` - delete the configuration item with the specified key Configuration consists of key/value pairs, where keys are strings (e.g. `"audit_logs_streaming_destinations"`) and values are JSON, including literals like numbers and strings. Only installation admins and site owners are permitted to modify installation and site configuration, respectively. The endpoints are planned to be used in an upcoming feature for enabling audit log streaming for an installation and/or site. Future functionality may use the endpoints as well, which may require extending the current capabilities (e.g. adding support for storing secrets, additional metadata fields, etc.). Test Plan: Server tests Reviewers: paulfitz, jarek Reviewed By: paulfitz, jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D4377
318 lines
9.1 KiB
TypeScript
318 lines
9.1 KiB
TypeScript
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<InstallProperties>).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<Config | PreviousAndCurrent<Config>>
|
|
) {
|
|
if (!result.data) {
|
|
return result as unknown as QueryResult<undefined>;
|
|
}
|
|
|
|
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) });
|
|
}
|
|
}
|