From 3f0600fed70512f87dc20fe039695d1681a73d39 Mon Sep 17 00:00:00 2001 From: "Sam X. Chen" Date: Sat, 17 Aug 2019 19:17:57 -0400 Subject: Add Feature: enable two-way webhooks - stage one --- client/components/settings/settingBody.jade | 8 +++ client/components/settings/settingBody.js | 3 + client/components/sidebar/sidebar.jade | 36 +++++++----- client/components/sidebar/sidebar.js | 86 +++++++++++++++++++---------- client/lib/utils.js | 1 - i18n/en.i18n.json | 5 ++ models/integrations.js | 18 ++++-- models/settings.js | 1 + server/publications/settings.js | 6 ++ 9 files changed, 115 insertions(+), 49 deletions(-) diff --git a/client/components/settings/settingBody.jade b/client/components/settings/settingBody.jade index 8eb584dc..04b635e8 100644 --- a/client/components/settings/settingBody.jade +++ b/client/components/settings/settingBody.jade @@ -18,6 +18,8 @@ template(name="setting") a.js-setting-menu(data-id="announcement-setting") {{_ 'admin-announcement'}} li a.js-setting-menu(data-id="layout-setting") {{_ 'layout'}} + li + a.js-setting-menu(data-id="webhook-setting") {{_ 'global-webhook'}} .main-body if loading.get +spinner @@ -31,6 +33,12 @@ template(name="setting") +announcementSettings else if layoutSetting.get +layoutSettings + else if webhookSetting.get + +webhookSettings + +template(name="webhookSettings") + span + +outgoingWebhooksPopup template(name="general") ul#registration-setting.setting-detail diff --git a/client/components/settings/settingBody.js b/client/components/settings/settingBody.js index f9b5c08d..4ff5aedd 100644 --- a/client/components/settings/settingBody.js +++ b/client/components/settings/settingBody.js @@ -7,11 +7,13 @@ BlazeComponent.extendComponent({ this.accountSetting = new ReactiveVar(false); this.announcementSetting = new ReactiveVar(false); this.layoutSetting = new ReactiveVar(false); + this.webhookSetting = new ReactiveVar(false); Meteor.subscribe('setting'); Meteor.subscribe('mailServer'); Meteor.subscribe('accountSettings'); Meteor.subscribe('announcements'); + Meteor.subscribe('globalwebhooks'); }, setError(error) { @@ -83,6 +85,7 @@ BlazeComponent.extendComponent({ this.accountSetting.set('account-setting' === targetID); this.announcementSetting.set('announcement-setting' === targetID); this.layoutSetting.set('layout-setting' === targetID); + this.webhookSetting.set('webhook-setting' === targetID); } }, diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade index 2dfe41b3..ccfadc0c 100644 --- a/client/components/sidebar/sidebar.jade +++ b/client/components/sidebar/sidebar.jade @@ -135,22 +135,30 @@ template(name="archiveBoardPopup") template(name="outgoingWebhooksPopup") each integrations form.integration-form - if title - h4 {{title}} - else - h4 {{_ 'no-name'}} - label - | URL - input.js-outgoing-webhooks-url(type="text" name="url" value=url) - input(type="hidden" value=_id name="id") + a.flex + span {{_ 'disable-webhook'}} + b   + .materialCheckBox(class="{{#unless enabled}}is-checked{{/unless}}") + input.js-outgoing-webhooks-title(placeholder="{{_ 'webhook-title'}}" type="text" name="title" value=title) + input.js-outgoing-webhooks-url(type="text" name="url" value=url autofocus) + input.js-outgoing-webhooks-token(placeholder="{{_ 'webhook-token' }}" type="text" value=token name="token") + select.js-outgoing-webhooks-type(name="type") + each _type in types + if($eq _type this.type) + option(value=_type selected="selected") {{_ _type}} + else + option(value=_type) {{_ _type}} + input(type="hidden" value=this.type name="_type") + input(type="hidden" value=_id name="id") input.primary.wide(type="submit" value="{{_ 'save'}}") form.integration-form - h4 - | {{_ 'new-outgoing-webhook'}} - label - | URL - input.js-outgoing-webhooks-url(type="text" name="url" autofocus) - input.primary.wide(type="submit" value="{{_ 'save'}}") + input.js-outgoing-webhooks-title(placeholder="{{_ 'webhook-title'}}" type="text" name="title" autofocus) + input.js-outgoing-webhooks-url(placeholder="{{_ 'URL' }}" type="text" name="url") + input.js-outgoing-webhooks-token(placeholder="{{_ 'webhook-token' }}" type="text" name="token") + select.js-outgoing-webhooks-type(name="type") + each _type in types + option(value=_type) {{_ _type}} + input.primary.wide(type="submit" value="{{_ 'create'}}") template(name="boardMenuPopup") ul.pop-over-list diff --git a/client/components/sidebar/sidebar.js b/client/components/sidebar/sidebar.js index f7efb1e8..f1ccfb1e 100644 --- a/client/components/sidebar/sidebar.js +++ b/client/components/sidebar/sidebar.js @@ -1,6 +1,8 @@ Sidebar = null; const defaultView = 'home'; +const MCB = '.materialCheckBox'; +const CKCLS = 'is-checked'; const viewTitles = { filter: 'filter-cards', @@ -280,44 +282,71 @@ Template.membersWidget.events({ }); BlazeComponent.extendComponent({ + boardId() { + return Session.get('currentBoard') || Integrations.Const.GLOBAL_WEBHOOK_ID; + }, integrations() { - const boardId = Session.get('currentBoard'); + const boardId = this.boardId(); return Integrations.find({ boardId: `${boardId}` }).fetch(); }, - - integration(id) { - const boardId = Session.get('currentBoard'); - return Integrations.findOne({ _id: id, boardId: `${boardId}` }); + types() { + return Integrations.Const.WEBHOOK_TYPES; + }, + integration(cond) { + const boardId = this.boardId(); + const condition = { boardId, ...cond }; + for (const k in condition) { + if (!condition[k]) delete condition[k]; + } + return Integrations.findOne(condition); + }, + onCreated() { + this.disabled = new ReactiveVar(false); }, - events() { return [ { + 'click a.flex'(evt) { + this.disabled.set(!this.disabled.get()); + $(evt.target).toggleClass(CKCLS, this.disabled.get()); + }, submit(evt) { evt.preventDefault(); const url = evt.target.url.value; - const boardId = Session.get('currentBoard'); + const boardId = this.boardId(); let id = null; let integration = null; + const title = evt.target.title.value; + const token = evt.target.token.value; + const type = evt.target.type.value; + const enabled = !this.disabled.get(); + let remove = false; + const values = { + url, + type, + token, + title, + enabled, + }; if (evt.target.id) { id = evt.target.id.value; - integration = this.integration(id); - if (url) { - Integrations.update(integration._id, { - $set: { - url: `${url}`, - }, - }); - } else { - Integrations.remove(integration._id); - } + integration = this.integration({ _id: id }); + remove = !url; + } else if (url) { + integration = this.integration({ url, token }); + } + if (remove) { + Integrations.remove(integration._id); + } else if (integration && integration._id) { + Integrations.update(integration._id, { + $set: values, + }); } else if (url) { Integrations.insert({ + ...values, userId: Meteor.userId(), enabled: true, - type: 'outgoing-webhooks', - url: `${url}`, - boardId: `${boardId}`, + boardId, activities: ['all'], }); } @@ -474,12 +503,12 @@ BlazeComponent.extendComponent({ evt.preventDefault(); this.currentBoard.allowsSubtasks = !this.currentBoard.allowsSubtasks; this.currentBoard.setAllowsSubtasks(this.currentBoard.allowsSubtasks); - $('.js-field-has-subtasks .materialCheckBox').toggleClass( - 'is-checked', + $(`.js-field-has-subtasks ${MCB}`).toggleClass( + CKCLS, this.currentBoard.allowsSubtasks, ); $('.js-field-has-subtasks').toggleClass( - 'is-checked', + CKCLS, this.currentBoard.allowsSubtasks, ); $('.js-field-deposit-board').prop( @@ -515,15 +544,12 @@ BlazeComponent.extendComponent({ ]; options.forEach(function(element) { if (element !== value) { - $(`#${element} .materialCheckBox`).toggleClass( - 'is-checked', - false, - ); - $(`#${element}`).toggleClass('is-checked', false); + $(`#${element} ${MCB}`).toggleClass(CKCLS, false); + $(`#${element}`).toggleClass(CKCLS, false); } }); - $(`#${value} .materialCheckBox`).toggleClass('is-checked', true); - $(`#${value}`).toggleClass('is-checked', true); + $(`#${value} ${MCB}`).toggleClass(CKCLS, true); + $(`#${value}`).toggleClass(CKCLS, true); this.currentBoard.setPresentParentTask(value); evt.preventDefault(); }, diff --git a/client/lib/utils.js b/client/lib/utils.js index 81835929..cc3526c0 100644 --- a/client/lib/utils.js +++ b/client/lib/utils.js @@ -23,7 +23,6 @@ Utils = { }) ); }, - MAX_IMAGE_PIXEL: Meteor.settings.public.MAX_IMAGE_PIXEL, COMPRESS_RATIO: Meteor.settings.public.IMAGE_COMPRESS_RATIO, processUploadedAttachment(card, fileObj, callback) { diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 7065396f..c8acc0de 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -508,9 +508,14 @@ "email-smtp-test-text": "You have successfully sent an email", "error-invitation-code-not-exist": "Invitation code doesn't exist", "error-notAuthorized": "You are not authorized to view this page.", + "webhook-title": "Webhook Name", + "webhook-token": "Token (Optional for Authentication)", "outgoing-webhooks": "Outgoing Webhooks", + "bidirectional-webhooks": "Two-Way Webhooks", "outgoingWebhooksPopup-title": "Outgoing Webhooks", "boardCardTitlePopup-title": "Card Title Filter", + "disable-webhook": "Disable This Webhook", + "global-webhook": "Global Webhooks", "new-outgoing-webhook": "New Outgoing Webhook", "no-name": "(Unknown)", "Node_version": "Node version", diff --git a/models/integrations.js b/models/integrations.js index 0b2e08c6..0313c959 100644 --- a/models/integrations.js +++ b/models/integrations.js @@ -88,16 +88,26 @@ Integrations.attachSchema( }, }), ); - +Integrations.Const = { + GLOBAL_WEBHOOK_ID: '_global', + WEBHOOK_TYPES: ['outgoing-webhooks', 'bidirectional-webhooks'], +}; +const permissionHelper = { + allow(userId, doc) { + const user = Users.findOne(userId); + const isAdmin = user && Meteor.user().isAdmin; + return isAdmin || allowIsBoardAdmin(userId, Boards.findOne(doc.boardId)); + }, +}; Integrations.allow({ insert(userId, doc) { - return allowIsBoardAdmin(userId, Boards.findOne(doc.boardId)); + return permissionHelper.allow(userId, doc); }, update(userId, doc) { - return allowIsBoardAdmin(userId, Boards.findOne(doc.boardId)); + return permissionHelper.allow(userId, doc); }, remove(userId, doc) { - return allowIsBoardAdmin(userId, Boards.findOne(doc.boardId)); + return permissionHelper.allow(userId, doc); }, fetch: ['boardId'], }); diff --git a/models/settings.js b/models/settings.js index 4a0359d5..80c2d8e0 100644 --- a/models/settings.js +++ b/models/settings.js @@ -147,6 +147,7 @@ if (Meteor.isServer) { }:${doc.mailServer.port}/`; } Accounts.emailTemplates.from = doc.mailServer.from; + console.log('Settings saved:', Accounts.emailTemplates); } }); diff --git a/server/publications/settings.js b/server/publications/settings.js index d273fe62..034737e7 100644 --- a/server/publications/settings.js +++ b/server/publications/settings.js @@ -1,3 +1,9 @@ +Meteor.publish('globalwebhooks', () => { + const boardId = Integrations.Const.GLOBAL_WEBHOOK_ID; + return Integrations.find({ + boardId, + }); +}); Meteor.publish('setting', () => { return Settings.find( {}, -- cgit v1.2.3-1-g7c22 From dd0682328bc26bbe852fb19a85131e4017c547b0 Mon Sep 17 00:00:00 2001 From: "Sam X. Chen" Date: Thu, 29 Aug 2019 22:07:40 -0400 Subject: Add Feature: enable two-way webhooks - stage two --- models/activities.js | 23 +++++++--- models/integrations.js | 6 ++- models/settings.js | 1 - server/notifications/outgoing.js | 97 +++++++++++++++++++++++++++++++++------- 4 files changed, 102 insertions(+), 25 deletions(-) diff --git a/models/activities.js b/models/activities.js index 3ecd5c8c..f64fce10 100644 --- a/models/activities.js +++ b/models/activities.js @@ -184,10 +184,11 @@ if (Meteor.isServer) { // it's person at himself, ignore it? continue; } - const user = Users.findOne(username) || Users.findOne({ username }); - const uid = user && user._id; + const atUser = + Users.findOne(username) || Users.findOne({ username }); + const uid = atUser && atUser._id; params.atUsername = username; - params.atEmails = user.emails; + params.atEmails = atUser.emails; if (board.hasMember(uid)) { title = 'act-atUserComment'; watchers = _.union(watchers, [uid]); @@ -268,13 +269,23 @@ if (Meteor.isServer) { }); const integrations = Integrations.find({ - boardId: board._id, - type: 'outgoing-webhooks', + boardId: { $in: [board._id, Integrations.Const.GLOBAL_WEBHOOK_ID] }, + // type: 'outgoing-webhooks', // all types enabled: true, activities: { $in: [description, 'all'] }, }).fetch(); if (integrations.length > 0) { - Meteor.call('outgoingWebhooks', integrations, description, params); + integrations.forEach(integration => { + Meteor.call( + 'outgoingWebhooks', + integration, + description, + params, + () => { + return; + }, + ); + }); } }); } diff --git a/models/integrations.js b/models/integrations.js index 0313c959..ce843680 100644 --- a/models/integrations.js +++ b/models/integrations.js @@ -90,7 +90,11 @@ Integrations.attachSchema( ); Integrations.Const = { GLOBAL_WEBHOOK_ID: '_global', - WEBHOOK_TYPES: ['outgoing-webhooks', 'bidirectional-webhooks'], + ONEWAY: 'outgoing-webhooks', + TWOWAY: 'bidirectional-webhooks', + get WEBHOOK_TYPES() { + return [this.ONEWAY, this.TWOWAY]; + }, }; const permissionHelper = { allow(userId, doc) { diff --git a/models/settings.js b/models/settings.js index 80c2d8e0..4a0359d5 100644 --- a/models/settings.js +++ b/models/settings.js @@ -147,7 +147,6 @@ if (Meteor.isServer) { }:${doc.mailServer.port}/`; } Accounts.emailTemplates.from = doc.mailServer.from; - console.log('Settings saved:', Accounts.emailTemplates); } }); diff --git a/server/notifications/outgoing.js b/server/notifications/outgoing.js index 85d54968..850b3acd 100644 --- a/server/notifications/outgoing.js +++ b/server/notifications/outgoing.js @@ -8,6 +8,19 @@ const postCatchError = Meteor.wrapAsync((url, options, resolve) => { }); }); +const Lock = { + _lock: {}, + has(id) { + return !!this._lock[id]; + }, + set(id) { + this._lock[id] = 1; + }, + unset(id) { + delete this._lock[id]; + }, +}; + const webhooksAtbts = (process.env.WEBHOOKS_ATTRIBUTES && process.env.WEBHOOKS_ATTRIBUTES.split(',')) || [ 'cardId', @@ -20,15 +33,44 @@ const webhooksAtbts = (process.env.WEBHOOKS_ATTRIBUTES && 'commentId', 'swimlaneId', ]; - +const responseFunc = 'reactOnHookResponse'; Meteor.methods({ - outgoingWebhooks(integrations, description, params) { - check(integrations, Array); + [responseFunc](data) { + check(data, Object); + const paramCommentId = data.commentId; + const paramCardId = data.cardId; + const paramBoardId = data.boardId; + const newComment = data.comment; + if (paramCardId && paramBoardId && newComment) { // only process data with the cardid, boardid and comment text, TODO can expand other functions here to react on returned data + const comment = CardComments.findOne({ + _id: paramCommentId, + cardId: paramCardId, + boardId: paramBoardId, + }); + if (comment) { + CardComments.update(comment._id, { + $set: { + text: newComment, + }, + }); + } else { + CardComments.insert({ + text: newComment, + cardId, + boardId, + }); + } + } + }, + outgoingWebhooks(integration, description, params) { + check(integration, Object); check(description, String); check(params, Object); + this.unblock(); // label activity did not work yet, see wekan/models/activities.js const quoteParams = _.clone(params); + const clonedParams = _.clone(params); [ 'card', 'list', @@ -63,23 +105,44 @@ Meteor.methods({ if (params[key]) value[key] = params[key]; }); value.description = description; - + //integrations.forEach(integration => { + const is2way = integration.type === Integrations.Const.TWOWAY; + const token = integration.token || ''; + const headers = { + 'Content-Type': 'application/json', + }; + if (token) headers['X-Wekan-Token'] = token; const options = { - headers: { - // 'Content-Type': 'application/json', - // 'X-Wekan-Activities-Token': 'Random.Id()', - }, - data: value, + headers, + data: is2way ? clonedParams : value, }; + const url = integration.url; + const response = postCatchError(url, options); - integrations.forEach(integration => { - const response = postCatchError(integration.url, options); - - if (response && response.statusCode && response.statusCode === 200) { - return true; // eslint-disable-line consistent-return - } else { - throw new Meteor.Error('error-invalid-webhook-response'); + if (response && response.statusCode && response.statusCode === 200) { + if (is2way) { + const cid = params.commentId; + const tooSoon = Lock.has(cid); // if an activity happens to fast, notification shouldn't fire with the same id + if (!tooSoon) { + let clearNotification = () => {}; + if (cid) { + Lock.set(cid); + const clearNotificationFlagTimeout = 1000; + clearNotification = () => Lock.unset(cid); + Meteor.setTimeout(clearNotification, clearNotificationFlagTimeout); + } + const data = response.data; // only an JSON encoded response will be actioned + if (data) { + Meteor.call(responseFunc, data, () => { + clearNotification(); + }); + } + } } - }); + return response; // eslint-disable-line consistent-return + } else { + throw new Meteor.Error('error-invalid-webhook-response'); + } + //}); }, }); -- cgit v1.2.3-1-g7c22 From b477fc1b1c45e36460f061e0bdcc357188eea372 Mon Sep 17 00:00:00 2001 From: "Sam X. Chen" Date: Thu, 29 Aug 2019 23:49:32 -0400 Subject: Add Feature: enable two-way webhooks - fixing lint error --- server/notifications/outgoing.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/notifications/outgoing.js b/server/notifications/outgoing.js index 850b3acd..6fe90d93 100644 --- a/server/notifications/outgoing.js +++ b/server/notifications/outgoing.js @@ -41,7 +41,8 @@ Meteor.methods({ const paramCardId = data.cardId; const paramBoardId = data.boardId; const newComment = data.comment; - if (paramCardId && paramBoardId && newComment) { // only process data with the cardid, boardid and comment text, TODO can expand other functions here to react on returned data + if (paramCardId && paramBoardId && newComment) { + // only process data with the cardid, boardid and comment text, TODO can expand other functions here to react on returned data const comment = CardComments.findOne({ _id: paramCommentId, cardId: paramCardId, -- cgit v1.2.3-1-g7c22 From 510407467c5245f13fb9508b6e95f6b490dcd36b Mon Sep 17 00:00:00 2001 From: "Sam X. Chen" Date: Fri, 30 Aug 2019 10:36:17 -0400 Subject: Add Feature: enable two-way webhooks - add comments need userid --- server/notifications/outgoing.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server/notifications/outgoing.js b/server/notifications/outgoing.js index 6fe90d93..1dc3d805 100644 --- a/server/notifications/outgoing.js +++ b/server/notifications/outgoing.js @@ -55,11 +55,15 @@ Meteor.methods({ }, }); } else { - CardComments.insert({ - text: newComment, - cardId, - boardId, - }); + const userId = data.userId; + if (userId) { + CardComments.insert({ + text: newComment, + userId, + cardId, + boardId, + }); + } } } }, -- cgit v1.2.3-1-g7c22