(core) Initial webhooks implementation

Summary:
See https://grist.quip.com/VKd3ASF99ezD/Outgoing-Webhooks

- 2 new DocApi endpoints: _subscribe and _unsubscribe, not meant to be user friendly or publicly documented. _unsubscribe should be given the response from _subscribe in the body, e.g:

```
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_subscribe" -H "Content-type: application/json" -d '{"url": "https://webhook.site/a916b526-8afc-46e6-aa8f-a625d0d83ec3", "eventTypes": ["add"], "isReadyColumn": "C"}'
{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_unsubscribe" -H "Content-type: application/json" -d '{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}'
{"success":true}
```

- New DB entity Secret to hold the webhook URL and unsubscribe key
- New document metatable _grist_Triggers subscribes to table changes and points to a secret to use for a webhook
- New file Triggers.ts processes action summaries and uses the two new tables to send webhooks.
- Also went on a bit of a diversion and made a typesafe subclass of TableData for metatables.

I think this is essentially good enough for a first diff, to keep the diffs manageable and to talk about the overall structure. Future diffs can add tests and more robustness using redis etc. After this diff I can also start building the Zapier integration privately.

Test Plan: Tested manually: see curl commands in summary for an example. Payloads can be seen in https://webhook.site/#!/a916b526-8afc-46e6-aa8f-a625d0d83ec3/0b9fe335-33f7-49fe-b90b-2db5ba53382d/1 . Great site for testing webhooks btw.

Reviewers: dsagal, paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3019
This commit is contained in:
Alex Hall
2021-09-23 01:06:23 +02:00
parent de76cc48d1
commit 3c4d71aeca
14 changed files with 584 additions and 27 deletions

View File

@@ -6,6 +6,7 @@ import {Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryColumn} from "t
import {AclRuleDoc} from "./AclRule";
import {Alias} from "./Alias";
import {Resource} from "./Resource";
import {Secret} from "./Secret";
import {Workspace} from "./Workspace";
// Acceptable ids for use in document urls.
@@ -58,6 +59,9 @@ export class Document extends Resource {
@Column({name: 'options', type: nativeValues.jsonEntityType, nullable: true})
public options: DocumentOptions | null;
@OneToMany(_type => Secret, secret => secret.doc)
public secrets: Secret[];
public checkProperties(props: any): props is Partial<DocumentProperties> {
return super.checkProperties(props, documentPropertyKeys);
}

View File

@@ -0,0 +1,16 @@
import {BaseEntity, Column, Entity, JoinColumn, ManyToOne, PrimaryColumn} from "typeorm";
import {Document} from "./Document";
@Entity({name: 'secrets'})
export class Secret extends BaseEntity {
@PrimaryColumn()
public id: string; // generally a UUID
@Column({name: 'value'})
public value: string;
@ManyToOne(_type => Document, { onDelete: 'CASCADE' })
@JoinColumn({name: 'doc_id'})
public doc: Document;
}

View File

@@ -22,6 +22,7 @@ import {Login} from "app/gen-server/entity/Login";
import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/entity/Organization";
import {Pref} from "app/gen-server/entity/Pref";
import {getDefaultProductNames, Product, starterFeatures} from "app/gen-server/entity/Product";
import {Secret} from "app/gen-server/entity/Secret";
import {User} from "app/gen-server/entity/User";
import {Workspace} from "app/gen-server/entity/Workspace";
import {Permissions} from 'app/gen-server/lib/Permissions';
@@ -31,11 +32,13 @@ import {bitOr, getRawAndEntities, now, readJson} from 'app/gen-server/sqlUtils';
import {makeId} from 'app/server/lib/idUtils';
import * as log from 'app/server/lib/log';
import {Permit} from 'app/server/lib/Permit';
import {WebHookSecret} from "app/server/lib/Triggers";
import {EventEmitter} from 'events';
import flatten = require('lodash/flatten');
import pick = require('lodash/pick');
import {Brackets, Connection, createConnection, DatabaseType, EntityManager,
getConnection, SelectQueryBuilder, WhereExpression} from "typeorm";
import * as uuidv4 from "uuid/v4";
// Support transactions in Sqlite in async code. This is a monkey patch, affecting
// the prototypes of various TypeORM classes.
@@ -1548,6 +1551,47 @@ export class HomeDBManager extends EventEmitter {
});
}
public addSecret(value: string, docId: string): Promise<Secret> {
return this._connection.transaction(async manager => {
const secret = new Secret();
secret.id = uuidv4();
secret.value = value;
secret.doc = {id: docId} as any;
await manager.save([secret]);
return secret;
});
}
public async getSecret(id: string, docId: string, manager?: EntityManager): Promise<string | undefined> {
const secret = await (manager || this._connection).createQueryBuilder()
.select('secrets')
.from(Secret, 'secrets')
.where('id = :id AND doc_id = :docId', {id, docId})
.getOne();
return secret?.value;
}
public async removeWebhook(id: string, docId: string, unsubscribeKey: string): Promise<void> {
if (!(id && unsubscribeKey)) {
throw new ApiError('Bad request: id and unsubscribeKey both required', 400);
}
return await this._connection.transaction(async manager => {
const secret = await this.getSecret(id, docId, manager);
if (!secret) {
throw new ApiError('Webhook with given id not found', 404);
}
const webhook = JSON.parse(secret) as WebHookSecret;
if (webhook.unsubscribeKey !== unsubscribeKey) {
throw new ApiError('Wrong unsubscribeKey', 401);
}
await manager.createQueryBuilder()
.delete()
.from(Secret)
.where('id = :id', {id})
.execute();
});
}
// Checks that the user has UPDATE permissions to the given doc. If not, throws an
// error. Otherwise updates the given doc with the given name. Returns an empty
// query result with status 200 on success.

View File

@@ -0,0 +1,38 @@
import {MigrationInterface, QueryRunner, Table} from "typeorm";
export class Secret1631286208009 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.createTable(new Table({
name: "secrets",
columns: [
{
name: "id",
type: "varchar",
isPrimary: true
},
{
name: "value",
type: "varchar",
},
{
name: "doc_id",
type: "varchar",
}
],
foreignKeys: [
{
columnNames: ["doc_id"],
referencedColumnNames: ["id"],
referencedTableName: "docs",
onDelete: 'CASCADE' // delete secret if linked to doc that is deleted
}
]
}));
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropTable('secrets');
}
}