CamilleLegeron 4 weeks ago committed by GitHub
commit ed93c39239
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -104,6 +104,12 @@ const WEBHOOK_COLUMNS = [
type: 'Text', type: 'Text',
label: t('Status'), label: t('Status'),
}, },
{
id: 'vt_webhook_fc11',
colId: 'authorization',
type: 'Text',
label: t('Header Authorization'),
},
] as const; ] as const;
/** /**
@ -111,10 +117,11 @@ const WEBHOOK_COLUMNS = [
*/ */
const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [ const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [
'name', 'memo', 'name', 'memo',
'eventTypes', 'url', 'eventTypes', 'tableId',
'tableId', 'isReadyColumn', 'watchedColIdsText', 'isReadyColumn',
'watchedColIdsText', 'webhookId', 'url', 'authorization',
'enabled', 'status' 'webhookId', 'enabled',
'status'
]; ];
/** /**
@ -133,7 +140,7 @@ class WebhookExternalTable implements IExternalTable {
public name = 'GristHidden_WebhookTable'; public name = 'GristHidden_WebhookTable';
public initialActions = _prepareWebhookInitialActions(this.name); public initialActions = _prepareWebhookInitialActions(this.name);
public saveableFields = [ public saveableFields = [
'tableId', 'watchedColIdsText', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn', 'tableId', 'watchedColIdsText', 'url', 'authorization', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
]; ];
public webhooks: ObservableArray<UIWebhookSummary> = observableArray<UIWebhookSummary>([]); public webhooks: ObservableArray<UIWebhookSummary> = observableArray<UIWebhookSummary>([]);

@ -14,6 +14,7 @@ export const Webhook = t.iface([], {
export const WebhookFields = t.iface([], { export const WebhookFields = t.iface([], {
"url": "string", "url": "string",
"authorization": t.opt("string"),
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))), "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
"tableId": "string", "tableId": "string",
"watchedColIds": t.opt(t.array("string")), "watchedColIds": t.opt(t.array("string")),
@ -29,6 +30,7 @@ export const WebhookStatus = t.union(t.lit('idle'), t.lit('sending'), t.lit('ret
export const WebhookSubscribe = t.iface([], { export const WebhookSubscribe = t.iface([], {
"url": "string", "url": "string",
"authorization": t.opt("string"),
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))), "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
"watchedColIds": t.opt(t.array("string")), "watchedColIds": t.opt(t.array("string")),
"enabled": t.opt("boolean"), "enabled": t.opt("boolean"),
@ -45,6 +47,7 @@ export const WebhookSummary = t.iface([], {
"id": "string", "id": "string",
"fields": t.iface([], { "fields": t.iface([], {
"url": "string", "url": "string",
"authorization": t.opt("string"),
"unsubscribeKey": "string", "unsubscribeKey": "string",
"eventTypes": t.array("string"), "eventTypes": t.array("string"),
"isReadyColumn": t.union("string", "null"), "isReadyColumn": t.union("string", "null"),
@ -64,6 +67,7 @@ export const WebhookUpdate = t.iface([], {
export const WebhookPatch = t.iface([], { export const WebhookPatch = t.iface([], {
"url": t.opt("string"), "url": t.opt("string"),
"authorization": t.opt("string"),
"eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))), "eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))),
"tableId": t.opt("string"), "tableId": t.opt("string"),
"watchedColIds": t.opt(t.array("string")), "watchedColIds": t.opt(t.array("string")),

@ -8,6 +8,7 @@ export interface Webhook {
export interface WebhookFields { export interface WebhookFields {
url: string; url: string;
authorization?: string;
eventTypes: Array<"add"|"update">; eventTypes: Array<"add"|"update">;
tableId: string; tableId: string;
watchedColIds?: string[]; watchedColIds?: string[];
@ -26,6 +27,7 @@ export type WebhookStatus = 'idle'|'sending'|'retrying'|'postponed'|'error'|'inv
// tableId from the url) but generics are not yet supported by ts-interface-builder // tableId from the url) but generics are not yet supported by ts-interface-builder
export interface WebhookSubscribe { export interface WebhookSubscribe {
url: string; url: string;
authorization?: string;
eventTypes: Array<"add"|"update">; eventTypes: Array<"add"|"update">;
watchedColIds?: string[]; watchedColIds?: string[];
enabled?: boolean; enabled?: boolean;
@ -42,6 +44,7 @@ export interface WebhookSummary {
id: string; id: string;
fields: { fields: {
url: string; url: string;
authorization?: string;
unsubscribeKey: string; unsubscribeKey: string;
eventTypes: string[]; eventTypes: string[];
isReadyColumn: string|null; isReadyColumn: string|null;
@ -64,6 +67,7 @@ export interface WebhookUpdate {
// ts-interface-builder // ts-interface-builder
export interface WebhookPatch { export interface WebhookPatch {
url?: string; url?: string;
authorization?: string;
eventTypes?: Array<"add"|"update">; eventTypes?: Array<"add"|"update">;
tableId?: string; tableId?: string;
watchedColIds?: string[]; watchedColIds?: string[];

@ -1952,7 +1952,8 @@ export class HomeDBManager extends EventEmitter {
// Update the webhook url in the webhook's corresponding secret (note: the webhook identifier is // Update the webhook url in the webhook's corresponding secret (note: the webhook identifier is
// its secret identifier). // its secret identifier).
public async updateWebhookUrl(id: string, docId: string, url: string, outerManager?: EntityManager) { public async updateWebhookUrlAndAuth(
id: string, docId: string, url: string, auth: string | undefined, outerManager?: EntityManager) {
return await this._runInTransaction(outerManager, async manager => { return await this._runInTransaction(outerManager, async manager => {
const value = await this.getSecret(id, docId, manager); const value = await this.getSecret(id, docId, manager);
if (!value) { if (!value) {
@ -1960,6 +1961,7 @@ export class HomeDBManager extends EventEmitter {
} }
const webhookSecret = JSON.parse(value); const webhookSecret = JSON.parse(value);
webhookSecret.url = url; webhookSecret.url = url;
webhookSecret.authorization = auth;
await this.updateSecret(id, docId, JSON.stringify(webhookSecret), manager); await this.updateSecret(id, docId, JSON.stringify(webhookSecret), manager);
}); });
} }

@ -324,7 +324,7 @@ export class DocWorkerApi {
); );
const registerWebhook = async (activeDoc: ActiveDoc, req: RequestWithLogin, webhook: WebhookFields) => { const registerWebhook = async (activeDoc: ActiveDoc, req: RequestWithLogin, webhook: WebhookFields) => {
const {fields, url} = await getWebhookSettings(activeDoc, req, null, webhook); const {fields, url, authorization} = await getWebhookSettings(activeDoc, req, null, webhook);
if (!fields.eventTypes?.length) { if (!fields.eventTypes?.length) {
throw new ApiError(`eventTypes must be a non-empty array`, 400); throw new ApiError(`eventTypes must be a non-empty array`, 400);
} }
@ -336,7 +336,7 @@ export class DocWorkerApi {
} }
const unsubscribeKey = uuidv4(); const unsubscribeKey = uuidv4();
const webhookSecret: WebHookSecret = {unsubscribeKey, url}; const webhookSecret: WebHookSecret = {unsubscribeKey, url, authorization};
const secretValue = JSON.stringify(webhookSecret); const secretValue = JSON.stringify(webhookSecret);
const webhookId = (await this._dbManager.addSecret(secretValue, activeDoc.docName)).id; const webhookId = (await this._dbManager.addSecret(secretValue, activeDoc.docName)).id;
@ -392,7 +392,7 @@ export class DocWorkerApi {
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables"); const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined; const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined;
let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined; let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined;
const {url, eventTypes, watchedColIds, isReadyColumn, name} = webhook; const {url, authorization, eventTypes, watchedColIds, isReadyColumn, name} = webhook;
const tableId = await getRealTableId(req.params.tableId || webhook.tableId, {metaTables}); const tableId = await getRealTableId(req.params.tableId || webhook.tableId, {metaTables});
const fields: Partial<SchemaTypes['_grist_Triggers']> = {}; const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
@ -454,6 +454,7 @@ export class DocWorkerApi {
return { return {
fields, fields,
url, url,
authorization,
}; };
} }
@ -926,16 +927,16 @@ export class DocWorkerApi {
const docId = activeDoc.docName; const docId = activeDoc.docName;
const webhookId = req.params.webhookId; const webhookId = req.params.webhookId;
const {fields, url} = await getWebhookSettings(activeDoc, req, webhookId, req.body); const {fields, url, authorization} = await getWebhookSettings(activeDoc, req, webhookId, req.body);
if (fields.enabled === false) { if (fields.enabled === false) {
await activeDoc.triggers.clearSingleWebhookQueue(webhookId); await activeDoc.triggers.clearSingleWebhookQueue(webhookId);
} }
const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id; const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id;
// update url in homedb // update url and authorization header in homedb
if (url) { if (url || authorization) {
await this._dbManager.updateWebhookUrl(webhookId, docId, url); await this._dbManager.updateWebhookUrlAndAuth(webhookId, docId, url, authorization || undefined);
activeDoc.triggers.webhookDeleted(webhookId); // clear cache activeDoc.triggers.webhookDeleted(webhookId); // clear cache
} }

@ -72,6 +72,7 @@ type Trigger = MetaRowRecord<"_grist_Triggers">;
export interface WebHookSecret { export interface WebHookSecret {
url: string; url: string;
unsubscribeKey: string; unsubscribeKey: string;
authorization?: string;
} }
// Work to do after fetching values from the document // Work to do after fetching values from the document
@ -259,6 +260,7 @@ export class DocTriggers {
const getTableId = docData.getMetaTable("_grist_Tables").getRowPropFunc("tableId"); const getTableId = docData.getMetaTable("_grist_Tables").getRowPropFunc("tableId");
const getColId = docData.getMetaTable("_grist_Tables_column").getRowPropFunc("colId"); const getColId = docData.getMetaTable("_grist_Tables_column").getRowPropFunc("colId");
const getUrl = async (id: string) => (await this._getWebHook(id))?.url ?? ''; const getUrl = async (id: string) => (await this._getWebHook(id))?.url ?? '';
const getAuthorization = async (id: string) => (await this._getWebHook(id))?.authorization ?? '';
const getUnsubscribeKey = async (id: string) => (await this._getWebHook(id))?.unsubscribeKey ?? ''; const getUnsubscribeKey = async (id: string) => (await this._getWebHook(id))?.unsubscribeKey ?? '';
const resultTable: WebhookSummary[] = []; const resultTable: WebhookSummary[] = [];
@ -271,6 +273,7 @@ export class DocTriggers {
for (const act of webhookActions) { for (const act of webhookActions) {
// Url, probably should be hidden for non-owners (but currently this API is owners only). // Url, probably should be hidden for non-owners (but currently this API is owners only).
const url = await getUrl(act.id); const url = await getUrl(act.id);
const authorization = await getAuthorization(act.id);
// Same story, should be hidden. // Same story, should be hidden.
const unsubscribeKey = await getUnsubscribeKey(act.id); const unsubscribeKey = await getUnsubscribeKey(act.id);
if (!url || !unsubscribeKey) { if (!url || !unsubscribeKey) {
@ -285,6 +288,7 @@ export class DocTriggers {
fields: { fields: {
// Url, probably should be hidden for non-owners (but currently this API is owners only). // Url, probably should be hidden for non-owners (but currently this API is owners only).
url, url,
authorization,
unsubscribeKey, unsubscribeKey,
// Other fields used to register this webhook. // Other fields used to register this webhook.
eventTypes: decodeObject(t.eventTypes) as string[], eventTypes: decodeObject(t.eventTypes) as string[],
@ -683,6 +687,7 @@ export class DocTriggers {
const batch = _.takeWhile(this._webHookEventQueue.slice(0, 100), {id}); const batch = _.takeWhile(this._webHookEventQueue.slice(0, 100), {id});
const body = JSON.stringify(batch.map(e => e.payload)); const body = JSON.stringify(batch.map(e => e.payload));
const url = await this._getWebHookUrl(id); const url = await this._getWebHookUrl(id);
const authorization = (await this._getWebHook(id))?.authorization || "";
if (this._loopAbort.signal.aborted) { if (this._loopAbort.signal.aborted) {
continue; continue;
} }
@ -698,7 +703,8 @@ export class DocTriggers {
this._activeDoc.logTelemetryEvent(null, 'sendingWebhooks', { this._activeDoc.logTelemetryEvent(null, 'sendingWebhooks', {
limited: {numEvents: meta.numEvents}, limited: {numEvents: meta.numEvents},
}); });
success = await this._sendWebhookWithRetries(id, url, body, batch.length, this._loopAbort.signal); success = await this._sendWebhookWithRetries(
id, url, authorization, body, batch.length, this._loopAbort.signal);
if (this._loopAbort.signal.aborted) { if (this._loopAbort.signal.aborted) {
continue; continue;
} }
@ -770,7 +776,8 @@ export class DocTriggers {
return this._drainingQueue ? Math.min(5, TRIGGER_MAX_ATTEMPTS) : TRIGGER_MAX_ATTEMPTS; return this._drainingQueue ? Math.min(5, TRIGGER_MAX_ATTEMPTS) : TRIGGER_MAX_ATTEMPTS;
} }
private async _sendWebhookWithRetries(id: string, url: string, body: string, size: number, signal: AbortSignal) { private async _sendWebhookWithRetries(
id: string, url: string, authorization: string, body: string, size: number, signal: AbortSignal) {
const maxWait = 64; const maxWait = 64;
let wait = 1; let wait = 1;
for (let attempt = 0; attempt < this._maxWebhookAttempts; attempt++) { for (let attempt = 0; attempt < this._maxWebhookAttempts; attempt++) {
@ -786,6 +793,7 @@ export class DocTriggers {
body, body,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(authorization ? {'Authorization': authorization} : {}),
}, },
signal, signal,
agent: proxyAgent(new URL(url)), agent: proxyAgent(new URL(url)),

@ -1195,7 +1195,8 @@
"URL": "URL", "URL": "URL",
"Webhook Id": "Webhook Id", "Webhook Id": "Webhook Id",
"Table": "Table", "Table": "Table",
"Filter for changes in these columns (semicolon-separated ids)": "Filter for changes in these columns (semicolon-separated ids)" "Filter for changes in these columns (semicolon-separated ids)": "Filter for changes in these columns (semicolon-separated ids)",
"Header Authorization": "Header Authorization"
}, },
"FormulaAssistant": { "FormulaAssistant": {
"Ask the bot.": "Ask the bot.", "Ask the bot.": "Ask the bot.",

@ -52,10 +52,11 @@ describe('WebhookPage', function () {
'Name', 'Name',
'Memo', 'Memo',
'Event Types', 'Event Types',
'URL',
'Table', 'Table',
'Ready Column',
'Filter for changes in these columns (semicolon-separated ids)', 'Filter for changes in these columns (semicolon-separated ids)',
'Ready Column',
'URL',
'Header Authorization',
'Webhook Id', 'Webhook Id',
'Enabled', 'Enabled',
'Status', 'Status',

@ -4504,6 +4504,7 @@ function testDocApi() {
id: first.webhookId, id: first.webhookId,
fields: { fields: {
url: `${serving.url}/200`, url: `${serving.url}/200`,
authorization: '',
unsubscribeKey: first.unsubscribeKey, unsubscribeKey: first.unsubscribeKey,
eventTypes: ['add', 'update'], eventTypes: ['add', 'update'],
enabled: true, enabled: true,
@ -4522,6 +4523,7 @@ function testDocApi() {
id: second.webhookId, id: second.webhookId,
fields: { fields: {
url: `${serving.url}/404`, url: `${serving.url}/404`,
authorization: '',
unsubscribeKey: second.unsubscribeKey, unsubscribeKey: second.unsubscribeKey,
eventTypes: ['add', 'update'], eventTypes: ['add', 'update'],
enabled: true, enabled: true,
@ -4889,6 +4891,7 @@ function testDocApi() {
const expectedFields = { const expectedFields = {
url: `${serving.url}/foo`, url: `${serving.url}/foo`,
authorization: '',
eventTypes: ['add'], eventTypes: ['add'],
isReadyColumn: 'B', isReadyColumn: 'B',
tableId: 'Table1', tableId: 'Table1',

Loading…
Cancel
Save