(core) Adds a UI panel for managing webhooks

Summary:
This adds a UI panel for managing webhooks. Work started by Cyprien Pindat. You can find the UI on a document's settings page. Main changes relative to Cyprien's demo:

  * Changed behavior of virtual table to be more consistent with the rest of Grist, by factoring out part of the implementation of on-demand tables.
  * Cell values that would create an error can now be denied and reverted (as for the rest of Grist).
  * Changes made by other users are integrated in a sane way.
  * Basic undo/redo support is added using the regular undo/redo stack.
  * The table list in the drop-down is now updated if schema changes.
  * Added a notification from back-end when webhook status is updated so constant polling isn't needed to support multi-user operation.
  *  Factored out webhook specific logic from general virtual table support.
  * Made a bunch of fixes to various broken behavior.
  * Added tests.

The code remains somewhat unpolished, and behavior in the presence of errors is imperfect in general but may be adequate for this case.

I assume that we'll soon be lifting the restriction on the set of domains that are supported for webhooks - otherwise we'd want to provide some friendly way to discover that list of supported domains rather than just throwing an error.

I don't actually know a lot about how the front-end works - it looks like tables/columns/fields/sections can be safely added if they have string ids that won't collide with bone fide numeric ids from the back end. Sneaky.

Contains a migration, so needs an extra reviewer for that.

Test Plan: added tests

Reviewers: jarek, dsagal

Reviewed By: jarek, dsagal

Differential Revision: https://phab.getgrist.com/D3856
This commit is contained in:
Paul Fitzpatrick
2023-05-08 18:06:24 -04:00
parent 5e9f2e06ea
commit 603238e966
37 changed files with 1698 additions and 376 deletions

View File

@@ -28,7 +28,7 @@ import {
TableOperationsImpl,
TableOperationsPlatform
} from 'app/plugin/TableOperationsImpl';
import {concatenateSummaries, summarizeAction} from "app/server/lib/ActionSummary";
import {concatenateSummaries, summarizeAction} from "app/common/ActionSummarizer";
import {ActiveDoc, colIdToRef as colIdToReference, tableIdToRef} from "app/server/lib/ActiveDoc";
import {
assertAccess,
@@ -117,20 +117,20 @@ const {
*/
function validate(checker: Checker): RequestHandler {
return (req, res, next) => {
try {
checker.check(req.body);
} catch(err) {
log.warn(`Error during api call to ${req.path}: Invalid payload: ${String(err)}`);
res.status(400).json({
error : "Invalid payload",
details: String(err)
}).end();
return;
}
validateCore(checker, req, req.body);
next();
};
}
function validateCore(checker: Checker, req: Request, body: any) {
try {
checker.check(body);
} catch(err) {
log.warn(`Error during api call to ${req.path}: Invalid payload: ${String(err)}`);
throw new ApiError('Invalid payload', 400, {userError: String(err)});
}
}
export class DocWorkerApi {
// Map from docId to number of requests currently being handled for that doc
private _currentUsage = new Map<string, number>();
@@ -237,6 +237,59 @@ export class DocWorkerApi {
activeDoc.fetchMetaTables(docSessionFromRequest(req)));
}
async function getWebhookSettings(activeDoc: ActiveDoc, req: RequestWithLogin, webhookId: string|null) {
const metaTables = await getMetaTables(activeDoc, req);
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined;
let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined;
const {url, eventTypes, isReadyColumn, name} = req.body;
const tableId = req.params.tableId || req.body.tableId;
const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
if (url && !isUrlAllowed(url)) {
throw new ApiError('Provided url is forbidden', 403);
}
if (eventTypes) {
if (!eventTypes.length) {
throw new ApiError(`eventTypes must be a non-empty array`, 400);
}
fields.eventTypes = [GristObjCode.List, ...eventTypes];
}
if (tableId !== undefined) {
fields.tableRef = tableIdToRef(metaTables, tableId);
currentTableId = tableId;
}
if (isReadyColumn !== undefined) {
// When isReadyColumn is defined let's explicitly change the ready column to the new col
// id, null or empty string being a special case that unsets it.
if (isReadyColumn !== null && isReadyColumn !== '') {
if (!currentTableId) {
throw new ApiError(`Cannot find column "${isReadyColumn}" because table is not known`, 404);
}
fields.isReadyColRef = colIdToReference(metaTables, currentTableId, isReadyColumn);
} else {
fields.isReadyColRef = 0;
}
} else if (tableId) {
// When isReadyColumn is undefined but tableId was changed, let's unset the ready column
fields.isReadyColRef = 0;
}
// assign other field properties
Object.assign(fields, _.pick(req.body, ['enabled', 'memo']));
if (name) {
fields.label = name;
}
return {
fields,
url,
trigger,
};
}
// Get the columns of the specified table in recordish format
this._app.get('/api/docs/:docId/tables/:tableId/columns', canView,
withDoc(async (activeDoc, req, res) => {
@@ -358,9 +411,27 @@ export class DocWorkerApi {
// Adds records given in a record oriented format,
// returns in the same format as GET /records but without the fields object for now
this._app.post('/api/docs/:docId/tables/:tableId/records', canEdit, validate(RecordsPost),
// WARNING: The `req.body` object is modified in place.
this._app.post('/api/docs/:docId/tables/:tableId/records', canEdit,
withDoc(async (activeDoc, req, res) => {
const body = req.body as Types.RecordsPost;
let body = req.body;
if (isAffirmative(req.query.flat)) {
if (!body.records && Array.isArray(body)) {
for (const [i, rec] of body.entries()) {
if (!rec.fields) {
// If ids arrive in a loosely formatted flat payload,
// remove them since we cannot honor them. If not loosely
// formatted, throw an error later. TODO: would be useful
// to have a way to exclude or rename fields via query
// parameters.
if (rec.id) { delete rec.id; }
body[i] = {fields: rec};
}
}
body = {records: body};
}
}
validateCore(RecordsPost, req, body);
const ops = getTableOperations(req, activeDoc);
const records = await ops.create(body.records);
res.json({records});
@@ -550,22 +621,15 @@ export class DocWorkerApi {
// Add a new webhook and trigger
this._app.post('/api/docs/:docId/tables/:tableId/_subscribe', isOwner, validate(WebhookSubscribe),
withDoc(async (activeDoc, req, res) => {
const {isReadyColumn, eventTypes, url} = req.body;
if (!eventTypes.length) {
const {fields, url} = await getWebhookSettings(activeDoc, req, null);
if (!fields.eventTypes?.length) {
throw new ApiError(`eventTypes must be a non-empty array`, 400);
}
if (!isUrlAllowed(url)) {
throw new ApiError('Provided url is forbidden', 403);
}
const tableId = req.params.tableId;
const metaTables = await getMetaTables(activeDoc, req);
const tableRef = tableIdToRef(metaTables, tableId);
let isReadyColRef = 0;
if (isReadyColumn) {
isReadyColRef = colIdToReference(metaTables, tableId, isReadyColumn);
if (!fields.tableRef) {
throw new ApiError(`tableId is required`, 400);
}
const unsubscribeKey = uuidv4();
@@ -579,9 +643,8 @@ export class DocWorkerApi {
const sandboxRes = await handleSandboxError("_grist_Triggers", [], activeDoc.applyUserActions(
docSessionFromRequest(req),
[['AddRecord', "_grist_Triggers", null, {
eventTypes: [GristObjCode.List, ...eventTypes],
isReadyColRef,
tableRef,
enabled: true,
...fields,
actions: JSON.stringify([webhookAction])
}]]));
@@ -596,6 +659,8 @@ export class DocWorkerApi {
// remove webhook
await this._dbManager.removeWebhook(webhookId, activeDoc.docName, '', false);
throw err;
} finally {
await activeDoc.sendWebhookNotification();
}
})
);
@@ -622,56 +687,19 @@ export class DocWorkerApi {
docSessionFromRequest(req),
[['RemoveRecord', "_grist_Triggers", triggerRowId]]));
await activeDoc.sendWebhookNotification();
res.json({success: true});
})
);
// Update a webhoook
// Update a webhook
this._app.patch(
'/api/docs/:docId/webhooks/:webhookId', isOwner, validate(WebhookPatch), withDoc(async (activeDoc, req, res) => {
const docId = activeDoc.docName;
const webhookId = req.params.webhookId;
const metaTables = await getMetaTables(activeDoc, req);
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const trigger = activeDoc.triggers.getWebhookTriggerRecord(webhookId);
let currentTableId = tablesTable.getValue(trigger.tableRef, 'tableId')!;
const {url, eventTypes, isReadyColumn, tableId} = req.body;
const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
if (url && !isUrlAllowed(url)) {
// TODO: remove redundancy with same validation in _subscribe endpoint
throw new ApiError('Provided url is forbidden', 403);
}
if (eventTypes) {
// TODO: remove redundancy with same validation in _subscribe endpoint
if (!eventTypes.length) {
throw new ApiError(`eventTypes must be a non-empty array`, 400);
}
fields.eventTypes = [GristObjCode.List, ...eventTypes];
}
if (tableId !== undefined) {
fields.tableRef = tableIdToRef(metaTables, tableId);
currentTableId = tableId;
}
if (isReadyColumn !== undefined) {
// When isReadyColumn is defined let's explicitly changes the ready column to the new col
// id, null being a special case that unsets it.
if (isReadyColumn !== null) {
fields.isReadyColRef = colIdToReference(metaTables, currentTableId, isReadyColumn);
} else {
fields.isReadyColRef = 0;
}
} else if (tableId) {
// When isReadyColumn is undefined but tableId was changed, let's implicitely unset the ready column
fields.isReadyColRef = 0;
}
// assign other fields properties
Object.assign(fields, _.pick(req.body, ['enabled']));
const {fields, trigger, url} = await getWebhookSettings(activeDoc, req, webhookId);
const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id;
@@ -694,6 +722,8 @@ export class DocWorkerApi {
}
});
await activeDoc.sendWebhookNotification();
res.json({success: true});
})
);
@@ -702,6 +732,7 @@ export class DocWorkerApi {
this._app.delete('/api/docs/:docId/webhooks/queue', isOwner,
withDoc(async (activeDoc, req, res) => {
await activeDoc.clearWebhookQueue();
await activeDoc.sendWebhookNotification();
res.json({success: true});
})
);